From d9f68e9e52bbb497bec1d4406646b410b4a87a19 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sat, 11 Jul 2026 12:10:12 +0200 Subject: [PATCH 1/3] fix(radio): replace one-way noise-floor ratchet with median estimator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The noise-floor calibration sampled only RSSI values below the current floor + threshold, a one-way ratchet: it accepted ever-lower samples but never recovered upward, so _noise_floor drifted to the -120 clamp and stayed there. That left the RSSI-margin LBT (isChannelActive with interference_threshold, plus isResendChannelActive / isChannelNoisy on the feature branches that consume _noise_floor) permanently over-sensitive — resends and dwell-gated TX deferred even on a quiet channel. Replace the ratcheted block mean with the median of the 64-sample block: - accepts every idle (!isReceivingPacket) sample — no downward bias; - median rejects transient interference spikes (high and low outliers) and recovers in BOTH directions; - _noise_floor is written only after a full block, so the previous value stays valid while the next block is sampled — no reset-to-0 and thus no permissive LBT window (margin = RSSI - 0) during reconvergence. resetAGC no longer forces _noise_floor = 0 (the stuck-ratchet workaround); it only discards the in-progress block so a fresh one is measured after the analog frontend reset. Verified: Heltec_v3_repeater firmware build (compiles RadioLibWrappers.cpp against real RadioLib). Co-Authored-By: Claude --- src/helpers/radiolib/RadioLibWrappers.cpp | 60 +++++++++++++++-------- src/helpers/radiolib/RadioLibWrappers.h | 5 +- 2 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 5e72336c05..7d3aff8c53 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -8,11 +8,22 @@ #define STATE_TX_DONE 4 #define STATE_INT_READY 16 -#define NUM_NOISE_FLOOR_SAMPLES 64 -#define SAMPLING_THRESHOLD 14 - static volatile uint8_t state = STATE_IDLE; +// In-place insertion sort of int16_t samples for the noise-floor median. Runs once per +// calibration block (64 elements, ~every 2 s of idle), so O(n^2) is irrelevant here. +static void sortInt16(int16_t* a, int n) { + for (int i = 1; i < n; i++) { + int16_t key = a[i]; + int j = i - 1; + while (j >= 0 && a[j] > key) { + a[j + 1] = a[j]; + j--; + } + a[j + 1] = key; + } +} + // this function is called when a complete packet // is transmitted by the module static @@ -40,7 +51,7 @@ void RadioLibWrapper::begin() { // start average out some samples _num_floor_samples = 0; - _floor_sample_sum = 0; + _floor_block_ready = false; } uint32_t RadioLibWrapper::getRngSeed() { @@ -58,9 +69,9 @@ void RadioLibWrapper::idle() { void RadioLibWrapper::triggerNoiseFloorCalibrate(int threshold) { _threshold = threshold; - if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // ignore trigger if currently sampling + if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES) { // restart only once the current block is complete _num_floor_samples = 0; - _floor_sample_sum = 0; + _floor_block_ready = false; } } @@ -75,32 +86,39 @@ void RadioLibWrapper::resetAGC() { doResetAGC(); state = STATE_IDLE; // trigger a startReceive() - // Reset noise floor sampling so it reconverges from scratch. - // Without this, a stuck _noise_floor of -120 makes the sampling threshold - // too low (-106) to accept normal samples (~-105), self-reinforcing the - // stuck value even after the receiver has recovered. - _noise_floor = 0; + // Discard any in-progress noise-floor block: the analog frontend was just reset, so + // queued RSSI samples are stale. _noise_floor itself is left in place — the median + // estimator no longer drifts to -120 (the reason the old ratchet needed a hard + // _noise_floor = 0 reset), and forcing 0 here would create a brief permissive LBT + // window (margin = RSSI - 0) until the next block completes. _num_floor_samples = 0; - _floor_sample_sum = 0; + _floor_block_ready = false; } void RadioLibWrapper::loop() { if (state == STATE_RX && _num_floor_samples < NUM_NOISE_FLOOR_SAMPLES) { if (!isReceivingPacket()) { - int rssi = getCurrentRSSI(); - if (rssi < _noise_floor + SAMPLING_THRESHOLD) { // only consider samples below current floor + sampling THRESHOLD - _num_floor_samples++; - _floor_sample_sum += rssi; - } + // Accept every idle sample. The old "rssi < floor + threshold" filter was a one-way + // ratchet: it only ever accepted samples below the current floor, so the block average + // drifted downward to the -120 clamp and never recovered — leaving _noise_floor stuck + // low and the RSSI-margin LBT permanently over-sensitive. + _floor_samples[_num_floor_samples++] = (int16_t)getCurrentRSSI(); } - } else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && _floor_sample_sum != 0) { - _noise_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES; + } else if (_num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES && !_floor_block_ready) { + // Block complete: reduce to the median. The median rejects transient interference + // spikes (high and low outliers) and recovers in BOTH directions, unlike the ratcheted + // mean. _noise_floor is written only here, so the previous value stays valid while the + // next block is sampled — no reset-to-0, no permissive LBT window during reconvergence. + sortInt16(_floor_samples, NUM_NOISE_FLOOR_SAMPLES); + int16_t median = (int16_t)(((int32_t)_floor_samples[NUM_NOISE_FLOOR_SAMPLES / 2 - 1] + + (int32_t)_floor_samples[NUM_NOISE_FLOOR_SAMPLES / 2]) / 2); + _noise_floor = median; if (_noise_floor < -120) { _noise_floor = -120; // clamp to lower bound of -120dBi } - _floor_sample_sum = 0; + _floor_block_ready = true; - MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d", (int)_noise_floor); + MESH_DEBUG_PRINTLN("RadioLibWrapper: noise_floor = %d (median)", (int)_noise_floor); } } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 3091832f11..a764065588 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -3,6 +3,8 @@ #include #include +#define NUM_NOISE_FLOOR_SAMPLES 64 // RSSI samples reduced to a median per noise-floor calibration block + class RadioLibWrapper : public mesh::Radio { protected: PhysicalLayer* _radio; @@ -11,7 +13,8 @@ class RadioLibWrapper : public mesh::Radio { int16_t _noise_floor, _threshold; bool _cad_enabled; uint16_t _num_floor_samples; - int32_t _floor_sample_sum; + int16_t _floor_samples[NUM_NOISE_FLOOR_SAMPLES]; + bool _floor_block_ready; // true once a full block has been reduced to a median (waits for trigger to restart) uint8_t _preamble_sf; void idle(); From efc011f64a714bf63e5b6cf3d10a18db67878529 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sat, 11 Jul 2026 12:26:33 +0200 Subject: [PATCH 2/3] docs: add README-bugfix.md for noise-floor median estimator Documents the ratchet-to-median fix on fix/noise-floor-ratchet: symptom, root cause (one-way ratchet drift to -120), the median-of-64 replacement, files touched, build verification note (sim does not compile RadioLibWrappers.cpp; verified via Heltec_v3_repeater), and merge intent. Co-Authored-By: Claude --- README-bugfix.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 README-bugfix.md diff --git a/README-bugfix.md b/README-bugfix.md new file mode 100644 index 0000000000..acfa29ff39 --- /dev/null +++ b/README-bugfix.md @@ -0,0 +1,81 @@ +# Bugfix: noise-floor median estimator (branch `fix/noise-floor-ratchet`) + +Branched off `dev` at `102f1d2a`. Fix commit: `d9f68e9e`. + +## Symptom + +On long-running nodes (ufo integration branch, which combines +`feature/repeated-sending-2` + `feature/quiet-dwell`), direct-packet resends +and dwell-gated TX get deferred or suppressed even on a quiet channel. The +radio's reported `noise_floor` drifts to the `-120` clamp and never recovers, +so the RSSI-margin LBT checks (`isResendChannelActive`, `isChannelNoisy`, +`isChannelActive` with `interference_threshold`) stay permanently +over-sensitive — every send looks like it collides with noise. + +This is a **secondary** cause of "resends degrade to zero after long uptime." +The primary cause (stale `sending_attempts` on pool reuse) is fixed separately +on `feature/repeated-sending-2` (commit `110b9b40`, in `free()`). + +## Root cause + +`RadioLibWrapper::loop()` calibrated `_noise_floor` from a 64-sample block, but +only accepted samples that satisfied + +``` +rssi < _noise_floor + SAMPLING_THRESHOLD // SAMPLING_THRESHOLD = 14 +``` + +That filter is a **one-way ratchet**: it admits ever-lower samples but rejects +anything above the current floor, so the block mean can only move down. Over +time it walks to the `-120` lower clamp and sticks there. The only thing that +reset it was `resetAGC` setting `_noise_floor = 0` — but `resetAGC` is gated on +`agc_reset_interval`, which defaults to `0` (off), and forcing `0` would +anyway open a brief permissive LBT window (`margin = RSSI − 0`) until the next +block completes. + +## The fix + +Replace the ratcheted block mean with the **median** of the 64-sample block: + +- Accept **every** idle sample (`!isReceivingPacket()`) — no downward bias. +- Sort the 64 samples and take the median (mean of the two middle values). + The median rejects transient interference spikes in **both** directions and + recovers upward as well as down. +- Write `_noise_floor` **only after a full block** is collected. The previous + value stays valid while the next block is sampled — no reset-to-0, hence no + permissive LBT window during reconvergence. +- Clamp the result to `-120` (lower bound of the radio's RSSI range). +- `resetAGC()` no longer touches `_noise_floor`; it only discards the + in-progress block (the analog frontend was just reset, so queued samples are + stale). `_noise_floor` itself is left in place because the median estimator + no longer needs the hard reset that the ratchet did. + +`SAMPLING_THRESHOLD` is removed (it only fed the ratchet filter). +`NUM_NOISE_FLOOR_SAMPLES` (64) moves to the header so the sample buffer can be +a member array. + +## Files + +| File | Change | +|---|---| +| `src/helpers/radiolib/RadioLibWrappers.h` | `NUM_NOISE_FLOOR_SAMPLES` macro; replace `_floor_sample_sum` with `_floor_samples[64]` + `_floor_block_ready` flag | +| `src/helpers/radiolib/RadioLibWrappers.cpp` | `sortInt16()` helper; `loop()` median logic; `resetAGC()` no longer zeros `_noise_floor`; `triggerNoiseFloorCalibrate()`/`begin()` reset the block state | + +## Build verification + +The sim build (`cargo check -p mcsim-firmware`) does **not** compile +`RadioLibWrappers.cpp` — it links a `sim_radio.cpp` mock. Verified with a real +firmware target instead: + +``` +FIRMWARE_VERSION=v1.0.0 bash build.sh build-firmware Heltec_v3_repeater +# -> SUCCESS (14.7s), RadioLibWrappers.cpp compiles against real RadioLib +``` + +## Merge note + +This branch is `dev`-based and touches only the noise-floor calibration. It is +meant to be merged into `ufo` alongside `feature/repeated-sending-2`; on plain +`dev`, `_noise_floor` is consumed only when `interference_threshold != 0`, so +the bug bites primarily on `ufo` where `quiet-dwell` and `repeated-sending-2` +read it unconditionally. From f879a9f423644957171db9b225f36065b1ba9098 Mon Sep 17 00:00:00 2001 From: Florian Sager Date: Sun, 12 Jul 2026 22:11:42 +0200 Subject: [PATCH 3/3] Removed bugfix documentation for merge request --- README-bugfix.md | 81 ------------------------------------------------ 1 file changed, 81 deletions(-) delete mode 100644 README-bugfix.md diff --git a/README-bugfix.md b/README-bugfix.md deleted file mode 100644 index acfa29ff39..0000000000 --- a/README-bugfix.md +++ /dev/null @@ -1,81 +0,0 @@ -# Bugfix: noise-floor median estimator (branch `fix/noise-floor-ratchet`) - -Branched off `dev` at `102f1d2a`. Fix commit: `d9f68e9e`. - -## Symptom - -On long-running nodes (ufo integration branch, which combines -`feature/repeated-sending-2` + `feature/quiet-dwell`), direct-packet resends -and dwell-gated TX get deferred or suppressed even on a quiet channel. The -radio's reported `noise_floor` drifts to the `-120` clamp and never recovers, -so the RSSI-margin LBT checks (`isResendChannelActive`, `isChannelNoisy`, -`isChannelActive` with `interference_threshold`) stay permanently -over-sensitive — every send looks like it collides with noise. - -This is a **secondary** cause of "resends degrade to zero after long uptime." -The primary cause (stale `sending_attempts` on pool reuse) is fixed separately -on `feature/repeated-sending-2` (commit `110b9b40`, in `free()`). - -## Root cause - -`RadioLibWrapper::loop()` calibrated `_noise_floor` from a 64-sample block, but -only accepted samples that satisfied - -``` -rssi < _noise_floor + SAMPLING_THRESHOLD // SAMPLING_THRESHOLD = 14 -``` - -That filter is a **one-way ratchet**: it admits ever-lower samples but rejects -anything above the current floor, so the block mean can only move down. Over -time it walks to the `-120` lower clamp and sticks there. The only thing that -reset it was `resetAGC` setting `_noise_floor = 0` — but `resetAGC` is gated on -`agc_reset_interval`, which defaults to `0` (off), and forcing `0` would -anyway open a brief permissive LBT window (`margin = RSSI − 0`) until the next -block completes. - -## The fix - -Replace the ratcheted block mean with the **median** of the 64-sample block: - -- Accept **every** idle sample (`!isReceivingPacket()`) — no downward bias. -- Sort the 64 samples and take the median (mean of the two middle values). - The median rejects transient interference spikes in **both** directions and - recovers upward as well as down. -- Write `_noise_floor` **only after a full block** is collected. The previous - value stays valid while the next block is sampled — no reset-to-0, hence no - permissive LBT window during reconvergence. -- Clamp the result to `-120` (lower bound of the radio's RSSI range). -- `resetAGC()` no longer touches `_noise_floor`; it only discards the - in-progress block (the analog frontend was just reset, so queued samples are - stale). `_noise_floor` itself is left in place because the median estimator - no longer needs the hard reset that the ratchet did. - -`SAMPLING_THRESHOLD` is removed (it only fed the ratchet filter). -`NUM_NOISE_FLOOR_SAMPLES` (64) moves to the header so the sample buffer can be -a member array. - -## Files - -| File | Change | -|---|---| -| `src/helpers/radiolib/RadioLibWrappers.h` | `NUM_NOISE_FLOOR_SAMPLES` macro; replace `_floor_sample_sum` with `_floor_samples[64]` + `_floor_block_ready` flag | -| `src/helpers/radiolib/RadioLibWrappers.cpp` | `sortInt16()` helper; `loop()` median logic; `resetAGC()` no longer zeros `_noise_floor`; `triggerNoiseFloorCalibrate()`/`begin()` reset the block state | - -## Build verification - -The sim build (`cargo check -p mcsim-firmware`) does **not** compile -`RadioLibWrappers.cpp` — it links a `sim_radio.cpp` mock. Verified with a real -firmware target instead: - -``` -FIRMWARE_VERSION=v1.0.0 bash build.sh build-firmware Heltec_v3_repeater -# -> SUCCESS (14.7s), RadioLibWrappers.cpp compiles against real RadioLib -``` - -## Merge note - -This branch is `dev`-based and touches only the noise-floor calibration. It is -meant to be merged into `ufo` alongside `feature/repeated-sending-2`; on plain -`dev`, `_noise_floor` is consumed only when `interference_threshold != 0`, so -the bug bites primarily on `ufo` where `quiet-dwell` and `repeated-sending-2` -read it unconditionally.