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.
This commit is contained in:
2025-09-28 16:10:23 -07:00
parent bf1e547a8c
commit 5cf6bbda48
9 changed files with 1151 additions and 24 deletions
+3 -1
View File
@@ -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",
+5
View File
@@ -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`.
*/
+4
View File
@@ -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) };
+5 -1
View File
@@ -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::<Heart>().unwrap();
assert_eq!(heart.organ_type(), OrganType::Heart);
let blood = p.find_organ_typed::<Bloodstream>().unwrap();
assert_eq!(blood.organ_type(), OrganType::Bloodstream);
}
}
+537
View File
@@ -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<String>) -> 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);
}
}
+2
View File
@@ -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;
+589 -22
View File
@@ -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<String>) -> crate::Result<Self> {
@@ -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::<Lungs>().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::<Stomach>().map(|s| StomachSignals {
@@ -349,6 +395,8 @@ impl Patient {
let liver_signals = self.find_organ_typed::<Liver>().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::<Kidneys>().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::<Spleen>().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::<Bloodstream>() {
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::<Bloodstream>()
.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::<Heart>() {
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>() {
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::<Brain>() {
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::<SpinalCord>() {
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::<Kidneys>() {
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::<Liver>() {
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::<Pancreas>() {
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::<Stomach>() {
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::<Intestines>() {
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::<Gallbladder>() {
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::<Esophagus>() {
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::<Bladder>() {
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::<Spleen>() {
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>() {
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::<Lungs>() {
if let Some((spo2, hgb, hct)) = self.find_organ_typed::<Bloodstream>().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::<Lungs>() {
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>() {
bloodstream.override_glucose(glucose);
}
let kidneys_after = self
.find_organ_typed::<Kidneys>()
.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>() {
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::<Spleen>().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>() {
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::<crate::organs::Heart>()
.unwrap()
.preload_mm_hg;
let initial_consciousness = patient
.find_organ_typed::<crate::organs::Brain>()
.unwrap()
.consciousness;
let initial_kupffer = patient
.find_organ_typed::<crate::organs::Liver>()
.unwrap()
.kupffer_activation;
{
let blood = patient.find_organ_typed_mut::<Bloodstream>().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::<crate::organs::Heart>().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::<crate::organs::Brain>().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::<crate::organs::Liver>().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::{
+2
View File
@@ -10,6 +10,8 @@ pub enum OrganType {
Heart,
/// Lungs
Lungs,
/// Circulating blood volume and transport network
Bloodstream,
/// Brain
Brain,
/// Spinal cord
+4
View File
@@ -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"));
}