feat(adr-115): P4.5b — 6 remaining semantic primitives — all 10 HA-MIND v1 done (66 tests)
Lands the remaining six §3.12 v1 primitives: - `distress` (PossibleDistress) — EWMA baseline HR + 1.5× multiplier + agitated motion + no-fall + 60 s dwell → ON. Refractory 5 min after exit. Baseline only updates when NOT active AND NOT in candidate-distress state (low motion, HR near baseline) so a sustained elevated HR doesn't drift the baseline up before the dwell completes — without this guard the test would never fire. - `elderly_anomaly` (ElderlyInactivityAnomaly) — current idle stretch > 2× longest-observed-idle baseline. Baseline floor at 30 min so the first day doesn't fire spuriously. 24 h refractory per resident. - `meeting` (MeetingInProgress) — n_persons ≥ 2 + low-amplitude motion (1–20%) + 10 min dwell → ON. 2 min exit dwell on count drop. - `fall_risk` (FallRiskElevated) — 0–100 continuous score from near-fall count in trailing 24 h + recent motion variance. Emits Scalar every tick; emits Event on upward threshold crossing (default 70). - `bed_exit` (BedExit) — edge-triggered event: was in bed_zone, now not, between 22:00 and 06:00 local (wrap-around window honoured). - `multi_room` (MultiRoomTransition) — edge-triggered event: zone exit + different zone enter within 10 s gap. Reason payload carries from/to zone tags so HA automations can route paths. Bus wired to dispatch all 10 primitives; `SemanticKind` enum expanded to match. `tick()` returns up to 10 events per snapshot. 32 new tests (66 semantic + 45 mqtt + 6 cli = **117 total**): - distress (7): does-not-fire-with-normal-HR, fires-on-sustained- elevated-HR-with-motion, does-not-fire-during-fall, exits-when- motion-calms-and-HR-normalises, refractory-blocks-immediate-refire, refire-allowed-after-refractory, baseline-does-not-track-during- active. - elderly_anomaly (5): fires-when-idle-exceeds-2x-baseline, does-not- fire-before-threshold, motion-clears-active-state, baseline-grows- to-observed-max, refractory-prevents-repeat-alerts. - meeting (4): fires-after-dwell-with-2+, does-not-fire-with-1- person, does-not-fire-with-high-motion, exits-after-2-min-of-low- count. - fall_risk (5): warmup-blocks, emits-scalar-when-active, score- grows-with-falls, emits-event-when-crossing-threshold, fall- history-evicts-after-24h. - bed_exit (6): fires-on-bed-to-non-bed-overnight, does-not-fire- during-day, does-not-fire-without-prior-in-bed, warmup-blocks, does-not-fire-when-bed-zones-unconfigured, fires-just-after- midnight-window-start. - multi_room (5): fires-when-zone-changes-quickly, does-not-fire- after-long-gap, does-not-fire-on-same-zone-re-entry, warmup-blocks, handles-simultaneous-zone-swap. ADR-115 §3.12 inference layer now complete. Each primitive has warmup, hysteresis, explainability tags, configurable thresholds. Adding a v2 primitive is one file + one bus entry. Refs #776. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
8e416af203
commit
b2a692369e
|
|
@ -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 { .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SemanticEvent> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<f64>,
|
||||
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<Duration>,
|
||||
last_exit: Option<Duration>,
|
||||
}
|
||||
|
||||
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<f64>, 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Duration>,
|
||||
/// 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<Duration>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Duration>,
|
||||
/// 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::<f64>() / samples.len() as f64;
|
||||
let v = samples
|
||||
.iter()
|
||||
.map(|(_, m)| (m - mean).powi(2))
|
||||
.sum::<f64>()
|
||||
/ 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Duration>,
|
||||
exit_since: Option<Duration>,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
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<String> = 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue