feat(patient): add EKG monitor with configurable leads
Introduce crate-level ekg module and re-export EkgLead, EkgMonitor, EkgSnapshot, and HeartElectricalState from lib. Patient now owns an optional EKG monitor that: - auto-initializes and syncs to the first Heart organ (lead count) - supports configure_ekg_leads() for custom lead sets - exposes ekg_monitor(), ekg_monitor_mut(), and ekg_snapshot() - is advanced during Patient::update() by observing heart electrical state - contributes to patient_summary() output Examples: - demo_app adds "set ekg <lead...>" command - dashboard renders Electrocardiogram section (rhythm, rate, axis, lead amplitudes) - lead parsing and human-readable labels added Organs: - export CardiacRhythmState from organs::heart - minor refactors in heart (dedupe impl placement), bladder and brain code style cleanups Tests: - extend patient_lifecycle with EKG assertions - add ekg_monitor_tracks_leads to validate lead config and snapshot No breaking changes.
This commit is contained in:
+113
-4
@@ -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 <on|off> Force arrhythmic behaviour on the heart
|
||||
set tone <value> Set heart autonomic tone (-1.0..=1.0)
|
||||
set svr <value> Set heart systemic vascular resistance (mmHg*min/L)
|
||||
set ekg <lead...> Configure EKG leads (e.g., set ekg I II V1 V5)
|
||||
set glucose <mg/dL> Override blood glucose
|
||||
set spo2 <percent> Override blood SpO2 (0-100)
|
||||
set bp <systolic> <diastolic> 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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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, "<not attached>".to_string()))
|
||||
stat_line(
|
||||
"Heart",
|
||||
colorize(COLOR_WARNING, "<not attached>".to_string())
|
||||
)
|
||||
);
|
||||
}
|
||||
println!();
|
||||
@@ -602,6 +675,42 @@ fn parse_toggle(value: Option<&str>) -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_lead(raw: &str) -> Result<EkgLead, String> {
|
||||
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"
|
||||
|
||||
+483
@@ -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<EkgLeadSample>,
|
||||
}
|
||||
|
||||
/// Lead-tracking ECG monitor bound to a heart organ.
|
||||
#[derive(Debug)]
|
||||
pub struct EkgMonitor {
|
||||
heart_id: String,
|
||||
leads: Vec<LeadState>,
|
||||
global_phase: f32,
|
||||
time_elapsed_s: f32,
|
||||
last_rr_interval_s: f32,
|
||||
last_snapshot: Option<EkgSnapshot>,
|
||||
}
|
||||
|
||||
impl EkgMonitor {
|
||||
/// Create a new monitor for a specific heart id and lead configuration.
|
||||
pub fn new(heart_id: impl Into<String>, leads: Vec<EkgLead>) -> 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<String>) {
|
||||
self.heart_id = heart_id.into();
|
||||
}
|
||||
|
||||
/// Ordered set of leads currently simulated.
|
||||
pub fn leads(&self) -> Vec<EkgLead> {
|
||||
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<EkgLead>) {
|
||||
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<EkgLead>) -> Vec<EkgLead> {
|
||||
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<LeadState> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+41
-41
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+137
-3
@@ -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<EkgMonitor>,
|
||||
}
|
||||
|
||||
#[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<dyn Organ> = Box::new(organ);
|
||||
if boxed.organ_type() == OrganType::Heart {
|
||||
if let Some(heart) = boxed.as_ref().as_any().downcast_ref::<Heart>() {
|
||||
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<EkgLead>) {
|
||||
let heart_info = self
|
||||
.find_organ_typed::<Heart>()
|
||||
.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<EkgLead> {
|
||||
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::<Heart>() {
|
||||
self.blood_pressure = heart.arterial_bp;
|
||||
let heart_snapshot = self
|
||||
.find_organ_typed::<Heart>()
|
||||
.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::<Lungs>() {
|
||||
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::<Liver>() {
|
||||
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::<crate::organs::Heart>().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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user