feat(v1): add temporal fall state machine
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""Deterministic per-person temporal fall event state machine."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class FallState(str, Enum):
|
||||
NORMAL = "NORMAL"
|
||||
SUSPECT = "SUSPECT"
|
||||
CONFIRMED = "CONFIRMED"
|
||||
RECOVERING = "RECOVERING"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Evidence:
|
||||
accepted: bool
|
||||
is_fall_candidate: bool
|
||||
is_recovery_candidate: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FallEvent:
|
||||
event_id: str
|
||||
track_id: str
|
||||
suspected_at_monotonic: float
|
||||
confirmed_at_monotonic: float
|
||||
latency_seconds: float
|
||||
state: FallState = FallState.CONFIRMED
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Record:
|
||||
state: FallState
|
||||
suspect_started_at: Optional[float] = None
|
||||
recovery_started_at: Optional[float] = None
|
||||
last_updated_at: Optional[float] = None
|
||||
|
||||
|
||||
class FallStateMachine:
|
||||
"""Confirm only uninterrupted, accepted fall evidence for one tracked person."""
|
||||
|
||||
def __init__(self, confirm_window_seconds: float, recovery_window_seconds: float) -> None:
|
||||
if not 1.0 <= confirm_window_seconds <= 3.0:
|
||||
raise ValueError("confirm_window_seconds must be between 1 and 3 seconds")
|
||||
if recovery_window_seconds <= 0:
|
||||
raise ValueError("recovery_window_seconds must be positive")
|
||||
self._confirm_window_seconds = float(confirm_window_seconds)
|
||||
self._recovery_window_seconds = float(recovery_window_seconds)
|
||||
self._records: Dict[str, _Record] = {}
|
||||
self._next_event_number = 1
|
||||
|
||||
def state_of(self, track_id: str) -> FallState:
|
||||
record = self._records.get(track_id)
|
||||
return record.state if record is not None else FallState.NORMAL
|
||||
|
||||
def update(self, track_id: str, evidence: Evidence, now: float) -> List[FallEvent]:
|
||||
if not track_id:
|
||||
raise ValueError("track_id must be non-empty")
|
||||
timestamp = float(now)
|
||||
record = self._records.setdefault(track_id, _Record(state=FallState.NORMAL))
|
||||
if record.last_updated_at is not None and timestamp < record.last_updated_at:
|
||||
raise ValueError("timestamps must be monotonic per track")
|
||||
record.last_updated_at = timestamp
|
||||
|
||||
if not evidence.accepted:
|
||||
self._reject_evidence(record)
|
||||
return []
|
||||
|
||||
if record.state is FallState.NORMAL:
|
||||
if evidence.is_fall_candidate:
|
||||
record.state = FallState.SUSPECT
|
||||
record.suspect_started_at = timestamp
|
||||
return []
|
||||
|
||||
if record.state is FallState.SUSPECT:
|
||||
if not evidence.is_fall_candidate:
|
||||
self._set_normal(record)
|
||||
return []
|
||||
if timestamp - record.suspect_started_at >= self._confirm_window_seconds:
|
||||
record.state = FallState.CONFIRMED
|
||||
event = self._new_event(track_id, record.suspect_started_at, timestamp)
|
||||
record.suspect_started_at = None
|
||||
return [event]
|
||||
return []
|
||||
|
||||
if record.state is FallState.CONFIRMED:
|
||||
if evidence.is_recovery_candidate:
|
||||
record.state = FallState.RECOVERING
|
||||
record.recovery_started_at = timestamp
|
||||
return []
|
||||
|
||||
if record.state is FallState.RECOVERING:
|
||||
if evidence.is_fall_candidate:
|
||||
record.state = FallState.SUSPECT
|
||||
record.suspect_started_at = timestamp
|
||||
record.recovery_started_at = None
|
||||
return []
|
||||
if not evidence.is_recovery_candidate:
|
||||
record.state = FallState.CONFIRMED
|
||||
record.recovery_started_at = None
|
||||
return []
|
||||
if timestamp - record.recovery_started_at >= self._recovery_window_seconds:
|
||||
self._set_normal(record)
|
||||
return []
|
||||
|
||||
raise RuntimeError("unknown fall state")
|
||||
|
||||
def _reject_evidence(self, record: _Record) -> None:
|
||||
if record.state is FallState.SUSPECT:
|
||||
self._set_normal(record)
|
||||
elif record.state is FallState.RECOVERING:
|
||||
record.state = FallState.CONFIRMED
|
||||
record.recovery_started_at = None
|
||||
|
||||
@staticmethod
|
||||
def _set_normal(record: _Record) -> None:
|
||||
record.state = FallState.NORMAL
|
||||
record.suspect_started_at = None
|
||||
record.recovery_started_at = None
|
||||
|
||||
def _new_event(
|
||||
self, track_id: str, suspected_at: float, confirmed_at: float
|
||||
) -> FallEvent:
|
||||
event = FallEvent(
|
||||
event_id="FALL-{0:06d}".format(self._next_event_number),
|
||||
track_id=track_id,
|
||||
suspected_at_monotonic=suspected_at,
|
||||
confirmed_at_monotonic=confirmed_at,
|
||||
latency_seconds=confirmed_at - suspected_at,
|
||||
)
|
||||
self._next_event_number += 1
|
||||
return event
|
||||
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
|
||||
from v1.fall_state import Evidence, FallState, FallStateMachine
|
||||
|
||||
|
||||
def test_confirmed_event_is_emitted_once_after_persistent_evidence():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.8, recovery_window_seconds=2.0)
|
||||
|
||||
assert machine.update("P-0007", Evidence(True, True), now=0.0) == []
|
||||
events = machine.update("P-0007", Evidence(True, True), now=1.8)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].track_id == "P-0007"
|
||||
assert events[0].latency_seconds == 1.8
|
||||
assert machine.update("P-0007", Evidence(True, True), now=2.0) == []
|
||||
assert machine.state_of("P-0007") is FallState.CONFIRMED
|
||||
|
||||
|
||||
def test_brief_low_posture_returns_to_normal_without_event():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
|
||||
|
||||
machine.update("P-0007", Evidence(True, True), now=0.0)
|
||||
events = machine.update("P-0007", Evidence(True, False), now=0.3)
|
||||
|
||||
assert events == []
|
||||
assert machine.state_of("P-0007") is FallState.NORMAL
|
||||
|
||||
|
||||
def test_rejected_pose_resets_suspect_and_cannot_shorten_confirmation_window():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
|
||||
|
||||
machine.update("P-0007", Evidence(True, True), now=0.0)
|
||||
machine.update("P-0007", Evidence(False, False), now=0.9)
|
||||
events = machine.update("P-0007", Evidence(True, True), now=1.1)
|
||||
|
||||
assert events == []
|
||||
assert machine.state_of("P-0007") is FallState.SUSPECT
|
||||
|
||||
|
||||
def test_recovery_must_persist_before_new_event_is_allowed():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
|
||||
machine.update("P-0007", Evidence(True, True), now=0.0)
|
||||
first_event = machine.update("P-0007", Evidence(True, True), now=1.0)
|
||||
assert len(first_event) == 1
|
||||
|
||||
machine.update("P-0007", Evidence(True, False, True), now=1.1)
|
||||
machine.update("P-0007", Evidence(True, False, True), now=3.1)
|
||||
assert machine.state_of("P-0007") is FallState.NORMAL
|
||||
|
||||
machine.update("P-0007", Evidence(True, True), now=4.0)
|
||||
second_event = machine.update("P-0007", Evidence(True, True), now=5.0)
|
||||
assert len(second_event) == 1
|
||||
assert first_event[0].event_id != second_event[0].event_id
|
||||
|
||||
|
||||
def test_confirmation_window_must_remain_within_customer_target():
|
||||
with pytest.raises(ValueError, match="between 1 and 3"):
|
||||
FallStateMachine(confirm_window_seconds=0.9, recovery_window_seconds=2.0)
|
||||
|
||||
|
||||
def test_each_track_has_an_independent_confirmation_window():
|
||||
machine = FallStateMachine(confirm_window_seconds=1.0, recovery_window_seconds=2.0)
|
||||
|
||||
machine.update("P-0001", Evidence(True, True), now=0.0)
|
||||
machine.update("P-0002", Evidence(True, True), now=0.6)
|
||||
first_events = machine.update("P-0001", Evidence(True, True), now=1.0)
|
||||
second_events = machine.update("P-0002", Evidence(True, True), now=1.6)
|
||||
|
||||
assert first_events[0].track_id == "P-0001"
|
||||
assert second_events[0].track_id == "P-0002"
|
||||
assert first_events[0].event_id != second_events[0].event_id
|
||||
Reference in New Issue
Block a user