457 lines
15 KiB
Rust
457 lines
15 KiB
Rust
//! Rain detection from CSI micro-disturbances — ADR-041 exotic module.
|
|
//!
|
|
//! # Algorithm
|
|
//!
|
|
//! Raindrops impacting surfaces (roof, windows, walls) produce broadband
|
|
//! impulse vibrations that propagate through building structure and
|
|
//! modulate CSI phase. These perturbations are distinguishable from
|
|
//! human motion by their:
|
|
//!
|
|
//! 1. **Broadband nature** — rain affects all subcarriers roughly equally,
|
|
//! unlike human motion which is spatially selective.
|
|
//! 2. **Stochastic timing** — Poisson-distributed impulse arrivals, unlike
|
|
//! the quasi-periodic patterns of walking or breathing.
|
|
//! 3. **Absence of large-scale motion** — rain perturbations are small
|
|
//! and lack the coherent phase shifts of a moving body.
|
|
//!
|
|
//! ## Detection pipeline
|
|
//!
|
|
//! 1. Require `presence == 0` (empty room) to avoid confounding.
|
|
//! 2. Compute broadband phase variance across all subcarrier groups.
|
|
//! If the variance is uniformly elevated (all groups above threshold),
|
|
//! this suggests a distributed vibration source (rain).
|
|
//! 3. Estimate intensity from aggregate vibration energy:
|
|
//! - Light: energy < 0.3
|
|
//! - Moderate: 0.3 <= energy < 0.7
|
|
//! - Heavy: energy >= 0.7
|
|
//! 4. Track onset (transition from quiet to rain) and cessation
|
|
//! (transition from rain to quiet) with hysteresis.
|
|
//!
|
|
//! # Events (660-series: Exotic / Research)
|
|
//!
|
|
//! - `RAIN_ONSET` (660): 1.0 when rain begins.
|
|
//! - `RAIN_INTENSITY` (661): Intensity level (1=light, 2=moderate, 3=heavy).
|
|
//! - `RAIN_CESSATION` (662): 1.0 when rain stops.
|
|
//!
|
|
//! # Budget
|
|
//!
|
|
//! L (light, < 2 ms) — per-frame: variance comparison across 8 groups.
|
|
|
|
use crate::vendor_common::Ema;
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
/// Number of subcarrier groups to monitor.
|
|
const N_GROUPS: usize = 8;
|
|
|
|
/// Maximum subcarriers from host API.
|
|
const MAX_SC: usize = 32;
|
|
|
|
/// Baseline variance EWMA alpha (very slow, tracks ambient noise).
|
|
const BASELINE_ALPHA: f32 = 0.0005;
|
|
|
|
/// Short-term variance EWMA alpha (fast, tracks current conditions).
|
|
const SHORT_ALPHA: f32 = 0.05;
|
|
|
|
/// Aggregate energy EWMA alpha for intensity smoothing.
|
|
const ENERGY_ALPHA: f32 = 0.03;
|
|
|
|
/// Variance ratio threshold: current / baseline must exceed this to count
|
|
/// as "elevated" for a group.
|
|
const VARIANCE_RATIO_THRESHOLD: f32 = 2.5;
|
|
|
|
/// Minimum fraction of groups that must be elevated for broadband detection.
|
|
/// Rain should affect most groups; 6/8 = 75%.
|
|
const MIN_GROUP_FRACTION: f32 = 0.75;
|
|
|
|
/// Hysteresis: consecutive frames of rain signal before onset.
|
|
const ONSET_FRAMES: u32 = 10;
|
|
|
|
/// Hysteresis: consecutive quiet frames before cessation.
|
|
const CESSATION_FRAMES: u32 = 20;
|
|
|
|
/// Intensity thresholds (normalized energy).
|
|
const INTENSITY_LIGHT_MAX: f32 = 0.3;
|
|
const INTENSITY_MODERATE_MAX: f32 = 0.7;
|
|
|
|
/// Minimum empty-room frames before detection starts.
|
|
const MIN_EMPTY_FRAMES: u32 = 40;
|
|
|
|
// ── Event IDs (660-series: Exotic) ───────────────────────────────────────────
|
|
|
|
pub const EVENT_RAIN_ONSET: i32 = 660;
|
|
pub const EVENT_RAIN_INTENSITY: i32 = 661;
|
|
pub const EVENT_RAIN_CESSATION: i32 = 662;
|
|
|
|
// ── Rain intensity level ─────────────────────────────────────────────────────
|
|
|
|
/// Rain intensity classification.
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
#[repr(u8)]
|
|
pub enum RainIntensity {
|
|
None = 0,
|
|
Light = 1,
|
|
Moderate = 2,
|
|
Heavy = 3,
|
|
}
|
|
|
|
// ── Rain Detector ────────────────────────────────────────────────────────────
|
|
|
|
/// Detects rain from broadband CSI phase variance perturbations.
|
|
pub struct RainDetector {
|
|
/// Baseline variance per subcarrier group (slow EWMA).
|
|
baseline_var: [Ema; N_GROUPS],
|
|
/// Short-term variance per subcarrier group (fast EWMA).
|
|
short_var: [Ema; N_GROUPS],
|
|
/// Smoothed aggregate vibration energy.
|
|
energy_ema: Ema,
|
|
/// Current rain state.
|
|
raining: bool,
|
|
/// Current intensity classification.
|
|
intensity: RainIntensity,
|
|
/// Consecutive frames of broadband variance elevation.
|
|
rain_frames: u32,
|
|
/// Consecutive frames without broadband variance elevation.
|
|
quiet_frames: u32,
|
|
/// Number of empty-room frames processed.
|
|
empty_frames: u32,
|
|
/// Total frames processed.
|
|
frame_count: u32,
|
|
}
|
|
|
|
impl RainDetector {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
baseline_var: [
|
|
Ema::new(BASELINE_ALPHA), Ema::new(BASELINE_ALPHA),
|
|
Ema::new(BASELINE_ALPHA), Ema::new(BASELINE_ALPHA),
|
|
Ema::new(BASELINE_ALPHA), Ema::new(BASELINE_ALPHA),
|
|
Ema::new(BASELINE_ALPHA), Ema::new(BASELINE_ALPHA),
|
|
],
|
|
short_var: [
|
|
Ema::new(SHORT_ALPHA), Ema::new(SHORT_ALPHA),
|
|
Ema::new(SHORT_ALPHA), Ema::new(SHORT_ALPHA),
|
|
Ema::new(SHORT_ALPHA), Ema::new(SHORT_ALPHA),
|
|
Ema::new(SHORT_ALPHA), Ema::new(SHORT_ALPHA),
|
|
],
|
|
energy_ema: Ema::new(ENERGY_ALPHA),
|
|
raining: false,
|
|
intensity: RainIntensity::None,
|
|
rain_frames: 0,
|
|
quiet_frames: 0,
|
|
empty_frames: 0,
|
|
frame_count: 0,
|
|
}
|
|
}
|
|
|
|
/// Process one CSI frame.
|
|
///
|
|
/// `phases` — per-subcarrier phase values (up to 32).
|
|
/// `variance` — per-subcarrier variance values (up to 32).
|
|
/// `amplitudes` — per-subcarrier amplitude values (up to 32).
|
|
/// `presence` — 0 = room empty, >0 = humans present.
|
|
///
|
|
/// Returns events as `(event_id, value)` pairs.
|
|
pub fn process_frame(
|
|
&mut self,
|
|
phases: &[f32],
|
|
variance: &[f32],
|
|
amplitudes: &[f32],
|
|
presence: i32,
|
|
) -> &[(i32, f32)] {
|
|
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
|
|
let mut n_ev = 0usize;
|
|
|
|
self.frame_count += 1;
|
|
|
|
// Only detect when room is empty.
|
|
if presence != 0 {
|
|
return &[];
|
|
}
|
|
|
|
let n_sc = core::cmp::min(phases.len(), MAX_SC);
|
|
let n_sc = core::cmp::min(n_sc, variance.len());
|
|
let n_sc = core::cmp::min(n_sc, amplitudes.len());
|
|
if n_sc < N_GROUPS {
|
|
return &[];
|
|
}
|
|
|
|
self.empty_frames += 1;
|
|
|
|
// Compute per-group variance.
|
|
let subs_per = n_sc / N_GROUPS;
|
|
if subs_per == 0 {
|
|
return &[];
|
|
}
|
|
|
|
let mut group_var = [0.0f32; N_GROUPS];
|
|
for g in 0..N_GROUPS {
|
|
let start = g * subs_per;
|
|
let end = if g == N_GROUPS - 1 { n_sc } else { start + subs_per };
|
|
let count = (end - start) as f32;
|
|
let mut sv = 0.0f32;
|
|
for i in start..end {
|
|
sv += variance[i];
|
|
}
|
|
group_var[g] = sv / count;
|
|
}
|
|
|
|
// Update baselines and short-term estimates.
|
|
let mut elevated_count = 0u32;
|
|
let mut total_energy = 0.0f32;
|
|
for g in 0..N_GROUPS {
|
|
self.baseline_var[g].update(group_var[g]);
|
|
self.short_var[g].update(group_var[g]);
|
|
|
|
let baseline = self.baseline_var[g].value;
|
|
let short = self.short_var[g].value;
|
|
|
|
// Check if this group has elevated variance.
|
|
if baseline > 1e-10 && short > baseline * VARIANCE_RATIO_THRESHOLD {
|
|
elevated_count += 1;
|
|
}
|
|
|
|
// Accumulate energy as excess above baseline.
|
|
if baseline > 1e-10 {
|
|
let excess = if short > baseline {
|
|
(short - baseline) / baseline
|
|
} else {
|
|
0.0
|
|
};
|
|
total_energy += excess;
|
|
}
|
|
}
|
|
|
|
// Normalize energy to [0, 1] (cap at 1.0).
|
|
let avg_energy = total_energy / N_GROUPS as f32;
|
|
let norm_energy = if avg_energy > 1.0 { 1.0 } else { avg_energy };
|
|
self.energy_ema.update(norm_energy);
|
|
|
|
// Need minimum data before detection.
|
|
if self.empty_frames < MIN_EMPTY_FRAMES {
|
|
return &[];
|
|
}
|
|
|
|
// Check broadband criterion: most groups must be elevated.
|
|
let fraction = elevated_count as f32 / N_GROUPS as f32;
|
|
let broadband = fraction >= MIN_GROUP_FRACTION;
|
|
|
|
// Update state machine with hysteresis.
|
|
if broadband {
|
|
self.rain_frames += 1;
|
|
self.quiet_frames = 0;
|
|
} else {
|
|
self.quiet_frames += 1;
|
|
self.rain_frames = 0;
|
|
}
|
|
|
|
let was_raining = self.raining;
|
|
|
|
// Onset: was not raining, now have enough consecutive rain frames.
|
|
if !self.raining && self.rain_frames >= ONSET_FRAMES {
|
|
self.raining = true;
|
|
unsafe {
|
|
EVENTS[n_ev] = (EVENT_RAIN_ONSET, 1.0);
|
|
}
|
|
n_ev += 1;
|
|
}
|
|
|
|
// Cessation: was raining, now have enough quiet frames.
|
|
if was_raining && self.quiet_frames >= CESSATION_FRAMES {
|
|
self.raining = false;
|
|
self.intensity = RainIntensity::None;
|
|
unsafe {
|
|
EVENTS[n_ev] = (EVENT_RAIN_CESSATION, 1.0);
|
|
}
|
|
n_ev += 1;
|
|
}
|
|
|
|
// Classify intensity while raining.
|
|
if self.raining {
|
|
let energy = self.energy_ema.value;
|
|
self.intensity = if energy < INTENSITY_LIGHT_MAX {
|
|
RainIntensity::Light
|
|
} else if energy < INTENSITY_MODERATE_MAX {
|
|
RainIntensity::Moderate
|
|
} else {
|
|
RainIntensity::Heavy
|
|
};
|
|
|
|
unsafe {
|
|
EVENTS[n_ev] = (EVENT_RAIN_INTENSITY, self.intensity as u8 as f32);
|
|
}
|
|
n_ev += 1;
|
|
}
|
|
|
|
unsafe { &EVENTS[..n_ev] }
|
|
}
|
|
|
|
/// Whether rain is currently detected.
|
|
pub fn is_raining(&self) -> bool {
|
|
self.raining
|
|
}
|
|
|
|
/// Get the current rain intensity.
|
|
pub fn intensity(&self) -> RainIntensity {
|
|
self.intensity
|
|
}
|
|
|
|
/// Get the smoothed vibration energy [0, 1].
|
|
pub fn energy(&self) -> f32 {
|
|
self.energy_ema.value
|
|
}
|
|
|
|
/// Get total frames processed.
|
|
pub fn frame_count(&self) -> u32 {
|
|
self.frame_count
|
|
}
|
|
|
|
/// Get number of empty-room frames processed.
|
|
pub fn empty_frames(&self) -> u32 {
|
|
self.empty_frames
|
|
}
|
|
|
|
/// Reset to initial state.
|
|
pub fn reset(&mut self) {
|
|
*self = Self::new();
|
|
}
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_const_new() {
|
|
let rd = RainDetector::new();
|
|
assert_eq!(rd.frame_count(), 0);
|
|
assert_eq!(rd.empty_frames(), 0);
|
|
assert!(!rd.is_raining());
|
|
assert_eq!(rd.intensity() as u8, RainIntensity::None as u8);
|
|
}
|
|
|
|
#[test]
|
|
fn test_presence_blocks_detection() {
|
|
let mut rd = RainDetector::new();
|
|
let phases = [0.5f32; 32];
|
|
let vars = [1.0f32; 32]; // high variance
|
|
let amps = [1.0f32; 32];
|
|
for _ in 0..100 {
|
|
let events = rd.process_frame(&phases, &vars, &s, 1); // present
|
|
assert!(events.is_empty());
|
|
}
|
|
assert_eq!(rd.empty_frames(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_quiet_room_no_rain() {
|
|
let mut rd = RainDetector::new();
|
|
let phases = [0.5f32; 32];
|
|
let vars = [0.001f32; 32]; // very low variance
|
|
let amps = [1.0f32; 32];
|
|
for _ in 0..MIN_EMPTY_FRAMES + 50 {
|
|
let events = rd.process_frame(&phases, &vars, &s, 0);
|
|
for ev in events {
|
|
assert_ne!(ev.0, EVENT_RAIN_ONSET,
|
|
"quiet room should not trigger rain onset");
|
|
}
|
|
}
|
|
assert!(!rd.is_raining());
|
|
}
|
|
|
|
#[test]
|
|
fn test_broadband_variance_triggers_rain() {
|
|
let mut rd = RainDetector::new();
|
|
let phases = [0.5f32; 32];
|
|
let amps = [1.0f32; 32];
|
|
let low_vars = [0.001f32; 32];
|
|
|
|
// Build baseline with low variance.
|
|
for _ in 0..MIN_EMPTY_FRAMES + 50 {
|
|
rd.process_frame(&phases, &low_vars, &s, 0);
|
|
}
|
|
|
|
// Inject broadband high variance (rain-like).
|
|
let high_vars = [0.5f32; 32];
|
|
let mut onset_seen = false;
|
|
for _ in 0..ONSET_FRAMES + 20 {
|
|
let events = rd.process_frame(&phases, &high_vars, &s, 0);
|
|
for ev in events {
|
|
if ev.0 == EVENT_RAIN_ONSET {
|
|
onset_seen = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(onset_seen, "broadband variance elevation should trigger rain onset");
|
|
assert!(rd.is_raining());
|
|
}
|
|
|
|
#[test]
|
|
fn test_rain_cessation() {
|
|
let mut rd = RainDetector::new();
|
|
let phases = [0.5f32; 32];
|
|
let amps = [1.0f32; 32];
|
|
let low_vars = [0.001f32; 32];
|
|
let high_vars = [0.5f32; 32];
|
|
|
|
// Build baseline then start rain.
|
|
for _ in 0..MIN_EMPTY_FRAMES + 50 {
|
|
rd.process_frame(&phases, &low_vars, &s, 0);
|
|
}
|
|
for _ in 0..ONSET_FRAMES + 10 {
|
|
rd.process_frame(&phases, &high_vars, &s, 0);
|
|
}
|
|
assert!(rd.is_raining());
|
|
|
|
// Return to quiet — the short-term EWMA needs time to decay
|
|
// below the baseline before the broadband criterion fails.
|
|
// With SHORT_ALPHA=0.05, the EWMA half-life is ~14 frames,
|
|
// so we need ~50+ quiet frames before the short-term drops
|
|
// below 2.5x baseline, then CESSATION_FRAMES more to confirm.
|
|
let mut cessation_seen = false;
|
|
for _ in 0..200 {
|
|
let events = rd.process_frame(&phases, &low_vars, &s, 0);
|
|
for ev in events {
|
|
if ev.0 == EVENT_RAIN_CESSATION {
|
|
cessation_seen = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(cessation_seen, "return to quiet should trigger rain cessation");
|
|
assert!(!rd.is_raining());
|
|
}
|
|
|
|
#[test]
|
|
fn test_intensity_levels() {
|
|
assert_eq!(RainIntensity::None as u8, 0);
|
|
assert_eq!(RainIntensity::Light as u8, 1);
|
|
assert_eq!(RainIntensity::Moderate as u8, 2);
|
|
assert_eq!(RainIntensity::Heavy as u8, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_insufficient_subcarriers() {
|
|
let mut rd = RainDetector::new();
|
|
let small = [1.0f32; 4];
|
|
let events = rd.process_frame(&small, &small, &small, 0);
|
|
assert!(events.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_reset() {
|
|
let mut rd = RainDetector::new();
|
|
let phases = [0.5f32; 32];
|
|
let vars = [0.001f32; 32];
|
|
let amps = [1.0f32; 32];
|
|
for _ in 0..50 {
|
|
rd.process_frame(&phases, &vars, &s, 0);
|
|
}
|
|
assert!(rd.frame_count() > 0);
|
|
rd.reset();
|
|
assert_eq!(rd.frame_count(), 0);
|
|
assert!(!rd.is_raining());
|
|
}
|
|
}
|