feat(c6_sync_espnow): EMA-smooth cross-board offset, expose via get_epoch_us

SOTA iter 5 — converted the iter 4 ADR-110 §A0.8 closing recommendation
("host-side Kalman / linear fit on the offset trajectory") into a
firmware-side, fixed-point EMA so every downstream consumer of
c6_sync_espnow_get_epoch_us() gets bounded-jitter timestamps for free.

Implementation:
* α = 1/8 (Q3.3 shift = 3), ≈8-sample effective window at the 10 Hz
  beacon rate. Tracks the ≈1.4 ppm crystal drift §A0.8 measured while
  averaging out per-beacon WiFi-MAC jitter spikes.
* y[n] = y[n-1] + (raw - y[n-1]) >> 3  — integer arithmetic, two cycles
  on the RISC-V LP/HP cores, no float dependency.
* Seeded from the first follower-mode sample so we don't bias toward 0.
* New getter: int64_t c6_sync_espnow_get_offset_us_smoothed(void).
* c6_sync_espnow_get_offset_us() (raw) stays for diagnostics, unchanged.
* c6_sync_espnow_get_epoch_us() now prefers the smoothed offset once
  s_smoothed_seeded — meaning every CSI frame timestamp ADR-029/030
  consumes is already filtered, no host-side rework required.

Diag log line now prints both:
  c6_espnow: tx#N ... offset_us=R smoothed=S

90 s bench verification (witness §A0.9 + iter5-COM9-ema-90s.log) shows
both values tracking. Methodology caveat in §A0.9: short windows don't
let the smoothing benefit emerge over the raw noise floor — the
suppression ratio measurement needs ≥5 min, deferred to a long-soak
iteration.

Binary size cost: ~32 bytes (one int64, one bool, one getter). C6 build
still 45% partition slack.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-05-23 12:04:22 -04:00
parent 676297c48f
commit f41f5fc85b
3 changed files with 45 additions and 4 deletions
+35 -4
View File
@@ -54,6 +54,21 @@ static uint32_t s_tx_fail = 0;
static uint32_t s_rx_count = 0;
static uint32_t s_rx_magic_match = 0;
/* ADR-110 P10 — EMA-smoothed offset (host-side trajectory in firmware).
*
* The §A0.8 four-minute soak measured 540 µs sample-stdev around a true
* offset that drifts at ≈1.4 ppm between two C6 crystals. An exponential
* moving average with α=0.125 (Q3.3 fixed-point shift = 3) yields an
* effective ~8-sample window, fast enough to track the drift (~7 µs/sec
* worst-case) while suppressing the per-beacon WiFi-MAC jitter.
*
* Two consumers: get_offset_us() (raw, unchanged — for diagnostics) and
* get_offset_us_smoothed() (filtered — what CSI frames should stamp).
* Both expose `int64_t` so call sites stay identical. */
#define OFFSET_EMA_SHIFT 3 /* α = 1/8 = 0.125 */
static int64_t s_offset_us_smoothed = 0;
static bool s_smoothed_seeded = false;
static uint64_t mac6_to_u64(const uint8_t mac[6])
{
return ((uint64_t)mac[0] << 40) | ((uint64_t)mac[1] << 32) |
@@ -75,10 +90,11 @@ static void send_beacon(void)
if (r != ESP_OK) s_tx_fail++;
/* Diag log every 50 beacons. */
if ((s_tx_count % 50) == 1) {
ESP_LOGI(TAG, "tx#%lu (fail=%lu) rx#%lu (match=%lu) leader=%d offset_us=%lld",
ESP_LOGI(TAG, "tx#%lu (fail=%lu) rx#%lu (match=%lu) leader=%d offset_us=%lld smoothed=%lld",
(unsigned long)s_tx_count, (unsigned long)s_tx_fail,
(unsigned long)s_rx_count, (unsigned long)s_rx_magic_match,
(int)s_is_leader, (long long)s_offset_us);
(int)s_is_leader, (long long)s_offset_us,
(long long)s_offset_us_smoothed);
}
}
@@ -115,8 +131,16 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
/* If accepted leader, compute offset from their epoch (only for non-leader). */
if (b->leader_flag && !s_is_leader && sender_id == s_leader_id) {
s_offset_us = (int64_t)b->leader_epoch_us - (int64_t)now_us;
int64_t raw = (int64_t)b->leader_epoch_us - (int64_t)now_us;
s_offset_us = raw;
s_last_seen_us = now_us;
/* EMA: y[n] = y[n-1] + (raw - y[n-1]) >> SHIFT */
if (!s_smoothed_seeded) {
s_offset_us_smoothed = raw;
s_smoothed_seeded = true;
} else {
s_offset_us_smoothed += (raw - s_offset_us_smoothed) >> OFFSET_EMA_SHIFT;
}
}
}
@@ -189,11 +213,18 @@ esp_err_t c6_sync_espnow_init(void)
uint64_t c6_sync_espnow_get_epoch_us(void)
{
return (uint64_t)((int64_t)esp_timer_get_time() + s_offset_us);
/* Prefer the smoothed offset once we've heard a leader beacon; falls
* back to raw=0 on the leader board and during the first second after
* follower boot. The smoothed value is what CSI frames should stamp
* for cross-board multistatic alignment (§A0.8 measured 540 µs raw
* stdev → expected <100 µs smoothed with α=1/8 over ~8 samples). */
int64_t off = s_smoothed_seeded ? s_offset_us_smoothed : s_offset_us;
return (uint64_t)((int64_t)esp_timer_get_time() + off);
}
bool c6_sync_espnow_is_leader(void) { return s_is_leader; }
int64_t c6_sync_espnow_get_offset_us(void) { return s_offset_us; }
int64_t c6_sync_espnow_get_offset_us_smoothed(void) { return s_offset_us_smoothed; }
bool c6_sync_espnow_is_valid(void)
{
@@ -48,6 +48,15 @@ bool c6_sync_espnow_is_leader(void);
bool c6_sync_espnow_is_valid(void);
int64_t c6_sync_espnow_get_offset_us(void);
/**
* EMA-smoothed offset (α=1/8, ~8-sample effective window at the 10 Hz
* beacon rate). Tracks the ≈1.4 ppm crystal drift between two C6 boards
* (measured in §A0.8) while suppressing the 540 µs per-beacon WiFi-MAC
* jitter. CSI frame timestamps should stamp from this value, not the raw
* offset — `c6_sync_espnow_get_epoch_us()` already does so internally.
*/
int64_t c6_sync_espnow_get_offset_us_smoothed(void);
/* Counters for the witness harness — exposed for tests/diagnostics. */
uint32_t c6_sync_espnow_tx_count(void);
uint32_t c6_sync_espnow_tx_fail(void);