mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-26 13:10:40 +00:00
The Rust port lived two directories deep (rust-port/wifi-densepose-rs/) without any sibling under rust-port/ that warranted the extra level. Move the whole workspace up to v2/ to match v1/ (Python) at the same depth and shorten every cd / build command across the repo. git mv preserves history for all tracked files. 60 files updated for path references (CI workflows, ADRs, docs, scripts, READMEs, internal .claude-flow state). Two manual fixes for relative-cd paths in CLAUDE.md and ADR-043 that became wrong after the depth change (cd ../.. → cd ..). Validated: - cargo check --workspace --no-default-features → clean (after target/ nuke; the gitignored target/ was carried by the OS rename and had hard-coded old paths in build scripts) - cargo test --workspace --no-default-features → 1,539 passed, 0 failed, 8 ignored (same totals as pre-rename) - ESP32-S3 on COM7 → still streaming live CSI (cb #40300, RSSI -64 dBm) After-merge follow-up: contributors should `rm -rf v2/target` once and let cargo regenerate from the new path.
356 lines
11 KiB
Rust
356 lines
11 KiB
Rust
//! HVAC-optimized presence detection — ADR-041 Category 3: Smart Building.
|
|
//!
|
|
//! Provides presence information tuned for HVAC energy management:
|
|
//! - Long departure timeout (5 min / 6000 frames) to avoid premature shutoff
|
|
//! - Fast arrival debounce (10 s / 200 frames) for quick occupancy detection
|
|
//! - Activity level classification: sedentary vs active
|
|
//!
|
|
//! Host API used: `csi_get_presence()`, `csi_get_motion_energy()`
|
|
|
|
// No libm imports needed — pure arithmetic and comparisons.
|
|
|
|
/// Arrival debounce: 10 seconds at 20 Hz = 200 frames.
|
|
const ARRIVAL_DEBOUNCE: u32 = 200;
|
|
|
|
/// Departure timeout: 5 minutes at 20 Hz = 6000 frames.
|
|
const DEPARTURE_TIMEOUT: u32 = 6000;
|
|
|
|
/// Motion energy threshold separating sedentary from active.
|
|
const ACTIVITY_THRESHOLD: f32 = 0.3;
|
|
|
|
/// EMA smoothing for motion energy.
|
|
const MOTION_ALPHA: f32 = 0.1;
|
|
|
|
/// Minimum presence score to consider someone present.
|
|
const PRESENCE_THRESHOLD: f32 = 0.5;
|
|
|
|
/// Event emission interval (every N frames to limit bandwidth).
|
|
const EMIT_INTERVAL: u32 = 20;
|
|
|
|
// ── Event IDs (310-312: HVAC Presence) ──────────────────────────────────────
|
|
|
|
pub const EVENT_HVAC_OCCUPIED: i32 = 310;
|
|
pub const EVENT_ACTIVITY_LEVEL: i32 = 311;
|
|
pub const EVENT_DEPARTURE_COUNTDOWN: i32 = 312;
|
|
|
|
/// HVAC presence states.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum HvacState {
|
|
/// No one present, HVAC can enter energy-saving mode.
|
|
Vacant,
|
|
/// Presence detected but still within arrival debounce window.
|
|
ArrivalPending,
|
|
/// Confirmed occupied.
|
|
Occupied,
|
|
/// Presence lost, counting down before declaring vacant.
|
|
DeparturePending,
|
|
}
|
|
|
|
/// Activity level classification.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum ActivityLevel {
|
|
/// Low motion energy (reading, desk work, sleeping).
|
|
Sedentary,
|
|
/// High motion energy (walking, exercising, cleaning).
|
|
Active,
|
|
}
|
|
|
|
/// HVAC-optimized presence detector.
|
|
pub struct HvacPresenceDetector {
|
|
state: HvacState,
|
|
/// Smoothed motion energy (EMA).
|
|
motion_ema: f32,
|
|
/// Current activity level.
|
|
activity: ActivityLevel,
|
|
/// Consecutive frames with presence detected (for arrival debounce).
|
|
presence_frames: u32,
|
|
/// Consecutive frames without presence (for departure timeout).
|
|
absence_frames: u32,
|
|
/// Frame counter.
|
|
frame_count: u32,
|
|
}
|
|
|
|
impl HvacPresenceDetector {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
state: HvacState::Vacant,
|
|
motion_ema: 0.0,
|
|
activity: ActivityLevel::Sedentary,
|
|
presence_frames: 0,
|
|
absence_frames: 0,
|
|
frame_count: 0,
|
|
}
|
|
}
|
|
|
|
/// Process one frame of presence and motion data.
|
|
///
|
|
/// `presence_score`: 0.0-1.0 presence confidence from host.
|
|
/// `motion_energy`: raw motion energy from host.
|
|
///
|
|
/// Returns events as `(event_type, value)` pairs.
|
|
pub fn process_frame(
|
|
&mut self,
|
|
presence_score: f32,
|
|
motion_energy: f32,
|
|
) -> &[(i32, f32)] {
|
|
self.frame_count += 1;
|
|
|
|
// Smooth motion energy with EMA.
|
|
self.motion_ema = MOTION_ALPHA * motion_energy
|
|
+ (1.0 - MOTION_ALPHA) * self.motion_ema;
|
|
|
|
// Classify activity level.
|
|
self.activity = if self.motion_ema > ACTIVITY_THRESHOLD {
|
|
ActivityLevel::Active
|
|
} else {
|
|
ActivityLevel::Sedentary
|
|
};
|
|
|
|
let is_present = presence_score > PRESENCE_THRESHOLD;
|
|
|
|
// State machine transitions.
|
|
match self.state {
|
|
HvacState::Vacant => {
|
|
if is_present {
|
|
self.presence_frames += 1;
|
|
self.absence_frames = 0;
|
|
if self.presence_frames >= ARRIVAL_DEBOUNCE {
|
|
self.state = HvacState::Occupied;
|
|
} else {
|
|
self.state = HvacState::ArrivalPending;
|
|
}
|
|
} else {
|
|
self.presence_frames = 0;
|
|
}
|
|
}
|
|
HvacState::ArrivalPending => {
|
|
if is_present {
|
|
self.presence_frames += 1;
|
|
if self.presence_frames >= ARRIVAL_DEBOUNCE {
|
|
self.state = HvacState::Occupied;
|
|
}
|
|
} else {
|
|
// Lost presence during debounce, reset.
|
|
self.presence_frames = 0;
|
|
self.state = HvacState::Vacant;
|
|
}
|
|
}
|
|
HvacState::Occupied => {
|
|
if is_present {
|
|
self.absence_frames = 0;
|
|
} else {
|
|
self.absence_frames += 1;
|
|
self.state = HvacState::DeparturePending;
|
|
}
|
|
}
|
|
HvacState::DeparturePending => {
|
|
if is_present {
|
|
// Person returned, cancel departure.
|
|
self.absence_frames = 0;
|
|
self.state = HvacState::Occupied;
|
|
} else {
|
|
self.absence_frames += 1;
|
|
if self.absence_frames >= DEPARTURE_TIMEOUT {
|
|
self.state = HvacState::Vacant;
|
|
self.presence_frames = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build output events.
|
|
static mut EVENTS: [(i32, f32); 3] = [(0, 0.0); 3];
|
|
let mut n = 0usize;
|
|
|
|
if self.frame_count % EMIT_INTERVAL == 0 {
|
|
// Occupied status: 1.0 = occupied, 0.0 = vacant.
|
|
let occupied_val = match self.state {
|
|
HvacState::Occupied | HvacState::DeparturePending => 1.0,
|
|
_ => 0.0,
|
|
};
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_HVAC_OCCUPIED, occupied_val);
|
|
}
|
|
n += 1;
|
|
|
|
// Activity level: 0.0 = sedentary, 1.0 = active, plus raw EMA.
|
|
let activity_val = match self.activity {
|
|
ActivityLevel::Sedentary => 0.0 + self.motion_ema.min(0.99),
|
|
ActivityLevel::Active => 1.0,
|
|
};
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_ACTIVITY_LEVEL, activity_val);
|
|
}
|
|
n += 1;
|
|
}
|
|
|
|
// Departure countdown: emit remaining time fraction when pending.
|
|
if self.state == HvacState::DeparturePending
|
|
&& self.frame_count % EMIT_INTERVAL == 0
|
|
&& n < 3
|
|
{
|
|
let remaining = DEPARTURE_TIMEOUT.saturating_sub(self.absence_frames);
|
|
let fraction = remaining as f32 / DEPARTURE_TIMEOUT as f32;
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_DEPARTURE_COUNTDOWN, fraction);
|
|
}
|
|
n += 1;
|
|
}
|
|
|
|
unsafe { &EVENTS[..n] }
|
|
}
|
|
|
|
/// Get current HVAC state.
|
|
pub fn state(&self) -> HvacState {
|
|
self.state
|
|
}
|
|
|
|
/// Get current activity level.
|
|
pub fn activity(&self) -> ActivityLevel {
|
|
self.activity
|
|
}
|
|
|
|
/// Get smoothed motion energy.
|
|
pub fn motion_ema(&self) -> f32 {
|
|
self.motion_ema
|
|
}
|
|
|
|
/// Check if the space is considered occupied (for HVAC decisions).
|
|
pub fn is_occupied(&self) -> bool {
|
|
matches!(self.state, HvacState::Occupied | HvacState::DeparturePending)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_hvac_init() {
|
|
let det = HvacPresenceDetector::new();
|
|
assert_eq!(det.state(), HvacState::Vacant);
|
|
assert!(!det.is_occupied());
|
|
assert_eq!(det.activity(), ActivityLevel::Sedentary);
|
|
}
|
|
|
|
#[test]
|
|
fn test_arrival_debounce() {
|
|
let mut det = HvacPresenceDetector::new();
|
|
|
|
// Feed presence for less than debounce period.
|
|
for _ in 0..100 {
|
|
det.process_frame(0.8, 0.1);
|
|
}
|
|
// Should still be in ArrivalPending, not yet Occupied.
|
|
assert_eq!(det.state(), HvacState::ArrivalPending);
|
|
assert!(!det.is_occupied());
|
|
|
|
// Feed presence until debounce completes.
|
|
for _ in 100..ARRIVAL_DEBOUNCE + 1 {
|
|
det.process_frame(0.8, 0.1);
|
|
}
|
|
assert_eq!(det.state(), HvacState::Occupied);
|
|
assert!(det.is_occupied());
|
|
}
|
|
|
|
#[test]
|
|
fn test_departure_timeout() {
|
|
let mut det = HvacPresenceDetector::new();
|
|
|
|
// Establish occupancy.
|
|
for _ in 0..ARRIVAL_DEBOUNCE + 10 {
|
|
det.process_frame(0.8, 0.1);
|
|
}
|
|
assert!(det.is_occupied());
|
|
|
|
// Remove presence: should go to DeparturePending.
|
|
det.process_frame(0.0, 0.0);
|
|
assert_eq!(det.state(), HvacState::DeparturePending);
|
|
assert!(det.is_occupied()); // Still "occupied" during countdown.
|
|
|
|
// Feed absence frames up to timeout.
|
|
for _ in 0..DEPARTURE_TIMEOUT {
|
|
det.process_frame(0.0, 0.0);
|
|
}
|
|
assert_eq!(det.state(), HvacState::Vacant);
|
|
assert!(!det.is_occupied());
|
|
}
|
|
|
|
#[test]
|
|
fn test_departure_cancelled_on_return() {
|
|
let mut det = HvacPresenceDetector::new();
|
|
|
|
// Establish occupancy.
|
|
for _ in 0..ARRIVAL_DEBOUNCE + 10 {
|
|
det.process_frame(0.8, 0.1);
|
|
}
|
|
assert!(det.is_occupied());
|
|
|
|
// Start departure.
|
|
for _ in 0..100 {
|
|
det.process_frame(0.0, 0.0);
|
|
}
|
|
assert_eq!(det.state(), HvacState::DeparturePending);
|
|
|
|
// Person returns.
|
|
det.process_frame(0.8, 0.1);
|
|
assert_eq!(det.state(), HvacState::Occupied);
|
|
}
|
|
|
|
#[test]
|
|
fn test_activity_level_classification() {
|
|
let mut det = HvacPresenceDetector::new();
|
|
|
|
// Feed high motion energy for enough frames to saturate EMA.
|
|
for _ in 0..200 {
|
|
det.process_frame(0.8, 0.8);
|
|
}
|
|
assert_eq!(det.activity(), ActivityLevel::Active);
|
|
|
|
// Feed low motion energy.
|
|
for _ in 0..200 {
|
|
det.process_frame(0.8, 0.01);
|
|
}
|
|
assert_eq!(det.activity(), ActivityLevel::Sedentary);
|
|
}
|
|
|
|
#[test]
|
|
fn test_events_emitted_periodically() {
|
|
let mut det = HvacPresenceDetector::new();
|
|
|
|
// Establish occupancy.
|
|
for _ in 0..ARRIVAL_DEBOUNCE + 10 {
|
|
det.process_frame(0.8, 0.1);
|
|
}
|
|
|
|
// Process frames and check for events at EMIT_INTERVAL boundaries.
|
|
let mut found_occupied_event = false;
|
|
let mut found_activity_event = false;
|
|
for _ in 0..EMIT_INTERVAL + 1 {
|
|
let events = det.process_frame(0.8, 0.1);
|
|
for &(et, _) in events {
|
|
if et == EVENT_HVAC_OCCUPIED {
|
|
found_occupied_event = true;
|
|
}
|
|
if et == EVENT_ACTIVITY_LEVEL {
|
|
found_activity_event = true;
|
|
}
|
|
}
|
|
}
|
|
assert!(found_occupied_event, "should emit HVAC_OCCUPIED events");
|
|
assert!(found_activity_event, "should emit ACTIVITY_LEVEL events");
|
|
}
|
|
|
|
#[test]
|
|
fn test_false_presence_does_not_trigger() {
|
|
let mut det = HvacPresenceDetector::new();
|
|
|
|
// Brief presence blip (shorter than debounce).
|
|
for _ in 0..50 {
|
|
det.process_frame(0.8, 0.1);
|
|
}
|
|
// Then absence.
|
|
det.process_frame(0.0, 0.0);
|
|
assert_eq!(det.state(), HvacState::Vacant);
|
|
}
|
|
}
|