From cc6a9ae7ac82d3f601f452da570bf51b274938e8 Mon Sep 17 00:00:00 2001 From: bin101 Date: Fri, 24 Jul 2026 14:08:31 +0200 Subject: [PATCH] feat: self-calibrate the falling-back floor from the group's live RSSI baseline The falling-back floor used to be a fixed dBm value picked by hand via the Sensitivity setting -- the right number depends on terrain, group size and antenna placement and changes every ride, making field calibration slow and imprecise. The floor now derives automatically as `baseline - margin`, where the baseline is the median slow-EMA RSSI across the intact group (see src/calibration.{h,cpp}), falling back to the historical fixed -105 dBm floor until at least 2 peers are present to calibrate against. Sensitivity 0-10 keeps its familiar ruler but now sets the margin (0-30 dB) below that baseline instead of an absolute dBm number. To make the calibration observable in the field: the range-test screen now also shows the live baseline/floor/trend per peer, and a new opt-in onboard trace ring buffer (`trace on/off/dump/clear` on the serial console) records raw/fast/slow RSSI plus baseline/floor for post-ride CSV analysis. No wire-format change (Protocol::kVersion untouched) and no NVS migration -- the `sensLvl` key and the `status` console output stay exactly as before. --- CLAUDE.md | 4 +- README.md | 30 +++-- docs/mockups/generate.py | 20 +++- docs/mockups/range-test-dropped.svg | 5 +- docs/mockups/range-test-weak.svg | 29 +++++ docs/mockups/range-test.svg | 3 +- docs/ui-mockups.md | 17 ++- platformio.ini | 2 +- src/calibration.cpp | 45 +++++++ src/calibration.h | 55 +++++++++ src/config.cpp | 24 +++- src/config.h | 81 ++++++++++--- src/main.cpp | 3 + src/roster.cpp | 76 +++++++++++- src/roster.h | 28 +++++ src/trace.cpp | 124 +++++++++++++++++++ src/trace.h | 51 ++++++++ src/ui.cpp | 53 ++++++-- test/test_calibration/test_calibration.cpp | 133 +++++++++++++++++++++ 19 files changed, 727 insertions(+), 56 deletions(-) create mode 100644 docs/mockups/range-test-weak.svg create mode 100644 src/calibration.cpp create mode 100644 src/calibration.h create mode 100644 src/trace.cpp create mode 100644 src/trace.h create mode 100644 test/test_calibration/test_calibration.cpp diff --git a/CLAUDE.md b/CLAUDE.md index c049cb8..0d685e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,9 +20,11 @@ compile anything. |---|---| | `src/protocol.{h,cpp}` | Wire format: heartbeat + warning + gossip packets, versioned (`kVersion`) | | `src/roster.{h,cpp}` | Peer tracking: dual-EMA RSSI trend, falling-back/dropped-off detection | +| `src/calibration.{h,cpp}` | Pure math for the self-calibrated falling-back floor: group-baseline median/smoothing, baseline-relative floor derivation | | `src/coop.{h,cpp}` | Cooperative drop-off confirmation: anomaly-only gossip mesh that corroborates a peer's local falling-back/dropped-off verdict across the group before it becomes an alert | | `src/ui.{h,cpp}` | OLED rendering + single-button state machine (largest file, ~1300 lines) | | `src/config.{h,cpp}` | Pin map, tunable constants, persisted per-device settings (NVS), serial console | +| `src/trace.{h,cpp}` | Opt-in onboard ring buffer of calibration samples for post-ride CSV analysis (`trace` console commands) | | `src/power.{h,cpp}` | INA219 battery reading, low-battery latch, charging detection | | `src/charging_decision.{h,cpp}` | Pure hysteresis/dwell-time state machine behind `Power::isCharging()` | | `src/radio.{h,cpp}` | SX1262 GFSK wrapper, EU868 duty-cycle budget | @@ -32,7 +34,7 @@ compile anything. | `src/main.cpp` | Entry point wiring the modules together | | `flasher/flasher.py` | Standalone GUI flasher + device-settings tool (esptool-based) | | `docs/ui-mockups.md` + `docs/mockups/*.svg` | One rendered mockup per UI screen state; regenerate via `docs/mockups/generate.py` | -| `test/` | Native unit tests (protocol, coop, charging decision, battery curve, node id roundtrips) — no hardware needed | +| `test/` | Native unit tests (protocol, coop, charging decision, battery curve, node id roundtrips, calibration) — no hardware needed | | `.github/workflows/` | CI (build + test on push) and release (tag-triggered firmware + flasher builds) | ## Build, test, flash diff --git a/README.md b/README.md index 1e0821d..fd760e9 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,9 @@ The console can now set **all** device settings (case-insensitive): | `tone <0–10>` | Set the piezo tone level (plays a test beep) | | `display <0\|15\|30\|60\|300>` | Display auto-off in seconds (0 = never) | | `beep [Hz]` | Play a test tone | +| `trace on` / `trace off` | Start/stop recording the calibration trace (raw/fast/slow RSSI + group baseline/floor per peer, ~1/s) | +| `trace dump` | Print the recorded trace as CSV, for post-ride analysis | +| `trace clear` | Discard recorded trace samples | If you don't have `pio device monitor` handy: the **Vaura Flasher** has the "Device" section for this (see the flasher section above) — same commands, with form fields. @@ -180,7 +183,7 @@ The 5-character limit isn't a round number, it's calculated: the idle screen lis ## UI mockups -[`docs/ui-mockups.md`](docs/ui-mockups.md) shows **every** screen state from `ui.cpp` as an image: boot channel selection, idle screen (alone / 1-, 2-, and 3-column rider list / muted / battery hint), send menu, incoming warning, "still dropped off" prompt, settings, stats, tone and sensitivity (both with a ruler scale), display (auto-off timeout), channel, changing the name, and range test (normal + dropped off). +[`docs/ui-mockups.md`](docs/ui-mockups.md) shows **every** screen state from `ui.cpp` as an image: boot channel selection, idle screen (alone / 1-, 2-, and 3-column rider list / muted / battery hint), send menu, incoming warning, "still dropped off" prompt, settings, stats, tone and sensitivity (both with a ruler scale), display (auto-off timeout), channel, changing the name, and range test (normal + falling back + dropped off). ## Operation (one button) @@ -274,15 +277,15 @@ Long press from the idle screen opens settings — handy on the road, without Pl - **Stats** → overview of the current tour: ride time since power-on, warnings sent and received, drop-offs. Deliberately not saved — every tour starts at zero. - **Tone** → piezo beeper frequency, shown as a **level 0–10 on a ruler scale** (level = (Hz − 2500) / 100): short press advances one level (plays a 500 ms test tone each time), wraps back to level 0 at the top end. Long press saves permanently to flash. - **Display** → display auto-off timeout: short press cycles through **Never/15 s/30 s/1 min/5 min**, long press saves permanently to flash (see "Display sleep" above). -- **Sensitivity** → sensitivity of the falling-back early warning, same **ruler scale 0–10** (default: level 5 in the middle). Each level shifts the warning threshold by 3 dB: +- **Sensitivity** → sensitivity of the falling-back early warning, same **ruler scale 0–10** (default: level 5 in the middle). The floor is no longer a fixed dBm value picked once by hand — it self-calibrates from the **group's own current baseline signal** (the median slow-smoothed RSSI across everyone still present, see "How drop-off detection works" below), and the level sets the **margin** below that baseline: - | Level | Threshold | Meaning | + | Level | Margin below baseline | Meaning | |---|---|---| - | 0 | −120 dBm | below the reception limit → practically off (drop-off detection stays active) | - | 5 | −105 dBm | default (previous fixed behavior) | - | 10 | −90 dBm | very early — warns even at a slight falling back | + | 0 | 30 dB | least sensitive — only a large sag below the group triggers | + | 5 | 15 dB | default | + | 10 | 0 dB | most sensitive — any sag below the current baseline triggers, degenerating into the pure trend check | - The second condition (a sustained drop of ≥6 dB below your own baseline) stays the same across all levels — the level only determines **at what signal strength** a warning fires. To calibrate in the field: the range test (below) shows live the value this logic judges. + The second condition (a sustained drop of ≥6 dB below your own baseline) stays the same across all levels — the level only determines **how far below the group** a rider has to sag before the warning fires. Because the floor tracks the group's own signal instead of a fixed number, it adapts automatically between rides to terrain and formation — no more re-guessing after moving to different woods or a bigger group. The floor is clamped to [−112, −80] dBm and falls back to the historical fixed −105 dBm value until at least 2 peers are present to calibrate against. To watch it live in the field: the range test (below) shows the current baseline and floor. - **Channel** → radio channel 0–9 of the group (the same one the boot-time channel prompt sets). **All devices on a ride must be on the same channel** — different channels can't hear each other at all (a channel mistake looks exactly like everyone dropping off). Long press saves, switches immediately, and clears the rider list (riders on the old channel would otherwise become false drop-offs). Default: channel 0. Useful when two groups are riding at the same time. - **Name** → as described two sections below. - **Test** → range test, see the next section. @@ -292,7 +295,7 @@ Long press from the idle screen opens settings — handy on the road, without Pl ### Range test (field test tool) -Settings → **Test** shows for one rider, live: large, the smoothed RSSI (the value the falling-back logic judges), below it the raw RSSI of the last heartbeat and its age. Short press switches the observed rider, long press exits. **No timeout, no display sleep** in this mode — a range walk takes minutes of glancing, and you explicitly started the mode. The exact tool for verifying the estimated ~400–600 m range and the `RSSI_FALLING_BACK_*` thresholds in the field (see [Verification](#verification)). +Settings → **Test** shows for one rider, live: large, the smoothed RSSI (the value the falling-back logic judges — marked `!NAME!`/`(NAME)` the same way as the idle screen list, but reflecting *this device's own* local verdict, not the group-corroborated one), below it the raw RSSI and the trend (fast − slow, in dB) for that rider, and below that the group's self-calibrated **baseline** and the **floor** currently being judged against. Short press switches the observed rider, long press exits. **No timeout, no display sleep** in this mode — a range walk takes minutes of glancing, and you explicitly started the mode. The exact tool for verifying the estimated ~400–600 m range and watching the self-calibrated floor track real conditions in the field (see [Verification](#verification)). ### Changing the name on the device (no laptop needed) @@ -311,10 +314,10 @@ Once "Name" is confirmed with a long press in settings, the name is entered char Every device sends a heartbeat with nickname + battery voltage roughly every ~2 s (slightly randomized). Every receiver derives **two** smoothed signal-strength averages (RSSI EMAs) per rider from this: a fast one ("where the signal is right now") and a slow one as a baseline ("where it usually sits", time constant ~40 s): -- **"Falling back"**: the fast average has dropped below the **adjustable threshold** (Settings → Sensitivity, level 0–10 in 3 dB steps from −120 to −90 dBm; default level 5 = −105 dBm) **and** is ≥6 dB below the slow baseline → early warning on the idle screen. Comparing against the baseline (instead of the immediately preceding heartbeat) also catches **gradual** falling back — with only one EMA, the raw RSSI would have had to collapse by ~17 dB within a single heartbeat interval. +- **"Falling back"**: the fast average has dropped below the **self-calibrated floor** — the group's own current baseline signal (median slow-EMA RSSI across everyone still present) minus a margin (Settings → Sensitivity, level 0–10 = 30 dB down to 0 dB below baseline) — **and** is ≥6 dB below its own slow baseline → early warning on the idle screen. Comparing against the baseline (instead of the immediately preceding heartbeat) also catches **gradual** falling back — with only one EMA, the raw RSSI would have had to collapse by ~17 dB within a single heartbeat interval. The floor itself adapts automatically to the group's current signal (terrain, formation, antenna placement) rather than a number picked once by hand and never revisited — see `src/calibration.{h,cpp}`. Until at least 2 peers are present to calibrate against (fresh boot, riding alone), it falls back to the historical fixed −105 dBm floor. - **"Dropped off"**: no signal at all for ~5.8 s → a clear alert with a timestamp. The threshold (`DROPPED_OFF_TIMEOUT_MS`) deliberately budgets 2 full intervals **including send jitter** plus margin — a single lost heartbeat (e.g. because two half-duplex senders overlapped) doesn't trigger a false alarm this way; it takes two missing in a row. -Both are automatic, no button press needed. Thresholds live as constants in `src/config.h` (`RSSI_FALLING_BACK_*`, `RSSI_EMA_ALPHA_*`, `DROPPED_OFF_MISSED_INTERVALS`/`DROPPED_OFF_TIMEOUT_MS`) and can be re-tuned after initial field tests. +Both are automatic, no button press needed. Thresholds live as constants in `src/config.h` (`RSSI_FALLING_BACK_DROP_DB`, `RSSI_EMA_ALPHA_*`, `CAL_*`, `DROPPED_OFF_MISSED_INTERVALS`/`DROPPED_OFF_TIMEOUT_MS`), with the baseline/floor math itself in `src/calibration.{h,cpp}`; both can be re-tuned after initial field tests. ## Radio protocol (quick reference) @@ -337,10 +340,11 @@ With **at least 2 devices**: 7. Enable **Mute** (Settings) → an incoming warning appears without a beep, the header shows `MUTE`. Turn it off again afterward. 8. **Drop-off reminder**: leave one device off → after ~60 s, 1 short reminder beep; after ~120 s, the prompt `Still dropped off: (NAME)` → long press removes the rider (disappears from the list and counter, no more reminders). Turn the device back on → the rider comes back automatically with `BACK`. 9. **Channel test**: set one device to channel 1 (Settings → Channel) → the devices lose each other (`DROPPED` after ~6 s); back to 0 → `BACK`. -10. **Sensitivity**: set it to level 10 and put one device in another room → `WEAK` comes noticeably earlier than at level 5; at level 0, `WEAK` no longer occurs at all, only the drop-off. +10. **Sensitivity**: set it to level 10 and put one device in another room → `WEAK` comes noticeably earlier than at level 5; at level 0, `WEAK` requires a much larger sag below the group before it fires (the floor is baseline-relative now, not a fixed "off" endpoint like the old design). Watch the range test's `base`/`floor` line while changing levels to see the floor move in real time. 11. **Stats** (Settings) → ride time is running, warning/drop-off counters match the previous test steps. -12. Only once these steps work on the table, test in the field (a real ride) — that's what the **range test** (Settings → Test) is for: read the actual GFSK range live over the distances relevant in a group ride (the ~400–600 m is a datasheet estimate, not a field measurement), and fine-tune the heartbeat interval/RSSI thresholds as needed. -13. **USB charging mode**: plug a device with a battery fitted into USB power → within a few seconds the charging screen appears (battery %, voltage, charge current, your name), the other device shows this one `DROPPED` shortly after (radio is suspended). Confirm the sensed current with `charge` on the console — flip `INA219_CURRENT_CHARGE_SIGN` in `src/config.h` if it reads negative while charging. Wait 10 s → the screen goes dark; press the button → it comes back for another 10 s. Unplug → the device rejoins the group (`BACK`) within a few seconds. Repeat with a **battery-less** test device on USB power only → it must stay in normal operation the whole time, never showing the charging screen. +12. Only once these steps work on the table, test in the field (a real ride) — that's what the **range test** (Settings → Test) is for: read the actual GFSK range live over the distances relevant in a group ride (the ~400–600 m is a datasheet estimate, not a field measurement), watch the self-calibrated baseline/floor settle as the group rides together, and fine-tune the heartbeat interval/margin as needed. +13. **Calibration trace**: `trace on` on the console, ride/walk around for a few minutes with 2+ devices, then `trace off` and `trace dump` — a CSV of raw/fast/slow RSSI + group baseline/floor per peer, one row per tracked peer per second, for post-ride analysis of how the floor tracked the group. `trace clear` discards it. +14. **USB charging mode**: plug a device with a battery fitted into USB power → within a few seconds the charging screen appears (battery %, voltage, charge current, your name), the other device shows this one `DROPPED` shortly after (radio is suspended). Confirm the sensed current with `charge` on the console — flip `INA219_CURRENT_CHARGE_SIGN` in `src/config.h` if it reads negative while charging. Wait 10 s → the screen goes dark; press the button → it comes back for another 10 s. Unplug → the device rejoins the group (`BACK`) within a few seconds. Repeat with a **battery-less** test device on USB power only → it must stay in normal operation the whole time, never showing the charging screen. ## Open items for later diff --git a/docs/mockups/generate.py b/docs/mockups/generate.py index db09f27..d27eeb7 100644 --- a/docs/mockups/generate.py +++ b/docs/mockups/generate.py @@ -240,16 +240,28 @@ def rename(cells, cursor_idx, footer): write("rename-active", rename(["R", "O", " ", " ", " "], 1, "short=char long=OK")) write("rename-finish", rename(["K", "L", "A", "U", "S"], 4, "long=done")) -# --- 18/19. Range test ------------------------------------------------------------ +# --- 18/19/20. Range test --------------------------------------------------------- +# Big readout + two small calibration lines (raw/trend, then group +# base/floor) -- see renderRangeTest() in ui.cpp. The two detail lines use the +# same small ruler font as the tone/sensitivity rulers below (size=5). b = header("Range test:") b += text(0, 34, "MAX -87", size=13, weight="bold") -b += text(0, 46, "raw -85 dBm 2s ago", size=8) +b += text(0, 44, "raw -85 dBm trend -2 dB", size=5) +b += text(0, 53, "base -80 dBm floor -95 dBm", size=5) b += text(0, 61, "short=rider long=exit", size=8) write("range-test", b) b = header("Range test:") -b += text(0, 34, "MAX -104", size=13, weight="bold") -b += text(0, 46, "DROPPED 40s ago", size=8) +b += text(0, 34, "!MAX! -101", size=13, weight="bold") +b += text(0, 44, "raw -103 dBm trend -7 dB", size=5) +b += text(0, 53, "base -80 dBm floor -95 dBm", size=5) +b += text(0, 61, "short=rider long=exit", size=8) +write("range-test-weak", b) + +b = header("Range test:") +b += text(0, 34, "(MAX) -104", size=13, weight="bold") +b += text(0, 44, "DROPPED 40s ago", size=5) +b += text(0, 53, "base -80 dBm floor -95 dBm", size=5) b += text(0, 61, "short=rider long=exit", size=8) write("range-test-dropped", b) diff --git a/docs/mockups/range-test-dropped.svg b/docs/mockups/range-test-dropped.svg index d55fd4d..21a5ff2 100644 --- a/docs/mockups/range-test-dropped.svg +++ b/docs/mockups/range-test-dropped.svg @@ -21,8 +21,9 @@ Range test: - MAX -104 - DROPPED 40s ago + (MAX) -104 + DROPPED 40s ago + base -80 dBm floor -95 dBm short=rider long=exit diff --git a/docs/mockups/range-test-weak.svg b/docs/mockups/range-test-weak.svg new file mode 100644 index 0000000..e6679ae --- /dev/null +++ b/docs/mockups/range-test-weak.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + Range test: + + !MAX! -101 + raw -103 dBm trend -7 dB + base -80 dBm floor -95 dBm + short=rider long=exit + + diff --git a/docs/mockups/range-test.svg b/docs/mockups/range-test.svg index fbcef77..295be17 100644 --- a/docs/mockups/range-test.svg +++ b/docs/mockups/range-test.svg @@ -22,7 +22,8 @@ Range test: MAX -87 - raw -85 dBm 2s ago + raw -85 dBm trend -2 dB + base -80 dBm floor -95 dBm short=rider long=exit diff --git a/docs/ui-mockups.md b/docs/ui-mockups.md index 4321abe..c8ee384 100644 --- a/docs/ui-mockups.md +++ b/docs/ui-mockups.md @@ -125,13 +125,22 @@ unambiguous hint. ![Range test](mockups/range-test.svg) **Range test.** Settings → Test. Live: the smoothed RSSI (the value the falling-back logic judges) -large, raw RSSI + heartbeat age small. **No** timeout, **no** display sleep — exit only via **long -press**. The field tool for the `RSSI_FALLING_BACK_*` thresholds. +large, then two small lines — raw RSSI and the trend (fast − slow, in dB) for that rider, then the +group's self-calibrated baseline and the floor currently being judged against (see +`src/calibration.{h,cpp}`). **No** timeout, **no** display sleep — exit only via **long press**. +The field tool for watching the self-calibrated floor track real conditions. + +![Range test, falling back](mockups/range-test-weak.svg) + +**Range test: falling back.** The big readout is marked `!NAME!` — the same convention as the idle +screen's rider list, but reflecting *this device's own* local verdict, not the group-corroborated +one (this screen exists to calibrate the local heuristic itself). ![Range test, dropped off](mockups/range-test-dropped.svg) -**Range test: dropped off.** If the watched rider has dropped off, the last smoothed RSSI stays -put and the detail line shows `DROPPED` plus the age of the last heartbeat. +**Range test: dropped off.** If the watched rider has dropped off, the big readout is marked +`(NAME)` and the first detail line shows `DROPPED` plus the age of the last heartbeat — the +baseline/floor line keeps showing, since both are properties of the group, not of this one peer. ## Charging screen diff --git a/platformio.ini b/platformio.ini index e0329dd..163aae1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -37,4 +37,4 @@ build_flags = platform = native test_framework = unity test_build_src = yes -build_src_filter = + + + + +build_src_filter = + + + + + diff --git a/src/calibration.cpp b/src/calibration.cpp new file mode 100644 index 0000000..25c9d7c --- /dev/null +++ b/src/calibration.cpp @@ -0,0 +1,45 @@ +#include "calibration.h" + +namespace Calibration { + +float medianDbm(float *values, int n) { + if (n <= 0) { + return 0.0f; + } + // Insertion sort: n is at most MAX_PEERS+1 (a club-ride-sized group), same + // "small n, keep it simple" tradeoff as the rider-list sort in ui.cpp. + for (int i = 1; i < n; i++) { + float key = values[i]; + int j = i - 1; + while (j >= 0 && values[j] > key) { + values[j + 1] = values[j]; + j--; + } + values[j + 1] = key; + } + if (n % 2 == 1) { + return values[n / 2]; + } + return (values[n / 2 - 1] + values[n / 2]) / 2.0f; +} + +float smoothBaseline(float prevBaselineDbm, float sampleDbm, float alpha) { + return alpha * sampleDbm + (1.0f - alpha) * prevBaselineDbm; +} + +int16_t deriveFloorDbm(float baselineDbm, int marginDb, bool baselineEstablished, int16_t fallbackFloorDbm, + int16_t floorMinDbm, int16_t floorMaxDbm) { + if (!baselineEstablished) { + return fallbackFloorDbm; + } + float floor = baselineDbm - static_cast(marginDb); + if (floor < static_cast(floorMinDbm)) { + return floorMinDbm; + } + if (floor > static_cast(floorMaxDbm)) { + return floorMaxDbm; + } + return static_cast(floor); +} + +} // namespace Calibration diff --git a/src/calibration.h b/src/calibration.h new file mode 100644 index 0000000..b4e30d4 --- /dev/null +++ b/src/calibration.h @@ -0,0 +1,55 @@ +// Pure math behind the self-calibrating "falling back" floor: instead of a +// fixed absolute RSSI floor (guessed once, by hand, and wrong as soon as the +// terrain or group size changes), the floor tracks a "group baseline" -- the +// RSSI the intact group is actually riding at right now -- minus a margin. +// A rider genuinely falling back then sags below "everyone else, right now" +// rather than below a number picked on a different ride in different woods. +// +// Deliberately Arduino-free (like coop.h/charging_decision.h) so this runs in +// the native unit-test environment (pio test -e native): every tunable is a +// function parameter, not a #define, and callers (Roster) supply the actual +// values from config.h. +#pragma once + +#include + +namespace Calibration { + +// Median of the first `n` entries of `values` (in dBm, or any comparable +// unit -- used here for the group's slow-EMA RSSI values). Sorts `values` in +// place (insertion sort: n is at most MAX_PEERS+1, same "small n, keep it +// simple" tradeoff as the rider-list sort in ui.cpp). Median rather than mean +// so a single rider already falling back (an outlier by construction) can't +// drag the baseline down with them -- it takes a majority to move it. +// Returns 0 for n <= 0; callers must gate on their own peer count/ +// `established` flag rather than trust this return value to signal validity. +float medianDbm(float *values, int n); + +// One exponential-smoothing step of the group baseline toward `sample`. +// Keeps the baseline from jumping around every second as the peer set +// changes (a rider joining/leaving shifts the instantaneous median). Same +// alpha-blend shape as Roster's own per-peer RSSI EMAs (see roster.cpp) -- +// alpha closer to 1 tracks faster, closer to 0 is steadier. +float smoothBaseline(float prevBaselineDbm, float sampleDbm, float alpha); + +// Derives the "falling back" floor: `baselineDbm - marginDb`, clamped to +// [floorMinDbm, floorMaxDbm]. Falls back to `fallbackFloorDbm` verbatim when +// `baselineEstablished` is false (too few peers around to trust a group +// baseline -- e.g. right after boot, or riding alone) -- this reproduces the +// old fixed-floor behaviour exactly until there's enough company to +// calibrate against. +int16_t deriveFloorDbm(float baselineDbm, int marginDb, bool baselineEstablished, int16_t fallbackFloorDbm, + int16_t floorMinDbm, int16_t floorMaxDbm); + +// Linear step mapping for a 0..maxLevel ruler (shared shape with the old +// fallingBackFloorDbm(): each step is worth `dbPerStep` dB) but inverted in +// sense -- `level` is now a *sensitivity*, so higher level = smaller margin = +// triggers sooner. level is clamped to [0, maxLevel] first so an +// out-of-range value (shouldn't happen; NVS load already validates) can't +// produce a negative margin. constexpr (and defined here, not in the .cpp) +// so config.h's fallingBackMarginDb() can use it in a static_assert. +constexpr int marginDbForLevel(uint8_t level, uint8_t maxLevel, int dbPerStep) { + return dbPerStep * (static_cast(maxLevel) - static_cast(level > maxLevel ? maxLevel : level)); +} + +} // namespace Calibration diff --git a/src/config.cpp b/src/config.cpp index aff58d5..4ef1b19 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -6,6 +6,7 @@ #include "power.h" // `charge` console command reads the raw INA219 charge current #include "protocol.h" #include "radio.h" // console channel changes apply immediately (applyChannel) +#include "trace.h" // `trace` console commands namespace DeviceConfig { @@ -50,6 +51,9 @@ void printHelp() { Serial.println(F("display <0|15|30|60|300> Display auto-off in seconds (0 = never)")); Serial.println(F("beep [Hz] Play a test tone, e.g.: beep 3200 (no Hz: current frequency)")); Serial.println(F("charge Show raw charge current -- for charging-mode calibration")); + Serial.println(F("trace on|off Start/stop recording the calibration trace (see 'trace dump')")); + Serial.println(F("trace dump Print the recorded calibration trace as CSV")); + Serial.println(F("trace clear Discard recorded calibration trace samples")); Serial.println(F("help Show this help")); } @@ -128,8 +132,11 @@ void handleLine(const String &lineIn) { Serial.printf("Error: expected level 0-%d.\n", FALLING_BACK_SENSITIVITY_MAX); } else { setFallingBackSensitivity(static_cast(level)); - Serial.printf("OK sensitivity=%ld (floor %d dBm)\n", level, - fallingBackFloorDbm(static_cast(level))); + // The absolute floor isn't printable here any more -- it's derived from + // the live group baseline (see Roster::activeFloorDbm(), calibration.h), + // which this module doesn't track. Report the margin, the one part of + // the calibration this setting actually controls. + Serial.printf("OK sensitivity=%ld (margin %d dB)\n", level, fallingBackMarginDb(static_cast(level))); } } else if (lower.startsWith("tone ")) { long level = parseNumericArg(line, 5); @@ -187,6 +194,19 @@ void handleLine(const String &lineIn) { tone(PIN_PIEZO, freq, 600); } } + } else if (lower.equals("trace on")) { + Trace::setEnabled(true); + Serial.printf("Trace: recording on (%u/%u samples stored).\n", static_cast(Trace::count()), + static_cast(Trace::capacity())); + } else if (lower.equals("trace off")) { + Trace::setEnabled(false); + Serial.printf("Trace: recording off (%u/%u samples stored -- 'trace dump' to read them).\n", + static_cast(Trace::count()), static_cast(Trace::capacity())); + } else if (lower.equals("trace dump")) { + Trace::dumpCsv(); + } else if (lower.equals("trace clear")) { + Trace::clear(); + Serial.println(F("Trace: cleared.")); } else if (lower.equals("charge")) { // Not part of `status` -- this is a one-time hardware calibration aid // (see config.h's INA219_CURRENT_CHARGE_SIGN comment), not a persisted diff --git a/src/config.h b/src/config.h index 0ca9ec0..8aaf4d7 100644 --- a/src/config.h +++ b/src/config.h @@ -9,6 +9,8 @@ #include +#include "calibration.h" + // Injected by platformio.ini from `git describe --tags` (SemVer tags, e.g. // "v0.1.0" or "v0.1.0-3-g8f0e765-dirty" between releases). Fallback for // build paths that don't pass the flag. @@ -135,37 +137,70 @@ // and went straight to dropped-off. The slow EMA (alpha 0.05, time constant // ~20 heartbeats = ~40 s) is "where the signal usually sits"; the fast one is // "where it is right now". -#define RSSI_FALLING_BACK_FLOOR_DBM -105 #define RSSI_FALLING_BACK_DROP_DB 6 #define RSSI_EMA_ALPHA_FAST 0.35f #define RSSI_EMA_ALPHA_SLOW 0.05f -// The floor is user-adjustable in 11 steps (0..10) via the settings menu -// ("Sensitivity"), persisted in NVS -- see DeviceConfig::fallingBackSensitivity(). -// Each step shifts the floor by 3 dB; step 5 is the historical fixed value: -// step 0 -> -120 dBm: below the receiver's sensitivity (~-111 dBm), falling -// back effectively never fires -- a clean "practically off" -// endpoint (dropped-off detection is unaffected). -// step 10 -> -90 dBm: for distant riders the floor is almost always met, so -// the check degenerates -- deliberately -- into the pure trend -// detector ("warn on any 6 dB sag against the baseline"). +// The floor used to be a fixed dBm value the rider picked by hand -- but the +// "right" number depends on terrain, group size and antenna placement, and +// changes every ride. Instead the floor now self-calibrates: Roster::tick() +// tracks a "group baseline" (the median slow-EMA RSSI across the intact +// group, see Calibration::medianDbm()/smoothBaseline() in calibration.{h,cpp}) +// and the floor becomes `baseline - margin` (Calibration::deriveFloorDbm()). +// A rider genuinely falling back then has to sag below "everyone else, right +// now", not below a number guessed on a different ride in different woods. +// +// The margin is user-adjustable in 11 steps (0..10) via the settings menu +// ("Sensitivity"), persisted in NVS as before -- see +// DeviceConfig::fallingBackSensitivity(). Each step is worth 3 dB, but the +// direction is now a genuine sensitivity (higher = more sensitive = triggers +// on a smaller sag), the opposite sense of the old absolute-floor scale: +// step 0 -> 30 dB margin: only a very large sag below the group trips it -- +// a clean "practically off" endpoint, closest to never firing. +// step 10 -> 0 dB margin: any sag below the current group baseline trips +// it, deliberately degenerating into the pure trend detector +// ("warn on any 6 dB sag against the baseline"). +// step 5 (default) -> 15 dB, replacing the historical fixed -105 dBm floor as +// the everyday middle ground. // The RSSI_FALLING_BACK_DROP_DB condition stays FIXED across all steps: // shrinking it would trip on normal multipath wobble (underpass, truck, // corner), and one explainable degree of freedom is all a one-button menu // can reasonably carry. #define FALLING_BACK_SENSITIVITY_DEFAULT 5 #define FALLING_BACK_SENSITIVITY_MAX 10 // steps 0..10 = 11 ruler positions -constexpr int16_t fallingBackFloorDbm(uint8_t level) { - return static_cast(-120 + 3 * static_cast(level)); +#define FALLING_BACK_MARGIN_DB_PER_STEP 3 +constexpr int fallingBackMarginDb(uint8_t level) { + return Calibration::marginDbForLevel(level, FALLING_BACK_SENSITIVITY_MAX, FALLING_BACK_MARGIN_DB_PER_STEP); } -static_assert(fallingBackFloorDbm(FALLING_BACK_SENSITIVITY_DEFAULT) == RSSI_FALLING_BACK_FLOOR_DBM, - "step 5 must equal the historical fixed floor"); +static_assert(fallingBackMarginDb(FALLING_BACK_SENSITIVITY_DEFAULT) == 15, + "step 5 must equal the historical fixed floor's effective margin (-105 vs. the old -90 dBm endpoint)"); // The tone menu shares the same 0..10 ruler scale (step = (Hz - min) / step // width) -- if either range changes, the two menus drift apart visually. static_assert((BEEP_FREQUENCY_MAX_HZ - BEEP_FREQUENCY_MIN_HZ) / BEEP_FREQUENCY_STEP_HZ == FALLING_BACK_SENSITIVITY_MAX, "tone and sensitivity menus share the same 0..10 ruler scale"); +// Group-baseline calibration tunables (Roster::tick(), Calibration::*). +// Smooths the per-tick median slow-EMA RSSI across the intact group into a +// stable baseline -- same alpha-blend shape as the per-peer EMAs above, but +// slower still: the baseline should track "what terrain/formation are we in +// right now", not wobble with every single tick's median. +#define CAL_BASELINE_ALPHA 0.1f +// Below this many present peers (this device's own view of the group, not +// counting the subject being judged) there aren't enough independent RSSI +// samples to trust a median -- falls back to the fixed floor below, exactly +// reproducing the pre-calibration behaviour until there's enough company. +#define CAL_MIN_PEERS_FOR_BASELINE 2 +// Fallback absolute floor while no baseline is established yet (fresh boot, +// riding alone, or too small a group) -- the historical fixed value. +#define RSSI_FALLING_BACK_FLOOR_DBM -105 +// Clamp band for the derived floor: never below the radio's real receive +// sensitivity (there's no point warning on RSSI values that can't occur), and +// never so high that a very tight, quiet pack could put it into "everything +// is a false alarm" territory. +#define CAL_FLOOR_MIN_DBM -112 +#define CAL_FLOOR_MAX_DBM -80 + // A peer is considered fully dropped ("dropped off") once its heartbeat has // been missing for this many intervals. Kept at 2 rather than 1: a single missed // heartbeat is often just a collision on a shared channel with several peers @@ -191,6 +226,22 @@ static_assert((BEEP_FREQUENCY_MAX_HZ - BEEP_FREQUENCY_MIN_HZ) / BEEP_FREQUENCY_S #define MAX_PEERS 16 +// --------------------------------------------------------------------------- +// Onboard calibration trace (Trace::, trace.{h,cpp}) +// --------------------------------------------------------------------------- +// Ring buffer of per-peer calibration samples (raw/fast/slow RSSI + group +// baseline/floor + local verdict), recorded once per Roster::tick() (~1/s) +// while recording is on (`trace on`/`trace off`/`trace dump`/`trace clear` on +// the serial console) -- for post-ride CSV analysis of how the self- +// calibrated floor actually tracked the group. Off by default: pure overhead +// otherwise, and nobody wants a ride's worth of samples dumped unasked-for. +// Prefers PSRAM (this board has 8 MB) for a ride-length buffer; falls back to +// a small static RAM allocation if no PSRAM is found (psramFound()) so the +// feature still works, just with a much shorter recording window, on a board +// without it enabled. +#define TRACE_CAPACITY_PSRAM_SAMPLES 20000 +#define TRACE_CAPACITY_RAM_FALLBACK_SAMPLES 200 + // Cooperative drop-off confirmation's own tunables (COOP_MIN_GROUP, // COOP_REFRESH_MS, quorum percentages, ...) live in coop.h, not here: that // module is deliberately Arduino-free (like protocol.h) so it runs in the @@ -321,7 +372,7 @@ uint32_t displayTimeoutMs(); void setDisplayTimeoutMs(uint32_t ms); // Persisted "falling back" sensitivity step (0..FALLING_BACK_SENSITIVITY_MAX), -// see fallingBackFloorDbm() above for the mapping. Falls back to +// see fallingBackMarginDb() above for the mapping. Falls back to // FALLING_BACK_SENSITIVITY_DEFAULT if never set. uint8_t fallingBackSensitivity(); void setFallingBackSensitivity(uint8_t level); diff --git a/src/main.cpp b/src/main.cpp index 516b6f6..8aa0d8b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,7 @@ #include "radio.h" #include "roster.h" #include "stats.h" +#include "trace.h" #include "ui.h" namespace { @@ -152,6 +153,7 @@ void setup() { Radio::begin(); Roster::begin(); Coop::begin(); + Trace::begin(); // random() (used for heartbeat jitter, see randomizedHeartbeatInterval()) // is otherwise unseeded, so devices booted around the same time would draw @@ -236,6 +238,7 @@ void loop() { if (static_cast(now - nextRosterTickMs) >= 0) { Roster::tick(now); // also drives Coop::tick() internally + Trace::sample(now); // no-op unless `trace on` -- see trace.h nextRosterTickMs = now + 1000; } diff --git a/src/roster.cpp b/src/roster.cpp index 6e7c741..a6bd2fe 100644 --- a/src/roster.cpp +++ b/src/roster.cpp @@ -2,6 +2,7 @@ #include +#include "calibration.h" #include "config.h" #include "coop.h" #include "stats.h" @@ -54,6 +55,57 @@ struct Peer { Peer peers[MAX_PEERS]; +// Self-calibrated group baseline RSSI (median slow-EMA across the intact +// group, smoothed) and whether enough peers are present to trust it -- see +// updateGroupBaseline() and calibration.h. Read by onHeartbeat() (via +// currentFloorDbm()) to derive the falling-back floor, and exposed to the +// range-test screen via Roster::groupBaselineDbm()/baselineEstablished()/ +// activeFloorDbm(). +float groupBaselineDbm_ = 0.0f; +bool baselineEstablished_ = false; + +// Recomputes groupBaselineDbm_/baselineEstablished_ from the current peer +// set. Called once per tick() (~1/s) -- onHeartbeat() (which runs far more +// often, once per received heartbeat) just reads the result rather than +// recomputing it itself, since the baseline is a property of the whole group, +// not of any one peer's heartbeat. +void updateGroupBaseline() { + float samples[MAX_PEERS]; + int n = 0; + for (auto &p : peers) { + // Same presence bar as the consensus loop in tick(): dropped-off or + // dismissed peers aren't part of "the intact group" the baseline should + // reflect, and hasPreviousEma excludes anyone whose EMAs are still + // fresh-seeded (one heartbeat old) and therefore not yet a trend. + if (p.used && !p.dismissed && !p.effectiveDropped && p.hasPreviousEma) { + samples[n++] = p.rssiEmaSlow; + } + } + if (n < CAL_MIN_PEERS_FOR_BASELINE) { + baselineEstablished_ = false; + return; + } + float median = Calibration::medianDbm(samples, n); + // Seed directly on the first tick a baseline becomes established (nothing + // sane to blend against yet); smooth on every tick after that so the + // baseline doesn't jump around with the peer set the way the instantaneous + // median would. + groupBaselineDbm_ = + baselineEstablished_ ? Calibration::smoothBaseline(groupBaselineDbm_, median, CAL_BASELINE_ALPHA) : median; + baselineEstablished_ = true; +} + +// The floor currently in effect: baseline-relative once established, the +// fixed fallback otherwise. A pure function of module state + the current +// sensitivity setting -- same value for every peer at a given moment, which +// is why Roster::activeFloorDbm() can just call this too instead of storing +// a per-peer copy. +int16_t currentFloorDbm() { + return Calibration::deriveFloorDbm(groupBaselineDbm_, fallingBackMarginDb(DeviceConfig::fallingBackSensitivity()), + baselineEstablished_, RSSI_FALLING_BACK_FLOOR_DBM, CAL_FLOOR_MIN_DBM, + CAL_FLOOR_MAX_DBM); +} + char lastEventBuf[24] = {0}; uint32_t lastEventMs = 0; @@ -193,9 +245,12 @@ void onHeartbeat(uint16_t nodeId, const char *nickname, uint16_t batteryMillivol RSSI_EMA_ALPHA_SLOW * static_cast(rssi) + (1.0f - RSSI_EMA_ALPHA_SLOW) * peer->rssiEmaSlow; bool wasLocalWeak = peer->localWeak; - // Floor is user-adjustable (0..10, settings menu); re-evaluated on every - // heartbeat, so a changed step takes effect within one interval. - int16_t floorDbm = fallingBackFloorDbm(DeviceConfig::fallingBackSensitivity()); + // Floor is self-calibrated from the group baseline (see + // updateGroupBaseline(), called once per tick()) plus the user-adjustable + // margin (0..10, settings menu); re-evaluated on every heartbeat, so a + // changed sensitivity step -- or a baseline that has since shifted -- + // takes effect within one interval. + int16_t floorDbm = currentFloorDbm(); peer->localWeak = peer->hasPreviousEma && peer->rssiEmaFast < floorDbm && peer->rssiEmaFast < (peer->rssiEmaSlow - RSSI_FALLING_BACK_DROP_DB); @@ -210,6 +265,11 @@ void onHeartbeat(uint16_t nodeId, const char *nickname, uint16_t batteryMillivol } void tick(uint32_t nowMs) { + // Refresh the self-calibrated group baseline before anything below reads + // it (onHeartbeat() already has, for heartbeats received since the last + // tick -- this just brings it current for the *next* interval). + updateGroupBaseline(); + // This device + every peer it's still tracking (dismissed ones excluded, // same headcount totalCount() already reports) -- the consensus quorum // denominator, computed once per tick rather than per peer. @@ -300,10 +360,12 @@ bool peerInfo(int index, PeerInfo &out) { strncpy(out.nickname, p.nickname, sizeof(out.nickname) - 1); out.nickname[sizeof(out.nickname) - 1] = '\0'; out.rssiDbm = static_cast(lroundf(p.rssiEmaFast)); + out.rssiSlowDbm = static_cast(lroundf(p.rssiEmaSlow)); out.lastRawRssiDbm = p.lastRawRssi; out.batteryMillivolts = p.batteryMillivolts; out.fallingBack = p.effectiveWeak; out.droppedOff = p.effectiveDropped; + out.localFallingBack = p.localWeak; out.lastSeenMs = p.lastSeenMs; return true; } @@ -376,4 +438,12 @@ uint32_t lastAlertGeneration() { return alertGeneration; } AlertType lastAlertType() { return lastAlertType_; } +int16_t groupBaselineDbm() { + return baselineEstablished_ ? static_cast(lroundf(groupBaselineDbm_)) : RSSI_FALLING_BACK_FLOOR_DBM; +} + +bool baselineEstablished() { return baselineEstablished_; } + +int16_t activeFloorDbm() { return currentFloorDbm(); } + } // namespace Roster diff --git a/src/roster.h b/src/roster.h index f88baf8..e06f0e7 100644 --- a/src/roster.h +++ b/src/roster.h @@ -23,6 +23,7 @@ struct PeerInfo { uint16_t nodeId; // for Roster::dismiss() char nickname[Protocol::kNicknameFieldLen + 1]; int16_t rssiDbm; // fast-smoothed (EMA), the value this device's own local verdict judges + int16_t rssiSlowDbm; // slow-smoothed (EMA) -- "where the signal usually sits", the trend baseline int16_t lastRawRssiDbm; // unsmoothed RSSI of the most recent heartbeat uint16_t batteryMillivolts; // 0 if the peer has no battery monitor // Group-consensus-gated, not purely local: e.g. droppedOff can be false @@ -30,6 +31,13 @@ struct PeerInfo { // rest of the group still hears them (see Roster::tick()). bool fallingBack; bool droppedOff; + // This device's own, purely local falling-back opinion (see Peer::localWeak + // in roster.cpp), *before* group-consensus gating -- distinct from + // `fallingBack` above. Exposed for the range-test screen, which is + // specifically the field-calibration tool and wants to show what this + // device itself is seeing, not the (possibly not-yet-corroborated) group + // verdict. + bool localFallingBack; uint32_t lastSeenMs; }; @@ -97,4 +105,24 @@ uint32_t lastAlertGeneration(); // lastAlertTimestampMs() is non-zero. AlertType lastAlertType(); +// The self-calibrated "group baseline" RSSI this device is currently using to +// derive the falling-back floor (median slow-EMA RSSI across the intact +// group, smoothed -- see Calibration::medianDbm()/smoothBaseline() in +// calibration.{h,cpp} and Roster::tick()). Only meaningful when +// baselineEstablished() is true; returns RSSI_FALLING_BACK_FLOOR_DBM +// verbatim otherwise (matching the fixed-floor fallback, so callers that +// don't check baselineEstablished() still show something sane). +int16_t groupBaselineDbm(); + +// True once enough peers are present to trust groupBaselineDbm() (see +// CAL_MIN_PEERS_FOR_BASELINE in config.h) -- false right after boot or when +// riding alone, in which case falling-back detection uses the fixed fallback +// floor instead. +bool baselineEstablished(); + +// The floor currently in effect for a given peer's falling-back check -- the +// same value onHeartbeat() itself computes and judges rssiDbm against; for +// the range-test screen so it can show exactly what's being compared. +int16_t activeFloorDbm(); + } // namespace Roster diff --git a/src/trace.cpp b/src/trace.cpp new file mode 100644 index 0000000..96abf81 --- /dev/null +++ b/src/trace.cpp @@ -0,0 +1,124 @@ +#include "trace.h" + +#include "config.h" +#include "roster.h" + +namespace Trace { + +namespace { +struct Sample { + uint32_t tMs; + uint16_t nodeId; + int16_t rawDbm; + int16_t fastDbm; + int16_t slowDbm; + int16_t baselineDbm; + int16_t floorDbm; + bool baselineEstablished; + bool localWeak; +}; + +Sample *buffer_ = nullptr; +size_t capacity_ = 0; +size_t writeIndex_ = 0; // next slot to write +size_t count_ = 0; // valid samples currently stored, <= capacity_ +bool enabled_ = false; + +void push(const Sample &s) { + if (capacity_ == 0) { + return; + } + buffer_[writeIndex_] = s; + writeIndex_ = (writeIndex_ + 1) % capacity_; + if (count_ < capacity_) { + count_++; + } +} +} // namespace + +void begin() { + // psramFound()/ps_malloc() are standard Arduino-ESP32 APIs -- safe to call + // even on a board without PSRAM fitted/enabled, they simply report/return + // false/nullptr in that case, and the fallback below takes over. + if (psramFound()) { + buffer_ = static_cast(ps_malloc(sizeof(Sample) * TRACE_CAPACITY_PSRAM_SAMPLES)); + if (buffer_ != nullptr) { + capacity_ = TRACE_CAPACITY_PSRAM_SAMPLES; + Serial.printf("Trace: %u-sample buffer allocated in PSRAM.\n", static_cast(capacity_)); + return; + } + Serial.println("Trace: PSRAM present but allocation failed -- falling back to a small RAM buffer."); + } + buffer_ = static_cast(malloc(sizeof(Sample) * TRACE_CAPACITY_RAM_FALLBACK_SAMPLES)); + if (buffer_ != nullptr) { + capacity_ = TRACE_CAPACITY_RAM_FALLBACK_SAMPLES; + Serial.printf("Trace: %u-sample buffer allocated in RAM (no PSRAM) -- a short recording window only.\n", + static_cast(capacity_)); + } else { + // Genuinely out of RAM (unusual, but the feature must degrade gracefully + // rather than crash the whole device over a debug tool) -- trace stays a + // permanent, harmless no-op. + capacity_ = 0; + Serial.println("Trace: buffer allocation failed entirely -- trace disabled for this boot."); + } +} + +void setEnabled(bool on) { enabled_ = on; } + +bool isEnabled() { return enabled_; } + +void clear() { + writeIndex_ = 0; + count_ = 0; +} + +void sample(uint32_t nowMs) { + if (!enabled_ || capacity_ == 0) { + return; + } + int16_t baselineDbm = Roster::groupBaselineDbm(); + int16_t floorDbm = Roster::activeFloorDbm(); + bool established = Roster::baselineEstablished(); + + Roster::PeerInfo p; + int i = 0; + while (Roster::peerInfo(i, p)) { + Sample s; + s.tMs = nowMs; + s.nodeId = p.nodeId; + s.rawDbm = p.lastRawRssiDbm; + s.fastDbm = p.rssiDbm; + s.slowDbm = p.rssiSlowDbm; + s.baselineDbm = baselineDbm; + s.floorDbm = floorDbm; + s.baselineEstablished = established; + s.localWeak = p.localFallingBack; + push(s); + i++; + } +} + +void dumpCsv() { + if (capacity_ == 0) { + Serial.println("Trace: no buffer allocated."); + return; + } + Serial.println("t_ms,node_id,raw_dbm,fast_dbm,slow_dbm,baseline_dbm,floor_dbm,baseline_established,local_weak"); + // Oldest first: when the ring hasn't wrapped yet, that's just index 0.. + // count_-1; once wrapped, the oldest sample is the one about to be + // overwritten next (writeIndex_), and index 0..count_-1 is read starting + // there, wrapping around the array. + size_t start = (count_ < capacity_) ? 0 : writeIndex_; + for (size_t k = 0; k < count_; k++) { + const Sample &s = buffer_[(start + k) % capacity_]; + Serial.printf("%lu,%04X,%d,%d,%d,%d,%d,%d,%d\n", static_cast(s.tMs), s.nodeId, s.rawDbm, + s.fastDbm, s.slowDbm, s.baselineDbm, s.floorDbm, s.baselineEstablished ? 1 : 0, + s.localWeak ? 1 : 0); + } +} + +size_t capacity() { return capacity_; } + +size_t count() { return count_; } + +} // namespace Trace diff --git a/src/trace.h b/src/trace.h new file mode 100644 index 0000000..24c4ad9 --- /dev/null +++ b/src/trace.h @@ -0,0 +1,51 @@ +// Onboard calibration trace: an opt-in ring buffer of per-peer RSSI/EMA/ +// baseline/floor/verdict samples, recorded once per Roster::tick() (~1/s), +// for post-ride analysis of how the self-calibrated falling-back floor +// (see calibration.{h,cpp}) actually tracked the group -- the field-test +// evidence complementing the range-test screen's live readout (ui.cpp). +// +// Entirely passive: never influences any detection logic, just observes it. +// Off by default and controlled via the serial console (`trace on`/`off`/ +// `dump`/`clear`, see config.cpp) -- nobody wants a ride's worth of samples +// dumped unasked-for, and recording costs nothing while disabled. +#pragma once + +#include + +namespace Trace { + +// Allocates the ring buffer (PSRAM if available, else a small RAM fallback -- +// see TRACE_CAPACITY_*_SAMPLES in config.h). Call once from setup(), after +// Serial.begin() (logs which backing store it picked). +void begin(); + +// Starts/stops recording. Disabling does not clear what's already recorded -- +// `trace dump` still works afterwards, and re-enabling resumes appending +// where it left off (the ring wraps once full, oldest samples first to go). +void setEnabled(bool on); +bool isEnabled(); + +// Discards all recorded samples (but keeps the allocated buffer and the +// enabled/disabled state). +void clear(); + +// Records one row per currently-tracked peer (via Roster::peerInfo()) plus +// the group baseline/floor in effect right now (Roster::groupBaselineDbm()/ +// activeFloorDbm()/baselineEstablished()). No-op if disabled or the buffer +// failed to allocate. Call once per Roster::tick() interval, right after it, +// from main.cpp's loop() -- deliberately not called from inside Roster +// itself, so this debug-only feature can never affect the safety-relevant +// module it observes. +void sample(uint32_t nowMs); + +// Prints the whole ring buffer as CSV (header row first, oldest sample +// first) over Serial, for the `trace dump` console command. Blocking -- +// meant to be run stopped at the roadside after a ride, not mid-ride. +void dumpCsv(); + +// Capacity actually allocated and how many samples are currently stored -- +// for the `trace on`/`trace dump` command echoes. +size_t capacity(); +size_t count(); + +} // namespace Trace diff --git a/src/ui.cpp b/src/ui.cpp index 9c7009e..ebd0b9d 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -50,8 +50,9 @@ constexpr size_t kSettingsItemBack = 8; size_t settingsIndex = 0; // RangeTest ("Test" in settings): live RSSI readout for one chosen peer, the -// field tool for verifying real-world range and tuning the RSSI_FALLING_BACK_* -// thresholds (see README "Verification"). Index into the roster's slot order. +// field tool for verifying real-world range and watching the self-calibrated +// falling-back floor track the group baseline (see README "Verification" and +// calibration.h). Index into the roster's slot order. int rangeTestIndex = 0; // Mute switch for all alert beeps. Deliberately NOT persisted: after a restart @@ -633,22 +634,54 @@ void renderRangeTest() { } } + // Big readout: name + fast-smoothed RSSI, marked with the same "!NAME!"/ + // "(NAME)" convention as the rider list (decorateNickname()) but keyed on + // this device's own *local* verdict (p.localFallingBack), not the + // group-consensus-gated p.fallingBack -- this screen exists specifically to + // calibrate the local heuristic, so it shows what this device itself is + // seeing even if the group hasn't (yet, or ever, with too few observers) + // corroborated it. display.setFont(u8g2_font_9x15B_tf); - char big[24]; - snprintf(big, sizeof(big), "%s %d", p.nickname, p.rssiDbm); + char deco[18]; + const char *markerFmt = p.droppedOff ? "(%s)" : (p.localFallingBack ? "!%s!" : "%s"); + snprintf(deco, sizeof(deco), markerFmt, p.nickname); + char big[26]; + snprintf(big, sizeof(big), "%s %d", deco, p.rssiDbm); display.drawStr(0, 34, big); - display.setFont(u8g2_font_6x10_tf); - char detail[28]; + // Two compact calibration lines in the small ruler font (u8g2_font_4x6_tf, + // also used by drawLevelRuler() below) -- there's no room for two more + // full 6x10 lines above the footer, and this detail is meant to be read + // stopped at the roadside while calibrating, not at a glance while riding. + display.setFont(u8g2_font_4x6_tf); + char calLine1[32]; if (p.droppedOff) { uint32_t ageS = (millis() - p.lastSeenMs) / 1000; - snprintf(detail, sizeof(detail), "DROPPED %lus ago", static_cast(ageS)); + snprintf(calLine1, sizeof(calLine1), "DROPPED %lus ago", static_cast(ageS)); } else { - snprintf(detail, sizeof(detail), "raw %d dBm %lus ago", p.lastRawRssiDbm, - static_cast((millis() - p.lastSeenMs) / 1000)); + // trend = fast EMA minus slow EMA -- the same quantity onHeartbeat() + // compares against -RSSI_FALLING_BACK_DROP_DB; negative means sagging + // away from this peer's own baseline, regardless of the group floor. + int trend = p.rssiDbm - p.rssiSlowDbm; + snprintf(calLine1, sizeof(calLine1), "raw %d dBm trend %+d dB", p.lastRawRssiDbm, trend); } - display.drawStr(0, 46, detail); + display.drawStr(0, 44, calLine1); + + // Group baseline + the floor actually being judged against right now (see + // Roster::groupBaselineDbm()/activeFloorDbm(), calibration.h) -- shown + // regardless of whether the *subject* above is dropped, since both are + // properties of the group, not of any one peer. + char calLine2[32]; + if (Roster::baselineEstablished()) { + snprintf(calLine2, sizeof(calLine2), "base %d dBm floor %d dBm", Roster::groupBaselineDbm(), + Roster::activeFloorDbm()); + } else { + snprintf(calLine2, sizeof(calLine2), "base n/a (need %d+ peers) floor %d", CAL_MIN_PEERS_FOR_BASELINE, + Roster::activeFloorDbm()); + } + display.drawStr(0, 53, calLine2); + display.setFont(u8g2_font_6x10_tf); display.drawStr(0, 61, "short=rider long=exit"); } diff --git a/test/test_calibration/test_calibration.cpp b/test/test_calibration/test_calibration.cpp new file mode 100644 index 0000000..2376963 --- /dev/null +++ b/test/test_calibration/test_calibration.cpp @@ -0,0 +1,133 @@ +// Native unit tests for the self-calibrating falling-back floor's pure math +// (src/calibration.cpp). Run with: pio test -e native +#include + +#include "calibration.h" + +void setUp() {} +void tearDown() {} + +// --- medianDbm --------------------------------------------------------- + +void test_median_single_value() { + float v[] = {-90.0f}; + TEST_ASSERT_EQUAL_FLOAT(-90.0f, Calibration::medianDbm(v, 1)); +} + +void test_median_odd_count_unsorted_input() { + float v[] = {-70.0f, -100.0f, -85.0f}; + // Sorted: -100, -85, -70 -- middle is -85. + TEST_ASSERT_EQUAL_FLOAT(-85.0f, Calibration::medianDbm(v, 3)); +} + +void test_median_even_count_averages_middle_two() { + float v[] = {-90.0f, -80.0f, -100.0f, -70.0f}; + // Sorted: -100, -90, -80, -70 -- middle two are -90 and -80 -> -85. + TEST_ASSERT_EQUAL_FLOAT(-85.0f, Calibration::medianDbm(v, 4)); +} + +void test_median_single_outlier_does_not_dominate() { + // Three riders riding together around -85 dBm, one already falling back + // hard at -115 dBm -- the median should stay close to the healthy group, + // not get dragged toward the outlier the way a mean would. + float v[] = {-84.0f, -86.0f, -85.0f, -115.0f}; + float median = Calibration::medianDbm(v, 4); + TEST_ASSERT_TRUE(median > -90.0f); +} + +void test_median_zero_or_negative_count_returns_zero() { + float v[] = {-90.0f}; + TEST_ASSERT_EQUAL_FLOAT(0.0f, Calibration::medianDbm(v, 0)); + TEST_ASSERT_EQUAL_FLOAT(0.0f, Calibration::medianDbm(v, -1)); +} + +// --- smoothBaseline ------------------------------------------------------ + +void test_smooth_baseline_alpha_zero_keeps_previous() { + TEST_ASSERT_EQUAL_FLOAT(-90.0f, Calibration::smoothBaseline(-90.0f, -70.0f, 0.0f)); +} + +void test_smooth_baseline_alpha_one_jumps_to_sample() { + TEST_ASSERT_EQUAL_FLOAT(-70.0f, Calibration::smoothBaseline(-90.0f, -70.0f, 1.0f)); +} + +void test_smooth_baseline_blends_partially() { + // alpha 0.1 blend of prev=-100, sample=-90 -> -100 + 0.1*10 = -99. + TEST_ASSERT_FLOAT_WITHIN(0.01f, -99.0f, Calibration::smoothBaseline(-100.0f, -90.0f, 0.1f)); +} + +// --- deriveFloorDbm ------------------------------------------------------ + +void test_derive_floor_uses_fallback_when_not_established() { + int16_t floor = Calibration::deriveFloorDbm(/*baselineDbm=*/-70.0f, /*marginDb=*/15, /*baselineEstablished=*/false, + /*fallbackFloorDbm=*/-105, /*floorMinDbm=*/-112, /*floorMaxDbm=*/-80); + TEST_ASSERT_EQUAL_INT16(-105, floor); +} + +void test_derive_floor_baseline_minus_margin_when_established() { + int16_t floor = Calibration::deriveFloorDbm(/*baselineDbm=*/-85.0f, /*marginDb=*/15, /*baselineEstablished=*/true, + /*fallbackFloorDbm=*/-105, /*floorMinDbm=*/-112, /*floorMaxDbm=*/-80); + TEST_ASSERT_EQUAL_INT16(-100, floor); +} + +void test_derive_floor_clamps_to_min() { + // A very weak group baseline (-110) minus a small margin would compute + // below the receiver's real sensitivity -- clamp to floorMinDbm instead of + // producing a floor that can never actually be crossed. + int16_t floor = Calibration::deriveFloorDbm(/*baselineDbm=*/-110.0f, /*marginDb=*/5, /*baselineEstablished=*/true, + /*fallbackFloorDbm=*/-105, /*floorMinDbm=*/-112, /*floorMaxDbm=*/-80); + TEST_ASSERT_EQUAL_INT16(-112, floor); +} + +void test_derive_floor_clamps_to_max() { + // A very strong, tight group baseline (-60) with a small margin would push + // the floor implausibly high -- clamp so an ordinary signal dip a bit below + // the pack doesn't become a permanent false alarm. + int16_t floor = Calibration::deriveFloorDbm(/*baselineDbm=*/-60.0f, /*marginDb=*/0, /*baselineEstablished=*/true, + /*fallbackFloorDbm=*/-105, /*floorMinDbm=*/-112, /*floorMaxDbm=*/-80); + TEST_ASSERT_EQUAL_INT16(-80, floor); +} + +// --- marginDbForLevel ------------------------------------------------------ + +void test_margin_default_level_matches_historical_floor_margin() { + // Level 5 of 0..10 at 3 dB/step must equal 15 dB -- the margin that + // reproduces the historical fixed -105 dBm floor's effective sensitivity + // (see config.h's fallingBackMarginDb() static_assert). + TEST_ASSERT_EQUAL_INT(15, Calibration::marginDbForLevel(5, 10, 3)); +} + +void test_margin_max_level_is_most_sensitive_zero_margin() { + TEST_ASSERT_EQUAL_INT(0, Calibration::marginDbForLevel(10, 10, 3)); +} + +void test_margin_zero_level_is_least_sensitive_full_margin() { + TEST_ASSERT_EQUAL_INT(30, Calibration::marginDbForLevel(0, 10, 3)); +} + +void test_margin_level_above_max_clamps_to_max_level() { + // Shouldn't happen (NVS load already validates), but must not produce a + // negative margin if it ever did. + TEST_ASSERT_EQUAL_INT(0, Calibration::marginDbForLevel(255, 10, 3)); +} + +int main(int, char **) { + UNITY_BEGIN(); + RUN_TEST(test_median_single_value); + RUN_TEST(test_median_odd_count_unsorted_input); + RUN_TEST(test_median_even_count_averages_middle_two); + RUN_TEST(test_median_single_outlier_does_not_dominate); + RUN_TEST(test_median_zero_or_negative_count_returns_zero); + RUN_TEST(test_smooth_baseline_alpha_zero_keeps_previous); + RUN_TEST(test_smooth_baseline_alpha_one_jumps_to_sample); + RUN_TEST(test_smooth_baseline_blends_partially); + RUN_TEST(test_derive_floor_uses_fallback_when_not_established); + RUN_TEST(test_derive_floor_baseline_minus_margin_when_established); + RUN_TEST(test_derive_floor_clamps_to_min); + RUN_TEST(test_derive_floor_clamps_to_max); + RUN_TEST(test_margin_default_level_matches_historical_floor_margin); + RUN_TEST(test_margin_max_level_is_most_sensitive_zero_margin); + RUN_TEST(test_margin_zero_level_is_least_sensitive_full_margin); + RUN_TEST(test_margin_level_above_max_clamps_to_max_level); + return UNITY_END(); +}