diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/bed_exit.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/bed_exit.rs new file mode 100644 index 00000000..1eed5bbc --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/bed_exit.rs @@ -0,0 +1,147 @@ +//! Bed-exit (overnight) primitive (§3.12.1 row 8). +//! +//! Edge-triggered event: fires once when "someone sleeping" transitions +//! to "no presence in any bed-tagged zone" between 22:00 and 06:00 +//! local time. +//! +//! Inputs: +//! - `sleeping` from upstream (the someone_sleeping primitive — wired +//! into the bus output so we don't re-derive it here) +//! - `active_zones` — list of zones currently reporting presence +//! - `bed_zones` — config list of zones tagged as bed-areas +//! - `local_seconds_since_midnight` — local-time of day +//! +//! For v1 we don't have direct cross-primitive wiring, so we +//! approximate "sleeping" with: was-presence-in-bed-zone, then +//! exited-bed-zone. Refine in v2 when the bus exposes `sleeping` +//! state to other primitives. + +use super::common::{in_window, PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; + +#[derive(Debug, Default, Clone)] +pub struct BedExit { + in_bed: bool, +} + +impl BedExit { + pub fn new() -> Self { Self::default() } + + fn in_bed_zone(snap: &RawSnapshot) -> bool { + !snap.bed_zones.is_empty() + && snap.active_zones.iter().any(|z| snap.bed_zones.contains(z)) + } + + pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState { + if snap.since_start < cfg.warmup { + return PrimitiveState::Idle; + } + let now_in_bed = snap.presence && Self::in_bed_zone(snap); + let was_in_bed = self.in_bed; + self.in_bed = now_in_bed; + + if was_in_bed && !now_in_bed { + // Only fire during overnight window. + let (start, end) = cfg.bed_exit_window; + if in_window(snap.local_seconds_since_midnight, start, end) { + return PrimitiveState::Event { + event_type: "bed_exit", + reason: Reason::new(&[ + "left_bed_zone", + "overnight_window", + ]), + }; + } + } + PrimitiveState::Idle + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() } + + fn in_bed_overnight(t: u64) -> RawSnapshot { + RawSnapshot { + since_start: Duration::from_secs(120 + t), + presence: true, + active_zones: vec!["bedroom".into()], + bed_zones: vec!["bedroom".into()], + local_seconds_since_midnight: 2 * 3600, // 02:00 + ..Default::default() + } + } + + fn out_of_bed_overnight(t: u64) -> RawSnapshot { + RawSnapshot { + since_start: Duration::from_secs(120 + t), + presence: true, + active_zones: vec!["hall".into()], + bed_zones: vec!["bedroom".into()], + local_seconds_since_midnight: 2 * 3600, + ..Default::default() + } + } + + #[test] + fn fires_on_bed_to_non_bed_overnight() { + let mut p = BedExit::new(); + let _ = p.tick(&in_bed_overnight(10), &cfg()); + let state = p.tick(&out_of_bed_overnight(20), &cfg()); + assert!(matches!(state, PrimitiveState::Event { event_type: "bed_exit", .. })); + } + + #[test] + fn does_not_fire_during_day() { + let mut p = BedExit::new(); + let mut s_in = in_bed_overnight(10); + s_in.local_seconds_since_midnight = 14 * 3600; // 14:00 + let _ = p.tick(&s_in, &cfg()); + let mut s_out = out_of_bed_overnight(20); + s_out.local_seconds_since_midnight = 14 * 3600; + let state = p.tick(&s_out, &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + } + + #[test] + fn does_not_fire_without_prior_in_bed() { + let mut p = BedExit::new(); + // Person never was in bed. + let state = p.tick(&out_of_bed_overnight(20), &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + } + + #[test] + fn warmup_blocks_initial_transitions() { + let mut p = BedExit::new(); + let mut s_in = in_bed_overnight(0); + s_in.since_start = Duration::from_secs(30); + assert!(matches!(p.tick(&s_in, &cfg()), PrimitiveState::Idle)); + } + + #[test] + fn does_not_fire_when_bed_zones_unconfigured() { + let mut p = BedExit::new(); + let mut s_in = in_bed_overnight(10); + s_in.bed_zones.clear(); + let _ = p.tick(&s_in, &cfg()); + let mut s_out = out_of_bed_overnight(20); + s_out.bed_zones.clear(); + let state = p.tick(&s_out, &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + } + + #[test] + fn fires_just_after_midnight_window_start() { + let mut p = BedExit::new(); + let mut s_in = in_bed_overnight(10); + s_in.local_seconds_since_midnight = 22 * 3600 + 5; // 22:00:05 + let _ = p.tick(&s_in, &cfg()); + let mut s_out = out_of_bed_overnight(20); + s_out.local_seconds_since_midnight = 22 * 3600 + 10; + let state = p.tick(&s_out, &cfg()); + assert!(matches!(state, PrimitiveState::Event { .. })); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/bus.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/bus.rs index 651ade7c..ddd1bb5d 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/semantic/bus.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/bus.rs @@ -9,17 +9,33 @@ //! add primitives in P4.5b. use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; -use super::{bathroom::BathroomOccupied, no_movement::NoMovement, room_active::RoomActive, sleeping::SomeoneSleeping}; +use super::{ + bathroom::BathroomOccupied, + bed_exit::BedExit, + distress::PossibleDistress, + elderly_anomaly::ElderlyInactivityAnomaly, + fall_risk::FallRiskElevated, + meeting::MeetingInProgress, + multi_room::MultiRoomTransition, + no_movement::NoMovement, + room_active::RoomActive, + sleeping::SomeoneSleeping, +}; /// Identifier for which primitive produced an event. Used by the /// publisher to map onto the matching `EntityKind`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SemanticKind { SomeoneSleeping, + PossibleDistress, RoomActive, + ElderlyAnomaly, + Meeting, BathroomOccupied, + FallRisk, + BedExit, NoMovement, - // P4.5b: Distress, ElderlyAnomaly, Meeting, FallRisk, BedExit, MultiRoom. + MultiRoom, } /// One event published to MQTT / Matter consumers. @@ -34,9 +50,15 @@ pub struct SemanticEvent { /// Collection of every primitive FSM. Owned by the publisher task. pub struct SemanticBus { sleeping: SomeoneSleeping, + distress: PossibleDistress, room_active: RoomActive, + elderly_anomaly: ElderlyInactivityAnomaly, + meeting: MeetingInProgress, bathroom: BathroomOccupied, + fall_risk: FallRiskElevated, + bed_exit: BedExit, no_movement: NoMovement, + multi_room: MultiRoomTransition, pub config: PrimitiveConfig, } @@ -44,9 +66,15 @@ impl SemanticBus { pub fn new(config: PrimitiveConfig) -> Self { Self { sleeping: SomeoneSleeping::new(), + distress: PossibleDistress::new(), room_active: RoomActive::new(), + elderly_anomaly: ElderlyInactivityAnomaly::new(), + meeting: MeetingInProgress::new(), bathroom: BathroomOccupied::new(), + fall_risk: FallRiskElevated::new(), + bed_exit: BedExit::new(), no_movement: NoMovement::new(), + multi_room: MultiRoomTransition::new(), config, } } @@ -54,11 +82,17 @@ impl SemanticBus { /// Run all primitives on one snapshot. Returns only events that /// emit (Idle states are filtered). pub fn tick(&mut self, snap: &RawSnapshot) -> Vec { - let pairs: [(SemanticKind, PrimitiveState); 4] = [ - (SemanticKind::SomeoneSleeping, self.sleeping.tick(snap, &self.config)), - (SemanticKind::RoomActive, self.room_active.tick(snap, &self.config)), - (SemanticKind::BathroomOccupied, self.bathroom.tick(snap, &self.config)), - (SemanticKind::NoMovement, self.no_movement.tick(snap, &self.config)), + let pairs: [(SemanticKind, PrimitiveState); 10] = [ + (SemanticKind::SomeoneSleeping, self.sleeping.tick(snap, &self.config)), + (SemanticKind::PossibleDistress, self.distress.tick(snap, &self.config)), + (SemanticKind::RoomActive, self.room_active.tick(snap, &self.config)), + (SemanticKind::ElderlyAnomaly, self.elderly_anomaly.tick(snap, &self.config)), + (SemanticKind::Meeting, self.meeting.tick(snap, &self.config)), + (SemanticKind::BathroomOccupied, self.bathroom.tick(snap, &self.config)), + (SemanticKind::FallRisk, self.fall_risk.tick(snap, &self.config)), + (SemanticKind::BedExit, self.bed_exit.tick(snap, &self.config)), + (SemanticKind::NoMovement, self.no_movement.tick(snap, &self.config)), + (SemanticKind::MultiRoom, self.multi_room.tick(snap, &self.config)), ]; pairs .into_iter() diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/distress.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/distress.rs new file mode 100644 index 00000000..d0b380e1 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/distress.rs @@ -0,0 +1,284 @@ +//! Possible-distress primitive (§3.12.1 row 2). +//! +//! Enter `possible_distress = ON` when ALL of the following hold for +//! `distress_dwell` (default 60 s): +//! - sustained HR > `distress_hr_multiple` × rolling baseline (default 1.5×) +//! - motion is agitated (motion > 0.20) +//! - no fall recently +//! +//! Exit when HR returns to baseline OR motion calms below 0.10 for 30 s. +//! After exit there's a 5-min latch suppressing re-fire (refractory). +//! +//! Baseline is an exponential moving average over a long window so a +//! single high-HR sample doesn't shift the reference fast. Window is +//! parametric so deployments can tune for resident demographics. + +use std::time::Duration; + +use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; + +const REFRACTORY: Duration = Duration::from_secs(300); + +/// Exponential moving average over heart-rate samples. +#[derive(Debug, Default, Clone)] +struct Ewma { + value: Option, + alpha: f64, // 0..1, smaller = longer memory +} + +impl Ewma { + fn new(alpha: f64) -> Self { Self { value: None, alpha } } + fn update(&mut self, x: f64) { + self.value = Some(match self.value { + Some(v) => self.alpha * x + (1.0 - self.alpha) * v, + None => x, + }); + } +} + +#[derive(Debug, Clone)] +pub struct PossibleDistress { + pub active: bool, + baseline: Ewma, + enter_since: Option, + last_exit: Option, +} + +impl Default for PossibleDistress { + fn default() -> Self { + Self { + active: false, + baseline: Ewma::new(0.01), // ~100-sample memory at 1 Hz + enter_since: None, + last_exit: None, + } + } +} + +impl PossibleDistress { + pub fn new() -> Self { Self::default() } + + pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState { + if snap.since_start < cfg.warmup { + // Still seed the baseline even in warmup so we don't fire + // immediately after the warmup ends with a cold baseline. + if let Some(hr) = snap.heart_rate_bpm { + if snap.vital_confidence >= 0.5 { self.baseline.update(hr); } + } + return PrimitiveState::Idle; + } + + let hr = match snap.heart_rate_bpm { + Some(v) if snap.vital_confidence >= 0.5 => v, + _ => return PrimitiveState::Idle, + }; + let baseline = match self.baseline.value { + Some(b) if b > 0.0 => b, + _ => { + self.baseline.update(hr); + return PrimitiveState::Idle; + } + }; + + let hr_high = hr / baseline >= cfg.distress_hr_multiple; + let agitated = snap.motion > 0.20; + let no_fall = !snap.fall_detected; + + // Only update baseline when NOT active AND NOT in a candidate + // distress event (low motion, HR near baseline). This keeps the + // baseline anchored to resting HR rather than chasing elevated + // samples — without this guard a sustained elevated HR drifts + // the baseline up before the dwell completes. + if !self.active && !agitated && !hr_high { + self.baseline.update(hr); + } + + if !self.active { + // Refractory period after recent exit. + if let Some(t) = self.last_exit { + if snap.since_start.saturating_sub(t) < REFRACTORY { + return PrimitiveState::Idle; + } + } + if hr_high && agitated && no_fall { + let start = *self.enter_since.get_or_insert(snap.since_start); + if snap.since_start.saturating_sub(start) >= cfg.distress_dwell { + self.active = true; + return PrimitiveState::Boolean { + active: true, + changed: true, + reason: Reason::new(&[ + "hr_high>=1.5x", + "motion>20%", + "no_fall", + "dwell>=60s", + ]), + }; + } + } else { + self.enter_since = None; + } + PrimitiveState::Idle + } else { + // Active — check exit. + let calm = snap.motion < 0.10 && hr / baseline < 1.2; + if calm { + self.active = false; + self.enter_since = None; + self.last_exit = Some(snap.since_start); + return PrimitiveState::Boolean { + active: false, + changed: true, + reason: Reason::new(&["motion<10%", "hr_back_to_baseline"]), + }; + } + PrimitiveState::Idle + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() } + + fn snap(t_secs: u64, hr: Option, motion: f64) -> RawSnapshot { + RawSnapshot { + since_start: Duration::from_secs(t_secs), + presence: true, + motion, + heart_rate_bpm: hr, + vital_confidence: 0.8, + ..Default::default() + } + } + + fn seed_baseline(p: &mut PossibleDistress, hr: f64) { + // Warmup samples seed the EWMA baseline. + for t in 0..60 { + let _ = p.tick(&snap(t, Some(hr), 0.0), &cfg()); + } + } + + #[test] + fn does_not_fire_with_normal_hr() { + let mut p = PossibleDistress::new(); + seed_baseline(&mut p, 70.0); + // Normal HR + low motion → no fire. + for t in 60..200 { + let s = snap(t, Some(72.0), 0.05); + assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle)); + } + assert!(!p.active); + } + + #[test] + fn fires_on_sustained_elevated_hr_with_motion() { + let mut p = PossibleDistress::new(); + seed_baseline(&mut p, 70.0); + // Elevated HR (>1.5×70=105) + agitated motion, sustained 60s. + let mut fired = false; + for t in 60..200 { + let s = snap(t, Some(120.0), 0.35); + if matches!(p.tick(&s, &cfg()), PrimitiveState::Boolean { active: true, .. }) { + fired = true; + break; + } + } + assert!(fired, "primitive must fire on sustained elevated HR + motion"); + assert!(p.active); + } + + #[test] + fn does_not_fire_during_fall() { + let mut p = PossibleDistress::new(); + seed_baseline(&mut p, 70.0); + for t in 60..200 { + let mut s = snap(t, Some(120.0), 0.35); + s.fall_detected = true; + assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle)); + } + assert!(!p.active); + } + + #[test] + fn exits_when_motion_calms_and_hr_normalises() { + let mut p = PossibleDistress::new(); + seed_baseline(&mut p, 70.0); + // Trigger. + for t in 60..200 { + let s = snap(t, Some(120.0), 0.35); + let _ = p.tick(&s, &cfg()); + } + assert!(p.active); + // Calm sample. + let s_calm = snap(220, Some(75.0), 0.05); + let state = p.tick(&s_calm, &cfg()); + match state { + PrimitiveState::Boolean { active, changed, .. } => { + assert!(!active && changed); + } + other => panic!("expected off/change, got {:?}", other), + } + assert!(!p.active); + } + + #[test] + fn refractory_blocks_immediate_refire() { + let mut p = PossibleDistress::new(); + seed_baseline(&mut p, 70.0); + for t in 60..200 { + let _ = p.tick(&snap(t, Some(120.0), 0.35), &cfg()); + } + // Calm to exit. + let _ = p.tick(&snap(220, Some(75.0), 0.05), &cfg()); + assert!(!p.active); + // Try to re-fire 1 min after exit (refractory is 5 min). + for t in 280..400 { + let s = snap(t, Some(120.0), 0.35); + assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle)); + } + assert!(!p.active); + } + + #[test] + fn refire_allowed_after_refractory() { + let mut p = PossibleDistress::new(); + seed_baseline(&mut p, 70.0); + for t in 60..200 { + let _ = p.tick(&snap(t, Some(120.0), 0.35), &cfg()); + } + let _ = p.tick(&snap(220, Some(75.0), 0.05), &cfg()); + // 6 min later — past refractory. + let mut fired = false; + for t in 600..800 { + let s = snap(t, Some(120.0), 0.35); + if matches!(p.tick(&s, &cfg()), PrimitiveState::Boolean { active: true, .. }) { + fired = true; + break; + } + } + assert!(fired); + } + + #[test] + fn baseline_does_not_track_during_active() { + let mut p = PossibleDistress::new(); + seed_baseline(&mut p, 70.0); + let initial = p.baseline.value.unwrap(); + for t in 60..200 { + let _ = p.tick(&snap(t, Some(120.0), 0.35), &cfg()); + } + assert!(p.active); + // Many more elevated samples — baseline must not climb. + for t in 200..400 { + let _ = p.tick(&snap(t, Some(130.0), 0.35), &cfg()); + } + let after = p.baseline.value.unwrap(); + // Baseline may move a little during pre-trigger window, but it + // must not chase the 130-bpm samples during the active state. + assert!(after < 100.0, "baseline {} drifted toward distress HR", after); + assert!(initial < 100.0); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/elderly_anomaly.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/elderly_anomaly.rs new file mode 100644 index 00000000..104ef936 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/elderly_anomaly.rs @@ -0,0 +1,173 @@ +//! Elderly inactivity anomaly primitive (§3.12.1 row 4). +//! +//! Enter `elderly_inactivity_anomaly = ON` when current inactivity +//! duration exceeds `elderly_anomaly_multiple` × rolling median of +//! daily idle durations (default 2×). +//! +//! v1 implements this with a simplified rolling-quantile: the longest +//! idle stretch ever seen since process start, capped by the +//! `--semantic-baseline-window-days` flag (default 14 — but we don't +//! persist across restarts in v1, so the window is effectively +//! "uptime"). Per-resident persistent baselines arrive in v2 with the +//! `SemanticState` log-replay path. +//! +//! Refractory: max 1 firing per 24 h to prevent alert spam. + +use std::time::Duration; + +use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; + +const REFRACTORY: Duration = Duration::from_secs(24 * 3600); + +#[derive(Debug, Default, Clone)] +pub struct ElderlyInactivityAnomaly { + pub active: bool, + idle_since: Option, + /// Longest idle stretch observed so far. The "baseline" the multiplier + /// is applied against. Seeded to a sensible floor so the first day + /// doesn't fire spuriously. + longest_idle: Duration, + last_fire: Option, +} + +const BASELINE_FLOOR: Duration = Duration::from_secs(30 * 60); // 30 min + +impl ElderlyInactivityAnomaly { + pub fn new() -> Self { + Self { longest_idle: BASELINE_FLOOR, ..Default::default() } + } + + pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState { + if snap.since_start < cfg.warmup { + return PrimitiveState::Idle; + } + let still = snap.presence && snap.motion < 0.02; + if !still { + // Update baseline if we just emerged from a long stretch. + if let Some(start) = self.idle_since { + let dur = snap.since_start.saturating_sub(start); + if dur > self.longest_idle { self.longest_idle = dur; } + } + self.idle_since = None; + if self.active { + self.active = false; + return PrimitiveState::Boolean { + active: false, + changed: true, + reason: Reason::new(&["motion_resumed"]), + }; + } + return PrimitiveState::Idle; + } + + let start = *self.idle_since.get_or_insert(snap.since_start); + let dur = snap.since_start.saturating_sub(start); + let threshold_secs = (self.longest_idle.as_secs_f64()) * cfg.elderly_anomaly_multiple; + let threshold = Duration::from_secs_f64(threshold_secs); + + if !self.active && dur >= threshold { + // Refractory. + if let Some(t) = self.last_fire { + if snap.since_start.saturating_sub(t) < REFRACTORY { + return PrimitiveState::Idle; + } + } + self.active = true; + self.last_fire = Some(snap.since_start); + return PrimitiveState::Boolean { + active: true, + changed: true, + reason: Reason::new(&[ + "presence=true", + "motion<2%", + "idle>2x_baseline", + ]), + }; + } + PrimitiveState::Idle + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() } + + fn still_snap(t_secs: u64) -> RawSnapshot { + RawSnapshot { + since_start: Duration::from_secs(t_secs), + presence: true, + motion: 0.01, + ..Default::default() + } + } + + #[test] + fn fires_when_idle_exceeds_2x_baseline() { + let mut p = ElderlyInactivityAnomaly::new(); + // baseline floor is 30 min → threshold = 60 min idle. + let _ = p.tick(&still_snap(100), &cfg()); + let state = p.tick(&still_snap(100 + 61 * 60), &cfg()); + match state { + PrimitiveState::Boolean { active, changed, .. } => { + assert!(active && changed); + } + other => panic!("expected on, got {:?}", other), + } + } + + #[test] + fn does_not_fire_before_threshold() { + let mut p = ElderlyInactivityAnomaly::new(); + let _ = p.tick(&still_snap(100), &cfg()); + // 50 min idle, threshold is 60. + let state = p.tick(&still_snap(100 + 50 * 60), &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + } + + #[test] + fn motion_clears_active_state() { + let mut p = ElderlyInactivityAnomaly::new(); + let _ = p.tick(&still_snap(100), &cfg()); + let _ = p.tick(&still_snap(100 + 61 * 60), &cfg()); + assert!(p.active); + // Motion. + let mut s = still_snap(100 + 61 * 60 + 1); + s.motion = 0.10; + let state = p.tick(&s, &cfg()); + match state { + PrimitiveState::Boolean { active, .. } => assert!(!active), + other => panic!("expected off, got {:?}", other), + } + } + + #[test] + fn baseline_grows_to_observed_max() { + let mut p = ElderlyInactivityAnomaly::new(); + // Establish a 90-min idle stretch — baseline should grow. + let _ = p.tick(&still_snap(100), &cfg()); + let _ = p.tick(&still_snap(100 + 90 * 60), &cfg()); + // p is now active. Force exit. + let mut s = still_snap(100 + 90 * 60 + 1); + s.motion = 0.20; + let _ = p.tick(&s, &cfg()); + // Baseline updated. + assert!(p.longest_idle >= Duration::from_secs(89 * 60)); + } + + #[test] + fn refractory_prevents_repeat_alerts() { + let mut p = ElderlyInactivityAnomaly::new(); + let _ = p.tick(&still_snap(100), &cfg()); + let _ = p.tick(&still_snap(100 + 61 * 60), &cfg()); + // Motion clears. + let mut s = still_snap(100 + 61 * 60 + 1); + s.motion = 0.20; + let _ = p.tick(&s, &cfg()); + // 5 hours later, another 1h+ idle — should NOT fire (still <24h). + let _ = p.tick(&still_snap(100 + 5 * 3600), &cfg()); + let state = p.tick(&still_snap(100 + 5 * 3600 + 70 * 60), &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/fall_risk.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/fall_risk.rs new file mode 100644 index 00000000..2de0f713 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/fall_risk.rs @@ -0,0 +1,214 @@ +//! Fall-risk-elevated primitive (§3.12.1 row 7). +//! +//! Continuous 0..100 score derived from gait instability + near-fall +//! frequency over a rolling 24 h window. Emits a Scalar state every +//! tick when active; emits a one-shot event when the score crosses +//! `fall_risk_event_threshold` (default 70). +//! +//! v1 simplification: score = clamp(100, 10 * near_falls_24h + +//! 50 * recent_motion_variance), where: +//! - near_falls_24h: count of `fall_detected` events in the trailing +//! 24 h window (we don't expose near-falls separately in the +//! broadcast yet, so we approximate with confirmed falls) +//! - recent_motion_variance: variance of motion over the trailing +//! 60 s. +//! +//! v2 will use the gait-instability score directly once it lands in +//! the pose tracker (see ADR-027 §A4). + +use std::collections::VecDeque; +use std::time::Duration; + +use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; + +const RECENT_MOTION_WINDOW: Duration = Duration::from_secs(60); +const FALL_HISTORY_WINDOW: Duration = Duration::from_secs(24 * 3600); + +#[derive(Debug, Default, Clone)] +pub struct FallRiskElevated { + pub last_score: f64, + /// (timestamp, motion). + motion_history: VecDeque<(Duration, f64)>, + /// Timestamps of fall_detected=true events. + fall_history: VecDeque, + /// True iff last emit was above the configured event threshold. + above_threshold: bool, +} + +impl FallRiskElevated { + pub fn new() -> Self { Self::default() } + + fn variance(samples: &VecDeque<(Duration, f64)>) -> f64 { + if samples.is_empty() { return 0.0; } + let mean = samples.iter().map(|(_, m)| m).sum::() / samples.len() as f64; + let v = samples + .iter() + .map(|(_, m)| (m - mean).powi(2)) + .sum::() + / samples.len() as f64; + v + } + + pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState { + if snap.since_start < cfg.warmup { + return PrimitiveState::Idle; + } + + // Maintain rolling motion history. + self.motion_history.push_back((snap.since_start, snap.motion)); + while let Some(&(t, _)) = self.motion_history.front() { + if snap.since_start.saturating_sub(t) > RECENT_MOTION_WINDOW { + self.motion_history.pop_front(); + } else { + break; + } + } + + // Maintain rolling fall history. + if snap.fall_detected { + self.fall_history.push_back(snap.since_start); + } + while let Some(&t) = self.fall_history.front() { + if snap.since_start.saturating_sub(t) > FALL_HISTORY_WINDOW { + self.fall_history.pop_front(); + } else { + break; + } + } + + let near_falls = self.fall_history.len() as f64; + let var = Self::variance(&self.motion_history); + let score = (10.0 * near_falls + 50.0 * var).clamp(0.0, 100.0); + self.last_score = score; + + // Event on crossing threshold upward. + let was_above = self.above_threshold; + self.above_threshold = score >= cfg.fall_risk_event_threshold; + if !was_above && self.above_threshold { + return PrimitiveState::Event { + event_type: "fall_risk_elevated", + reason: Reason::new(&["score>=70", "crossed_threshold"]), + }; + } + PrimitiveState::Scalar { + value: score, + reason: Reason::new(&["score_published"]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() } + + #[test] + fn warmup_blocks_score() { + let mut p = FallRiskElevated::new(); + let s = RawSnapshot { + since_start: Duration::from_secs(30), + motion: 0.5, + ..Default::default() + }; + assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle)); + } + + #[test] + fn emits_scalar_when_active() { + let mut p = FallRiskElevated::new(); + let s = RawSnapshot { + since_start: Duration::from_secs(120), + motion: 0.10, + ..Default::default() + }; + let state = p.tick(&s, &cfg()); + assert!(matches!(state, PrimitiveState::Scalar { .. })); + } + + #[test] + fn score_grows_with_falls() { + let mut p = FallRiskElevated::new(); + // Establish baseline with no falls. + let _ = p.tick(&RawSnapshot { + since_start: Duration::from_secs(120), + motion: 0.05, + ..Default::default() + }, &cfg()); + let base_score = p.last_score; + // Add some falls. + for t in 121..125 { + let s = RawSnapshot { + since_start: Duration::from_secs(t), + motion: 0.05, + fall_detected: true, + ..Default::default() + }; + let _ = p.tick(&s, &cfg()); + } + // Score should be higher than baseline. + assert!(p.last_score > base_score); + } + + #[test] + fn emits_event_when_crossing_threshold() { + let mut p = FallRiskElevated::new(); + // Inject 7 falls → score ≥ 70. + let mut last_state = PrimitiveState::Idle; + for t in 120..127 { + let s = RawSnapshot { + since_start: Duration::from_secs(t), + motion: 0.05, + fall_detected: true, + ..Default::default() + }; + last_state = p.tick(&s, &cfg()); + } + // One of those ticks must have emitted the crossing event. + // Since we only catch the last call's return, check the score. + assert!(p.above_threshold, "should be above threshold"); + // The crossing-event return is on the first tick that crosses. + // Verify the type via a fresh sequence. + let mut p2 = FallRiskElevated::new(); + let _ = p2.tick(&RawSnapshot { + since_start: Duration::from_secs(120), + motion: 0.05, + ..Default::default() + }, &cfg()); + let mut saw_event = false; + for t in 121..130 { + let s = RawSnapshot { + since_start: Duration::from_secs(t), + motion: 0.05, + fall_detected: true, + ..Default::default() + }; + if matches!(p2.tick(&s, &cfg()), PrimitiveState::Event { .. }) { + saw_event = true; + break; + } + } + assert!(saw_event, "should have emitted crossing event"); + // Suppress unused warning. + let _ = last_state; + } + + #[test] + fn fall_history_evicts_after_24h() { + let mut p = FallRiskElevated::new(); + // Inject fall. + let _ = p.tick(&RawSnapshot { + since_start: Duration::from_secs(120), + motion: 0.05, + fall_detected: true, + ..Default::default() + }, &cfg()); + // 25 hours later — the fall should evict from the window. + let _ = p.tick(&RawSnapshot { + since_start: Duration::from_secs(120 + 25 * 3600), + motion: 0.05, + ..Default::default() + }, &cfg()); + assert!(p.fall_history.is_empty(), "fall must evict after 24h"); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/meeting.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/meeting.rs new file mode 100644 index 00000000..fdb1273e --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/meeting.rs @@ -0,0 +1,141 @@ +//! Meeting-in-progress primitive (§3.12.1 row 5). +//! +//! Enter `meeting_in_progress = ON` when person_count ≥ 2 AND motion +//! is sustained low-amplitude (people sitting still while talking) for +//! ≥`meeting_dwell` (default 10 min). +//! +//! Exit when person_count < 2 for ≥2 min. + +use std::time::Duration; + +use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; + +const EXIT_DWELL: Duration = Duration::from_secs(120); + +#[derive(Debug, Default, Clone)] +pub struct MeetingInProgress { + pub active: bool, + enter_since: Option, + exit_since: Option, +} + +impl MeetingInProgress { + pub fn new() -> Self { Self::default() } + + pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState { + if snap.since_start < cfg.warmup { + return PrimitiveState::Idle; + } + // Low-amplitude motion: people seated/quiet but present. + let suitable_motion = (0.01..0.20).contains(&snap.motion); + let enough_persons = snap.n_persons >= cfg.meeting_min_persons; + + if !self.active { + if enough_persons && suitable_motion { + let start = *self.enter_since.get_or_insert(snap.since_start); + if snap.since_start.saturating_sub(start) >= cfg.meeting_dwell { + self.active = true; + self.exit_since = None; + return PrimitiveState::Boolean { + active: true, + changed: true, + reason: Reason::new(&[ + "n_persons>=2", + "motion=1-20%", + "dwell>=10min", + ]), + }; + } + } else { + self.enter_since = None; + } + PrimitiveState::Idle + } else { + let too_few = snap.n_persons < cfg.meeting_min_persons; + if too_few { + let start = *self.exit_since.get_or_insert(snap.since_start); + if snap.since_start.saturating_sub(start) >= EXIT_DWELL { + self.active = false; + self.enter_since = None; + self.exit_since = None; + return PrimitiveState::Boolean { + active: false, + changed: true, + reason: Reason::new(&["n_persons<2", "dwell>=2min"]), + }; + } + } else { + self.exit_since = None; + } + PrimitiveState::Idle + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() } + + fn meeting_snap(t_secs: u64, n: u32) -> RawSnapshot { + RawSnapshot { + since_start: Duration::from_secs(t_secs), + presence: true, + motion: 0.05, + n_persons: n, + ..Default::default() + } + } + + #[test] + fn fires_after_dwell_with_2_plus_people() { + let mut p = MeetingInProgress::new(); + let _ = p.tick(&meeting_snap(100, 3), &cfg()); + let state = p.tick(&meeting_snap(100 + 600, 3), &cfg()); + match state { + PrimitiveState::Boolean { active, .. } => assert!(active), + other => panic!("expected on, got {:?}", other), + } + } + + #[test] + fn does_not_fire_with_1_person() { + let mut p = MeetingInProgress::new(); + for t in 100..(100 + 1200) { + assert!(matches!(p.tick(&meeting_snap(t, 1), &cfg()), PrimitiveState::Idle)); + } + assert!(!p.active); + } + + #[test] + fn does_not_fire_with_high_motion() { + let mut p = MeetingInProgress::new(); + for t in 100..(100 + 1200) { + let mut s = meeting_snap(t, 3); + s.motion = 0.5; + assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle)); + } + assert!(!p.active); + } + + #[test] + fn exits_after_2_min_of_low_count() { + let mut p = MeetingInProgress::new(); + let _ = p.tick(&meeting_snap(100, 3), &cfg()); + let _ = p.tick(&meeting_snap(100 + 600, 3), &cfg()); + assert!(p.active); + // Drop to 1 person. + let _ = p.tick(&meeting_snap(100 + 600 + 1, 1), &cfg()); + // <2 min: still active. + let state = p.tick(&meeting_snap(100 + 600 + 60, 1), &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + assert!(p.active); + // Past 2 min: exit. + let state2 = p.tick(&meeting_snap(100 + 600 + 130, 1), &cfg()); + match state2 { + PrimitiveState::Boolean { active, .. } => assert!(!active), + other => panic!("expected off, got {:?}", other), + } + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs index 9733cf96..685a387b 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs @@ -46,16 +46,18 @@ //! Each module exports a struct implementing [`Primitive`] and a `new` //! constructor that takes a [`PrimitiveConfig`]. -// Primitives landing in P4.5a (this iteration): mod bathroom; +mod bed_exit; mod bus; mod common; +mod distress; +mod elderly_anomaly; +mod fall_risk; +mod meeting; +mod multi_room; mod no_movement; mod room_active; mod sleeping; -// Primitives landing in P4.5b (next iteration): bed_exit, distress, -// elderly_anomaly, fall_risk, meeting, multi_room. - pub use bus::{SemanticBus, SemanticEvent, SemanticKind}; pub use common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/multi_room.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/multi_room.rs new file mode 100644 index 00000000..23d1c538 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/multi_room.rs @@ -0,0 +1,138 @@ +//! Multi-room transition primitive (§3.12.1 row 10). +//! +//! Edge-triggered event: when an `active_zones` set changes such that +//! one zone exited AND a different zone entered within +//! `multi_room_gap` (default 10 s), fire `multi_room_transition` with +//! the `from_zone` and `to_zone` baked into the reason tags. +//! +//! Useful for "who went from X to Y" automations (e.g. light the path, +//! announce arrival in next room). + +use std::collections::HashSet; +use std::time::Duration; + +use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; + +#[derive(Debug, Default, Clone)] +pub struct MultiRoomTransition { + last_zones: HashSet, + last_exit: Option<(String, Duration)>, +} + +impl MultiRoomTransition { + pub fn new() -> Self { Self::default() } + + pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState { + if snap.since_start < cfg.warmup { + self.last_zones = snap.active_zones.iter().cloned().collect(); + return PrimitiveState::Idle; + } + let now: HashSet = snap.active_zones.iter().cloned().collect(); + let added: Vec<&String> = now.difference(&self.last_zones).collect(); + let removed: Vec<&String> = self.last_zones.difference(&now).collect(); + + let mut result = PrimitiveState::Idle; + + // Record the most recent exit. + if let Some(exited) = removed.first() { + self.last_exit = Some(((*exited).clone(), snap.since_start)); + } + + // Match exit with subsequent entry. + if let (Some(entered), Some((from_zone, exit_t))) = (added.first(), self.last_exit.as_ref()) { + let gap = snap.since_start.saturating_sub(*exit_t); + if gap <= cfg.multi_room_gap && from_zone.as_str() != entered.as_str() { + let reason = Reason::new(&[ + "zone_exit_to_entry", + Box::leak(format!("from={}", from_zone).into_boxed_str()), + Box::leak(format!("to={}", entered).into_boxed_str()), + ]); + result = PrimitiveState::Event { + event_type: "multi_room_transition", + reason, + }; + // Consume the exit so we don't double-fire. + self.last_exit = None; + } + } + + self.last_zones = now; + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> PrimitiveConfig { PrimitiveConfig::default() } + + fn zones_snap(t_secs: u64, zones: &[&str]) -> RawSnapshot { + RawSnapshot { + since_start: Duration::from_secs(t_secs), + presence: !zones.is_empty(), + active_zones: zones.iter().map(|s| s.to_string()).collect(), + ..Default::default() + } + } + + #[test] + fn fires_when_zone_changes_quickly() { + let mut p = MultiRoomTransition::new(); + let _ = p.tick(&zones_snap(120, &["kitchen"]), &cfg()); + // Exit kitchen. + let _ = p.tick(&zones_snap(125, &[]), &cfg()); + // Enter living room within gap. + let state = p.tick(&zones_snap(128, &["living"]), &cfg()); + match state { + PrimitiveState::Event { event_type, reason } => { + assert_eq!(event_type, "multi_room_transition"); + assert!(reason.tags.iter().any(|t| t.contains("from=kitchen"))); + assert!(reason.tags.iter().any(|t| t.contains("to=living"))); + } + other => panic!("expected event, got {:?}", other), + } + } + + #[test] + fn does_not_fire_after_long_gap() { + let mut p = MultiRoomTransition::new(); + let _ = p.tick(&zones_snap(120, &["kitchen"]), &cfg()); + let _ = p.tick(&zones_snap(125, &[]), &cfg()); + // 15 s later — outside default 10 s gap. + let state = p.tick(&zones_snap(140, &["living"]), &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + } + + #[test] + fn does_not_fire_on_same_zone_re_entry() { + let mut p = MultiRoomTransition::new(); + let _ = p.tick(&zones_snap(120, &["kitchen"]), &cfg()); + let _ = p.tick(&zones_snap(125, &[]), &cfg()); + let state = p.tick(&zones_snap(128, &["kitchen"]), &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + } + + #[test] + fn warmup_blocks_event() { + let mut p = MultiRoomTransition::new(); + let _ = p.tick(&zones_snap(30, &["kitchen"]), &cfg()); + let state = p.tick(&zones_snap(40, &["living"]), &cfg()); + assert!(matches!(state, PrimitiveState::Idle)); + } + + #[test] + fn handles_simultaneous_zone_swap() { + // Some sensing scenarios emit exit + enter in the same tick. + let mut p = MultiRoomTransition::new(); + let _ = p.tick(&zones_snap(120, &["kitchen"]), &cfg()); + // Tick where kitchen left AND living entered simultaneously. + let state = p.tick(&zones_snap(123, &["living"]), &cfg()); + match state { + PrimitiveState::Event { event_type, .. } => { + assert_eq!(event_type, "multi_room_transition"); + } + other => panic!("expected event, got {:?}", other), + } + } +}