Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 67 additions & 24 deletions firmware/esp32-csi-node/main/edge_processing.c
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ static int64_t s_fall_last_alert_us; /**< Timestamp of last fall alert (deboun

/** Adaptive calibration state. */
static bool s_calibrated;
static float s_floor;
static float s_calib_sum;
static float s_calib_sum_sq;
static uint32_t s_calib_count;
Expand Down Expand Up @@ -533,33 +534,65 @@ static bool presence_flag_update(bool prev, float score, float threshold,
return true; /* Still within the hold window — keep asserting. */
}

/* ======================================================================
* Adaptive Presence Calibration
* ====================================================================== */

/* Continuously track the quiet floor instead of calibrating once at boot.
*
* The old scheme averaged the first EDGE_CALIB_FRAMES frames and latched the
* result for the lifetime of the boot. That cannot be made to work: the node
* is calibrating in the seconds right after a human walked over and plugged it
* in, so the window is contaminated by construction -- and nobody can leave a
* room fast enough to fix it. Whatever the room looked like during that one
* minute became "normal" permanently, and a node commissioned during a busy
* afternoon stayed desensitised for weeks.
*
* A leaky minimum needs no clean room and no cooperation. It drops instantly
* to any new low, so it finds the quiet by itself whenever quiet next happens
* -- minutes after a bad start, not never. It rises only slowly, so a genuine
* change in the room (furniture moved, a fan installed) is eventually adopted
* while a person standing still for a while is not mistaken for the new floor.
*
* Measured support for the shape: on this fleet the room's true floor is about
* 14x below where the animals sit and the settling period after human activity
* runs a couple of hours. A tracker that only ever descends to real quiet, and
* climbs at well under a percent a minute, resolves that; an average over one
* arbitrary minute cannot.
*/
static void calibration_update(float motion)
{
if (s_calibrated) return;

s_calib_sum += motion;
s_calib_sum_sq += motion * motion;
s_calib_count++;
/* Warm-up: seed from the first frames so the floor starts somewhere
* plausible rather than at whatever the very first sample happened to be. */
if (!s_calibrated) {
s_calib_sum += motion;
s_calib_count++;
if (s_calib_count >= EDGE_CALIB_FRAMES) {
s_floor = s_calib_sum / (float)s_calib_count;
s_calibrated = true;
ESP_LOGI(TAG, "floor seeded at %.4f from %lu frames; tracking "
"continuously from here (no clean-room assumption)",
s_floor, (unsigned long)s_calib_count);
}
return;
}

if (s_calib_count >= EDGE_CALIB_FRAMES) {
float mean = s_calib_sum / (float)s_calib_count;
float var = (s_calib_sum_sq / (float)s_calib_count) - (mean * mean);
float sigma = (var > 0.0f) ? sqrtf(var) : 0.001f;
/* Descend immediately to any new low; climb slowly otherwise. */
if (motion < s_floor) {
s_floor = motion;
} else {
s_floor *= EDGE_FLOOR_LEAK;
}
if (s_floor < 1e-5f) {
s_floor = 1e-5f;
}

s_adaptive_threshold = mean + EDGE_CALIB_SIGMA_MULT * sigma;
if (s_adaptive_threshold < 0.01f) {
s_adaptive_threshold = 0.01f;
}
s_adaptive_threshold = s_floor * EDGE_FLOOR_MULT;
if (s_adaptive_threshold < 0.01f) {
s_adaptive_threshold = 0.01f;
}

s_calibrated = true;
ESP_LOGI(TAG, "Adaptive calibration complete: mean=%.4f sigma=%.4f "
"threshold=%.4f (from %lu frames)",
mean, sigma, s_adaptive_threshold,
(unsigned long)s_calib_count);
/* Report occasionally: a floor that never descends means the room is never
* quiet, which is a siting problem rather than a firmware one. */
static uint32_t s_floor_log;
if ((++s_floor_log % 2000) == 0) {
ESP_LOGI(TAG, "floor %.4f threshold %.4f", s_floor, s_adaptive_threshold);
}
}

Expand Down Expand Up @@ -1189,8 +1222,18 @@ static void process_frame(const edge_ring_slot_t *slot)
/* --- Step 9: Presence detection --- */
s_presence_score = s_motion_energy;

/* Adaptive calibration: learn ambient noise level from first N frames. */
if (!s_calibrated && s_cfg.presence_thresh == 0.0f) {
/* Adaptive floor: runs on EVERY frame, not only during warm-up.
*
* This guard used to read `!s_calibrated && ...`, which meant the tracker
* stopped the moment warm-up finished -- so the leaky minimum below it
* never executed and the floor stayed frozen at whatever the seeding
* window happened to average. The continuous tracker was dead code past
* its first 1200 frames, which is precisely the boot-only behaviour it was
* written to replace.
*
* `presence_thresh != 0` still means the operator pinned a fixed threshold
* and does not want an adaptive one, so that half of the guard stays. */
if (s_cfg.presence_thresh == 0.0f) {
calibration_update(s_motion_energy);
}

Expand Down
4 changes: 4 additions & 0 deletions firmware/esp32-csi-node/main/edge_processing.h
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ static inline uint8_t edge_evidence_person_count(bool presence, uint8_t active_c
#define EDGE_CALIB_FRAMES 1200 /**< Frames for adaptive calibration (~60s at 20 Hz). */
#define EDGE_CALIB_SIGMA_MULT 3.0f /**< Threshold = mean + 3*sigma of ambient. */

#define EDGE_FLOOR_LEAK 1.000002f
/* Presence threshold as a multiple of the tracked floor. */
#define EDGE_FLOOR_MULT 4.0f

/* ---- Fall detection ---- */
#define EDGE_FALL_COOLDOWN_MS 5000 /**< Minimum ms between fall alerts (debounce). */
#define EDGE_FALL_CONSEC_MIN 3 /**< Consecutive frames above threshold to trigger. */
Expand Down
1 change: 1 addition & 0 deletions firmware/esp32-csi-node/tests/host/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
test_adaptive_controller
test_rv_feature_state
test_rv_mesh
test_edge_calibration_floor
*.o
12 changes: 11 additions & 1 deletion firmware/esp32-csi-node/tests/host/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,21 @@ FEATURE_STATE_SRCS := $(MAIN_DIR)/rv_feature_state.c
# before including the .c. The decide() body itself has no ESP-IDF deps.
# Simpler: just recompile decide() here via a small shim.

TESTS := test_adaptive_controller test_rv_feature_state test_rv_mesh
TESTS := test_adaptive_controller test_rv_feature_state test_rv_mesh test_edge_calibration_floor

all: $(TESTS)

test_adaptive_controller: test_adaptive_controller.c $(MAIN_DIR)/adaptive_controller_decide.c $(MAIN_DIR)/adaptive_controller.h $(MAIN_DIR)/rv_radio_ops.h
$(CC) $(CFLAGS) test_adaptive_controller.c $(MAIN_DIR)/adaptive_controller_decide.c -o $@ $(LDLIBS)

# BACKLOG row 127 / PR #1821 dead-code floor guard. edge_processing.c is not
# host-linkable (FreeRTOS/esp_log/esp_timer/nvs_config/csi_collector/
# mmwave_sensor/wasm_runtime/stream_sender), so this test mirrors the guard
# and calibration_update() logic against edge_processing.h's real constants.
# See the test file's header comment for the exact source line mapping.
test_edge_calibration_floor: test_edge_calibration_floor.c $(MAIN_DIR)/edge_processing.h
$(CC) $(CFLAGS) test_edge_calibration_floor.c -o $@ $(LDLIBS)

test_rv_feature_state: test_rv_feature_state.c $(FEATURE_STATE_SRCS) $(MAIN_DIR)/rv_feature_state.h $(MAIN_DIR)/rv_radio_ops.h
$(CC) $(CFLAGS) test_rv_feature_state.c $(FEATURE_STATE_SRCS) -o $@ $(LDLIBS)

Expand All @@ -52,6 +60,8 @@ check: all
./test_rv_feature_state
@echo ""
./test_rv_mesh
@echo ""
./test_edge_calibration_floor

clean:
rm -f $(TESTS) *.o
Expand Down
197 changes: 197 additions & 0 deletions firmware/esp32-csi-node/tests/host/test_edge_calibration_floor.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* Host unit test for the adaptive floor guard fixed in this branch
* (BACKLOG row 127 / PR #1821: "PR #1821 dead-code floor").
*
* `edge_processing.c` cannot be linked on host: process_frame() and
* calibration_update() are `static` inside a ~1500-line translation unit
* that pulls in FreeRTOS, esp_log, esp_timer, nvs_config, csi_collector,
* mmwave_sensor, wasm_runtime and stream_sender. None of those are
* available (or meaningful) outside the target, and stubbing all of them
* just to reach two static functions is not a proportionate change to a
* one-line guard fix.
*
* Instead this test mirrors the exact two pieces of logic the bug lived
* in, quoting them verbatim so drift is visible in review:
*
* edge_processing.c:1224 (fixed, this branch)
* if (s_cfg.presence_thresh == 0.0f) {
* calibration_update(s_motion_energy);
* }
*
* edge_processing.c:1224 (BUGGY, pre-fix / contrib/adaptive-floor@9cce83c6)
* if (!s_calibrated && s_cfg.presence_thresh == 0.0f) {
* calibration_update(s_motion_energy);
* }
*
* calibration_update() body, edge_processing.c:559-597 -- reproduced
* verbatim below as mirror_calibration_update(), sans the ESP_LOGI
* calls (no host logging shim exists or is needed for this test).
*
* The constants (EDGE_CALIB_FRAMES, EDGE_FLOOR_LEAK, EDGE_FLOOR_MULT) are
* NOT duplicated -- they're pulled from the real edge_processing.h, so
* this test tracks the real tuning if it ever changes.
*
* Build + run (from this directory):
* make -f Makefile
* ./test_edge_calibration_floor
*/

#include <assert.h>
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

#include "edge_processing.h"

static int g_pass = 0, g_fail = 0;

#define CHECK(cond, msg) do { \
if (cond) { g_pass++; } \
else { g_fail++; printf(" FAIL: %s (line %d)\n", msg, __LINE__); } \
} while (0)

/* Mirrors s_calibrated / s_floor / s_calib_sum / s_calib_count /
* s_adaptive_threshold from edge_processing.c:394-399. */
typedef struct {
bool calibrated;
float floor;
float calib_sum;
uint32_t calib_count;
float adaptive_threshold;
} calib_state_t;

static void calib_state_reset(calib_state_t *st)
{
/* Mirrors edge_processing.c:1479-1483. */
st->calibrated = false;
st->calib_sum = 0.0f;
st->calib_count = 0;
st->floor = 0.0f;
st->adaptive_threshold = 0.05f;
}

/* Verbatim port of calibration_update(), edge_processing.c:559-597,
* minus the two ESP_LOGI calls (no host equivalent, and logging is not
* part of the property under test). */
static void mirror_calibration_update(calib_state_t *st, float motion)
{
if (!st->calibrated) {
st->calib_sum += motion;
st->calib_count++;
if (st->calib_count >= EDGE_CALIB_FRAMES) {
st->floor = st->calib_sum / (float)st->calib_count;
st->calibrated = true;
}
return;
}

if (motion < st->floor) {
st->floor = motion;
} else {
st->floor *= EDGE_FLOOR_LEAK;
}
if (st->floor < 1e-5f) {
st->floor = 1e-5f;
}

st->adaptive_threshold = st->floor * EDGE_FLOOR_MULT;
if (st->adaptive_threshold < 0.01f) {
st->adaptive_threshold = 0.01f;
}
}

/* The call-site guard is the entire bug. `fixed_guard` selects which
* variant of edge_processing.c:1224 to run this frame. */
static bool guard_should_update(const calib_state_t *st, float presence_thresh, bool fixed_guard)
{
if (fixed_guard) {
/* This branch's edge_processing.c:1224. */
return presence_thresh == 0.0f;
}
/* contrib/adaptive-floor@9cce83c6's edge_processing.c:1226. */
return !st->calibrated && presence_thresh == 0.0f;
}

/* Drive EDGE_CALIB_FRAMES warm-up frames at `warmup_motion`, then
* `settle_frames` post-warm-up frames at `settled_motion`, applying the
* guard given by `fixed_guard` on every frame (matching process_frame()
* calling calibration_update() once per processed frame). Returns the
* floor immediately after warm-up via *floor_after_warmup, so callers can
* compare against the exact seeded value (sum/count of 1200 float32
* additions of a repeated constant is not bit-exact to the constant
* itself, so comparing against a literal would be testing float rounding
* rather than the guard). */
static void run_scenario(calib_state_t *st, bool fixed_guard,
float warmup_motion, float settled_motion,
uint32_t settle_frames, float *floor_after_warmup)
{
calib_state_reset(st);

for (uint32_t i = 0; i < EDGE_CALIB_FRAMES; i++) {
if (guard_should_update(st, 0.0f, fixed_guard)) {
mirror_calibration_update(st, warmup_motion);
}
}
CHECK(st->calibrated, "warm-up reaches s_calibrated after EDGE_CALIB_FRAMES");
if (floor_after_warmup) {
*floor_after_warmup = st->floor;
}

for (uint32_t i = 0; i < settle_frames; i++) {
if (guard_should_update(st, 0.0f, fixed_guard)) {
mirror_calibration_update(st, settled_motion);
}
}
}

/* --- Positive case: fixed guard, floor descends after warm-up when
* motion drops. This is the property PR #1821 shipped without. --- */
static void test_fixed_guard_floor_descends(void)
{
printf("test_fixed_guard_floor_descends:\n");
calib_state_t st;
float seeded_floor = 0.0f;

/* Seed a floor around 0.20 during warm-up (a "noisy room" baseline),
* then hold at a genuinely quieter 0.02 for a while post-warm-up. */
run_scenario(&st, /*fixed_guard=*/true, 0.20f, 0.02f, 5000, &seeded_floor);

CHECK(st.floor < seeded_floor - 1e-6f,
"fixed guard: floor descends below the warm-up seed once motion drops");
CHECK(st.floor < 0.05f,
"fixed guard: floor tracks close to the new quiet level, not frozen at seed");
printf(" seeded floor ~0.20, settled floor = %.6f\n", (double)st.floor);
}

/* --- Negative control: the OLD (pre-fix) guard on the identical
* scenario. The floor must stay frozen at the warm-up seed, because
* `!s_calibrated` makes calibration_update() unreachable forever once
* s_calibrated flips true -- reproducing the exact dead-code-floor bug
* this branch fixes. If this assertion ever fails, the mirror above has
* drifted from the real bug and needs re-checking against
* edge_processing.c, not "fixed". --- */
static void test_buggy_guard_floor_stays_frozen(void)
{
printf("test_buggy_guard_floor_stays_frozen (negative control):\n");
calib_state_t st;
float seeded_floor = 0.0f;

run_scenario(&st, /*fixed_guard=*/false, 0.20f, 0.02f, 5000, &seeded_floor);

/* Under the buggy guard, calibration_update() is unreachable once
* s_calibrated flips true, so the settle loop must be a complete
* no-op: the floor after "settling" must be bit-identical to the
* floor captured immediately after warm-up. */
CHECK(st.floor == seeded_floor,
"buggy guard: floor stays latched at the warm-up seed (dead code, as shipped in #1821)");
printf(" seeded floor ~0.20, buggy-guard \"settled\" floor = %.6f (unchanged)\n", (double)st.floor);
}

int main(void)
{
test_fixed_guard_floor_descends();
test_buggy_guard_floor_stays_frozen();

printf("\n%d passed, %d failed\n", g_pass, g_fail);
return g_fail == 0 ? 0 : 1;
}
Loading