mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +00:00
ADR-081: implement Layers 1/2/4 end-to-end + host tests + QEMU hooks
Turns the ADR-081 scaffolding into a working adaptive CSI mesh kernel:
Layer 1 radio abstraction has an ESP32 binding and a mock binding; Layer 2
adaptive controller runs on FreeRTOS timers; Layer 4 feature-state packet
is emitted at 5 Hz by default, replacing raw ADR-018 CSI as the default
upstream.
New files:
firmware/esp32-csi-node/main/adaptive_controller_decide.c (pure policy)
firmware/esp32-csi-node/main/rv_radio_ops_mock.c (QEMU binding)
firmware/esp32-csi-node/tests/host/Makefile (host tests)
firmware/esp32-csi-node/tests/host/test_adaptive_controller.c
firmware/esp32-csi-node/tests/host/test_rv_feature_state.c
firmware/esp32-csi-node/tests/host/esp_err.h (shim)
firmware/esp32-csi-node/tests/host/.gitignore
Modified:
adaptive_controller.c — includes pure decide.c; emit_feature_state()
wired into fast loop (200 ms = 5 Hz)
rv_radio_ops_esp32.c — get_health() fills pkt_yield + send_fail
csi_collector.{c,h} — pkt_yield/send_fail accessors (ADR-081 L1)
rv_feature_state.h — packed size corrected to 60 bytes
(was incorrectly 80 in initial commit)
main.c — mock binding registered under mock CSI
CMakeLists.txt — rv_radio_ops_mock.c under CSI_MOCK_ENABLED
scripts/validate_qemu_output.py — 3 new ADR-081 checks (17/18/19)
docs/adr/ADR-081-*.md — status → Accepted (partial);
implementation-status matrix; measured
benchmarks (decide 3.2 ns, CRC32 614 ns);
bandwidth 300 B/s @ 5 Hz (99.7% vs raw);
verification section
CHANGELOG.md — artifact-level entries
Tests (host, gcc -O2 -std=c11):
test_adaptive_controller: 18/18 pass, decide() = 3.2 ns/call
test_rv_feature_state: 15/15 pass, CRC32(56 B) = 614 ns/pkt, 87 MB/s
sizeof(rv_feature_state_t) == 60 asserted
IEEE CRC32 known vectors verified
Deferred (tracked in ADR-081 roadmap Phase 3/4):
Layer 3 mesh-plane message types, role-assignment FSM, Rust-side mirror
trait in crates/wifi-densepose-hardware/src/radio_ops.rs.
This commit is contained in:
@@ -14,7 +14,10 @@
|
||||
|
||||
#include "adaptive_controller.h"
|
||||
#include "rv_radio_ops.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "edge_processing.h"
|
||||
#include "stream_sender.h"
|
||||
#include "csi_collector.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
@@ -86,78 +89,10 @@ static void apply_defaults(adapt_config_t *cfg)
|
||||
cfg->min_pkt_yield = CONFIG_ADAPTIVE_MIN_PKT_YIELD;
|
||||
}
|
||||
|
||||
/* ---- Pure decision function (unit-testable) ---- */
|
||||
|
||||
void adaptive_controller_decide(const adapt_config_t *cfg,
|
||||
adapt_state_t current,
|
||||
const adapt_observation_t *obs,
|
||||
adapt_decision_t *out)
|
||||
{
|
||||
if (cfg == NULL || obs == NULL || out == NULL) {
|
||||
return;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->new_state = (uint8_t)current;
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
|
||||
/* Degraded gate: any of pkt yield collapse, severe coherence loss → DEGRADED. */
|
||||
if (obs->pkt_yield_per_sec < cfg->min_pkt_yield ||
|
||||
obs->node_coherence < 0.20f) {
|
||||
if (current != ADAPT_STATE_DEGRADED) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_DEGRADED;
|
||||
}
|
||||
out->change_profile = (current != ADAPT_STATE_DEGRADED);
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
out->suggested_vital_interval_ms = 2000;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Anomaly trumps motion. */
|
||||
if (obs->anomaly_score >= cfg->anomaly_threshold) {
|
||||
if (current != ADAPT_STATE_ALERT) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_ALERT;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_FAST_MOTION;
|
||||
out->suggested_vital_interval_ms = 100;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Motion → SENSE_ACTIVE with FAST_MOTION profile. */
|
||||
if (obs->motion_score >= cfg->motion_threshold) {
|
||||
if (current != ADAPT_STATE_SENSE_ACTIVE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_ACTIVE;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_FAST_MOTION;
|
||||
out->suggested_vital_interval_ms = cfg->aggressive ? 100 : 200;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Stable environment with valid presence → high-sensitivity respiration mode. */
|
||||
if (obs->presence_score >= 0.5f && obs->motion_score < 0.05f) {
|
||||
if (current != ADAPT_STATE_SENSE_IDLE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_IDLE;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_RESP_HIGH_SENS;
|
||||
out->suggested_vital_interval_ms = 1000;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Default: passive low rate. */
|
||||
if (current != ADAPT_STATE_SENSE_IDLE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_IDLE;
|
||||
}
|
||||
out->change_profile = (current != ADAPT_STATE_SENSE_IDLE);
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
out->suggested_vital_interval_ms = cfg->aggressive ? 500 : 1000;
|
||||
}
|
||||
/* Pure decision policy lives in its own file so it can link under
|
||||
* host unit tests without FreeRTOS. It is part of this translation
|
||||
* unit via #include to preserve a single object at build time. */
|
||||
#include "adaptive_controller_decide.c"
|
||||
|
||||
/* ---- Observation collection ---- */
|
||||
|
||||
@@ -237,6 +172,12 @@ static void fast_loop_cb(TimerHandle_t t)
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
|
||||
apply_decision(&dec);
|
||||
|
||||
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
|
||||
* (default 200 ms → 5 Hz, within the 1–10 Hz spec). Replaces raw
|
||||
* ADR-018 CSI as the default upstream; raw remains available as a
|
||||
* debug stream gated by the channel plan. */
|
||||
emit_feature_state();
|
||||
}
|
||||
|
||||
static void medium_loop_cb(TimerHandle_t t)
|
||||
@@ -260,13 +201,81 @@ static void medium_loop_cb(TimerHandle_t t)
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-081 Layer 4: emit one rv_feature_state_t packet onto the wire.
|
||||
*
|
||||
* Pulls from the latest observation + latest vitals + the active capture
|
||||
* profile. Send is best-effort — stream_sender will report its own
|
||||
* failures; we don't re-queue. At 5 Hz default cadence this is 300 B/s
|
||||
* per node, vs. ~100 KB/s for raw ADR-018 CSI. */
|
||||
static uint16_t s_feature_state_seq = 0;
|
||||
|
||||
static void emit_feature_state(void)
|
||||
{
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
|
||||
adapt_observation_t obs;
|
||||
bool have_obs = false;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
if (s_obs_valid) {
|
||||
obs = s_last_obs;
|
||||
have_obs = true;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
if (have_obs) {
|
||||
pkt.motion_score = obs.motion_score;
|
||||
pkt.presence_score = obs.presence_score;
|
||||
pkt.anomaly_score = obs.anomaly_score;
|
||||
pkt.node_coherence = obs.node_coherence;
|
||||
}
|
||||
|
||||
/* Fill vitals from edge_processing's latest packet. */
|
||||
edge_vitals_pkt_t v;
|
||||
if (edge_get_vitals(&v)) {
|
||||
pkt.respiration_bpm = (float)v.breathing_rate / 100.0f;
|
||||
pkt.heartbeat_bpm = (float)v.heartrate / 10000.0f;
|
||||
/* Confidence proxies: presence score for resp, 1.0 if heart BPM
|
||||
* is within physiological range. */
|
||||
pkt.respiration_conf = (v.breathing_rate > 0) ? v.presence_score : 0.0f;
|
||||
pkt.heartbeat_conf = (v.heartrate > 400000u && v.heartrate < 1800000u)
|
||||
? 0.8f : 0.0f;
|
||||
if (pkt.respiration_bpm > 0.0f) pkt.quality_flags |= RV_QFLAG_RESPIRATION_VALID;
|
||||
if (pkt.heartbeat_bpm > 0.0f) pkt.quality_flags |= RV_QFLAG_HEARTBEAT_VALID;
|
||||
if (pkt.presence_score >= 0.5f) pkt.quality_flags |= RV_QFLAG_PRESENCE_VALID;
|
||||
if (v.flags & 0x02) pkt.quality_flags |= RV_QFLAG_ANOMALY_TRIGGERED; /* fall bit */
|
||||
}
|
||||
|
||||
if (s_state == ADAPT_STATE_DEGRADED) pkt.quality_flags |= RV_QFLAG_DEGRADED_MODE;
|
||||
if (s_state == ADAPT_STATE_CALIBRATION) pkt.quality_flags |= RV_QFLAG_CALIBRATING;
|
||||
|
||||
/* Active profile, for receiver-side weighting. */
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
uint8_t profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
if (ops != NULL && ops->get_health != NULL) {
|
||||
rv_radio_health_t h;
|
||||
if (ops->get_health(&h) == ESP_OK) profile = h.current_profile;
|
||||
}
|
||||
|
||||
rv_feature_state_finalize(&pkt,
|
||||
csi_collector_get_node_id(),
|
||||
s_feature_state_seq++,
|
||||
(uint64_t)esp_timer_get_time(),
|
||||
profile);
|
||||
|
||||
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
|
||||
if (sent < 0) {
|
||||
ESP_LOGW(TAG, "feature_state emit failed");
|
||||
}
|
||||
}
|
||||
|
||||
static void slow_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* Slow loop: publish a HEALTH message, request CALIBRATION_START on
|
||||
* sustained drift. Both routed through swarm_bridge once the mesh
|
||||
* plane lands. Today we log a rollover so operators see the cadence. */
|
||||
ESP_LOGI(TAG, "slow tick (state=%u)", (unsigned)s_state);
|
||||
/* Slow loop: log a heartbeat and (future Phase 3) publish HEALTH
|
||||
* messages + request CALIBRATION_START on sustained drift. */
|
||||
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u)",
|
||||
(unsigned)s_state, (unsigned)s_feature_state_seq);
|
||||
}
|
||||
|
||||
/* ---- Public API ---- */
|
||||
|
||||
Reference in New Issue
Block a user