probing improvements

This commit is contained in:
liquidraver
2026-07-29 15:27:26 +02:00
parent f2d6271f2d
commit 3e534e6239
5 changed files with 232 additions and 12 deletions
+42 -2
View File
@@ -204,14 +204,54 @@ get cad
Compact output (kept short so it survives a truncated LoRa reply):
```
> a:on o:-1 pk:20(b21/4s) iv:15s bc:25%
> a:on o:-1 pk:20(b21/4s) sp:0.9/84%(312) bc:25%
-2(19) 241p 9b 7f 2t 3%
*-1(20) 900p 5b 3f 2t 0%
+0(21) 300p 2b 0f 2t 0%
```
Header: `a` auto on/off · `o` operating offset · `pk` operating detPeak ·
`b` family base · `4s` 4 symbols · `iv` probe interval · `bc` busy cap.
`b` family base · `4s` 4 symbols · `sp` RSSI burst quality · `bc` busy cap.
**`sp` — are the noise-floor sampler's reads independent?** The floor
sampler takes a median of 8 RSSI reads. The chip refreshes RSSI only once
per averaging window (~16 µs at BW 62.5, ~134 µs at BW 7.8); reads issued
faster than that return the same underlying value repeatedly and the median
collapses to a single read. `sp` reports mean spread (maxmin) across a
burst, the share of bursts whose spread was 0, and the burst count.
Read it this way:
- **A non-zero mean proves the reads are independent**, however high the
zero-spread share climbs. A quiet or steadily-occupied channel genuinely
reads the same value 8 times at integer-dB resolution — that is correct,
not broken.
- **Mean 0.0 with a high share is the fault signature**: no burst ever
spans anything, i.e. 8 copies of one sample.
- The count matters. The burst rate is not derivable from uptime, because
the sampler's guards (TX, mid-RX, duty-cycle sleep) block an unknown
fraction of attempts, so a share without its denominator is unreadable.
It appears on the local USB console only — a remote reply is capped at
161 B and the three level rows have first claim on it. All three
counters halve together at 8192 bursts, so the count stays four digits
and the figures describe a recent window rather than everything since
boot.
Measured on-air at BW 62.5: `0.6/90%` on a quiet channel, `0.9/84%` with
the floor at 103 dBm. The share falls and the spread rises as ambient
comes up — independent reads, responding the right way.
Note what this median does and does not do. The burst spans ~300 µs against
a ~200 ms SF8/BW62.5 packet — about 0.15% of one transmission — so a
neighbour's packet is either wholly inside the burst or wholly outside it,
every read sees the same level, and the median returns it rather than
rejecting it. The median rejects sub-300 µs glitches and bad SPI reads.
Interference is handled by the mid-receive guard and the +14 dB threshold
filter, not here.
The probe interval used to sit in this slot; it moved out because it is a
pref you set, readable with `get probe.interval`, whereas burst spread is
only observable from inside the sampler.
Then a **three-rung window** centred on the operating offset (`*` marks
it): the frontier (one step more sensitive), the operating level, and one
step less sensitive — exactly the three rungs the staircase reads to judge
+1 -1
View File
@@ -208,7 +208,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot.
| `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`; a recent clock set — manual or GPS — shows as `hold (suppressed)`, and a backward step a forward-only role would refuse is annotated `(skipped: forward-only)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = counted toward the verdict above). Entries that count print first, so a size-capped reply never hides the ones that explain the summary; if the table doesn't fully fit, a trailing `+N more` shows how many were left out. Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. |
| `get probe.interval` | Seconds between periodic radio measurements (noise-floor sample + CAD probe). 0 = CAD probing off |
| `get dc.restarts` | Duty-cycle preamble false-positive re-arm counter (RxTimeout re-arms + parked-RX watchdog recoveries). High values mean the preamble detector is tripping on noise/interference without real packets arriving — inflates RX-on time and drains battery; packets are never lost to it. Reset by `clear stats`. |
| `get cad` | Adaptive-CAD status: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `iv` probe interval, `bc` busy cap), then a 3-rung window around the operating offset (`*` marks it) with probe/busy/fp/tp counts and false-positive rate — the three levels the knee controller reads. Probing runs even while `cad.auto` is off (dry-run), so this is the observation tool for picking a site-appropriate detPeak. See `ADAPTIVE_CAD.md`. Not available on SX127x boards (no hardware CAD). |
| `get cad` | Adaptive-CAD status: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `sp` noise-floor RSSI burst quality as `mean-spread-dB/zero-spread-%` (plus `(burst-count)` on the local USB console, omitted over the air to protect the 161 B reply budget) — a non-zero mean proves the 8 reads are independent however high the share climbs; only mean `0.0` with a high share indicts the sampler. See `ADAPTIVE_CAD.md`. `bc` busy cap), then a 3-rung window around the operating offset (`*` marks it) with probe/busy/fp/tp counts and false-positive rate — the three levels the knee controller reads. Probing runs even while `cad.auto` is off (dry-run), so this is the observation tool for picking a site-appropriate detPeak. See `ADAPTIVE_CAD.md`. Not available on SX127x boards (no hardware CAD). |
| `get adc.multiplier` | Battery voltage ADC calibration multiplier |
| `get bootloader.ver` | Bootloader version string |
| `get public.key` | *(USB only)* Node's public key as hex |
+125 -9
View File
@@ -50,6 +50,8 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board,
_noise_floor_next_ms(0), _noise_floor_retries(0),
_measure_interval_ms(CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS),
_sample_rssi(0), _sample_channel_quiet(false), _sample_fresh(false),
_rx_entry_cyc(0),
_rssi_bursts(0), _rssi_spread_sum(0), _rssi_degenerate(0),
_cad_auto(false), _cad_offset(0), _probe_interval_s(0),
_cad_busycap_pct(0),
_cad_last_probe_ms(0), _cad_last_decay_ms(0),
@@ -583,6 +585,7 @@ void LoRaRadioBase::startReceive()
K_USEC(sleep_us),
rxCallbackStatic, this);
if (ret == 0) {
_rx_entry_cyc = k_cycle_get_32();
atomic_set(&_in_recv_mode, 1);
return;
}
@@ -608,6 +611,7 @@ void LoRaRadioBase::startReceive()
atomic_set(&_in_recv_mode, 0);
return;
}
_rx_entry_cyc = k_cycle_get_32();
atomic_set(&_in_recv_mode, 1);
}
@@ -833,12 +837,56 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
return;
}
/* Median of multiple RSSI reads (~200 us). Rejects up to N/2-1
* outliers in either direction without the downward bias of min
* or the spike sensitivity of average. Insertion sort is fine
* for N=8 (28 comparisons worst case, all in registers). */
/* GetRssiInst needs time after RX entry before the first value is
* valid (DS Table 13-82). isRadioReady() only clears BUSY, and the
* delay is measured *from* the BUSY falling edge, so BUSY alone does
* not prove the reading has settled. Only host-driven RX entries are
* stamped: under RX duty cycle the sleep->RX wakes are chip-internal
* and invisible to us. That is acceptable rather than ideal — the
* delay is ~0.25 ms at BW 62.5 against an RX window orders of
* magnitude longer, so the odds of a duty-cycle sample landing inside
* an unsettled window are small, and the median absorbs the odd one. */
uint16_t bw_khz = (uint16_t)(getActiveBandwidthKHzX10() / 10);
uint32_t since_rx_us =
k_cyc_to_us_floor32(k_cycle_get_32() - _rx_entry_cyc);
if (since_rx_us < rssi_settle_delay_us(bw_khz)) {
return;
}
/* Median of multiple RSSI reads: no downward bias of min, no spike
* sensitivity of average. Insertion sort is fine for N=8 (28
* comparisons worst case, all in registers).
*
* Scope, measured on-air 2026-07-29 (`get cad` sp field, BW 62.5):
* 84-90%% of bursts return N identical values, and the rest average
* ~6 dB of spread. The reads ARE independent -- the degenerate share
* falls and the spread rises when ambient comes up, exactly as it
* should. The burst is simply short: ~300 us against a ~200 ms
* SF8/BW62.5 packet, about 0.15%% of one transmission. So a
* neighbour's packet is either wholly inside the burst or wholly
* outside it, every read sees the same level, and the median returns
* it rather than rejecting it.
*
* What this median actually buys is rejection of sub-300 us glitches
* and single bad SPI reads. That is worth its ~300 us every 15 s, but
* it is NOT the defence against interference -- that is the
* isReceiving() guard above and the floor + SAMPLING_THRESHOLD filter
* below. An earlier comment here claimed "rejects up to N/2-1
* outliers", which credited the median with their work.
*
* Reads are spaced by the RSSI averaging window, without which they
* can all fall inside one window and return the same underlying
* sample N times — a median of N copies of one read. At BW 62.5 the
* spacing (~16 us) is already covered by the SPI transaction itself;
* it matters at the narrow presets, where the window grows past the
* whole burst. */
uint32_t window_us = rssi_avg_window_us(bw_khz);
int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK];
for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) {
if (i) {
k_busy_wait(window_us);
}
samples[i] = hwGetCurrentRSSI();
if (samples[i] == -128) {
/* Chip busy or RSSI read contended — keep the short
@@ -864,6 +912,31 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] +
samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2;
/* Burst quality, reported by `get cad`. Sorted, so max-min is the
* spread. Kept as running totals rather than an EMA so the numbers
* stay readable and the degenerate share is a true proportion.
*
* Reading it: a high zero-spread share on its own is NOT a fault — a
* quiet or steadily-occupied channel genuinely reads the same value
* N times at integer-dB resolution. What would indict the sampler is
* a high share together with a mean of 0.0, i.e. no burst ever spans
* anything: that is reads landing inside one RSSI averaging window and
* returning one sample N times over. A non-zero mean proves the reads
* are independent however high the share climbs. */
_rssi_bursts++;
_rssi_spread_sum += (uint32_t)(samples[NOISE_FLOOR_SAMPLES_PER_TICK - 1] -
samples[0]);
if (samples[NOISE_FLOOR_SAMPLES_PER_TICK - 1] == samples[0]) {
_rssi_degenerate++;
}
/* Rescale together so both derived figures survive untouched, and the
* printed count stays four digits however long the node is up. */
if (_rssi_bursts >= RSSI_BURST_STATS_CAP) {
_rssi_bursts >>= 1;
_rssi_spread_sum >>= 1;
_rssi_degenerate >>= 1;
}
/* Publish this sample for cadMaintenance(). The CAD probe needs exactly
* the same fact we just established — "is the channel at its floor right
* now?" — and used to answer it with its own single hwGetCurrentRSSI() on
@@ -1323,16 +1396,59 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap)
}
/* Terse on purpose — remote replies are capped at ~160 B over LoRa.
* Header: a:on o:1 pk:22(b21/4s) iv:15s bc:25%
* Header: a:on o:1 pk:22(b21/4s) sp:0.9/84%(312) bc:25%
* a auto on/off o offset pk operating peak
* b family base 4s symbols iv probe interval bc busy cap
* b family base 4s symbols bc busy cap
* sp RSSI burst quality: mean spread in dB across the median-of-N
* reads, the share of bursts whose spread was 0, and the burst
* count. The count is not decoration: a share without its
* denominator cannot be read, and the burst rate is not
* derivable from uptime because the sampler's guards (TX, mid-RX,
* duty-cycle sleep) block an unknown fraction of attempts.
* Level: *+1(22) 22p 18b 16f 2t 72%
* '*' = operating rung level(peak) probes busy fp tp fp-rate%%. */
* '*' = operating rung level(peak) probes busy fp tp fp-rate%%.
*
* sp replaced the probe interval here because the interval is a pref
* you already set and can read back with `get probe.interval`, whereas
* burst spread is only observable from inside the sampler.
*
* It answers one question: are the N reads independent? A non-zero
* mean proves they are, whatever the zero-spread share — a steady
* channel reads identically at integer-dB resolution, which is correct
* rather than broken. Only mean 0.0 with a high share indicts the
* sampler: that is N copies of one sample from inside a single RSSI
* averaging window (see rssi_avg_window_us() in radio_common.h).
* Measured on-air 2026-07-29 at BW 62.5: 0.6/90% quiet, 0.9/84% with
* the floor at -103 — independent, and responding the right way.
* Mean is tenths of a dB. Counters halve at RSSI_BURST_STATS_CAP, so
* the count is bounded to four digits and the figures describe a
* recent window rather than everything since boot. */
unsigned spread_mean10 = _rssi_bursts
? (unsigned)((_rssi_spread_sum * 10U + _rssi_bursts / 2U) /
_rssi_bursts)
: 0;
unsigned degen_pct = _rssi_bursts
? (unsigned)((_rssi_degenerate * 100U + _rssi_bursts / 2U) /
_rssi_bursts)
: 0;
/* The burst count is bench diagnostics, and the header competes with
* the three level rows for a 161 B remote reply — with wide level
* counters the full header pushes the last row into truncation. So
* print it only into the roomy local-console buffer; a remote reader
* still gets the mean and the share, which is the actual verdict. */
bool room_for_count = (cap >= 200);
n += snprintf(buf + n, cap > n ? cap - n : 0,
"a:%s o:%d pk:%d(b%u/4s) iv:%us bc:%u%%",
"a:%s o:%d pk:%d(b%u/4s) sp:%u.%u/%u%%",
_cad_auto ? "on" : "off", (int)_cad_offset,
(int)base + _cad_offset, base,
(unsigned)_probe_interval_s,
spread_mean10 / 10U, spread_mean10 % 10U, degen_pct);
if (room_for_count) {
n += snprintf(buf + n, cap > n ? cap - n : 0, "(%u)",
(unsigned)_rssi_bursts);
}
n += snprintf(buf + n, cap > n ? cap - n : 0, " bc:%u%%",
(unsigned)_cad_busycap_pct);
/* Only the 3 rungs around the operating offset — the far rungs are mildly
+12
View File
@@ -210,6 +210,18 @@ protected:
int16_t _sample_rssi;
bool _sample_channel_quiet;
bool _sample_fresh;
/* Cycle stamp of the last host-driven RX entry, used to skip a floor
* sample taken before GetRssiInst has settled (DS Table 13-82). */
uint32_t _rx_entry_cyc;
/* Median-of-N quality accounting, surfaced by `get cad` as sp:<mean>/<%>.
* The median only rejects outliers if the N reads are independent; if
* they land inside one RSSI averaging window they are the same sample
* N times over and the median is decorative. Spread (max-min of the
* burst) and the share of zero-spread bursts make that visible without
* a debug build. */
uint32_t _rssi_bursts;
uint32_t _rssi_spread_sum;
uint32_t _rssi_degenerate;
/* Adaptive CAD state */
struct CadLevelStats {
+52
View File
@@ -20,6 +20,58 @@
#define NOISE_FLOOR_SAMPLING_THRESHOLD 14 /* dB above floor to reject as interference */
#define DEFAULT_NOISE_FLOOR 0 /* sentinel: seed from first sample */
/* --- RSSI read timing (SX1261/2 DS rev 2.2 Table 13-82) ---
*
* The median-of-N above only rejects outliers if the N reads are actually
* independent. The chip updates RSSI once per "averaging window"; reads
* issued faster than that return the same underlying sample repeatedly, and
* the median degenerates into one read with extra SPI traffic.
*
* The published table is indexed by GFSK channel-filter bandwidth. Across
* all 19 rows the product window_us * BW_kHz lands in 921..938, so
* 936 / BW_kHz reproduces every published value to within a microsecond.
* The separate "RSSI delay" column (BUSY falling edge -> first valid sample)
* is 12x to 15x the window across the same rows.
*
* CAVEAT: Semtech documents this for GFSK only and publishes no LoRa
* equivalent. The RSSI path is the same analog/AGC chain, so these are used
* as the best available proxy not as specified LoRa figures. `get cad`
* reports the measured burst spread so the assumption stays falsifiable on
* real hardware.
*/
#define RSSI_WINDOW_BW_PRODUCT 936U /* window_us * BW_kHz, DS Table 13-82 */
#define RSSI_SETTLE_WINDOWS 16U /* delay/window is 12..15; round up */
/* Burst-stat rescale point. The counters behind `get cad`'s sp field are
* halved (all three together, so the mean and the zero-spread share are
* preserved exactly) once the burst count reaches this.
*
* Two reasons, and the display one is the hard constraint: the remote CLI
* reply is capped at CLI_REMOTE_REPLY_SIZE (161 B) and has to fit the header
* plus three level lines, so no field may grow without bound. At one burst
* per 15 s a free-running counter passes 500 000 in three months and would
* eat the level rows. 8192 keeps it to four digits forever.
*
* The second reason is that halving turns the totals into an exponential
* forgetting window, so the numbers describe recent conditions instead of
* being anchored to whatever the channel was doing at boot. Same idea as
* CAD_STATS_DECAY_MS below, which halves the CAD counters on a timer. */
#define RSSI_BURST_STATS_CAP 8192U
static inline uint32_t rssi_avg_window_us(uint16_t bw_khz)
{
uint32_t bw = bw_khz ? bw_khz : 1U;
/* ceil. Non-zero for every LoRa bandwidth (BW 500 -> 2 us), so no
* zero-spacing guard is needed. */
return (RSSI_WINDOW_BW_PRODUCT + bw - 1U) / bw;
}
static inline uint32_t rssi_settle_delay_us(uint16_t bw_khz)
{
return rssi_avg_window_us(bw_khz) * RSSI_SETTLE_WINDOWS;
}
/* Sampling cadence. The sampler used to run once per housekeeping tick and so
* inherited that 5 s period; owning an explicit interval is what let the
* periodic tick go away (see mesh/Maintenance.h). Both the 8-sample warmup and