384 lines
13 KiB
Rust
384 lines
13 KiB
Rust
//! Triage classification following START protocol.
|
|
//!
|
|
//! The START (Simple Triage and Rapid Treatment) protocol is used to
|
|
//! quickly categorize victims in mass casualty incidents.
|
|
|
|
use super::{BreathingType, MovementType, VitalSignsReading};
|
|
|
|
/// Triage status following START protocol
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
|
pub enum TriageStatus {
|
|
/// Immediate (Red) - Life-threatening, requires immediate intervention
|
|
/// RPM: Respiration >30 or <10, or absent pulse, or unable to follow commands
|
|
Immediate,
|
|
|
|
/// Delayed (Yellow) - Serious but stable, can wait for treatment
|
|
/// RPM: Normal respiration, pulse present, follows commands, non-life-threatening
|
|
Delayed,
|
|
|
|
/// Minor (Green) - Walking wounded, minimal treatment needed
|
|
/// Can walk, minor injuries
|
|
Minor,
|
|
|
|
/// Deceased (Black) - No vital signs, or not breathing after airway cleared
|
|
Deceased,
|
|
|
|
/// Unknown - Insufficient data for classification
|
|
Unknown,
|
|
}
|
|
|
|
impl TriageStatus {
|
|
/// Get the priority level (1 = highest)
|
|
pub fn priority(&self) -> u8 {
|
|
match self {
|
|
TriageStatus::Immediate => 1,
|
|
TriageStatus::Delayed => 2,
|
|
TriageStatus::Minor => 3,
|
|
TriageStatus::Deceased => 4,
|
|
TriageStatus::Unknown => 5,
|
|
}
|
|
}
|
|
|
|
/// Get display color
|
|
pub fn color(&self) -> &'static str {
|
|
match self {
|
|
TriageStatus::Immediate => "red",
|
|
TriageStatus::Delayed => "yellow",
|
|
TriageStatus::Minor => "green",
|
|
TriageStatus::Deceased => "black",
|
|
TriageStatus::Unknown => "gray",
|
|
}
|
|
}
|
|
|
|
/// Get human-readable description
|
|
pub fn description(&self) -> &'static str {
|
|
match self {
|
|
TriageStatus::Immediate => "Requires immediate life-saving intervention",
|
|
TriageStatus::Delayed => "Serious but can wait for treatment",
|
|
TriageStatus::Minor => "Minor injuries, walking wounded",
|
|
TriageStatus::Deceased => "No vital signs detected",
|
|
TriageStatus::Unknown => "Unable to determine status",
|
|
}
|
|
}
|
|
|
|
/// Check if this status requires urgent attention
|
|
pub fn is_urgent(&self) -> bool {
|
|
matches!(self, TriageStatus::Immediate | TriageStatus::Delayed)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for TriageStatus {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
TriageStatus::Immediate => write!(f, "IMMEDIATE (Red)"),
|
|
TriageStatus::Delayed => write!(f, "DELAYED (Yellow)"),
|
|
TriageStatus::Minor => write!(f, "MINOR (Green)"),
|
|
TriageStatus::Deceased => write!(f, "DECEASED (Black)"),
|
|
TriageStatus::Unknown => write!(f, "UNKNOWN"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Calculator for triage status based on vital signs
|
|
pub struct TriageCalculator;
|
|
|
|
impl TriageCalculator {
|
|
/// Calculate triage status from vital signs reading
|
|
///
|
|
/// Uses modified START protocol adapted for remote sensing:
|
|
/// 1. Check breathing (respiration)
|
|
/// 2. Check for movement/responsiveness (proxy for perfusion/mental status)
|
|
/// 3. Classify based on combined assessment
|
|
pub fn calculate(vitals: &VitalSignsReading) -> TriageStatus {
|
|
// Step 1: Check if any vitals are detected
|
|
if !vitals.has_vitals() {
|
|
// No vitals at all - either deceased or signal issue
|
|
return TriageStatus::Unknown;
|
|
}
|
|
|
|
// Step 2: Assess breathing
|
|
let breathing_status = Self::assess_breathing(vitals);
|
|
|
|
// Step 3: Assess movement/responsiveness
|
|
let movement_status = Self::assess_movement(vitals);
|
|
|
|
// Step 4: Combine assessments
|
|
let status = Self::combine_assessments(breathing_status, movement_status);
|
|
|
|
// Step 5: SAFETY OVERRIDE — a detectable heartbeat means the survivor is
|
|
// ALIVE. `combine_assessments` only sees breathing + movement, so a
|
|
// person with a pulse but no *sensed* breathing/movement (respiratory
|
|
// arrest, or breathing too shallow for CSI to pick up) would otherwise
|
|
// be reported Deceased and deprioritized for rescue. No breathing + a
|
|
// pulse is the most time-critical *savable* state, so escalate to
|
|
// Immediate rather than ever calling a survivor with a heartbeat dead.
|
|
if status == TriageStatus::Deceased && vitals.heartbeat.is_some() {
|
|
return TriageStatus::Immediate;
|
|
}
|
|
|
|
status
|
|
}
|
|
|
|
/// Assess breathing status
|
|
fn assess_breathing(vitals: &VitalSignsReading) -> BreathingAssessment {
|
|
match &vitals.breathing {
|
|
None => BreathingAssessment::Absent,
|
|
Some(breathing) => {
|
|
// Check for agonal breathing (pre-death)
|
|
if breathing.pattern_type == BreathingType::Agonal {
|
|
return BreathingAssessment::Agonal;
|
|
}
|
|
|
|
// Check rate
|
|
if breathing.rate_bpm < 10.0 {
|
|
BreathingAssessment::TooSlow
|
|
} else if breathing.rate_bpm > 30.0 {
|
|
BreathingAssessment::TooFast
|
|
} else {
|
|
BreathingAssessment::Normal
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Assess movement/responsiveness
|
|
fn assess_movement(vitals: &VitalSignsReading) -> MovementAssessment {
|
|
match vitals.movement.movement_type {
|
|
MovementType::Gross if vitals.movement.is_voluntary => MovementAssessment::Responsive,
|
|
MovementType::Gross => MovementAssessment::Moving,
|
|
MovementType::Fine => MovementAssessment::MinimalMovement,
|
|
MovementType::Tremor => MovementAssessment::InvoluntaryOnly,
|
|
MovementType::Periodic => MovementAssessment::MinimalMovement,
|
|
MovementType::None => MovementAssessment::None,
|
|
}
|
|
}
|
|
|
|
/// Combine breathing and movement assessments into triage status
|
|
fn combine_assessments(
|
|
breathing: BreathingAssessment,
|
|
movement: MovementAssessment,
|
|
) -> TriageStatus {
|
|
match (breathing, movement) {
|
|
// No breathing
|
|
(BreathingAssessment::Absent, MovementAssessment::None) => TriageStatus::Deceased,
|
|
(BreathingAssessment::Agonal, _) => TriageStatus::Immediate,
|
|
(BreathingAssessment::Absent, _) => {
|
|
// No breathing but movement - possible airway obstruction
|
|
TriageStatus::Immediate
|
|
}
|
|
|
|
// Abnormal breathing rates
|
|
(BreathingAssessment::TooFast, _) => TriageStatus::Immediate,
|
|
(BreathingAssessment::TooSlow, _) => TriageStatus::Immediate,
|
|
|
|
// Normal breathing with movement assessment
|
|
(BreathingAssessment::Normal, MovementAssessment::Responsive) => TriageStatus::Minor,
|
|
(BreathingAssessment::Normal, MovementAssessment::Moving) => TriageStatus::Delayed,
|
|
(BreathingAssessment::Normal, MovementAssessment::MinimalMovement) => {
|
|
TriageStatus::Delayed
|
|
}
|
|
(BreathingAssessment::Normal, MovementAssessment::InvoluntaryOnly) => {
|
|
TriageStatus::Immediate // Not following commands
|
|
}
|
|
(BreathingAssessment::Normal, MovementAssessment::None) => {
|
|
TriageStatus::Immediate // Breathing but unresponsive
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if status should be upgraded based on deterioration
|
|
pub fn should_upgrade(current: &TriageStatus, is_deteriorating: bool) -> bool {
|
|
if !is_deteriorating {
|
|
return false;
|
|
}
|
|
|
|
// Upgrade if not already at highest priority
|
|
matches!(current, TriageStatus::Delayed | TriageStatus::Minor)
|
|
}
|
|
|
|
/// Get upgraded triage status
|
|
pub fn upgrade(current: &TriageStatus) -> TriageStatus {
|
|
match current {
|
|
TriageStatus::Minor => TriageStatus::Delayed,
|
|
TriageStatus::Delayed => TriageStatus::Immediate,
|
|
other => other.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Internal breathing assessment
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum BreathingAssessment {
|
|
Normal,
|
|
TooFast,
|
|
TooSlow,
|
|
Agonal,
|
|
Absent,
|
|
}
|
|
|
|
/// Internal movement assessment
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum MovementAssessment {
|
|
Responsive, // Voluntary purposeful movement
|
|
Moving, // Movement but unclear if responsive
|
|
MinimalMovement, // Small movements only
|
|
InvoluntaryOnly, // Only tremors/involuntary
|
|
None, // No movement detected
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::domain::{
|
|
BreathingPattern, ConfidenceScore, HeartbeatSignature, MovementProfile, SignalStrength,
|
|
};
|
|
use chrono::Utc;
|
|
|
|
fn create_vitals(
|
|
breathing: Option<BreathingPattern>,
|
|
movement: MovementProfile,
|
|
) -> VitalSignsReading {
|
|
VitalSignsReading {
|
|
breathing,
|
|
heartbeat: None,
|
|
movement,
|
|
timestamp: Utc::now(),
|
|
confidence: ConfidenceScore::new(0.8),
|
|
}
|
|
}
|
|
|
|
/// SAFETY regression: a survivor with a detectable heartbeat but no sensed
|
|
/// breathing or movement is in respiratory arrest — Immediate (Red), and
|
|
/// must NEVER be reported Deceased. (Before the fix, `combine_assessments`
|
|
/// ignored heartbeat and returned Deceased; that path was in fact only
|
|
/// reachable *because* a heartbeat made `has_vitals()` true.)
|
|
#[test]
|
|
fn heartbeat_with_no_breathing_or_movement_is_immediate_not_deceased() {
|
|
let vitals = VitalSignsReading {
|
|
breathing: None,
|
|
heartbeat: Some(HeartbeatSignature {
|
|
rate_bpm: 72.0,
|
|
variability: 0.1,
|
|
strength: SignalStrength::Moderate,
|
|
}),
|
|
movement: MovementProfile::default(),
|
|
timestamp: Utc::now(),
|
|
confidence: ConfidenceScore::new(0.8),
|
|
};
|
|
let status = TriageCalculator::calculate(&vitals);
|
|
assert_eq!(status, TriageStatus::Immediate, "pulse present ⇒ alive");
|
|
assert_ne!(status, TriageStatus::Deceased);
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_vitals_is_unknown() {
|
|
let vitals = create_vitals(None, MovementProfile::default());
|
|
assert_eq!(TriageCalculator::calculate(&vitals), TriageStatus::Unknown);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normal_breathing_responsive_is_minor() {
|
|
let vitals = create_vitals(
|
|
Some(BreathingPattern {
|
|
rate_bpm: 16.0,
|
|
amplitude: 0.8,
|
|
regularity: 0.9,
|
|
pattern_type: BreathingType::Normal,
|
|
}),
|
|
MovementProfile {
|
|
movement_type: MovementType::Gross,
|
|
intensity: 0.8,
|
|
frequency: 0.5,
|
|
is_voluntary: true,
|
|
},
|
|
);
|
|
assert_eq!(TriageCalculator::calculate(&vitals), TriageStatus::Minor);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fast_breathing_is_immediate() {
|
|
let vitals = create_vitals(
|
|
Some(BreathingPattern {
|
|
rate_bpm: 35.0,
|
|
amplitude: 0.7,
|
|
regularity: 0.5,
|
|
pattern_type: BreathingType::Labored,
|
|
}),
|
|
MovementProfile {
|
|
movement_type: MovementType::Fine,
|
|
intensity: 0.3,
|
|
frequency: 0.2,
|
|
is_voluntary: false,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
TriageCalculator::calculate(&vitals),
|
|
TriageStatus::Immediate
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_slow_breathing_is_immediate() {
|
|
let vitals = create_vitals(
|
|
Some(BreathingPattern {
|
|
rate_bpm: 8.0,
|
|
amplitude: 0.5,
|
|
regularity: 0.6,
|
|
pattern_type: BreathingType::Shallow,
|
|
}),
|
|
MovementProfile {
|
|
movement_type: MovementType::None,
|
|
intensity: 0.0,
|
|
frequency: 0.0,
|
|
is_voluntary: false,
|
|
},
|
|
);
|
|
assert_eq!(
|
|
TriageCalculator::calculate(&vitals),
|
|
TriageStatus::Immediate
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_agonal_breathing_is_immediate() {
|
|
let vitals = create_vitals(
|
|
Some(BreathingPattern {
|
|
rate_bpm: 4.0,
|
|
amplitude: 0.3,
|
|
regularity: 0.2,
|
|
pattern_type: BreathingType::Agonal,
|
|
}),
|
|
MovementProfile::default(),
|
|
);
|
|
assert_eq!(
|
|
TriageCalculator::calculate(&vitals),
|
|
TriageStatus::Immediate
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_triage_priority() {
|
|
assert_eq!(TriageStatus::Immediate.priority(), 1);
|
|
assert_eq!(TriageStatus::Delayed.priority(), 2);
|
|
assert_eq!(TriageStatus::Minor.priority(), 3);
|
|
assert_eq!(TriageStatus::Deceased.priority(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn test_upgrade_triage() {
|
|
assert_eq!(
|
|
TriageCalculator::upgrade(&TriageStatus::Minor),
|
|
TriageStatus::Delayed
|
|
);
|
|
assert_eq!(
|
|
TriageCalculator::upgrade(&TriageStatus::Delayed),
|
|
TriageStatus::Immediate
|
|
);
|
|
assert_eq!(
|
|
TriageCalculator::upgrade(&TriageStatus::Immediate),
|
|
TriageStatus::Immediate
|
|
);
|
|
}
|
|
}
|