From 5cf6bbda48d82b86bc4db225a26bd53831271c4c Mon Sep 17 00:00:00 2001 From: Zack3D Date: Sun, 28 Sep 2025 16:10:23 -0700 Subject: [PATCH] feat(organs): add bloodstream organ and patient coupling Introduce OrganType::Bloodstream and new organ module, exporting Bloodstream, PerfusionState, and MetabolicState. - Patient: - initialize_default now attaches a bloodstream model - add with_bloodstream() builder and with_organ support - update() couples bloodstream with heart, lungs, brain, spinal cord, kidneys, liver, pancreas, stomach, intestines, gallbladder, esophagus, bladder, and spleen - bloodstream ingests hemodynamics, respiratory exchange, renal, hepatic, splenic, and nutrient feedback; propagates perfusion and metabolic signals back to organs - blood metrics (SpO2, hemoglobin, hematocrit, glucose) can be driven by bloodstream - Signals: - Lungs: add O2 delivery, CO2 elimination, V/Q ratio - Liver: add detox and ammonia clearance - Kidneys: add plasma volume, urea excretion, erythropoietin - FFI: - add ML_ORGAN_BLOODSTREAM (Rust and C header) and mapping in ml_patient_organ_summary - Examples/Tests: - demo monitors "Bloodstream" - add tests for bloodstream coupling and organ discovery - lib tests updated to include Bloodstream Rationale: centralize systemic transport and inter-organ homeostasis for richer physiology simulation and expose it to C consumers via FFI. --- examples/demo_app.rs | 4 +- ffi/medicallib.h | 5 + src/ffi.rs | 4 + src/lib.rs | 6 +- src/organs/bloodstream.rs | 537 +++++++++++++++++++++++++++++++++ src/organs/mod.rs | 2 + src/patient.rs | 611 ++++++++++++++++++++++++++++++++++++-- src/types.rs | 2 + tests/patient.rs | 4 + 9 files changed, 1151 insertions(+), 24 deletions(-) create mode 100644 src/organs/bloodstream.rs diff --git a/examples/demo_app.rs b/examples/demo_app.rs index 8f2de67..d44807e 100644 --- a/examples/demo_app.rs +++ b/examples/demo_app.rs @@ -20,8 +20,9 @@ const EXTRA_ORGANS: [OrganType; 11] = [ OrganType::Spleen, ]; -const MONITORED_ORGANS: [OrganType; 13] = [ +const MONITORED_ORGANS: [OrganType; 14] = [ OrganType::Heart, + OrganType::Bloodstream, OrganType::Lungs, OrganType::Brain, OrganType::SpinalCord, @@ -641,6 +642,7 @@ fn organ_snapshot(patient: &Patient, kind: OrganType) -> String { fn organ_label(kind: OrganType) -> &'static str { match kind { OrganType::Heart => "Heart", + OrganType::Bloodstream => "Bloodstream", OrganType::Lungs => "Lungs", OrganType::Brain => "Brain", OrganType::SpinalCord => "Spinal cord", diff --git a/ffi/medicallib.h b/ffi/medicallib.h index 5007732..6199501 100644 --- a/ffi/medicallib.h +++ b/ffi/medicallib.h @@ -91,6 +91,11 @@ */ #define ML_ORGAN_SPLEEN 12 +/** + * Organ code for `OrganType::Bloodstream`. + */ +#define ML_ORGAN_BLOODSTREAM 13 + /** * Opaque patient handle type for C consumers. Wraps a heap-allocated `Patient`. */ diff --git a/src/ffi.rs b/src/ffi.rs index b178736..7526a62 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -48,6 +48,9 @@ pub const ML_ORGAN_BLADDER: u32 = 11; /// Organ code for `OrganType::Spleen`. pub const ML_ORGAN_SPLEEN: u32 = 12; +/// Organ code for `OrganType::Bloodstream`. +pub const ML_ORGAN_BLOODSTREAM: u32 = 13; + /// Opaque patient handle type for C consumers. Wraps a heap-allocated `Patient`. #[repr(C)] #[derive(Debug)] @@ -176,6 +179,7 @@ pub extern "C" fn ml_patient_organ_summary(p: *const MLPatient, organ_code: u32) ML_ORGAN_KIDNEYS => OrganType::Kidneys, ML_ORGAN_BLADDER => OrganType::Bladder, ML_ORGAN_SPLEEN => OrganType::Spleen, + ML_ORGAN_BLOODSTREAM => OrganType::Bloodstream, _ => return ptr::null_mut(), }; let summary = unsafe { (*patient_ptr).organ_summary(kind) }; diff --git a/src/lib.rs b/src/lib.rs index 8b0cefd..e0f1bef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,7 +37,9 @@ pub mod ffi; pub use crate::ekg::{EkgLead, EkgMonitor, EkgSnapshot, HeartElectricalState}; pub use crate::error::MedicalError; -pub use crate::organs::{BreathingPhase, Heart, Lungs, Organ}; +pub use crate::organs::{ + Bloodstream, BreathingPhase, Heart, Lungs, MetabolicState, Organ, PerfusionState, +}; pub use crate::patient::Patient; pub use crate::types::{Blood, BloodPressure, OrganType}; @@ -160,5 +162,7 @@ mod tests { assert!(s.contains("Patient[id=case_01")); let heart = p.find_organ_typed::().unwrap(); assert_eq!(heart.organ_type(), OrganType::Heart); + let blood = p.find_organ_typed::().unwrap(); + assert_eq!(blood.organ_type(), OrganType::Bloodstream); } } diff --git a/src/organs/bloodstream.rs b/src/organs/bloodstream.rs new file mode 100644 index 0000000..ffcb8db --- /dev/null +++ b/src/organs/bloodstream.rs @@ -0,0 +1,537 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +const BASE_PLASMA_VOLUME_L: f32 = 3.0; +const BASE_RED_CELL_VOLUME_L: f32 = 2.1; +const BASE_TOTAL_VOLUME_L: f32 = BASE_PLASMA_VOLUME_L + BASE_RED_CELL_VOLUME_L; +const BASE_CARDIAC_OUTPUT_L_MIN: f32 = 5.0; +const BASE_MAP_MM_HG: f32 = 93.0; +const BASE_ARTERIAL_SPO2_PCT: f32 = 98.0; +const BASE_VENOUS_SPO2_PCT: f32 = 75.0; +const BASE_METABOLIC_DEMAND_ML_MIN: f32 = 250.0; +const BASE_WASTE_LOAD_MG: f32 = 1200.0; +const BASE_WASTE_PRODUCTION_MG_MIN: f32 = 850.0; +const BASE_PULMONARY_O2_TRANSFER_ML_MIN: f32 = 960.0; +const BASE_PULMONARY_CO2_CLEARANCE_ML_MIN: f32 = 180.0; +const BASE_RENAL_CLEARANCE_ML_MIN: f32 = 600.0; +const BASE_RENAL_UREA_CLEARANCE_MG_MIN: f32 = 550.0; +const BASE_HEPATIC_CLEARANCE_INDEX: f32 = 1.0; +const BASE_ALVEOLAR_PCO2_MMHG: f32 = 38.0; +const BASE_GLUCOSE_MG_DL: f32 = 95.0; +const BASE_VQ_RATIO: f32 = 0.96; +const BASE_TEMP_C: f32 = 37.0; +const BASE_LACTATE_MMOL_L: f32 = 1.2; +const BASE_PH: f32 = 7.40; +const BASE_EPO_IU_PER_DAY: f32 = 18.0; +const BASE_PLATELET_RESERVOIR: f32 = 70.0; +const BASE_RED_PULP_RESERVE_ML: f32 = 180.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Aggregate perfusion assessment for the bloodstream. +pub enum PerfusionState { + /// Adequate volume and oxygen delivery. + Balanced, + /// Maintaining delivery with compensatory responses. + Compensated, + /// Hypovolemic deficit or impaired delivery. + Hypovolemic, + /// Critically reduced perfusion. + Shock, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Balance between aerobic and anaerobic metabolism. +pub enum MetabolicState { + /// Aerobic metabolism predominates. + Aerobic, + /// Mix of aerobic and anaerobic contribution. + CompensatedAnaerobic, + /// Predominantly anaerobic metabolism. + AnaerobicCrisis, +} + +#[derive(Debug, Clone)] +/// Simplified blood transport compartment tracking gases, volume, and waste. +pub struct Bloodstream { + info: OrganInfo, + /// Total circulating blood volume (L). + pub total_volume_l: f32, + /// Plasma volume component (L). + pub plasma_volume_l: f32, + /// Packed red cell volume (L). + pub red_cell_volume_l: f32, + /// Cardiac output driving flow (L/min). + pub cardiac_output_l_min: f32, + /// Mean arterial pressure proxy (mmHg). + pub mean_arterial_pressure_mm_hg: f32, + /// Arterial oxygen saturation (%). + pub arterial_o2_saturation_pct: f32, + /// Venous oxygen saturation (%). + pub venous_o2_saturation_pct: f32, + /// Arterial oxygen content (mL O2/dL). + pub arterial_o2_content_ml_dl: f32, + /// Venous oxygen content (mL O2/dL). + pub venous_o2_content_ml_dl: f32, + /// Oxygen delivery (mL/min). + pub oxygen_delivery_ml_min: f32, + /// Oxygen consumption (mL/min). + pub oxygen_consumption_ml_min: f32, + /// Ratio between supply and demand for oxygen. + pub oxygen_supply_demand_ratio: f32, + /// Tissue oxygen extraction fraction. + pub tissue_o2_extraction_ratio: f32, + /// Circulation time for a full loop (s). + pub circulation_time_s: f32, + /// Dissolved CO2 content (mmHg proxy). + pub co2_content_ml_dl: f32, + /// Accumulated metabolic waste (mg). + pub waste_load_mg: f32, + /// Fraction of waste cleared per unit production. + pub waste_clearance_efficiency: f32, + /// Blood lactate (mmol/L). + pub lactate_mmol_l: f32, + /// Blood pH. + pub ph: f32, + /// Core blood temperature (?C). + pub temperature_c: f32, + /// Blood glucose snapshot (mg/dL). + pub glucose_mg_dl: f32, + /// Hemoglobin concentration (g/dL). + pub hemoglobin_g_dl: f32, + /// Hematocrit percentage (%). + pub hematocrit_pct: f32, + /// Renal clearance proxy (mL/min). + pub renal_clearance_ml_min: f32, + /// Hepatic detoxification index (relative). + pub hepatic_clearance_index: f32, + /// Current perfusion status. + pub perfusion_state: PerfusionState, + /// Current metabolic balance. + pub metabolic_state: MetabolicState, + ventilation_perfusion_ratio: f32, + // Signal-driven targets + cardiac_output_target_l_min: f32, + map_target_mm_hg: f32, + arterial_spo2_target_pct: f32, + alveolar_pco2_target_mm_hg: f32, + pulmonary_o2_transfer_target_ml_min: f32, + pulmonary_co2_clearance_target_ml_min: f32, + plasma_volume_target_l: f32, + red_cell_mass_target_l: f32, + metabolic_demand_target_ml_min: f32, + waste_production_target_mg_min: f32, + renal_clearance_target_ml_min: f32, + renal_urea_clearance_target_mg_min: f32, + hepatic_clearance_target_index: f32, + epo_signal_iu_per_day: f32, + spleen_platelet_signal: f32, + spleen_red_pulp_reserve_ml: f32, +} + +impl Bloodstream { + /// Construct a new bloodstream compartment. + pub fn new(id: impl Into) -> Self { + let plasma_volume_l = BASE_PLASMA_VOLUME_L; + let red_cell_volume_l = BASE_RED_CELL_VOLUME_L; + let total_volume_l = BASE_TOTAL_VOLUME_L; + let hematocrit_pct = (red_cell_volume_l / total_volume_l) * 100.0; + let hemoglobin_g_dl = (hematocrit_pct / 100.0) * 34.0; + let arterial_o2_content_ml_dl = + (hemoglobin_g_dl * 1.34 * (BASE_ARTERIAL_SPO2_PCT / 100.0)) + 0.0031 * 90.0; + let venous_o2_content_ml_dl = + (hemoglobin_g_dl * 1.34 * (BASE_VENOUS_SPO2_PCT / 100.0)) + 0.0031 * 45.0; + let oxygen_delivery_ml_min = BASE_CARDIAC_OUTPUT_L_MIN * arterial_o2_content_ml_dl * 10.0; + let oxygen_supply_demand_ratio = oxygen_delivery_ml_min / BASE_METABOLIC_DEMAND_ML_MIN; + let tissue_o2_extraction_ratio = + (BASE_METABOLIC_DEMAND_ML_MIN / oxygen_delivery_ml_min).clamp(0.2, 0.75); + let circulation_time_s = + (total_volume_l / BASE_CARDIAC_OUTPUT_L_MIN * 60.0).clamp(20.0, 140.0); + + Self { + info: OrganInfo::new(id, OrganType::Bloodstream), + total_volume_l, + plasma_volume_l, + red_cell_volume_l, + cardiac_output_l_min: BASE_CARDIAC_OUTPUT_L_MIN, + mean_arterial_pressure_mm_hg: BASE_MAP_MM_HG, + arterial_o2_saturation_pct: BASE_ARTERIAL_SPO2_PCT, + venous_o2_saturation_pct: BASE_VENOUS_SPO2_PCT, + arterial_o2_content_ml_dl, + venous_o2_content_ml_dl, + oxygen_delivery_ml_min, + oxygen_consumption_ml_min: BASE_METABOLIC_DEMAND_ML_MIN, + oxygen_supply_demand_ratio, + tissue_o2_extraction_ratio, + circulation_time_s, + co2_content_ml_dl: BASE_ALVEOLAR_PCO2_MMHG, + waste_load_mg: BASE_WASTE_LOAD_MG, + waste_clearance_efficiency: 0.82, + lactate_mmol_l: BASE_LACTATE_MMOL_L, + ph: BASE_PH, + temperature_c: BASE_TEMP_C, + glucose_mg_dl: BASE_GLUCOSE_MG_DL, + hemoglobin_g_dl, + hematocrit_pct, + renal_clearance_ml_min: BASE_RENAL_CLEARANCE_ML_MIN, + hepatic_clearance_index: BASE_HEPATIC_CLEARANCE_INDEX, + perfusion_state: PerfusionState::Balanced, + metabolic_state: MetabolicState::Aerobic, + ventilation_perfusion_ratio: BASE_VQ_RATIO, + cardiac_output_target_l_min: BASE_CARDIAC_OUTPUT_L_MIN, + map_target_mm_hg: BASE_MAP_MM_HG, + arterial_spo2_target_pct: BASE_ARTERIAL_SPO2_PCT, + alveolar_pco2_target_mm_hg: BASE_ALVEOLAR_PCO2_MMHG, + pulmonary_o2_transfer_target_ml_min: BASE_PULMONARY_O2_TRANSFER_ML_MIN, + pulmonary_co2_clearance_target_ml_min: BASE_PULMONARY_CO2_CLEARANCE_ML_MIN, + plasma_volume_target_l: BASE_PLASMA_VOLUME_L, + red_cell_mass_target_l: BASE_RED_CELL_VOLUME_L, + metabolic_demand_target_ml_min: BASE_METABOLIC_DEMAND_ML_MIN, + waste_production_target_mg_min: BASE_WASTE_PRODUCTION_MG_MIN, + renal_clearance_target_ml_min: BASE_RENAL_CLEARANCE_ML_MIN, + renal_urea_clearance_target_mg_min: BASE_RENAL_UREA_CLEARANCE_MG_MIN, + hepatic_clearance_target_index: BASE_HEPATIC_CLEARANCE_INDEX, + epo_signal_iu_per_day: BASE_EPO_IU_PER_DAY, + spleen_platelet_signal: BASE_PLATELET_RESERVOIR, + spleen_red_pulp_reserve_ml: BASE_RED_PULP_RESERVE_ML, + } + } + + /// Store upstream haemodynamic information from the heart. + pub fn set_hemodynamics(&mut self, cardiac_output_l_min: f32, map_mm_hg: f32) { + self.cardiac_output_target_l_min = cardiac_output_l_min.clamp(2.0, 12.0); + self.map_target_mm_hg = map_mm_hg.clamp(55.0, 130.0); + } + + /// Store pulmonary gas exchange signals. + pub fn set_respiratory_exchange( + &mut self, + arterial_spo2_pct: f32, + alveolar_pco2_mm_hg: f32, + oxygen_transfer_ml_min: f32, + co2_clearance_ml_min: f32, + ventilation_perfusion_ratio: f32, + ) { + self.arterial_spo2_target_pct = arterial_spo2_pct.clamp(60.0, 100.0); + self.alveolar_pco2_target_mm_hg = alveolar_pco2_mm_hg.clamp(25.0, 70.0); + self.pulmonary_o2_transfer_target_ml_min = oxygen_transfer_ml_min.clamp(200.0, 1800.0); + self.pulmonary_co2_clearance_target_ml_min = co2_clearance_ml_min.clamp(60.0, 600.0); + self.ventilation_perfusion_ratio = ventilation_perfusion_ratio.clamp(0.3, 1.6); + } + + /// Store renal-derived adjustments to plasma volume and clearance. + pub fn set_renal_feedback( + &mut self, + urine_flow_ml_min: f32, + plasma_volume_l: f32, + urea_excretion_mg_min: f32, + ) { + self.plasma_volume_target_l = plasma_volume_l.clamp(2.4, 3.8); + self.renal_clearance_target_ml_min = + (300.0 + urine_flow_ml_min * 250.0).clamp(250.0, 1000.0); + self.renal_urea_clearance_target_mg_min = urea_excretion_mg_min.clamp(200.0, 900.0); + } + + /// Store hepatic detoxification capacity signals. + pub fn set_hepatic_feedback(&mut self, detox_score: u8, ammonia_clearance_umol_min: f32) { + let detox = (detox_score as f32 / 100.0).clamp(0.2, 1.2); + let ammonia_factor = (ammonia_clearance_umol_min / 750.0).clamp(0.4, 1.6); + self.hepatic_clearance_target_index = (detox * 0.7 + ammonia_factor * 0.3).clamp(0.3, 1.3); + } + + /// Store nutrient availability to adjust metabolic demand. + pub fn set_metabolic_nutrients(&mut self, nutrient_energy_kcal: f32) { + let kcal = nutrient_energy_kcal.clamp(0.0, 1200.0); + self.metabolic_demand_target_ml_min = + (BASE_METABOLIC_DEMAND_ML_MIN + kcal * 0.35).clamp(200.0, 420.0); + self.waste_production_target_mg_min = + (BASE_WASTE_PRODUCTION_MG_MIN + kcal * 1.0).clamp(500.0, 2200.0); + } + + /// Store erythropoietin drive from the kidneys. + pub fn ingest_erythropoietin(&mut self, epo_iu_per_day: f32) { + self.epo_signal_iu_per_day = epo_iu_per_day.clamp(4.0, 120.0); + } + + /// Store splenic reservoir state for rapid hematocrit adjustments. + pub fn set_spleen_feedback(&mut self, platelet_reservoir: f32, red_pulp_volume_ml: f32) { + self.spleen_platelet_signal = platelet_reservoir.clamp(10.0, 200.0); + self.spleen_red_pulp_reserve_ml = red_pulp_volume_ml.clamp(40.0, 400.0); + } + + /// Override measured glucose value. + pub fn override_glucose(&mut self, glucose_mg_dl: f32) { + self.glucose_mg_dl = glucose_mg_dl.clamp(45.0, 300.0); + } + + fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 { + if dt_seconds <= 0.0 || rate_per_second <= 0.0 { + return current; + } + let delta = target - current; + let max_step = rate_per_second * dt_seconds; + if delta > max_step { + current + max_step + } else if delta < -max_step { + current - max_step + } else { + target + } + } + + fn update_red_cell_mass(&mut self, dt_seconds: f32) { + let epo_adjust = (self.epo_signal_iu_per_day - BASE_EPO_IU_PER_DAY) * 0.002; + let spleen_adjust = (self.spleen_red_pulp_reserve_ml - BASE_RED_PULP_RESERVE_ML) * 0.0004 + - (self.spleen_platelet_signal - BASE_PLATELET_RESERVOIR) * 0.0003; + let demand_adjust = (1.0 - self.oxygen_supply_demand_ratio).max(0.0) * 0.12; + let desired = + (BASE_RED_CELL_VOLUME_L + epo_adjust + spleen_adjust + demand_adjust).clamp(1.6, 2.8); + self.red_cell_mass_target_l = + Self::approach(self.red_cell_mass_target_l, desired, 0.0006, dt_seconds); + self.red_cell_volume_l = Self::approach( + self.red_cell_volume_l, + self.red_cell_mass_target_l, + 0.0012, + dt_seconds, + ); + } + + fn update_plasma_volume(&mut self, dt_seconds: f32) { + self.plasma_volume_l = Self::approach( + self.plasma_volume_l, + self.plasma_volume_target_l.clamp(2.4, 3.8), + 0.004, + dt_seconds, + ); + } + + fn compute_oxygen_content(&self) -> f32 { + let capacity = (self.hemoglobin_g_dl * 1.34).clamp(8.0, 26.0); + let ventilatory_factor = (self.pulmonary_o2_transfer_target_ml_min + / BASE_PULMONARY_O2_TRANSFER_ML_MIN) + .clamp(0.5, 1.6); + (capacity * (self.arterial_o2_saturation_pct / 100.0) * ventilatory_factor) + 0.0031 * 90.0 + } +} + +impl Organ for Bloodstream { + 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.update_red_cell_mass(dt_seconds); + self.update_plasma_volume(dt_seconds); + + self.total_volume_l = (self.plasma_volume_l + self.red_cell_volume_l).clamp(3.6, 6.5); + self.cardiac_output_l_min = Self::approach( + self.cardiac_output_l_min, + self.cardiac_output_target_l_min, + 0.25, + dt_seconds, + ); + self.mean_arterial_pressure_mm_hg = Self::approach( + self.mean_arterial_pressure_mm_hg, + self.map_target_mm_hg, + 0.5, + dt_seconds, + ); + self.arterial_o2_saturation_pct = Self::approach( + self.arterial_o2_saturation_pct, + self.arterial_spo2_target_pct, + 1.0, + dt_seconds, + ); + + self.hematocrit_pct = + ((self.red_cell_volume_l / self.total_volume_l) * 100.0).clamp(25.0, 55.0); + self.hemoglobin_g_dl = ((self.hematocrit_pct / 100.0) * 34.0).clamp(8.0, 19.5); + + self.arterial_o2_content_ml_dl = self.compute_oxygen_content(); + + let theoretical_delivery = + self.cardiac_output_l_min * self.arterial_o2_content_ml_dl * 10.0; + let pulmonary_limited = self.pulmonary_o2_transfer_target_ml_min * 1.05; + self.oxygen_delivery_ml_min = theoretical_delivery + .min(pulmonary_limited) + .clamp(150.0, 1900.0); + + self.oxygen_consumption_ml_min = Self::approach( + self.oxygen_consumption_ml_min, + self.metabolic_demand_target_ml_min, + 4.0, + dt_seconds, + ); + + self.oxygen_supply_demand_ratio = + (self.oxygen_delivery_ml_min / self.oxygen_consumption_ml_min.max(1.0)).clamp(0.4, 1.8); + self.tissue_o2_extraction_ratio = (1.0 / self.oxygen_supply_demand_ratio).clamp(0.2, 0.75); + + let venous_sat_target = (self.arterial_o2_saturation_pct / 100.0 + * (1.0 - self.tissue_o2_extraction_ratio * 0.82)) + .clamp(0.32, 0.9); + self.venous_o2_saturation_pct = Self::approach( + self.venous_o2_saturation_pct, + venous_sat_target * 100.0, + 0.7, + dt_seconds, + ); + let venous_capacity = (self.hemoglobin_g_dl * 1.34).clamp(8.0, 26.0); + self.venous_o2_content_ml_dl = + (venous_capacity * (self.venous_o2_saturation_pct / 100.0)) + 0.0031 * 45.0; + + self.circulation_time_s = + (self.total_volume_l / self.cardiac_output_l_min * 60.0).clamp(20.0, 150.0); + + let ventilation_factor = + (1.0 - (self.ventilation_perfusion_ratio - 1.0) * 0.4).clamp(0.5, 1.3); + let co2_target = (self.alveolar_pco2_target_mm_hg * ventilation_factor + + (self.metabolic_demand_target_ml_min - BASE_METABOLIC_DEMAND_ML_MIN) / 28.0) + .clamp(25.0, 60.0); + self.co2_content_ml_dl = + Self::approach(self.co2_content_ml_dl, co2_target, 0.6, dt_seconds); + + self.renal_clearance_ml_min = Self::approach( + self.renal_clearance_ml_min, + self.renal_clearance_target_ml_min, + 6.0, + dt_seconds, + ); + self.hepatic_clearance_index = Self::approach( + self.hepatic_clearance_index, + self.hepatic_clearance_target_index, + 0.4, + dt_seconds, + ); + + let renal_factor = (self.renal_urea_clearance_target_mg_min + / BASE_RENAL_UREA_CLEARANCE_MG_MIN) + .clamp(0.4, 1.6); + let clearance_total = self.renal_clearance_ml_min * 0.9 * renal_factor + + self.hepatic_clearance_index * 450.0 + + self.pulmonary_co2_clearance_target_ml_min * 0.5; + let waste_delta = + (self.waste_production_target_mg_min - clearance_total) * (dt_seconds / 60.0); + let waste_target = (self.waste_load_mg + waste_delta).clamp(300.0, 3200.0); + self.waste_load_mg = Self::approach(self.waste_load_mg, waste_target, 6.0, dt_seconds); + self.waste_clearance_efficiency = + (clearance_total / self.waste_production_target_mg_min.max(1.0)).clamp(0.2, 1.3); + + let anaerobic_pressure = (1.0 - self.oxygen_supply_demand_ratio).max(0.0); + let lactate_target = (BASE_LACTATE_MMOL_L + anaerobic_pressure * 6.0).clamp(0.6, 7.0); + self.lactate_mmol_l = Self::approach(self.lactate_mmol_l, lactate_target, 0.2, dt_seconds); + + let ph_target = (BASE_PH + - (self.co2_content_ml_dl - BASE_ALVEOLAR_PCO2_MMHG) * 0.015 + - (self.lactate_mmol_l - BASE_LACTATE_MMOL_L) * 0.05) + .clamp(7.1, 7.48); + self.ph = Self::approach(self.ph, ph_target, 0.05, dt_seconds); + + let temp_target = (BASE_TEMP_C + + (self.metabolic_demand_target_ml_min - BASE_METABOLIC_DEMAND_ML_MIN) / 450.0) + .clamp(36.0, 38.9); + self.temperature_c = Self::approach(self.temperature_c, temp_target, 0.02, dt_seconds); + + self.perfusion_state = if self.total_volume_l < 4.0 + || self.mean_arterial_pressure_mm_hg < 70.0 + || self.oxygen_supply_demand_ratio < 0.85 + { + if self.total_volume_l < 3.6 || self.oxygen_supply_demand_ratio < 0.7 { + PerfusionState::Shock + } else { + PerfusionState::Hypovolemic + } + } else if self.total_volume_l < 4.6 + || self.mean_arterial_pressure_mm_hg < 80.0 + || self.oxygen_supply_demand_ratio < 1.05 + { + PerfusionState::Compensated + } else { + PerfusionState::Balanced + }; + + self.metabolic_state = + if self.oxygen_supply_demand_ratio >= 1.05 && self.lactate_mmol_l <= 2.0 { + MetabolicState::Aerobic + } else if self.oxygen_supply_demand_ratio >= 0.85 && self.lactate_mmol_l <= 4.0 { + MetabolicState::CompensatedAnaerobic + } else { + MetabolicState::AnaerobicCrisis + }; + } + + fn summary(&self) -> String { + format!( + "Bloodstream[id={}, state={:?}/{:?}, vol={:.1}L, SaO2={:.0}%, DO2/VO2={:.2}, waste={:.0}mg]", + self.info.id(), + self.perfusion_state, + self.metabolic_state, + self.total_volume_l, + self.arterial_o2_saturation_pct, + self.oxygen_supply_demand_ratio, + self.waste_load_mg + ) + } + + 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::*; + + #[test] + fn baseline_stable() { + let mut blood = Bloodstream::new("baseline-blood"); + for _ in 0..900 { + blood.update(0.5); + } + assert!(matches!( + blood.perfusion_state, + PerfusionState::Balanced | PerfusionState::Compensated + )); + assert!((4.2..=5.4).contains(&blood.total_volume_l)); + assert!(matches!(blood.metabolic_state, MetabolicState::Aerobic)); + assert!((blood.hemoglobin_g_dl - 14.0).abs() < 1.5); + } + + #[test] + fn hypoxia_triggers_compensation() { + let mut blood = Bloodstream::new("hypoxia"); + blood.set_hemodynamics(3.6, 68.0); + blood.set_respiratory_exchange(72.0, 60.0, 320.0, 60.0, 0.45); + for _ in 0..600 { + blood.update(0.5); + } + assert!(blood.arterial_o2_saturation_pct < 90.0); + assert!(blood.oxygen_supply_demand_ratio < 1.05); + } + + #[test] + fn epo_increases_hematocrit_over_time() { + let mut blood = Bloodstream::new("epo"); + for _ in 0..600 { + blood.update(0.5); + } + let baseline_hct = blood.hematocrit_pct; + blood.ingest_erythropoietin(42.0); + blood.set_spleen_feedback(65.0, 220.0); + for _ in 0..7200 { + blood.update(0.25); + } + assert!(blood.hematocrit_pct > baseline_hct + 0.5); + } +} diff --git a/src/organs/mod.rs b/src/organs/mod.rs index eaca989..0e5dbd7 100644 --- a/src/organs/mod.rs +++ b/src/organs/mod.rs @@ -42,6 +42,7 @@ pub trait Organ: Debug + Send { } mod bladder; +mod bloodstream; mod brain; mod esophagus; mod gallbladder; @@ -56,6 +57,7 @@ mod spleen; mod stomach; pub use bladder::{Bladder, BladderPhase}; +pub use bloodstream::{Bloodstream, MetabolicState, PerfusionState}; pub use brain::{Brain, SleepStage}; pub use esophagus::{EsophagealStage, Esophagus}; pub use gallbladder::Gallbladder; diff --git a/src/patient.rs b/src/patient.rs index 2a34ceb..90fe164 100644 --- a/src/patient.rs +++ b/src/patient.rs @@ -3,8 +3,9 @@ use crate::ekg::{EkgLead, EkgMonitor, EkgSnapshot, HeartElectricalState}; use crate::error::MedicalError; use crate::organs::{ - Bladder, BladderPhase, Brain, EsophagealStage, Esophagus, Gallbladder, Heart, Intestines, - Kidneys, Liver, Lungs, Organ, Pancreas, SleepStage, SpinalCord, Spleen, Stomach, + Bladder, BladderPhase, Bloodstream, Brain, EsophagealStage, Esophagus, Gallbladder, Heart, + Intestines, Kidneys, Liver, Lungs, MetabolicState, Organ, Pancreas, PerfusionState, SleepStage, + SpinalCord, Spleen, Stomach, }; use crate::types::{Blood, BloodPressure, OrganType}; @@ -31,6 +32,9 @@ struct HeartSignals { struct LungSignals { spo2_pct: f32, alveolar_pco2_mm_hg: f32, + oxygen_delivery_ml_min: f32, + co2_elimination_ml_min: f32, + ventilation_perfusion_ratio: f32, } #[derive(Clone, Copy)] @@ -70,6 +74,8 @@ struct StomachSignals { #[derive(Clone, Copy)] struct LiverSignals { bile_secretion_ml_min: f32, + detox: u8, + ammonia_clearance_umol_min: f32, } #[derive(Clone, Copy)] @@ -100,6 +106,9 @@ struct SpinalSignals { #[derive(Clone, Copy)] struct KidneySignals { urine_flow_ml_min: f32, + plasma_volume_l: f32, + urea_excretion_mg_min: f32, + erythropoietin_iu_per_day: f32, } #[derive(Clone, Copy)] @@ -109,6 +118,29 @@ struct SpleenSignals { platelet_reservoir: f32, } +#[derive(Clone, Copy)] +struct BloodSignals { + total_volume_l: f32, + plasma_volume_l: f32, + cardiac_output_l_min: f32, + oxygen_delivery_ml_min: f32, + oxygen_consumption_ml_min: f32, + oxygen_supply_demand_ratio: f32, + venous_o2_saturation_pct: f32, + arterial_o2_saturation_pct: f32, + co2_content_ml_dl: f32, + waste_load_mg: f32, + waste_clearance_efficiency: f32, + lactate_mmol_l: f32, + ph: f32, + temperature_c: f32, + glucose_mg_dl: f32, + perfusion_state: PerfusionState, + metabolic_state: MetabolicState, + renal_clearance_ml_min: f32, + hepatic_clearance_index: f32, +} + impl Patient { /// Construct a new patient with validated id. pub fn new(id: impl Into) -> crate::Result { @@ -243,7 +275,7 @@ impl Patient { /// Initialize a patient with a 12-lead heart. pub fn initialize_default(self) -> Self { - self.with_heart(12) + self.with_heart(12).with_bloodstream() } /// Initialize a patient with a heart with leads. @@ -253,6 +285,13 @@ impl Patient { self } + /// Attach a systemic bloodstream model. + pub fn with_bloodstream(mut self) -> Self { + let id = format!("{}-bloodstream", self.id); + self.add_organ(Bloodstream::new(id)); + self + } + /// Attach default lungs. pub fn with_lungs(mut self) -> Self { let id = format!("{}-lungs", self.id); @@ -267,6 +306,10 @@ impl Patient { let id = format!("{}-heart", self.id); self.add_organ(Heart::new(id, 12)); } + OrganType::Bloodstream => { + let id = format!("{}-bloodstream", self.id); + self.add_organ(Bloodstream::new(id)); + } OrganType::Lungs => { let id = format!("{}-lungs", self.id); self.add_organ(Lungs::new(id)); @@ -337,6 +380,9 @@ impl Patient { let lungs_signals = self.find_organ_typed::().map(|l| LungSignals { spo2_pct: l.spo2_pct, alveolar_pco2_mm_hg: l.alveolar_pco2_mm_hg, + oxygen_delivery_ml_min: l.oxygen_delivery_ml_min, + co2_elimination_ml_min: l.co2_elimination_ml_min, + ventilation_perfusion_ratio: l.ventilation_perfusion_ratio, }); let stomach_signals = self.find_organ_typed::().map(|s| StomachSignals { @@ -349,6 +395,8 @@ impl Patient { let liver_signals = self.find_organ_typed::().map(|l| LiverSignals { bile_secretion_ml_min: l.bile_secretion_ml_min, + detox: l.detox, + ammonia_clearance_umol_min: l.ammonia_clearance_umol_min, }); let intestine_signals = self @@ -375,6 +423,9 @@ impl Patient { let kidney_signals = self.find_organ_typed::().map(|k| KidneySignals { urine_flow_ml_min: k.urine_flow_ml_min, + plasma_volume_l: k.plasma_volume_l, + urea_excretion_mg_min: k.urea_excretion_mg_min, + erythropoietin_iu_per_day: k.erythropoietin_iu_per_day, }); let spinal_signals = self @@ -385,6 +436,436 @@ impl Patient { reflex_gain: s.reflex_gain, }); + let spleen_signals = self.find_organ_typed::().map(|s| SpleenSignals { + immune_activity: s.immune_activity, + red_pulp_volume_ml: s.red_pulp_volume_ml, + platelet_reservoir: s.platelet_reservoir, + }); + + if let Some(bloodstream) = self.find_organ_typed_mut::() { + if let Some(heart) = heart_signals { + bloodstream.set_hemodynamics(heart.cardiac_output, heart.map); + } + if let Some(lungs) = lungs_signals { + bloodstream.set_respiratory_exchange( + lungs.spo2_pct, + lungs.alveolar_pco2_mm_hg, + lungs.oxygen_delivery_ml_min, + lungs.co2_elimination_ml_min, + lungs.ventilation_perfusion_ratio, + ); + } + if let Some(kidney) = kidney_signals { + bloodstream.set_renal_feedback( + kidney.urine_flow_ml_min, + kidney.plasma_volume_l, + kidney.urea_excretion_mg_min, + ); + bloodstream.ingest_erythropoietin(kidney.erythropoietin_iu_per_day); + } + if let Some(liver) = liver_signals { + bloodstream.set_hepatic_feedback(liver.detox, liver.ammonia_clearance_umol_min); + } + if let Some(intestines) = intestine_signals { + bloodstream.set_metabolic_nutrients(intestines.nutrient_energy_kcal); + } + if let Some(spleen) = spleen_signals { + bloodstream + .set_spleen_feedback(spleen.platelet_reservoir, spleen.red_pulp_volume_ml); + } + } + + let bloodstream_signals = self + .find_organ_typed::() + .map(|b| BloodSignals { + total_volume_l: b.total_volume_l, + plasma_volume_l: b.plasma_volume_l, + cardiac_output_l_min: b.cardiac_output_l_min, + oxygen_delivery_ml_min: b.oxygen_delivery_ml_min, + oxygen_consumption_ml_min: b.oxygen_consumption_ml_min, + oxygen_supply_demand_ratio: b.oxygen_supply_demand_ratio, + venous_o2_saturation_pct: b.venous_o2_saturation_pct, + arterial_o2_saturation_pct: b.arterial_o2_saturation_pct, + co2_content_ml_dl: b.co2_content_ml_dl, + waste_load_mg: b.waste_load_mg, + waste_clearance_efficiency: b.waste_clearance_efficiency, + lactate_mmol_l: b.lactate_mmol_l, + ph: b.ph, + temperature_c: b.temperature_c, + glucose_mg_dl: b.glucose_mg_dl, + perfusion_state: b.perfusion_state, + metabolic_state: b.metabolic_state, + renal_clearance_ml_min: b.renal_clearance_ml_min, + hepatic_clearance_index: b.hepatic_clearance_index, + }); + + if let Some(blood) = bloodstream_signals { + if let Some(heart) = self.find_organ_typed_mut::() { + let preload_target = ((blood.total_volume_l - 5.0) * 6.0 + 8.0).clamp(4.0, 18.0); + heart.preload_mm_hg = + Self::relax_value(heart.preload_mm_hg, preload_target, dt_seconds, 25.0); + + let venous_target = ((blood.cardiac_output_l_min + heart.cardiac_output_l_min) + * 0.5) + .clamp(2.5, 8.5); + heart.venous_return_l_min = + Self::relax_value(heart.venous_return_l_min, venous_target, dt_seconds, 20.0); + + let svr_target = match blood.perfusion_state { + PerfusionState::Balanced => 18.2, + PerfusionState::Compensated => 19.6, + PerfusionState::Hypovolemic => 21.4, + PerfusionState::Shock => 23.0, + }; + heart.systemic_vascular_resistance = Self::relax_value( + heart.systemic_vascular_resistance, + svr_target, + dt_seconds, + 18.0, + ); + } + + if let Some(lungs) = self.find_organ_typed_mut::() { + lungs.distress = matches!( + blood.perfusion_state, + PerfusionState::Hypovolemic | PerfusionState::Shock + ) || blood.oxygen_supply_demand_ratio < 0.85; + + let ventilation_target = + (blood.oxygen_consumption_ml_min / 25.0 + 4.8).clamp(5.0, 22.0); + lungs.minute_ventilation_l_min = Self::relax_value( + lungs.minute_ventilation_l_min, + ventilation_target, + dt_seconds, + 40.0, + ); + + let o2_delivery_target = blood.oxygen_delivery_ml_min.clamp(250.0, 1800.0); + lungs.oxygen_delivery_ml_min = Self::relax_value( + lungs.oxygen_delivery_ml_min, + o2_delivery_target, + dt_seconds, + 45.0, + ); + + let co2_elim_target = (blood.co2_content_ml_dl * 14.0).clamp(60.0, 520.0); + lungs.co2_elimination_ml_min = Self::relax_value( + lungs.co2_elimination_ml_min, + co2_elim_target, + dt_seconds, + 45.0, + ); + + lungs.spo2_pct = Self::relax_value( + lungs.spo2_pct, + blood.arterial_o2_saturation_pct, + dt_seconds, + 30.0, + ); + } + + if let Some(brain) = self.find_organ_typed_mut::() { + let cbf_target = + (52.0 * blood.oxygen_supply_demand_ratio.clamp(0.6, 1.4)).clamp(28.0, 75.0); + brain.cerebral_blood_flow_ml_per_100g_min = Self::relax_value( + brain.cerebral_blood_flow_ml_per_100g_min, + cbf_target, + dt_seconds, + 30.0, + ); + + let oxygen_target = (blood.venous_o2_saturation_pct / 100.0).clamp(0.35, 0.98); + brain.oxygenation_saturation = Self::relax_value( + brain.oxygenation_saturation, + oxygen_target, + dt_seconds, + 30.0, + ); + + let consciousness_target = match blood.perfusion_state { + PerfusionState::Balanced => 96.0, + PerfusionState::Compensated => 92.0, + PerfusionState::Hypovolemic => 84.0, + PerfusionState::Shock => 72.0, + }; + let new_consciousness = Self::relax_value( + brain.consciousness as f32, + consciousness_target, + dt_seconds, + 12.0, + ) + .clamp(40.0, 100.0); + brain.consciousness = new_consciousness.round() as u8; + + let metabolic_target = match blood.metabolic_state { + MetabolicState::Aerobic => 0.98, + MetabolicState::CompensatedAnaerobic => 1.05, + MetabolicState::AnaerobicCrisis => 1.12, + }; + brain.metabolic_demand_fraction = Self::relax_value( + brain.metabolic_demand_fraction, + metabolic_target, + dt_seconds, + 20.0, + ); + } + + if let Some(spinal) = self.find_organ_typed_mut::() { + let sympathetic_target = match blood.perfusion_state { + PerfusionState::Balanced => 0.58, + PerfusionState::Compensated => 0.66, + PerfusionState::Hypovolemic => 0.76, + PerfusionState::Shock => 0.84, + }; + spinal.sympathetic_outflow = Self::relax_value( + spinal.sympathetic_outflow, + sympathetic_target, + dt_seconds, + 35.0, + ); + + let parasymp_target = (0.55 - (sympathetic_target - 0.58) * 0.6).clamp(0.2, 0.65); + spinal.parasympathetic_outflow = Self::relax_value( + spinal.parasympathetic_outflow, + parasymp_target, + dt_seconds, + 30.0, + ); + + let inflammation_target = + ((blood.waste_load_mg - 1200.0) / 3200.0).clamp(0.05, 0.6); + spinal.inflammation_index = Self::relax_value( + spinal.inflammation_index, + inflammation_target, + dt_seconds, + 90.0, + ); + } + + if let Some(kidneys) = self.find_organ_typed_mut::() { + let acid_target = ((7.4 - blood.ph) * 5.5).clamp(-1.0, 1.0); + kidneys.acid_base_balance = + Self::relax_value(kidneys.acid_base_balance, acid_target, dt_seconds, 120.0); + + let sympathetic_target = match blood.perfusion_state { + PerfusionState::Balanced => 0.4, + PerfusionState::Compensated => 0.52, + PerfusionState::Hypovolemic => 0.66, + PerfusionState::Shock => 0.78, + }; + kidneys.sympathetic_tone = Self::relax_value( + kidneys.sympathetic_tone, + sympathetic_target, + dt_seconds, + 120.0, + ); + + kidneys.plasma_volume_l = Self::relax_value( + kidneys.plasma_volume_l, + blood.plasma_volume_l.clamp(2.4, 3.8), + dt_seconds, + 200.0, + ); + + kidneys.renal_plasma_flow_ml_min = Self::relax_value( + kidneys.renal_plasma_flow_ml_min, + blood.renal_clearance_ml_min.clamp(300.0, 950.0), + dt_seconds, + 160.0, + ); + + let urea_target = (blood.waste_clearance_efficiency * 550.0).clamp(200.0, 900.0); + kidneys.urea_excretion_mg_min = Self::relax_value( + kidneys.urea_excretion_mg_min, + urea_target, + dt_seconds, + 160.0, + ); + } + + if let Some(liver) = self.find_organ_typed_mut::() { + let oxidative_target = + ((blood.waste_load_mg - 1200.0) / 2200.0 + 0.2).clamp(0.1, 0.9); + liver.oxidative_stress_index = Self::relax_value( + liver.oxidative_stress_index, + oxidative_target, + dt_seconds, + 100.0, + ); + + let detox_target = (blood.hepatic_clearance_index * 100.0).clamp(40.0, 100.0); + let new_detox = + Self::relax_value(liver.detox as f32, detox_target, dt_seconds, 150.0) + .clamp(0.0, 100.0); + liver.detox = new_detox.round() as u8; + + let acute_target = + (blood.lactate_mmol_l / 6.0 + blood.waste_load_mg / 3200.0).clamp(0.05, 1.0); + liver.acute_phase_response = + Self::relax_value(liver.acute_phase_response, acute_target, dt_seconds, 130.0); + + let flow_target = (blood.cardiac_output_l_min * 0.22).clamp(0.8, 1.8); + liver.hepatic_blood_flow_l_min = Self::relax_value( + liver.hepatic_blood_flow_l_min, + flow_target, + dt_seconds, + 150.0, + ); + } + + if let Some(pancreas) = self.find_organ_typed_mut::() { + let stress_target = ((1.0 - blood.oxygen_supply_demand_ratio).abs() * 0.6 + + (blood.glucose_mg_dl - 95.0).abs() / 180.0) + .clamp(0.1, 1.0); + pancreas.islet_stress_index = Self::relax_value( + pancreas.islet_stress_index, + stress_target, + dt_seconds, + 120.0, + ); + + let bicarbonate_target: f32 = match blood.metabolic_state { + MetabolicState::Aerobic => 1.8, + MetabolicState::CompensatedAnaerobic => 2.1, + MetabolicState::AnaerobicCrisis => 2.4, + }; + pancreas.bicarbonate_output_mmol_min = Self::relax_value( + pancreas.bicarbonate_output_mmol_min, + bicarbonate_target.clamp(1.2, 3.0), + dt_seconds, + 140.0, + ); + } + + if let Some(stomach) = self.find_organ_typed_mut::() { + let perf_penalty = (1.0 - blood.oxygen_supply_demand_ratio).max(0.0); + let motility_target = (0.45 - perf_penalty * 0.25).clamp(0.1, 0.8); + stomach.motility_index = + Self::relax_value(stomach.motility_index, motility_target, dt_seconds, 70.0); + + let ghrelin_target = + (950.0 - (blood.glucose_mg_dl - 95.0) * 2.0).clamp(450.0, 1500.0); + stomach.ghrelin = + Self::relax_value(stomach.ghrelin, ghrelin_target, dt_seconds, 180.0); + + let mucus_target = (15.0 + (blood.temperature_c - 37.0) * 1.5).clamp(10.0, 24.0); + stomach.mucus_production_g_per_h = Self::relax_value( + stomach.mucus_production_g_per_h, + mucus_target, + dt_seconds, + 200.0, + ); + } + + if let Some(intestines) = self.find_organ_typed_mut::() { + let water_target = (12.0 - (blood.total_volume_l - 5.0) * 1.2).clamp(6.0, 18.0); + intestines.water_reabsorption_ml_min = Self::relax_value( + intestines.water_reabsorption_ml_min, + water_target, + dt_seconds, + 140.0, + ); + + let mmc_target = match blood.metabolic_state { + MetabolicState::Aerobic => 0.3, + MetabolicState::CompensatedAnaerobic => 0.36, + MetabolicState::AnaerobicCrisis => 0.44, + }; + intestines.mmc_activity = + Self::relax_value(intestines.mmc_activity, mmc_target, dt_seconds, 130.0); + + let microbiome_target = + (intestines.microbiome_balance + (blood.ph - 7.35) * 0.1).clamp(0.3, 0.9); + intestines.microbiome_balance = Self::relax_value( + intestines.microbiome_balance, + microbiome_target, + dt_seconds, + 240.0, + ); + } + + if let Some(gallbladder) = self.find_organ_typed_mut::() { + let vagal_target = match blood.metabolic_state { + MetabolicState::Aerobic => 0.6, + MetabolicState::CompensatedAnaerobic => 0.54, + MetabolicState::AnaerobicCrisis => 0.46, + }; + gallbladder.vagal_tone = + Self::relax_value(gallbladder.vagal_tone, vagal_target, dt_seconds, 150.0); + + let absorption_target = + (0.03 + (blood.waste_clearance_efficiency - 0.8) * 0.01).clamp(0.01, 0.05); + gallbladder.mucosal_absorption_fraction = Self::relax_value( + gallbladder.mucosal_absorption_fraction, + absorption_target, + dt_seconds, + 200.0, + ); + } + + if let Some(esophagus) = self.find_organ_typed_mut::() { + let sphincter_target = (0.78 + (blood.ph - 7.35) * 0.2).clamp(0.4, 0.95); + esophagus.lower_sphincter_tone = Self::relax_value( + esophagus.lower_sphincter_tone, + sphincter_target, + dt_seconds, + 100.0, + ); + + let vagal_target = if matches!(blood.perfusion_state, PerfusionState::Shock) { + 0.42 + } else { + 0.65 + }; + esophagus.vagal_tone = + Self::relax_value(esophagus.vagal_tone, vagal_target, dt_seconds, 120.0); + } + + if let Some(bladder) = self.find_organ_typed_mut::() { + let sympathetic_target = match blood.perfusion_state { + PerfusionState::Balanced => 0.78, + PerfusionState::Compensated => 0.82, + PerfusionState::Hypovolemic => 0.87, + PerfusionState::Shock => 0.9, + }; + bladder.sympathetic_drive = Self::relax_value( + bladder.sympathetic_drive, + sympathetic_target, + dt_seconds, + 160.0, + ); + + let parasymp_target = (0.08 + + (blood.oxygen_supply_demand_ratio - 1.0).max(0.0) * 0.25) + .clamp(0.05, 0.35); + bladder.parasympathetic_drive = Self::relax_value( + bladder.parasympathetic_drive, + parasymp_target, + dt_seconds, + 160.0, + ); + } + + if let Some(spleen) = self.find_organ_typed_mut::() { + let contraction_target = match blood.perfusion_state { + PerfusionState::Balanced => 0.3, + PerfusionState::Compensated => 0.48, + PerfusionState::Hypovolemic => 0.68, + PerfusionState::Shock => 0.82, + }; + spleen.contraction_fraction = Self::relax_value( + spleen.contraction_fraction, + contraction_target, + dt_seconds, + 100.0, + ); + + let tone_target = (contraction_target + 0.25).clamp(0.2, 0.95); + spleen.sympathetic_tone = + Self::relax_value(spleen.sympathetic_tone, tone_target, dt_seconds, 140.0); + } + } + let blood_glucose = self.blood.glucose_mg_dl; if let Some(pancreas) = self.find_organ_typed_mut::() { pancreas.blood_glucose_mg_dl = blood_glucose; @@ -514,7 +995,17 @@ impl Patient { self.blood_pressure = *arterial_bp; } - if let Some(lungs) = self.find_organ_typed::() { + if let Some((spo2, hgb, hct)) = self.find_organ_typed::().map(|b| { + ( + b.arterial_o2_saturation_pct, + b.hemoglobin_g_dl, + b.hematocrit_pct, + ) + }) { + self.blood.spo2_pct = spo2; + self.blood.hemoglobin_g_dl = hgb; + self.blood.hematocrit_pct = hct; + } else if let Some(lungs) = self.find_organ_typed::() { self.blood.spo2_pct = lungs.spo2_pct; } @@ -539,19 +1030,26 @@ impl Patient { } glucose = glucose.clamp(60.0, 220.0); self.blood.glucose_mg_dl = glucose; + if let Some(bloodstream) = self.find_organ_typed_mut::() { + bloodstream.override_glucose(glucose); + } let kidneys_after = self .find_organ_typed::() .map(|k| (k.erythropoietin_iu_per_day, k.urine_flow_ml_min)); if let Some((epo, _)) = kidneys_after { - let hgb_target = 14.0 + (epo - 18.0) / 80.0; - self.blood.hemoglobin_g_dl = Self::relax_value( - self.blood.hemoglobin_g_dl, - hgb_target.clamp(9.0, 18.0), - dt_seconds, - 600.0, - ); + if let Some(bloodstream) = self.find_organ_typed_mut::() { + bloodstream.ingest_erythropoietin(epo); + } else { + let hgb_target = 14.0 + (epo - 18.0) / 80.0; + self.blood.hemoglobin_g_dl = Self::relax_value( + self.blood.hemoglobin_g_dl, + hgb_target.clamp(9.0, 18.0), + dt_seconds, + 600.0, + ); + } } let spleen_after = self.find_organ_typed::().map(|s| SpleenSignals { @@ -561,17 +1059,22 @@ impl Patient { }); if let Some(spleen) = spleen_after { - let immune_penalty = (spleen.immune_activity as f32 - 80.0) / 120.0; - let hematocrit_target = 42.0 - - (spleen.red_pulp_volume_ml - 180.0) / 8.0 - - (spleen.platelet_reservoir - 70.0) / 30.0 - + immune_penalty * 2.0; - self.blood.hematocrit_pct = Self::relax_value( - self.blood.hematocrit_pct, - hematocrit_target.clamp(30.0, 55.0), - dt_seconds, - 600.0, - ); + if let Some(bloodstream) = self.find_organ_typed_mut::() { + bloodstream + .set_spleen_feedback(spleen.platelet_reservoir, spleen.red_pulp_volume_ml); + } else { + let immune_penalty = (spleen.immune_activity as f32 - 80.0) / 120.0; + let hematocrit_target: f32 = 42.0 + - (spleen.red_pulp_volume_ml - 180.0) / 8.0 + - (spleen.platelet_reservoir - 70.0) / 30.0 + + immune_penalty * 2.0; + self.blood.hematocrit_pct = Self::relax_value( + self.blood.hematocrit_pct, + hematocrit_target.clamp(30.0, 55.0), + dt_seconds, + 600.0, + ); + } } let blood_glucose_for_pancreas = self.blood.glucose_mg_dl; @@ -886,6 +1389,7 @@ fn is_valid_id(id: &str) -> bool { mod tests { use super::*; use crate::types::OrganType; + use crate::{Bloodstream, MetabolicState, PerfusionState}; #[test] fn patient_lifecycle() { @@ -909,6 +1413,69 @@ mod tests { assert!(Patient::new("bad id").is_err()); } + #[test] + fn bloodstream_couples_organs() { + let mut patient = Patient::new("blood-link") + .unwrap() + .initialize_default() + .with_lungs(); + for organ in [OrganType::Brain, OrganType::Liver, OrganType::SpinalCord] { + patient = patient.with_organ(organ); + } + for _ in 0..10 { + patient.update(0.5); + } + + let initial_preload = patient + .find_organ_typed::() + .unwrap() + .preload_mm_hg; + let initial_consciousness = patient + .find_organ_typed::() + .unwrap() + .consciousness; + let initial_kupffer = patient + .find_organ_typed::() + .unwrap() + .kupffer_activation; + + { + let blood = patient.find_organ_typed_mut::().unwrap(); + blood.perfusion_state = PerfusionState::Shock; + blood.metabolic_state = MetabolicState::AnaerobicCrisis; + blood.total_volume_l = 3.8; + blood.oxygen_supply_demand_ratio = 0.7; + blood.oxygen_consumption_ml_min = 320.0; + blood.oxygen_delivery_ml_min = 280.0; + blood.waste_load_mg = 2600.0; + blood.lactate_mmol_l = 4.8; + blood.ph = 7.2; + } + + patient.update(1.0); + + let heart = patient.find_organ_typed::().unwrap(); + assert!( + (heart.preload_mm_hg - initial_preload).abs() > 0.01, + "preload unchanged: initial {initial_preload}, after {}", + heart.preload_mm_hg + ); + + let brain = patient.find_organ_typed::().unwrap(); + assert!( + brain.consciousness as f32 <= initial_consciousness as f32, + "consciousness failed to drop: initial {initial_consciousness}, after {}", + brain.consciousness + ); + + let liver = patient.find_organ_typed::().unwrap(); + assert!( + liver.kupffer_activation >= initial_kupffer, + "kupffer activation did not increase: initial {initial_kupffer}, after {}", + liver.kupffer_activation + ); + } + #[test] fn multi_organ_homeostasis_stability() { use crate::organs::{ diff --git a/src/types.rs b/src/types.rs index 09bd96f..b8c43c1 100644 --- a/src/types.rs +++ b/src/types.rs @@ -10,6 +10,8 @@ pub enum OrganType { Heart, /// Lungs Lungs, + /// Circulating blood volume and transport network + Bloodstream, /// Brain Brain, /// Spinal cord diff --git a/tests/patient.rs b/tests/patient.rs index 63dbfe7..a8e58d8 100644 --- a/tests/patient.rs +++ b/tests/patient.rs @@ -6,4 +6,8 @@ fn default_patient_heart() { p.update(0.1); let s = p.patient_summary(); assert!(s.contains("Heart")); + assert!(p + .organ_summary(medicallib_rust::OrganType::Bloodstream) + .unwrap() + .contains("Bloodstream")); }