mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
- Add 154 missing vendor files (gitignore was filtering them) - vendor/midstream: 564 files (was 561) - vendor/sublinear-time-solver: 1190 files (was 1039) - Add ESP32 edge processing (ADR-039): presence, vitals, fall detection - Add WASM programmable sensing (ADR-040/041) with wasm3 runtime - Add firmware CI workflow (.github/workflows/firmware-ci.yml) - Add wifi-densepose-wasm-edge crate for edge WASM modules - Update sensing server, provision.py, UI components Co-Authored-By: claude-flow <ruv@ruv.net>
347 lines
11 KiB
Rust
347 lines
11 KiB
Rust
//! Vital sign trend analysis — ADR-041 Phase 1 module.
|
|
//!
|
|
//! Monitors breathing rate and heart rate over time windows (1-min, 5-min, 15-min)
|
|
//! and detects clinically significant trends:
|
|
//! - Bradypnea (breathing < 12 BPM sustained)
|
|
//! - Tachypnea (breathing > 25 BPM sustained)
|
|
//! - Bradycardia (HR < 50 BPM sustained)
|
|
//! - Tachycardia (HR > 120 BPM sustained)
|
|
//! - Apnea (no breathing detected for > 20 seconds)
|
|
//! - Trend reversal (sudden direction change in vital trajectory)
|
|
|
|
// No libm imports needed — pure arithmetic.
|
|
|
|
/// Window sizes in samples (at 1 Hz timer rate).
|
|
const WINDOW_1M: usize = 60;
|
|
const WINDOW_5M: usize = 300;
|
|
|
|
/// Maximum history depth.
|
|
const MAX_HISTORY: usize = 300; // 5 minutes at 1 Hz.
|
|
|
|
/// Clinical thresholds (BPM).
|
|
const BRADYPNEA_THRESH: f32 = 12.0;
|
|
const TACHYPNEA_THRESH: f32 = 25.0;
|
|
const BRADYCARDIA_THRESH: f32 = 50.0;
|
|
const TACHYCARDIA_THRESH: f32 = 120.0;
|
|
const APNEA_SECONDS: u32 = 20;
|
|
|
|
/// Minimum consecutive alerts before emitting (debounce).
|
|
const ALERT_DEBOUNCE: u8 = 5;
|
|
|
|
/// Event types (100-series: Medical).
|
|
pub const EVENT_VITAL_TREND: i32 = 100;
|
|
pub const EVENT_BRADYPNEA: i32 = 101;
|
|
pub const EVENT_TACHYPNEA: i32 = 102;
|
|
pub const EVENT_BRADYCARDIA: i32 = 103;
|
|
pub const EVENT_TACHYCARDIA: i32 = 104;
|
|
pub const EVENT_APNEA: i32 = 105;
|
|
pub const EVENT_BREATHING_AVG: i32 = 110;
|
|
pub const EVENT_HEARTRATE_AVG: i32 = 111;
|
|
|
|
/// Ring buffer for vital sign history.
|
|
struct VitalHistory {
|
|
values: [f32; MAX_HISTORY],
|
|
len: usize,
|
|
idx: usize,
|
|
}
|
|
|
|
impl VitalHistory {
|
|
const fn new() -> Self {
|
|
Self {
|
|
values: [0.0; MAX_HISTORY],
|
|
len: 0,
|
|
idx: 0,
|
|
}
|
|
}
|
|
|
|
fn push(&mut self, val: f32) {
|
|
self.values[self.idx] = val;
|
|
self.idx = (self.idx + 1) % MAX_HISTORY;
|
|
if self.len < MAX_HISTORY {
|
|
self.len += 1;
|
|
}
|
|
}
|
|
|
|
/// Compute mean of the last N samples.
|
|
fn mean_last(&self, n: usize) -> f32 {
|
|
let count = n.min(self.len);
|
|
if count == 0 {
|
|
return 0.0;
|
|
}
|
|
let mut sum = 0.0f32;
|
|
for i in 0..count {
|
|
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
|
|
sum += self.values[ri];
|
|
}
|
|
sum / count as f32
|
|
}
|
|
|
|
/// Check if all of the last N samples are below threshold.
|
|
#[allow(dead_code)]
|
|
fn all_below(&self, n: usize, threshold: f32) -> bool {
|
|
let count = n.min(self.len);
|
|
if count < n {
|
|
return false;
|
|
}
|
|
for i in 0..count {
|
|
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
|
|
if self.values[ri] >= threshold {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Check if all of the last N samples are above threshold.
|
|
#[allow(dead_code)]
|
|
fn all_above(&self, n: usize, threshold: f32) -> bool {
|
|
let count = n.min(self.len);
|
|
if count < n {
|
|
return false;
|
|
}
|
|
for i in 0..count {
|
|
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
|
|
if self.values[ri] <= threshold {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Compute simple linear trend (positive = increasing).
|
|
fn trend(&self, n: usize) -> f32 {
|
|
let count = n.min(self.len);
|
|
if count < 4 {
|
|
return 0.0;
|
|
}
|
|
|
|
// Simple: (last_quarter_mean - first_quarter_mean) / window.
|
|
let quarter = count / 4;
|
|
let mut first_sum = 0.0f32;
|
|
let mut last_sum = 0.0f32;
|
|
|
|
for i in 0..quarter {
|
|
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
|
|
first_sum += self.values[ri];
|
|
}
|
|
for i in (count - quarter)..count {
|
|
let ri = (self.idx + MAX_HISTORY - count + i) % MAX_HISTORY;
|
|
last_sum += self.values[ri];
|
|
}
|
|
|
|
let first_mean = first_sum / quarter as f32;
|
|
let last_mean = last_sum / quarter as f32;
|
|
(last_mean - first_mean) / count as f32
|
|
}
|
|
}
|
|
|
|
/// Vital trend analyzer.
|
|
pub struct VitalTrendAnalyzer {
|
|
breathing: VitalHistory,
|
|
heartrate: VitalHistory,
|
|
/// Debounce counters for each alert type.
|
|
bradypnea_count: u8,
|
|
tachypnea_count: u8,
|
|
bradycardia_count: u8,
|
|
tachycardia_count: u8,
|
|
/// Consecutive samples with near-zero breathing.
|
|
apnea_counter: u32,
|
|
/// Timer call count.
|
|
timer_count: u32,
|
|
}
|
|
|
|
impl VitalTrendAnalyzer {
|
|
pub const fn new() -> Self {
|
|
Self {
|
|
breathing: VitalHistory::new(),
|
|
heartrate: VitalHistory::new(),
|
|
bradypnea_count: 0,
|
|
tachypnea_count: 0,
|
|
bradycardia_count: 0,
|
|
tachycardia_count: 0,
|
|
apnea_counter: 0,
|
|
timer_count: 0,
|
|
}
|
|
}
|
|
|
|
/// Called at ~1 Hz with current vital signs.
|
|
///
|
|
/// Returns events as (event_type, value) pairs.
|
|
pub fn on_timer(&mut self, breathing_bpm: f32, heartrate_bpm: f32) -> &[(i32, f32)] {
|
|
self.timer_count += 1;
|
|
self.breathing.push(breathing_bpm);
|
|
self.heartrate.push(heartrate_bpm);
|
|
|
|
static mut EVENTS: [(i32, f32); 8] = [(0, 0.0); 8];
|
|
let mut n = 0usize;
|
|
|
|
// ── Apnea detection (highest priority) ──────────────────────────
|
|
if breathing_bpm < 1.0 {
|
|
self.apnea_counter += 1;
|
|
if self.apnea_counter >= APNEA_SECONDS {
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_APNEA, self.apnea_counter as f32);
|
|
}
|
|
n += 1;
|
|
}
|
|
} else {
|
|
self.apnea_counter = 0;
|
|
}
|
|
|
|
// ── Bradypnea (sustained low breathing) ────────────────────────
|
|
if breathing_bpm > 0.0 && breathing_bpm < BRADYPNEA_THRESH {
|
|
self.bradypnea_count = self.bradypnea_count.saturating_add(1);
|
|
if self.bradypnea_count >= ALERT_DEBOUNCE && n < 7 {
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_BRADYPNEA, breathing_bpm);
|
|
}
|
|
n += 1;
|
|
}
|
|
} else {
|
|
self.bradypnea_count = 0;
|
|
}
|
|
|
|
// ── Tachypnea (sustained high breathing) ───────────────────────
|
|
if breathing_bpm > TACHYPNEA_THRESH {
|
|
self.tachypnea_count = self.tachypnea_count.saturating_add(1);
|
|
if self.tachypnea_count >= ALERT_DEBOUNCE && n < 7 {
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_TACHYPNEA, breathing_bpm);
|
|
}
|
|
n += 1;
|
|
}
|
|
} else {
|
|
self.tachypnea_count = 0;
|
|
}
|
|
|
|
// ── Bradycardia ────────────────────────────────────────────────
|
|
if heartrate_bpm > 0.0 && heartrate_bpm < BRADYCARDIA_THRESH {
|
|
self.bradycardia_count = self.bradycardia_count.saturating_add(1);
|
|
if self.bradycardia_count >= ALERT_DEBOUNCE && n < 7 {
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_BRADYCARDIA, heartrate_bpm);
|
|
}
|
|
n += 1;
|
|
}
|
|
} else {
|
|
self.bradycardia_count = 0;
|
|
}
|
|
|
|
// ── Tachycardia ────────────────────────────────────────────────
|
|
if heartrate_bpm > TACHYCARDIA_THRESH {
|
|
self.tachycardia_count = self.tachycardia_count.saturating_add(1);
|
|
if self.tachycardia_count >= ALERT_DEBOUNCE && n < 7 {
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_TACHYCARDIA, heartrate_bpm);
|
|
}
|
|
n += 1;
|
|
}
|
|
} else {
|
|
self.tachycardia_count = 0;
|
|
}
|
|
|
|
// ── Periodic averages (every 60 seconds) ───────────────────────
|
|
if self.timer_count % 60 == 0 && self.breathing.len >= WINDOW_1M {
|
|
let br_avg = self.breathing.mean_last(WINDOW_1M);
|
|
let hr_avg = self.heartrate.mean_last(WINDOW_1M);
|
|
if n < 7 {
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_BREATHING_AVG, br_avg);
|
|
}
|
|
n += 1;
|
|
}
|
|
if n < 8 {
|
|
unsafe {
|
|
EVENTS[n] = (EVENT_HEARTRATE_AVG, hr_avg);
|
|
}
|
|
n += 1;
|
|
}
|
|
}
|
|
|
|
unsafe { &EVENTS[..n] }
|
|
}
|
|
|
|
/// Get the 1-minute breathing average.
|
|
pub fn breathing_avg_1m(&self) -> f32 {
|
|
self.breathing.mean_last(WINDOW_1M)
|
|
}
|
|
|
|
/// Get the breathing trend (positive = increasing).
|
|
pub fn breathing_trend_5m(&self) -> f32 {
|
|
self.breathing.trend(WINDOW_5M)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_vital_trend_init() {
|
|
let vt = VitalTrendAnalyzer::new();
|
|
assert_eq!(vt.timer_count, 0);
|
|
assert_eq!(vt.apnea_counter, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normal_vitals_no_alerts() {
|
|
let mut vt = VitalTrendAnalyzer::new();
|
|
// Normal breathing (16 BPM) and heart rate (72 BPM).
|
|
for _ in 0..60 {
|
|
let events = vt.on_timer(16.0, 72.0);
|
|
// Should not generate clinical alerts.
|
|
for &(et, _) in events {
|
|
assert!(
|
|
et != EVENT_BRADYPNEA && et != EVENT_TACHYPNEA
|
|
&& et != EVENT_BRADYCARDIA && et != EVENT_TACHYCARDIA
|
|
&& et != EVENT_APNEA,
|
|
"unexpected clinical alert with normal vitals"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_apnea_detection() {
|
|
let mut vt = VitalTrendAnalyzer::new();
|
|
let mut apnea_detected = false;
|
|
|
|
for _ in 0..30 {
|
|
let events = vt.on_timer(0.0, 72.0);
|
|
for &(et, _) in events {
|
|
if et == EVENT_APNEA {
|
|
apnea_detected = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(apnea_detected, "apnea should be detected after 20+ seconds of zero breathing");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tachycardia_detection() {
|
|
let mut vt = VitalTrendAnalyzer::new();
|
|
let mut tachy_detected = false;
|
|
|
|
for _ in 0..20 {
|
|
let events = vt.on_timer(16.0, 130.0);
|
|
for &(et, _) in events {
|
|
if et == EVENT_TACHYCARDIA {
|
|
tachy_detected = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(tachy_detected, "tachycardia should be detected with sustained HR > 120");
|
|
}
|
|
|
|
#[test]
|
|
fn test_breathing_average() {
|
|
let mut vt = VitalTrendAnalyzer::new();
|
|
for _ in 0..60 {
|
|
vt.on_timer(16.0, 72.0);
|
|
}
|
|
let avg = vt.breathing_avg_1m();
|
|
assert!((avg - 16.0).abs() < 0.1, "1-min breathing average should be ~16.0");
|
|
}
|
|
}
|