feat(patient): integrate brain/bladder/esophagus/stomach coupling

Introduce richer neuro-visceral coupling and signal integration across
organs to improve physiological realism:
- Add Brain, Esophagus, Bladder, Stomach, Spinal signal structs
- Couple bladder with spinal autonomics and brain state to drive
  parasympathetic/sympathetic/somatic outputs and thresholds
- Deliver esophageal bolus into stomach; update hiatal pressure and LES
  tone from stomach distension/acid/motility
- Move stomach emptying into intestines within patient update loop
- Replace spleen state penalty with immune activity-based adjustment
- Simplify gallbladder control using intestinal nutrient energy
- Refine heart autonomic tone using brain/spinal outputs
- Re-export BladderPhase, SleepStage, EsophagealStage

No breaking API changes.
This commit is contained in:
2025-09-24 02:01:53 -07:00
parent f439894864
commit d849f71127
2 changed files with 269 additions and 75 deletions
+3 -3
View File
@@ -55,9 +55,9 @@ mod spinal_cord;
mod spleen;
mod stomach;
pub use bladder::Bladder;
pub use brain::Brain;
pub use esophagus::Esophagus;
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 intestines::Intestines;
+266 -72
View File
@@ -2,10 +2,10 @@
use crate::error::MedicalError;
use crate::organs::{
Bladder, Brain, Gallbladder, Heart, IntestinalPhase, Intestines, Kidneys, Liver, Lungs, Organ,
Pancreas, SpinalCord, Spleen, SplenicState, Stomach,
Bladder, BladderPhase, Brain, EsophagealStage, Esophagus, Gallbladder, Heart, Intestines,
Kidneys, Liver, Lungs, Organ, Pancreas, SleepStage, SpinalCord, Spleen, Stomach,
};
use crate::organs::intestines::IntestinalPhase;\r\nuse crate::organs::spleen::SplenicState;\r\n\r\nuse crate::types::{Blood, BloodPressure, OrganType};
use crate::types::{Blood, BloodPressure, OrganType};
/// Patient container and simulation entry.
#[derive(Debug)]
@@ -20,21 +20,48 @@ pub struct Patient {
#[derive(Clone, Copy)]
struct HeartSignals {
systolic: f32,
diastolic: f32,
map: f32,
cardiac_output: f32,
heart_rate: f32,
}
#[derive(Clone, Copy)]
struct LungSignals {
spo2_pct: f32,
alveolar_po2_mm_hg: f32,
alveolar_pco2_mm_hg: f32,
minute_ventilation_l_min: f32,
oxygen_delivery_ml_min: f32,
shunt_fraction: f32,
}
#[derive(Clone, Copy)]
struct BrainSignals {
brainstem_drive: f32,
autonomic_variability: f32,
consciousness: f32,
sleep_depth: f32,
rem_tone: f32,
}
#[derive(Clone, Copy)]
struct EsophagusSignals {
stage: EsophagealStage,
bolus_volume_ml: f32,
peristaltic_progress_cm: f32,
lower_sphincter_tone: f32,
hiatal_pressure_cm_h2o: f32,
}
#[derive(Clone, Copy)]
struct BladderSignals {
afferent_signal: f32,
urgency: f32,
phase: BladderPhase,
}
#[derive(Clone, Copy)]
struct StomachSignals {
emptying_rate_ml_min: f32,
nutrient_load_kcal: f32,
volume_ml: f32,
acid_level: u8,
motility_index: f32,
}
#[derive(Clone, Copy)]
@@ -52,7 +79,6 @@ struct PancreasSignals {
#[derive(Clone, Copy)]
struct IntestineSignals {
phase: IntestinalPhase,
nutrient_energy_kcal: f32,
}
@@ -65,6 +91,7 @@ struct GallbladderSignals {
struct SpinalSignals {
sympathetic_outflow: f32,
parasympathetic_outflow: f32,
reflex_gain: f32,
}
#[derive(Clone, Copy)]
@@ -77,7 +104,6 @@ struct SpleenSignals {
immune_activity: u8,
red_pulp_volume_ml: f32,
platelet_reservoir: f32,
state: SplenicState,
}
impl Patient {
@@ -191,7 +217,7 @@ impl Patient {
}
OrganType::Esophagus => {
let id = format!("{}-eso", self.id);
self.add_organ(crate::organs::Esophagus::new(id));
self.add_organ(Esophagus::new(id));
}
OrganType::Kidneys => {
let id = format!("{}-kidneys", self.id);
@@ -219,21 +245,22 @@ impl Patient {
let systolic = h.arterial_bp.systolic as f32;
let diastolic = h.arterial_bp.diastolic as f32;
HeartSignals {
systolic,
diastolic,
map: diastolic + (systolic - diastolic) / 3.0,
cardiac_output: h.cardiac_output_l_min,
heart_rate: h.heart_rate_bpm,
}
});
let lungs_signals = self.find_organ_typed::<Lungs>().map(|l| LungSignals {
spo2_pct: l.spo2_pct,
alveolar_po2_mm_hg: l.alveolar_po2_mm_hg,
alveolar_pco2_mm_hg: l.alveolar_pco2_mm_hg,
minute_ventilation_l_min: l.minute_ventilation_l_min,
oxygen_delivery_ml_min: l.oxygen_delivery_ml_min,
shunt_fraction: l.shunt_fraction,
});
let stomach_signals = self.find_organ_typed::<Stomach>().map(|s| StomachSignals {
emptying_rate_ml_min: s.emptying_rate_ml_min,
nutrient_load_kcal: s.nutrient_load_kcal,
volume_ml: s.volume_ml,
acid_level: s.acid_level,
motility_index: s.motility_index,
});
let liver_signals = self.find_organ_typed::<Liver>().map(|l| LiverSignals {
@@ -243,7 +270,6 @@ impl Patient {
let intestine_signals = self
.find_organ_typed::<Intestines>()
.map(|i| IntestineSignals {
phase: i.phase,
nutrient_energy_kcal: i.nutrient_energy_kcal,
});
@@ -253,6 +279,16 @@ impl Patient {
bile_acid_concentration_mmol_l: g.bile_acid_concentration_mmol_l,
});
let esophagus_before = self
.find_organ_typed::<Esophagus>()
.map(|e| EsophagusSignals {
stage: e.stage,
bolus_volume_ml: e.bolus_volume_ml,
peristaltic_progress_cm: e.peristaltic_progress_cm,
lower_sphincter_tone: e.lower_sphincter_tone,
hiatal_pressure_cm_h2o: e.hiatal_pressure_gradient_cm_h2o,
});
let kidney_signals = self.find_organ_typed::<Kidneys>().map(|k| KidneySignals {
urine_flow_ml_min: k.urine_flow_ml_min,
});
@@ -262,10 +298,12 @@ impl Patient {
.map(|s| SpinalSignals {
sympathetic_outflow: s.sympathetic_outflow,
parasympathetic_outflow: s.parasympathetic_outflow,
reflex_gain: s.reflex_gain,
});
let blood_glucose = self.blood.glucose_mg_dl;
if let Some(pancreas) = self.find_organ_typed_mut::<Pancreas>() {
pancreas.blood_glucose_mg_dl = self.blood.glucose_mg_dl;
pancreas.blood_glucose_mg_dl = blood_glucose;
if let Some(intestines) = intestine_signals {
let incretin_target = (intestines.nutrient_energy_kcal / 400.0).clamp(0.05, 1.0);
pancreas.incretin_signal =
@@ -302,8 +340,9 @@ impl Patient {
}
}
let blood_glucose_for_kidneys = self.blood.glucose_mg_dl;
if let Some(kidneys) = self.find_organ_typed_mut::<Kidneys>() {
let osm_target = 285.0 + (self.blood.glucose_mg_dl - 95.0) * 0.06;
let osm_target = 285.0 + (blood_glucose_for_kidneys - 95.0) * 0.06;
kidneys.serum_osmolality_mosm =
Self::relax_value(kidneys.serum_osmolality_mosm, osm_target, dt_seconds, 120.0);
if let Some(heart) = heart_signals {
@@ -324,10 +363,7 @@ impl Patient {
);
}
if let Some(intestines) = intestine_signals {
if matches!(
intestines.phase,
IntestinalPhase::IlealBrake | IntestinalPhase::Dysmotility
) {
if intestines.nutrient_energy_kcal < 80.0 {
gallbladder.sphincter_of_oddi_tone = (gallbladder.sphincter_of_oddi_tone
+ 0.05 * dt_seconds / 60.0)
.clamp(0.2, 0.95);
@@ -372,6 +408,16 @@ impl Patient {
}
}
if let Some(stomach) = stomach_signals {
let delivered_ml = stomach.emptying_rate_ml_min * dt_seconds / 60.0;
let delivered_kcal = (delivered_ml * 0.8).min(stomach.nutrient_load_kcal);
if delivered_kcal > 0.0 {
if let Some(intestines) = self.find_organ_typed_mut::<Intestines>() {
intestines.nutrient_energy_kcal += delivered_kcal;
}
}
}
for organ in &mut self.organs {
organ.update(dt_seconds);
}
@@ -417,21 +463,14 @@ impl Patient {
immune_activity: s.immune_activity,
red_pulp_volume_ml: s.red_pulp_volume_ml,
platelet_reservoir: s.platelet_reservoir,
state: s.state,
});
if let Some(spleen) = spleen_after {
let state_penalty = match spleen.state {
SplenicState::SympatheticContraction => -2.0,
SplenicState::HyperimmuneActivation => 2.5,
SplenicState::Sequestration => 3.0,
SplenicState::Hypofunction => -1.5,
SplenicState::Homeostatic => 0.0,
};
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
+ state_penalty;
+ immune_penalty * 2.0;
self.blood.hematocrit_pct = Self::relax_value(
self.blood.hematocrit_pct,
hematocrit_target.clamp(30.0, 55.0),
@@ -440,8 +479,9 @@ impl Patient {
);
}
let blood_glucose_for_pancreas = self.blood.glucose_mg_dl;
if let Some(pancreas) = self.find_organ_typed_mut::<Pancreas>() {
pancreas.blood_glucose_mg_dl = self.blood.glucose_mg_dl;
pancreas.blood_glucose_mg_dl = blood_glucose_for_pancreas;
}
let pancreas_after = self
@@ -453,6 +493,188 @@ impl Patient {
somatostatin: p.somatostatin,
});
let brain_after = self.find_organ_typed::<Brain>().map(|b| BrainSignals {
brainstem_drive: b.brainstem_autonomic_drive,
autonomic_variability: b.autonomic_variability,
consciousness: b.consciousness as f32 / 100.0,
sleep_depth: match b.sleep_stage {
SleepStage::Wake => 0.0,
SleepStage::N1 => 0.25,
SleepStage::N2 => 0.55,
SleepStage::N3 => 0.9,
SleepStage::Rem => 0.4,
},
rem_tone: matches!(b.sleep_stage, SleepStage::Rem) as i32 as f32,
});
let spinal_after = self
.find_organ_typed::<SpinalCord>()
.map(|s| SpinalSignals {
sympathetic_outflow: s.sympathetic_outflow,
parasympathetic_outflow: s.parasympathetic_outflow,
reflex_gain: s.reflex_gain,
});
let bladder_after = self.find_organ_typed::<Bladder>().map(|b| BladderSignals {
afferent_signal: b.afferent_signal,
urgency: b.urgency,
phase: b.phase,
});
let esophagus_after = self
.find_organ_typed::<Esophagus>()
.map(|e| EsophagusSignals {
stage: e.stage,
bolus_volume_ml: e.bolus_volume_ml,
peristaltic_progress_cm: e.peristaltic_progress_cm,
lower_sphincter_tone: e.lower_sphincter_tone,
hiatal_pressure_cm_h2o: e.hiatal_pressure_gradient_cm_h2o,
});
if let (Some(before), Some(after)) = (esophagus_before, esophagus_after) {
let mut delivered_ml = 0.0;
if matches!(
after.stage,
EsophagealStage::Clearing | EsophagealStage::Idle
) {
delivered_ml = (before.bolus_volume_ml - after.bolus_volume_ml).max(0.0);
} else if after.peristaltic_progress_cm > 22.0 && before.peristaltic_progress_cm <= 22.0
{
delivered_ml = before.bolus_volume_ml * 0.6;
}
let delivered_ml = delivered_ml.clamp(0.0, 40.0);
if delivered_ml > 0.0 {
if let Some(stomach) = self.find_organ_typed_mut::<Stomach>() {
stomach.volume_ml = (stomach.volume_ml + delivered_ml).clamp(80.0, 1600.0);
stomach.nutrient_load_kcal += delivered_ml * 0.8;
}
}
}
if let (Some(stomach), Some(esophagus_state)) = (stomach_signals, esophagus_after) {
if let Some(esophagus) = self.find_organ_typed_mut::<Esophagus>() {
let distension = ((stomach.volume_ml - 250.0) / 160.0).clamp(-0.3, 2.2);
let acid_factor = (stomach.acid_level as f32 / 100.0 - 0.65).max(0.0);
let motility_relief = (stomach.motility_index - 0.5).min(0.0).abs();
let gradient_target =
(esophagus_state.hiatal_pressure_cm_h2o + distension * 6.0 + acid_factor * 3.0
- motility_relief * 2.0)
.clamp(4.0, 22.0);
esophagus.hiatal_pressure_gradient_cm_h2o = Self::relax_value(
esophagus.hiatal_pressure_gradient_cm_h2o,
gradient_target,
dt_seconds,
90.0,
);
let les_target = (esophagus_state.lower_sphincter_tone
+ 0.15 * (0.6 - distension).clamp(-0.4, 0.4)
- 0.1 * acid_factor)
.clamp(0.2, 0.95);
esophagus.lower_sphincter_tone = Self::relax_value(
esophagus.lower_sphincter_tone,
les_target,
dt_seconds,
120.0,
);
}
}
if let Some(bladder_state) = bladder_after {
if let Some(spinal) = self.find_organ_typed_mut::<SpinalCord>() {
let stretch = bladder_state.afferent_signal;
let urgency = bladder_state.urgency;
let voiding_signal =
matches!(bladder_state.phase, BladderPhase::Voiding) as i32 as f32;
let sleep_bias = brain_after.map(|b| b.sleep_depth).unwrap_or(0.0);
let para_target = (0.42 + 0.5 * stretch + 0.25 * voiding_signal
- 0.15 * sleep_bias)
.clamp(0.2, 0.95);
let sym_target = (0.65 - 0.4 * stretch - 0.25 * voiding_signal + 0.2 * sleep_bias)
.clamp(0.15, 0.9);
spinal.parasympathetic_outflow = Self::relax_value(
spinal.parasympathetic_outflow,
para_target,
dt_seconds,
90.0,
);
spinal.sympathetic_outflow =
Self::relax_value(spinal.sympathetic_outflow, sym_target, dt_seconds, 90.0);
let sedation = brain_after.map(|b| 1.0 - b.consciousness).unwrap_or(0.0);
let reflex_target =
(1.0 + 0.55 * stretch + 0.3 * voiding_signal - 0.3 * sedation).clamp(0.6, 1.6);
spinal.reflex_gain =
Self::relax_value(spinal.reflex_gain, reflex_target, dt_seconds, 120.0);
spinal.nociceptive_facilitation = Self::relax_value(
spinal.nociceptive_facilitation,
(0.2 + 0.5 * urgency + 0.1 * voiding_signal).clamp(0.15, 1.0),
dt_seconds,
120.0,
);
}
if let Some(bladder) = self.find_organ_typed_mut::<Bladder>() {
if let Some(brain) = brain_after {
let sedation = (1.0 - brain.consciousness).clamp(0.0, 1.0);
let sleep_bonus = brain.sleep_depth * 0.25 + brain.rem_tone * 0.1;
let capacity = bladder.capacity_ml.max(1.0);
let micturition_target = (capacity
* (0.65 + sleep_bonus + sedation * 0.2 - bladder_state.urgency * 0.05))
.clamp(capacity * 0.5, capacity * 0.9);
bladder.micturition_threshold_ml = Self::relax_value(
bladder.micturition_threshold_ml,
micturition_target,
dt_seconds,
900.0,
);
let urge_target = (capacity * (0.4 + sleep_bonus * 0.5 + sedation * 0.15))
.clamp(capacity * 0.3, capacity * 0.7);
bladder.urge_threshold_ml = Self::relax_value(
bladder.urge_threshold_ml,
urge_target,
dt_seconds,
600.0,
);
}
if let Some(brain) = brain_after {
let voiding_signal =
matches!(bladder_state.phase, BladderPhase::Voiding) as i32 as f32;
let spinal_sym = spinal_after.map(|s| s.sympathetic_outflow).unwrap_or(0.6);
let spinal_para = spinal_after
.map(|s| s.parasympathetic_outflow)
.unwrap_or(0.5);
let reflex_gain = spinal_after.map(|s| s.reflex_gain).unwrap_or(1.0);
let para_target = (0.3
+ brain.brainstem_drive * 0.4
+ spinal_para * 0.3
+ bladder_state.urgency * 0.5
+ voiding_signal * 0.3)
.clamp(0.05, 1.1);
bladder.parasympathetic_drive = Self::relax_value(
bladder.parasympathetic_drive,
para_target,
dt_seconds,
120.0,
)
.clamp(0.0, 1.0);
let sym_target =
(0.55 + (1.0 - brain.brainstem_drive) * 0.35 + spinal_sym * 0.4
- bladder_state.urgency * 0.4
- voiding_signal * 0.4)
.clamp(0.1, 1.0);
bladder.sympathetic_drive =
Self::relax_value(bladder.sympathetic_drive, sym_target, dt_seconds, 160.0)
.clamp(0.0, 1.0);
let voluntary = brain.consciousness;
let somatic_target = ((0.55 + voluntary * 0.35 - brain.sleep_depth * 0.3)
* (1.0 - bladder_state.urgency * 0.55)
+ reflex_gain * 0.1
- voiding_signal * 0.5)
.clamp(0.1, 0.95);
bladder.somatic_drive =
Self::relax_value(bladder.somatic_drive, somatic_target, dt_seconds, 110.0)
.clamp(0.0, 1.0);
}
}
}
if let Some(pancreas) = pancreas_after {
if let Some(liver) = self.find_organ_typed_mut::<Liver>() {
let insulin_target = (pancreas.insulin / 60.0).clamp(0.1, 1.0);
@@ -464,28 +686,6 @@ impl Patient {
}
}
if let Some(liver) = self.find_organ_typed::<Liver>() {
if let Some(gallbladder) = self.find_organ_typed_mut::<Gallbladder>() {
let inflow_target = (liver.bile_secretion_ml_min * 0.8).clamp(0.05, 2.4);
gallbladder.hepatic_bile_flow_ml_per_min = Self::relax_value(
gallbladder.hepatic_bile_flow_ml_per_min,
inflow_target,
dt_seconds,
80.0,
);
}
}
if let Some(stomach) = self.find_organ_typed::<Stomach>() {
let delivered_ml = stomach.emptying_rate_ml_min * dt_seconds / 60.0;
let delivered_kcal = (delivered_ml * 0.8).min(stomach.nutrient_load_kcal);
if delivered_kcal > 0.0 {
if let Some(intestines) = self.find_organ_typed_mut::<Intestines>() {
intestines.nutrient_energy_kcal += delivered_kcal;
}
}
}
if let Some((_, urine_flow)) = kidneys_after {
let produced = (urine_flow * dt_seconds / 60.0).max(0.0);
if produced > 0.0 {
@@ -495,17 +695,13 @@ impl Patient {
}
}
let brain_after = self
.find_organ_typed::<Brain>()
.map(|b| (b.brainstem_autonomic_drive, b.autonomic_variability));
let spinal_after = self
.find_organ_typed::<SpinalCord>()
.map(|s| (s.sympathetic_outflow, s.parasympathetic_outflow));
if let Some(heart) = self.find_organ_typed_mut::<Heart>() {
if let Some((brain_drive, brain_sympathetic)) = brain_after {
let mut tone_target = (brain_drive - 0.5) * 1.2 + (brain_sympathetic - 0.5) * 0.8;
if let Some((sym, para)) = spinal_after {
tone_target += (sym - para) * 0.6;
if let Some(brain) = brain_after {
let mut tone_target =
(brain.brainstem_drive - 0.5) * 1.2 + (brain.autonomic_variability - 0.5) * 0.8;
if let Some(spinal) = spinal_after {
tone_target +=
(spinal.sympathetic_outflow - spinal.parasympathetic_outflow) * 0.6;
}
heart.autonomic_tone = Self::relax_value(
heart.autonomic_tone,
@@ -584,7 +780,7 @@ fn is_valid_id(id: &str) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::organs::intestines::IntestinalPhase;\r\nuse crate::organs::spleen::SplenicState;\r\n\r\nuse crate::types::OrganType;
use crate::types::OrganType;
#[test]
fn patient_lifecycle() {
@@ -603,5 +799,3 @@ mod tests {
assert!(Patient::new("bad id").is_err());
}
}