Fix/noise floor ratchet, adapt noise to the real floor with recovery in both directions - #2933
Fix/noise floor ratchet, adapt noise to the real floor with recovery in both directions#2933usrflo wants to merge 7 commits into
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Problem Proof on dev Branch, _noise_floor = -120 trap, Case CPatch noise-floor-provocation-C.patch demonstrates on the dev branch that if |
Problem Proof on dev Branch, Drifting Floor, Case BPatch noise-floor-provocation-B.patch demonstrates the one-way drift of the noise floor on the dev branch, provoked by a reduced threshold. From 20 s — Drift (threshold = 2): cutoff = floor + 2 slips into the noise; each block censors the upper portion → floor slides monotonically downward, rejected count rises, cutoff follows. What B demonstrates (vs. C) Together, they cover: cause (B) and symptom (C). |
Proof of fix/noise-floor-ratchet (PR 2933), _noise_floor = -120 trap, Case CPatch fix-noise-floor-ratchet-C.patch demonstrates the noise floor doesn't drop permanently if once lowered using the changes from PR 2933. |
Proof of fix/noise-floor-ratchet (PR 2933), Drifting Floor, Case BPatch fix-noise-floor-ratchet-B.patch demonstrates the noise floor doesn't drift one-sided when using the changes from PR 2933. |
Overall picture: 4 test/debug patches (bug ↔ solution)Above you can find 4 patches that demonstrate a) the problem b) the bugfix of this pull request.
|
…tection to be replaced by _prefs.interference_threshold when the currentRSSI problem is solved, possibly via PR meshcore-dev#2933
|
This PR ist part of the ufo firmware, latest build at time of writing: v0.6 |
The old estimator admitted a sample only if `rssi < _noise_floor + 14`, so each 64-sample block mean was computed from a lower-truncated set and could only move down. At the -120 clamp the admission threshold becomes -106, ordinary ~-104 idle samples stop qualifying, `_num_floor_samples` stalls, and the block never completes again -- the node wedges and goes SILENT on noise_floor rather than reporting a wrong value. `resetAGC()` was the only escape and is gated on `agc_reset_interval`, which is 0 (off) on our hardware. Adopts upstream meshcore-dev#2933: accept every idle sample, reduce the block to its median (rejects transients in BOTH directions and recovers upward), write `_noise_floor` only on block completion so the previous value stays valid during reconvergence, and stop forcing `_noise_floor = 0` in `resetAGC()`. Conflict note: upstream's hunk also carried the dev-era CC310/PacketMillis block, which does not exist on this main-based branch. Only the `NUM_NOISE_FLOOR_SAMPLES` define was taken. Bench-verified on the RAK4631 rig (869.618/62.5/SF8/CR5, agc.reset.interval=0), identical stimulus of 250 zero-hop adverts at 0.4s spacing: before: -120 at 18.9s, still -120 at 168s, only 6 samples (blocks stalled) after: -104 final, min -106 / max -35, 56 samples, 15 up / 14 down Wide cadence (4s) does NOT reproduce it -- the quiet gaps let the old estimator recover. Tight cadence is what compounds the bias. Closes #3
|
Independent reproduction, on unmodified firmware - no provocation patch on the device under test. Complements the synthetic proofs above, which need a code change to trigger. Setup. RAK4631 (SX1262), MeshCore 1.16.0 base (
With this PR applied, identical stimulus (250 @ 0.4 s; 85 packets received vs 88 on the unpatched run): final -104, min -106 / max -35, 56 blocks in 199 s, 15 up / 14 down, and it recovers from a -35 transient - which the old Useful second signature for anyone else testing this: the block completion rate, not just the value. As the estimator wedges, the Three caveats, so nobody over-reads this:
Happy to run the same harness against #2842 if that is useful for comparing the two approaches - the rig is a two-node bench with a scripted stimulus, so it is the same effort either way. |
The fork carries meshcore-dev#2933 (usrflo's median noise-floor estimator) ahead of mainline merging it. Nothing in the repo said so, and the commit that applied it (a028adc) is authored by this fork with the provenance only in its message body -- easy to lose track of, and easy to mistake for fork-original work. Records for each carried patch: who wrote it upstream, which PR, why we are not waiting, and what has to happen to drop it again. Also notes meshcore-dev#2797 as resolved, since 1.17 landed it and the replant deduplicated the fork copy automatically. Calls out that meshcore-dev#2842 is a competing fix for the same root cause, roughly thirty times the size, and that if it is the one that lands this is not a clean revert -- it rewrites the estimator our patch touches, so the bench stimulus has to be re-run against its clamp defaults rather than assumed equivalent. Explicitly: do not re-submit meshcore-dev#2933 upstream under fork authorship.
a028adc is pushed with fork authorship and no Co-authored-by trailer, so the credit cannot be fixed in history without rewriting published commits. Decision 2026-08-09: pay it in the release notes instead. Records that any release carrying the patch must name usrflo and link meshcore-dev#2933, in both the German and English text, so the requirement survives to whoever cuts fwdfilter8.
|
@usrflo Ran it. Same rig, same stimulus, three arms. What was tested against whatNeither PR was merged or rebased onto anything for this. Each ran at its own head, and the control is
The two PRs branch from dev at different points, but I deliberately did not merge either PR forward onto current dev ( SetupRAK4631 (SX1262), 869.618 MHz / 62.5 kHz / SF8 / CR5. Built with Results
Both PRs fix the ratchet. Neither reached the −120 clamp, neither needed a reboot or Where they differ is what gets published while the channel is busy. #2933 keeps publishing, and what it publishes tracks the channel rather than the idle floor. With the #2842 refuses to publish an activity-contaminated batch and keeps the previous value. Its The batch freezes at 47/64 accepted, the reject counter climbs, the window expires, the batch restarts. On "improvement or more logic than required"For the ratchet alone, #2933 is sufficient and is a fraction of the size. The extra logic in #2842 buys
Of #2842's machinery, the parts that visibly did work on this bench were the high-jump reject gate and Caveats, so nobody over-reads this
|
…ples and improving median calculation logic
|
@ACETyr, thanks for the analysis/the comparison tests; this is very helpful. I see under load #2933's median tracked the busy channel (-56, permissive LBT) because the 64 samples were taken in a few ms instead of a longer time period. I was thinking about switching over to #2842 but decided to stay with the minimum approach of this PR.
Would you mind to repeat the comparison test with these 2 changes on occasion? |
# Conflicts: # src/helpers/radiolib/RadioLibWrappers.cpp
|
@usrflo Re-ran it on Arms
I re-ran the control rather than quoting yesterday's, and that turned out to matter — see the last Same rig as before: RAK4631 (SX1262), 869.618 MHz / 62.5 kHz / SF8 / CR5, Results
The idle cadence moved from 2.0 s to 4.0 s with a hard minimum of 3.2 s — that is 64 × 50 ms exactly, Under load the published floor never moved off −104. Two blocks completed during the ~110 s of load,
What it costsBlock completion nearly stalls under load. Before the change: 30 blocks published during the load One practical consequence: on a production build ( One thing I did not measure — the hold has no releaseThis is from reading the code, not from the bench, and it applies to both PRs, so it is not a point
Two things that are not a problem, for completeness:
#2842 has the same structural gap and its version is symmetric ( Also minor: Correction to my caveat 5 of 2026-08-10Yesterday I reported that the unpatched control cleared the −120 pin about 30 s after the stimulus The difference is the channel, not the build. Today's "quiet" capture logged 18 RX-activity lines and a Caveats
|
|
@ACETyr, thanks again for this analysis. There are at least two points I need to focus next: the "hold vs. stalled" aspect and the stucked noise floor in the other direction. |
…nt stuck low values
Symptom
On long-running nodes (I saw it with an 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_floordrifts to the-120clamp and never recovers, so the RSSI-margin LBT checks (isResendChannelActive,isChannelNoisy,isChannelActivewithinterference_threshold) stay permanently over-sensitive — every send looks like it collides with noise.Root cause
RadioLibWrapper::loop()calibrated_noise_floorfrom a 64-sample block, but only accepted samples that satisfiedThat 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
-120lower clamp and sticks there. The only thing that reset it wasresetAGCsetting_noise_floor = 0— butresetAGCis gated onagc_reset_interval, which defaults to0(off), and forcing0would 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:
!isReceivingPacket()) — no downward bias._noise_flooronly 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.-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_flooritself is left in place because the median estimator no longer needs the hard reset that the ratchet did.SAMPLING_THRESHOLDis 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.