mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
- ADR-042: Coherent Human Channel Imaging (non-CSI sensing protocol) with DDD domain model (6 bounded contexts) - 24 new WASM edge modules: medical (5), retail (5), security (5), building (5), industrial (5), exotic (8) - README: plain-language rewrites, moved detail sections below TOC, added edge module links to use case tables, firmware release docs - User guide: firmware release table, edge intelligence documentation - .gitignore: added rules for wasm, esp32 temp files, NVS binaries - WASM edge crate: cargo config, integration tests, module registry Co-Authored-By: claude-flow <ruv@ruv.net>
478 lines
16 KiB
Rust
478 lines
16 KiB
Rust
//! Multi-zone perimeter breach detection — ADR-041 Category 2 Security module.
|
|
//!
|
|
//! Monitors up to 4 perimeter zones via phase gradient analysis across subcarrier
|
|
//! groups. Determines movement direction (approach vs departure) from the temporal
|
|
//! ordering of phase disturbances and tracks zone-to-zone transitions with
|
|
//! directional vectors.
|
|
//!
|
|
//! Events: PERIMETER_BREACH(210), APPROACH_DETECTED(211),
|
|
//! DEPARTURE_DETECTED(212), ZONE_TRANSITION(213). Budget: S (<5 ms).
|
|
|
|
#[cfg(not(feature = "std"))]
|
|
use libm::{fabsf, sqrtf};
|
|
#[cfg(feature = "std")]
|
|
fn sqrtf(x: f32) -> f32 { x.sqrt() }
|
|
#[cfg(feature = "std")]
|
|
fn fabsf(x: f32) -> f32 { x.abs() }
|
|
|
|
const MAX_SC: usize = 32;
|
|
/// Number of perimeter zones.
|
|
const MAX_ZONES: usize = 4;
|
|
/// Calibration frames (5 seconds at 20 Hz).
|
|
const BASELINE_FRAMES: u32 = 100;
|
|
/// Phase gradient threshold for breach detection (rad/subcarrier).
|
|
const BREACH_GRADIENT_THRESH: f32 = 0.6;
|
|
/// Minimum variance ratio above baseline to consider zone disturbed.
|
|
const VARIANCE_RATIO_THRESH: f32 = 2.5;
|
|
/// Consecutive frames required for direction confirmation.
|
|
const DIRECTION_DEBOUNCE: u8 = 3;
|
|
/// Cooldown frames after event emission.
|
|
const COOLDOWN: u16 = 40;
|
|
/// History depth for direction estimation.
|
|
const HISTORY_LEN: usize = 8;
|
|
|
|
pub const EVENT_PERIMETER_BREACH: i32 = 210;
|
|
pub const EVENT_APPROACH_DETECTED: i32 = 211;
|
|
pub const EVENT_DEPARTURE_DETECTED: i32 = 212;
|
|
pub const EVENT_ZONE_TRANSITION: i32 = 213;
|
|
|
|
/// Per-zone state for gradient tracking.
|
|
#[derive(Clone, Copy)]
|
|
struct ZoneState {
|
|
/// Baseline mean phase gradient magnitude.
|
|
baseline_grad: f32,
|
|
/// Baseline amplitude variance.
|
|
baseline_var: f32,
|
|
/// Recent disturbance energy history (rolling).
|
|
energy_history: [f32; HISTORY_LEN],
|
|
hist_idx: usize,
|
|
/// Consecutive frames zone is disturbed.
|
|
disturb_run: u8,
|
|
}
|
|
|
|
impl ZoneState {
|
|
const fn new() -> Self {
|
|
Self {
|
|
baseline_grad: 0.0,
|
|
baseline_var: 0.001,
|
|
energy_history: [0.0; HISTORY_LEN],
|
|
hist_idx: 0,
|
|
disturb_run: 0,
|
|
}
|
|
}
|
|
|
|
fn push_energy(&mut self, e: f32) {
|
|
self.energy_history[self.hist_idx] = e;
|
|
self.hist_idx = (self.hist_idx + 1) % HISTORY_LEN;
|
|
}
|
|
|
|
/// Compute gradient trend: positive = increasing (approach), negative = decreasing (departure).
|
|
fn energy_trend(&self) -> f32 {
|
|
// Simple linear regression slope over history buffer.
|
|
let n = HISTORY_LEN as f32;
|
|
let mut sx = 0.0f32;
|
|
let mut sy = 0.0f32;
|
|
let mut sxy = 0.0f32;
|
|
let mut sxx = 0.0f32;
|
|
for k in 0..HISTORY_LEN {
|
|
// Read in chronological order from oldest to newest.
|
|
let idx = (self.hist_idx + k) % HISTORY_LEN;
|
|
let x = k as f32;
|
|
let y = self.energy_history[idx];
|
|
sx += x;
|
|
sy += y;
|
|
sxy += x * y;
|
|
sxx += x * x;
|
|
}
|
|
let denom = n * sxx - sx * sx;
|
|
if fabsf(denom) < 1e-6 { return 0.0; }
|
|
(n * sxy - sx * sy) / denom
|
|
}
|
|
}
|
|
|
|
/// Multi-zone perimeter breach detector.
|
|
pub struct PerimeterBreachDetector {
|
|
zones: [ZoneState; MAX_ZONES],
|
|
/// Calibration accumulators per zone: sum of gradient magnitudes.
|
|
cal_grad_sum: [f32; MAX_ZONES],
|
|
/// Calibration accumulators per zone: sum of variance.
|
|
cal_var_sum: [f32; MAX_ZONES],
|
|
cal_count: u32,
|
|
calibrated: bool,
|
|
/// Previous frame phase values.
|
|
prev_phases: [f32; MAX_SC],
|
|
phase_init: bool,
|
|
/// Last zone that was disturbed (for transition detection).
|
|
last_active_zone: i32,
|
|
/// Cooldowns per event type.
|
|
cd_breach: u16,
|
|
cd_approach: u16,
|
|
cd_departure: u16,
|
|
cd_transition: u16,
|
|
frame_count: u32,
|
|
/// Approach/departure debounce counters per zone.
|
|
approach_run: [u8; MAX_ZONES],
|
|
departure_run: [u8; MAX_ZONES],
|
|
}
|
|
|
|
impl PerimeterBreachDetector {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
zones: [ZoneState::new(); MAX_ZONES],
|
|
cal_grad_sum: [0.0; MAX_ZONES],
|
|
cal_var_sum: [0.0; MAX_ZONES],
|
|
cal_count: 0,
|
|
calibrated: false,
|
|
prev_phases: [0.0; MAX_SC],
|
|
phase_init: false,
|
|
last_active_zone: -1,
|
|
cd_breach: 0,
|
|
cd_approach: 0,
|
|
cd_departure: 0,
|
|
cd_transition: 0,
|
|
frame_count: 0,
|
|
approach_run: [0; MAX_ZONES],
|
|
departure_run: [0; MAX_ZONES],
|
|
}
|
|
}
|
|
|
|
/// Process one CSI frame. Returns `(event_id, value)` pairs.
|
|
pub fn process_frame(
|
|
&mut self,
|
|
phases: &[f32],
|
|
amplitudes: &[f32],
|
|
variance: &[f32],
|
|
_motion_energy: f32,
|
|
) -> &[(i32, f32)] {
|
|
let n_sc = phases.len().min(amplitudes.len()).min(variance.len()).min(MAX_SC);
|
|
if n_sc < 4 {
|
|
return &[];
|
|
}
|
|
|
|
self.frame_count += 1;
|
|
self.cd_breach = self.cd_breach.saturating_sub(1);
|
|
self.cd_approach = self.cd_approach.saturating_sub(1);
|
|
self.cd_departure = self.cd_departure.saturating_sub(1);
|
|
self.cd_transition = self.cd_transition.saturating_sub(1);
|
|
|
|
static mut EVENTS: [(i32, f32); 4] = [(0, 0.0); 4];
|
|
let mut ne = 0usize;
|
|
|
|
let subs_per_zone = n_sc / MAX_ZONES;
|
|
if subs_per_zone < 1 {
|
|
return &[];
|
|
}
|
|
|
|
// Compute per-zone metrics.
|
|
let mut zone_grad = [0.0f32; MAX_ZONES];
|
|
let mut zone_var = [0.0f32; MAX_ZONES];
|
|
|
|
for z in 0..MAX_ZONES {
|
|
let start = z * subs_per_zone;
|
|
let end = if z == MAX_ZONES - 1 { n_sc } else { start + subs_per_zone };
|
|
let count = (end - start) as f32;
|
|
if count < 2.0 { continue; }
|
|
|
|
// Phase gradient: mean absolute difference between adjacent subcarriers.
|
|
let mut grad_sum = 0.0f32;
|
|
if self.phase_init {
|
|
for i in start..end {
|
|
grad_sum += fabsf(phases[i] - self.prev_phases[i]);
|
|
}
|
|
}
|
|
zone_grad[z] = grad_sum / count;
|
|
|
|
// Mean variance for zone.
|
|
let mut var_sum = 0.0f32;
|
|
for i in start..end {
|
|
var_sum += variance[i];
|
|
}
|
|
zone_var[z] = var_sum / count;
|
|
}
|
|
|
|
// Save phases for next frame.
|
|
for i in 0..n_sc {
|
|
self.prev_phases[i] = phases[i];
|
|
}
|
|
if !self.phase_init {
|
|
self.phase_init = true;
|
|
return unsafe { &EVENTS[..0] };
|
|
}
|
|
|
|
// Calibration phase.
|
|
if !self.calibrated {
|
|
for z in 0..MAX_ZONES {
|
|
self.cal_grad_sum[z] += zone_grad[z];
|
|
self.cal_var_sum[z] += zone_var[z];
|
|
}
|
|
self.cal_count += 1;
|
|
if self.cal_count >= BASELINE_FRAMES {
|
|
let n = self.cal_count as f32;
|
|
for z in 0..MAX_ZONES {
|
|
self.zones[z].baseline_grad = self.cal_grad_sum[z] / n;
|
|
self.zones[z].baseline_var = (self.cal_var_sum[z] / n).max(0.001);
|
|
}
|
|
self.calibrated = true;
|
|
}
|
|
return unsafe { &EVENTS[..0] };
|
|
}
|
|
|
|
// Detect breaches and direction per zone.
|
|
let mut most_disturbed_zone: i32 = -1;
|
|
let mut max_energy = 0.0f32;
|
|
|
|
for z in 0..MAX_ZONES {
|
|
let grad_ratio = if self.zones[z].baseline_grad > 1e-6 {
|
|
zone_grad[z] / self.zones[z].baseline_grad
|
|
} else {
|
|
zone_grad[z] / 0.001
|
|
};
|
|
let var_ratio = zone_var[z] / self.zones[z].baseline_var;
|
|
|
|
let energy = grad_ratio * 0.6 + var_ratio * 0.4;
|
|
self.zones[z].push_energy(energy);
|
|
|
|
let is_breach = zone_grad[z] > BREACH_GRADIENT_THRESH
|
|
&& var_ratio > VARIANCE_RATIO_THRESH;
|
|
|
|
if is_breach {
|
|
self.zones[z].disturb_run = self.zones[z].disturb_run.saturating_add(1);
|
|
if energy > max_energy {
|
|
max_energy = energy;
|
|
most_disturbed_zone = z as i32;
|
|
}
|
|
} else {
|
|
self.zones[z].disturb_run = 0;
|
|
}
|
|
|
|
// Direction detection via energy trend.
|
|
let trend = self.zones[z].energy_trend();
|
|
if trend > 0.05 {
|
|
self.approach_run[z] = self.approach_run[z].saturating_add(1);
|
|
self.departure_run[z] = 0;
|
|
} else if trend < -0.05 {
|
|
self.departure_run[z] = self.departure_run[z].saturating_add(1);
|
|
self.approach_run[z] = 0;
|
|
} else {
|
|
self.approach_run[z] = 0;
|
|
self.departure_run[z] = 0;
|
|
}
|
|
|
|
// Emit approach event.
|
|
if self.approach_run[z] >= DIRECTION_DEBOUNCE && is_breach
|
|
&& self.cd_approach == 0 && ne < 4
|
|
{
|
|
unsafe { EVENTS[ne] = (EVENT_APPROACH_DETECTED, z as f32); }
|
|
ne += 1;
|
|
self.cd_approach = COOLDOWN;
|
|
self.approach_run[z] = 0;
|
|
}
|
|
|
|
// Emit departure event.
|
|
if self.departure_run[z] >= DIRECTION_DEBOUNCE
|
|
&& self.cd_departure == 0 && ne < 4
|
|
{
|
|
unsafe { EVENTS[ne] = (EVENT_DEPARTURE_DETECTED, z as f32); }
|
|
ne += 1;
|
|
self.cd_departure = COOLDOWN;
|
|
self.departure_run[z] = 0;
|
|
}
|
|
}
|
|
|
|
// Perimeter breach event.
|
|
if most_disturbed_zone >= 0 && self.cd_breach == 0 && ne < 4 {
|
|
unsafe { EVENTS[ne] = (EVENT_PERIMETER_BREACH, max_energy); }
|
|
ne += 1;
|
|
self.cd_breach = COOLDOWN;
|
|
}
|
|
|
|
// Zone transition event.
|
|
if most_disturbed_zone >= 0
|
|
&& self.last_active_zone >= 0
|
|
&& most_disturbed_zone != self.last_active_zone
|
|
&& self.cd_transition == 0
|
|
&& ne < 4
|
|
{
|
|
// Encode as from*10 + to.
|
|
let transition_code = self.last_active_zone as f32 * 10.0
|
|
+ most_disturbed_zone as f32;
|
|
unsafe { EVENTS[ne] = (EVENT_ZONE_TRANSITION, transition_code); }
|
|
ne += 1;
|
|
self.cd_transition = COOLDOWN;
|
|
}
|
|
|
|
if most_disturbed_zone >= 0 {
|
|
self.last_active_zone = most_disturbed_zone;
|
|
}
|
|
|
|
unsafe { &EVENTS[..ne] }
|
|
}
|
|
|
|
pub fn is_calibrated(&self) -> bool { self.calibrated }
|
|
pub fn frame_count(&self) -> u32 { self.frame_count }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_quiet() -> ([f32; 16], [f32; 16], [f32; 16]) {
|
|
([0.1; 16], [1.0; 16], [0.01; 16])
|
|
}
|
|
|
|
#[test]
|
|
fn test_init() {
|
|
let det = PerimeterBreachDetector::new();
|
|
assert!(!det.is_calibrated());
|
|
assert_eq!(det.frame_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_calibration_completes() {
|
|
let mut det = PerimeterBreachDetector::new();
|
|
let (p, a, v) = make_quiet();
|
|
// Need one extra frame for phase_init.
|
|
for i in 0..(BASELINE_FRAMES + 2) {
|
|
let mut pp = p;
|
|
// Vary slightly so phase_init triggers.
|
|
for j in 0..16 { pp[j] = 0.1 + (i as f32) * 0.001 + (j as f32) * 0.0001; }
|
|
det.process_frame(&pp, &a, &v, 0.0);
|
|
}
|
|
assert!(det.is_calibrated());
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_events_during_calibration() {
|
|
let mut det = PerimeterBreachDetector::new();
|
|
let (p, a, v) = make_quiet();
|
|
for _ in 0..50 {
|
|
let ev = det.process_frame(&p, &a, &v, 0.0);
|
|
assert!(ev.is_empty());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_breach_detection() {
|
|
let mut det = PerimeterBreachDetector::new();
|
|
// Calibrate with quiet data.
|
|
for i in 0..(BASELINE_FRAMES + 2) {
|
|
let mut p = [0.1f32; 16];
|
|
for j in 0..16 { p[j] = 0.1 + (i as f32) * 0.001; }
|
|
det.process_frame(&p, &[1.0; 16], &[0.01; 16], 0.0);
|
|
}
|
|
assert!(det.is_calibrated());
|
|
|
|
// Inject large disturbance in zone 0 (subcarriers 0-3).
|
|
let mut found_breach = false;
|
|
for frame in 0..20u32 {
|
|
let mut p = [0.1f32; 16];
|
|
let mut a = [1.0f32; 16];
|
|
let mut v = [0.01f32; 16];
|
|
// Zone 0: big phase jump + high variance.
|
|
for j in 0..4 {
|
|
p[j] = 3.0 + (frame as f32) * 1.5;
|
|
a[j] = 8.0;
|
|
v[j] = 5.0;
|
|
}
|
|
let ev = det.process_frame(&p, &a, &v, 5.0);
|
|
for &(et, _) in ev {
|
|
if et == EVENT_PERIMETER_BREACH {
|
|
found_breach = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found_breach, "perimeter breach should be detected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_zone_transition() {
|
|
let mut det = PerimeterBreachDetector::new();
|
|
// Calibrate.
|
|
for i in 0..(BASELINE_FRAMES + 2) {
|
|
let mut p = [0.1f32; 16];
|
|
for j in 0..16 { p[j] = 0.1 + (i as f32) * 0.001; }
|
|
det.process_frame(&p, &[1.0; 16], &[0.01; 16], 0.0);
|
|
}
|
|
|
|
// Disturb zone 0 first.
|
|
for frame in 0..10u32 {
|
|
let mut p = [0.1f32; 16];
|
|
let mut v = [0.01f32; 16];
|
|
for j in 0..4 {
|
|
p[j] = 3.0 + (frame as f32) * 1.5;
|
|
v[j] = 5.0;
|
|
}
|
|
det.process_frame(&p, &[1.0; 16], &v, 5.0);
|
|
}
|
|
|
|
// Now disturb zone 2 (subcarriers 8-11) — should trigger zone transition.
|
|
let mut found_transition = false;
|
|
for frame in 0..10u32 {
|
|
let mut p = [0.1f32; 16];
|
|
let mut v = [0.01f32; 16];
|
|
for j in 8..12 {
|
|
p[j] = 3.0 + (frame as f32) * 1.5;
|
|
v[j] = 5.0;
|
|
}
|
|
let ev = det.process_frame(&p, &[1.0; 16], &v, 5.0);
|
|
for &(et, _) in ev {
|
|
if et == EVENT_ZONE_TRANSITION {
|
|
found_transition = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found_transition, "zone transition should be detected");
|
|
}
|
|
|
|
#[test]
|
|
fn test_approach_detection() {
|
|
let mut det = PerimeterBreachDetector::new();
|
|
// Calibrate.
|
|
for i in 0..(BASELINE_FRAMES + 2) {
|
|
let mut p = [0.1f32; 16];
|
|
for j in 0..16 { p[j] = 0.1 + (i as f32) * 0.001; }
|
|
det.process_frame(&p, &[1.0; 16], &[0.01; 16], 0.0);
|
|
}
|
|
|
|
// Simulate increasing disturbance in zone 1 (approaching).
|
|
let mut found_approach = false;
|
|
for frame in 0..30u32 {
|
|
let mut p = [0.1f32; 16];
|
|
let mut v = [0.01f32; 16];
|
|
// Gradually increase disturbance in zone 1 (subcarriers 4-7).
|
|
let intensity = 0.5 + (frame as f32) * 0.3;
|
|
for j in 4..8 {
|
|
p[j] = intensity * 2.0;
|
|
v[j] = intensity;
|
|
}
|
|
let ev = det.process_frame(&p, &[1.0; 16], &v, intensity);
|
|
for &(et, _) in ev {
|
|
if et == EVENT_APPROACH_DETECTED {
|
|
found_approach = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found_approach, "approach should be detected on increasing disturbance");
|
|
}
|
|
|
|
#[test]
|
|
fn test_quiet_no_breach() {
|
|
let mut det = PerimeterBreachDetector::new();
|
|
// Calibrate.
|
|
for i in 0..(BASELINE_FRAMES + 2) {
|
|
let mut p = [0.1f32; 16];
|
|
for j in 0..16 { p[j] = 0.1 + (i as f32) * 0.001; }
|
|
det.process_frame(&p, &[1.0; 16], &[0.01; 16], 0.0);
|
|
}
|
|
|
|
// Continue with quiet data — should not trigger breach.
|
|
for i in 0..100u32 {
|
|
let mut p = [0.1f32; 16];
|
|
for j in 0..16 { p[j] = 0.1 + ((BASELINE_FRAMES + 2 + i) as f32) * 0.001; }
|
|
let ev = det.process_frame(&p, &[1.0; 16], &[0.01; 16], 0.0);
|
|
for &(et, _) in ev {
|
|
assert_ne!(et, EVENT_PERIMETER_BREACH, "no breach on quiet signal");
|
|
}
|
|
}
|
|
}
|
|
}
|