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:
2025-09-26 01:01:46 -07:00
parent 0e6365bf7f
commit a74f9c408b
8 changed files with 782 additions and 54 deletions
+113 -4
View File
@@ -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
View File
@@ -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]);
}
}
+2
View File
@@ -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;
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
}
}