diff --git a/examples/demo_app.rs b/examples/demo_app.rs index fc974f3..8f2de67 100644 --- a/examples/demo_app.rs +++ b/examples/demo_app.rs @@ -1,6 +1,6 @@ use medicallib_rust::{ - bmi_measurement, calculate_bmi, classify_bmi, BloodPressure, Heart, Measurement, OrganType, - Patient, Result as MedicalResult, VitalSign, + bmi_measurement, calculate_bmi, classify_bmi, BloodPressure, EkgLead, Heart, Measurement, + OrganType, Patient, Result as MedicalResult, VitalSign, }; use std::fmt::Write as _; use std::io::{self, Write}; @@ -67,6 +67,7 @@ Available commands: set arrhythmia Force arrhythmic behaviour on the heart set tone Set heart autonomic tone (-1.0..=1.0) set svr Set heart systemic vascular resistance (mmHg*min/L) + set ekg Configure EKG leads (e.g., set ekg I II V1 V5) set glucose Override blood glucose set spo2 Override blood SpO2 (0-100) set bp Override brachial blood pressure @@ -333,6 +334,25 @@ fn handle_set_command<'a>( value ))) } + "ekg" => { + let tokens: Vec<_> = parts.collect(); + if tokens.is_empty() { + return Err(String::from("set ekg expects one or more lead identifiers")); + } + let mut leads = Vec::with_capacity(tokens.len()); + for token in tokens { + leads.push(parse_lead(token)?); + } + let summary = leads + .iter() + .map(|lead| lead_label(*lead)) + .collect::>() + .join(", "); + state.patient.configure_ekg_leads(leads); + Ok(CommandOutcome::Continue(format!( + "EKG leads set to {summary}." + ))) + } "glucose" => { let raw = parts .next() @@ -448,7 +468,11 @@ fn render_dashboard(state: &MonitorState, status: &str) { "{}", stat_line( "Blood pressure", - format!("{} {}", accent(format!("{bp}")), validity_tag(bp.validate())) + format!( + "{} {}", + accent(format!("{bp}")), + validity_tag(bp.validate()) + ) ) ); @@ -467,6 +491,52 @@ fn render_dashboard(state: &MonitorState, status: &str) { let chemistry = chemistry_parts.join(""); println!("{}", stat_line("Blood chemistry", chemistry)); + println!("{}", section_line("Electrocardiogram")); + match state.patient.ekg_snapshot() { + Some(snapshot) => { + println!( + "{}", + stat_line( + "Rhythm", + format!( + "{:?} | {:.0} bpm | RR {:.3} s", + snapshot.rhythm, snapshot.heart_rate_bpm, snapshot.rr_interval_s + ) + ) + ); + println!( + "{}", + stat_line( + "Axis", + format!( + "{:+.0} deg | variability {:.2}", + snapshot.frontal_axis_deg, snapshot.variability_index + ) + ) + ); + for (idx, chunk) in snapshot.lead_samples.chunks(6).enumerate() { + let label = if idx == 0 { "Leads" } else { "" }; + let body = chunk + .iter() + .map(|sample| { + format!("{} {:+.2}mV", lead_label(sample.lead), sample.amplitude_mv) + }) + .collect::>() + .join(" "); + println!("{}", stat_line(label, body)); + } + } + None => { + let message = if state.patient.ekg_monitor().is_some() { + muted("monitor waiting for first snapshot") + } else { + muted("monitor not configured") + }; + println!("{}", stat_line("Status", message)); + } + } + println!(); + let (weight, height) = state.bmi_inputs; let bmi_line = match bmi_measurement(weight, height) { Ok(measurement) => { @@ -528,7 +598,10 @@ fn render_dashboard(state: &MonitorState, status: &str) { } else { println!( "{}", - stat_line("Heart", colorize(COLOR_WARNING, "".to_string())) + stat_line( + "Heart", + colorize(COLOR_WARNING, "".to_string()) + ) ); } println!(); @@ -602,6 +675,42 @@ fn parse_toggle(value: Option<&str>) -> Result { } } +fn parse_lead(raw: &str) -> Result { + let upper = raw.trim().to_ascii_uppercase(); + match upper.as_str() { + "I" => Ok(EkgLead::I), + "II" => Ok(EkgLead::II), + "III" => Ok(EkgLead::III), + "AVR" => Ok(EkgLead::AVR), + "AVL" => Ok(EkgLead::AVL), + "AVF" => Ok(EkgLead::AVF), + "V1" => Ok(EkgLead::V1), + "V2" => Ok(EkgLead::V2), + "V3" => Ok(EkgLead::V3), + "V4" => Ok(EkgLead::V4), + "V5" => Ok(EkgLead::V5), + "V6" => Ok(EkgLead::V6), + other => Err(format!("unknown lead '{other}'")), + } +} + +fn lead_label(lead: EkgLead) -> &'static str { + match lead { + EkgLead::I => "I", + EkgLead::II => "II", + EkgLead::III => "III", + EkgLead::AVR => "aVR", + EkgLead::AVL => "aVL", + EkgLead::AVF => "aVF", + EkgLead::V1 => "V1", + EkgLead::V2 => "V2", + EkgLead::V3 => "V3", + EkgLead::V4 => "V4", + EkgLead::V5 => "V5", + EkgLead::V6 => "V6", + } +} + fn yes_no(value: bool) -> &'static str { if value { "yes" diff --git a/src/ekg/mod.rs b/src/ekg/mod.rs new file mode 100644 index 0000000..4d4b975 --- /dev/null +++ b/src/ekg/mod.rs @@ -0,0 +1,483 @@ +//! ECG (electrocardiogram) simulation utilities tightly coupled to the heart organ. +//! +//! The monitor exposes a configurable set of leads and produces synthetic waveforms that +//! respond to the hemodynamic and electrophysiological state of the [`Heart`]. The +//! implementation intentionally mirrors common surface ECG morphology without aiming for +//! diagnostic fidelity. + +use crate::organs::{CardiacRhythmState, Heart, Organ}; +use core::f32::consts::TAU; + +const MIN_RR_INTERVAL_S: f32 = 0.3; +const MAX_RR_INTERVAL_S: f32 = 2.5; +const DEFAULT_LEADS: [EkgLead; 12] = [ + EkgLead::I, + EkgLead::II, + EkgLead::III, + EkgLead::AVR, + EkgLead::AVL, + EkgLead::AVF, + EkgLead::V1, + EkgLead::V2, + EkgLead::V3, + EkgLead::V4, + EkgLead::V5, + EkgLead::V6, +]; + +/// Simplified representation of the standard ECG leads. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum EkgLead { + /// Standard limb lead I. + I = 0, + /// Standard limb lead II. + II, + /// Standard limb lead III. + III, + /// Augmented limb lead aVR. + AVR, + /// Augmented limb lead aVL. + AVL, + /// Augmented limb lead aVF. + AVF, + /// Precordial lead V1. + V1, + /// Precordial lead V2. + V2, + /// Precordial lead V3. + V3, + /// Precordial lead V4. + V4, + /// Precordial lead V5. + V5, + /// Precordial lead V6. + V6, +} + +impl EkgLead { + /// Returns the canonical ordering for a 12-lead ECG. + pub const fn standard_order() -> &'static [EkgLead] { + &DEFAULT_LEADS + } + + fn geometry(self) -> LeadVector { + use EkgLead::*; + match self { + I => LeadVector::new(1.0, 0.1, 0.05), + II => LeadVector::new(0.6, 0.9, 0.1), + III => LeadVector::new(-0.1, 1.0, 0.12), + AVR => LeadVector::new(-0.9, -0.3, -0.05), + AVL => LeadVector::new(0.7, -0.2, 0.02), + AVF => LeadVector::new(0.0, 1.0, 0.12), + V1 => LeadVector::new(-0.7, 0.05, 1.0), + V2 => LeadVector::new(-0.3, 0.15, 1.0), + V3 => LeadVector::new(0.0, 0.2, 0.9), + V4 => LeadVector::new(0.3, 0.25, 0.8), + V5 => LeadVector::new(0.7, 0.25, 0.7), + V6 => LeadVector::new(0.9, 0.2, 0.6), + } + } +} + +#[derive(Debug, Clone, Copy)] +struct LeadVector { + lateral: f32, + inferior: f32, + anterior: f32, +} + +impl LeadVector { + const fn new(lateral: f32, inferior: f32, anterior: f32) -> Self { + Self { + lateral, + inferior, + anterior, + } + } + + fn dot(self, other: Self) -> f32 { + self.lateral * other.lateral + + self.inferior * other.inferior + + self.anterior * other.anterior + } + + fn normalized(self) -> Self { + let mag = (self.lateral * self.lateral + + self.inferior * self.inferior + + self.anterior * self.anterior) + .sqrt(); + if mag <= f32::EPSILON { + return Self::new(0.0, 0.0, 0.0); + } + Self::new( + (self.lateral / mag).clamp(-1.5, 1.5), + (self.inferior / mag).clamp(-1.5, 1.5), + (self.anterior / mag).clamp(-1.5, 1.5), + ) + } +} + +/// Snapshot of the cardiac state relevant for ECG generation. +#[derive(Debug, Clone)] +pub struct HeartElectricalState { + /// Current heart rate in beats per minute. + pub heart_rate_bpm: f32, + /// Sympathetic versus parasympathetic balance (negative favors vagal). + pub autonomic_tone: f32, + /// Inotropic state relative to baseline (1.0 equals nominal). + pub contractility_index: f32, + /// Fraction of beats affected by conduction irregularity. + pub arrhythmia_burden: f32, + /// Stroke volume per beat expressed in milliliters. + pub stroke_volume_ml: f32, + /// Cardiac output in liters per minute. + pub cardiac_output_l_min: f32, + /// Estimated atrial preload pressure in millimeters of mercury. + pub preload_mm_hg: f32, + /// Effective arterial afterload pressure in millimeters of mercury. + pub afterload_mm_hg: f32, + /// Venous return rate feeding the heart in liters per minute. + pub venous_return_l_min: f32, + /// Coronary perfusion pressure available for myocardial oxygenation. + pub coronary_perfusion_mm_hg: f32, + /// Fraction of ventricular volume ejected each beat (0.0-1.0). + pub ejection_fraction: f32, + /// Classified rhythm state for pacing the waveform generator. + pub rhythm: CardiacRhythmState, +} + +impl From<&Heart> for HeartElectricalState { + fn from(heart: &Heart) -> Self { + Self { + heart_rate_bpm: heart.heart_rate_bpm, + autonomic_tone: heart.autonomic_tone, + contractility_index: heart.contractility_index, + arrhythmia_burden: heart.arrhythmia_burden, + stroke_volume_ml: heart.stroke_volume_ml, + cardiac_output_l_min: heart.cardiac_output_l_min, + preload_mm_hg: heart.preload_mm_hg, + afterload_mm_hg: heart.afterload_mm_hg, + venous_return_l_min: heart.venous_return_l_min, + coronary_perfusion_mm_hg: heart.coronary_perfusion_mm_hg, + ejection_fraction: heart.ejection_fraction, + rhythm: heart.rhythm_state, + } + } +} + +/// Instantaneous reading for a configured lead. +#[derive(Debug, Clone)] +pub struct EkgLeadSample { + /// Lead identity associated with this sample. + pub lead: EkgLead, + /// Composite millivolt amplitude observed on the lead. + pub amplitude_mv: f32, + /// Contribution from simulated atrial depolarization (P-wave). + pub p_wave_mv: f32, + /// Contribution from simulated ventricular depolarization (QRS complex). + pub qrs_complex_mv: f32, + /// Contribution from simulated ventricular repolarization (T-wave). + pub t_wave_mv: f32, + /// ST-segment offset from the baseline isoelectric line. + pub st_deviation_mv: f32, + /// Additive high-frequency noise used to keep traces dynamic. + pub noise_mv: f32, +} + +/// Most recent ECG snapshot produced by the monitor. +#[derive(Debug, Clone)] +pub struct EkgSnapshot { + /// Identifier for the associated heart organ. + pub heart_id: String, + /// Elapsed simulation time in seconds. + pub time_s: f32, + /// Estimated heart rate in beats per minute at this instant. + pub heart_rate_bpm: f32, + /// Duration of the most recent R-R interval in seconds. + pub rr_interval_s: f32, + /// Cardiac rhythm classification captured with the reading. + pub rhythm: CardiacRhythmState, + /// Frontal plane electrical axis in degrees. + pub frontal_axis_deg: f32, + /// Normalized heart rate variability metric (0.0-1.0). + pub variability_index: f32, + /// Per-lead synthesized waveform samples. + pub lead_samples: Vec, +} + +/// Lead-tracking ECG monitor bound to a heart organ. +#[derive(Debug)] +pub struct EkgMonitor { + heart_id: String, + leads: Vec, + global_phase: f32, + time_elapsed_s: f32, + last_rr_interval_s: f32, + last_snapshot: Option, +} + +impl EkgMonitor { + /// Create a new monitor for a specific heart id and lead configuration. + pub fn new(heart_id: impl Into, leads: Vec) -> Self { + let heart_id = heart_id.into(); + let filtered = sanitize_leads(leads); + let lead_states = build_lead_states(&filtered); + Self { + heart_id, + leads: lead_states, + global_phase: 0.0, + time_elapsed_s: 0.0, + last_rr_interval_s: 60.0 / 72.0, + last_snapshot: None, + } + } + + /// Construct a monitor using the heart's configured lead count. + pub fn from_heart(heart: &Heart) -> Self { + let count = heart.leads.clamp(1, DEFAULT_LEADS.len() as u8) as usize; + let leads = DEFAULT_LEADS[..count].to_vec(); + Self::new(heart.id().to_string(), leads) + } + + /// Identifier of the heart this monitor is following. + pub fn heart_id(&self) -> &str { + &self.heart_id + } + + /// Retarget the monitor to a different heart identifier. + pub fn retarget(&mut self, heart_id: impl Into) { + self.heart_id = heart_id.into(); + } + + /// Ordered set of leads currently simulated. + pub fn leads(&self) -> Vec { + self.leads.iter().map(|state| state.lead).collect() + } + + /// Replace the simulated leads while keeping accumulated phase information. + pub fn configure_leads(&mut self, leads: Vec) { + let filtered = sanitize_leads(leads); + self.leads = build_lead_states(&filtered); + } + + /// Update the monitor with the latest heart state. + pub fn observe(&mut self, state: &HeartElectricalState, dt_seconds: f32) { + if dt_seconds <= 0.0 || self.leads.is_empty() { + return; + } + + let rr_interval = (60.0 / state.heart_rate_bpm).clamp(MIN_RR_INTERVAL_S, MAX_RR_INTERVAL_S); + let arrhythmia = state.arrhythmia_burden.clamp(0.0, 1.0); + let variability = 0.12 + arrhythmia * 0.6 + state.autonomic_tone.abs() * 0.1; + let variability = variability.clamp(0.05, 0.9); + let phase_rate = + (1.0 + (arrhythmia - 0.2) * 0.15 * (self.time_elapsed_s * 0.7).sin()).clamp(0.7, 1.3); + self.global_phase = (self.global_phase + dt_seconds / (rr_interval * phase_rate)).fract(); + + let axis = derive_axis(state); + let frontal_axis_rad = axis.inferior.atan2(axis.lateral); + let frontal_axis_deg = frontal_axis_rad.to_degrees(); + let amplitude_scale = derive_amplitude_scale(state); + let qrs_width = 0.055 + 0.03 * arrhythmia + (state.contractility_index - 1.0).abs() * 0.015; + let qrs_width = qrs_width.clamp(0.04, 0.12); + let st_target = derive_st_deviation(state); + + let mut lead_samples = Vec::with_capacity(self.leads.len()); + for (idx, lead_state) in self.leads.iter_mut().enumerate() { + let lead_phase = (self.global_phase + lead_state.phase_offset).fract(); + let geom = lead_state.lead.geometry().normalized(); + let axis_gain = axis.dot(geom).clamp(-1.4, 1.4); + + let p_center = 0.18 + geom.lateral * 0.015 - arrhythmia * 0.02; + let p_width = (0.04 + 0.015 * variability).clamp(0.03, 0.08); + let p_shape = gaussian(lead_phase, p_center, p_width); + let p_wave_mv = amplitude_scale * (0.11 + axis_gain * 0.045) * p_shape; + + let qrs_center = 0.32 + geom.inferior * 0.01 - arrhythmia * 0.015; + let qrs_shape = qrs_triplet(lead_phase, qrs_center, qrs_width * 0.6); + let polarity = if axis_gain >= 0.0 { 1.0 } else { -1.0 }; + let qrs_wave_mv = + polarity * amplitude_scale * (0.95 + axis_gain.abs() * 0.55) * qrs_shape; + + let t_center = 0.6 + geom.anterior * 0.04 + arrhythmia * 0.03; + let t_width = (0.1 + 0.04 * variability).clamp(0.07, 0.18); + let t_shape = gaussian(lead_phase, t_center, t_width); + let t_wave_mv = amplitude_scale * (0.35 + axis_gain * 0.06) * t_shape; + + let baseline_target = (st_target + axis_gain * 0.05).clamp(-0.7, 0.7); + lead_state.baseline_mv = + relax(lead_state.baseline_mv, baseline_target, dt_seconds, 1.6); + + lead_state.noise_phase = + (lead_state.noise_phase + dt_seconds * (1.6 + idx as f32 * 0.35)).fract(); + let noise = arrhythmia * 0.22 * (lead_state.noise_phase * TAU).sin() + + variability * 0.11 * ((lead_state.noise_phase * TAU * 2.0).sin()); + + let amplitude_mv = p_wave_mv + qrs_wave_mv + t_wave_mv + lead_state.baseline_mv + noise; + + lead_samples.push(EkgLeadSample { + lead: lead_state.lead, + amplitude_mv, + p_wave_mv, + qrs_complex_mv: qrs_wave_mv, + t_wave_mv, + st_deviation_mv: lead_state.baseline_mv, + noise_mv: noise, + }); + } + + self.time_elapsed_s += dt_seconds; + self.last_rr_interval_s = rr_interval; + self.last_snapshot = Some(EkgSnapshot { + heart_id: self.heart_id.clone(), + time_s: self.time_elapsed_s, + heart_rate_bpm: state.heart_rate_bpm, + rr_interval_s: rr_interval, + rhythm: state.rhythm, + frontal_axis_deg, + variability_index: variability, + lead_samples, + }); + } + + /// The most recent snapshot produced by [`observe`]. + pub fn last_snapshot(&self) -> Option<&EkgSnapshot> { + self.last_snapshot.as_ref() + } + + /// Last RR interval that drove waveform generation. + pub fn last_rr_interval(&self) -> f32 { + self.last_rr_interval_s + } +} + +#[derive(Debug, Clone)] +struct LeadState { + lead: EkgLead, + phase_offset: f32, + noise_phase: f32, + baseline_mv: f32, +} + +fn sanitize_leads(leads: Vec) -> Vec { + let mut result = Vec::with_capacity(leads.len().max(1)); + for lead in leads { + if !result.contains(&lead) { + result.push(lead); + } + } + if result.is_empty() { + result.push(EkgLead::II); + } + result +} + +fn build_lead_states(leads: &[EkgLead]) -> Vec { + leads + .iter() + .enumerate() + .map(|(idx, lead)| LeadState { + lead: *lead, + phase_offset: (idx as f32) * 0.035, + noise_phase: ((idx as f32 + 1.0) * 0.137).fract(), + baseline_mv: 0.0, + }) + .collect() +} + +fn derive_axis(state: &HeartElectricalState) -> LeadVector { + let lateral = (state.autonomic_tone * 0.6 + (state.contractility_index - 1.0) * 0.4) + - state.arrhythmia_burden * 0.15; + let inferior = (state.stroke_volume_ml - 65.0) / 35.0 + + (state.cardiac_output_l_min - 5.0) * 0.12 + - state.arrhythmia_burden * 0.1 + + 0.6; + let anterior = (state.preload_mm_hg - 8.0) / 16.0 + (state.venous_return_l_min - 5.0) * 0.08 + - (state.afterload_mm_hg - 95.0) / 240.0 + + 0.2; + LeadVector::new(lateral, inferior, anterior).normalized() +} + +fn derive_amplitude_scale(state: &HeartElectricalState) -> f32 { + (0.9 + (state.contractility_index - 1.0) * 0.35 + + (state.ejection_fraction - 0.55) * 1.1 + + (state.cardiac_output_l_min - 5.0) * 0.08) + .clamp(0.35, 2.4) +} + +fn derive_st_deviation(state: &HeartElectricalState) -> f32 { + ((state.coronary_perfusion_mm_hg - 70.0) / 210.0) - (state.arrhythmia_burden * 0.3) + + (state.autonomic_tone * 0.1) +} + +fn gaussian(x: f32, center: f32, width: f32) -> f32 { + if width <= 0.0 { + return 0.0; + } + let diff = wrap_phase(x - center); + (-0.5 * (diff / width).powi(2)).exp() +} + +fn qrs_triplet(phase: f32, center: f32, width: f32) -> f32 { + if width <= 0.0 { + return 0.0; + } + let q = -0.35 * gaussian(phase, center - width * 0.8, width * 0.6); + let r = 1.35 * gaussian(phase, center, width * 0.5); + let s = -0.4 * gaussian(phase, center + width * 0.9, width * 0.7); + q + r + s +} + +fn wrap_phase(mut value: f32) -> f32 { + if value > 0.5 { + value -= 1.0; + } else if value < -0.5 { + value += 1.0; + } + value +} + +fn relax(current: f32, target: f32, dt_seconds: f32, time_constant: f32) -> f32 { + if time_constant <= 0.0 { + target + } else { + let alpha = (dt_seconds / time_constant).clamp(0.0, 1.0); + current + (target - current) * alpha + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monitor_tracks_heart_activity() { + let mut heart = Heart::new("ekg", 12); + // induce slight sympathetic tone to exercise dynamics + heart.autonomic_tone = 0.3; + let mut monitor = EkgMonitor::from_heart(&heart); + let dt = 0.01; + for _ in 0..2000 { + heart.update(dt); + let state = HeartElectricalState::from(&heart); + monitor.observe(&state, dt); + } + let snapshot = monitor.last_snapshot().expect("snapshot available"); + assert_eq!(snapshot.lead_samples.len(), heart.leads as usize); + assert!((snapshot.heart_rate_bpm - heart.heart_rate_bpm).abs() < 1.0); + assert!(snapshot + .lead_samples + .iter() + .any(|s| s.amplitude_mv.abs() > 0.2)); + } + + #[test] + fn lead_configuration_can_change() { + let heart = Heart::new("ekg", 12); + let mut monitor = EkgMonitor::from_heart(&heart); + monitor.configure_leads(vec![EkgLead::V1, EkgLead::II, EkgLead::V6, EkgLead::II]); + assert_eq!(monitor.leads().len(), 3); + assert_eq!(monitor.leads(), vec![EkgLead::V1, EkgLead::II, EkgLead::V6]); + } +} diff --git a/src/lib.rs b/src/lib.rs index eb87cc5..5fd4e51 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ #![deny(unsafe_code)] #![warn(missing_docs, rust_2018_idioms, missing_debug_implementations)] +mod ekg; mod error; mod organs; mod patient; @@ -34,6 +35,7 @@ mod types; #[cfg(feature = "ffi")] pub mod ffi; +pub use crate::ekg::{EkgLead, EkgMonitor, EkgSnapshot, HeartElectricalState}; pub use crate::error::MedicalError; pub use crate::organs::{Heart, Organ}; pub use crate::patient::Patient; diff --git a/src/organs/bladder.rs b/src/organs/bladder.rs index 1a8aab2..79470e2 100644 --- a/src/organs/bladder.rs +++ b/src/organs/bladder.rs @@ -154,10 +154,10 @@ impl Bladder { } fn handle_filling_phase(&mut self, _dt_seconds: f32) { - if self.volume_ml >= self.micturition_threshold_ml || self.pressure > 45.0 { - if self.external_sphincter_tone < 0.4 || self.urgency > 0.95 { - self.phase = BladderPhase::Voiding; - } + if (self.volume_ml >= self.micturition_threshold_ml || self.pressure > 45.0) + && (self.external_sphincter_tone < 0.4 || self.urgency > 0.95) + { + self.phase = BladderPhase::Voiding; } let overdistention_limit = self.capacity_ml * 1.4; if self.volume_ml > overdistention_limit { diff --git a/src/organs/brain.rs b/src/organs/brain.rs index 76ffc7e..796e1fd 100644 --- a/src/organs/brain.rs +++ b/src/organs/brain.rs @@ -104,7 +104,7 @@ impl Brain { } fn wrap_phase(phase: f32) -> f32 { - if phase >= 0.0 && phase < TAU { + if (0.0..TAU).contains(&phase) { return phase; } let mut wrapped = phase % TAU; diff --git a/src/organs/heart.rs b/src/organs/heart.rs index f603f6e..380fd5d 100644 --- a/src/organs/heart.rs +++ b/src/organs/heart.rs @@ -260,6 +260,47 @@ impl Heart { } } +impl Organ for Heart { + fn id(&self) -> &str { + self.info.id() + } + fn organ_type(&self) -> OrganType { + self.info.kind() + } + fn update(&mut self, dt_seconds: f32) { + if dt_seconds <= 0.0 { + return; + } + + self.time_in_state_s += dt_seconds; + + self.update_autonomic_state(dt_seconds); + self.determine_rhythm_state(); + self.update_rate_and_contractility(dt_seconds); + self.update_volumes_and_output(dt_seconds); + self.update_arrhythmia_burden(dt_seconds); + } + fn summary(&self) -> String { + format!( + "Heart[id={}, leads={}, rhythm={:?}, HR={:.0} bpm, CO={:.1} L/min, EF={:.0}%, BP={}/{}]", + self.id(), + self.leads, + self.rhythm_state, + self.heart_rate_bpm, + self.cardiac_output_l_min, + self.ejection_fraction * 100.0, + self.arterial_bp.systolic, + self.arterial_bp.diastolic + ) + } + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + #[cfg(test)] mod tests { use super::*; @@ -301,44 +342,3 @@ mod tests { ); } } - -impl Organ for Heart { - fn id(&self) -> &str { - self.info.id() - } - fn organ_type(&self) -> OrganType { - self.info.kind() - } - fn update(&mut self, dt_seconds: f32) { - if dt_seconds <= 0.0 { - return; - } - - self.time_in_state_s += dt_seconds; - - self.update_autonomic_state(dt_seconds); - self.determine_rhythm_state(); - self.update_rate_and_contractility(dt_seconds); - self.update_volumes_and_output(dt_seconds); - self.update_arrhythmia_burden(dt_seconds); - } - fn summary(&self) -> String { - format!( - "Heart[id={}, leads={}, rhythm={:?}, HR={:.0} bpm, CO={:.1} L/min, EF={:.0}%, BP={}/{}]", - self.id(), - self.leads, - self.rhythm_state, - self.heart_rate_bpm, - self.cardiac_output_l_min, - self.ejection_fraction * 100.0, - self.arterial_bp.systolic, - self.arterial_bp.diastolic - ) - } - fn as_any(&self) -> &dyn core::any::Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn core::any::Any { - self - } -} diff --git a/src/organs/mod.rs b/src/organs/mod.rs index 03d85ce..63f7d2b 100644 --- a/src/organs/mod.rs +++ b/src/organs/mod.rs @@ -59,7 +59,7 @@ pub use bladder::{Bladder, BladderPhase}; pub use brain::{Brain, SleepStage}; pub use esophagus::{EsophagealStage, Esophagus}; pub use gallbladder::Gallbladder; -pub use heart::Heart; +pub use heart::{CardiacRhythmState, Heart}; pub use intestines::Intestines; pub use kidneys::Kidneys; pub use liver::Liver; diff --git a/src/patient.rs b/src/patient.rs index 1f39271..2a34ceb 100644 --- a/src/patient.rs +++ b/src/patient.rs @@ -1,5 +1,6 @@ //! Patient type holding organs and core physiology snapshots. +use crate::ekg::{EkgLead, EkgMonitor, EkgSnapshot, HeartElectricalState}; use crate::error::MedicalError; use crate::organs::{ Bladder, BladderPhase, Brain, EsophagealStage, Esophagus, Gallbladder, Heart, Intestines, @@ -16,6 +17,8 @@ pub struct Patient { pub blood: Blood, /// Non-invasive brachial blood pressure. pub blood_pressure: BloodPressure, + /// Surface electrocardiogram monitor bound to the heart. + ekg_monitor: Option, } #[derive(Clone, Copy)] @@ -120,6 +123,7 @@ impl Patient { organs: Vec::with_capacity(4), blood: Blood::default(), blood_pressure: BloodPressure::default(), + ekg_monitor: None, }) } @@ -130,7 +134,87 @@ impl Patient { /// Add an organ to the patient. pub fn add_organ(&mut self, organ: impl Organ + 'static) { - self.organs.push(Box::new(organ)); + let boxed: Box = Box::new(organ); + if boxed.organ_type() == OrganType::Heart { + if let Some(heart) = boxed.as_ref().as_any().downcast_ref::() { + let heart_id = heart.id().to_string(); + self.sync_ekg_with_heart(&heart_id, heart.leads); + } + } + self.organs.push(boxed); + } + + /// Access the ECG monitor if configured. + pub fn ekg_monitor(&self) -> Option<&EkgMonitor> { + self.ekg_monitor.as_ref() + } + + /// Mutable access to the ECG monitor. + pub fn ekg_monitor_mut(&mut self) -> Option<&mut EkgMonitor> { + self.ekg_monitor.as_mut() + } + + /// Latest ECG snapshot produced by the monitor, when available. + pub fn ekg_snapshot(&self) -> Option<&EkgSnapshot> { + self.ekg_monitor + .as_ref() + .and_then(|monitor| monitor.last_snapshot()) + } + + /// Configure the ECG monitor with a custom set of leads. + /// Configure the ECG monitor with a custom set of leads. + pub fn configure_ekg_leads(&mut self, leads: Vec) { + let heart_info = self + .find_organ_typed::() + .map(|heart| (heart.id().to_string(), heart.leads)); + + if let Some(monitor) = self.ekg_monitor.as_mut() { + monitor.configure_leads(leads); + } else if let Some((ref heart_id, lead_count)) = heart_info.as_ref() { + let mut monitor = + EkgMonitor::new(heart_id.clone(), Self::default_lead_subset(*lead_count)); + monitor.configure_leads(leads); + self.ekg_monitor = Some(monitor); + } else { + self.ekg_monitor = Some(EkgMonitor::new(format!("{}-heart", self.id), leads)); + } + + if let Some((heart_id, lead_count)) = heart_info { + self.sync_ekg_with_heart(&heart_id, lead_count); + } + } + + fn sync_ekg_with_heart(&mut self, heart_id: &str, lead_count: u8) { + let desired_len = lead_count.clamp(1, EkgLead::standard_order().len() as u8) as usize; + match self.ekg_monitor.as_mut() { + Some(monitor) => { + monitor.retarget(heart_id); + let mut leads = monitor.leads(); + let mut changed = false; + if leads.is_empty() { + leads = Self::default_lead_subset(lead_count); + changed = true; + } + if leads.len() > desired_len { + leads.truncate(desired_len); + changed = true; + } + if changed { + monitor.configure_leads(leads); + } + } + None => { + self.ekg_monitor = Some(EkgMonitor::new( + heart_id.to_string(), + Self::default_lead_subset(lead_count), + )); + } + } + } + + fn default_lead_subset(lead_count: u8) -> Vec { + let desired_len = lead_count.clamp(1, EkgLead::standard_order().len() as u8) as usize; + EkgLead::standard_order()[..desired_len].to_vec() } /// Find the first organ matching the given type. @@ -422,13 +506,24 @@ impl Patient { organ.update(dt_seconds); } - if let Some(heart) = self.find_organ_typed::() { - self.blood_pressure = heart.arterial_bp; + let heart_snapshot = self + .find_organ_typed::() + .map(|heart| (heart.arterial_bp, HeartElectricalState::from(heart))); + + if let Some((arterial_bp, _)) = &heart_snapshot { + self.blood_pressure = *arterial_bp; } + if let Some(lungs) = self.find_organ_typed::() { self.blood.spo2_pct = lungs.spo2_pct; } + if let Some((_, state)) = heart_snapshot { + if let Some(monitor) = self.ekg_monitor.as_mut() { + monitor.observe(&state, dt_seconds); + } + } + let mut glucose = self.blood.glucose_mg_dl; if let Some(liver) = self.find_organ_typed::() { let hepatic_balance = liver.gluconeogenesis_rate * 24.0 @@ -754,6 +849,16 @@ impl Patient { "Patient[id={}, BP={}, Hgb={:.1} g/dL, SpO2={:.0}%]", self.id, self.blood_pressure, self.blood.hemoglobin_g_dl, self.blood.spo2_pct )); + if let Some(snapshot) = self.ekg_snapshot() { + parts.push(format!( + "EKG[leads={}, HR={:.0} bpm, axis={:+.0}deg]", + snapshot.lead_samples.len(), + snapshot.heart_rate_bpm, + snapshot.frontal_axis_deg + )); + } else if self.ekg_monitor.is_some() { + parts.push("EKG[pending]".to_string()); + } for o in &self.organs { parts.push(o.summary()); } @@ -786,8 +891,13 @@ mod tests { fn patient_lifecycle() { let mut p = Patient::new("alice-01").unwrap().initialize_default(); assert!(p.organ_summary(OrganType::Heart).unwrap().contains("Heart")); + assert!(p.ekg_monitor().is_some()); p.update(0.5); + assert!(p.ekg_snapshot().is_some()); assert!(p.patient_summary().contains("Patient[id=alice-01")); + assert!(p.patient_summary().contains("EKG[")); + p.configure_ekg_leads(vec![EkgLead::I, EkgLead::III, EkgLead::V4]); + p.update(0.2); // Downcast to Heart and tweak let h = p.find_organ_typed::().unwrap(); assert_eq!(h.organ_type(), OrganType::Heart); @@ -1107,4 +1217,28 @@ mod tests { stats.assert_within_ranges(); } + + #[test] + fn ekg_monitor_tracks_leads() { + let mut patient = Patient::new("ekg-suite") + .unwrap() + .initialize_default() + .with_lungs(); + patient.configure_ekg_leads(vec![EkgLead::II, EkgLead::V2, EkgLead::V5]); + for _ in 0..120 { + patient.update(0.1); + } + let snapshot = patient.ekg_snapshot().expect("ekg snapshot"); + assert_eq!( + patient.ekg_monitor().unwrap().leads().len(), + 3, + "leads: {:?}", + patient.ekg_monitor().unwrap().leads() + ); + assert_eq!(snapshot.lead_samples.len(), 3); + assert!(snapshot + .lead_samples + .iter() + .any(|sample| sample.amplitude_mv.abs() > 0.05)); + } }