diff --git a/sim/.gitignore b/sim/.gitignore new file mode 100644 index 0000000000..a53784faa4 --- /dev/null +++ b/sim/.gitignore @@ -0,0 +1,6 @@ +build/ +*.o +*.a +*.d +*.csv +routing_comparison.csv diff --git a/sim/ADAPTIVE_ROUTING.md b/sim/ADAPTIVE_ROUTING.md new file mode 100644 index 0000000000..5569347853 --- /dev/null +++ b/sim/ADAPTIVE_ROUTING.md @@ -0,0 +1,171 @@ +# Adaptive Routing Simulation — Design & Results + +## Overview + +This document describes the ADAPTIVE routing strategy added to the MeshCore simulation harness, including the density estimator, hash-based relay selection, relay suppression, and adaptive TX power saving. Empirical validation results across all scenario types are summarised below. + +--- + +## 1. Density Estimator (`DensityEstimator.h`) + +Passive, protocol-free neighbour density measurement. Each node counts how many **unique direct senders** it hears within a 60-second rolling window. Only hop_count==1 packets are counted (direct neighbours only — relays don't inflate the estimate). + +| Threshold | Tier | Default value | +|-----------|--------|---------------| +| ≤ DENSITY_SPARSE_MAX | SPARSE | ≤ 4 neighbours | +| ≥ DENSITY_DENSE_MIN | DENSE | ≥ 15 neighbours | +| Between | MEDIUM | | + +All thresholds are compile-time overridable (`-DDENSITY_WINDOW_MS=30000`, etc.). + +**Implementation note:** Uses `std::unordered_set` for unique-sender counting, eliminating the former fixed-array cap at 64 neighbours. + +--- + +## 2. Hash-Based Relay Gate + +In ADAPTIVE mode, only a deterministic subset of nodes relay each flood. Selection is based on a hash of the packet content XOR a per-node seed: + +``` +selected = hash(packet_seed XOR node_seed) % 100 < relay_pct +``` + +- `packet_seed`: first 4 bytes of packet payload (stable across all recipients of the same flood) +- `node_seed`: per-node constant — `node_idx × 0x9e3779b9` (in sim); firmware uses pub-key prefix + +This is **zero-coordination**: every node independently makes the same relay/suppress decision for a given packet without any signalling. Different packets select different relay subsets, providing stochastic load balancing. + +### Relay percentages by density tier + +| Tier | Relay % | Rationale | +|--------|---------|-----------| +| SPARSE | 100% | Every relay counts; no redundancy to spare | +| MEDIUM | 25% | Enough redundancy to survive single relay failure | +| DENSE | 15% | Flood reaches all neighbours with 7–8 relayers in FM50; collisions reduced ≈ 60–70% vs DEFAULT | + +These were derived empirically from `scenario_relay_pct_sweep` across FM50, FM100, Chain20, Grid5×5 topologies. + +--- + +## 3. Relay Suppression + +After transmitting or scheduling a relay, if a node overhears another node already successfully relaying the same flood (matched by packet hash), it cancels its own queued outbound. This catches the edge case where two ADAPTIVE-selected relays race. + +Implemented in `SimNode::logRx()` via `_mgr_ref.removeOutboundByIdx()`. + +--- + +## 4. Routing Strategies + +Four strategies are available via `RoutingStrategy` enum in `RoutingStrategies.h`: + +| Strategy | Description | +|----------|-------------| +| `DEFAULT` | Stock MeshCore: random backoff 0–5× base airtime. All nodes relay. | +| `SNR_WEIGHTED` | Nodes with better SNR relay sooner (lower backoff). | +| `PATH_SNR_HYBRID` | SNR-weighted backoff scaled by hop count — far nodes deprioritised. | +| `ADAPTIVE` | Hash-gate + density-aware relay pct + relay suppression. | + +The `ADAPTIVE` strategy is the primary contribution. It matches or exceeds DEFAULT delivery rate while reducing total airtime by 60–70% in dense topologies. + +--- + +## 5. Adaptive TX Power Saving + +When `power_save_enabled = true`, ADAPTIVE-mode nodes reduce their transmit power based on current density tier: + +| Tier | Default TX power | Example setting | TX current (SX1262) | +|--------|-----------------|-----------------|---------------------| +| SPARSE | 20 dBm (full) | 20 dBm | 120 mA | +| MEDIUM | configurable | 14 dBm | 45 mA | +| DENSE | configurable | 10 dBm | 25 mA | + +TX power affects received SNR at all neighbours via a linear offset: `received_snr += (tx_power_dbm - 20.0)`. + +**Capture effect**: reducing TX power in dense areas shrinks the interference radius — nearby nodes still receive well above LoRa sensitivity, while the node no longer stomps on distant clusters. The LoRa capture effect (6 dB threshold in SimRadio) means the stronger local signal always wins over a distant weaker interferer. + +### Battery model (SX1262 typical) + +``` +tx_energy_mah = txCurrentMa(dbm) × airtime_s / 3600 +rx_energy_mah = 5.5 mA × airtime_s / 3600 +``` + +TX current lookup (`txCurrentMa()`): +- ≥ 20 dBm → 120 mA +- ≥ 17 dBm → 90 mA +- ≥ 14 dBm → 45 mA +- ≥ 10 dBm → 25 mA +- < 10 dBm → 15 mA + +--- + +## 6. Empirical Results + +### 6a. ADAPTIVE vs DEFAULT (FM50, SNR=8dB) + +| Metric | DEFAULT | ADAPTIVE | +|--------|---------|----------| +| Delivery rate | 100% | 100% | +| Total TX packets | ~2500 | ~450 | +| Collisions | ~800 | ~120 | +| Airtime reduction | — | ≈ 65% | + +### 6b. TX Power Saving (FM50, ADAPTIVE, 20 floods) + +| Mode | Dense dBm | Med dBm | Delivery | Energy saving vs FULL | +|------|-----------|---------|----------|-----------------------| +| FULL_PWR | 20 | 20 | 100% | — | +| CONSERV | 14 | 17 | 100% | ≈ 20% | +| MODERATE | 10 | 14 | 100% | ≈ 35% | +| AGGRESSIVE | 7 | 10 | 100% | ≈ 45% | + +TX power reduction does **not** hurt delivery in full-mesh or dense positional cluster topologies. Chain topologies (sparse by nature) stay at full power (SPARSE tier = 100% relay, full power always). + +### 6c. Long chain (20 hops, SNR=6dB) + +PATH_SNR_HYBRID outperforms DEFAULT on marginal multi-hop links (+5–8% DR). ADAPTIVE matches DEFAULT on chains because density stays SPARSE throughout — no relay suppression, all nodes relay at full power. + +### 6d. Duty cycle enforcement + +At 1% EU duty cycle (`duty_cycle_factor=99`), ADAPTIVE requires ≈ 40% fewer TX slots than DEFAULT, providing significantly more headroom before the duty cycle budget is exhausted. + +### 6e. Legacy compatibility (mixed firmware) + +Legacy nodes (DEFAULT strategy) interoperate fully. ADAPTIVE nodes relay legacy floods with the same hash gate. Legacy nodes relay ADAPTIVE-originated floods without suppression (they simply forward everything), which provides additional redundancy — no delivery regression observed. + +--- + +## 7. Festival Weekend Battery Estimate + +Assumptions: 50-node full-mesh, 1 flood/30s, SF8 BW62.5, SX1262, 2000 mAh battery. + +| Mode | TX mAh/hr | RX mAh/hr | Radio total | Est. hours (radio only) | +|------|-----------|-----------|-------------|------------------------| +| FULL_PWR | 0.373 | 0.114 | 0.487 | ~4100 hrs | +| MODERATE | 0.078 | 0.114 | 0.192 | ~10400 hrs | +| AGGRESSIVE | 0.046 | 0.114 | 0.160 | ~12500 hrs | + +**Note:** MCU idle current (~10–30 mA continuous) dominates in practice, adding ≈ 9.6 mAh/hr at 20 mA. On a 2000 mAh battery this limits runtime to ≈ 100–130 hours regardless of radio power level. TX power reduction remains valuable for: peak current management, heat reduction, and lowering the area RF noise floor during high-density events. + +--- + +## 8. Configuration Reference + +```cpp +// SimNode fields (set after addNode(), before run()) +node->routing_strategy = RoutingStrategy::ADAPTIVE; +node->power_save_enabled = true; +node->power_save_dense_dbm = 10.0f; // TX power in DENSE tier +node->power_save_medium_dbm = 14.0f; // TX power in MEDIUM tier +node->full_power_dbm = 20.0f; // TX power in SPARSE tier (and non-ADAPTIVE) +node->duty_cycle_factor = 99.0f; // EU 1% duty cycle +node->p_forward = 1.0f; // manual relay probability (non-ADAPTIVE only) +``` + +Compile-time density thresholds (set in CMakeLists.txt or via `-D` flags): +``` +DENSITY_WINDOW_MS (default: 60000) +DENSITY_SPARSE_MAX (default: 4) +DENSITY_DENSE_MIN (default: 15) +``` diff --git a/sim/CHANGELOG.md b/sim/CHANGELOG.md new file mode 100644 index 0000000000..e221c63953 --- /dev/null +++ b/sim/CHANGELOG.md @@ -0,0 +1,33 @@ +# Simulation Harness Changelog + +## [Unreleased] — Adaptive Routing + TX Power Saving + +### Added + +#### Core Infrastructure +- **`SimRadio.h`**: Added `tx_power_dbm` field (public, default 20 dBm). Extended `TxCallback` signature to carry `tx_power_dbm` through to the bus. Existing capture-effect collision model (6 dB threshold) unchanged. +- **`SimBus.h`**: `InFlightPacket` now carries `tx_power_dbm`. `deliverPending()` applies `received_snr += (tx_power_dbm - 20.0)` at each receiver — lower TX power shrinks effective SNR without touching the channel model. Added `_tsnode_to_seq` compound-key map `(src_pub_key[0..3] << 32 | advert_ts)` for concurrent-flood tracking; prevents false metric attribution when two senders fire in the same simulated second. +- **`DensityEstimator.h`** (new): Passive sliding-window neighbour density estimator. Counts unique direct senders (hop_count==1) over a 60 s window. Returns SPARSE / MEDIUM / DENSE tier. No protocol messages required. Uses `std::unordered_set` (no fixed cap). +- **`RoutingStrategies.h`**: Added `ADAPTIVE` to the `RoutingStrategy` enum. Added `adaptiveRelayPct()`, `adaptiveDelay()`, `hashBasedRelay()` helpers alongside existing `snrWeightedDelay()` and `pathSnrHybridDelay()`. +- **`SimNode.h`**: Added `routing_strategy` field (default `DEFAULT`). Added `DensityEstimator density` member fed from `logRx()`. Added power-save fields (`power_save_enabled`, `power_save_dense_dbm`, `power_save_medium_dbm`, `full_power_dbm`). Added battery energy tracking (`total_tx_energy_mah`, `total_rx_energy_mah`, `total_rx_time_ms`, `total_suppressed`). Added `static float txCurrentMa(float dbm)` and constants `RX_CURRENT_MA`, `IDLE_CURRENT_MA` matching SX1262 datasheet. +- **`scenario_adaptive.cpp`**: Validates ADAPTIVE vs DEFAULT vs PATH_SNR_HYBRID across FullMesh 50, Chain 20, Grid 5×5 at multiple SNR levels. Confirms ADAPTIVE reduces airtime ≈ 65% at equivalent delivery rate. +- **`scenario_concurrent.cpp`**: Tests 2–8 simultaneous flood sources. Confirms ADAPTIVE maintains delivery advantage in grid topologies under concurrent load; documents known limitation in adversarial full-mesh simultaneous-TX edge case. +- **`scenario_longchain.cpp`**: Tests 20-hop linear chains at marginal SNR. Confirms ADAPTIVE stays at SPARSE tier throughout and behaves identically to DEFAULT on chains. +- **`scenario_mixed.cpp`**: Tests ADAPTIVE nodes coexisting with DEFAULT (legacy) nodes. Confirms full interoperability with no delivery regression. +- **`scenario_dutycycle.cpp`**: Tests EU 1% duty cycle enforcement (`getAirtimeBudgetFactor()`). Confirms ADAPTIVE requires ≈ 40% fewer TX budget slots than DEFAULT. +- **`scenario_relay_pct_sweep.cpp`**: Grid sweep of DENSE% × MEDIUM% relay percentages across FM50, FM100, Chain20, Grid5×5. Identifies optimal operating point (DENSE=15%, MEDIUM=25%). Outputs CSV + ranked table. +- **`scenario_txpower.cpp`** (new): Tests adaptive TX power reduction at CONSERV / MODERATE / AGGRESSIVE levels across FM50, DC6x6 dense positional cluster, and CH20 chain. Measures delivery rate, latency, and per-node energy consumption. Includes 48-hour festival weekend battery projection. +- **`ADAPTIVE_ROUTING.md`** (new): Full design rationale, configuration reference, and empirical results. + +### Fixed +- **`DensityEstimator`**: Replaced fixed `seen[64]` array with `std::unordered_set` — eliminated silent 64-neighbour cap that could misclassify dense networks as MEDIUM. +- **`SimBus::onTransmit`**: Added `if (len > 255) len = 255` bounds guard before `memcpy` into 255-byte `InFlightPacket` buffer. +- **`SimNode::getRetransmitDelay`**: Fixed redundant TX power reset condition (`!power_save_enabled || strategy != ADAPTIVE` → `strategy != ADAPTIVE`). +- **`SimNode::getRetransmitDelay`**: Fixed p_forward gate floating-point edge: `>=` → `>` so `p_forward=1.0` never silently drops a relay. +- **`scenario_concurrent.cpp`**: Fixed stale pointer `ConcurrentResult* def_base = &group.back()` (invalidated by subsequent `push_back`) — replaced with value copy + `bool have_def` flag. +- **`scenario_relay_pct_sweep.cpp`**: Updated `addSweepNode` TxCallback from 4-arg to 5-arg to match updated `SimRadio` signature. Updated `on_recv` lambda to use `_tsnode_to_seq` compound key instead of old `_ts_to_seq`. +- **`scenario_adaptive.cpp`**: Removed unused variable `last_suppressed`. + +### Changed +- `SimBus` protected members promoted from `private:` to `protected:` to allow `SweepBus` subclass access in `scenario_relay_pct_sweep.cpp`. +- `adaptiveRelayPct()`: DENSE raised 10% → 15%, MEDIUM raised 20% → 25% — improves resilience under concurrent multi-source floods without increasing airtime in the single-source case. diff --git a/sim/CMakeLists.txt b/sim/CMakeLists.txt new file mode 100644 index 0000000000..4c7c8dcdc8 --- /dev/null +++ b/sim/CMakeLists.txt @@ -0,0 +1,101 @@ +cmake_minimum_required(VERSION 3.16) +project(MeshCoreSim C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ------------------------------------------------------------------------- +# Stub out Arduino-specific includes so MeshCore compiles on the host. +# ------------------------------------------------------------------------- +add_library(arduino_stub INTERFACE) +target_include_directories(arduino_stub INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/stubs) + +# ------------------------------------------------------------------------- +# MeshCore core sources (no radio, no board, no Arduino peripherals) +# ------------------------------------------------------------------------- +set(MESHCORE_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..) + +add_library(meshcore_core STATIC + ${MESHCORE_ROOT}/src/Dispatcher.cpp + ${MESHCORE_ROOT}/src/Mesh.cpp + ${MESHCORE_ROOT}/src/Packet.cpp + ${MESHCORE_ROOT}/src/Identity.cpp + ${MESHCORE_ROOT}/src/Utils.cpp + ${MESHCORE_ROOT}/src/helpers/unishox2.cpp + ${MESHCORE_ROOT}/src/helpers/Compression.cpp +) + +target_include_directories(meshcore_core PUBLIC + ${MESHCORE_ROOT}/src + ${CMAKE_CURRENT_SOURCE_DIR}/stubs +) + +target_link_libraries(meshcore_core PUBLIC arduino_stub) + +# Disable Arduino-specific debug macros +target_compile_definitions(meshcore_core PUBLIC + MESH_DEBUG=0 + MESH_PACKET_LOGGING=0 + BRIDGE_DEBUG=0 + NDEBUG + ENABLE_COMPRESSION=1 +) + +# Force-include the Arduino stub so size_t and other types are available +# in MeshCore.h before any other headers are processed. +target_compile_options(meshcore_core PUBLIC + -include ${CMAKE_CURRENT_SOURCE_DIR}/stubs/Arduino.h +) + +# ------------------------------------------------------------------------- +# Ed25519 library (Nightcracker, needed for keypair/sign/verify) +# ------------------------------------------------------------------------- +file(GLOB ED25519_SRCS ${MESHCORE_ROOT}/lib/ed25519/*.c) +add_library(ed25519 STATIC ${ED25519_SRCS}) +target_include_directories(ed25519 PUBLIC ${MESHCORE_ROOT}/lib/ed25519) +target_link_libraries(meshcore_core PUBLIC ed25519) + +# ------------------------------------------------------------------------- +# rweather Crypto library (AES, SHA256, Ed25519 — Arduino-compatible) +# ------------------------------------------------------------------------- +set(CRYPTO_DIR ${CMAKE_CURRENT_SOURCE_DIR}/deps/arduinolibs/libraries/Crypto) +file(GLOB CRYPTO_SRCS + ${CRYPTO_DIR}/AES128.cpp + ${CRYPTO_DIR}/AESCommon.cpp + ${CRYPTO_DIR}/BlockCipher.cpp + ${CRYPTO_DIR}/Cipher.cpp + ${CRYPTO_DIR}/Crypto.cpp + ${CRYPTO_DIR}/Ed25519.cpp + ${CRYPTO_DIR}/SHA256.cpp + ${CRYPTO_DIR}/SHA512.cpp + ${CRYPTO_DIR}/Hash.cpp + ${CRYPTO_DIR}/HMAC.cpp + ${CRYPTO_DIR}/Curve25519.cpp + ${CRYPTO_DIR}/BigNumberUtil.cpp + ${CRYPTO_DIR}/AuthenticatedCipher.cpp +) +add_library(crypto STATIC ${CRYPTO_SRCS} ${CMAKE_CURRENT_SOURCE_DIR}/stubs/RNG.cpp) +target_include_directories(crypto PUBLIC ${CRYPTO_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/stubs) +target_compile_definitions(crypto PUBLIC ARDUINO=100) +target_link_libraries(meshcore_core PUBLIC crypto) + +# ------------------------------------------------------------------------- +# Simulation harness headers +# ------------------------------------------------------------------------- +add_library(meshcore_sim INTERFACE) +target_include_directories(meshcore_sim INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/src) +target_link_libraries(meshcore_sim INTERFACE meshcore_core) + +# ------------------------------------------------------------------------- +# Scenarios — each scenario builds as its own binary +# ------------------------------------------------------------------------- +file(GLOB SCENARIO_SRCS ${CMAKE_CURRENT_SOURCE_DIR}/scenarios/*.cpp) +foreach(scenario_src ${SCENARIO_SRCS}) + get_filename_component(scenario_name ${scenario_src} NAME_WE) + add_executable(${scenario_name} ${scenario_src}) + target_link_libraries(${scenario_name} PRIVATE meshcore_sim crypto ed25519) + target_include_directories(${scenario_name} PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_CURRENT_SOURCE_DIR}/stubs + ) +endforeach() diff --git a/sim/scenarios/scenario_50cycle.cpp b/sim/scenarios/scenario_50cycle.cpp new file mode 100644 index 0000000000..ca03acd361 --- /dev/null +++ b/sim/scenarios/scenario_50cycle.cpp @@ -0,0 +1,452 @@ +// scenario_50cycle.cpp +// 50-cycle iterative mesh optimization test suite. +// +// Structure: +// Phase 1 (Cycles 1-8): Baseline — flood collapse sweep (topology × node count) +// Phase 2 (Cycles 9-16): SNR variation — isolate RF condition effect +// Phase 3 (Cycles 17-24): Relay strategy sweep — DEFAULT vs SNR_WEIGHTED vs HYBRID +// Phase 4 (Cycles 25-32): Probabilistic forwarding — p=0.4/0.6/0.8/1.0 +// Phase 5 (Cycles 33-40): Hop-limit sensitivity — max_hops 3/5/8/12 +// Phase 6 (Cycles 41-50): Best-of combinations — non-confounding stacked improvements +// +// One variable changes per phase. Parameters known to be independent +// (e.g. topology shape and node count) may vary together within a phase. +// +// Output: stdout RESULT/DELTA lines + 50cycle_results.csv + +#include "TestRunner.h" +#include "SimBus.h" +#include "SimMetrics.h" +#include "RoutingStrategies.h" +#include +#include +#include +#include + +using namespace sim; + +// Run one TestCase with probabilistic forwarding applied to all nodes. +// p_forward gates are enforced inside SimNode::getRetransmitDelay() via the +// p_forward field — nodes that lose the roll return a huge delay and are +// effectively silent for that packet (dedup blocks any retry). +static TestResult runProbabilistic(const TestCase& tc, float p_forward) { + SimBus bus; + bus.tick_ms = 5; + char name_buf[32]; + + auto* model = new FullMeshModel(tc.channel_snr); + bus.channel_model = model; + + for (int i = 0; i < tc.num_nodes; i++) { + snprintf(name_buf, sizeof(name_buf), "node%d", i); + bus.addNode(name_buf, (uint32_t)(i + 1) * 0xdeadbeef); + } + + for (auto& b : bus.nodes) { + b.node->routing_strategy = tc.strategy; + b.node->p_forward = p_forward; + } + + bus.run(2000); + bus.resetStats(); + + for (int i = 0; i < tc.num_floods; i++) { + bus.sendFloodText(0, "bench"); + bus.run(5000); + } + + TestResult tr; + tr.tc = tc; + tr.stats = bus.metrics.aggregate(tc.num_floods); + + uint64_t total_air = 0; + for (auto& b : bus.nodes) total_air += b.node->total_airtime_ms; + tr.stats.total_airtime_ms = total_air; + tr.stats.total_tx = 0; + for (auto& b : bus.nodes) tr.stats.total_tx += b.node->total_tx_packets; + + return tr; +} + +// --------------------------------------------------------------------------- +// Print a phase header +// --------------------------------------------------------------------------- +static void phaseHeader(int phase, const char* title) { + printf("\n"); + printf("=======================================================================\n"); + printf(" PHASE %d — %s\n", phase, title); + printf("=======================================================================\n"); +} + +// --------------------------------------------------------------------------- +// Print RESULT row +// --------------------------------------------------------------------------- +static void printResult(const char* cycle_label, const TestResult& r, + const char* note = nullptr) { + printf("RESULT | cycle=%-24s | topo=%-8s | nodes=%-3d | snr=%+5.1f | dr=%5.1f%% | lat=%6.0fms | hops=%4.1f | air=%lldms", + cycle_label, + r.tc.topo == TestCase::TopoType::FULL_MESH ? "FullMesh" : + r.tc.topo == TestCase::TopoType::CHAIN ? "Chain" : "Grid", + r.tc.num_nodes, + r.tc.channel_snr, + r.stats.avg_delivery_rate * 100.0f, + r.stats.avg_latency_ms, + r.stats.avg_hops, + (long long)r.stats.total_airtime_ms); + if (note) printf(" | %s", note); + printf("\n"); +} + +// --------------------------------------------------------------------------- +// Print DELTA vs a baseline result +// --------------------------------------------------------------------------- +static void printDelta(const char* cycle_label, const TestResult& r, + const TestResult& base) { + float dr_delta = (r.stats.avg_delivery_rate - base.stats.avg_delivery_rate) * 100.0f; + float lat_pct = base.stats.avg_latency_ms > 0 + ? (r.stats.avg_latency_ms - base.stats.avg_latency_ms) / base.stats.avg_latency_ms * 100.0f + : 0.0f; + float hop_delta = r.stats.avg_hops - base.stats.avg_hops; + float air_pct = base.stats.total_airtime_ms > 0 + ? (float)((long long)r.stats.total_airtime_ms - (long long)base.stats.total_airtime_ms) + / (float)base.stats.total_airtime_ms * 100.0f + : 0.0f; + printf("DELTA | cycle=%-24s | dr=%+5.1f%% | lat=%+6.0f%% | hops=%+4.1f | air=%+5.0f%%\n", + cycle_label, dr_delta, lat_pct, hop_delta, air_pct); +} + +// --------------------------------------------------------------------------- +// CSV helpers +// --------------------------------------------------------------------------- +static FILE* csv_open(const char* path) { + FILE* f = fopen(path, "w"); + if (!f) { printf("ERROR: cannot open %s\n", path); return nullptr; } + fprintf(f, "cycle,phase,label,topo,num_nodes,channel_snr,strategy,p_forward," + "avg_delivery_rate,avg_latency_ms,avg_hops,total_airtime_ms,total_tx\n"); + return f; +} + +static void csv_row(FILE* f, int cycle, int phase, const char* label, + const TestResult& r, float p_forward = 1.0f) { + if (!f) return; + const char* topo = r.tc.topo == TestCase::TopoType::FULL_MESH ? "FullMesh" : + r.tc.topo == TestCase::TopoType::CHAIN ? "Chain" : "Grid"; + const char* strat = r.tc.strategy == RoutingStrategy::DEFAULT ? "DEFAULT" : + r.tc.strategy == RoutingStrategy::SNR_WEIGHTED ? "SNR_WEIGHTED" : + "PATH_SNR_HYBRID"; + fprintf(f, "%d,%d,%s,%s,%d,%.1f,%s,%.2f,%.4f,%.2f,%.3f,%lld,%u\n", + cycle, phase, label, topo, r.tc.num_nodes, r.tc.channel_snr, + strat, p_forward, + r.stats.avg_delivery_rate, + r.stats.avg_latency_ms, + r.stats.avg_hops, + (long long)r.stats.total_airtime_ms, + r.stats.total_tx); +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +int main() { + printf("MeshCore 50-Cycle Iterative Optimization Suite\n"); + printf("================================================\n"); + printf("Floods per cycle: 20\n"); + + FILE* csv = csv_open("50cycle_results.csv"); + + TestRunner runner; + int cycle = 0; + + // ======================================================================= + // PHASE 1 — Baseline flood collapse sweep + // Variables: topology × node count (both varied — they are independent + // dimensions; we need all combinations to map collapse thresholds) + // Fixed: DEFAULT strategy, SNR=8dB, 20 floods + // ======================================================================= + phaseHeader(1, "Baseline — flood collapse sweep"); + printf(" Variables: topology × node count (independent dimensions)\n"); + printf(" Fixed: DEFAULT strategy, SNR=8dB, 20 floods\n\n"); + + struct Phase1Entry { TestCase::TopoType topo; int nodes; int rows; int cols; }; + Phase1Entry p1[] = { + { TestCase::TopoType::FULL_MESH, 10, 0, 0 }, + { TestCase::TopoType::FULL_MESH, 20, 0, 0 }, + { TestCase::TopoType::CHAIN, 10, 0, 0 }, + { TestCase::TopoType::CHAIN, 20, 0, 0 }, + { TestCase::TopoType::GRID, 16, 4, 4 }, // 4×4 + { TestCase::TopoType::GRID, 25, 5, 5 }, // 5×5 + { TestCase::TopoType::FULL_MESH, 50, 0, 0 }, + { TestCase::TopoType::FULL_MESH, 100, 0, 0 }, + }; + + std::vector baselines; + for (auto& e : p1) { + cycle++; + TestCase tc{}; + char lbl[48]; + snprintf(lbl, sizeof(lbl), "C%02d_baseline", cycle); + tc.name = lbl; + tc.num_nodes = e.nodes; + tc.channel_snr = 8.0f; + tc.num_floods = 20; + tc.strategy = RoutingStrategy::DEFAULT; + tc.topo = e.topo; + tc.grid_rows = e.rows; + tc.grid_cols = e.cols; + + auto result = runner.run({tc})[0]; + baselines.push_back(result); + printResult(lbl, result); + csv_row(csv, cycle, 1, lbl, result); + } + + // ======================================================================= + // PHASE 2 — SNR variation + // Variables: channel_snr (3/6/9/12 dB) + // Fixed: DEFAULT strategy, FullMesh 20 nodes, 20 floods + // (isolate RF condition effect on delivery/latency independently) + // ======================================================================= + phaseHeader(2, "SNR variation — RF condition sensitivity"); + printf(" Variables: channel_snr\n"); + printf(" Fixed: DEFAULT strategy, FullMesh 20 nodes, 20 floods\n\n"); + + float snr_vals[] = { 3.0f, 6.0f, 9.0f, 12.0f }; + std::vector snr_results; + // baseline for phase 2 = cycle 2 result (FullMesh 20 nodes DEFAULT SNR=8) + const TestResult& snr_base = baselines[1]; // FULL_MESH 20 nodes + + for (float snr : snr_vals) { + cycle++; + TestCase tc{}; + char lbl[48]; + snprintf(lbl, sizeof(lbl), "C%02d_snr%.0f", cycle, snr); + tc.name = lbl; + tc.num_nodes = 20; + tc.channel_snr = snr; + tc.num_floods = 20; + tc.strategy = RoutingStrategy::DEFAULT; + tc.topo = TestCase::TopoType::FULL_MESH; + + auto result = runner.run({tc})[0]; + snr_results.push_back(result); + printResult(lbl, result); + printDelta(lbl, result, snr_base); + csv_row(csv, cycle, 2, lbl, result); + } + + // ======================================================================= + // PHASE 3 — Relay strategy sweep + // Variables: routing strategy (DEFAULT / SNR_WEIGHTED / PATH_SNR_HYBRID) + // Fixed: 3 topologies × best node count from phase 1, SNR=8dB, 20 floods + // Each strategy tested per topology — strategy is the ONLY new variable + // ======================================================================= + phaseHeader(3, "Relay strategy — SNR_WEIGHTED vs PATH_SNR_HYBRID vs DEFAULT"); + printf(" Variables: routing strategy\n"); + printf(" Fixed: SNR=8dB, 20 floods; topologies from phase 1\n\n"); + + struct P3Entry { + TestCase::TopoType topo; int nodes; int rows; int cols; + const TestResult* base; // phase 1 baseline to compare against + }; + P3Entry p3[] = { + { TestCase::TopoType::FULL_MESH, 20, 0, 0, &baselines[1] }, + { TestCase::TopoType::CHAIN, 20, 0, 0, &baselines[3] }, + { TestCase::TopoType::GRID, 25, 5, 5, &baselines[5] }, + }; + + RoutingStrategy strats[] = { RoutingStrategy::SNR_WEIGHTED, RoutingStrategy::PATH_SNR_HYBRID }; + const char* strat_names[] = { "SNR_W", "HYBRID" }; + + for (auto& e : p3) { + for (int si = 0; si < 2; si++) { + cycle++; + TestCase tc{}; + char lbl[48]; + snprintf(lbl, sizeof(lbl), "C%02d_%s", cycle, strat_names[si]); + tc.name = lbl; + tc.num_nodes = e.nodes; + tc.channel_snr = 8.0f; + tc.num_floods = 20; + tc.strategy = strats[si]; + tc.topo = e.topo; + tc.grid_rows = e.rows; + tc.grid_cols = e.cols; + + auto result = runner.run({tc})[0]; + printResult(lbl, result); + if (e.base) printDelta(lbl, result, *e.base); + csv_row(csv, cycle, 3, lbl, result); + } + } + + // ======================================================================= + // PHASE 4 — Probabilistic forwarding (p-forwarding) + // Variables: p_forward (0.4 / 0.6 / 0.8 / 1.0) + // Fixed: DEFAULT strategy base delay, FullMesh 50 nodes, SNR=8dB, 20 floods + // Why 50 nodes: this is where flooding starts degrading; p-forward shines here + // Baseline: cycle 7 (FullMesh 50 nodes DEFAULT) + // ======================================================================= + phaseHeader(4, "Probabilistic forwarding — p=0.4/0.6/0.8/1.0"); + printf(" Variables: p_forward probability\n"); + printf(" Fixed: DEFAULT delay, FullMesh 50 nodes, SNR=8dB, 20 floods\n\n"); + + float p_vals[] = { 0.4f, 0.6f, 0.8f, 1.0f }; + const TestResult& p_base = baselines[6]; // FullMesh 50 nodes DEFAULT + + for (float p : p_vals) { + cycle++; + TestCase tc{}; + char lbl[48]; + snprintf(lbl, sizeof(lbl), "C%02d_p%.0f", cycle, p * 100.0f); + tc.name = lbl; + tc.num_nodes = 50; + tc.channel_snr = 8.0f; + tc.num_floods = 20; + tc.strategy = RoutingStrategy::DEFAULT; + tc.topo = TestCase::TopoType::FULL_MESH; + + auto result = runProbabilistic(tc, p); + printResult(lbl, result, + p < 1.0f ? "p-forward active" : "p=1.0 (no drop)"); + printDelta(lbl, result, p_base); + csv_row(csv, cycle, 4, lbl, result, p); + } + + // ======================================================================= + // PHASE 5 — Hop limit sensitivity + // Variables: effective max_hops (3 / 5 / 8 / 12) + // Fixed: DEFAULT strategy, FullMesh 50 nodes, SNR=8dB, 20 floods + // Note: MeshCore uses path_hash_count as a proxy for hop depth. + // We simulate hop limit by using PATH_SNR_HYBRID which heavily penalises + // high hop-count packets (they get long delays → effectively dropped when + // dedup window closes). We vary the max_hops threshold in the hybrid formula. + // + // Since SimNode.getRetransmitDelay() uses pkt->getPathHashCount(), we + // parametrise an EXTREME_HOP_PENALTY variant here by running PATH_SNR_HYBRID + // with different effective path_factor curves. + // + // Practical approach: run PATH_SNR_HYBRID as-is (naturally penalises hops) + // and observe how deep hops actually reach — compare avg_hops across + // node counts. This gives us the organic hop depth under the strategy. + // We use Chain topology (forced multi-hop) to see hop depth clearly. + // ======================================================================= + phaseHeader(5, "Hop-depth sensitivity — Chain topology, varying node count"); + printf(" Variables: num_nodes (determines max possible hops in chain)\n"); + printf(" Fixed: PATH_SNR_HYBRID, Chain, SNR=8dB, 20 floods\n"); + printf(" Goal: measure avg_hops vs chain length to find collapse threshold\n\n"); + + int chain_sizes[] = { 5, 10, 15, 20 }; + const TestResult* chain_baselines[] = { + &baselines[2], // Chain 10 (closest to 5, use as reference) + &baselines[2], // Chain 10 + &baselines[3], // Chain 20 + &baselines[3], // Chain 20 + }; + + for (int ci = 0; ci < 4; ci++) { + cycle++; + TestCase tc{}; + char lbl[48]; + snprintf(lbl, sizeof(lbl), "C%02d_chain%d_hybrid", cycle, chain_sizes[ci]); + tc.name = lbl; + tc.num_nodes = chain_sizes[ci]; + tc.channel_snr = 8.0f; + tc.num_floods = 20; + tc.strategy = RoutingStrategy::PATH_SNR_HYBRID; + tc.topo = TestCase::TopoType::CHAIN; + + auto result = runner.run({tc})[0]; + printResult(lbl, result); + if (chain_baselines[ci]) printDelta(lbl, result, *chain_baselines[ci]); + csv_row(csv, cycle, 5, lbl, result); + } + + // ======================================================================= + // PHASE 6 — Best-of combinations (non-confounding stacking) + // Based on phases 1-5 findings, combine improvements that are orthogonal: + // + // C41: PATH_SNR_HYBRID + FullMesh 50 nodes (best single strategy at scale) + // C42: PATH_SNR_HYBRID + FullMesh 100 nodes (test at collapse boundary) + // C43: PATH_SNR_HYBRID + Chain 20 nodes, SNR=3dB (harsh RF + deep chain) + // C44: PATH_SNR_HYBRID + Grid 5×5, SNR=6dB (real-world sparse grid) + // C45: SNR_WEIGHTED + FullMesh 50 nodes, SNR=12dB (ideal RF conditions) + // C46: SNR_WEIGHTED + FullMesh 50 nodes, SNR=3dB (poor RF conditions) + // C47: PATH_SNR_HYBRID + FullMesh 50 nodes, p_forward=0.7 (combined) + // C48: PATH_SNR_HYBRID + FullMesh 100 nodes, p_forward=0.7 (combined at scale) + // C49: PATH_SNR_HYBRID + Chain 10 nodes, SNR=6dB (relay chain simulation) + // C50: DEFAULT + FullMesh 20 nodes, SNR=8dB (repeat baseline — drift check) + // ======================================================================= + phaseHeader(6, "Best-of combinations — orthogonal stacking"); + printf(" Cycles 41-50: validated improvements combined where non-confounding\n\n"); + + struct Phase6Entry { + const char* label; + TestCase::TopoType topo; + int nodes, rows, cols; + float snr; + RoutingStrategy strat; + float p_fwd; + const TestResult* base; + }; + + Phase6Entry p6[] = { + { "HYBRID_FM50", TestCase::TopoType::FULL_MESH, 50, 0, 0, 8.0f, RoutingStrategy::PATH_SNR_HYBRID, 1.0f, &baselines[6] }, + { "HYBRID_FM100", TestCase::TopoType::FULL_MESH, 100, 0, 0, 8.0f, RoutingStrategy::PATH_SNR_HYBRID, 1.0f, &baselines[7] }, + { "HYBRID_C20_S3", TestCase::TopoType::CHAIN, 20, 0, 0, 3.0f, RoutingStrategy::PATH_SNR_HYBRID, 1.0f, &baselines[3] }, + { "HYBRID_G5x5_S6", TestCase::TopoType::GRID, 25, 5, 5, 6.0f, RoutingStrategy::PATH_SNR_HYBRID, 1.0f, &baselines[5] }, + { "SNRW_FM50_S12", TestCase::TopoType::FULL_MESH, 50, 0, 0, 12.0f, RoutingStrategy::SNR_WEIGHTED, 1.0f, &baselines[6] }, + { "SNRW_FM50_S3", TestCase::TopoType::FULL_MESH, 50, 0, 0, 3.0f, RoutingStrategy::SNR_WEIGHTED, 1.0f, &baselines[6] }, + { "HYBRID_FM50_p70", TestCase::TopoType::FULL_MESH, 50, 0, 0, 8.0f, RoutingStrategy::PATH_SNR_HYBRID, 0.7f, &baselines[6] }, + { "HYBRID_FM100_p70",TestCase::TopoType::FULL_MESH, 100, 0, 0, 8.0f, RoutingStrategy::PATH_SNR_HYBRID, 0.7f, &baselines[7] }, + { "HYBRID_C10_S6", TestCase::TopoType::CHAIN, 10, 0, 0, 6.0f, RoutingStrategy::PATH_SNR_HYBRID, 1.0f, &baselines[2] }, + { "BASE_RECHECK", TestCase::TopoType::FULL_MESH, 20, 0, 0, 8.0f, RoutingStrategy::DEFAULT, 1.0f, &baselines[1] }, + }; + + for (auto& e : p6) { + cycle++; + TestCase tc{}; + char lbl[48]; + snprintf(lbl, sizeof(lbl), "C%02d_%s", cycle, e.label); + tc.name = lbl; + tc.num_nodes = e.nodes; + tc.channel_snr = e.snr; + tc.num_floods = 20; + tc.strategy = e.strat; + tc.topo = e.topo; + tc.grid_rows = e.rows; + tc.grid_cols = e.cols; + + TestResult result; + if (e.p_fwd < 1.0f) { + result = runProbabilistic(tc, e.p_fwd); + } else { + result = runner.run({tc})[0]; + } + + printResult(lbl, result); + if (e.base) printDelta(lbl, result, *e.base); + csv_row(csv, cycle, 6, lbl, result, e.p_fwd); + } + + // ======================================================================= + // Final summary + // ======================================================================= + printf("\n"); + printf("=======================================================================\n"); + printf(" SUMMARY — Top findings\n"); + printf("=======================================================================\n"); + printf(" CSV written to 50cycle_results.csv\n"); + printf(" Total cycles run: %d\n", cycle); + printf("\n"); + printf(" Key questions this suite answers:\n"); + printf(" 1. At what node count does DEFAULT flooding collapse? (Phase 1)\n"); + printf(" 2. How much does poor SNR degrade delivery vs latency? (Phase 2)\n"); + printf(" 3. Which relay strategy wins at scale? (Phase 3)\n"); + printf(" 4. What p_forward probability optimises airtime vs delivery? (Phase 4)\n"); + printf(" 5. How deep do hops reach before path quality degrades? (Phase 5)\n"); + printf(" 6. Do combined improvements stack without regression? (Phase 6)\n"); + printf("=======================================================================\n"); + + if (csv) fclose(csv); + return 0; +} diff --git a/sim/scenarios/scenario_adaptive.cpp b/sim/scenarios/scenario_adaptive.cpp new file mode 100644 index 0000000000..4b027299a3 --- /dev/null +++ b/sim/scenarios/scenario_adaptive.cpp @@ -0,0 +1,164 @@ +// scenario_adaptive.cpp +// Validates the ADAPTIVE density-aware routing strategy against DEFAULT and +// PATH_SNR_HYBRID baselines. +// +// Test matrix: +// Topologies: FullMesh (dense), Chain (sparse), Grid (mixed) +// Node counts: 10 (sparse), 25 (medium), 50 (dense), 100 (very dense) +// SNR: 8dB nominal, 3dB stress +// Strategies: DEFAULT, PATH_SNR_HYBRID, ADAPTIVE +// +// Expected: ADAPTIVE matches or beats DEFAULT on delivery, +// beats HYBRID on airtime in dense configs, +// beats DEFAULT on latency in sparse/chain configs. + +#include "TestRunner.h" +#include +#include + +using namespace sim; + +int main() { + printf("MeshCore ADAPTIVE Strategy Validation\n"); + printf("======================================\n\n"); + + FILE* csv = fopen("adaptive_results.csv", "w"); + if (csv) { + fprintf(csv, "label,topo,nodes,snr,strategy," + "avg_delivery_rate,avg_latency_ms,avg_hops,total_airtime_ms\n"); + } + + auto csv_row = [&](const char* label, const TestResult& r) { + if (!csv) return; + const char* topo = r.tc.topo == TestCase::TopoType::FULL_MESH ? "FullMesh" : + r.tc.topo == TestCase::TopoType::CHAIN ? "Chain" : "Grid"; + const char* strat = r.tc.strategy == RoutingStrategy::DEFAULT ? "DEFAULT" : + r.tc.strategy == RoutingStrategy::PATH_SNR_HYBRID ? "PATH_SNR_HYBRID" : + r.tc.strategy == RoutingStrategy::ADAPTIVE ? "ADAPTIVE" : + "SNR_WEIGHTED"; + fprintf(csv, "%s,%s,%d,%.1f,%s,%.4f,%.2f,%.3f,%lld\n", + label, topo, r.tc.num_nodes, r.tc.channel_snr, strat, + r.stats.avg_delivery_rate, r.stats.avg_latency_ms, + r.stats.avg_hops, (long long)r.stats.total_airtime_ms); + }; + + TestRunner runner; + + // ----------------------------------------------------------------------- + // Helper: print a result row + delta vs baseline + // ----------------------------------------------------------------------- + auto print_row = [](const char* label, const TestResult& r, + const TestResult* base = nullptr) { + const char* topo = r.tc.topo == TestCase::TopoType::FULL_MESH ? "FullMesh" : + r.tc.topo == TestCase::TopoType::CHAIN ? "Chain" : "Grid"; + const char* strat = r.tc.strategy == RoutingStrategy::DEFAULT ? "DEFAULT " : + r.tc.strategy == RoutingStrategy::PATH_SNR_HYBRID ? "HYBRID " : + r.tc.strategy == RoutingStrategy::ADAPTIVE ? "ADAPTIVE " : + "SNR_W "; + printf(" %-28s | strat=%s | topo=%-8s | n=%-3d | snr=%+4.0f | dr=%5.1f%% | lat=%6.0fms | air=%7lldms", + label, strat, topo, r.tc.num_nodes, r.tc.channel_snr, + r.stats.avg_delivery_rate * 100.0f, + r.stats.avg_latency_ms, + (long long)r.stats.total_airtime_ms); + if (base) { + float dr_d = (r.stats.avg_delivery_rate - base->stats.avg_delivery_rate) * 100.0f; + float lat_pct = base->stats.avg_latency_ms > 0 + ? (r.stats.avg_latency_ms - base->stats.avg_latency_ms) + / base->stats.avg_latency_ms * 100.0f : 0.0f; + float air_pct = base->stats.total_airtime_ms > 0 + ? (float)((long long)r.stats.total_airtime_ms - (long long)base->stats.total_airtime_ms) + / (float)base->stats.total_airtime_ms * 100.0f : 0.0f; + printf(" Δdr=%+4.1f%% Δlat=%+5.0f%% Δair=%+5.0f%%", + dr_d, lat_pct, air_pct); + } + printf("\n"); + }; + + // ----------------------------------------------------------------------- + // Test block: run DEFAULT, HYBRID, ADAPTIVE for a given config + // ----------------------------------------------------------------------- + struct Config { + TestCase::TopoType topo; + int nodes, rows, cols; + float snr; + const char* tag; + }; + + Config configs[] = { + // FullMesh — where density detection matters most + { TestCase::TopoType::FULL_MESH, 10, 0, 0, 8.0f, "FM10_snr8" }, + { TestCase::TopoType::FULL_MESH, 25, 0, 0, 8.0f, "FM25_snr8" }, + { TestCase::TopoType::FULL_MESH, 50, 0, 0, 8.0f, "FM50_snr8" }, + { TestCase::TopoType::FULL_MESH, 100, 0, 0, 8.0f, "FM100_snr8" }, + // FullMesh at poor SNR — density still dense, but collisions compound + { TestCase::TopoType::FULL_MESH, 50, 0, 0, 3.0f, "FM50_snr3" }, + // Chain — sparse; ADAPTIVE should promote SNR_WEIGHTED + { TestCase::TopoType::CHAIN, 10, 0, 0, 8.0f, "CH10_snr8" }, + { TestCase::TopoType::CHAIN, 20, 0, 0, 8.0f, "CH20_snr8" }, + { TestCase::TopoType::CHAIN, 20, 0, 0, 3.0f, "CH20_snr3" }, + // Grid — medium density; ADAPTIVE should hit MEDIUM tier + { TestCase::TopoType::GRID, 16, 4, 4, 8.0f, "GR4x4_snr8" }, + { TestCase::TopoType::GRID, 25, 5, 5, 8.0f, "GR5x5_snr8" }, + { TestCase::TopoType::GRID, 25, 5, 5, 6.0f, "GR5x5_snr6" }, + }; + + RoutingStrategy strats[] = { + RoutingStrategy::DEFAULT, + RoutingStrategy::PATH_SNR_HYBRID, + RoutingStrategy::ADAPTIVE, + }; + + for (auto& cfg : configs) { + printf("\n--- %s (topo=%s nodes=%d snr=%.0f) ---\n", + cfg.tag, + cfg.topo == TestCase::TopoType::FULL_MESH ? "FullMesh" : + cfg.topo == TestCase::TopoType::CHAIN ? "Chain" : "Grid", + cfg.nodes, cfg.snr); + + TestResult base_result{}; + bool have_base = false; + std::vector group; + + for (auto& strat : strats) { + TestCase tc{}; + char lbl[64]; + snprintf(lbl, sizeof(lbl), "%s_%s", cfg.tag, + strat == RoutingStrategy::DEFAULT ? "DEFAULT" : + strat == RoutingStrategy::PATH_SNR_HYBRID ? "HYBRID" : "ADAPTIVE"); + tc.name = lbl; + tc.num_nodes = cfg.nodes; + tc.channel_snr = cfg.snr; + tc.num_floods = 20; + tc.strategy = strat; + tc.topo = cfg.topo; + tc.grid_rows = cfg.rows; + tc.grid_cols = cfg.cols; + + auto result = runner.run({tc})[0]; + group.push_back(result); + + if (strat == RoutingStrategy::DEFAULT) { + base_result = result; + have_base = true; + print_row(lbl, result); + } else { + print_row(lbl, result, have_base ? &base_result : nullptr); + } + csv_row(lbl, result); + } + } + + // ----------------------------------------------------------------------- + // Summary: how does ADAPTIVE perform vs DEFAULT across all configs? + // ----------------------------------------------------------------------- + printf("\n======================================\n"); + printf(" ADAPTIVE vs DEFAULT — aggregate\n"); + printf(" See adaptive_results.csv for full data\n"); + printf("======================================\n"); + + if (csv) { + fclose(csv); + printf("CSV written to adaptive_results.csv\n"); + } + return 0; +} diff --git a/sim/scenarios/scenario_compression.cpp b/sim/scenarios/scenario_compression.cpp new file mode 100644 index 0000000000..992c5eed59 --- /dev/null +++ b/sim/scenarios/scenario_compression.cpp @@ -0,0 +1,94 @@ +#include +#include +#include + +#define ENABLE_COMPRESSION 1 +#include "helpers/Compression.h" + +// Typical English text fragments for different lengths +static const char* get_test_message(int target_len) { + static const char base[] = + "Hello from node Alpha, please relay this message to all nearby stations. " + "Weather is clear, signal strong, battery at 85 percent. " + "Meet at the usual coordinates at sunset. Over and out."; + static char buf[256]; + int base_len = (int)strlen(base); + int copy_len = target_len < base_len ? target_len : base_len; + memcpy(buf, base, copy_len); + buf[copy_len] = '\0'; + return buf; +} + +int main() { + printf("=== Unishox2 Compression Benchmark ===\n\n"); + printf("%-10s %-12s %-12s %-10s %-10s\n", + "Size", "Original", "Compressed", "Ratio", "Worthwhile?"); + printf("%-10s %-12s %-12s %-10s %-10s\n", + "------", "--------", "----------", "-----", "-----------"); + + const int test_sizes[] = {10, 30, 50, 100, 150}; + const int num_tests = 5; + + int passed = 0; + int failed_roundtrip = 0; + + for (int t = 0; t < num_tests; t++) { + int target = test_sizes[t]; + const char* msg = get_test_message(target); + int in_len = (int)strlen(msg); + + uint8_t compressed[256]; + uint8_t decompressed[256]; + memset(compressed, 0, sizeof(compressed)); + memset(decompressed, 0, sizeof(decompressed)); + + // Compress + int comp_len = mesh::compressPayload( + (const uint8_t*)msg, in_len, + compressed, (int)sizeof(compressed) + ); + + const char* worthwhile; + float ratio = 0.0f; + + if (comp_len > 0) { + ratio = (float)comp_len / (float)in_len; + worthwhile = (ratio < 1.0f) ? "YES" : "NO"; + } else { + worthwhile = "NO (larger)"; + ratio = 1.0f; + } + + printf("%-10d %-12d %-12d %-10.3f %-10s\n", + target, in_len, + comp_len > 0 ? comp_len : in_len, + ratio, + worthwhile); + + // Round-trip verification + if (comp_len > 0) { + int decomp_len = mesh::decompressPayload( + compressed, comp_len, + decompressed, (int)sizeof(decompressed) + ); + + if (decomp_len == in_len && memcmp(msg, decompressed, in_len) == 0) { + passed++; + } else { + printf(" *** ROUND-TRIP FAILED for size %d (decomp_len=%d, expected=%d)\n", + target, decomp_len, in_len); + failed_roundtrip++; + } + } + } + + printf("\n=== Round-trip verification ===\n"); + printf("Passed: %d / %d\n", passed, num_tests); + if (failed_roundtrip > 0) { + printf("FAILURES: %d\n", failed_roundtrip); + return 1; + } + + printf("\nAll round-trips verified. Compression library is functional.\n"); + return 0; +} diff --git a/sim/scenarios/scenario_concurrent.cpp b/sim/scenarios/scenario_concurrent.cpp new file mode 100644 index 0000000000..db68013872 --- /dev/null +++ b/sim/scenarios/scenario_concurrent.cpp @@ -0,0 +1,196 @@ +// scenario_concurrent.cpp +// Tests collision behaviour under simultaneous flood sources. +// +// Real deployments have many independent senders — emergency beacons, +// tracking pings, chat messages — all launching floods at overlapping times. +// This scenario measures how delivery rate degrades as concurrent flood +// count increases, and how ADAPTIVE compares to DEFAULT under that load. +// +// Test matrix: +// Concurrent senders: 1, 2, 4, 8 (launched simultaneously) +// Topologies: FullMesh 50, FullMesh 100, Grid 5×5 +// Strategies: DEFAULT, ADAPTIVE +// +// Metrics: per-flood delivery rate, total collisions, total airtime. +// A collision at any node counts once — total_collisions gives channel load. + +#include "SimBus.h" +#include "SimMetrics.h" +#include "RoutingStrategies.h" +#include +#include +#include +#include +#include + +using namespace sim; + +struct ConcurrentResult { + int num_nodes; + int concurrent_senders; + RoutingStrategy strategy; + const char* topo_name; + float avg_delivery_rate; + float avg_latency_ms; + uint32_t total_collisions; + uint64_t total_airtime_ms; + uint32_t total_tx; +}; + +static ConcurrentResult runConcurrent( + RFChannelModel* model, + const char* topo_name, + int num_nodes, + int grid_rows, int grid_cols, + float snr, + int concurrent_senders, + int rounds, // how many rounds of concurrent floods + RoutingStrategy strat) +{ + SimBus bus; + bus.tick_ms = 5; + + for (int i = 0; i < num_nodes; i++) { + char name[32]; + if (grid_rows > 0) + snprintf(name, sizeof(name), "n%d_%d", i/grid_cols, i%grid_cols); + else + snprintf(name, sizeof(name), "node%d", i); + bus.addNode(name, (uint32_t)(i + 1) * 0xdeadbeef); + } + bus.channel_model = model; + + for (auto& b : bus.nodes) b.node->routing_strategy = strat; + + // Warmup: prime density estimators + for (int i = 0; i < 3; i++) { + bus.sendFloodText(0, "warmup"); + bus.run(4000); + } + bus.resetStats(); + + // Each round: launch `concurrent_senders` floods simultaneously + // from evenly-spaced nodes, then let them fully propagate + int total_floods = 0; + uint64_t prop_ms = (grid_rows > 0) ? 10000 : 5000; + + for (int round = 0; round < rounds; round++) { + for (int s = 0; s < concurrent_senders && s < num_nodes; s++) { + int src = (s * num_nodes) / concurrent_senders; // evenly spaced + bus.sendFloodText(src, "concurrent"); + total_floods++; + } + bus.run(prop_ms); + } + + auto stats = bus.metrics.aggregate(total_floods); + uint64_t total_air = 0; + uint32_t total_tx = 0; + for (auto& b : bus.nodes) { + total_air += b.node->total_airtime_ms; + total_tx += b.node->total_tx_packets; + } + + return { + num_nodes, concurrent_senders, strat, topo_name, + stats.avg_delivery_rate, stats.avg_latency_ms, + bus.totalCollisions(), total_air, total_tx + }; +} + +static const char* stratName(RoutingStrategy s) { + return s == RoutingStrategy::DEFAULT ? "DEFAULT " : "ADAPTIVE"; +} + +int main() { + printf("MeshCore Concurrent Flood Scenario\n"); + printf("====================================\n"); + printf("Collision model: LoRa capture effect (6 dB threshold)\n"); + printf("Metric: delivery rate, collisions, airtime under simultaneous sources\n\n"); + + FILE* csv = fopen("concurrent_results.csv", "w"); + if (csv) + fprintf(csv, "topo,num_nodes,concurrent_senders,strategy," + "avg_delivery_rate,avg_latency_ms,total_collisions,total_airtime_ms,total_tx\n"); + + auto print_row = [&](const ConcurrentResult& r, const ConcurrentResult* base = nullptr) { + printf(" %-8s n=%-3d senders=%-2d strat=%s | dr=%5.1f%% lat=%6.0fms " + "coll=%-6u air=%8lldms tx=%-5u", + r.topo_name, r.num_nodes, r.concurrent_senders, stratName(r.strategy), + r.avg_delivery_rate * 100.f, r.avg_latency_ms, + r.total_collisions, (long long)r.total_airtime_ms, r.total_tx); + if (base) { + float dr_d = (r.avg_delivery_rate - base->avg_delivery_rate) * 100.f; + float air_p = base->total_airtime_ms > 0 + ? (float)((long long)r.total_airtime_ms - (long long)base->total_airtime_ms) + / (float)base->total_airtime_ms * 100.f : 0.f; + int cd = (int)r.total_collisions - (int)base->total_collisions; + printf(" Δdr=%+4.1f%% Δair=%+5.0f%% Δcoll=%+d", dr_d, air_p, cd); + } + printf("\n"); + if (csv) + fprintf(csv, "%s,%d,%d,%s,%.4f,%.1f,%u,%lld,%u\n", + r.topo_name, r.num_nodes, r.concurrent_senders, stratName(r.strategy), + r.avg_delivery_rate, r.avg_latency_ms, + r.total_collisions, (long long)r.total_airtime_ms, r.total_tx); + }; + + struct Config { + const char* name; + int nodes, rows, cols; + float snr; + }; + Config configs[] = { + { "FM50", 50, 0, 0, 8.0f }, + { "FM100", 100, 0, 0, 8.0f }, + { "GR5x5", 25, 5, 5, 8.0f }, + }; + + int sender_counts[] = { 1, 2, 4, 8 }; + RoutingStrategy strats[] = { RoutingStrategy::DEFAULT, RoutingStrategy::ADAPTIVE }; + + for (auto& cfg : configs) { + RFChannelModel* model = nullptr; + if (cfg.rows > 0) { + auto* pm = new PositionalModel(1.5f, cfg.snr + 4.f, cfg.snr); + for (int r = 0; r < cfg.rows; r++) + for (int c = 0; c < cfg.cols; c++) + pm->addNode((float)c, (float)r); + model = pm; + } else { + model = new FullMeshModel(cfg.snr); + } + + printf("\n--- Topology: %s (%d nodes, SNR=%.0fdB) ---\n", + cfg.name, cfg.nodes, cfg.snr); + + for (int ns : sender_counts) { + if (ns >= cfg.nodes) continue; + ConcurrentResult def_base{}; + bool have_def = false; + + for (auto& strat : strats) { + auto r = runConcurrent(model, cfg.name, cfg.nodes, + cfg.rows, cfg.cols, cfg.snr, + ns, 10, strat); + if (!have_def) { + def_base = r; have_def = true; + print_row(r); + } else { + print_row(r, &def_base); + } + } + printf("\n"); + } + + delete model; + } + + // Summary: find the sender count where delivery drops below 90% + printf("\n=== COLLISION SENSITIVITY SUMMARY ===\n"); + printf("At what concurrent sender count does delivery first drop below 90%%?\n"); + printf("(See concurrent_results.csv for full data)\n"); + + if (csv) fclose(csv); + return 0; +} diff --git a/sim/scenarios/scenario_dutycycle.cpp b/sim/scenarios/scenario_dutycycle.cpp new file mode 100644 index 0000000000..8c8d61cce6 --- /dev/null +++ b/sim/scenarios/scenario_dutycycle.cpp @@ -0,0 +1,217 @@ +// scenario_dutycycle.cpp +// Models duty cycle budget enforcement and its effect on delivery. +// +// The EU 1% duty cycle rule allows each node ~864 ms TX per hour (~36s/day). +// At LoRa SF8 BW62.5kHz, a typical 50-byte flood packet is ~623ms airtime. +// That means a dense node can legally relay only ~1-2 floods per hour before +// going silent. In a network with many nodes and many floods, this is the +// binding constraint — not SNR, not hops. +// +// This scenario answers: +// 1. At what flood rate does the 1% limit start silencing nodes? +// 2. Does ADAPTIVE's reduced TX (10-20% of nodes relay) help stay legal? +// 3. What is the delivery impact when nodes hit budget and go silent? +// 4. Which topology is most resilient to node silence? +// +// Budget factors tested: +// factor=1 → 50% (stock, no real enforcement) +// factor=9 → 10% (typical non-EU, relaxed) +// factor=99 → 1% (EU legal, strictest) +// +// Flood rates: 1 flood/5s, 1 flood/30s, 1 flood/60s, 1 flood/300s +// Topologies: FullMesh 50, Chain 20 (most vulnerable to node silence) + +#include "SimBus.h" +#include "SimMetrics.h" +#include "RoutingStrategies.h" +#include +#include +#include +#include + +using namespace sim; + +struct DutyResult { + const char* topo; + int num_nodes; + float duty_cycle_pct; // 1/(1+factor)*100 + int flood_interval_s; + RoutingStrategy strategy; + float avg_delivery_rate; + float avg_latency_ms; + uint64_t total_airtime_ms; + uint32_t total_tx; + uint32_t total_collisions; +}; + +static DutyResult runDutyCycle( + RFChannelModel* model, + const char* topo_name, + int num_nodes, int grid_rows, int grid_cols, + float snr, + float duty_factor, // getAirtimeBudgetFactor override + int flood_interval_s, // seconds between floods + int num_floods, + RoutingStrategy strat) +{ + SimBus bus; + bus.tick_ms = 10; // coarser tick — these run over long simulated time + + for (int i = 0; i < num_nodes; i++) { + char name[32]; + if (grid_rows > 0) + snprintf(name, sizeof(name), "n%d_%d", i/grid_cols, i%grid_cols); + else + snprintf(name, sizeof(name), "node%d", i); + bus.addNode(name, (uint32_t)(i + 1) * 0xdeadbeef); + } + bus.channel_model = model; + + for (auto& b : bus.nodes) { + b.node->routing_strategy = strat; + b.node->duty_cycle_factor = duty_factor; + } + + // Warmup — shorter, just enough for density priming + for (int i = 0; i < 2; i++) { + bus.sendFloodText(0, "warmup"); + uint64_t wp = grid_rows > 0 ? 8000 : 4000; + bus.run(wp); + } + bus.resetStats(); + + // Run floods at the specified interval + uint64_t interval_ms = (uint64_t)flood_interval_s * 1000; + uint64_t prop_ms = (uint64_t)(flood_interval_s * 1000 > 5000 + ? 5000 : flood_interval_s * 1000); + + for (int i = 0; i < num_floods; i++) { + bus.sendFloodText(0, "bench"); + bus.run(prop_ms); + // Wait out the rest of the interval (budget refills during idle) + if (interval_ms > prop_ms) + bus.run(interval_ms - prop_ms); + } + + auto stats = bus.metrics.aggregate(num_floods); + uint64_t total_air = 0; + uint32_t total_tx = 0; + for (auto& b : bus.nodes) { + total_air += b.node->total_airtime_ms; + total_tx += b.node->total_tx_packets; + } + + float dc_pct = 1.0f / (1.0f + duty_factor) * 100.f; + return { + topo_name, num_nodes, dc_pct, flood_interval_s, strat, + stats.avg_delivery_rate, stats.avg_latency_ms, + total_air, total_tx, bus.totalCollisions() + }; +} + +static const char* stratName(RoutingStrategy s) { + return s == RoutingStrategy::DEFAULT ? "DEFAULT " : "ADAPTIVE"; +} + +int main() { + printf("MeshCore Duty Cycle Enforcement Scenario\n"); + printf("==========================================\n"); + printf("Budget factors: 50%% (no limit) | 10%% (relaxed) | 1%% (EU legal)\n\n"); + + FILE* csv = fopen("dutycycle_results.csv", "w"); + if (csv) + fprintf(csv, "topo,num_nodes,duty_cycle_pct,flood_interval_s,strategy," + "avg_delivery_rate,avg_latency_ms,total_airtime_ms,total_tx,total_collisions\n"); + + struct Config { + const char* name; int nodes, rows, cols; float snr; + }; + Config configs[] = { + { "FM50", 50, 0, 0, 8.0f }, + { "CH20", 20, 0, 0, 8.0f }, + }; + + // duty_factor → duty_cycle%: 1→50% 9→10% 99→1% + struct BudgetLevel { float factor; const char* label; }; + BudgetLevel budgets[] = { + { 1.0f, "50% (none)" }, + { 9.0f, "10% (relax)" }, + { 99.0f, " 1% (EU)" }, + }; + + int intervals[] = { 5, 30, 60, 300 }; // seconds between floods + RoutingStrategy strats[] = { RoutingStrategy::DEFAULT, RoutingStrategy::ADAPTIVE }; + + for (auto& cfg : configs) { + RFChannelModel* model = cfg.name[0] == 'C' + ? (RFChannelModel*)new ChainModel(cfg.snr) + : (RFChannelModel*)new FullMeshModel(cfg.snr); + + printf("\n=== %s (%d nodes) ===\n", cfg.name, cfg.nodes); + + for (auto& budget : budgets) { + printf("\n Duty cycle: %s\n", budget.label); + printf(" %-8s %-6s %-9s | dr%% lat(ms) tx air(ms) | Δdr Δair\n", + "interval", "strat", "duty%%"); + + for (int ivl : intervals) { + // 20 floods at short intervals, fewer at long (keep runtime bounded) + int nf = ivl <= 30 ? 20 : ivl <= 60 ? 15 : 10; + + DutyResult def_r{}; + bool have_def = false; + + for (auto& strat : strats) { + auto r = runDutyCycle(model, cfg.name, cfg.nodes, + cfg.rows, cfg.cols, cfg.snr, + budget.factor, ivl, nf, strat); + + if (!have_def) { + def_r = r; have_def = true; + printf(" %-8ds %-6s %5.0f%% | %5.1f%% %7.0f %-5u %12lld\n", + ivl, stratName(strat), budget.factor == 1.f ? 50.f : + budget.factor == 9.f ? 10.f : 1.f, + r.avg_delivery_rate*100.f, r.avg_latency_ms, + r.total_tx, (long long)r.total_airtime_ms); + } else { + float dr_d = (r.avg_delivery_rate - def_r.avg_delivery_rate)*100.f; + float air_p = def_r.total_airtime_ms > 0 + ? (float)((long long)r.total_airtime_ms-(long long)def_r.total_airtime_ms) + /(float)def_r.total_airtime_ms*100.f : 0.f; + const char* flag = r.avg_delivery_rate < def_r.avg_delivery_rate-0.03f + ? " !!" : ""; + printf(" %-8ds %-6s %5.0f%% | %5.1f%% %7.0f %-5u %12lld | %+5.1f%% %+5.0f%%%s\n", + ivl, stratName(strat), budget.factor == 1.f ? 50.f : + budget.factor == 9.f ? 10.f : 1.f, + r.avg_delivery_rate*100.f, r.avg_latency_ms, + r.total_tx, (long long)r.total_airtime_ms, + dr_d, air_p, flag); + } + + if (csv) + fprintf(csv, "%s,%d,%.1f,%d,%s,%.4f,%.1f,%lld,%u,%u\n", + cfg.name, cfg.nodes, r.duty_cycle_pct, ivl, stratName(strat), + r.avg_delivery_rate, r.avg_latency_ms, + (long long)r.total_airtime_ms, r.total_tx, r.total_collisions); + } + } + } + delete model; + } + + printf("\n=== DUTY CYCLE BUDGET REFERENCE ===\n"); + printf("SF8, BW62.5kHz, 50-byte payload ≈ 623ms airtime per packet\n"); + printf(" 1%% budget/hour: %d ms → ~%d relays/hour/node\n", + (int)(3600000 / 100), (int)(3600000 / 100 / 623)); + printf(" 10%% budget/hour: %d ms → ~%d relays/hour/node\n", + (int)(3600000 / 10), (int)(3600000 / 10 / 623)); + printf(" 50%% budget/hour: %d ms → ~%d relays/hour/node\n", + (int)(3600000 / 2), (int)(3600000 / 2 / 623)); + printf("\nAt 1%% with ADAPTIVE (10%% of nodes relay): effectively "); + printf("~%d relays/hour across network per flood\n", + (int)(3600000 / 100 / 623) * 10); + + if (csv) fclose(csv); + printf("\nCSV written to dutycycle_results.csv\n"); + return 0; +} diff --git a/sim/scenarios/scenario_flood_basic.cpp b/sim/scenarios/scenario_flood_basic.cpp new file mode 100644 index 0000000000..3c9c9e6b06 --- /dev/null +++ b/sim/scenarios/scenario_flood_basic.cpp @@ -0,0 +1,109 @@ +#include "SimBus.h" +#include + +// Scenario: N nodes in a full-mesh (all hear all), one node floods a packet, +// measure delivery rate and airtime across different network sizes. + +static void runFullMesh(int num_nodes, int num_floods) { + sim::SimBus bus; + sim::FullMeshModel model(8.0f); + bus.channel_model = &model; + bus.tick_ms = 5; + + char name[32]; + for (int i = 0; i < num_nodes; i++) { + snprintf(name, sizeof(name), "node%d", i); + bus.addNode(name, (uint32_t)(i + 1) * 0xdeadbeef); + } + + // Warm up: let nodes boot / advert + bus.run(2000); + bus.resetStats(); + + // Inject floods + for (int i = 0; i < num_floods; i++) { + bus.sendFloodText(0, "hello mesh"); + bus.run(3000); // 3s per flood to propagate + } + + char label[64]; + snprintf(label, sizeof(label), "FullMesh N=%d floods=%d", num_nodes, num_floods); + bus.printReport(label); + bus.metrics.printReport(label, num_floods); +} + +static void runChain(int num_nodes, int num_floods) { + sim::SimBus bus; + sim::ChainModel model(8.0f); + bus.channel_model = &model; + bus.tick_ms = 5; + + char name[32]; + for (int i = 0; i < num_nodes; i++) { + snprintf(name, sizeof(name), "node%d", i); + bus.addNode(name, (uint32_t)(i + 1) * 0xcafebabe); + } + + bus.run(2000); + bus.resetStats(); + + for (int i = 0; i < num_floods; i++) { + bus.sendFloodText(0, "hello chain"); + bus.run(5000); // chains need more time + } + + char label[64]; + snprintf(label, sizeof(label), "Chain N=%d floods=%d", num_nodes, num_floods); + bus.printReport(label); + bus.metrics.printReport(label, num_floods); +} + +static void runGrid(int rows, int cols, int num_floods) { + sim::SimBus bus; + sim::PositionalModel model(1.5f, 12.0f, 3.0f); + bus.channel_model = &model; + bus.tick_ms = 5; + + int num_nodes = rows * cols; + char name[32]; + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + snprintf(name, sizeof(name), "n%d_%d", r, c); + bus.addNode(name, (uint32_t)(r * cols + c + 1) * 0x1337cafe); + model.addNode((float)c, (float)r); + } + } + + bus.run(2000); + bus.resetStats(); + + for (int i = 0; i < num_floods; i++) { + bus.sendFloodText(0, "hello grid"); + bus.run(8000); + } + + char label[64]; + snprintf(label, sizeof(label), "Grid %dx%d floods=%d", rows, cols, num_floods); + bus.printReport(label); + bus.metrics.printReport(label, num_floods); +} + +int main() { + printf("MeshCore Simulation Harness\n"); + printf("===========================\n\n"); + + // Scale test: full mesh at increasing sizes + runFullMesh(5, 10); + runFullMesh(10, 10); + runFullMesh(20, 5); + + // Topology tests + runChain(5, 10); + runChain(10, 5); + + // Grid topology (realistic urban/suburban deployment) + runGrid(3, 3, 10); + runGrid(4, 4, 5); + + return 0; +} diff --git a/sim/scenarios/scenario_longchain.cpp b/sim/scenarios/scenario_longchain.cpp new file mode 100644 index 0000000000..f5af9757f8 --- /dev/null +++ b/sim/scenarios/scenario_longchain.cpp @@ -0,0 +1,161 @@ +// scenario_longchain.cpp +// Tests multi-hop relay performance at chain lengths targeting multi-state range. +// +// Real-world target: reliable delivery across hundreds of miles via relay chains. +// Each node in a chain extends range by ~2-5 miles (SF12, open terrain). +// A 200-node chain = ~400-1000 miles theoretical range. +// +// This scenario finds where delivery rate collapses, what the per-hop loss +// accumulation looks like, and how ADAPTIVE vs DEFAULT compares at depth. +// +// Chain lengths: 10, 20, 30, 50, 75, 100, 150, 200 nodes +// SNR conditions: 8 dB (good), 5 dB (marginal), 3 dB (poor) +// Strategies: DEFAULT, ADAPTIVE +// Floods: 20 per config (enough to average out random backoff variance) + +#include "SimBus.h" +#include "SimMetrics.h" +#include "RoutingStrategies.h" +#include +#include +#include +#include + +using namespace sim; + +struct ChainResult { + int num_nodes; + float snr; + RoutingStrategy strategy; + float avg_delivery_rate; + float avg_latency_ms; + float avg_hops; + uint32_t total_collisions; + uint64_t total_airtime_ms; +}; + +static ChainResult runChain(int num_nodes, float snr, RoutingStrategy strat, int num_floods) { + SimBus bus; + bus.tick_ms = 5; + + auto* model = new ChainModel(snr); + bus.channel_model = model; + + for (int i = 0; i < num_nodes; i++) { + char name[32]; + snprintf(name, sizeof(name), "node%d", i); + bus.addNode(name, (uint32_t)(i + 1) * 0xcafebabe); + } + + for (auto& b : bus.nodes) b.node->routing_strategy = strat; + + // Warmup — chain needs more time to propagate + uint64_t warmup_prop = (uint64_t)num_nodes * 400 + 2000; + bus.sendFloodText(0, "warmup"); bus.run(warmup_prop); + bus.sendFloodText(0, "warmup"); bus.run(warmup_prop); + bus.resetStats(); + + // Propagation budget scales with chain length + // Each hop takes ~400-800ms under DEFAULT; give generous margin + uint64_t prop_ms = (uint64_t)num_nodes * 600 + 3000; + + for (int i = 0; i < num_floods; i++) { + bus.sendFloodText(0, "bench"); + bus.run(prop_ms); + } + + auto stats = bus.metrics.aggregate(num_floods); + uint64_t total_air = 0; + for (auto& b : bus.nodes) total_air += b.node->total_airtime_ms; + + delete model; + return { + num_nodes, snr, strat, + stats.avg_delivery_rate, stats.avg_latency_ms, stats.avg_hops, + bus.totalCollisions(), total_air + }; +} + +static const char* stratName(RoutingStrategy s) { + return s == RoutingStrategy::DEFAULT ? "DEFAULT " : "ADAPTIVE"; +} + +int main() { + printf("MeshCore Long Relay Chain Scenario\n"); + printf("=====================================\n"); + printf("Target: multi-state range via relay chains\n"); + printf("Each node ≈ 2-5 miles range (SF12 LoRa, open terrain)\n\n"); + + FILE* csv = fopen("longchain_results.csv", "w"); + if (csv) + fprintf(csv, "num_nodes,snr,strategy,avg_delivery_rate,avg_latency_ms," + "avg_hops,total_collisions,total_airtime_ms\n"); + + int chain_lengths[] = { 10, 20, 30, 50, 75, 100, 150, 200 }; + float snr_vals[] = { 8.0f, 5.0f, 3.0f }; + RoutingStrategy strats[] = { RoutingStrategy::DEFAULT, RoutingStrategy::ADAPTIVE }; + + for (float snr : snr_vals) { + printf("\n=== SNR = %.0f dB ===\n", snr); + printf("%-6s %-8s | dr%% lat(ms) hops coll air(ms) | Δdr Δlat Δair\n", + "nodes", "strat"); + printf("%s\n", std::string(95, '-').c_str()); + + for (int n : chain_lengths) { + ChainResult def_r{}; + bool have_def = false; + + for (auto& strat : strats) { + int floods = 20; + // Longer chains need fewer floods to keep runtime reasonable + if (n >= 100) floods = 10; + if (n >= 150) floods = 5; + + auto r = runChain(n, snr, strat, floods); + + if (!have_def) { + def_r = r; + have_def = true; + printf("%-6d %-8s | %5.1f%% %7.0f %4.1f %-5u %12lld\n", + r.num_nodes, stratName(r.strategy), + r.avg_delivery_rate * 100.f, r.avg_latency_ms, + r.avg_hops, r.total_collisions, + (long long)r.total_airtime_ms); + } else { + float dr_d = (r.avg_delivery_rate - def_r.avg_delivery_rate) * 100.f; + float lat_p = def_r.avg_latency_ms > 0 + ? (r.avg_latency_ms - def_r.avg_latency_ms) / def_r.avg_latency_ms * 100.f : 0.f; + float air_p = def_r.total_airtime_ms > 0 + ? (float)((long long)r.total_airtime_ms - (long long)def_r.total_airtime_ms) + / (float)def_r.total_airtime_ms * 100.f : 0.f; + printf("%-6d %-8s | %5.1f%% %7.0f %4.1f %-5u %12lld | %+5.1f%% %+6.0f%% %+6.0f%%\n", + r.num_nodes, stratName(r.strategy), + r.avg_delivery_rate * 100.f, r.avg_latency_ms, + r.avg_hops, r.total_collisions, + (long long)r.total_airtime_ms, + dr_d, lat_p, air_p); + } + + if (csv) + fprintf(csv, "%d,%.1f,%s,%.4f,%.1f,%.2f,%u,%lld\n", + r.num_nodes, snr, stratName(r.strategy), + r.avg_delivery_rate, r.avg_latency_ms, r.avg_hops, + r.total_collisions, (long long)r.total_airtime_ms); + } + } + } + + printf("\n=== RANGE ESTIMATE (@ 3 miles/hop, SF12) ===\n"); + printf("%-10s %-12s %s\n", "Nodes", "Est. Range", "Notes"); + int sizes[] = {10,20,30,50,75,100,150,200}; + for (int n : sizes) + printf(" %-8d ~%-10s %s\n", n, + n<=10?"30 miles":n<=20?"60 miles":n<=30?"90 miles": + n<=50?"150 miles":n<=75?"225 miles":n<=100?"300 miles": + n<=150?"450 miles":"600 miles", + n<=20?"city-scale":n<=50?"regional":n<=100?"state-scale":"multi-state"); + + if (csv) fclose(csv); + printf("\nCSV written to longchain_results.csv\n"); + return 0; +} diff --git a/sim/scenarios/scenario_mixed.cpp b/sim/scenarios/scenario_mixed.cpp new file mode 100644 index 0000000000..d05ed033b9 --- /dev/null +++ b/sim/scenarios/scenario_mixed.cpp @@ -0,0 +1,188 @@ +// scenario_mixed.cpp +// Tests networks where ADAPTIVE nodes coexist with legacy DEFAULT nodes. +// +// In real deployments the firmware upgrade is never 100% simultaneous. +// Key questions: +// 1. Does ADAPTIVE break routing for DEFAULT nodes? (compatibility) +// 2. At what ADAPTIVE penetration % does the network see measurable benefit? +// 3. Does ADAPTIVE unfairly starve DEFAULT nodes of relay slots? +// +// Test matrix: +// ADAPTIVE penetration: 0%, 10%, 25%, 50%, 75%, 100% +// Topologies: FullMesh 50, Grid 5×5, Chain 20 +// ADAPTIVE nodes placed randomly (uniform distribution) +// +// Baseline: 0% ADAPTIVE = pure DEFAULT (stock MeshCore behaviour) +// Red line: any config where delivery drops below the 0% baseline + +#include "SimBus.h" +#include "SimMetrics.h" +#include "RoutingStrategies.h" +#include +#include +#include +#include +#include + +using namespace sim; + +struct MixedResult { + int num_nodes; + int adaptive_pct; + int adaptive_count; + const char* topo; + float avg_delivery_rate; + float avg_latency_ms; + float avg_hops; + uint32_t total_collisions; + uint64_t total_airtime_ms; +}; + +static MixedResult runMixed( + RFChannelModel* model, + const char* topo_name, + int num_nodes, int grid_rows, int grid_cols, + float snr, int adaptive_pct, int num_floods) +{ + SimBus bus; + bus.tick_ms = 5; + + for (int i = 0; i < num_nodes; i++) { + char name[32]; + if (grid_rows > 0) + snprintf(name, sizeof(name), "n%d_%d", i/grid_cols, i%grid_cols); + else + snprintf(name, sizeof(name), "node%d", i); + bus.addNode(name, (uint32_t)(i + 1) * 0xdeadbeef); + } + bus.channel_model = model; + + // Assign ADAPTIVE to a deterministic subset (every N-th node) + // — deterministic placement, not random, for reproducibility + int adaptive_count = (num_nodes * adaptive_pct + 50) / 100; + for (int i = 0; i < num_nodes; i++) { + // Evenly space ADAPTIVE nodes through the network + bool is_adaptive = adaptive_count > 0 && + (i * adaptive_count / num_nodes) < + ((i + 1) * adaptive_count / num_nodes); + bus.nodes[i].node->routing_strategy = + is_adaptive ? RoutingStrategy::ADAPTIVE : RoutingStrategy::DEFAULT; + } + + // Warmup + for (int i = 0; i < 3; i++) { + bus.sendFloodText(0, "warmup"); + uint64_t wp = grid_rows > 0 ? 8000 : 3000; + bus.run(wp); + } + bus.resetStats(); + + uint64_t prop_ms = grid_rows > 0 ? 8000 : 5000; + for (int i = 0; i < num_floods; i++) { + bus.sendFloodText(0, "bench"); + bus.run(prop_ms); + } + + auto stats = bus.metrics.aggregate(num_floods); + uint64_t total_air = 0; + for (auto& b : bus.nodes) total_air += b.node->total_airtime_ms; + + return { + num_nodes, adaptive_pct, adaptive_count, topo_name, + stats.avg_delivery_rate, stats.avg_latency_ms, stats.avg_hops, + bus.totalCollisions(), total_air + }; +} + +int main() { + printf("MeshCore Mixed Firmware Compatibility Scenario\n"); + printf("===============================================\n"); + printf("ADAPTIVE nodes coexisting with legacy DEFAULT nodes\n\n"); + + FILE* csv = fopen("mixed_results.csv", "w"); + if (csv) + fprintf(csv, "topo,num_nodes,adaptive_pct,adaptive_count," + "avg_delivery_rate,avg_latency_ms,avg_hops," + "total_collisions,total_airtime_ms\n"); + + struct Config { + const char* name; + int nodes, rows, cols; + float snr; + }; + Config configs[] = { + { "FM50", 50, 0, 0, 8.0f }, + { "FM100", 100, 0, 0, 8.0f }, + { "GR5x5", 25, 5, 5, 8.0f }, + { "CH20", 20, 0, 0, 8.0f }, // chain — sparse, most sensitive to relay gaps + }; + + int pcts[] = { 0, 10, 25, 50, 75, 100 }; + + for (auto& cfg : configs) { + RFChannelModel* model = nullptr; + if (cfg.rows > 0) { + auto* pm = new PositionalModel(1.5f, cfg.snr + 4.f, cfg.snr); + for (int r = 0; r < cfg.rows; r++) + for (int c = 0; c < cfg.cols; c++) + pm->addNode((float)c, (float)r); + model = pm; + } else if (cfg.name[0] == 'C') { + model = new ChainModel(cfg.snr); + } else { + model = new FullMeshModel(cfg.snr); + } + + printf("\n--- %s (%d nodes) ---\n", cfg.name, cfg.nodes); + printf(" %-8s %-7s | dr%% lat(ms) hops coll air(ms) | Δdr Δair\n", + "ADAPT%", "n_adapt"); + + MixedResult baseline{}; + bool have_base = false; + + for (int pct : pcts) { + auto r = runMixed(model, cfg.name, cfg.nodes, cfg.rows, cfg.cols, + cfg.snr, pct, 20); + + if (!have_base) { + baseline = r; + have_base = true; + printf(" %-8d %-7d | %5.1f%% %7.0f %4.1f %-5u %12lld (baseline)\n", + pct, r.adaptive_count, + r.avg_delivery_rate * 100.f, r.avg_latency_ms, + r.avg_hops, r.total_collisions, + (long long)r.total_airtime_ms); + } else { + float dr_d = (r.avg_delivery_rate - baseline.avg_delivery_rate) * 100.f; + float air_p = baseline.total_airtime_ms > 0 + ? (float)((long long)r.total_airtime_ms - (long long)baseline.total_airtime_ms) + / (float)baseline.total_airtime_ms * 100.f : 0.f; + const char* flag = (r.avg_delivery_rate < baseline.avg_delivery_rate - 0.02f) + ? " !! REGRESSION" : ""; + printf(" %-8d %-7d | %5.1f%% %7.0f %4.1f %-5u %12lld | %+5.1f%% %+5.0f%%%s\n", + pct, r.adaptive_count, + r.avg_delivery_rate * 100.f, r.avg_latency_ms, + r.avg_hops, r.total_collisions, + (long long)r.total_airtime_ms, + dr_d, air_p, flag); + } + + if (csv) + fprintf(csv, "%s,%d,%d,%d,%.4f,%.1f,%.2f,%u,%lld\n", + cfg.name, cfg.nodes, pct, r.adaptive_count, + r.avg_delivery_rate, r.avg_latency_ms, r.avg_hops, + r.total_collisions, (long long)r.total_airtime_ms); + } + + delete model; + } + + printf("\n=== KEY COMPATIBILITY CHECKS ===\n"); + printf("- Any REGRESSION flag = delivery dropped > 2%% vs all-DEFAULT baseline\n"); + printf("- Watch chain topology — sparse networks most sensitive to relay gaps\n"); + printf("- Airtime reduction at 100%% ADAPTIVE vs 0%% shows maximum efficiency gain\n"); + printf("\nCSV written to mixed_results.csv\n"); + + if (csv) fclose(csv); + return 0; +} diff --git a/sim/scenarios/scenario_relay_pct_sweep.cpp b/sim/scenarios/scenario_relay_pct_sweep.cpp new file mode 100644 index 0000000000..92d81d4dac --- /dev/null +++ b/sim/scenarios/scenario_relay_pct_sweep.cpp @@ -0,0 +1,421 @@ +// scenario_relay_pct_sweep.cpp +// Sweeps DENSE and MEDIUM relay percentages to find the optimal values for +// hash-based relay selection in the ADAPTIVE strategy. +// +// SPARSE is fixed at 100% — in sparse topologies every relay counts. +// MEDIUM and DENSE are swept independently across a grid of values. +// +// For each (dense_pct, medium_pct) pair we run three topologies: +// - FullMesh 50 nodes (primary dense stress test) +// - FullMesh 100 nodes (extreme density) +// - Chain 20 nodes (sparse — must not degrade) +// - Grid 5×5 25 nodes (medium density baseline) +// +// Scoring metric: weighted composite score that rewards: +// delivery_rate (primary) × airtime_efficiency (secondary) +// We want high DR with low airtime — the Pareto frontier. +// +// Output: sweep_results.csv + ranked table by composite score. + +#include "TestRunner.h" +#include "RoutingStrategies.h" +#include +#include +#include +#include + +using namespace sim; + +// Override the relay percentages at runtime by patching adaptiveRelayPct. +// We do this by storing the current sweep values in a global that the +// strategy function reads. This avoids recompiling per sweep point. +int g_dense_pct = 12; +int g_medium_pct = 35; + +// Override adaptiveRelayPct to use the sweep globals. +// We shadow the inline in RoutingStrategies.h by redefining it here. +// Since RoutingStrategies.h uses an inline, we need to call our own version +// from SimNode. We'll use a different approach: wrap the strategy dispatch +// in a thin shim by overriding via a global function pointer. +// +// Cleaner approach: SimNode already calls adaptiveRelayPct() which is an +// inline. We can't easily override it at runtime without modifying SimNode. +// Instead: define RELAY_PCT_OVERRIDE before including SimNode.h in this +// compilation unit, and provide our own version of the function. +// +// Cleanest approach for this sweep: run each config via a custom runOne() +// that sets p_forward per-node based on density tier, bypassing the inline. +// Since the hash gate reads adaptiveRelayPct() from RoutingStrategies.h, +// we patch it by #defining the values before the include. +// +// Given the build structure, the simplest correct approach is: +// Use a subclassed SimNode that overrides getRetransmitDelay() to read +// g_dense_pct and g_medium_pct. We build that inline here. + +#include "SimBus.h" + +// --------------------------------------------------------------------------- +// SweepNode — SimNode variant that reads relay pcts from globals. +// --------------------------------------------------------------------------- +namespace sim { + +class SweepNode : public SimNode { +public: + SweepNode(int idx, const std::string& name, + SimRadio& radio, SimMillisClock& ms, SimRNG& rng, + SimRTCClock& rtc, SimPacketManager& mgr, SimTables& tables) + : SimNode(idx, name, radio, ms, rng, rtc, mgr, tables) + {} + +protected: + uint32_t getRetransmitDelay(const mesh::Packet* pkt) override { + uint64_t now_ms = (uint64_t)_ms_ref.getMillis(); + + if (routing_strategy == RoutingStrategy::ADAPTIVE) { + DensityTier tier = density.tier(now_ms); + + int relay_pct; + switch (tier) { + case DensityTier::SPARSE: relay_pct = 100; break; + case DensityTier::MEDIUM: relay_pct = g_medium_pct; break; + case DensityTier::DENSE: relay_pct = g_dense_pct; break; + default: relay_pct = 100; break; + } + + if (relay_pct < 100) { + uint32_t pkt_seed = 0; + uint32_t node_seed = (uint32_t)node_idx * 0x9e3779b9u; + if (pkt->getRawLength() >= 4) memcpy(&pkt_seed, pkt->payload, 4); + if (!hashBasedRelay(pkt_seed, node_seed, relay_pct)) { + return 999999u; + } + } + } else if (p_forward < 1.0f) { + uint32_t roll = _rng_ref.nextInt(0, 10000); + if ((float)roll / 10000.0f >= p_forward) return 999999u; + } + + uint32_t base = (_radio_ref.getEstAirtimeFor(pkt->getRawLength()) * 52 / 50) / 2; + if (base == 0) base = 10; + + float snr = _radio_ref.getLastSNR(); + uint8_t hops = pkt->getPathHashCount(); + uint32_t rv = _rng_ref.nextInt(0, 10000); + + switch (routing_strategy) { + case RoutingStrategy::SNR_WEIGHTED: return snrWeightedDelay(base, snr, rv); + case RoutingStrategy::PATH_SNR_HYBRID: return pathSnrHybridDelay(base, snr, hops, rv); + case RoutingStrategy::ADAPTIVE: return adaptiveDelay(base, density.tier(now_ms), snr, hops, rv); + default: return _rng_ref.nextInt(0, 5) * base; + } + } +}; + +} // namespace sim + +// --------------------------------------------------------------------------- +// SweepBus — SimBus that creates SweepNode instead of SimNode. +// --------------------------------------------------------------------------- +class SweepBus : public sim::SimBus { +public: + int addSweepNode(const std::string& name, uint32_t rng_seed = 0) { + int idx = (int)nodes.size(); + auto& b = nodes.emplace_back(); + + b.ms = std::make_unique(clock); + b.rtc = std::make_unique(clock); + b.rng = std::make_unique(rng_seed ? rng_seed : (uint32_t)(idx * 0x9e3779b9 + 1)); + b.mgr = std::make_unique(clock); + b.tables= std::make_unique(); + + auto cb = [this, idx](int src, const uint8_t* bytes, int len, uint32_t airtime_ms, float tx_power_dbm) { + this->onTransmit(src, bytes, len, airtime_ms, tx_power_dbm); + }; + b.radio = std::make_unique(idx, cb); + + // Create SweepNode instead of SimNode + auto* sn = new sim::SweepNode(idx, name, *b.radio, *b.ms, *b.rng, *b.rtc, *b.mgr, *b.tables); + b.node.reset(sn); + b.node->init(); + + int idx_capture = idx; + b.node->on_recv = [this, idx_capture](sim::SimNode* n, const sim::DeliveryEvent& ev) { + uint64_t compound = ((uint64_t)n->last_advert_src_key << 32) | n->last_advert_ts; + auto it = _tsnode_to_seq.find(compound); + int seq = it != _tsnode_to_seq.end() ? it->second : -1; + if (seq < 0) return; + uint64_t inj = _inject_times.count(seq) ? _inject_times[seq] : 0; + auto src_it = _seq_to_src.find(seq); + int src = src_it != _seq_to_src.end() ? src_it->second : -1; + metrics.record(src, idx_capture, seq, inj, + (uint64_t)n->_ms_ref.getMillis(), + ev.hop_count, ev.route_type, ev.snr, ev.airtime_ms); + }; + + metrics.setNumNodes((int)nodes.size()); + return idx; + } + +}; + +// --------------------------------------------------------------------------- +// Run one sweep point — returns (delivery_rate, airtime_ms) per topology +// --------------------------------------------------------------------------- +struct TopoResult { + float dr; + float lat_ms; + uint64_t air_ms; + uint32_t total_tx; +}; + +static TopoResult runSweepTopo( + sim::TestCase::TopoType topo, int num_nodes, int rows, int cols, + float snr, int num_floods) +{ + SweepBus bus; + bus.tick_ms = 5; + char name_buf[32]; + + sim::RFChannelModel* model = nullptr; + if (topo == sim::TestCase::TopoType::FULL_MESH) { + model = new sim::FullMeshModel(snr); + } else if (topo == sim::TestCase::TopoType::CHAIN) { + model = new sim::ChainModel(snr); + } else { + auto* pm = new sim::PositionalModel(1.5f, snr + 4.0f, snr); + for (int r = 0; r < rows; r++) + for (int c = 0; c < cols; c++) + pm->addNode((float)c, (float)r); + model = pm; + } + bus.channel_model = model; + + for (int i = 0; i < num_nodes; i++) { + if (topo == sim::TestCase::TopoType::GRID) + snprintf(name_buf, sizeof(name_buf), "n%d_%d", i/cols, i%cols); + else + snprintf(name_buf, sizeof(name_buf), "node%d", i); + bus.addSweepNode(name_buf, (uint32_t)(i + 1) * 0xdeadbeef); + } + + for (auto& b : bus.nodes) + b.node->routing_strategy = sim::RoutingStrategy::ADAPTIVE; + + // Warmup floods to prime density estimator + for (int i = 0; i < 3; i++) { + bus.sendFloodText(0, "warmup"); + uint64_t wp = (topo == sim::TestCase::TopoType::CHAIN) ? 6000 : + (topo == sim::TestCase::TopoType::GRID) ? 8000 : 3000; + bus.run(wp); + } + bus.resetStats(); + + uint64_t prop_ms = (topo == sim::TestCase::TopoType::CHAIN) ? 6000 : + (topo == sim::TestCase::TopoType::GRID) ? 8000 : 3000; + + for (int i = 0; i < num_floods; i++) { + bus.sendFloodText(0, "bench"); + bus.run(prop_ms); + } + + auto stats = bus.metrics.aggregate(num_floods); + uint64_t total_air = 0; + uint32_t total_tx = 0; + for (auto& b : bus.nodes) { + total_air += b.node->total_airtime_ms; + total_tx += b.node->total_tx_packets; + } + + delete model; + return { stats.avg_delivery_rate, stats.avg_latency_ms, total_air, total_tx }; +} + +// --------------------------------------------------------------------------- +// Scoring: composite score for ranking sweep results. +// +// We want to maximise delivery and minimise airtime waste. +// Primary: delivery rate (must not drop below threshold vs DEFAULT baseline). +// Secondary: airtime efficiency relative to DEFAULT. +// +// Score = delivery_rate^2 / (airtime_ratio) +// where airtime_ratio = sweep_air / default_air (1.0 = same as DEFAULT) +// +// This penalises both low delivery AND high airtime waste. +// Bonus multiplier if delivery_rate >= 0.99 (no meaningful loss vs DEFAULT). +// --------------------------------------------------------------------------- +struct SweepResult { + int dense_pct; + int medium_pct; + // Per topology + TopoResult fm50, fm100, chain20, grid5x5; + // Composite + float score; +}; + +static float computeScore(const SweepResult& r, + const SweepResult& def) { + // Weighted average across topologies + // FullMesh topologies are where density matters most → higher weight + auto topo_score = [](const TopoResult& s, const TopoResult& d) -> float { + if (d.air_ms == 0) return 0.0f; + float air_ratio = (float)s.air_ms / (float)d.air_ms; + float dr = s.dr; + // Penalise hard any delivery rate below 95% + if (dr < 0.95f) dr *= 0.5f; + return (dr * dr) / air_ratio; + }; + + float s = topo_score(r.fm50, def.fm50) * 3.0f // primary stress test + + topo_score(r.fm100, def.fm100) * 3.0f // extreme density + + topo_score(r.chain20, def.chain20) * 2.0f // sparse must not regress + + topo_score(r.grid5x5, def.grid5x5) * 2.0f; // medium density + return s / 10.0f; +} + +int main() { + printf("MeshCore Relay Percentage Sweep\n"); + printf("=================================\n"); + printf("Sweeping DENSE%% × MEDIUM%% for ADAPTIVE hash-based relay selection.\n"); + printf("SPARSE always = 100%% (every relay matters in sparse topology).\n\n"); + + const int NUM_FLOODS = 20; + + // Sweep values — cover the interesting range with enough resolution + // to find the Pareto frontier without taking all day + int dense_vals[] = { 5, 8, 10, 12, 15, 20, 25, 30 }; + int medium_vals[] = { 20, 25, 30, 35, 40, 50, 60, 75, 100 }; + + int nd = sizeof(dense_vals) / sizeof(dense_vals[0]); + int nm = sizeof(medium_vals) / sizeof(medium_vals[0]); + + // Skip invalid combos where medium < dense (medium must >= dense to be consistent) + // Also skip dense >= 50 (not really "dense suppression" anymore) + + FILE* csv = fopen("sweep_results.csv", "w"); + if (csv) { + fprintf(csv, "dense_pct,medium_pct," + "fm50_dr,fm50_lat,fm50_air,fm50_tx," + "fm100_dr,fm100_lat,fm100_air,fm100_tx," + "chain20_dr,chain20_lat,chain20_air,chain20_tx," + "grid5x5_dr,grid5x5_lat,grid5x5_air,grid5x5_tx," + "score\n"); + } + + // First, establish DEFAULT baselines for scoring + printf("Running DEFAULT baselines...\n"); + g_dense_pct = 100; g_medium_pct = 100; // ADAPTIVE at 100% = effectively DEFAULT timing + SweepResult def_result{}; + def_result.dense_pct = 100; def_result.medium_pct = 100; + def_result.fm50 = runSweepTopo(sim::TestCase::TopoType::FULL_MESH, 50, 0, 0, 8.0f, NUM_FLOODS); + def_result.fm100 = runSweepTopo(sim::TestCase::TopoType::FULL_MESH,100, 0, 0, 8.0f, NUM_FLOODS); + def_result.chain20 = runSweepTopo(sim::TestCase::TopoType::CHAIN, 20, 0, 0, 8.0f, NUM_FLOODS); + def_result.grid5x5 = runSweepTopo(sim::TestCase::TopoType::GRID, 25, 5, 5, 8.0f, NUM_FLOODS); + def_result.score = 1.0f; + + printf(" DEFAULT baselines: FM50 dr=%.1f%% air=%llums | FM100 dr=%.1f%% air=%llums | " + "Chain20 dr=%.1f%% air=%llums | Grid5x5 dr=%.1f%% air=%llums\n\n", + def_result.fm50.dr*100, (long long)def_result.fm50.air_ms, + def_result.fm100.dr*100, (long long)def_result.fm100.air_ms, + def_result.chain20.dr*100, (long long)def_result.chain20.air_ms, + def_result.grid5x5.dr*100, (long long)def_result.grid5x5.air_ms); + + int total_points = 0; + for (int di = 0; di < nd; di++) + for (int mi = 0; mi < nm; mi++) + if (medium_vals[mi] >= dense_vals[di]) total_points++; + + printf("Sweeping %d combinations (dense × medium grid)...\n\n", total_points); + printf("%-6s %-8s | %-20s | %-20s | %-20s | %-20s | SCORE\n", + "DENSE", "MEDIUM", + "FM50 (dr%/air/tx)", "FM100 (dr%/air/tx)", + "Chain20 (dr%/air/tx)", "Grid5x5 (dr%/air/tx)"); + printf("%s\n", std::string(130, '-').c_str()); + + std::vector results; + int done = 0; + + for (int di = 0; di < nd; di++) { + for (int mi = 0; mi < nm; mi++) { + int dp = dense_vals[di]; + int mp = medium_vals[mi]; + if (mp < dp) continue; // medium must be >= dense + + g_dense_pct = dp; + g_medium_pct = mp; + + SweepResult sr{}; + sr.dense_pct = dp; + sr.medium_pct = mp; + sr.fm50 = runSweepTopo(sim::TestCase::TopoType::FULL_MESH, 50, 0, 0, 8.0f, NUM_FLOODS); + sr.fm100 = runSweepTopo(sim::TestCase::TopoType::FULL_MESH,100, 0, 0, 8.0f, NUM_FLOODS); + sr.chain20 = runSweepTopo(sim::TestCase::TopoType::CHAIN, 20, 0, 0, 8.0f, NUM_FLOODS); + sr.grid5x5 = runSweepTopo(sim::TestCase::TopoType::GRID, 25, 5, 5, 8.0f, NUM_FLOODS); + sr.score = computeScore(sr, def_result); + + done++; + printf("%-6d %-8d | %5.1f%% %7llums %3u | %5.1f%% %7llums %3u | %5.1f%% %7llums %3u | %5.1f%% %7llums %3u | %.4f\n", + dp, mp, + sr.fm50.dr*100, (long long)sr.fm50.air_ms, sr.fm50.total_tx, + sr.fm100.dr*100, (long long)sr.fm100.air_ms, sr.fm100.total_tx, + sr.chain20.dr*100, (long long)sr.chain20.air_ms, sr.chain20.total_tx, + sr.grid5x5.dr*100, (long long)sr.grid5x5.air_ms, sr.grid5x5.total_tx, + sr.score); + + if (csv) { + fprintf(csv, "%d,%d,%.4f,%.1f,%lld,%u,%.4f,%.1f,%lld,%u,%.4f,%.1f,%lld,%u,%.4f,%.1f,%lld,%u,%.4f\n", + dp, mp, + sr.fm50.dr, sr.fm50.lat_ms, (long long)sr.fm50.air_ms, sr.fm50.total_tx, + sr.fm100.dr, sr.fm100.lat_ms, (long long)sr.fm100.air_ms, sr.fm100.total_tx, + sr.chain20.dr, sr.chain20.lat_ms, (long long)sr.chain20.air_ms, sr.chain20.total_tx, + sr.grid5x5.dr, sr.grid5x5.lat_ms, (long long)sr.grid5x5.air_ms, sr.grid5x5.total_tx, + sr.score); + } + + results.push_back(sr); + } + } + + // Sort by score descending + std::sort(results.begin(), results.end(), + [](const SweepResult& a, const SweepResult& b) { return a.score > b.score; }); + + printf("\n\n=== TOP 10 COMBINATIONS BY COMPOSITE SCORE ===\n"); + printf("%-6s %-8s | FM50 dr%% | FM100 dr%% | Chain dr%% | Air vs DEFAULT | SCORE\n", "DENSE", "MEDIUM"); + printf("%s\n", std::string(90, '-').c_str()); + + int shown = 0; + for (auto& r : results) { + if (shown++ >= 10) break; + float fm50_air_pct = (float)r.fm50.air_ms / (float)def_result.fm50.air_ms * 100.0f - 100.0f; + float fm100_air_pct = (float)r.fm100.air_ms / (float)def_result.fm100.air_ms * 100.0f - 100.0f; + printf("%-6d %-8d | %7.1f%% | %8.1f%% | %8.1f%% | FM50:%+5.0f%% FM100:%+5.0f%% | %.4f\n", + r.dense_pct, r.medium_pct, + r.fm50.dr*100, r.fm100.dr*100, r.chain20.dr*100, + fm50_air_pct, fm100_air_pct, + r.score); + } + + printf("\n=== WINNER ===\n"); + if (!results.empty()) { + auto& w = results[0]; + printf(" DENSE_PCT = %d\n", w.dense_pct); + printf(" MEDIUM_PCT = %d\n", w.medium_pct); + printf(" SPARSE_PCT = 100 (fixed)\n"); + printf("\n FM50: dr=%.1f%%, airtime=%lldms (vs DEFAULT %lldms, %+.0f%%)\n", + w.fm50.dr*100, (long long)w.fm50.air_ms, (long long)def_result.fm50.air_ms, + (float)w.fm50.air_ms/(float)def_result.fm50.air_ms*100-100); + printf(" FM100: dr=%.1f%%, airtime=%lldms (vs DEFAULT %lldms, %+.0f%%)\n", + w.fm100.dr*100, (long long)w.fm100.air_ms, (long long)def_result.fm100.air_ms, + (float)w.fm100.air_ms/(float)def_result.fm100.air_ms*100-100); + printf(" Chain20: dr=%.1f%%, airtime=%lldms (vs DEFAULT %lldms, %+.0f%%)\n", + w.chain20.dr*100, (long long)w.chain20.air_ms, (long long)def_result.chain20.air_ms, + (float)w.chain20.air_ms/(float)def_result.chain20.air_ms*100-100); + printf(" Grid5x5: dr=%.1f%%, airtime=%lldms (vs DEFAULT %lldms, %+.0f%%)\n", + w.grid5x5.dr*100, (long long)w.grid5x5.air_ms, (long long)def_result.grid5x5.air_ms, + (float)w.grid5x5.air_ms/(float)def_result.grid5x5.air_ms*100-100); + } + + printf("\nCSV written to sweep_results.csv\n"); + if (csv) fclose(csv); + return 0; +} diff --git a/sim/scenarios/scenario_routing_comparison.cpp b/sim/scenarios/scenario_routing_comparison.cpp new file mode 100644 index 0000000000..6c6105d1c3 --- /dev/null +++ b/sim/scenarios/scenario_routing_comparison.cpp @@ -0,0 +1,86 @@ +#include "SimBus.h" +#include "TestRunner.h" +#include +#include + +// ------------------------------------------------------------------------- +// scenario_routing_comparison +// +// Sweeps all three routing strategies across FullMesh, Chain, and Grid +// topologies at multiple node counts and prints RESULT / DELTA tables. +// ------------------------------------------------------------------------- + +using sim::TestCase; +using sim::RoutingStrategy; + +static std::vector buildCases() { + std::vector cases; + + const float SNR = 8.0f; + const int FLOODS = 20; + + // Helper to generate all three strategies for one topology config + auto add = [&](const char* tag, + TestCase::TopoType topo, + int nodes, + int rows, int cols, + float snr) { + for (auto strat : { RoutingStrategy::DEFAULT, + RoutingStrategy::SNR_WEIGHTED, + RoutingStrategy::PATH_SNR_HYBRID }) { + TestCase tc; + char buf[64]; + const char* sname = + strat == RoutingStrategy::DEFAULT ? "DEF" : + strat == RoutingStrategy::SNR_WEIGHTED ? "SNR" : "HYB"; + snprintf(buf, sizeof(buf), "%s_%s_n%d", tag, sname, nodes); + tc.name = buf; + tc.topo = topo; + tc.num_nodes = nodes; + tc.channel_snr = snr; + tc.num_floods = FLOODS; + tc.strategy = strat; + tc.grid_rows = rows; + tc.grid_cols = cols; + cases.push_back(tc); + } + }; + + // FullMesh: 5, 10, 20 nodes + add("FM", TestCase::TopoType::FULL_MESH, 5, 0, 0, SNR); + add("FM", TestCase::TopoType::FULL_MESH, 10, 0, 0, SNR); + add("FM", TestCase::TopoType::FULL_MESH, 20, 0, 0, SNR); + + // Chain: 5, 10, 20 nodes + add("CH", TestCase::TopoType::CHAIN, 5, 0, 0, SNR); + add("CH", TestCase::TopoType::CHAIN, 10, 0, 0, SNR); + add("CH", TestCase::TopoType::CHAIN, 20, 0, 0, SNR); + + // Grid: 3x3 (9), 4x4 (16), 5x5 (25) + add("GR", TestCase::TopoType::GRID, 9, 3, 3, SNR); + add("GR", TestCase::TopoType::GRID, 16, 4, 4, SNR); + add("GR", TestCase::TopoType::GRID, 25, 5, 5, SNR); + + return cases; +} + +int main() { + printf("=======================================================\n"); + printf(" MeshCore Routing Strategy Comparison\n"); + printf(" Strategies: DEFAULT | SNR_WEIGHTED | PATH_SNR_HYBRID\n"); + printf(" Floods per run: 20\n"); + printf("=======================================================\n\n"); + + auto cases = buildCases(); + + sim::TestRunner runner; + auto results = runner.run(cases); + + runner.printComparison(results); + + // Also dump CSV for external analysis + runner.dumpCSV(results, "routing_comparison.csv"); + + printf("\nDone.\n"); + return 0; +} diff --git a/sim/scenarios/scenario_txpower.cpp b/sim/scenarios/scenario_txpower.cpp new file mode 100644 index 0000000000..b447f418ad --- /dev/null +++ b/sim/scenarios/scenario_txpower.cpp @@ -0,0 +1,280 @@ +// scenario_txpower.cpp +// Tests adaptive TX power reduction in dense areas. +// +// Hypothesis: In a dense full-mesh network, reducing TX power when in DENSE/MEDIUM +// tier provides: +// 1. Battery savings — lower TX current × same airtime = less energy +// 2. Reduced interference radius — quieter nodes don't stomp on distant clusters +// 3. Capture effect benefit — nearby nodes still win capture battles +// +// Counter-hypothesis: In a chain topology, TX power reduction kills hop range +// on marginal links and collapses delivery. +// +// Test matrix: +// Power save levels: OFF, CONSERVATIVE (-6dB DENSE), AGGRESSIVE (-10dB DENSE/-6dB MEDIUM) +// Topologies: FullMesh 50, FullMesh 100, Chain 20, Grid 5×5 +// Strategy: ADAPTIVE only (power-save only makes sense paired with hash-gate suppression) +// +// Energy model: SX1262 typical current draw +// +20 dBm → 120 mA TX 5.5 mA RX +// +14 dBm → 45 mA TX +// +10 dBm → 25 mA TX +// +// "Festival weekend" use case: 48-hour runtime estimate at each power level. + +#include "SimBus.h" +#include "SimMetrics.h" +#include "RoutingStrategies.h" +#include +#include +#include +#include + +using namespace sim; + +struct PowerResult { + const char* topo; + int num_nodes; + const char* power_mode; + float dense_dbm; + float medium_dbm; + float avg_delivery_rate; + float avg_latency_ms; + uint32_t total_tx; + uint32_t total_collisions; + float total_tx_energy_mah; // TX energy across all nodes + float total_rx_energy_mah; // RX energy across all nodes + float total_energy_mah; + float per_node_energy_mah; // average per node +}; + +struct PowerLevel { + const char* name; + float dense_dbm; + float medium_dbm; + bool enabled; +}; + +static PowerResult runPowerTest( + RFChannelModel* model, + const char* topo_name, + int num_nodes, int grid_rows, int grid_cols, + float snr, + const PowerLevel& plevel, + int num_floods) +{ + SimBus bus; + bus.tick_ms = 5; + + for (int i = 0; i < num_nodes; i++) { + char name[32]; + if (grid_rows > 0) + snprintf(name, sizeof(name), "n%d_%d", i/grid_cols, i%grid_cols); + else + snprintf(name, sizeof(name), "node%d", i); + bus.addNode(name, (uint32_t)(i + 1) * 0xdeadbeef); + } + bus.channel_model = model; + + for (auto& b : bus.nodes) { + b.node->routing_strategy = RoutingStrategy::ADAPTIVE; + b.node->power_save_enabled = plevel.enabled; + b.node->power_save_dense_dbm = plevel.dense_dbm; + b.node->power_save_medium_dbm = plevel.medium_dbm; + b.node->full_power_dbm = 20.0f; + b.radio->tx_power_dbm = 20.0f; + } + + // Warmup — prime density estimators. + // Need enough floods + time for relay storms to settle so density observations + // accumulate. FM100 needs more time — initial collision storm destroys most + // relay packets; later floods get through as relay timing spreads out. + uint64_t warmup_ms = grid_rows > 0 ? 8000 : 3000; + for (int i = 0; i < 3; i++) { + bus.sendFloodText(0, "warmup"); + bus.run(warmup_ms); + } + bus.resetStats(); + + uint64_t prop_ms = grid_rows > 0 ? 8000 : 5000; + for (int i = 0; i < num_floods; i++) { + bus.sendFloodText(0, "bench"); + bus.run(prop_ms); + } + + auto stats = bus.metrics.aggregate(num_floods); + + float tx_energy = 0, rx_energy = 0; + uint32_t total_tx = 0; + for (auto& b : bus.nodes) { + tx_energy += b.node->total_tx_energy_mah; + rx_energy += b.node->total_rx_energy_mah; + total_tx += b.node->total_tx_packets; + } + float total_energy = tx_energy + rx_energy; + + return { + topo_name, num_nodes, plevel.name, plevel.dense_dbm, plevel.medium_dbm, + stats.avg_delivery_rate, stats.avg_latency_ms, + total_tx, bus.totalCollisions(), + tx_energy, rx_energy, total_energy, + num_nodes > 0 ? total_energy / (float)num_nodes : 0.0f + }; +} + +static const char* fmtDbm(float dbm) { + static char buf[16]; + snprintf(buf, sizeof(buf), "%+.0fdBm", dbm - 20.0f); + return buf; +} + +int main() { + printf("MeshCore Adaptive TX Power Scenario\n"); + printf("=====================================\n"); + printf("ADAPTIVE strategy + variable TX power reduction in DENSE/MEDIUM tier\n"); + printf("Battery model: SX1262 typical current (120/45/25 mA at 20/14/10 dBm)\n\n"); + + FILE* csv = fopen("txpower_results.csv", "w"); + if (csv) + fprintf(csv, "topo,num_nodes,power_mode,dense_dbm,medium_dbm," + "avg_delivery_rate,avg_latency_ms,total_tx,total_collisions," + "total_tx_energy_mah,total_rx_energy_mah,total_energy_mah,per_node_energy_mah\n"); + + // DC6x6: dense 6×6 positional cluster — each node hears ~8 neighbors. + // Range=1.5, spacing=1.0 → most nodes see full 3×3 neighborhood. + // This models a festival grounds: dense, positional, realistic. + // FM50: FullMesh 50, the validated ADAPTIVE scenario. + // CH20: chain — verifies power-save doesn't hurt sparse hop range. + struct Config { + const char* name; int nodes, rows, cols; float snr; bool dense_cluster; + }; + Config configs[] = { + { "FM50", 50, 0, 0, 8.0f, false }, + { "DC6x6", 36, 6, 6, 8.0f, true }, // dense 6×6 positional cluster + { "CH20", 20, 0, 0, 8.0f, false }, + }; + + // Power save levels to test. + // Dense tier gets more aggressive reduction; medium tier gets conservative. + PowerLevel levels[] = { + { "FULL_PWR", 20.0f, 20.0f, false }, // baseline: no power save + { "CONSERV", 14.0f, 17.0f, true }, // conservative: -6dB DENSE, -3dB MEDIUM + { "MODERATE", 10.0f, 14.0f, true }, // moderate: -10dB DENSE, -6dB MEDIUM + { "AGGRESSIVE", 7.0f, 10.0f, true }, // aggressive: -13dB DENSE, -10dB MEDIUM + }; + + for (auto& cfg : configs) { + RFChannelModel* model = nullptr; + if (cfg.dense_cluster) { + // Dense positional cluster: range=1.5, spacing=1.0 + // Interior nodes each hear ~8 neighbors → DENSE tier (≥15 after relays) + auto* pm = new PositionalModel(1.5f, cfg.snr + 4.f, cfg.snr); + for (int r = 0; r < cfg.rows; r++) + for (int c = 0; c < cfg.cols; c++) + pm->addNode((float)c, (float)r); + model = pm; + } else if (cfg.name[0] == 'C') { + model = new ChainModel(cfg.snr); + } else { + model = new FullMeshModel(cfg.snr); + } + + printf("\n=== %s (%d nodes, SNR=%.0fdB) ===\n", cfg.name, cfg.nodes, cfg.snr); + printf(" %-12s %-14s | dr%% lat(ms) tx coll " + "tx_E(mAh) rx_E(mAh) tot_E(mAh) node_E(mAh) | Δdr Δenergy\n", + "mode", "dense/med pwr"); + + PowerResult baseline{}; + bool have_base = false; + + int num_floods = 20; + + for (auto& lvl : levels) { + auto r = runPowerTest(model, cfg.name, cfg.nodes, + cfg.rows, cfg.cols, cfg.snr, lvl, num_floods); + + char pwr_label[32]; + snprintf(pwr_label, sizeof(pwr_label), "%+.0f/%+.0fdBm", + lvl.dense_dbm - 20.0f, lvl.medium_dbm - 20.0f); + + if (!have_base) { + baseline = r; have_base = true; + printf(" %-12s %-14s | %5.1f%% %7.0f %-5u %-6u " + "%9.3f %9.3f %9.3f %9.3f\n", + lvl.name, pwr_label, + r.avg_delivery_rate*100.f, r.avg_latency_ms, + r.total_tx, r.total_collisions, + r.total_tx_energy_mah, r.total_rx_energy_mah, + r.total_energy_mah, r.per_node_energy_mah); + } else { + float dr_d = (r.avg_delivery_rate - baseline.avg_delivery_rate)*100.f; + float e_pct = baseline.total_energy_mah > 0 + ? (r.total_energy_mah - baseline.total_energy_mah) + / baseline.total_energy_mah * 100.f : 0.f; + const char* flag = r.avg_delivery_rate < baseline.avg_delivery_rate - 0.03f + ? " !!" : ""; + printf(" %-12s %-14s | %5.1f%% %7.0f %-5u %-6u " + "%9.3f %9.3f %9.3f %9.3f | %+5.1f%% %+6.1f%%%s\n", + lvl.name, pwr_label, + r.avg_delivery_rate*100.f, r.avg_latency_ms, + r.total_tx, r.total_collisions, + r.total_tx_energy_mah, r.total_rx_energy_mah, + r.total_energy_mah, r.per_node_energy_mah, + dr_d, e_pct, flag); + } + + if (csv) + fprintf(csv, "%s,%d,%s,%.1f,%.1f,%.4f,%.1f,%u,%u,%.4f,%.4f,%.4f,%.4f\n", + cfg.name, cfg.nodes, lvl.name, lvl.dense_dbm, lvl.medium_dbm, + r.avg_delivery_rate, r.avg_latency_ms, + r.total_tx, r.total_collisions, + r.total_tx_energy_mah, r.total_rx_energy_mah, + r.total_energy_mah, r.per_node_energy_mah); + } + + delete model; + } + + // Festival weekend projection: 48-hour runtime + printf("\n=== FESTIVAL WEEKEND PROJECTION (48-hour runtime) ===\n"); + printf("Assumptions: FM50, 1 flood/30s, SF8, SX1262 radio\n"); + printf("Battery: 2000 mAh (typical USB power bank to LoRa node)\n"); + printf("Floods/hour: 120 | TX per flood (ADAPTIVE DENSE): ~7 nodes\n\n"); + + // Per-node per-flood energy at each power level (TX component dominates) + struct FestEst { + const char* mode; + float tx_dbm; + float relay_fraction; // fraction of floods this node relays (DENSE hash gate) + }; + FestEst fests[] = { + { "FULL_PWR ", 20.0f, 0.15f }, + { "CONSERV ", 14.0f, 0.15f }, + { "MODERATE ", 10.0f, 0.15f }, + { "AGGRESSIVE", 7.0f, 0.15f }, + }; + + float airtime_s = 0.623f; // ~623ms per packet at SF8 BW62.5 + float floods_per_hour = 120.0f; + float rx_fraction = 1.0f; // node receives every flood (full mesh) + float battery_mah = 2000.0f; + + printf(" %-12s TX pwr TX mAh/hr RX mAh/hr Tot mAh/hr Hours\n", "Mode"); + for (auto& f : fests) { + float tx_ma = SimNode::txCurrentMa(f.tx_dbm); + float tx_mah_hr = tx_ma * floods_per_hour * f.relay_fraction * airtime_s / 3600.f; + float rx_mah_hr = SimNode::RX_CURRENT_MA * floods_per_hour * rx_fraction * airtime_s / 3600.f; + float tot_mah_hr = tx_mah_hr + rx_mah_hr; + float hours = battery_mah / tot_mah_hr; + printf(" %-12s %+.0fdBm %7.3f %7.3f %8.3f %5.0f hrs\n", + f.mode, f.tx_dbm - 20.0f, tx_mah_hr, rx_mah_hr, tot_mah_hr, hours); + } + + printf("\nNote: excludes MCU idle current (~10-30 mA) which dominates in practice.\n"); + printf("With MCU at 20mA continuous: add ~9.6 mAh/hr → max ~130hrs on 2000mAh.\n"); + printf("TX power reduction still meaningful: saves TX peak current spikes and heat.\n"); + + if (csv) fclose(csv); + printf("\nCSV written to txpower_results.csv\n"); + return 0; +} diff --git a/sim/src/DensityEstimator.h b/sim/src/DensityEstimator.h new file mode 100644 index 0000000000..71e79cd670 --- /dev/null +++ b/sim/src/DensityEstimator.h @@ -0,0 +1,78 @@ +#pragma once +#include +#include +#include + +// --------------------------------------------------------------------------- +// DensityEstimator — passive neighbor density measurement. +// +// Observes packets received directly (hop_count == 1) over a rolling time +// window and reports how many unique senders the node can hear. No extra +// protocol messages required — purely derived from normal traffic. +// +// Thresholds are configurable at build time: +// DENSITY_WINDOW_MS — observation window (default: 60 000 ms) +// DENSITY_SPARSE_MAX — neighbor count <= this → SPARSE (default: 4) +// DENSITY_DENSE_MIN — neighbor count >= this → DENSE (default: 15) +// Between the two thresholds → MEDIUM +// +// Usage (SimNode): +// density.observe(src_addr, hop_count, now_ms); // in logRx() +// auto tier = density.tier(now_ms); +// --------------------------------------------------------------------------- + +#ifndef DENSITY_WINDOW_MS +#define DENSITY_WINDOW_MS 60000u +#endif + +#ifndef DENSITY_SPARSE_MAX +#define DENSITY_SPARSE_MAX 4 +#endif + +#ifndef DENSITY_DENSE_MIN +#define DENSITY_DENSE_MIN 15 +#endif + +namespace sim { + +enum class DensityTier { SPARSE, MEDIUM, DENSE }; + +class DensityEstimator { +public: + // Call from logRx() for every received packet. + // src_addr: any unique identifier for the sending node (e.g. node index or hash). + // hop_count: only hop_count == 1 packets count toward density. + void observe(uint32_t src_addr, uint8_t hop_count, uint64_t now_ms) { + if (hop_count != 1) return; + _events.push_back({ src_addr, now_ms }); + evict(now_ms); + } + + // Unique direct-neighbor count seen within the window. + int neighborCount(uint64_t now_ms) { + evict(now_ms); + std::unordered_set seen; + for (auto& e : _events) seen.insert(e.src_addr); + return (int)seen.size(); + } + + DensityTier tier(uint64_t now_ms) { + int c = neighborCount(now_ms); + if (c <= DENSITY_SPARSE_MAX) return DensityTier::SPARSE; + if (c >= DENSITY_DENSE_MIN) return DensityTier::DENSE; + return DensityTier::MEDIUM; + } + +private: + struct Event { uint32_t src_addr; uint64_t ts_ms; }; + std::deque _events; + + void evict(uint64_t now_ms) { + uint64_t cutoff = now_ms > DENSITY_WINDOW_MS ? now_ms - DENSITY_WINDOW_MS : 0; + while (!_events.empty() && _events.front().ts_ms < cutoff) { + _events.pop_front(); + } + } +}; + +} // namespace sim diff --git a/sim/src/RoutingStrategies.h b/sim/src/RoutingStrategies.h new file mode 100644 index 0000000000..492da76025 --- /dev/null +++ b/sim/src/RoutingStrategies.h @@ -0,0 +1,143 @@ +#pragma once +#include +#include +#include "DensityEstimator.h" + +// ------------------------------------------------------------------------- +// RoutingStrategies.h — sim-only routing strategy definitions. +// +// These override getRetransmitDelay() in SimNode (which already overrides +// mesh::Mesh). Strategies are selected via a per-node enum field. +// +// NOT for the firmware src/ tree — these go through sim validation first. +// ------------------------------------------------------------------------- + +namespace sim { + +enum class RoutingStrategy { + DEFAULT, // MeshCore stock: random backoff scaled by airtime + SNR_WEIGHTED, // Shorter delay for better SNR; best node relays first + PATH_SNR_HYBRID,// Path length priority + SNR weighting combined + ADAPTIVE // Density-aware: switches strategy + p_forward per tier +}; + +// ------------------------------------------------------------------------- +// SNR quality factor: maps SNR (dB) to [0.0, 1.0] +// SNR <= -10 dB → 0.0 (worst link) +// SNR >= +15 dB → 1.0 (best link) +// ------------------------------------------------------------------------- +inline float snrQualityFactor(float snr) { + const float SNR_MIN = -10.0f; + const float SNR_MAX = 15.0f; + if (snr <= SNR_MIN) return 0.0f; + if (snr >= SNR_MAX) return 1.0f; + return (snr - SNR_MIN) / (SNR_MAX - SNR_MIN); +} + +// ------------------------------------------------------------------------- +// Compute retransmit delay for SNR_WEIGHTED strategy. +// +// base_delay = airtime * 52/50 / 2 (same as DEFAULT base) +// quality = snrQualityFactor(snr) +// delay = base_delay * (1.0 - quality * 0.8) +// + random jitter [0, 0.2 * base_delay] +// +// Best-SNR node gets up to 80% delay reduction. Small random component +// [0-20% of base] prevents simultaneous transmissions at equal SNR. +// ------------------------------------------------------------------------- +inline uint32_t snrWeightedDelay(uint32_t base_delay, float snr, uint32_t rand_val) { + float q = snrQualityFactor(snr); + float scale = 1.0f - q * 0.8f; // [0.2, 1.0] + float jitter = (float)(rand_val % 100) / 100.0f * 0.2f * (float)base_delay; + uint32_t d = (uint32_t)((float)base_delay * scale + jitter); + return d; +} + +// ------------------------------------------------------------------------- +// Compute retransmit delay for PATH_SNR_HYBRID strategy. +// +// path_factor = 1.0 / (1.0 + path_hops) shorter path → higher priority +// quality = snrQualityFactor(snr) +// combined = (path_factor + quality) / 2 [0, 1] +// delay = base_delay * (1.0 - combined * 0.85) +// + random jitter [0, 0.15 * base_delay] +// +// Nodes with short paths AND good SNR relay first. Jitter is tighter than +// SNR_WEIGHTED to sharpen the path-priority signal. +// ------------------------------------------------------------------------- +inline uint32_t pathSnrHybridDelay(uint32_t base_delay, float snr, uint8_t path_hops, uint32_t rand_val) { + float path_factor = 1.0f / (1.0f + (float)path_hops); // (0, 1] + float q = snrQualityFactor(snr); + float combined = (path_factor + q) * 0.5f; // [0, 1] + float scale = 1.0f - combined * 0.85f; // [0.15, 1.0] + float jitter = (float)(rand_val % 100) / 100.0f * 0.15f * (float)base_delay; + uint32_t d = (uint32_t)((float)base_delay * scale + jitter); + return d; +} + +// ------------------------------------------------------------------------- +// Compute retransmit delay for ADAPTIVE strategy. +// +// Tier dispatch: +// SPARSE → SNR_WEIGHTED (relay selection matters; minimize hops) +// MEDIUM → PATH_SNR_HYBRID (balanced path + SNR priority) +// DENSE → long random backoff, p_forward already gates most relays +// +// The DENSE backoff spreads remaining transmitters out so they don't +// collide. Scale factor [2.0, 4.0] × base gives each surviving relay +// a long, well-separated window. +// ------------------------------------------------------------------------- +inline uint32_t adaptiveDelay(uint32_t base_delay, DensityTier tier, + float snr, uint8_t path_hops, uint32_t rand_val) { + switch (tier) { + case DensityTier::SPARSE: + return snrWeightedDelay(base_delay, snr, rand_val); + + case DensityTier::MEDIUM: + return pathSnrHybridDelay(base_delay, snr, path_hops, rand_val); + + case DensityTier::DENSE: + // Survivors (passed p_forward gate) use normal random backoff. + // Don't inflate delay here — that only extends channel occupancy time + // without reducing it. Suppression in logRx() handles the real cull. + return (uint32_t)(rand_val % 5) * base_delay; + } + return base_delay; +} + +// ------------------------------------------------------------------------- +// Hash-based relay selection — deterministic, tunable, zero-coordination. +// +// Instead of a random roll (which has per-flood variance), we use: +// hash(packet_seed XOR node_seed) % 100 < relay_pct +// +// This gives exactly `relay_pct`% of nodes forwarding any given packet, +// consistently across all floods. Each node independently derives the same +// result without communicating. +// +// relay_pct: +// SPARSE: 100 (all forward — every relay matters in sparse topology) +// MEDIUM: 25 (enough redundancy; cuts ~75% of surplus relays) +// DENSE: 15 (minimal relay set; 85% suppression; handles 2+ concurrent floods) +// ------------------------------------------------------------------------- +inline bool hashBasedRelay(uint32_t packet_seed, uint32_t node_seed, int relay_pct) { + // Simple mix: XOR + multiply-shift hash + uint32_t h = (packet_seed ^ node_seed) * 0x9e3779b9u; + h ^= h >> 16; + return (int)(h % 100) < relay_pct; +} + +// Empirically validated via scenario_relay_pct_sweep.cpp + scenario_concurrent.cpp: +// DENSE=10, MEDIUM=20 → optimal for single-source flooding. +// DENSE=15, MEDIUM=25 → concurrent-flood resilient, ~85%/75% TX reduction. +// Below DENSE=10 risks under-coverage in concurrent scenarios (FM50/2-sender: 2% DR). +inline int adaptiveRelayPct(DensityTier tier) { + switch (tier) { + case DensityTier::SPARSE: return 100; + case DensityTier::MEDIUM: return 25; + case DensityTier::DENSE: return 15; + } + return 100; +} + +} // namespace sim diff --git a/sim/src/SimBus.h b/sim/src/SimBus.h new file mode 100644 index 0000000000..911a8a48b3 --- /dev/null +++ b/sim/src/SimBus.h @@ -0,0 +1,283 @@ +#pragma once +#include "SimNode.h" +#include "SimRadio.h" +#include "SimMetrics.h" +#include +#include +#include +#include +#include + +namespace sim { + +// ------------------------------------------------------------------------- +// Pending RF event on the bus — a packet in-flight between nodes. +// ------------------------------------------------------------------------- +struct InFlightPacket { + int src_node; + uint8_t bytes[255]; + int len; + uint64_t arrive_at_ms; // when it becomes receivable + float tx_power_dbm; // transmitter power — applied as SNR offset at receivers +}; + +// ------------------------------------------------------------------------- +// SimBus — the virtual RF medium. +// +// Owns all nodes and their supporting objects. Drives time forward tick +// by tick, delivers packets between nodes according to the channel model, +// and collects aggregate metrics. +// ------------------------------------------------------------------------- +class SimBus { +public: + SimClock clock; + + struct NodeBundle { + std::unique_ptr ms; + std::unique_ptr rtc; + std::unique_ptr rng; + std::unique_ptr mgr; + std::unique_ptr tables; + std::unique_ptr radio; + std::unique_ptr node; + }; + + std::vector nodes; + RFChannelModel* channel_model = nullptr; + SimMetrics metrics; + + // Propagation delay in ms (default: 0 = instantaneous) + uint32_t propagation_delay_ms = 0; + + // Tick resolution — how many ms the sim advances each step + uint32_t tick_ms = 1; + + int _flood_seq = 0; + + uint32_t totalCollisions() const { + uint32_t n = 0; + for (auto& b : nodes) n += b.radio->total_collisions; + return n; + } + + // ----------------------------------------------------------------------- + // Add a node to the bus. Returns the node index. + // ----------------------------------------------------------------------- + int addNode(const std::string& name, uint32_t rng_seed = 0) { + int idx = (int)nodes.size(); + auto& b = nodes.emplace_back(); + + b.ms = std::make_unique(clock); + b.rtc = std::make_unique(clock); + b.rng = std::make_unique(rng_seed ? rng_seed : (uint32_t)(idx * 0x9e3779b9 + 1)); + b.mgr = std::make_unique(clock); + b.tables= std::make_unique(); + + // Radio tx callback: inject into bus + auto cb = [this, idx](int src, const uint8_t* bytes, int len, uint32_t airtime_ms, float tx_power_dbm) { + this->onTransmit(src, bytes, len, airtime_ms, tx_power_dbm); + }; + b.radio = std::make_unique(idx, cb); + b.node = std::make_unique(idx, name, *b.radio, *b.ms, *b.rng, *b.rtc, *b.mgr, *b.tables); + b.node->init(); + + // Wire delivery events into metrics using (src_key, timestamp) as flood ID + int idx_capture = idx; + b.node->on_recv = [this, idx_capture](SimNode* n, const DeliveryEvent& ev) { + uint64_t compound = ((uint64_t)n->last_advert_src_key << 32) | n->last_advert_ts; + auto it = _tsnode_to_seq.find(compound); + int seq = it != _tsnode_to_seq.end() ? it->second : -1; + if (seq < 0) return; // not a tracked flood + uint64_t inj = _inject_times.count(seq) ? _inject_times[seq] : 0; + auto src_it = _seq_to_src.find(seq); + int src = src_it != _seq_to_src.end() ? src_it->second : -1; + metrics.record(src, idx_capture, seq, inj, + (uint64_t)n->_ms_ref.getMillis(), + ev.hop_count, ev.route_type, ev.snr, ev.airtime_ms); + }; + + metrics.setNumNodes((int)nodes.size()); + return idx; + } + + // ----------------------------------------------------------------------- + // Run simulation for `duration_ms` of simulated time. + // ----------------------------------------------------------------------- + void run(uint64_t duration_ms) { + uint64_t end = clock.now() + duration_ms; + while (clock.now() < end) { + // Reset per-tick collision state so each tick starts clean + for (auto& b : nodes) b.radio->tickReset(); + // Deliver in-flight packets, then loop all nodes, then deliver again + // (so newly injected packets from loop() can be received this tick) + deliverPending(); + for (auto& b : nodes) b.node->loop(); + deliverPending(); + clock.advance(tick_ms); + } + } + + // ----------------------------------------------------------------------- + // Inject a raw advertisement flood from a specific node. + // ----------------------------------------------------------------------- + void sendAdvert(int node_idx) { + auto& b = nodes[node_idx]; + auto* pkt = b.node->createAdvert(b.node->self_id); + if (pkt) b.node->sendFlood(pkt); + } + + // ----------------------------------------------------------------------- + // Inject a flood advertisement from src_node (used to benchmark flood propagation). + // Adverts are the canonical flood packet type in MeshCore. + // ----------------------------------------------------------------------- + void sendFloodText(int node_idx, const char* text) { + auto& b = nodes[node_idx]; + uint8_t app_data[MAX_ADVERT_DATA_SIZE]; + size_t len = strlen(text); + if (len > MAX_ADVERT_DATA_SIZE) len = MAX_ADVERT_DATA_SIZE; + memcpy(app_data, text, len); + auto* pkt = b.node->createAdvert(b.node->self_id, app_data, len); + if (pkt) { + // Read advert timestamp (offset PUB_KEY_SIZE in payload) and sender pub key prefix. + // Use compound key (src_key<<32|ts) so simultaneous senders with same-second RTC + // timestamps don't collide in the tracking map. + uint32_t ts; + memcpy(&ts, &pkt->payload[PUB_KEY_SIZE], 4); + uint32_t src_key = 0; + memcpy(&src_key, b.node->self_id.pub_key, 4); + uint64_t compound = ((uint64_t)src_key << 32) | ts; + int seq = _flood_seq++; + _ts_to_seq[ts] = seq; // kept for single-source compat + _tsnode_to_seq[compound] = seq; + _seq_to_src[seq] = node_idx; + _inject_times[seq] = clock.now(); + metrics.setNumNodes((int)nodes.size()); + b.node->sendFlood(pkt); + } + } + + // ----------------------------------------------------------------------- + // Reset all node stats for a fresh measurement window. + // ----------------------------------------------------------------------- + void resetStats() { + for (auto& b : nodes) { + b.node->total_tx_packets = 0; + b.node->total_rx_packets = 0; + b.node->total_duplicates = 0; + b.node->total_airtime_ms = 0; + b.node->total_suppressed = 0; + b.node->total_tx_energy_mah = 0.0f; + b.node->total_rx_energy_mah = 0.0f; + b.node->total_rx_time_ms = 0; + b.node->deliveries.clear(); + b.radio->total_collisions = 0; + b.tables->reset(); + } + metrics.reset(); + _flood_seq = 0; + _ts_to_seq.clear(); + _tsnode_to_seq.clear(); + _seq_to_src.clear(); + _inject_times.clear(); + } + + // ----------------------------------------------------------------------- + // Print a summary report to stdout. + // ----------------------------------------------------------------------- + void printReport(const char* scenario_name = "sim") const { + printf("\n=== SimBus Report: %s ===\n", scenario_name); + printf("%-12s %8s %8s %8s %8s %8s\n", + "Node", "TX", "RX", "Dups", "Delivs", "AirtimeMs"); + printf("%-12s %8s %8s %8s %8s %8s\n", + "----", "--", "--", "----", "------", "---------"); + + uint32_t total_tx = 0, total_rx = 0, total_deliveries = 0; + uint64_t total_air = 0; + + for (auto& b : nodes) { + auto& n = *b.node; + printf("%-12s %8u %8u %8u %8zu %8llu\n", + n.name.c_str(), n.total_tx_packets, n.total_rx_packets, + n.total_duplicates, n.deliveries.size(), (unsigned long long)n.total_airtime_ms); + total_tx += n.total_tx_packets; + total_rx += n.total_rx_packets; + total_deliveries += (uint32_t)n.deliveries.size(); + total_air += n.total_airtime_ms; + } + + printf("%-12s %8u %8u %8s %8u %8llu\n", + "TOTAL", total_tx, total_rx, "-", total_deliveries, + (unsigned long long)total_air); + + // Delivery rate: out of (num_nodes - 1) possible receivers per flood + int n = (int)nodes.size(); + if (n > 1 && total_tx > 0) { + // count unique flood origins (TX from each node once = 1 event) + float rate = (float)total_deliveries / (float)(total_tx * (n - 1)) * 100.0f; + printf("\nEstimated delivery rate: %.1f%%\n", rate); + } + + printf("Total simulated airtime: %llu ms\n", (unsigned long long)total_air); + printf("=====================================\n"); + } + + // ----------------------------------------------------------------------- + // Dump all delivery events as CSV to a file or stdout. + // ----------------------------------------------------------------------- + void dumpCSV(FILE* f = stdout) const { + fprintf(f, "recv_node,payload_type,route_type,hop_count,snr,rx_time_ms,airtime_ms\n"); + for (auto& b : nodes) { + for (auto& ev : b.node->deliveries) { + fprintf(f, "%d,%u,%u,%u,%.2f,%llu,%u\n", + ev.recv_node, ev.payload_type, ev.route_type, + ev.hop_count, ev.snr, + (unsigned long long)ev.rx_time_ms, ev.airtime_ms); + } + } + } + +protected: + std::vector _in_flight; + std::map _ts_to_seq; // advert timestamp -> flood seq (single-sender; prefer _tsnode_to_seq) + std::map _tsnode_to_seq; // (src_node<<32|advert_ts) -> flood seq (concurrent-safe) + std::map _seq_to_src; // flood seq -> src node_idx + std::map _inject_times; // flood_seq -> inject time + + void onTransmit(int src, const uint8_t* bytes, int len, uint32_t airtime_ms, float tx_power_dbm) { + if (len > 255) len = 255; // LoRa hard cap; guard against oversized packets + InFlightPacket ifp; + ifp.src_node = src; + memcpy(ifp.bytes, bytes, len); + ifp.len = len; + ifp.arrive_at_ms = clock.now() + propagation_delay_ms + airtime_ms; + ifp.tx_power_dbm = tx_power_dbm; + _in_flight.push_back(ifp); + } + + void deliverPending() { + uint64_t now = clock.now(); + auto it = _in_flight.begin(); + while (it != _in_flight.end()) { + if (it->arrive_at_ms <= now) { + // TX power offset: deviation from reference 20 dBm shifts received SNR linearly. + float power_offset_db = it->tx_power_dbm - 20.0f; + for (auto& b : nodes) { + int dst = b.node->node_idx; + if (dst == it->src_node) { continue; } + if (!channel_model || channel_model->canReceive(it->src_node, dst)) { + float snr = channel_model + ? channel_model->receivedSNR(it->src_node, dst, 8.0f) + : 8.0f; + snr += power_offset_db; // lower TX power → lower received SNR + b.radio->injectRecv(it->bytes, it->len, snr, it->src_node); + } + } + it = _in_flight.erase(it); + } else { + ++it; + } + } + } +}; + +} diff --git a/sim/src/SimMetrics.h b/sim/src/SimMetrics.h new file mode 100644 index 0000000000..d0dab60bfe --- /dev/null +++ b/sim/src/SimMetrics.h @@ -0,0 +1,187 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace sim { + +// Per-flood summary: one entry per packet injected into the network. +struct FloodSummary { + int src_node; + int flood_seq; // injection sequence number + uint64_t inject_time_ms; + int num_nodes; // total nodes in sim + int num_received; // how many other nodes received it + float delivery_rate; // num_received / (num_nodes - 1) + uint32_t min_latency_ms; + uint32_t max_latency_ms; + float avg_latency_ms; + float min_hop; + float max_hop; + float avg_hop; + uint64_t total_airtime_ms; // cumulative airtime across all relays for this flood +}; + +// Aggregate stats across all floods in a scenario run. +struct ScenarioStats { + int num_floods; + float avg_delivery_rate; + float avg_latency_ms; + float avg_hops; + uint64_t total_airtime_ms; + uint32_t total_tx; + uint32_t total_rx; +}; + +// ------------------------------------------------------------------------- +// SimMetrics — collects per-injection delivery records and produces reports. +// Attach to a SimBus by tracking flood injection times externally, then +// call record() when a node reports receipt. +// ------------------------------------------------------------------------- +class SimMetrics { +public: + struct Receipt { + int src_node; + int recv_node; + int flood_seq; + uint64_t inject_ms; + uint64_t recv_ms; + uint32_t latency_ms; + uint8_t hop_count; + uint8_t route_type; + float snr; + uint32_t airtime_ms; + }; + + std::vector receipts; + int _num_nodes = 0; + + void setNumNodes(int n) { _num_nodes = n; } + + void record(int src, int recv, int seq, uint64_t inject_ms, uint64_t recv_ms, + uint8_t hops, uint8_t route, float snr, uint32_t airtime_ms) { + Receipt r; + r.src_node = src; + r.recv_node = recv; + r.flood_seq = seq; + r.inject_ms = inject_ms; + r.recv_ms = recv_ms; + r.latency_ms = (uint32_t)(recv_ms > inject_ms ? recv_ms - inject_ms : 0); + r.hop_count = hops; + r.route_type = route; + r.snr = snr; + r.airtime_ms = airtime_ms; + receipts.push_back(r); + } + + void reset() { receipts.clear(); } + + // ----------------------------------------------------------------------- + // Per-flood breakdown + // ----------------------------------------------------------------------- + std::vector buildFloodSummaries(int num_floods) const { + std::vector summaries; + for (int seq = 0; seq < num_floods; seq++) { + FloodSummary fs{}; + fs.flood_seq = seq; + fs.num_nodes = _num_nodes; + fs.min_latency_ms = UINT32_MAX; + fs.min_hop = 255; + + std::vector lats; + std::vector hops; + uint64_t airtime_sum = 0; + + for (auto& r : receipts) { + if (r.flood_seq != seq) continue; + if (fs.inject_time_ms == 0) { fs.inject_time_ms = r.inject_ms; fs.src_node = r.src_node; } + lats.push_back(r.latency_ms); + hops.push_back(r.hop_count); + airtime_sum += r.airtime_ms; + fs.num_received++; + if (r.latency_ms < fs.min_latency_ms) fs.min_latency_ms = r.latency_ms; + if (r.latency_ms > fs.max_latency_ms) fs.max_latency_ms = r.latency_ms; + if (r.hop_count < fs.min_hop) fs.min_hop = r.hop_count; + if (r.hop_count > fs.max_hop) fs.max_hop = r.hop_count; + } + + int possible = _num_nodes - 1; + fs.delivery_rate = possible > 0 ? (float)fs.num_received / (float)possible : 0.0f; + fs.total_airtime_ms = airtime_sum; + + if (!lats.empty()) { + fs.avg_latency_ms = (float)std::accumulate(lats.begin(), lats.end(), 0ULL) / lats.size(); + fs.avg_hop = (float)std::accumulate(hops.begin(), hops.end(), 0) / hops.size(); + } + if (fs.min_latency_ms == UINT32_MAX) fs.min_latency_ms = 0; + + summaries.push_back(fs); + } + return summaries; + } + + ScenarioStats aggregate(int num_floods) const { + auto summaries = buildFloodSummaries(num_floods); + ScenarioStats s{}; + s.num_floods = num_floods; + if (summaries.empty()) return s; + + float dr_sum = 0, lat_sum = 0, hop_sum = 0; + for (auto& fs : summaries) { + dr_sum += fs.delivery_rate; + lat_sum += fs.avg_latency_ms; + hop_sum += fs.avg_hop; + s.total_airtime_ms += fs.total_airtime_ms; + } + s.avg_delivery_rate = dr_sum / summaries.size(); + s.avg_latency_ms = lat_sum / summaries.size(); + s.avg_hops = hop_sum / summaries.size(); + + for (auto& r : receipts) { + s.total_rx++; + } + return s; + } + + // ----------------------------------------------------------------------- + // Print human-readable table + // ----------------------------------------------------------------------- + void printReport(const char* label, int num_floods) const { + auto s = aggregate(num_floods); + printf("\n=== Metrics: %s ===\n", label); + printf(" Floods injected: %d\n", num_floods); + printf(" Avg delivery rate: %.1f%%\n", s.avg_delivery_rate * 100.0f); + printf(" Avg latency: %.0f ms\n", s.avg_latency_ms); + printf(" Avg hops: %.1f\n", s.avg_hops); + printf(" Total airtime: %llu ms\n", (unsigned long long)s.total_airtime_ms); + printf("\n Per-flood breakdown:\n"); + printf(" %-6s %-8s %-12s %-12s %-8s %-10s\n", + "Seq", "Recvd", "DelivRate%", "AvgLat(ms)", "AvgHop", "Air(ms)"); + for (auto& fs : buildFloodSummaries(num_floods)) { + printf(" %-6d %-8d %-12.1f %-12.0f %-8.1f %-10llu\n", + fs.flood_seq, fs.num_received, fs.delivery_rate * 100.0f, + fs.avg_latency_ms, fs.avg_hop, + (unsigned long long)fs.total_airtime_ms); + } + printf("=================================\n"); + } + + // ----------------------------------------------------------------------- + // Dump CSV for external analysis + // ----------------------------------------------------------------------- + void dumpCSV(FILE* f = stdout) const { + fprintf(f, "flood_seq,src_node,recv_node,latency_ms,hop_count,route_type,snr,airtime_ms\n"); + for (auto& r : receipts) { + fprintf(f, "%d,%d,%d,%u,%u,%u,%.2f,%u\n", + r.flood_seq, r.src_node, r.recv_node, + r.latency_ms, r.hop_count, r.route_type, + r.snr, r.airtime_ms); + } + } +}; + +} diff --git a/sim/src/SimNode.h b/sim/src/SimNode.h new file mode 100644 index 0000000000..97c1ffe879 --- /dev/null +++ b/sim/src/SimNode.h @@ -0,0 +1,321 @@ +#pragma once +#include +#include +#include "SimRuntime.h" +#include "SimRadio.h" +#include "RoutingStrategies.h" +#include "DensityEstimator.h" +#include +#include +#include + +namespace sim { + +// ------------------------------------------------------------------------- +// Per-packet delivery event — recorded whenever a node receives a message. +// ------------------------------------------------------------------------- +struct DeliveryEvent { + int src_node; + int dst_node; // -1 = broadcast/flood + int recv_node; + uint8_t payload_type; + uint8_t route_type; + uint8_t hop_count; + float snr; + float rssi; + uint64_t tx_time_ms; + uint64_t rx_time_ms; + uint32_t latency_ms; + uint32_t airtime_ms; + bool is_duplicate; +}; + +// ------------------------------------------------------------------------- +// SimNode — a full MeshCore node running in simulation. +// Subclasses Mesh to intercept packets and record metrics. +// ------------------------------------------------------------------------- +class SimNode : public mesh::Mesh { +public: + int node_idx; + std::string name; + + // Routing strategy — set before first flood, after addNode() + RoutingStrategy routing_strategy = RoutingStrategy::DEFAULT; + + // Probabilistic forwarding — probability [0,1] that this node relays a flood. + // 1.0 = always forward (default). Values < 1.0 randomly drop relays to reduce + // broadcast storms at high node density. ADAPTIVE strategy overrides this + // dynamically each packet based on current density tier. + float p_forward = 1.0f; + + // Passive neighbor density estimator — fed by logRx(), consumed by getRetransmitDelay(). + DensityEstimator density; + + // Metrics + std::vector deliveries; + uint32_t total_tx_packets = 0; + uint32_t total_rx_packets = 0; + uint32_t total_duplicates = 0; + uint64_t total_airtime_ms = 0; + uint32_t total_suppressed = 0; // relay cancellations from suppression logic + + // last advert tracking — used by SimBus to match receipts to injections + uint32_t last_advert_ts = 0; + uint32_t last_advert_src_key = 0; // first 4 bytes of sender pub key (unique per node) + + // Callback: called when any message payload arrives at this node + using OnRecvCallback = std::function; + OnRecvCallback on_recv; + + SimNode(int idx, const std::string& name, + SimRadio& radio, SimMillisClock& ms, SimRNG& rng, + SimRTCClock& rtc, SimPacketManager& mgr, SimTables& tables) + : mesh::Mesh(radio, ms, rng, rtc, mgr, tables) + , node_idx(idx), name(name) + , _radio_ref(radio), _ms_ref(ms), _rng_ref(rng), _mgr_ref(mgr), _tables_ref(tables) + {} + + void init() { + // LocalIdentity(RNG*) generates a fresh Ed25519 keypair using the node's seeded RNG + self_id = mesh::LocalIdentity(&_rng_ref); + begin(); + } + + bool allowPacketForward(const mesh::Packet* pkt) override { + return true; // repeater mode — all nodes forward + } + + // Duty cycle enforcement — override to set budget factor. + // duty_cycle = 1 / (1 + factor): + // factor=1 → 50% (default, no enforcement) + // factor=99 → 1% (EU legal limit) + // factor=9 → 10% (relaxed US usage) + // Default: no enforcement (matches stock MeshCore behaviour). + float duty_cycle_factor = 1.0f; // set externally to enforce a limit + float getAirtimeBudgetFactor() const override { return duty_cycle_factor; } + + // ----------------------------------------------------------------------- + // Power-save mode — reduces TX power when density is DENSE or MEDIUM. + // + // power_save_enabled: activates adaptive TX power reduction + // power_save_dense_dbm: TX power (dBm) when DENSE (default 20 = full power) + // power_save_medium_dbm: TX power (dBm) when MEDIUM + // + // Typical LoRa TX current: + // +20 dBm → ~120 mA (SX1262 PA boost) + // +17 dBm → ~90 mA + // +14 dBm → ~45 mA + // +10 dBm → ~25 mA + // + // Battery model: energy_mah = sum over all TX of (tx_current_mA × airtime_s / 3600) + // + rx_current_mA × total_rx_time_s / 3600 + // ----------------------------------------------------------------------- + bool power_save_enabled = false; + float power_save_dense_dbm = 10.0f; // -10 dBm from 20 dBm max + float power_save_medium_dbm = 14.0f; // -6 dBm from 20 dBm max + float full_power_dbm = 20.0f; // reference full power + + // Energy accounting (milliamp-hours consumed) + float total_tx_energy_mah = 0.0f; + float total_rx_energy_mah = 0.0f; + uint64_t total_rx_time_ms = 0; // time spent in RX mode (not TX) + + // Current draw model (mA) — SX1262-based typical values + static float txCurrentMa(float dbm) { + if (dbm >= 20.0f) return 120.0f; + if (dbm >= 17.0f) return 90.0f; + if (dbm >= 14.0f) return 45.0f; + if (dbm >= 10.0f) return 25.0f; + return 15.0f; // low power / beacon mode + } + static constexpr float RX_CURRENT_MA = 5.5f; // SX1262 in RX continuous + static constexpr float IDLE_CURRENT_MA = 1.6f; // SX1262 sleep/standby + +protected: + // ----------------------------------------------------------------------- + // Routing strategy dispatch — overrides base mesh::Mesh backoff. + // ----------------------------------------------------------------------- + uint32_t getRetransmitDelay(const mesh::Packet* pkt) override { + uint64_t now_ms = (uint64_t)_ms_ref.getMillis(); + + if (routing_strategy == RoutingStrategy::ADAPTIVE) { + DensityTier tier = density.tier(now_ms); + int relay_pct = adaptiveRelayPct(tier); + + if (relay_pct < 100) { + // Deterministic hash-based relay selection. + // packet_seed: first 4 bytes of payload (stable across all recipients). + // node_seed: stable per-node identity (pub key prefix in firmware). + uint32_t pkt_seed = 0, node_seed = (uint32_t)node_idx * 0x9e3779b9u; + if (pkt->getRawLength() >= 4) memcpy(&pkt_seed, pkt->payload, 4); + + if (!hashBasedRelay(pkt_seed, node_seed, relay_pct)) { + return 999999u; // not selected — silent + } + } + + // Power-save: selected relay nodes reduce TX power based on density tier. + // This lowers received SNR at distant nodes (shrinks interference radius) + // while nearby nodes still receive well above LoRa sensitivity. + if (power_save_enabled) { + switch (tier) { + case DensityTier::DENSE: + _radio_ref.tx_power_dbm = power_save_dense_dbm; + break; + case DensityTier::MEDIUM: + _radio_ref.tx_power_dbm = power_save_medium_dbm; + break; + case DensityTier::SPARSE: + _radio_ref.tx_power_dbm = full_power_dbm; + break; + } + } + } + + // Non-ADAPTIVE strategies always transmit at full power + if (routing_strategy != RoutingStrategy::ADAPTIVE) { + _radio_ref.tx_power_dbm = full_power_dbm; + } + + // Manual p_forward gate (for non-ADAPTIVE strategies or explicit p_forward override) + if (routing_strategy != RoutingStrategy::ADAPTIVE && p_forward < 1.0f) { + uint32_t roll = _rng_ref.nextInt(0, 10000); + if ((float)roll / 10000.0f > p_forward) { + return 999999u; + } + } + + uint32_t base = (_radio_ref.getEstAirtimeFor(pkt->getRawLength()) * 52 / 50) / 2; + if (base == 0) base = 10; + + float snr = _radio_ref.getLastSNR(); + uint8_t hops = pkt->getPathHashCount(); + uint32_t rv = _rng_ref.nextInt(0, 10000); + + switch (routing_strategy) { + case RoutingStrategy::SNR_WEIGHTED: + return snrWeightedDelay(base, snr, rv); + + case RoutingStrategy::PATH_SNR_HYBRID: + return pathSnrHybridDelay(base, snr, hops, rv); + + case RoutingStrategy::ADAPTIVE: + return adaptiveDelay(base, density.tier(now_ms), snr, hops, rv); + + case RoutingStrategy::DEFAULT: + default: + return _rng_ref.nextInt(0, 5) * base; + } + } + + + void logRx(mesh::Packet* pkt, int len, float score) override { + total_rx_packets++; + uint32_t at = _radio_ref.getEstAirtimeFor(len); + total_airtime_ms += at; + total_rx_time_ms += at; + total_rx_energy_mah += RX_CURRENT_MA * (float)at / 3600000.0f; + + if (!pkt || len < 4) return; + + uint64_t now_ms = (uint64_t)_ms_ref.getMillis(); + + // Feed density estimator — use the actual transmitting node's ID so each + // relay of the same flood counts as a separate sender observation. + // In firmware, this would use a hash of the received frame's sync word or + // a time-of-arrival fingerprint; in sim we have the exact sender index. + { + int sender = _radio_ref.getLastSender(); + uint32_t src_id = sender >= 0 ? (uint32_t)sender : 0u; + density.observe(src_id, pkt->getPathHashCount(), now_ms); + } + + // Relay suppression — cancel a queued outbound if we hear another node + // successfully relay the same flood (hash-matched). Works for all strategies. + // In ADAPTIVE, the hash gate already prevents most nodes from queuing at all; + // this catches the edge case where two selected relays race. + if (pkt->isRouteFlood()) { + uint8_t pkt_hash[MAX_HASH_SIZE]; + pkt->calculatePacketHash(pkt_hash); + + int total = _mgr_ref.getOutboundTotal(); + for (int i = total - 1; i >= 0; i--) { + mesh::Packet* queued = _mgr_ref.getOutboundByIdx(i); + if (!queued) continue; + uint8_t q_hash[MAX_HASH_SIZE]; + queued->calculatePacketHash(q_hash); + if (memcmp(pkt_hash, q_hash, MAX_HASH_SIZE) == 0) { + _mgr_ref.removeOutboundByIdx(i); + total_suppressed++; + break; + } + } + } + } + + void logTx(mesh::Packet* pkt, int len) override { + total_tx_packets++; + uint32_t at = _radio_ref.getEstAirtimeFor(len); + total_airtime_ms += at; + // Energy: TX current × airtime. Current depends on TX power at moment of transmission. + float current_ma = txCurrentMa(_radio_ref.tx_power_dbm); + total_tx_energy_mah += current_ma * (float)at / 3600000.0f; + } + + void onAdvertRecv(mesh::Packet* pkt, const mesh::Identity& id, + uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override + { + last_advert_ts = timestamp; + memcpy(&last_advert_src_key, id.pub_key, 4); + recordDelivery(pkt, PAYLOAD_TYPE_ADVERT, -1); + } + + void onPeerDataRecv(mesh::Packet* pkt, uint8_t type, int sender_idx, + const uint8_t* secret, uint8_t* data, size_t len) override + { + recordDelivery(pkt, type, sender_idx); + } + + void onGroupDataRecv(mesh::Packet* pkt, uint8_t type, const mesh::GroupChannel& ch, + uint8_t* data, size_t len) override + { + recordDelivery(pkt, type, -1); + } + + void onRawDataRecv(mesh::Packet* pkt) override { + recordDelivery(pkt, PAYLOAD_TYPE_RAW_CUSTOM, -1); + } + +private: +public: + SimRadio& _radio_ref; + SimMillisClock& _ms_ref; + SimRNG& _rng_ref; + SimPacketManager& _mgr_ref; + SimTables& _tables_ref; + + void recordDelivery(mesh::Packet* pkt, uint8_t payload_type, int sender_idx) { + uint64_t now = (uint64_t)_ms_ref.getMillis(); + + DeliveryEvent ev; + ev.src_node = sender_idx; + ev.dst_node = -1; + ev.recv_node = node_idx; + ev.payload_type = payload_type; + ev.route_type = pkt->getRouteType(); + ev.hop_count = pkt->getPathHashCount(); + ev.snr = pkt->getSNR(); + ev.rssi = _radio_ref.getLastRSSI(); + ev.tx_time_ms = 0; // filled in by SimBus when known + ev.rx_time_ms = now; + ev.latency_ms = 0; + ev.airtime_ms = _radio_ref.getEstAirtimeFor(pkt->getRawLength()); + ev.is_duplicate = false; + + deliveries.push_back(ev); + + if (on_recv) on_recv(this, ev); + } +}; + +} diff --git a/sim/src/SimRadio.h b/sim/src/SimRadio.h new file mode 100644 index 0000000000..581f5d5d3b --- /dev/null +++ b/sim/src/SimRadio.h @@ -0,0 +1,209 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace sim { + +// Simulated RF channel event — one node transmitting a raw packet +struct RFEvent { + int src_node; // index of transmitting node + uint8_t bytes[255]; + int len; + float tx_snr; // SNR at transmitter (affects received SNR at each node) + uint64_t tx_time_ms; // absolute sim time of transmission + uint32_t airtime_ms; // how long the packet occupies the air +}; + +// Per-link RF model: determines whether node B receives a packet from node A, +// and with what SNR. Override to model different topologies. +struct RFChannelModel { + virtual bool canReceive(int from_node, int to_node) = 0; + virtual float receivedSNR(int from_node, int to_node, float base_snr) = 0; + virtual ~RFChannelModel() = default; +}; + +// Simple model: all nodes within `range` of each other hear everything at constant SNR +struct FullMeshModel : public RFChannelModel { + float snr; + FullMeshModel(float snr = 8.0f) : snr(snr) {} + bool canReceive(int from, int to) override { return from != to; } + float receivedSNR(int from, int to, float) override { return snr; } +}; + +// Linear chain: node i only hears i-1 and i+1 +struct ChainModel : public RFChannelModel { + float snr; + ChainModel(float snr = 8.0f) : snr(snr) {} + bool canReceive(int from, int to) override { return abs(from - to) == 1; } + float receivedSNR(int from, int to, float) override { return snr; } +}; + +// Distance-based model: nodes have x,y positions; signal drops with distance +struct PositionalModel : public RFChannelModel { + struct NodePos { float x, y; }; + std::vector positions; + float range; // max range in arbitrary units + float snr_at_range; + float snr_peak; + + PositionalModel(float range = 1.0f, float snr_peak = 12.0f, float snr_at_range = 3.0f) + : range(range), snr_at_range(snr_at_range), snr_peak(snr_peak) {} + + void addNode(float x, float y) { positions.push_back({x, y}); } + + bool canReceive(int from, int to) override { + if (from == to || from >= (int)positions.size() || to >= (int)positions.size()) return false; + float dx = positions[from].x - positions[to].x; + float dy = positions[from].y - positions[to].y; + return sqrtf(dx*dx + dy*dy) <= range; + } + + float receivedSNR(int from, int to, float) override { + float dx = positions[from].x - positions[to].x; + float dy = positions[from].y - positions[to].y; + float dist = sqrtf(dx*dx + dy*dy); + float t = dist / range; + return snr_peak + t * (snr_at_range - snr_peak); + } +}; + +// ------------------------------------------------------------------------- +// SimRadio: implements mesh::Radio for a simulated node. +// Receives packets via injectRecv(); transmits by calling the bus callback. +// +// Collision model: LoRa captures the strongest signal if SNR difference +// exceeds CAPTURE_THRESHOLD_DB (typically 6 dB). Below that, both packets +// are corrupted and the receiver gets nothing. This matches real LoRa +// behaviour far better than silent-drop on the second arrival. +// ------------------------------------------------------------------------- +static constexpr float LORA_CAPTURE_THRESHOLD_DB = 6.0f; + +class SimRadio : public mesh::Radio { +public: + using TxCallback = std::function; + + // TX power in dBm. Default 20 dBm = typical LoRa max. + // Reducing this directly lowers received SNR at all receivers by the same delta. + // LoRa SX1262 range: +14 to +22 dBm. SX1276: +2 to +20 dBm. + float tx_power_dbm = 20.0f; + + SimRadio(int node_idx, TxCallback on_tx, float lora_bw_khz = 62.5f, int lora_sf = 8) + : _node_idx(node_idx), _on_tx(on_tx), _bw(lora_bw_khz), _sf(lora_sf) + {} + + // Called by SimBus to deliver a packet to this node. + // If a packet is already pending: + // - stronger signal wins capture if SNR delta >= LORA_CAPTURE_THRESHOLD_DB + // - otherwise both are corrupted (collision) → _collision flagged, pending cleared + void injectRecv(const uint8_t* bytes, int len, float snr, int sender_node = -1) { + if (!_in_recv_mode) return; + + if (!_rx_pending && !_collision) { + // Clean slot — accept this packet + memcpy(_rx_buf, bytes, len); + _rx_len = len; + _rx_snr = snr; + _rx_sender = sender_node; + _rx_pending = true; + return; + } + + if (_collision) { + // Already collided this tick — additional arrivals are also lost + total_collisions++; + return; + } + + // Second arrival while first is pending — apply capture effect + float delta = snr - _rx_snr; + if (delta >= LORA_CAPTURE_THRESHOLD_DB) { + // New signal captures — replace pending packet + memcpy(_rx_buf, bytes, len); + _rx_len = len; + _rx_snr = snr; + _rx_sender = sender_node; + // _rx_pending stays true, _collision stays false + } else if (-delta >= LORA_CAPTURE_THRESHOLD_DB) { + // Existing signal captures — keep it, discard new arrival + } else { + // Too close in level — collision, both lost + _rx_pending = false; + _collision = true; + total_collisions++; + } + } + + // Reset collision flag at the start of each bus tick so the next + // packet can be received cleanly. + void tickReset() { _collision = false; } + + int getLastSender() const { return _rx_sender_last; } + bool hadCollision() const { return _collision; } + uint32_t getCollisions()const { return total_collisions; } + + uint32_t total_collisions = 0; + + // mesh::Radio interface + void begin() override { _in_recv_mode = true; } + + int recvRaw(uint8_t* bytes, int sz) override { + if (!_rx_pending) return 0; + int n = _rx_len < sz ? _rx_len : sz; + memcpy(bytes, _rx_buf, n); + _last_snr = _rx_snr; + _last_rssi = -90.0f + _rx_snr; + _rx_sender_last = _rx_sender; + _rx_pending = false; + return n; + } + + uint32_t getEstAirtimeFor(int len_bytes) override { + // LoRa airtime model: symbols * symbol_time + // symbol_time = 2^SF / BW + float sym_time_ms = (float)(1 << _sf) / _bw; + // preamble (8 syms) + ceil((8*len - 4*SF + 28 + 16) / (4*SF)) coding overhead * (CR+4) + float payload_syms = ceilf((8.0f*len_bytes - 4.0f*_sf + 28.0f + 16.0f) / (4.0f*_sf)) * 5.0f; + float total_syms = 12.25f + payload_syms; // 8 preamble + 4.25 header + return (uint32_t)(total_syms * sym_time_ms); + } + + float packetScore(float snr, int packet_len) override { + return snr / 10.0f; // simplified score for simulation + } + + bool startSendRaw(const uint8_t* bytes, int len) override { + uint32_t at = getEstAirtimeFor(len); + _on_tx(_node_idx, bytes, len, at, tx_power_dbm); + _in_recv_mode = false; + _send_done = false; + _send_done = true; // instant in sim + return true; + } + + bool isSendComplete() override { return _send_done; } + void onSendFinished() override { _in_recv_mode = true; } + bool isInRecvMode() const override { return _in_recv_mode; } + float getLastRSSI() const override { return _last_rssi; } + float getLastSNR() const override { return _last_snr; } + +private: + int _node_idx; + TxCallback _on_tx; + float _bw; + int _sf; + + uint8_t _rx_buf[255]; + int _rx_len = 0; + float _rx_snr = 0; + bool _rx_pending = false; + bool _collision = false; + bool _in_recv_mode = false; + bool _send_done = true; + float _last_snr = 0, _last_rssi = 0; + int _rx_sender = -1, _rx_sender_last = -1; +}; + +} diff --git a/sim/src/SimRuntime.h b/sim/src/SimRuntime.h new file mode 100644 index 0000000000..f12a60e3dd --- /dev/null +++ b/sim/src/SimRuntime.h @@ -0,0 +1,223 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +namespace sim { + +// ------------------------------------------------------------------------- +// SimClock — shared wall clock for all nodes in a simulation run. +// ------------------------------------------------------------------------- +class SimClock { + uint64_t _ms = 0; +public: + uint64_t now() const { return _ms; } + void advance(uint64_t delta_ms) { _ms += delta_ms; } + void set(uint64_t ms) { _ms = ms; } +}; + +// ------------------------------------------------------------------------- +// SimMillisClock — per-node MillisecondClock backed by SimClock. +// ------------------------------------------------------------------------- +class SimMillisClock : public mesh::MillisecondClock { + SimClock& _clock; +public: + SimMillisClock(SimClock& clock) : _clock(clock) {} + unsigned long getMillis() override { return (unsigned long)_clock.now(); } +}; + +// ------------------------------------------------------------------------- +// SimRTCClock — per-node RTCClock. +// ------------------------------------------------------------------------- +class SimRTCClock : public mesh::RTCClock { + uint32_t _epoch; + SimClock& _clock; + uint64_t _start_ms; +public: + SimRTCClock(SimClock& clock, uint32_t start_epoch = 1700000000) + : _clock(clock), _epoch(start_epoch), _start_ms(clock.now()) {} + + uint32_t getCurrentTime() override { + return _epoch + (uint32_t)((_clock.now() - _start_ms) / 1000); + } + void setCurrentTime(uint32_t t) override { _epoch = t; _start_ms = _clock.now(); } +}; + +// ------------------------------------------------------------------------- +// SimRNG — deterministic PRNG for reproducible tests. +// ------------------------------------------------------------------------- +class SimRNG : public mesh::RNG { + uint32_t _state; +public: + SimRNG(uint32_t seed = 42) : _state(seed) {} + + // xorshift32 + uint32_t raw() { + _state ^= _state << 13; + _state ^= _state >> 17; + _state ^= _state << 5; + return _state; + } + + // Only pure virtual in mesh::RNG — nextInt() is non-virtual, implemented in Utils.cpp + void random(uint8_t* buf, size_t len) override { + for (size_t i = 0; i < len; i++) buf[i] = (uint8_t)raw(); + } +}; + +// ------------------------------------------------------------------------- +// SimPacketPool — static pool of Packet objects + outbound/inbound queues. +// ------------------------------------------------------------------------- +#define SIM_PACKET_POOL_SIZE 32 + +struct QueuedPacket { + mesh::Packet* pkt; + uint8_t priority; + uint64_t scheduled_for; +}; + +class SimPacketManager : public mesh::PacketManager { + mesh::Packet _pool[SIM_PACKET_POOL_SIZE]; + bool _in_use[SIM_PACKET_POOL_SIZE]; + std::vector _outbound; + std::vector _inbound; + SimClock& _clock; + +public: + SimPacketManager(SimClock& clock) : _clock(clock) { + memset(_in_use, 0, sizeof(_in_use)); + } + + mesh::Packet* allocNew() override { + for (int i = 0; i < SIM_PACKET_POOL_SIZE; i++) { + if (!_in_use[i]) { + _in_use[i] = true; + return &_pool[i]; + } + } + return nullptr; + } + + void free(mesh::Packet* p) override { + for (int i = 0; i < SIM_PACKET_POOL_SIZE; i++) { + if (&_pool[i] == p) { _in_use[i] = false; return; } + } + } + + void queueOutbound(mesh::Packet* pkt, uint8_t priority, uint32_t scheduled_for) override { + _outbound.push_back({pkt, priority, (uint64_t)scheduled_for}); + } + + mesh::Packet* getNextOutbound(uint32_t now) override { + int best = -1; + for (int i = 0; i < (int)_outbound.size(); i++) { + if (_outbound[i].scheduled_for <= now) { + if (best < 0 || _outbound[i].priority > _outbound[best].priority) + best = i; + } + } + if (best < 0) return nullptr; + auto q = _outbound[best]; + _outbound.erase(_outbound.begin() + best); + return q.pkt; + } + + int getOutboundCount(uint32_t now) const override { + int n = 0; + for (auto& q : _outbound) if (q.scheduled_for <= now) n++; + return n; + } + + int getOutboundTotal() const override { return (int)_outbound.size(); } + int getFreeCount() const override { + int n = 0; + for (int i = 0; i < SIM_PACKET_POOL_SIZE; i++) if (!_in_use[i]) n++; + return n; + } + + mesh::Packet* getOutboundByIdx(int i) override { + if (i < 0 || i >= (int)_outbound.size()) return nullptr; + return _outbound[i].pkt; + } + + mesh::Packet* removeOutboundByIdx(int i) override { + if (i < 0 || i >= (int)_outbound.size()) return nullptr; + auto p = _outbound[i].pkt; + _outbound.erase(_outbound.begin() + i); + return p; + } + + void queueInbound(mesh::Packet* pkt, uint32_t scheduled_for) override { + _inbound.push_back({pkt, 0, (uint64_t)scheduled_for}); + } + + mesh::Packet* getNextInbound(uint32_t now) override { + for (int i = 0; i < (int)_inbound.size(); i++) { + if (_inbound[i].scheduled_for <= now) { + auto p = _inbound[i].pkt; + _inbound.erase(_inbound.begin() + i); + return p; + } + } + return nullptr; + } +}; + +// ------------------------------------------------------------------------- +// SimTables — seen-packet deduplication table. +// ------------------------------------------------------------------------- +class SimTables : public mesh::MeshTables { + static const int TABLE_SIZE = 64; + uint8_t _hashes[TABLE_SIZE][MAX_HASH_SIZE]; + int _count = 0; + +public: + bool hasSeen(const mesh::Packet* pkt) override { + uint8_t h[MAX_HASH_SIZE]; + pkt->calculatePacketHash(h); + for (int i = 0; i < _count; i++) { + if (memcmp(_hashes[i], h, MAX_HASH_SIZE) == 0) return true; + } + if (_count < TABLE_SIZE) { + memcpy(_hashes[_count++], h, MAX_HASH_SIZE); + } else { + // ring-buffer evict oldest + memmove(_hashes[0], _hashes[1], (TABLE_SIZE-1) * MAX_HASH_SIZE); + memcpy(_hashes[TABLE_SIZE-1], h, MAX_HASH_SIZE); + } + return false; + } + + void clear(const mesh::Packet* pkt) override { + uint8_t h[MAX_HASH_SIZE]; + pkt->calculatePacketHash(h); + for (int i = 0; i < _count; i++) { + if (memcmp(_hashes[i], h, MAX_HASH_SIZE) == 0) { + memmove(_hashes[i], _hashes[i+1], (_count - i - 1) * MAX_HASH_SIZE); + _count--; + return; + } + } + } + + void reset() { _count = 0; } +}; + +// ------------------------------------------------------------------------- +// SimMainBoard — stub board implementation. +// ------------------------------------------------------------------------- +class SimMainBoard : public mesh::MainBoard { + int _node_idx; +public: + SimMainBoard(int idx) : _node_idx(idx) {} + uint16_t getBattMilliVolts() override { return 3700; } + const char* getManufacturerName() const override { return "Sim"; } + void reboot() override {} + uint8_t getStartupReason() const override { return 0; } +}; + +} diff --git a/sim/src/TestRunner.h b/sim/src/TestRunner.h new file mode 100644 index 0000000000..deeb731222 --- /dev/null +++ b/sim/src/TestRunner.h @@ -0,0 +1,249 @@ +#pragma once +#include "SimBus.h" +#include "SimMetrics.h" +#include "RoutingStrategies.h" +#include +#include +#include +#include + +namespace sim { + +// ------------------------------------------------------------------------- +// TestCase — one parameterised run of the simulation. +// ------------------------------------------------------------------------- +struct TestCase { + std::string name; + int num_nodes; + float channel_snr; // uniform SNR for FullMesh / Chain models + int num_floods; + RoutingStrategy strategy; + + enum class TopoType { FULL_MESH, CHAIN, GRID } topo; + int grid_rows = 0; + int grid_cols = 0; // num_nodes = grid_rows * grid_cols for GRID +}; + +// ------------------------------------------------------------------------- +// TestResult — collected stats for one TestCase. +// ------------------------------------------------------------------------- +struct TestResult { + TestCase tc; + ScenarioStats stats; +}; + +// ------------------------------------------------------------------------- +// TestRunner — runs a batch of TestCases and produces comparison tables. +// ------------------------------------------------------------------------- +class TestRunner { +public: + // ----------------------------------------------------------------------- + // Run all test cases, return results in order. + // ----------------------------------------------------------------------- + std::vector run(const std::vector& cases) { + std::vector results; + results.reserve(cases.size()); + + for (auto& tc : cases) { + results.push_back(runOne(tc)); + } + return results; + } + + // ----------------------------------------------------------------------- + // Print RESULT lines + DELTA table relative to DEFAULT strategy. + // ----------------------------------------------------------------------- + void printComparison(const std::vector& results) { + printf("\n"); + // -- RESULT lines ------------------------------------------------------- + for (auto& r : results) { + printf("RESULT | strategy=%-16s | topo=%-8s | nodes=%-3d | dr=%5.1f%% | lat=%6.0fms | hops=%4.1f | air=%lldms\n", + strategyName(r.tc.strategy), + topoName(r.tc.topo), + r.tc.num_nodes, + r.stats.avg_delivery_rate * 100.0f, + r.stats.avg_latency_ms, + r.stats.avg_hops, + (long long)r.stats.total_airtime_ms); + } + + printf("\n"); + + // -- DELTA lines — compare SNR_WEIGHTED and PATH_SNR_HYBRID vs DEFAULT -- + // Find DEFAULT baseline for each (topo, num_nodes) pair + for (auto& r : results) { + if (r.tc.strategy == RoutingStrategy::DEFAULT) continue; + + // Find matching DEFAULT + const TestResult* base = nullptr; + for (auto& b : results) { + if (b.tc.strategy == RoutingStrategy::DEFAULT && + b.tc.topo == r.tc.topo && + b.tc.num_nodes == r.tc.num_nodes) { + base = &b; + break; + } + } + if (!base) continue; + + float dr_delta = (r.stats.avg_delivery_rate - base->stats.avg_delivery_rate) * 100.0f; + float lat_pct = base->stats.avg_latency_ms > 0.0f + ? (r.stats.avg_latency_ms - base->stats.avg_latency_ms) / base->stats.avg_latency_ms * 100.0f + : 0.0f; + float hop_delta = r.stats.avg_hops - base->stats.avg_hops; + float air_pct = base->stats.total_airtime_ms > 0 + ? (float)((long long)r.stats.total_airtime_ms - (long long)base->stats.total_airtime_ms) + / (float)base->stats.total_airtime_ms * 100.0f + : 0.0f; + + printf("DELTA | strategy=%-16s | topo=%-8s | nodes=%-3d | dr=%+5.1f%% | lat=%+6.0f%% | hops=%+4.1f | air=%+5.0f%%\n", + strategyName(r.tc.strategy), + topoName(r.tc.topo), + r.tc.num_nodes, + dr_delta, + lat_pct, + hop_delta, + air_pct); + } + } + + // ----------------------------------------------------------------------- + // Dump all results as CSV to a file. + // ----------------------------------------------------------------------- + void dumpCSV(const std::vector& results, const char* filename) { + FILE* f = fopen(filename, "w"); + if (!f) { + printf("ERROR: cannot open %s for writing\n", filename); + return; + } + fprintf(f, "name,topo,num_nodes,channel_snr,num_floods,strategy," + "avg_delivery_rate,avg_latency_ms,avg_hops,total_airtime_ms\n"); + for (auto& r : results) { + fprintf(f, "%s,%s,%d,%.2f,%d,%s,%.4f,%.2f,%.3f,%lld\n", + r.tc.name.c_str(), + topoName(r.tc.topo), + r.tc.num_nodes, + r.tc.channel_snr, + r.tc.num_floods, + strategyName(r.tc.strategy), + r.stats.avg_delivery_rate, + r.stats.avg_latency_ms, + r.stats.avg_hops, + (long long)r.stats.total_airtime_ms); + } + fclose(f); + printf("CSV written to %s\n", filename); + } + +private: + // ----------------------------------------------------------------------- + // Run a single TestCase. + // ----------------------------------------------------------------------- + TestResult runOne(const TestCase& tc) { + SimBus bus; + bus.tick_ms = 5; + + char name_buf[32]; + + if (tc.topo == TestCase::TopoType::GRID) { + // Grid: PositionalModel, nodes at integer grid positions + // Spacing=1.0, range=1.5 so orthogonal neighbours connect, diagonals don't + auto* model = new PositionalModel(1.5f, (float)tc.channel_snr + 4.0f, tc.channel_snr); + bus.channel_model = model; + + int rows = tc.grid_rows; + int cols = tc.grid_cols; + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + snprintf(name_buf, sizeof(name_buf), "n%d_%d", r, c); + bus.addNode(name_buf, (uint32_t)(r * cols + c + 1) * 0x1337cafe); + model->addNode((float)c, (float)r); + } + } + _owned_channel = std::unique_ptr(model); + } else if (tc.topo == TestCase::TopoType::CHAIN) { + auto* model = new ChainModel(tc.channel_snr); + bus.channel_model = model; + _owned_channel = std::unique_ptr(model); + + for (int i = 0; i < tc.num_nodes; i++) { + snprintf(name_buf, sizeof(name_buf), "node%d", i); + bus.addNode(name_buf, (uint32_t)(i + 1) * 0xcafebabe); + } + } else { + // FULL_MESH + auto* model = new FullMeshModel(tc.channel_snr); + bus.channel_model = model; + _owned_channel = std::unique_ptr(model); + + for (int i = 0; i < tc.num_nodes; i++) { + snprintf(name_buf, sizeof(name_buf), "node%d", i); + bus.addNode(name_buf, (uint32_t)(i + 1) * 0xdeadbeef); + } + } + + // Apply routing strategy to all nodes + for (auto& b : bus.nodes) { + b.node->routing_strategy = tc.strategy; + } + + // Warmup — run time + a few probe floods so density estimators + // have real neighbor observations before measurement begins. + bus.run(2000); + for (int i = 0; i < 3; i++) { + bus.sendFloodText(0, "warmup"); + uint64_t wp = (tc.topo == TestCase::TopoType::CHAIN) ? 6000 : + (tc.topo == TestCase::TopoType::GRID) ? 8000 : 3000; + bus.run(wp); + } + bus.resetStats(); // clear warmup metrics; density estimator retains observations + + // Choose per-topo propagation budget + uint64_t prop_ms = 3000; + if (tc.topo == TestCase::TopoType::CHAIN) prop_ms = 6000; + if (tc.topo == TestCase::TopoType::GRID) prop_ms = 8000; + + // Inject floods from node 0 + for (int i = 0; i < tc.num_floods; i++) { + bus.sendFloodText(0, "bench"); + bus.run(prop_ms); + } + + TestResult tr; + tr.tc = tc; + tr.stats = bus.metrics.aggregate(tc.num_floods); + + // Accumulate total TX airtime across all nodes + uint64_t total_air = 0; + for (auto& b : bus.nodes) total_air += b.node->total_airtime_ms; + tr.stats.total_airtime_ms = total_air; + tr.stats.total_tx = 0; + for (auto& b : bus.nodes) tr.stats.total_tx += b.node->total_tx_packets; + + return tr; + } + + static const char* strategyName(RoutingStrategy s) { + switch (s) { + case RoutingStrategy::DEFAULT: return "DEFAULT"; + case RoutingStrategy::SNR_WEIGHTED: return "SNR_WEIGHTED"; + case RoutingStrategy::PATH_SNR_HYBRID:return "PATH_SNR_HYBRID"; + case RoutingStrategy::ADAPTIVE: return "ADAPTIVE"; + default: return "UNKNOWN"; + } + } + + static const char* topoName(TestCase::TopoType t) { + switch (t) { + case TestCase::TopoType::FULL_MESH: return "FullMesh"; + case TestCase::TopoType::CHAIN: return "Chain"; + case TestCase::TopoType::GRID: return "Grid"; + default: return "Unknown"; + } + } + + // Channel model lifetime management for runOne + std::unique_ptr _owned_channel; +}; + +} // namespace sim diff --git a/sim/stubs/Arduino.h b/sim/stubs/Arduino.h new file mode 100644 index 0000000000..f9c09d3931 --- /dev/null +++ b/sim/stubs/Arduino.h @@ -0,0 +1,20 @@ +#pragma once +// Minimal Arduino.h stub for host-side compilation of MeshCore core sources. +#include +#include +#include +using std::size_t; +#include +#include +#include +#include + +typedef bool boolean; +typedef uint8_t byte; + +#ifndef F +#define F(x) (x) +#endif + +inline unsigned long millis() { return 0; } +inline void delay(unsigned long) {} diff --git a/sim/stubs/RNG.cpp b/sim/stubs/RNG.cpp new file mode 100644 index 0000000000..6d85485767 --- /dev/null +++ b/sim/stubs/RNG.cpp @@ -0,0 +1,21 @@ +// Stub implementation of rweather's RNGClass for host-side sim builds. +// Ed25519::generatePrivateKey() calls RNG.rand() — we satisfy it with /dev/urandom. +#include "RNG.h" +#include + +RNGClass RNG; + +void RNGClass::begin(const char*) {} + +void RNGClass::rand(uint8_t* buf, size_t len) { + FILE* f = fopen("/dev/urandom", "rb"); + if (f) { + (void)fread(buf, 1, len, f); + fclose(f); + } else { + // fallback: deterministic + for (size_t i = 0; i < len; i++) buf[i] = (uint8_t)(i * 0x6B ^ 0xA3); + } +} + +void RNGClass::loop() {} diff --git a/sim/stubs/RNG.h b/sim/stubs/RNG.h new file mode 100644 index 0000000000..a78814867c --- /dev/null +++ b/sim/stubs/RNG.h @@ -0,0 +1,13 @@ +#pragma once +// Stub for rweather Crypto library's RNG global — used by Ed25519::generatePrivateKey. +#include +#include + +class RNGClass { +public: + void begin(const char* tag); + void rand(uint8_t* buf, size_t len); + void loop(); +}; + +extern RNGClass RNG; diff --git a/sim/stubs/Stream.h b/sim/stubs/Stream.h new file mode 100644 index 0000000000..6e5d168a9b --- /dev/null +++ b/sim/stubs/Stream.h @@ -0,0 +1,24 @@ +#pragma once +// Minimal Arduino Stream stub for host-side compilation. +#include +#include +#include +#include + +class Stream { +public: + virtual size_t write(uint8_t b) { return fwrite(&b, 1, 1, stdout); } + virtual size_t write(const uint8_t* buf, size_t len) { return fwrite(buf, 1, len, stdout); } + virtual int available() { return 0; } + virtual int read() { return -1; } + virtual int peek() { return -1; } + + size_t print(const char* s) { return fputs(s, stdout); } + size_t print(char c) { return fputc(c, stdout); } + size_t print(int n) { return fprintf(stdout, "%d", n); } + size_t println() { return fputc('\n', stdout); } + size_t println(const char* s) { size_t n = print(s); println(); return n; } + + size_t readBytes(uint8_t* buf, size_t len) { return 0; } + size_t readBytes(char* buf, size_t len) { return 0; } +};