mirror of
https://github.com/ruvnet/RuView
synced 2026-07-17 16:33:18 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48c4d5e92a | |||
| da54d21a38 | |||
| d4c3d2b693 |
@@ -43,6 +43,12 @@ static const char *TAG = "edge_proc";
|
|||||||
static edge_ring_buf_t s_ring;
|
static edge_ring_buf_t s_ring;
|
||||||
static uint32_t s_ring_drops; /* Frames dropped due to full ring buffer. */
|
static uint32_t s_ring_drops; /* Frames dropped due to full ring buffer. */
|
||||||
|
|
||||||
|
/* Scratch buffers for BPM estimation — moved from stack to static to avoid
|
||||||
|
* stack overflow. process_frame + update_multi_person_vitals combined used
|
||||||
|
* ~6.5-7.5 KB of the 8 KB task stack. These save ~4 KB of stack. */
|
||||||
|
static float s_scratch_br[EDGE_PHASE_HISTORY_LEN];
|
||||||
|
static float s_scratch_hr[EDGE_PHASE_HISTORY_LEN];
|
||||||
|
|
||||||
static inline bool ring_push(const uint8_t *iq, uint16_t len,
|
static inline bool ring_push(const uint8_t *iq, uint16_t len,
|
||||||
int8_t rssi, uint8_t channel)
|
int8_t rssi, uint8_t channel)
|
||||||
{
|
{
|
||||||
@@ -513,20 +519,18 @@ static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
|
|||||||
|
|
||||||
/* Estimate BPM when we have enough history. */
|
/* Estimate BPM when we have enough history. */
|
||||||
if (pv->history_len >= 64) {
|
if (pv->history_len >= 64) {
|
||||||
/* Build contiguous buffer for zero-crossing. */
|
/* Build contiguous buffer (reuse static scratch to save ~2 KB stack). */
|
||||||
float br_buf[EDGE_PHASE_HISTORY_LEN];
|
|
||||||
float hr_buf[EDGE_PHASE_HISTORY_LEN];
|
|
||||||
uint16_t buf_len = pv->history_len;
|
uint16_t buf_len = pv->history_len;
|
||||||
|
|
||||||
for (uint16_t i = 0; i < buf_len; i++) {
|
for (uint16_t i = 0; i < buf_len; i++) {
|
||||||
uint16_t ri = (pv->history_idx + EDGE_PHASE_HISTORY_LEN
|
uint16_t ri = (pv->history_idx + EDGE_PHASE_HISTORY_LEN
|
||||||
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||||
br_buf[i] = s_person_br_filt[p][ri];
|
s_scratch_br[i] = s_person_br_filt[p][ri];
|
||||||
hr_buf[i] = s_person_hr_filt[p][ri];
|
s_scratch_hr[i] = s_person_hr_filt[p][ri];
|
||||||
}
|
}
|
||||||
|
|
||||||
float br = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
|
float br = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
|
||||||
float hr = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
|
float hr = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
|
||||||
|
|
||||||
/* Sanity clamp. */
|
/* Sanity clamp. */
|
||||||
if (br >= 6.0f && br <= 40.0f) pv->breathing_bpm = br;
|
if (br >= 6.0f && br <= 40.0f) pv->breathing_bpm = br;
|
||||||
@@ -690,20 +694,18 @@ static void process_frame(const edge_ring_slot_t *slot)
|
|||||||
|
|
||||||
/* --- Step 7: BPM estimation (zero-crossing) --- */
|
/* --- Step 7: BPM estimation (zero-crossing) --- */
|
||||||
if (s_history_len >= 64) {
|
if (s_history_len >= 64) {
|
||||||
/* Build contiguous buffers from ring. */
|
/* Build contiguous buffers from ring (using static scratch to save stack). */
|
||||||
float br_buf[EDGE_PHASE_HISTORY_LEN];
|
|
||||||
float hr_buf[EDGE_PHASE_HISTORY_LEN];
|
|
||||||
uint16_t buf_len = s_history_len;
|
uint16_t buf_len = s_history_len;
|
||||||
|
|
||||||
for (uint16_t i = 0; i < buf_len; i++) {
|
for (uint16_t i = 0; i < buf_len; i++) {
|
||||||
uint16_t ri = (s_history_idx + EDGE_PHASE_HISTORY_LEN
|
uint16_t ri = (s_history_idx + EDGE_PHASE_HISTORY_LEN
|
||||||
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||||
br_buf[i] = s_breathing_filtered[ri];
|
s_scratch_br[i] = s_breathing_filtered[ri];
|
||||||
hr_buf[i] = s_heartrate_filtered[ri];
|
s_scratch_hr[i] = s_heartrate_filtered[ri];
|
||||||
}
|
}
|
||||||
|
|
||||||
float br_bpm = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
|
float br_bpm = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
|
||||||
float hr_bpm = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
|
float hr_bpm = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
|
||||||
|
|
||||||
/* Sanity clamp: breathing 6-40 BPM, heart rate 40-180 BPM. */
|
/* Sanity clamp: breathing 6-40 BPM, heart rate 40-180 BPM. */
|
||||||
if (br_bpm >= 6.0f && br_bpm <= 40.0f) s_breathing_bpm = br_bpm;
|
if (br_bpm >= 6.0f && br_bpm <= 40.0f) s_breathing_bpm = br_bpm;
|
||||||
@@ -839,12 +841,11 @@ static void edge_task(void *arg)
|
|||||||
* Without a batch limit the task processes frames back-to-back with
|
* Without a batch limit the task processes frames back-to-back with
|
||||||
* only 1-tick yields, which on high frame rates can still starve
|
* only 1-tick yields, which on high frame rates can still starve
|
||||||
* IDLE1 enough to trip the 5-second task watchdog. See #266, #321. */
|
* IDLE1 enough to trip the 5-second task watchdog. See #266, #321. */
|
||||||
const uint8_t BATCH_LIMIT = 4;
|
|
||||||
|
|
||||||
while (1) {
|
while (1) {
|
||||||
uint8_t processed = 0;
|
uint8_t processed = 0;
|
||||||
|
|
||||||
while (processed < BATCH_LIMIT && ring_pop(&slot)) {
|
while (processed < EDGE_BATCH_LIMIT && ring_pop(&slot)) {
|
||||||
process_frame(&slot);
|
process_frame(&slot);
|
||||||
processed++;
|
processed++;
|
||||||
/* 1-tick yield between frames within a batch. */
|
/* 1-tick yield between frames within a batch. */
|
||||||
@@ -852,10 +853,10 @@ static void edge_task(void *arg)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (processed > 0) {
|
if (processed > 0) {
|
||||||
/* Post-batch yield: 2 ticks (~20 ms at 100 Hz) so IDLE1 can
|
/* Post-batch yield: ~20 ms so IDLE1 can run and feed the
|
||||||
* run and feed the Core 1 watchdog even under sustained load.
|
* Core 1 watchdog even under sustained load. Uses pdMS_TO_TICKS
|
||||||
* This is intentionally longer than the 1-tick inter-frame yield. */
|
* for tick-rate independence (minimum 1 tick). */
|
||||||
vTaskDelay(2);
|
{ TickType_t d = pdMS_TO_TICKS(20); vTaskDelay(d > 0 ? d : 1); }
|
||||||
} else {
|
} else {
|
||||||
/* No frames available — sleep one full tick.
|
/* No frames available — sleep one full tick.
|
||||||
* NOTE: pdMS_TO_TICKS(5) == 0 at 100 Hz, which would busy-spin. */
|
* NOTE: pdMS_TO_TICKS(5) == 0 at 100 Hz, which would busy-spin. */
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
#define EDGE_FALL_COOLDOWN_MS 5000 /**< Minimum ms between fall alerts (debounce). */
|
#define EDGE_FALL_COOLDOWN_MS 5000 /**< Minimum ms between fall alerts (debounce). */
|
||||||
#define EDGE_FALL_CONSEC_MIN 3 /**< Consecutive frames above threshold to trigger. */
|
#define EDGE_FALL_CONSEC_MIN 3 /**< Consecutive frames above threshold to trigger. */
|
||||||
|
|
||||||
|
/* ---- DSP task tuning ---- */
|
||||||
|
#define EDGE_BATCH_LIMIT 4 /**< Max frames per batch before longer yield. */
|
||||||
|
|
||||||
/* ---- SPSC ring buffer slot ---- */
|
/* ---- SPSC ring buffer slot ---- */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint8_t iq_data[EDGE_MAX_IQ_BYTES]; /**< Raw I/Q bytes from CSI callback. */
|
uint8_t iq_data[EDGE_MAX_IQ_BYTES]; /**< Raw I/Q bytes from CSI callback. */
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ pub use bvp::attention_weighted_bvp;
|
|||||||
pub use fresnel::solve_fresnel_geometry;
|
pub use fresnel::solve_fresnel_geometry;
|
||||||
pub use spectrogram::gate_spectrogram;
|
pub use spectrogram::gate_spectrogram;
|
||||||
pub use subcarrier::mincut_subcarrier_partition;
|
pub use subcarrier::mincut_subcarrier_partition;
|
||||||
|
pub use subcarrier::subcarrier_importance_weights;
|
||||||
|
|||||||
@@ -142,6 +142,29 @@ pub fn mincut_subcarrier_partition(sensitivity: &[f32]) -> (Vec<usize>, Vec<usiz
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a mincut partition into per-subcarrier importance weights.
|
||||||
|
///
|
||||||
|
/// Sensitive subcarriers (high body-motion correlation) get weight > 1.0,
|
||||||
|
/// insensitive ones get weight 0.5. This allows downstream feature extraction
|
||||||
|
/// to emphasise the most informative subcarriers.
|
||||||
|
pub fn subcarrier_importance_weights(sensitivity: &[f32]) -> Vec<f32> {
|
||||||
|
if sensitivity.is_empty() {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let (sensitive, _insensitive) = mincut_subcarrier_partition(sensitivity);
|
||||||
|
let max_sens = sensitivity
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.fold(f32::NEG_INFINITY, f32::max)
|
||||||
|
.max(1e-9);
|
||||||
|
|
||||||
|
let mut weights = vec![0.5f32; sensitivity.len()];
|
||||||
|
for &idx in &sensitive {
|
||||||
|
weights[idx] = 1.0 + (sensitivity[idx] / max_sens).min(1.0);
|
||||||
|
}
|
||||||
|
weights
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -175,4 +198,38 @@ mod tests {
|
|||||||
assert_eq!(s, vec![0]);
|
assert_eq!(s, vec![0]);
|
||||||
assert!(i.is_empty());
|
assert!(i.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_importance_weights_empty() {
|
||||||
|
let w = subcarrier_importance_weights(&[]);
|
||||||
|
assert!(w.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_importance_weights_all_equal() {
|
||||||
|
let sensitivity = vec![1.0f32; 8];
|
||||||
|
let w = subcarrier_importance_weights(&sensitivity);
|
||||||
|
assert_eq!(w.len(), 8);
|
||||||
|
// All subcarriers have identical sensitivity so all should be classified
|
||||||
|
// the same way (either all sensitive or all insensitive after mincut).
|
||||||
|
// At minimum, no weight should exceed 2.0 or be negative.
|
||||||
|
for &wt in &w {
|
||||||
|
assert!(wt >= 0.5 && wt <= 2.0, "weight {wt} out of range");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_importance_weights_sensitive_higher() {
|
||||||
|
// First 5 subcarriers have high sensitivity, last 5 low.
|
||||||
|
let sensitivity: Vec<f32> = (0..10).map(|i| if i < 5 { 0.9 } else { 0.1 }).collect();
|
||||||
|
let w = subcarrier_importance_weights(&sensitivity);
|
||||||
|
assert_eq!(w.len(), 10);
|
||||||
|
|
||||||
|
let mean_high: f32 = w[..5].iter().sum::<f32>() / 5.0;
|
||||||
|
let mean_low: f32 = w[5..].iter().sum::<f32>() / 5.0;
|
||||||
|
assert!(
|
||||||
|
mean_high > mean_low,
|
||||||
|
"sensitive subcarriers should have higher mean weight ({mean_high}) than insensitive ({mean_low})"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -804,6 +804,40 @@ fn estimate_breathing_rate_hz(frame_history: &VecDeque<Vec<f64>>, sample_rate_hz
|
|||||||
/// For each subcarrier index `k`, returns `Var[A_k]` over all stored frames.
|
/// For each subcarrier index `k`, returns `Var[A_k]` over all stored frames.
|
||||||
/// This captures spatial signal variation; subcarriers whose amplitude fluctuates
|
/// This captures spatial signal variation; subcarriers whose amplitude fluctuates
|
||||||
/// heavily across time correspond to directions with motion.
|
/// heavily across time correspond to directions with motion.
|
||||||
|
/// Compute per-subcarrier importance weights using a simple sensitivity split.
|
||||||
|
///
|
||||||
|
/// Subcarriers whose sensitivity (amplitude magnitude) is above the median are
|
||||||
|
/// considered "sensitive" and receive weight `1.0 + (sens / max_sens)` (range 1.0–2.0).
|
||||||
|
/// The rest receive a baseline weight of 0.5. This mirrors the RuVector mincut
|
||||||
|
/// partition logic without requiring the graph dependency.
|
||||||
|
fn compute_subcarrier_importance_weights(sensitivity: &[f64]) -> Vec<f64> {
|
||||||
|
let n = sensitivity.len();
|
||||||
|
if n == 0 {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
let max_sens = sensitivity.iter().cloned().fold(f64::NEG_INFINITY, f64::max).max(1e-9);
|
||||||
|
|
||||||
|
// Compute median via a sorted copy.
|
||||||
|
let mut sorted = sensitivity.to_vec();
|
||||||
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
let median = if n % 2 == 0 {
|
||||||
|
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
|
||||||
|
} else {
|
||||||
|
sorted[n / 2]
|
||||||
|
};
|
||||||
|
|
||||||
|
sensitivity
|
||||||
|
.iter()
|
||||||
|
.map(|&s| {
|
||||||
|
if s >= median {
|
||||||
|
1.0 + (s / max_sens).min(1.0)
|
||||||
|
} else {
|
||||||
|
0.5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn compute_subcarrier_variances(frame_history: &VecDeque<Vec<f64>>, n_sub: usize) -> Vec<f64> {
|
fn compute_subcarrier_variances(frame_history: &VecDeque<Vec<f64>>, n_sub: usize) -> Vec<f64> {
|
||||||
if frame_history.is_empty() || n_sub == 0 {
|
if frame_history.is_empty() || n_sub == 0 {
|
||||||
return vec![0.0; n_sub];
|
return vec![0.0; n_sub];
|
||||||
@@ -852,13 +886,34 @@ fn extract_features_from_frame(
|
|||||||
) -> (FeatureInfo, ClassificationInfo, f64, Vec<f64>, f64) {
|
) -> (FeatureInfo, ClassificationInfo, f64, Vec<f64>, f64) {
|
||||||
let n_sub = frame.amplitudes.len().max(1);
|
let n_sub = frame.amplitudes.len().max(1);
|
||||||
let n = n_sub as f64;
|
let n = n_sub as f64;
|
||||||
let mean_amp: f64 = frame.amplitudes.iter().sum::<f64>() / n;
|
|
||||||
let mean_rssi = frame.rssi as f64;
|
let mean_rssi = frame.rssi as f64;
|
||||||
|
|
||||||
// ── Intra-frame subcarrier variance (spatial spread across subcarriers) ──
|
// ── RuVector Phase 1: subcarrier importance weighting ──
|
||||||
let intra_variance: f64 = frame.amplitudes.iter()
|
// Compute per-subcarrier sensitivity from amplitude magnitude, then weight
|
||||||
.map(|a| (a - mean_amp).powi(2))
|
// sensitive subcarriers higher (>1.0) and insensitive ones lower (0.5).
|
||||||
.sum::<f64>() / n;
|
// This emphasises body-motion-correlated subcarriers in all downstream metrics.
|
||||||
|
let sub_sensitivity: Vec<f64> = frame.amplitudes.iter().map(|a| a.abs()).collect();
|
||||||
|
let importance_weights = compute_subcarrier_importance_weights(&sub_sensitivity);
|
||||||
|
|
||||||
|
let weight_sum: f64 = importance_weights.iter().sum::<f64>();
|
||||||
|
let mean_amp: f64 = if weight_sum > 0.0 {
|
||||||
|
frame.amplitudes.iter().zip(importance_weights.iter())
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum::<f64>() / weight_sum
|
||||||
|
} else {
|
||||||
|
frame.amplitudes.iter().sum::<f64>() / n
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Intra-frame subcarrier variance (weighted by importance) ──
|
||||||
|
let intra_variance: f64 = if weight_sum > 0.0 {
|
||||||
|
frame.amplitudes.iter().zip(importance_weights.iter())
|
||||||
|
.map(|(a, w)| w * (a - mean_amp).powi(2))
|
||||||
|
.sum::<f64>() / weight_sum
|
||||||
|
} else {
|
||||||
|
frame.amplitudes.iter()
|
||||||
|
.map(|a| (a - mean_amp).powi(2))
|
||||||
|
.sum::<f64>() / n
|
||||||
|
};
|
||||||
|
|
||||||
// ── Temporal (sliding-window) per-subcarrier variance ──
|
// ── Temporal (sliding-window) per-subcarrier variance ──
|
||||||
let sub_variances = compute_subcarrier_variances(frame_history, n_sub);
|
let sub_variances = compute_subcarrier_variances(frame_history, n_sub);
|
||||||
@@ -3129,7 +3184,10 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||||||
// We scope the mutable borrow of node_states so we can
|
// We scope the mutable borrow of node_states so we can
|
||||||
// access other AppStateInner fields afterward.
|
// access other AppStateInner fields afterward.
|
||||||
let node_id = frame.node_id;
|
let node_id = frame.node_id;
|
||||||
let adaptive_model_ref = s.adaptive_model.as_ref().map(|m| m as *const _);
|
// Clone adaptive model before mutable borrow of node_states
|
||||||
|
// to avoid unsafe raw pointer (review finding #2).
|
||||||
|
let adaptive_model_clone = s.adaptive_model.clone();
|
||||||
|
|
||||||
let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new);
|
let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new);
|
||||||
ns.last_frame_time = Some(std::time::Instant::now());
|
ns.last_frame_time = Some(std::time::Instant::now());
|
||||||
|
|
||||||
@@ -3143,12 +3201,8 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||||||
extract_features_from_frame(&frame, &ns.frame_history, sample_rate_hz);
|
extract_features_from_frame(&frame, &ns.frame_history, sample_rate_hz);
|
||||||
smooth_and_classify_node(ns, &mut classification, raw_motion);
|
smooth_and_classify_node(ns, &mut classification, raw_motion);
|
||||||
|
|
||||||
// SAFETY: adaptive_model_ref points into s which we hold
|
// Adaptive override using cloned model (safe, no raw pointers).
|
||||||
// via write lock; the model is not mutated here. We use a
|
if let Some(ref model) = adaptive_model_clone {
|
||||||
// raw pointer to break the borrow-checker deadlock between
|
|
||||||
// node_states and adaptive_model (both inside s).
|
|
||||||
if let Some(model_ptr) = adaptive_model_ref {
|
|
||||||
let model: &adaptive_classifier::AdaptiveModel = unsafe { &*model_ptr };
|
|
||||||
let amps = ns.frame_history.back()
|
let amps = ns.frame_history.back()
|
||||||
.map(|v| v.as_slice())
|
.map(|v| v.as_slice())
|
||||||
.unwrap_or(&[]);
|
.unwrap_or(&[]);
|
||||||
@@ -3263,6 +3317,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||||||
let _ = s.tx.send(json);
|
let _ = s.tx.send(json);
|
||||||
}
|
}
|
||||||
s.latest_update = Some(update);
|
s.latest_update = Some(update);
|
||||||
|
|
||||||
|
// Evict stale nodes every 100 ticks to prevent memory leak.
|
||||||
|
if tick % 100 == 0 {
|
||||||
|
let stale = Duration::from_secs(60);
|
||||||
|
let before = s.node_states.len();
|
||||||
|
s.node_states.retain(|_id, ns| {
|
||||||
|
ns.last_frame_time.map_or(false, |t| now.duration_since(t) < stale)
|
||||||
|
});
|
||||||
|
let evicted = before - s.node_states.len();
|
||||||
|
if evicted > 0 {
|
||||||
|
info!("Evicted {} stale node(s), {} active", evicted, s.node_states.len());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user