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.
This commit is contained in:
2025-09-28 15:03:12 -07:00
parent a74f9c408b
commit bf1e547a8c
3 changed files with 203 additions and 4 deletions
+1 -1
View File
@@ -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};
+201 -2
View File
@@ -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<String>) -> 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"
);
}
}
+1 -1
View File
@@ -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;