//! 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); } }