From bf1e547a8cfb5b65b91b73fbcf5bba8061ccfb1c Mon Sep 17 00:00:00 2001 From: Zack3D Date: Sun, 28 Sep 2025 15:03:12 -0700 Subject: [PATCH] feat(lungs): add breathing cycle and diaphragm kinematics Introduce a time-based breathing cycle with phases and diaphragm movement to improve realism and observability. - Add BreathingPhase enum (Inhalation, Exhalation, Pause) - Compute phase fractions/durations from RR and chemoreceptor drive - Advance phases over time and update diaphragm position/velocity - Call update_breath_cycle from Lungs::update - Extend summary with phase and diaphragm position - Re-export BreathingPhase and Lungs in lib and organs mod - Add unit test for phase transitions and kinematics No breaking changes; public API gains a new enum and fields. --- src/lib.rs | 2 +- src/organs/lungs.rs | 203 +++++++++++++++++++++++++++++++++++++++++++- src/organs/mod.rs | 2 +- 3 files changed, 203 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 5fd4e51..8b0cefd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,7 +37,7 @@ pub mod ffi; pub use crate::ekg::{EkgLead, EkgMonitor, EkgSnapshot, HeartElectricalState}; pub use crate::error::MedicalError; -pub use crate::organs::{Heart, Organ}; +pub use crate::organs::{BreathingPhase, Heart, Lungs, Organ}; pub use crate::patient::Patient; pub use crate::types::{Blood, BloodPressure, OrganType}; diff --git a/src/organs/lungs.rs b/src/organs/lungs.rs index cd91e58..cfbb604 100644 --- a/src/organs/lungs.rs +++ b/src/organs/lungs.rs @@ -1,6 +1,17 @@ use super::{Organ, OrganInfo}; use crate::types::OrganType; +/// High-level breathing cycle state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BreathingPhase { + /// Active inspiration with diaphragmatic contraction. + Inhalation, + /// Passive expiration and recoil. + Exhalation, + /// Brief end-expiratory pause before the next breath. + Pause, +} + /// Ventilatory operating mode reflecting dominant chemoreceptor drive. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VentilatoryState { @@ -58,14 +69,27 @@ pub struct Lungs { time_in_state_s: f32, metabolic_o2_consumption_ml_min: f32, metabolic_co2_production_ml_min: f32, + /// Current respiratory cycle phase. + pub breathing_phase: BreathingPhase, + phase_elapsed_s: f32, + current_phase_duration_s: f32, + breath_period_s: f32, + /// Diaphragm displacement fraction (0 relaxed, 1 fully contracted). + pub diaphragm_position: f32, + /// Rate of diaphragm displacement change per second. + pub diaphragm_velocity: f32, } impl Lungs { /// Construct lungs with a given id. pub fn new(id: impl Into) -> Self { + let respiratory_rate_bpm = 14.0_f32; + let breath_period_s = (60.0_f32 / respiratory_rate_bpm).clamp(0.5, 12.0); + let inhale_fraction = 0.42; + let inhale_duration = breath_period_s * inhale_fraction; Self { info: OrganInfo::new(id, OrganType::Lungs), - respiratory_rate_bpm: 14.0, + respiratory_rate_bpm, spo2_pct: 98.0, distress: false, tidal_volume_ml: 500.0, @@ -88,6 +112,12 @@ impl Lungs { time_in_state_s: 0.0, metabolic_o2_consumption_ml_min: 250.0, metabolic_co2_production_ml_min: 200.0, + breathing_phase: BreathingPhase::Inhalation, + phase_elapsed_s: 0.0, + current_phase_duration_s: inhale_duration.max(0.1), + breath_period_s, + diaphragm_position: 0.0, + diaphragm_velocity: 0.0, } } @@ -228,6 +258,130 @@ impl Lungs { ); } + fn breath_phase_fractions(&self) -> (f32, f32, f32) { + let base_inhale = match self.state { + VentilatoryState::Resting => 0.42, + VentilatoryState::HypercapnicResponse => 0.36, + VentilatoryState::HypoxicResponse => 0.38, + VentilatoryState::ExerciseAugmented => 0.46, + VentilatoryState::MechanicalDistress => 0.32, + }; + let base_pause = match self.state { + VentilatoryState::Resting => 0.06, + VentilatoryState::HypercapnicResponse => 0.03, + VentilatoryState::HypoxicResponse => 0.04, + VentilatoryState::ExerciseAugmented => 0.0, + VentilatoryState::MechanicalDistress => 0.02, + }; + let drive_adjust = (self.muscle_drive - 0.5).clamp(-0.5, 0.5); + let inhale = (base_inhale + 0.1 * drive_adjust).clamp(0.25, 0.55); + let pause = (base_pause - 0.05 * drive_adjust.max(0.0)).clamp(0.0, 0.1); + let exhale = (1.0 - inhale - pause).clamp(0.25, 0.7); + let total = inhale + exhale + pause; + if (total - 1.0).abs() < 1e-4 { + (inhale, exhale, pause) + } else { + let norm = if total <= 0.0 { 1.0 } else { total }; + (inhale / norm, exhale / norm, pause / norm) + } + } + + fn phase_durations(&self) -> (f32, f32, f32) { + let rate = self.respiratory_rate_bpm.clamp(4.0, 45.0); + let period = (60.0 / rate).clamp(0.6, 15.0); + let (inhale_frac, _exhale_frac, pause_frac) = self.breath_phase_fractions(); + let mut inhale = (period * inhale_frac).max(0.12); + let mut pause = (period * pause_frac).max(0.0); + let mut exhale = (period - inhale - pause).max(0.2); + let total = inhale + exhale + pause; + let scale = if total > 0.0 { period / total } else { 1.0 }; + inhale *= scale; + exhale *= scale; + pause *= scale; + (inhale, exhale, pause) + } + + fn advance_phase(&mut self, pause_duration: f32) { + self.breathing_phase = match self.breathing_phase { + BreathingPhase::Inhalation => BreathingPhase::Exhalation, + BreathingPhase::Exhalation => { + if pause_duration <= 1e-3 { + BreathingPhase::Inhalation + } else { + BreathingPhase::Pause + } + } + BreathingPhase::Pause => BreathingPhase::Inhalation, + }; + self.phase_elapsed_s = 0.0; + } + + fn easing(progress: f32) -> f32 { + let p = progress.clamp(0.0, 1.0); + p * p * (3.0 - 2.0 * p) + } + + fn update_breath_cycle(&mut self, dt_seconds: f32) { + if dt_seconds <= 0.0 { + return; + } + + let total_dt = dt_seconds; + let (inhale_duration, exhale_duration, pause_duration) = self.phase_durations(); + self.breath_period_s = inhale_duration + exhale_duration + pause_duration; + + let previous_duration = self.current_phase_duration_s.max(1e-6); + let mut phase_progress = (self.phase_elapsed_s / previous_duration).clamp(0.0, 1.0); + + let mut current_duration = match self.breathing_phase { + BreathingPhase::Inhalation => inhale_duration, + BreathingPhase::Exhalation => exhale_duration, + BreathingPhase::Pause => pause_duration.max(1e-3), + }; + if current_duration <= 0.0 { + current_duration = 1e-3; + } + self.current_phase_duration_s = current_duration; + self.phase_elapsed_s = phase_progress * current_duration; + + let mut remaining = dt_seconds; + while remaining > 0.0 { + let duration = self.current_phase_duration_s.max(1e-3); + let time_left = (duration - self.phase_elapsed_s).max(0.0); + if remaining < time_left { + self.phase_elapsed_s += remaining; + remaining = 0.0; + } else { + remaining -= time_left; + self.advance_phase(pause_duration); + let new_duration = match self.breathing_phase { + BreathingPhase::Inhalation => inhale_duration, + BreathingPhase::Exhalation => exhale_duration, + BreathingPhase::Pause => pause_duration.max(1e-3), + } + .max(1e-3); + self.current_phase_duration_s = new_duration; + } + } + + let current_duration = self.current_phase_duration_s.max(1e-3); + phase_progress = (self.phase_elapsed_s / current_duration).clamp(0.0, 1.0); + let start_position = self.diaphragm_position; + let target_position = match self.breathing_phase { + BreathingPhase::Inhalation => Self::easing(phase_progress), + BreathingPhase::Exhalation => 1.0 - Self::easing(phase_progress), + BreathingPhase::Pause => { + if pause_duration <= 1e-3 { + 0.0 + } else { + 0.0 + } + } + }; + self.diaphragm_position = target_position.clamp(0.0, 1.0); + self.diaphragm_velocity = (self.diaphragm_position - start_position) / total_dt; + } + fn update_gas_exchange(&mut self, dt_seconds: f32) { let effective_ventilation = self.minute_ventilation_l_min * (1.0 - self.dead_space_fraction); @@ -313,15 +467,18 @@ impl Organ for Lungs { self.update_state(); self.update_drives(dt_seconds); self.update_mechanics(dt_seconds); + self.update_breath_cycle(dt_seconds); self.update_gas_exchange(dt_seconds); self.update_pressures(dt_seconds); } fn summary(&self) -> String { format!( - "Lungs[id={}, state={:?}, RR={:.0}, VT={:.0} ml, SpO2={:.0}%, PaO2~{:.0}]", + "Lungs[id={}, state={:?}, phase={:?}, RR={:.0}, diaphragm={:.2}, VT={:.0} ml, SpO2={:.0}%, PaO2~{:.0}]", self.id(), self.state, + self.breathing_phase, self.respiratory_rate_bpm, + self.diaphragm_position, self.tidal_volume_ml, self.spo2_pct, self.alveolar_po2_mm_hg @@ -334,3 +491,45 @@ impl Organ for Lungs { self } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn breathing_cycle_advances_and_moves_diaphragm() { + let mut lungs = Lungs::new("test-lungs"); + assert_eq!(lungs.breathing_phase, BreathingPhase::Inhalation); + + let mut seen_exhalation = false; + let mut seen_pause = false; + let mut min_position = f32::MAX; + let mut max_position = f32::MIN; + let mut max_velocity: f32 = 0.0; + + for _ in 0..20 { + lungs.update(0.5); + min_position = min_position.min(lungs.diaphragm_position); + max_position = max_position.max(lungs.diaphragm_position); + max_velocity = max_velocity.max(lungs.diaphragm_velocity.abs()); + match lungs.breathing_phase { + BreathingPhase::Exhalation => seen_exhalation = true, + BreathingPhase::Pause => seen_pause = true, + BreathingPhase::Inhalation => {} + } + } + + assert!(seen_exhalation, "expected cycle to reach exhalation phase"); + assert!(seen_pause, "expected cycle to reach post-breath pause"); + assert!(max_position <= 1.0 + 1e-3, "diaphragm position upper bound"); + assert!(min_position >= -1e-3, "diaphragm position lower bound"); + assert!( + max_position - min_position > 0.3, + "diaphragm should sweep a meaningful range" + ); + assert!( + max_velocity > 0.05, + "diaphragm velocity should become non-zero" + ); + } +} diff --git a/src/organs/mod.rs b/src/organs/mod.rs index 63f7d2b..eaca989 100644 --- a/src/organs/mod.rs +++ b/src/organs/mod.rs @@ -63,7 +63,7 @@ pub use heart::{CardiacRhythmState, Heart}; pub use intestines::Intestines; pub use kidneys::Kidneys; pub use liver::Liver; -pub use lungs::Lungs; +pub use lungs::{BreathingPhase, Lungs}; pub use pancreas::Pancreas; pub use spinal_cord::SpinalCord; pub use spleen::Spleen;