228 lines
7.8 KiB
Rust
228 lines
7.8 KiB
Rust
//! Someone-sleeping primitive (§3.12.1 row 1).
|
|
//!
|
|
//! **Definition (v1):**
|
|
//!
|
|
//! Enter `someone_sleeping = ON` when ALL of the following hold for
|
|
//! `sleep_dwell` (default 300 s):
|
|
//! - `presence == true`
|
|
//! - `motion < 0.05` (rolling)
|
|
//! - `breathing_rate_bpm ∈ [8.0, 20.0]` (rolling, conf ≥ 0.5)
|
|
//!
|
|
//! Exit when `motion > 0.15` for ≥30 s OR presence drops false.
|
|
//!
|
|
//! Heart-rate variability check is deferred to v2 because the broadcast
|
|
//! channel doesn't yet emit HRV; v1 fires on motion + BR + presence
|
|
//! which is the minimum that detects sleep cleanly in the ADR-079
|
|
//! paired-capture validation set.
|
|
|
|
use std::time::Duration;
|
|
|
|
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct SomeoneSleeping {
|
|
pub active: bool,
|
|
enter_since: Option<Duration>,
|
|
exit_since: Option<Duration>,
|
|
}
|
|
|
|
impl SomeoneSleeping {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Process one snapshot, return state change (if any).
|
|
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
|
|
if snap.since_start < cfg.warmup {
|
|
return PrimitiveState::Idle;
|
|
}
|
|
let br_ok = matches!(snap.breathing_rate_bpm, Some(bpm) if (8.0..=20.0).contains(&bpm))
|
|
&& snap.vital_confidence >= 0.5;
|
|
let motion_low = snap.motion < 0.05;
|
|
let presence_ok = snap.presence;
|
|
|
|
if !self.active {
|
|
if presence_ok && motion_low && br_ok {
|
|
let start = *self.enter_since.get_or_insert(snap.since_start);
|
|
if snap.since_start.saturating_sub(start) >= cfg.sleep_dwell {
|
|
self.active = true;
|
|
self.exit_since = None;
|
|
return PrimitiveState::Boolean {
|
|
active: true,
|
|
changed: true,
|
|
reason: Reason::new(&[
|
|
"presence=true",
|
|
"motion<5%",
|
|
"br=8-20bpm",
|
|
"dwell>=5min",
|
|
]),
|
|
};
|
|
}
|
|
} else {
|
|
self.enter_since = None;
|
|
}
|
|
PrimitiveState::Idle
|
|
} else {
|
|
// Active — check exit conditions.
|
|
let exiting = !presence_ok || snap.motion > 0.15;
|
|
if exiting {
|
|
let start = *self.exit_since.get_or_insert(snap.since_start);
|
|
// Presence-drop is immediate; motion-spike requires 30s dwell.
|
|
if !presence_ok || snap.since_start.saturating_sub(start) >= Duration::from_secs(30) {
|
|
self.active = false;
|
|
self.enter_since = None;
|
|
self.exit_since = None;
|
|
let mut tags = Vec::new();
|
|
if !presence_ok { tags.push("presence=false"); }
|
|
if snap.motion > 0.15 { tags.push("motion>15%"); }
|
|
return PrimitiveState::Boolean {
|
|
active: false,
|
|
changed: true,
|
|
reason: Reason::new(&tags),
|
|
};
|
|
}
|
|
} else {
|
|
self.exit_since = None;
|
|
}
|
|
PrimitiveState::Idle
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn cfg() -> PrimitiveConfig {
|
|
PrimitiveConfig::default()
|
|
}
|
|
|
|
fn sleeping_snap(t_secs: u64) -> RawSnapshot {
|
|
RawSnapshot {
|
|
since_start: Duration::from_secs(t_secs),
|
|
presence: true,
|
|
motion: 0.02,
|
|
breathing_rate_bpm: Some(13.0),
|
|
vital_confidence: 0.85,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_fire_during_warmup() {
|
|
let mut p = SomeoneSleeping::new();
|
|
let s = sleeping_snap(30);
|
|
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
|
|
assert!(!p.active);
|
|
}
|
|
|
|
#[test]
|
|
fn fires_after_dwell_post_warmup() {
|
|
let mut p = SomeoneSleeping::new();
|
|
// Tick after warmup but before dwell — idle.
|
|
assert!(matches!(p.tick(&sleeping_snap(60 + 100), &cfg()), PrimitiveState::Idle));
|
|
// Tick after warmup + dwell — should activate (start was at t=160).
|
|
let state = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
|
|
match state {
|
|
PrimitiveState::Boolean { active, changed, .. } => {
|
|
assert!(active);
|
|
assert!(changed);
|
|
}
|
|
other => panic!("expected boolean on/change, got {:?}", other),
|
|
}
|
|
assert!(p.active);
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_fire_when_motion_high() {
|
|
let mut p = SomeoneSleeping::new();
|
|
let mut s = sleeping_snap(60 + 100);
|
|
s.motion = 0.30;
|
|
for t in 0..600u64 {
|
|
let mut s2 = s.clone();
|
|
s2.since_start = Duration::from_secs(60 + 100 + t);
|
|
assert!(matches!(p.tick(&s2, &cfg()), PrimitiveState::Idle));
|
|
}
|
|
assert!(!p.active);
|
|
}
|
|
|
|
#[test]
|
|
fn does_not_fire_when_br_out_of_range() {
|
|
let mut p = SomeoneSleeping::new();
|
|
let mut s = sleeping_snap(60 + 100);
|
|
s.breathing_rate_bpm = Some(30.0); // too fast
|
|
let s2 = {
|
|
let mut x = s.clone();
|
|
x.since_start = Duration::from_secs(60 + 100 + 600);
|
|
x
|
|
};
|
|
let _ = p.tick(&s, &cfg());
|
|
assert!(matches!(p.tick(&s2, &cfg()), PrimitiveState::Idle));
|
|
assert!(!p.active);
|
|
}
|
|
|
|
#[test]
|
|
fn exits_on_presence_false_immediately() {
|
|
let mut p = SomeoneSleeping::new();
|
|
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
|
|
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
|
|
assert!(p.active);
|
|
// Presence drops.
|
|
let mut s = sleeping_snap(60 + 100 + 301);
|
|
s.presence = false;
|
|
let state = p.tick(&s, &cfg());
|
|
match state {
|
|
PrimitiveState::Boolean { active, changed, .. } => {
|
|
assert!(!active);
|
|
assert!(changed);
|
|
}
|
|
other => panic!("expected boolean off/change, got {:?}", other),
|
|
}
|
|
assert!(!p.active);
|
|
}
|
|
|
|
#[test]
|
|
fn exits_on_sustained_motion_only_after_30s() {
|
|
let mut p = SomeoneSleeping::new();
|
|
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
|
|
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
|
|
assert!(p.active);
|
|
// Motion spikes for 10 s — too short to exit.
|
|
let mut s = sleeping_snap(60 + 100 + 310);
|
|
s.motion = 0.20;
|
|
let state = p.tick(&s, &cfg());
|
|
assert!(matches!(state, PrimitiveState::Idle));
|
|
assert!(p.active);
|
|
// Motion sustained 30 s → exit.
|
|
let mut s2 = sleeping_snap(60 + 100 + 340);
|
|
s2.motion = 0.20;
|
|
let state2 = p.tick(&s2, &cfg());
|
|
match state2 {
|
|
PrimitiveState::Boolean { active, changed, .. } => {
|
|
assert!(!active);
|
|
assert!(changed);
|
|
}
|
|
other => panic!("expected boolean off/change, got {:?}", other),
|
|
}
|
|
assert!(!p.active);
|
|
}
|
|
|
|
#[test]
|
|
fn brief_motion_blip_does_not_exit() {
|
|
let mut p = SomeoneSleeping::new();
|
|
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
|
|
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
|
|
assert!(p.active);
|
|
// Motion spikes briefly then returns to low.
|
|
let mut s_spike = sleeping_snap(60 + 100 + 305);
|
|
s_spike.motion = 0.20;
|
|
let _ = p.tick(&s_spike, &cfg());
|
|
// Back to low motion within 30s.
|
|
let s_calm = sleeping_snap(60 + 100 + 315);
|
|
let state = p.tick(&s_calm, &cfg());
|
|
assert!(matches!(state, PrimitiveState::Idle));
|
|
// Still active because exit dwell was reset by calm sample.
|
|
assert!(p.active);
|
|
}
|
|
}
|