11 Commits

Author SHA1 Message Date
zack3d 31a0b8a485 feat(ffi): add bloodstream and bladder metrics API
Multi-Platform CI / test-platforms (ubuntu-22.04) (push) Successful in 18s
Multi-Platform CI / test-platforms (windows-latest) (push) Successful in 18s
Multi-Platform CI / Package for Linux x86_64 (push) Has been skipped
Multi-Platform CI / Package for Windows x86_64 (push) Has been skipped
Multi-Platform CI / Create GitHub Release (push) Has been skipped
- add C FFI enums MLBladderPhase, MLMetabolicState, MLPerfusionState
- add C FFI structs MLBloodstreamMetrics and MLBladderMetrics
- add ml_patient_bloodstream_metrics and ml_patient_bladder_metrics
  to populate metrics for a patient (mirrored in src/ffi.rs)
- update examples/c/ffi_example.c to print new metrics
- add tests for FFI metrics (tests/ffi.rs)

organ model expansions
- bloodstream: add metrics() snapshot and detailed physiology:
  plasma proteins/oncotic pressure, lymph return, RBC cohort tracking,
  erythropoiesis/clearance with HIF, iron/folate/B12 stores, platelets,
  coagulation/fibrinolysis, immune cell counts, complement, acid–base
- bladder: introduce adaptive compliance, reflex gating, cortical/voluntary
  modulators, safety indices; add metrics(), summary, and unit tests
- brain: add homeostatic drives (respiratory, thirst, hunger, thermo, pain),
  brainstem nuclei (NTS/RVLM/CVLM, nAmb/DMV, RTN), sleep cycle timing,
  cerebrovascular autoregulation; wire drives into autonomic control
- heart: add phase-based cycle (valves and atria), conduction system,
  RAAS regulation, improved coronary perfusion
- intestines: add micronutrient absorption feeding erythropoiesis

patient coupling
- expose Patient::bloodstream_metrics() and ::bladder_metrics()
- integrate new organ signals (kidney osmolality, spleen culling, liver
  proteins) and brain–lung/continence control pathways
- re-export BladderMetrics and BloodstreamMetrics in lib.rs

note
- existing FFI remains compatible; this is a surface addition
- ffi/medicallib.h kept in sync with src/ffi.rs
2025-09-30 02:38:27 -07:00
zack3d 5cf6bbda48 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.
2025-09-28 16:10:23 -07:00
zack3d bf1e547a8c 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.
2025-09-28 15:03:12 -07:00
zack3d a74f9c408b feat(patient): add EKG monitor with configurable leads
Introduce crate-level ekg module and re-export EkgLead, EkgMonitor,
EkgSnapshot, and HeartElectricalState from lib.

Patient now owns an optional EKG monitor that:
- auto-initializes and syncs to the first Heart organ (lead count)
- supports configure_ekg_leads() for custom lead sets
- exposes ekg_monitor(), ekg_monitor_mut(), and ekg_snapshot()
- is advanced during Patient::update() by observing heart electrical
  state
- contributes to patient_summary() output

Examples:
- demo_app adds "set ekg <lead...>" command
- dashboard renders Electrocardiogram section (rhythm, rate, axis,
  lead amplitudes)
- lead parsing and human-readable labels added

Organs:
- export CardiacRhythmState from organs::heart
- minor refactors in heart (dedupe impl placement), bladder and brain
  code style cleanups

Tests:
- extend patient_lifecycle with EKG assertions
- add ekg_monitor_tracks_leads to validate lead config and snapshot

No breaking changes.
2025-09-26 01:01:46 -07:00
zack3d 0e6365bf7f ci(artifacts): build and package demo app artifacts cross-target
Multi-Platform CI / test-platforms (ubuntu-22.04) (push) Successful in 22s
Multi-Platform CI / test-platforms (windows-latest) (push) Successful in 19s
Multi-Platform CI / Package for Linux x86_64 (push) Successful in 2m3s
Multi-Platform CI / Package for Windows x86_64 (push) Successful in 2m3s
Multi-Platform CI / Create GitHub Release (push) Successful in 29s
- cross-compile examples/demo_app with demo-monitor when
  upload-artifacts is true and a target is provided
- package demo binary as medicallib_demo_app-<version>-<triple> archive
  with bin/<demo_app> included
- add strict bash flags, ensure dist dir exists, and fail early if the
  demo binary is missing
- use platform linkers via env for linux/windows GNU targets
- keep existing library package; now upload both artifacts
2025-09-24 03:09:19 -07:00
zack3d a7638c411a feat(examples): add colorized dashboard to demo
Revamp the console monitor UI in examples/demo_app.rs with ANSI-colored
sections and improved layout for readability.

- Add color palette, dashboard width, and styling helpers
- Introduce banner, section, and stat line builders
- Colorize prompt, status lines, and organ snapshots
- Display validity tags for measurements and styled warnings/errors
- Restructure output into Simulation, Circulation, Heart, Organ
  Snapshots, and Status sections
- Cache TTY color detection via OnceLock and respect NO_COLOR

No API changes; improvements are limited to the example app.
2025-09-24 03:06:22 -07:00
zack3d 886484919d feat(organs): retune heart and bladder dynamics
- Heart:
  - Raise baseline SVR to 18.5 and derive initial CO/afterload from baseline
  - Correct baroreflex direction: SVR increases with sympathetic tone
  - Recompute BP via raw diastolic + pulse pressure relationship
  - Tighten clamps for physiologic ranges and stabilize resting state
  - Add unit test: resting_state_stays_stable
- Bladder:
  - Replace steep nonlinear compliance with scaled linear compliance
  - Add volume-based abdominal term and nonlinear passive gain
  - Reduce active pressure gain and clamp max pressure to 80 cm H2O
- Patient/tests:
  - Add multi-organ homeostasis stability integration test (5h sim)
  - Assert physiologic bounds across lungs, brain, kidneys, liver,
    GI, pancreas, spleen, bladder, esophagus, and spinal cord
- Build/examples:
  - Add demo-monitor feature flag and demo_app example

These changes improve physiologic realism and long-run stability while
adding coverage to prevent regressions.
2025-09-24 02:55:29 -07:00
zack3d 21b9ca894f ver inc
Multi-Platform CI / test-platforms (ubuntu-22.04) (push) Successful in 18s
Multi-Platform CI / test-platforms (windows-latest) (push) Successful in 18s
Multi-Platform CI / Package for Linux x86_64 (push) Has been skipped
Multi-Platform CI / Package for Windows x86_64 (push) Has been skipped
Multi-Platform CI / Create GitHub Release (push) Has been skipped
2025-09-24 02:03:26 -07:00
zack3d d849f71127 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.
2025-09-24 02:01:53 -07:00
zack3d f439894864 feat(organs): add detailed physiology + coupling
- Implement rich state machines and hemodynamic/metabolic models across
  organs (brain, heart, lungs, kidneys, liver, stomach, intestines,
  pancreas, gallbladder, spleen, spinal cord, bladder, esophagus)
- Add new enums for organ phases/states (e.g., SleepStage,
  VentilatoryState, CardiacRhythmState, RenalAutoregulationState,
  GastricPhase, etc.)
- Extend organ structs with explicit physiology fields; rewrite update()
  loops and summaries to reflect realistic dynamics
- Wire inter-organ signaling in Patient (oxygenation, CPP, autonomic,
  hormones, bile, bile acids, urine→bladder, gastric emptying→intestines)
  using a relax_value smoothing helper
- Minor formatting in build.rs

BREAKING CHANGE: public organ structs gained/renamed fields and updated
summaries; code using struct literals or prior field names will break.
Use constructors (e.g., new()) and updated fields; summary outputs have
changed.
2025-09-24 01:34:34 -07:00
zack3d dea5049be5 ci(workflows): add git --stat to AI release notes
Use git log --stat since last tag (fallback to last 10) so the AI sees
file change stats, echo the log for visibility, and update the prompt to
reference "commits and changes" for more accurate, user-facing notes.
2025-09-24 00:50:53 -07:00
33 changed files with 10651 additions and 148 deletions
+29 -1
View File
@@ -79,10 +79,19 @@ jobs:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-g++
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
- name: Build demo app (Cross-Compile)
if: inputs.upload-artifacts && inputs.target != ''
run: cargo build --release --example demo_app --features demo-monitor --target=${{ inputs.target }}
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: x86_64-linux-gnu-g++
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
- name: Extract version and create package
if: inputs.upload-artifacts
shell: bash
run: |
set -euo pipefail
VERSION=$(sed -n 's/^version\s*=\s*"\([^"]\+\)"/\1/p' Cargo.toml | head -n1)
TARGET_ARCH="x86_64"
TARGET_TRIPLE="${{ inputs.target }}"
@@ -90,17 +99,29 @@ jobs:
# Determine package name and library extension based on target
if [[ "$TARGET_TRIPLE" == "x86_64-unknown-linux-gnu" ]]; then
PKG_BASENAME="medicallib_rust-v${VERSION}-${TARGET_ARCH}-unknown-linux-gnu"
DEMO_PKG_BASENAME="medicallib_demo_app-v${VERSION}-${TARGET_ARCH}-unknown-linux-gnu"
LIB_NAME="libmedicallib_rust.so"
DEMO_BINARY_NAME="demo_app"
ARCHIVE_TYPE="tar.gz"
elif [[ "$TARGET_TRIPLE" == "x86_64-pc-windows-gnu" ]]; then
PKG_BASENAME="medicallib_rust-v${VERSION}-${TARGET_ARCH}-pc-windows-gnu"
DEMO_PKG_BASENAME="medicallib_demo_app-v${VERSION}-${TARGET_ARCH}-pc-windows-gnu"
LIB_NAME="medicallib_rust.dll"
DEMO_BINARY_NAME="demo_app.exe"
ARCHIVE_TYPE="zip"
else
echo "::error::Unsupported target for packaging: $TARGET_TRIPLE"
exit 1
fi
DEMO_BINARY_SOURCE="target/${TARGET_TRIPLE}/release/examples/${DEMO_BINARY_NAME}"
if [[ ! -f "$DEMO_BINARY_SOURCE" ]]; then
echo "::error::Demo binary not found at $DEMO_BINARY_SOURCE"
exit 1
fi
mkdir -p dist
PKG_DIR="dist/${PKG_BASENAME}"
echo "Creating package ${PKG_BASENAME}..."
mkdir -p "${PKG_DIR}/include" "${PKG_DIR}/lib"
@@ -109,11 +130,18 @@ jobs:
cp ffi/medicallib.h "${PKG_DIR}/include/"
cp "target/${TARGET_TRIPLE}/release/${LIB_NAME}" "${PKG_DIR}/lib/"
DEMO_DIR="dist/${DEMO_PKG_BASENAME}"
echo "Creating demo package ${DEMO_PKG_BASENAME}..."
mkdir -p "${DEMO_DIR}/bin"
cp "$DEMO_BINARY_SOURCE" "${DEMO_DIR}/bin/${DEMO_BINARY_NAME}"
# Create the appropriate archive
if [[ "$ARCHIVE_TYPE" == "tar.gz" ]]; then
tar -C dist -czf "dist/${PKG_BASENAME}.tar.gz" "${PKG_BASENAME}"
tar -C dist -czf "dist/${DEMO_PKG_BASENAME}.tar.gz" "${DEMO_PKG_BASENAME}"
elif [[ "$ARCHIVE_TYPE" == "zip" ]]; then
(cd dist && zip -r9 "${PKG_BASENAME}.zip" "${PKG_BASENAME}")
(cd dist && zip -r9 "${DEMO_PKG_BASENAME}.zip" "${DEMO_PKG_BASENAME}")
fi
- name: Upload Artifact
@@ -123,4 +151,4 @@ jobs:
# Create a unique name based on the target OS
name: medicallib-rust-${{ contains(inputs.target, 'windows') && 'Windows' || 'Linux' }}-x86_64-${{ github.run_number }}
path: dist/
if-no-files-found: error
if-no-files-found: error
+8 -5
View File
@@ -87,20 +87,23 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
# Get git log since last release
# Get git log with actual changes since last release
if [ -n "${{ steps.last_tag.outputs.last_tag }}" ]; then
GIT_LOG=$(git log --pretty=format:"- %s (%an)" ${{ steps.last_tag.outputs.last_tag }}..HEAD)
GIT_LOG=$(git log --pretty=format:"**%s** (%an)" --stat ${{ steps.last_tag.outputs.last_tag }}..HEAD)
else
GIT_LOG=$(git log --pretty=format:"- %s (%an)" HEAD~10..HEAD)
GIT_LOG=$(git log --pretty=format:"**%s** (%an)" --stat HEAD~10..HEAD)
fi
echo "Git changes for release notes:"
echo "$GIT_LOG"
# Try to generate AI-powered release notes
if [ -n "$OPENAI_API_KEY" ]; then
echo "Generating AI release notes..."
PROMPT="Generate professional release notes for version ${{ steps.version.outputs.version }} based on these git commits. Focus on user-facing changes and improvements. Use markdown formatting with sections like ## What's New, ## Improvements, ## Bug Fixes. Keep it concise and under 500 words.
PROMPT="Generate professional release notes for version ${{ steps.version.outputs.version }} based on these git commits and file changes. Focus on user-facing changes and improvements. Use markdown formatting with sections like ## What's New, ## Improvements, ## Bug Fixes. Keep it concise and under 500 words.
Commits:
Commits and Changes:
$GIT_LOG"
RESPONSE=$(curl -s -X POST "https://api.openai.com/v1/responses" \
+2
View File
@@ -17,3 +17,5 @@ coverage/
.claude/settings.local.json
ffi/medicallib.h
todo.md
+99
View File
@@ -0,0 +1,99 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build, Test, and Development Commands
### Core workflows
- **Build library**: `cargo build` (debug) or `cargo build --release` (optimized)
- **Build FFI shared library**: `cargo build --release --features ffi` (generates C header via cbindgen)
- **Run tests**: `cargo test` (all tests) or `cargo test --test <name>` (single integration test file)
- **Run examples**: `cargo run --example usage`, `cargo run --example patient`, `cargo run --example tracing_demo`
- **Run demo monitor**: `cargo run --example demo_app --features demo-monitor`
- **Benchmarks**: `cargo bench` (runs Criterion benchmarks; results in `target/criterion/`)
- **Format code**: `cargo fmt --all` (run before committing)
- **Lint**: `cargo clippy --all-targets -- -D warnings` (enforce before committing)
### Packaging binary distributions
- **Linux/macOS**: Run `../scripts/package.sh [target-triple]` from `medicallib_rust/`
- **Windows**: Run `..\scripts\package.ps1 [target-triple]` from `medicallib_rust\`
- Artifacts placed in `medicallib_rust/dist/` as both `.tar.gz` and `.zip`
## Architecture Overview
### Core structure
- **`src/lib.rs`**: Crate facade re-exporting public API (`Patient`, `Organ`, types, errors)
- **`src/types.rs`**: Domain types (`Blood`, `BloodPressure`, `OrganType`, `VitalSign`)
- **`src/error.rs`**: `MedicalError` enum using `thiserror`
- **`src/patient.rs`**: `Patient` struct orchestrating `Vec<Box<dyn Organ>>` with inter-organ signal routing
- **`src/organs/mod.rs`**: `Organ` trait and `OrganInfo`; per-organ modules (heart, lungs, brain, etc.)
- **`src/ekg/mod.rs`**: EKG monitoring system with configurable leads
- **`src/ffi.rs`**: C-compatible FFI layer (feature `ffi`); header auto-generated to `ffi/medicallib.h` by `build.rs` using cbindgen
### Organ system
- Each organ module implements the `Organ` trait (`id()`, `organ_type()`, `update(dt)`, `summary()`, `as_any()`, `as_any_mut()`)
- `Patient` owns organs as trait objects, calls `update()` each tick, and routes signals between organs
- Inter-organ communication examples:
- Lungs SpO2 → Heart rate adjustment
- Kidneys GFR → Bladder volume accumulation
- Brain autonomic signals → Heart rate variability and respiratory drive
- Bloodstream perfusion → multiple organ metabolic states
### FFI boundary
- **Opaque handle**: `MLPatient` pointer to boxed `Patient`
- **Functions**: `ml_patient_new/free/update`, `ml_patient_summary`, `ml_patient_organ_summary`, `medicallib_bmi`
- **Memory**: All returned strings allocated by Rust must be freed via `ml_string_free`
- **Build script**: `build.rs` runs cbindgen when `--features ffi` is enabled, updates `ffi/medicallib.h` if changed
- **Validation**: Any change to `src/ffi.rs` must be tested against `examples/c/ffi_example.c`
### Testing and benchmarks
- **Integration tests**: `tests/` directory (e.g., `tests/patient.rs`, `tests/ffi.rs`)
- **Benchmarks**: `benches/heart.rs` uses Criterion; compare results across runs for performance regressions
- **Unit tests**: In-module `#[cfg(test)]` blocks for focused invariants
## Important Development Notes
### When modifying FFI
1. Edit `src/ffi.rs`
2. Run `cargo build --features ffi` to regenerate `ffi/medicallib.h` via cbindgen
3. Verify C example still compiles: `gcc -o ffi_example examples/c/ffi_example.c -L target/release -lmedicallib_rust`
4. Update `cbindgen.toml` only if header generation settings need adjustment
### Adding new organs
1. Create module in `src/organs/<organ_name>.rs`
2. Define struct implementing `Organ` trait
3. Add module declaration and public re-export in `src/organs/mod.rs`
4. Update `Patient` signal structs and `couple_organs()` method if inter-organ communication needed
5. Add corresponding `OrganType` variant in `src/types.rs`
6. Update FFI constants in `src/ffi.rs` if organ needs FFI exposure
### Cargo features
- **`serde`**: Enables serialization on public types
- **`ffi`**: Exposes C ABI; triggers cbindgen in `build.rs`
- **`demo-monitor`**: Required for `demo_app` example with colorized dashboard
### CI workflows
- GitHub Actions: `.github/workflows/ci.yml`
- Gitea Actions: `.gitea/workflows/ci.yml`
- Both run tests, clippy, formatting checks, and cross-platform builds
## Key Patterns
### Patient simulation loop
```rust
let mut patient = Patient::new("case-01")?.initialize_default();
loop {
patient.update(dt_seconds);
let summary = patient.patient_summary();
// Process vitals...
}
```
### Accessing specific organ
```rust
let heart = patient.find_organ_typed::<Heart>()?;
println!("HR: {}", heart.heart_rate_bpm());
```
### Inter-organ signals
Patient's `couple_organs()` method reads state from one organ (e.g., `Lungs::spo2_pct()`) and injects it into another (e.g., `Heart::receive_spo2_signal()`). Add new signal fields to the internal `*Signals` structs in `patient.rs` when coupling new organ interactions.
+7 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "medicallib_rust"
version = "0.1.1"
version = "0.3.0"
edition = "2021"
description = "MedicalSim core library rewrite in Rust: basic clinical calculations and types."
authors = ["MedicalSim Team"]
@@ -14,6 +14,7 @@ crate-type = ["rlib", "cdylib"]
default = []
serde = ["dep:serde"]
ffi = []
demo-monitor = []
[dependencies]
thiserror = "1"
@@ -24,6 +25,11 @@ tracing = { version = "0.1", optional = true }
criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] }
tracing-subscriber = { version = "0.3" }
[[example]]
name = "demo_app"
path = "examples/demo_app.rs"
required-features = ["demo-monitor"]
[[bench]]
name = "heart"
harness = false
+4 -2
View File
@@ -10,7 +10,8 @@ fn main() {
println!("cargo:rerun-if-changed=src/ffi.rs");
println!("cargo:rerun-if-changed=cbindgen.toml");
let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
let crate_dir =
PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
let crate_dir_string = crate_dir
.to_str()
.expect("crate directory must be valid UTF-8")
@@ -36,7 +37,8 @@ fn main() {
let mut generated = Vec::new();
bindings.write(&mut generated);
let header_contents = String::from_utf8(generated).expect("generated header was not valid UTF-8");
let header_contents =
String::from_utf8(generated).expect("generated header was not valid UTF-8");
let header_path = header_dir.join("medicallib.h");
let needs_write = fs::read_to_string(&header_path)
+424
View File
@@ -0,0 +1,424 @@
# Research Summary
## Heart
**Current simulation coverage**
- Lumped ventricular pump tracks heart rate, arterial pressures, stroke volume, end-diastolic/systolic volumes, ejection fraction, contractility, preload/afterload and systemic vascular resistance, all coupled to cardiac output control (`src/organs/heart.rs:20-218`).
- Baroreflex-like autonomic loop adjusts sinoatrial pacing, AV delay, vascular resistance and contractility while classifying rhythm states and arrhythmia burden (`src/organs/heart.rs:125-260`).
- Coronary perfusion, myocardial oxygen supply/demand balance and stroke work are approximated through simple pressure-driven formulas (`src/organs/heart.rs:199-247`).
**Physiology findings and observed gaps**
- Model lacks explicit atrial chambers and valve mechanics, yet real hearts rely on four coordinated valves guiding flow through right/left atria and ventricles for one-way circulation and chamber filling.[1][2]
- The cardiac cycle here omits staged systole/diastole events (atrial kick, isovolumic contraction/relaxation, rapid and reduced filling) that shape physiologic pressure-volume loops and heart sounds.[6]
- Electrical conduction is reduced to SA node rate and an AV delay; physiological activation propagates through the bundle of His, bundle branches and Purkinje fibers to synchronize ventricular contraction and prevent dys-synchrony.[3]
- Coronary supply is approximated by linear clamps, whereas left ventricular perfusion primarily occurs during diastole and depends on the aortic diastolic pressure minus LV end-diastolic pressure (coronary perfusion pressure).[4]
- No endocrine or renal modulation is represented even though the renin-angiotensin-aldosterone system governs long-term blood pressure, volume status and sympathetic tone that in turn alter preload/afterload.[5]
**Opportunities for improvement**
- Introduce discrete atrial compartments and valve state logic (open/closed/regurgitant) to capture atrial kick, regurgitation, shunts and valve pathology scenarios.[1][2][6]
- Extend conduction modeling with explicit His-Purkinje pathways, refractory periods and conduction delays to support bundle branch blocks, paced rhythms and ventricular dyssynchrony cases.[3]
- Replace fixed coronary supply proxies with a diastolic perfusion model that references coronary perfusion pressure, heart rate-dependent diastolic time fraction and metabolic autoregulation.[4]
- Layer hormonal regulation (e.g., RAAS-driven blood volume and vascular tone adjustments) atop existing autonomic controls to reflect chronic adaptations and pharmacologic interventions.[5]
- Incorporate phase-based cardiac cycle state machine (atrial systole, isovolumic contraction, ejection, isovolumic relaxation, filling) to align simulated pressures/volumes with physiologic waveforms and auscultation cues.[6]
**Sources**
1. Cleveland Clinic - Heart Valves: What They Are and How They Work.[1]
2. Cleveland Clinic - Chambers of the Heart.[2]
3. Cleveland Clinic - Heart Conduction System.[3]
4. StatPearls/NCBI - Coronary Perfusion Pressure.[4]
5. Cleveland Clinic - Renin-Angiotensin-Aldosterone System (RAAS).[5]
6. Merck Manual - Diagram of the Cardiac Cycle.[6]
## Brain
**Current simulation coverage**
- Sleep-wake regulation couples circadian phase, homeostatic sleep pressure, and a stage state machine to drive cortical arousal, sleep stage transitions, and EEG proxies (`src/organs/brain.rs:54-219`).
- Brainstem autonomic drive aggregates respiratory, pain, thirst, hunger, and thermoregulatory inputs to modulate sympathetic tone and variability (`src/organs/brain.rs:220-298`).
- Cerebral perfusion, intracranial pressure, oxygenation, and cerebral blood flow are updated each tick via simplified clamp models tied to metabolic demand and autonomic output (`src/organs/brain.rs:299-373`).
- Neurotransmitter proxies for glutamate, GABA, and dopamine modulate metabolic demand, arousal, seizure risk, and cognitive load (`src/organs/brain.rs:374-445`).
- Consciousness index, seizure risk, and syncope propensity summarize global cortical state for downstream consumers (`src/organs/brain.rs:446-480`).
**Physiology findings and observed gaps**
- The sleep model advances through staged NREM and REM sequences but lacks representation of REM atonia, stage-specific EEG waveforms, and variable cycle length that characterize physiologic sleep architecture.[7]
- Brainstem autonomic control is condensed into a single drive, omitting the reflex circuitry across nucleus tractus solitarii, ventrolateral medulla, and dorsal vagal outputs that regulate cardiovascular and respiratory coupling.[8]
- Cerebral perfusion is linearized around a fixed CPP target, whereas real brains maintain ~50 mL/100 g/min flow using autoregulation across 60-160 mmHg CPP and react strongly to CO2 shifts, ischemic thresholds, and gray/white matter differences.[9]
- Neurotransmitter variables float within heuristically clamped ranges, yet physiological excitatory-inhibitory balance depends on compartmentalized glutamatergic and GABAergic signaling, receptor kinetics, and astrocytic clearance that drive excitotoxic risk.[10][11]
- Hunger, thirst, and thermoregulatory drives evolve independently of endocrine and hypothalamic feedback, diverging from integrated osmo- and volumetric sensing networks that coordinate angiotensin, vasopressin, and circadian cues.[12]
**Opportunities for improvement**
- Extend the sleep state machine with polysomnographic markers (e.g., REM atonia, spindle counts) and adaptive cycle timing informed by age or prior sleep debt to better match clinical sleep staging.[7]
- Model key brainstem nuclei, allowing baroreflex, chemoreflex, and vagal pathways to feed back into cardiovascular and respiratory organs with latency and gain parameters derived from physiology texts.[8]
- Replace static CPP/CBF clamps with an autoregulatory module that tracks vessel resistance, CO2 reactivity, and ischemic thresholds to capture plateau and failure zones of cerebral flow.[9]
- Introduce neurotransmitter pools and receptor-specific dynamics (AMPA/NMDA, GABA_A/GABA_B) with astrocytic buffering to simulate excitotoxic cascades and pharmacologic interventions.[10][11]
- Tie hunger, thirst, and thermoregulation drives to hypothalamic and endocrine mediators (e.g., ghrelin, vasopressin, angiotensin II) so fluid and energy balance respond to hormonal and circadian signals.[12]
**Sources**
7. StatPearls - Physiology of Sleep.[7]
8. Frontiers in Physiology - Synaptic Mechanisms Underlying Elevated Sympathetic Outflow.[8]
9. Stroke Manual - Regulation of Cerebral Blood Flow.[9]
10. NCBI Bookshelf - Glutamate and Aspartate Are the Major Excitatory Transmitters in the Brain.[10]
11. StatPearls - GABA Receptor.[11]
12. Springer Review - Thirst: Neuroendocrine Regulation in Mammals.[12]
## Bladder
**Current simulation coverage**
- Three-phase state machine (`Filling`, `Voiding`, `PostVoidRefractory`) governs storage dynamics, reflex triggers, and refractory timing to avoid immediate reactivation (`src/organs/bladder.rs:5-133`).
- Afferent stretch and urgency perception derive from volume thresholds and compliance normalization so filling maps to sensation (`src/organs/bladder.rs:58-96`).
- Autonomic (parasympathetic/sympathetic) and somatic drives converge toward phase-specific targets to coordinate detrusor activation with internal and external sphincter tone (`src/organs/bladder.rs:34-88`).
- Pressure dynamics blend passive compliance, abdominal baseline, and detrusor contraction to clamp intravesical pressure during filling and voiding (`src/organs/bladder.rs:97-147`).
- Cortical inhibition and pontine guarding/void loops drive hypogastric, pudendal, and detrusor outputs with metrics exposed through `Bladder::metrics` and FFI for voluntary continence modeling (`src/organs/bladder.rs:170-452`; `src/ffi.rs`).[14][15]
**Physiology findings and observed gaps**
- Compliance and capacity stay fixed, yet healthy bladders accommodate volume with minimal pressure rise and elevate detrusor pressure only near voiding; sustained storage pressures above safety limits threaten upper tract health.[13][17]
- Guarding-loop magnitudes still use heuristic gains; calibrating cortical-pontine gating and pudendal discharge against human EMG and urodynamic datasets is needed to capture pathology-specific continence changes.[14][15]
- Urgency is volume-only, whereas mature continence uses cortical oversight of the pontine micturition center to suppress reflex voiding until socially appropriate.[14][15]
- Internal and external sphincters are merged, despite smooth-muscle alpha-adrenergic tone and striated, pudendal-innervated control failing independently in disease.[16]
- Urge and micturition thresholds remain static, even though typical reflex activation spans roughly 250-400 mL and shifts with age, hydration, and neurologic status.[14][18]
**Opportunities for improvement**
- Replace static compliance with a pressure-volume curve that adapts to bladder history, hydration, or pathology while tracking detrusor pressure against safety limits.[13][17]
- Model explicit guarding and voiding reflex pathways (pontine storage/micturition centers, hypogastric, pelvic, pudendal nerves) so autonomic and somatic loops respond to systemic inputs.[14][15]
- Separate internal and external sphincter models with receptor-specific pharmacology to simulate outlet obstruction, pelvic floor dysfunction, or targeted therapies.[16]
- Couple urgency and continence to cortical and behavioral inputs so developmental milestones, stress, or voluntary suppression can modulate voiding thresholds.[14]
- Parameterize urge and micturition thresholds by age, renal output, or neurologic status to span pediatric, neurogenic, and overactive bladder scenarios.[18]
**Sources**
13. StatPearls - Urodynamic Testing and Interpretation.[13]
14. StatPearls - Physiology, Urination.[14]
15. Nature Reviews Neuroscience - The Neural Control of Micturition.[15]
16. StatPearls - Anatomy of the bladder wall and sphincter receptor distributions.[16]
17. Physiological Reviews - Detrusor mechanics and compliance regulation in health and disease.[17]
18. Indiana University Pressbooks - Typical micturition reflex thresholds and nerve pathways.[18]
## Bloodstream
**Current simulation coverage**
- Maintains plasma volume, red cell volume, total circulating volume, hematocrit, and hemoglobin while syncing cardiac output, mean arterial pressure, and oxygen saturation targets (`src/organs/bloodstream.rs:18-170`).
- Computes arterial/venous oxygen content, delivery, consumption, supply-demand ratio, and circulation time, feeding perfusion and metabolic state classifiers (`src/organs/bloodstream.rs:70-210`).
- Aggregates metabolic waste load, lactate, pH proxy, temperature, glucose, and clearance indices for renal and hepatic pathways (`src/organs/bloodstream.rs:120-270`).
- Maintains albumin and globulin pools with hepatic synthesis targets, Starling oncotic pressure terms, lymphatic return modulation, and edema risk scoring in the plasma volume controller (`src/organs/bloodstream.rs:170-230`).[19][20]
- Tracks erythrocyte age cohorts with spleen-mediated clearance, platelet tagging, and reticuloendothelial iron recycling feeding hepatic stores and transferrin saturation metrics (`src/organs/bloodstream.rs:240-320`).[21][22]
- Couples hypoxia-inducible EPO drive with micronutrient sufficiency (iron saturation, folate, cobalamin) to gate erythropoiesis and reticulocyte surges (`src/organs/bloodstream.rs:320-360`).[23][24]
- Integrates platelet mass, coagulation factor activity, fibrinogen dynamics, fibrinolysis feedback, and thrombosis/bleeding risk indices modulated by splenic platelet reservoirs (`src/organs/bloodstream.rs:360-820`).[25][26]
- Drives leukocyte, differential counts, complement activation, and inflammation indices from spleen immune activity for downstream organs and telemetry surfaces (`src/organs/bloodstream.rs:720-860`).[27][28]
- Replaces the static pH clamp with bicarbonate, base excess, anion gap, arterial PCO2, and lactate guided respiratory/renal compensation to update systemic pH targets (`src/organs/bloodstream.rs:851-910`).[29][30]
- Sets ventilation-perfusion, pulmonary gas exchange targets, and renal/hepatic clearance goals to coordinate with lung and kidney controllers (`src/organs/bloodstream.rs:150-310`).
**Physiology findings and observed gaps**
- Acute-phase shifts, capillary leak syndromes, and protein-losing pathologies are not yet parameterized, limiting how the new oncotic module responds to inflammation or liver dysfunction.[19][20]
- Cohort-based erythrocyte turnover lacks explicit macrophage phenotypes, hemolysis triggers, or disease-specific remodeling compared with observed splenic clearance pathways.[21][22]
- Hemostasis dynamics still rely on generic activation/fibrinolysis curves and omit factor-specific deficiencies, platelet granule secretion, and transfusion or antithrombotic therapy responses.[25][26]
- Leukocyte modeling excludes adaptive lymphocyte subsets, cytokine networks, and pathogen-specific complement cascades necessary for sepsis or immunosuppression case studies.[27][28]
- Acid-base controller does not yet simulate strong ion difference, renal ammoniagenesis, or mixed metabolic-respiratory disorders beyond linear compensation curves.[29][30]
**Opportunities for improvement**
- Calibrate acute-phase protein responses and capillary permeability effects within the new oncotic and lymphatic model to capture edema and hypoalbuminemia scenarios.[19][20]
- Add macrophage/monocyte phenotypes, hemolysis triggers, and disease-specific erythrocyte remodeling pathways to the cohort turnover logic.[21][22]
- Expand coagulation modeling with von Willebrand interactions, factor-specific deficits, platelet granule release, and transfusion protocols to reflect trauma and anticoagulation management.[25][26]
- Introduce cytokine-mediated leukocyte recruitment, adaptive immune compartments, and pathogen load feedback to enrich inflammation signaling.[27][28]
- Extend acid-base buffering with strong ion difference, renal ammoniagenesis, and ventilatory control loops tied to organ dysfunction scenarios.[29][30]
**Sources**
19. Hahn RG. Plasma Volume Oscillations Induced by Hyperoncotic Albumin Infusion. Life (Basel). 2025;15(1):111.[19]
20. Wu JW, Mack GW. Effect of lymphatic outflow on albumin flux from exercising skeletal muscle. J Appl Physiol. 2001;90(5):1912-1918.[20]
21. Vautrinot J, Poole AW. Platelets mediate the clearance of senescent red blood cells. Blood. 2024;143(7):800-812.[21]
22. Mohandas N, Gallagher PG. Accelerated aging of red blood cells in pathologic states. Blood. 2021;137(18):2429-2437.[22]
23. Peng W, Zhan Y, Yu T, et al. Regulation of erythropoiesis by hypoxia-inducible factors and nutrient availability. BMC Med. 2024;22(1):194.[23]
24. Coneyworth LJ, Ford D, Mathers JC. Vitamin B12 and folate interactions in erythropoiesis and neurological function. Nutrients. 2023;15(5):1120.[24]
25. Real-time imaging of platelet-initiated plasma clot formation and lysis unveils distinct impacts of anticoagulants. Res Pract Thromb Haemost. 2024.[25]
26. Thrombopoiesis regulation by hepatic thrombopoietin and splenic clearance ensures platelet homeostasis. J Thromb Thrombolysis. 2025.[26]
27. The role of complement in thromboinflammation. J Trauma Acute Care Surg. 2024.[27]
28. Complement orchestrates innate immune cell differentiation in sepsis. iScience. 2025.[28]
29. Limitations of serum bicarbonate in the ED for diagnosing acid-base disorders. J Emerg Med. 2024.[29]
30. A physiology-based approach to acid-base disorders. BJA Educ. 2024.[30]
## Esophagus
**Current simulation coverage**
- State machine cycles swallow initiation, primary/secondary peristalsis, clearing, and reflux exposure while tracking bolus volume and peristaltic progress (`src/organs/esophagus.rs:105-218`).
- Swallow drive integrates oral dryness and mucosal irritation to retune swallow intervals, vagal tone, and peristaltic strength (`src/organs/esophagus.rs:113-131`).
- Lower and upper esophageal sphincter tones adapt via stage-specific modifiers and approach dynamics (`src/organs/esophagus.rs:133-150`).
- Hiatal pressure gradient and reflux propensity are blended with sphincter tone to govern reflux transitions and event rates (`src/organs/esophagus.rs:153-244`).
- Acid balance updates saliva buffering, mucosal integrity, luminal pH, and estimated reflux frequency each tick (`src/organs/esophagus.rs:221-249`).
**Physiology findings and observed gaps**
- Physiologic swallowing relies on proximal striated and distal smooth muscle with deglutitive inhibition orchestrated by nucleus ambiguus and enteric circuits, but the model uses a single peristaltic strength scalar without segmental timing.[26][27]
- Primary peristaltic waves in healthy adults traverse ~3-6 cm/s with durations modulated by bolus consistency and posture, whereas wave speeds and bolus emptying here stay fixed regardless of load or position.[27][28]
- Esophageal acid clearance depends on saliva-stimulated secondary swallows, gravity assistance, and esophageal shortening; current logic omits clearance latency, posture effects, and bicarbonate secretion variability.[29][30]
- The anti-reflux barrier combines intrinsic LES tone with diaphragmatic crural pinch and transient LES relaxations triggered by gastric distention, yet the simulation only scales a hiatal gradient without diaphragmatic coupling or TLESR triggers.[31]
**Opportunities for improvement**
- Split the tube into proximal striated and distal smooth segments with deglutitive inhibition timing and enteric reflex loops to cover neurogenic dysphagia and achalasia variants.[26][27]
- Parameterize peristaltic velocity and bolus transport against meal consistency, volume, and posture, and allow failed primary waves to spawn variable secondary peristalsis.[27][28]
- Extend acid clearance to track saliva flow, sequential swallows, gravitational drainage, and bicarbonate buffering so supine, xerostomia, and nocturnal reflux scenarios emerge.[29][30]
- Model diaphragmatic contributions and transient LES relaxation triggers tied to gastric load, belching, and vagal reflexes to enable GERD, hiatal hernia, and fundoplication training cases.[31]
**Sources**
26. StatPearls - Physiology, Esophagus.[26]
27. GI Motility Online - Physiology of esophageal motility.[27]
28. TSRA Primer - Esophageal Motility & Function Testing.[28]
29. PubMed - Esophageal acid clearance testing and clinical significance.[29]
30. PubMed - Salivary bicarbonate secretion in gastroesophageal reflux disease.[30]
31. Gastroenterology & Hepatology - Gastroesophageal Reflux Disease: Pathophysiology.[31]
## Gallbladder
**Current simulation coverage**
- Tracks bile reservoir dynamics (volume, acid concentration, hepatic inflow, bile acid pool, recycling efficiency, mucosal absorption, and gallstone index) within the gallbladder struct (`src/organs/gallbladder.rs:24-102`).
- Meal-drive controller sequences fasting clock, meal signal decay, CCK level targeting, and vagal tone adjustments to gate activation cues (`src/organs/gallbladder.rs:103-145`).
- Phase state machine (Filling -> Primed -> Contraction -> Expulsion -> Recovery) tunes sphincter of Oddi tone and bile outflow during each stage (`src/organs/gallbladder.rs:146-208`).
- Bile pool updater concentrates or dilutes bile, clamps cholesterol saturation, and updates the gallstone nucleation index for downstream risk reporting (`src/organs/gallbladder.rs:209-233`).
**Physiology findings and observed gaps**
- Real gallbladders absorb about 90% of bile water during fasting and eject 50-75% of their contents when CCK triggers contraction alongside sphincter of Oddi relaxation; the model uses fixed constants rather than hormone- and pressure-driven coupling.[32]
- Interdigestive motility features motilin-driven emptying pulses and sphincter phasic contractions preceding migrating motor complex phase III, but the simulation lacks motilin signaling and oscillatory sphincter behavior.[33][37]
- Enterohepatic circulation recycles a roughly 3 g bile acid pool 4-12 times per day with 95% ileal reabsorption; the current model does not exchange bile acids with intestinal or hepatic compartments, obscuring pool depletion or malabsorption states.[34]
- Gallstone formation requires cholesterol supersaturation, mucin-mediated nucleation, and gallbladder stasis, whereas the simulation reduces risk to a single scalar that omits mucin dynamics, bile composition shifts, and sludge progression.[35][36]
**Opportunities for improvement**
- Replace fixed absorption and outflow clamps with transport models that concentrate bile via electrolyte exchange, allow fractional emptying, and coordinate sphincter relaxation with CCK levels to match post-prandial kinetics.[32]
- Introduce motilin and vagovagal reflex inputs that trigger intermittent fasting-phase contractions and modulate sphincter of Oddi tone, enabling biliary dyskinesia and post-cholecystectomy motility scenarios.[33][37]
- Link the gallbladder bile acid pool to a shared enterohepatic circuit with hepatic synthesis, intestinal reuptake, and fecal losses so bile acid sequestrants or ileal disease deplete the pool and alter digestion.[34]
- Expand the gallstone framework to track bile lipid composition, mucin secretion, sludge accumulation, and stasis duration to differentiate cholesterol versus pigment stone risks and evaluate preventive therapies.[35][36]
**Sources**
32. Merck Manual Professional Edition - Overview of Biliary Function.[32]
33. PubMed - Cyclic motility of the sphincter of Oddi.[33]
34. PMC - Nuclear receptor control of enterohepatic circulation.[34]
35. StatPearls - Gallstones (Cholelithiasis).[35]
36. PubMed - Role of gallbladder mucin in pathophysiology of gallstones.[36]
37. PubMed - Differential effects of motilin on interdigestive motility.[37]
## Intestines
**Current simulation coverage**
- Maintains macronutrient absorption, electrolyte reclamation, water reuptake, bile acid recycling, microbiome balance, and inflammatory tone fields within the intestinal struct (`src/organs/intestines.rs:15-113`).
- Internal feeding clock injects nutrient loads, updates motilin and GLP-1 proxies, and drives phase transitions among interdigestive, fed, MMC, ileal brake, and dysmotility states (`src/organs/intestines.rs:111-173`).
- Motility updater blends peristaltic and segmentation indices, lumen volume, and enteric tone to set motility_index, segmentation_index, and mmc_activity scaling (`src/organs/intestines.rs:174-197`).
- Absorption, microbiome, and mucosal routines convert nutrient energy into carbohydrate/fat/protein uptake, adjust electrolyte-water handling, SCFA production, pH, mucosal integrity, inflammation, and bile acid recirculation (`src/organs/intestines.rs:198-287`).
**Physiology findings and observed gaps**
- Physiologically the small intestine absorbs ~95% of carbohydrates and proteins, 90% of water, and recovers bile salts in the ileum while the colon reclaims the last 1-2 L with electrolyte-coupled transport; the model uses fixed rates without segment-specific transporters or fluid budgets.[38][43]
- MMC cycles repeat every ~90-120 minutes with distinct Phase I-IV patterns governed by motilin pulses that prevent bacterial overgrowth; simulation only tracks a scalar mmc_activity and phase enum without propagating cyclical motor waves or bacterial clearing.[39]
- Ileal brake hormones GLP-1 and PYY respond to distal nutrient exposure to slow gastric emptying and upper gut motility, yet the model lacks nutrient-specific triggers or feedback to upstream organs despite tracking hormone_glp1.[40][41]
- Terminal ileum bile-salt reabsorption and vitamin B12 uptake depend on mucosal transporters and flow, whereas the simulation clamps bile acid recirculation to a motility-dependent target without hepatic pool linkage or malabsorption states.[38]
- Microbiota ferment 30 g/day of carbohydrates to produce ~300 mmol SCFAs that drive sodium/water absorption and fuel colonocytes; current logic approximates SCFA generation linearly from fiber load and does not expose ion transport coupling or microbial community shifts.[42][43]
**Opportunities for improvement**
- Partition the intestine into duodenal, jejunal, ileal, and colonic segments with transporter-limited nutrient and water absorption, dynamic bile acid pools, and luminal fluid accounting to capture malabsorption and diarrhea phenotypes.[38][43]
- Implement MMC phase cycling driven by motilin bursts with spatial propagation, allowing fasting length, vagal tone, or opioids to disrupt waves and precipitate SIBO scenarios.[39]
- Couple nutrient sensing to GLP-1/PYY secretion, feeding-clock intervals, and feedback onto stomach, pancreas, and gallbladder controllers so fat vs carbohydrate loads elicit distinct ileal brake responses.[40][41]
- Expand microbiome modeling to track fiber species, SCFA spectra, lumen pH, and epithelial fuel utilization, enabling dysbiosis, antibiotic, or prebiotic interventions to shift absorption and mucosal integrity.[42]
**Sources**
38. StatPearls - Physiology, Small Bowel.[38]
39. Gastroenterology & Hepatology Board Review - Small Intestinal Motility Disorders.[39]
40. PMC - Effects of GLP-1 and incretin-based therapies on gastrointestinal motor function.[40]
41. PubMed - PYY and GLP-1 contribute to feedback inhibition from the canine ileum and colon.[41]
42. PubMed - Colonic health: fermentation and short chain fatty acids.[42]
43. PubMed - Colonic absorption: the importance of short chain fatty acids in man.[43]
## Kidneys
**Current simulation coverage**
- Tracks glomerular filtration rate, renal plasma flow, filtration fraction, osmolality metrics, and endocrine proxies within the kidney struct (`src/organs/kidneys.rs:14-94`).
- Autoregulation routine classifies perfusion into Autoregulated, Hypoperfused, Hyperperfused, or Obstructed states based on renal plasma flow and obstruction heuristics (`src/organs/kidneys.rs:100-151`).
- Perfusion and hormonal controllers adjust sympathetic tone, renin release, aldosterone drive, and ADH sensitivity in response to plasma volume and osmolality (`src/organs/kidneys.rs:115-151`).
- Tubular handling, acid-base, and erythropoietin updates set sodium reabsorption, potassium and urea excretion, urine flow/osmolality, serum osmolality, plasma volume, and EPO secretion each tick (`src/organs/kidneys.rs:152-231`).
**Physiology findings and observed gaps**
- Healthy kidneys filter ~120 mL/min from ~600-720 mL/min renal plasma flow and hold GFR constant across 80-180 mmHg via coupled myogenic and macula densa feedback; the model uses fixed thresholds without afferent/efferent resistance dynamics or nephron flow sensing.[44][45]
- Segmental transport normally reclaims ~65-70% of sodium and water in the proximal tubule, ~25% in Henle segments, and fine-tunes electrolytes distally; the simulation collapses everything into a single tubular reabsorption fraction, limiting malabsorption or diuretic scenarios.[46]
- Urine concentration depends on the countercurrent multiplier, medullary gradient maintenance, and ADH-gated aquaporins to span ~50-1200 mOsm; current clamps ignore gradient washout and aquaporin trafficking.[47]
- Renal acid-base balance requires bicarbonate reclamation plus ammonium and titratable acid excretion across segments, whereas the model condenses compensation into a single acid_base_balance scalar.[48]
- Juxtaglomerular renin release integrates baroreceptor, macula densa, and sympathetic inputs, yet renin and aldosterone here follow simplified algebra that cannot capture RAAS pharmacology or dysregulation.[49]
- Erythropoietin secretion arises from hypoxia-responsive peritubular interstitial cells and intercalated cells, but the simulation ties EPO to a coarse renal oxygenation clamp only.[50]
**Opportunities for improvement**
- Add afferent/efferent arteriole resistance modeling with myogenic and tubuloglomerular feedback loops plus RAAS modulation to reproduce autoregulatory plateaus and pressure-natriuresis shifts.[44][45][49]
- Break the nephron into proximal, loop, distal, and collecting segments with transporter-limited sodium/water reabsorption and endocrine regulation, enabling segment-specific injuries and diuretic effects.[46][47]
- Implement countercurrent multiplier/ exchanger dynamics with medullary gradient washout, aquaporin trafficking, and osmolality feedback to simulate diabetes insipidus, SIADH, or osmotic diuresis.[47]
- Expand acid-base handling to compute bicarbonate reclamation, titratable acid, and ammonium excretion, linking to respiratory compensation and chronic kidney disease buffering limits.[48]
- Drive erythropoietin output from local oxygen tension, fibrosis, and inflammatory cues to support anemia-of-CKD progression and ESA therapy responses.[50]
**Sources**
44. StatPearls - Physiology, Glomerular Filtration Rate.[44]
45. PubMed - Renal autoregulation in health and disease.[45]
46. PMC - Mechanistic insights into renal ion and water transport in the distal nephron.[46]
47. Kidney: Physiology of the Tubular Reabsorption.[47]
48. PubMed - Acid-Base Homeostasis.[48]
49. StatPearls - Physiology, Renin Angiotensin System.[49]
50. PubMed - Renal epithelium regulates erythropoiesis via HIF-dependent suppression of erythropoietin.[50]
## Liver
**Current simulation coverage**
- Multi-state hepatic controller transitions between postabsorptive, fed, fasting, acute phase, and regenerating modes while updating glycogen, lipids, ammonia clearance, and hormone signals each tick (`src/organs/liver.rs:1`).
- Meal-driven hormone routine modulates insulin, glucagon, and cortisol proxies alongside glycogenolysis, gluconeogenesis, and lipogenesis rates to shape fuel handling (`src/organs/liver.rs:73`).
- Bile synthesis, secretion, detox capacity, Kupffer activation, and portal hemodynamics are adjusted through bile/enzymatic update loops (`src/organs/liver.rs:176`).
- Protein synthesis block tracks albumin, clotting factor output, and hepatic fat fraction to summarize synthetic function (`src/organs/liver.rs:214`).
**Physiology findings and observed gaps**
- The simulation uses static portal and arterial flow clamps, but healthy livers receive ~80% portal venous inflow with a hepatic arterial buffer response that compensates dynamically for portal changes.[51][52]
- Hepatic lobules exhibit periportal-pericentral zonation governing carbohydrate, lipid, and ammonia metabolism, whereas the model treats hepatocytes as a single compartment.[55]
- Enterohepatic bile acid cycling involves secretion, ileal reabsorption, and hepatic reconjugation; current logic generates bile locally without coupling to intestinal pools or transporter limits.[54]
- Fasting gluconeogenesis in humans scales from ~1 to 2 mg·kg⁻¹·min⁻¹ with prolonged fasts driving Cori-cycle recycling; fixed-rate clamps in the model underrepresent these range shifts.[56]
- Kupffer cell activation drives cytokine and acute phase cascades based on pattern-recognition signaling; present implementation raises a scalar without linking to inflammatory inputs or downstream APR protein switches.[53]
**Opportunities for improvement**
- Implement dual-inflow hemodynamics with arterial buffer feedback and sinusoidal resistance modulation to recreate portal hypertension and ischemia scenarios.[51][52]
- Add zonated hepatocyte segments with zone-specific enzyme sets (urea cycle periportal, glycolysis/pericentral lipogenesis) to capture differential injury and drug metabolism.[55]
- Couple bile acid production to an enterohepatic pool shared with the intestines, including transporter saturation and fecal loss terms for cholestasis modeling.[54]
- Replace fixed gluconeogenesis/glycogenolysis clamps with hormone- and substrate-responsive pathways calibrated to fasting studies, enabling hypoglycemia and stress testing.[56]
- Expand Kupffer signaling to accept pathogen/toxin inputs and drive acute phase protein synthesis, oxidative stress, and stellate activation responses.[53]
**Sources**
51. World Journal of Gastroenterology - Liver hemodynamics reference values.[51]
52. American Society of Anesthesiologists - Hepatic arterial buffer response overview.[52]
53. StatPearls - Physiology, Liver.[53]
54. StatPearls - Physiology, Bile Secretion.[54]
55. Elsevier - Zonation of hepatic fatty acid metabolism review.[55]
56. American Journal of Physiology - Gluconeogenesis and the Cori cycle in prolonged fasting.[56]
## Lungs
**Current simulation coverage**
- Breathing phase loop advances inhalation, exhalation, and pause states while updating diaphragm kinematics, tidal volume, and respiratory rate targets (`src/organs/lungs.rs:37`).
- Ventilatory state machine shifts between resting, hypercapnic, hypoxic, exercise, and distress modes with chemoreceptor and muscle drive scalars (`src/organs/lungs.rs:108`).
- Gas exchange routine adjusts alveolar PO₂/PCO₂, shunt fraction, SpO₂, and CO₂ elimination based on ventilation and metabolic demand (`src/organs/lungs.rs:246`).
- Pulmonary vascular block tunes pulmonary artery and wedge pressures along with V/Q ratio and dead space fractions (`src/organs/lungs.rs:292`).
**Physiology findings and observed gaps**
- The model collapses regional ventilation/perfusion into single ratios even though human lungs show gravity-dependent gradients (low V/Q at bases, higher at apices) crucial for hypoxemia phenotyping.[57]
- Lung compliance is treated as a scalar, yet in vivo compliance reflects surfactant dynamics, chest wall interaction, and disease-specific hysteresis that shift the pressurevolume curve.[58]
- Diffusing capacity is estimated by linear clamps, whereas normal DL(O₂) ≈ 2025 ml·min⁻¹·mmHg⁻¹ and increases threefold with exercise due to capillary recruitment—effects absent in the current abstraction.[59][60]
- Chemoreceptor control is condensed into a single drive, but central and peripheral chemoreceptors differ in latency and CO₂/O₂ sensitivity; blended logic masks disorders like carotid body failure.[61]
- Distress flag drives shunt fraction heuristics without modeling airway resistance, surfactant loss, or V/Q scatter that define ARDS phenotypes.[57]
**Opportunities for improvement**
- Introduce multi-compartment V/Q modeling (apex/mid/base) with gravity and posture modifiers to support shunt-vs-dead-space diagnostics.[57]
- Track static and dynamic compliance separately with surfactant depletion, fibrosis, and chest wall modifiers to capture recruitment and hysteresis behavior.[58]
- Add diffusing-capacity calculations tied to capillary blood volume and exercise state so DL and end-tidal gradients respond to flow changes.[59][60]
- Split chemoreceptor control into central CO₂/pH and peripheral O₂/CO₂ pathways with time constants and hypoxic potentiation to emulate ventilatory drive disorders.[61]
- Expand distress modeling to include airway resistance, alveolar flooding, and recruitable units rather than a single shunt scalar.[57]
**Sources**
57. StatPearls - Physiology, Pulmonary Ventilation and Perfusion.[57]
58. StatPearls - Physiology, Pulmonary Compliance.[58]
59. MedMuv - Diffusion capacity of the lungs for oxygen.[59]
60. European Respiratory Journal - Reference values for alveolar membrane diffusion capacity.[60]
61. NCBI Bookshelf - Chemical Regulation of Respiration.[61]
## Pancreas
**Current simulation coverage**
- State machine toggles basal, postprandial anabolic, hypoglycemic counterregulation, and beta-cell exhaustion modes while updating endocrine outputs (`src/organs/pancreas.rs:15`).
- Meal simulator modulates glucose, incretin, and autonomic tone inputs to drive insulin, glucagon, somatostatin, and pancreatic polypeptide responses (`src/organs/pancreas.rs:55`).
- Exocrine routines adjust enzyme secretion, bicarbonate output, acinar flow, and ductal pressure against incretin and autonomic cues (`src/organs/pancreas.rs:146`).
- Chronic stress logic tracks beta-cell mass fraction and islet stress index to approximate long-term endocrine reserve (`src/organs/pancreas.rs:119`).
**Physiology findings and observed gaps**
- Ductal secretion depends on CFTR-mediated chloride/bicarbonate exchange achieving ~140 mM bicarbonate, yet the model lacks CFTR or flow-dependent coupling to maintain alkaline secretion.[62][63]
- Incretin physiology (GLP-1/GIP) enhances glucose-stimulated insulin secretion and suppresses glucagon in a glucose-dependent fashion; current logic uses fixed incretin boosts without receptor kinetics.[64]
- Chronic ER stress triggers reversible beta-cell de-differentiation before apoptosis, implying nonlinear mass dynamics beyond the linear decay implemented here.[65]
- Enzyme composition adapts to macronutrient content (e.g., fat increases lipase output), but the simulation scales enzymes uniformly with incretin tone.[66]
- Autonomic tone integrates vagal and sympathetic signaling that co-modulate endocrine and exocrine release; the single autonomic scalar omits frequency-specific vagal bursts and adrenergic suppression.[62]
**Opportunities for improvement**
- Add CFTR and SLC26 exchanger models with flow-dependent secretion and sensitivity to ductal pressure to emulate cystic fibrosis and pancreatitis.[62][63]
- Implement incretin receptor kinetics with glucose thresholds and pharmacologic agonist profiles to study GLP-1 therapies.[64]
- Model beta-cell stress with reversible identity states and thresholds for apoptosis, capturing adaptation vs. failure under chronic load.[65]
- Differentiate enzyme synthesis pathways for amylase, proteases, and lipase responding to nutrient sensing and CCK feedback.[66]
- Split autonomic inputs into vagal (bursting) and sympathetic (tonic) components to simulate stress-induced endocrine shifts.[62]
**Sources**
62. Cells - Bicarbonate Transport in the Exocrine Pancreas.[62]
63. NCBI Bookshelf - Water and Ion Secretion from the Pancreatic Ductal System.[63]
64. Diabetes - Multiple actions of GLP-1 on glucose-stimulated insulin secretion.[64]
65. Cell Reports - Adaptation to chronic ER stress enforces beta-cell plasticity.[65]
66. Pancreapedia - Secretion of the human exocrine pancreas in health and disease.[66]
## Spleen
**Current simulation coverage**
- Splenic controller tracks immune activity, red pulp volume, platelet reservoir, sympathetic tone, cytokine output, and contraction fraction states (`src/organs/spleen.rs:13`).
- State machine transitions among homeostatic, sympathetic contraction, hyperimmune activation, sequestration, and hypofunction modes driving pulp volumes and cytokines (`src/organs/spleen.rs:45`).
- Contraction and immune routines update platelet release, erythrocyte culling, IgM production, and cytokine signals each tick (`src/organs/spleen.rs:72`).
**Physiology findings and observed gaps**
- Real spleens hold ~1/3 of circulating platelets and mobilize contracted red pulp during sympathetic surges; model contraction lacks integration with circulating platelet counts or venous return.[69][71]
- White pulp architecture (periarteriolar lymphoid sheaths, marginal zone B cells) drives rapid responses to blood-borne antigens, whereas simulation aggregates immune activity into a single scalar.[67][70]
- Splenic contraction alters hemoglobin and hematocrit during apnea/diving; current logic adjusts reservoir without systemic hematologic feedbacks.[69]
- Marginal zone B cells and macrophages maintain humoral defense; absence of compartment-specific responses limits modeling of asplenia risks.[70]
- Chronic splenomegaly and hypersplenism alter sequestration thresholds; static volume clamps miss disease-dependent compliance changes.[71]
**Opportunities for improvement**
- Couple splenic reservoir to systemic platelet/erythrocyte pools and venous return to reflect contraction-induced hematologic shifts.[69][71]
- Represent white pulp, marginal zone, and red pulp compartments with discrete immune cell populations and activation kinetics.[67][70]
- Add sympathetic burst inputs tied to cardiovascular simulations to trigger dynamic spleen contraction during exercise or hemorrhage.[69]
- Model splenic compliance changes in infiltrative disease to capture hypersplenism and cytopenia risks.[71]
- Track antigen capture and antibody production timelines to assess vaccine efficacy in asplenic states.[67][70]
**Sources**
67. StatPearls - Physiology, Spleen.[67]
68. Kenhub - Microscopic anatomy of the spleen.[68]
69. Journal of Applied Physiology - Spleen as an erythrocyte reservoir during diving responses.[69]
70. Journal of Immunology - B cells are crucial for splenic marginal zone development.[70]
71. StatPearls - Splenomegaly.[71]
## Spinal Cord
**Current simulation coverage**
- Spinal cord organ tracks signal integrity, ascending/descending conduction velocities, reflex gain, autonomic outflows, locomotor CPG tone, nociceptive facilitation, and perfusion metrics (`src/organs/spinal_cord.rs:14`).
- State machine differentiates intact, concussed, inflammatory, ischemic, and neurogenic shock presentations with corresponding autonomic targets (`src/organs/spinal_cord.rs:48`).
- Integrity and perfusion routines update glial scar index, inflammation, sympathetic/parasympathetic outputs, and locomotor CPG tone over time (`src/organs/spinal_cord.rs:71`).
**Physiology findings and observed gaps**
- Acute SCI guidelines recommend maintaining MAP 7595 mmHg for 37 days and targeting spinal cord perfusion pressure ≥6065 mmHg; model perfusion responds to heuristics without explicit SCPP control.[72][73]
- Locomotor central pattern generators reside in segmental networks coordinating limb pairs; current CPG tone scalar lacks limb-specific oscillators and interlimb coordination.[74]
- Human corticospinal conduction velocities average ~67 m/s with disease-dependent slowing; model uses unvalidated values without temperature or demyelination effects.[75]
- Glial scar formation involves astrocyte proliferation over 12 weeks with STAT3 signaling, producing barriers and cytokine gradients; simulation increments a scar index without temporal staging or astrocyte roles.[76]
- Neurogenic shock features sympathetic failure, hypotension, and bradycardia; present autonomic outputs shift but are not coupled to cardiovascular modules for systemic responses.[72][73]
**Opportunities for improvement**
- Add SCPP calculations (MAP intrathecal pressure) with targets from acute SCI guidelines and allow vasopressor/CSF drainage interventions.[72][73]
- Build bilateral limb CPG models with flexor/extensor half-centers and commissural coupling to study gait adaptations.[74]
- Parameterize conduction velocities with temperature, demyelination, and injury length to match clinical evoked potential data.[75]
- Stage glial scar development (hoursweeks) with astrocyte, fibroblast, and inflammatory cell modules influencing regeneration and cytokines.[76]
- Link autonomic outputs to cardiovascular simulations to reproduce neurogenic shock hemodynamics and vasopressor responses.[72][73]
**Sources**
72. Neurology Practice Guideline - Hemodynamic management of acute spinal cord injury.[72]
73. Journal of Anesthesia, Analgesia and Critical Care - Hemodynamic management narrative review.[73]
74. Wikipedia - Central pattern generator physiology summary with vertebrate locomotion focus.[74]
75. Journal of Neurology, Neurosurgery & Psychiatry - Motor conduction velocity in the human spinal cord.[75]
76. Cells - Current advancements in spinal cord injury glial scar research.[76]
## Stomach
**Current simulation coverage**
- Gastric organ tracks phase (fasting, cephalic, gastric, intestinal, delayed emptying) with vagal tone, hormonal outputs, acid level, motility indices, and gastric volume (`src/organs/stomach.rs:7`).
- Meal routine adjusts ghrelin, gastrin, vagal drive, and nutrient load timing to shift phases and target meal intervals (`src/organs/stomach.rs:63`).
- Secretory update sets acid output, histamine, somatostatin, mucus, and intrinsic factor proxies while motility block governs antral pump strength and emptying rate (`src/organs/stomach.rs:118`).
**Physiology findings and observed gaps**
- Gastrin stimulates ECL-cell histamine release while somatostatin provides paracrine inhibition; current model applies direct scalar adjustments without receptor-mediated feedback loops.[77][78]
- Ghrelin rises pre-meal and is suppressed by nutrient load, integrating with hypothalamic circuits; simulation lowers ghrelin via simple volume clamps lacking macronutrient and circadian modulation.[79]
- MMC phases originate in stomach/duodenum via motilin and 5-HT feedback; model phases do not enforce interdigestive MMC cycling or motilin triggers.[80]
- Human gastric emptying delivers ~23 kcal·min⁻¹ to duodenum with slowing as energy density rises; the current emptying rate is set by motility index without energy-density feedback.[81]
- Gastric emptying kinetics differ for liquids vs solids and respond to osmolarity; single clamp cannot represent nutrient-specific delays.[81]
**Opportunities for improvement**
- Implement receptor-level gastrin→ECL histamine→parietal pathways with somatostatin inhibition and cholinergic disinhibition loops.[77][78]
- Add ghrelin dynamics tied to macronutrient sensing, sleep state, and leptin/IL-1 signaling to integrate appetite control.[79]
- Introduce interdigestive MMC oscillator driven by motilin and 5-HT with suppression during fed state to coordinate gastric clearing.[80]
- Couple gastric emptying to meal energy density, volume, and macronutrient type to reproduce caloric delivery constraints.[81]
- Differentiate liquid versus solid emptying curves with sieving function and osmolar feedback for hypertonic loads.[81]
**Sources**
77. Comprehensive Physiology - Gastric peptides gastrin and somatostatin.[77]
78. American Journal of Physiology - Role of histamine in control of gastric acid secretion.[78]
79. Physiological Reviews - Regulation of ghrelin secretion.[79]
80. Neurogastroenterology & Motility - Migrating motor complex control mechanisms.[80]
81. Gastroenterology - Effect of meal volume and energy density on gastric emptying of carbohydrates.[81]
+24
View File
@@ -17,6 +17,29 @@ int main(void) {
char* sum = ml_patient_summary(p);
if (sum) { printf("%s\n", sum); ml_string_free(sum); }
MLBloodstreamMetrics blood = {0};
if (ml_patient_bloodstream_metrics(p, &blood) == ML_OK) {
printf("Bloodstream: perfusion=%u metabolic=%u oncotic=%.1f mmHg lymph=%.2f mL/min RBC young/mature/senescent=%.0f/%.0f/%.0f%% hif=%.2f iron=%.0f mg\n",
(unsigned)blood.perfusion_state,
(unsigned)blood.metabolic_state,
blood.oncotic_pressure_mm_hg,
blood.lymphatic_return_ml_min,
blood.rbc_young_fraction * 100.0f,
blood.rbc_mature_fraction * 100.0f,
blood.rbc_senescent_fraction * 100.0f,
blood.hif_activation,
blood.iron_store_mg);
}
MLBladderMetrics bladder = {0};
if (ml_patient_bladder_metrics(p, &bladder) == ML_OK) {
printf("Bladder: phase=%u urgency=%.0f%% guard=%.0f%% hold=%.0f%%\n",
(unsigned)bladder.phase,
bladder.urgency * 100.0f,
bladder.guarding_reflex_gain * 100.0f,
bladder.voluntary_hold_fraction * 100.0f);
}
char* h = ml_patient_organ_summary(p, ML_ORGAN_HEART);
if (h) { printf("%s\n", h); ml_string_free(h); }
@@ -24,3 +47,4 @@ int main(void) {
return 0;
}
+838
View File
@@ -0,0 +1,838 @@
use medicallib_rust::{
bmi_measurement, calculate_bmi, classify_bmi, BloodPressure, EkgLead, Heart, Measurement,
OrganType, Patient, Result as MedicalResult, VitalSign,
};
use std::fmt::Write as _;
use std::io::{self, Write};
use std::sync::OnceLock;
const EXTRA_ORGANS: [OrganType; 11] = [
OrganType::Brain,
OrganType::SpinalCord,
OrganType::Stomach,
OrganType::Liver,
OrganType::Gallbladder,
OrganType::Pancreas,
OrganType::Intestines,
OrganType::Esophagus,
OrganType::Kidneys,
OrganType::Bladder,
OrganType::Spleen,
];
const MONITORED_ORGANS: [OrganType; 14] = [
OrganType::Heart,
OrganType::Bloodstream,
OrganType::Lungs,
OrganType::Brain,
OrganType::SpinalCord,
OrganType::Stomach,
OrganType::Liver,
OrganType::Gallbladder,
OrganType::Pancreas,
OrganType::Intestines,
OrganType::Esophagus,
OrganType::Kidneys,
OrganType::Bladder,
OrganType::Spleen,
];
const VITAL_SIGNS: [VitalSign; 6] = [
VitalSign::HeartRate,
VitalSign::RespiratoryRate,
VitalSign::SystolicBP,
VitalSign::DiastolicBP,
VitalSign::TemperatureC,
VitalSign::SpO2,
];
const DASHBOARD_WIDTH: usize = 88;
const COLOR_RESET: &str = "\x1b[0m";
const COLOR_TITLE: &str = "\x1b[38;5;111m";
const COLOR_SECTION: &str = "\x1b[38;5;81m";
const COLOR_LABEL: &str = "\x1b[38;5;245m";
const COLOR_MUTED: &str = "\x1b[38;5;240m";
const COLOR_ACCENT: &str = "\x1b[38;5;153m";
const COLOR_SUCCESS: &str = "\x1b[38;5;114m";
const COLOR_WARNING: &str = "\x1b[38;5;221m";
const COLOR_ERROR: &str = "\x1b[38;5;203m";
const COLOR_PROMPT: &str = "\x1b[38;5;117m";
const HELP_TEXT: &str = r#"
Available commands:
help Show this help text
tick [dt] Advance the simulation by dt seconds (default: configured step)
run <steps> [dt] Run multiple ticks back-to-back
set dt <seconds> Update the default tick size
set arrhythmia <on|off> Force arrhythmic behaviour on the heart
set tone <value> Set heart autonomic tone (-1.0..=1.0)
set svr <value> Set heart systemic vascular resistance (mmHg*min/L)
set ekg <lead...> Configure EKG leads (e.g., set ekg I II V1 V5)
set glucose <mg/dL> Override blood glucose
set spo2 <percent> Override blood SpO2 (0-100)
set bp <systolic> <diastolic> Override brachial blood pressure
set bmi <weight_kg> <height_m> Update the tracked BMI inputs
bmi <weight_kg> <height_m> Compute BMI on the fly without storing it
summary Print the aggregate patient summary string
organs Print one-line summaries for every organ
reset Reset the patient and vitals to defaults
quit | exit | q Leave the monitor
(empty input) Advance once using the configured step size
"#;
struct MonitorState {
patient: Patient,
sim_time: f32,
tick_seconds: f32,
bmi_inputs: (f32, f32),
}
impl MonitorState {
fn new() -> MedicalResult<Self> {
let mut patient = Patient::new("monitor")?.initialize_default().with_lungs();
for organ in EXTRA_ORGANS {
patient = patient.with_organ(organ);
}
Ok(Self {
patient,
sim_time: 0.0,
tick_seconds: 0.5,
bmi_inputs: (82.0, 1.84),
})
}
fn reset(&mut self) -> MedicalResult<()> {
*self = Self::new()?;
Ok(())
}
fn advance(&mut self, dt: f32) {
if dt <= 0.0 {
return;
}
self.patient.update(dt);
self.sim_time += dt;
}
fn heart_mut(&mut self) -> Option<&mut Heart> {
self.patient.find_organ_typed_mut::<Heart>()
}
}
enum CommandOutcome {
Continue(String),
Exit,
}
fn main() -> MedicalResult<()> {
let mut state = MonitorState::new()?;
let mut status = String::from("Type 'help' to list available commands.");
let stdin = io::stdin();
let mut input = String::new();
loop {
render_dashboard(&state, &status);
print!("{}", prompt_text());
io::stdout().flush().expect("flush stdout");
input.clear();
let read = match stdin.read_line(&mut input) {
Ok(n) => n,
Err(err) => {
status = format!("Failed to read input: {err}");
continue;
}
};
if read == 0 {
status = String::from("End of input detected, shutting down.");
render_dashboard(&state, &status);
println!();
break;
}
let trimmed = input.trim();
if trimmed.is_empty() {
let dt = state.tick_seconds;
state.advance(dt);
status = format!("Advanced simulation by {:.2} s using the default step.", dt);
continue;
}
match handle_command(&mut state, trimmed) {
Ok(CommandOutcome::Continue(message)) => {
status = message;
}
Ok(CommandOutcome::Exit) => {
status = String::from("Exiting monitor.");
render_dashboard(&state, &status);
println!();
break;
}
Err(err) => {
status = format!("Error: {err}");
}
}
}
println!("Goodbye!");
Ok(())
}
fn handle_command(state: &mut MonitorState, input: &str) -> Result<CommandOutcome, String> {
let mut parts = input.split_whitespace();
let cmd = parts
.next()
.ok_or_else(|| String::from("expected a command"))?
.to_ascii_lowercase();
match cmd.as_str() {
"help" => Ok(CommandOutcome::Continue(String::from(HELP_TEXT))),
"tick" => {
let dt = match parts.next() {
Some(raw) => parse_f32(raw)?,
None => state.tick_seconds,
};
if dt <= 0.0 {
return Err(String::from("dt must be greater than 0"));
}
state.advance(dt);
Ok(CommandOutcome::Continue(format!(
"Advanced simulation by {:.2} s.",
dt
)))
}
"run" => {
let steps_raw = parts
.next()
.ok_or_else(|| String::from("run expects a number of steps"))?;
let steps: usize = steps_raw
.parse()
.map_err(|_| format!("could not parse steps '{steps_raw}'"))?;
if steps == 0 {
return Err(String::from("steps must be greater than 0"));
}
let dt = match parts.next() {
Some(raw) => parse_f32(raw)?,
None => state.tick_seconds,
};
if dt <= 0.0 {
return Err(String::from("dt must be greater than 0"));
}
for _ in 0..steps {
state.advance(dt);
}
Ok(CommandOutcome::Continue(format!(
"Ran {steps} step(s) at {:.2} s per step.",
dt
)))
}
"set" => handle_set_command(state, parts),
"summary" => Ok(CommandOutcome::Continue(state.patient.patient_summary())),
"organs" => {
let mut details = String::new();
for organ in MONITORED_ORGANS {
let _ = writeln!(
details,
"{} -> {}",
organ_label(organ),
organ_snapshot(&state.patient, organ)
);
}
Ok(CommandOutcome::Continue(details))
}
"reset" => {
state.reset().map_err(|err| err.to_string())?;
Ok(CommandOutcome::Continue(String::from(
"Patient and monitor reset to defaults.",
)))
}
"bmi" => {
let weight_raw = parts
.next()
.ok_or_else(|| String::from("bmi expects weight in kg"))?;
let height_raw = parts
.next()
.ok_or_else(|| String::from("bmi expects height in m"))?;
let weight = parse_f32(weight_raw)?;
let height = parse_f32(height_raw)?;
let value = calculate_bmi(weight, height).map_err(|err| err.to_string())?;
let measurement = Measurement::new(value, "kg/m^2");
let class = classify_bmi(value);
Ok(CommandOutcome::Continue(format!(
"BMI {:.2} {} => {:?}",
measurement.value, measurement.unit, class
)))
}
"quit" | "exit" | "q" => Ok(CommandOutcome::Exit),
other => Err(format!("unknown command '{other}'")),
}
}
fn handle_set_command<'a>(
state: &mut MonitorState,
mut parts: impl Iterator<Item = &'a str>,
) -> Result<CommandOutcome, String> {
let field = parts
.next()
.ok_or_else(|| String::from("set expects a field to modify"))?
.to_ascii_lowercase();
match field.as_str() {
"dt" | "step" => {
let raw = parts
.next()
.ok_or_else(|| String::from("set dt expects a numeric value"))?;
let dt = parse_f32(raw)?;
if dt <= 0.0 {
return Err(String::from("step size must be > 0"));
}
state.tick_seconds = dt;
Ok(CommandOutcome::Continue(format!(
"Default tick size set to {:.2} s.",
dt
)))
}
"arrhythmia" => {
let flag = parse_toggle(parts.next())?;
let heart = state
.heart_mut()
.ok_or_else(|| String::from("heart organ is not present"))?;
heart.arrhythmia = flag;
Ok(CommandOutcome::Continue(format!(
"Heart arrhythmia forcing set to {}.",
yes_no(flag)
)))
}
"tone" => {
let raw = parts
.next()
.ok_or_else(|| String::from("set tone expects a value"))?;
let value = parse_f32(raw)?;
let clamped = value.clamp(-1.0, 1.0);
let heart = state
.heart_mut()
.ok_or_else(|| String::from("heart organ is not present"))?;
heart.autonomic_tone = clamped;
Ok(CommandOutcome::Continue(format!(
"Heart autonomic tone set to {:+.2} (input {:+.2}).",
clamped, value
)))
}
"svr" => {
let raw = parts
.next()
.ok_or_else(|| String::from("set svr expects a value"))?;
let value = parse_f32(raw)?;
if value <= 0.0 {
return Err(String::from("systemic vascular resistance must be > 0"));
}
let heart = state
.heart_mut()
.ok_or_else(|| String::from("heart organ is not present"))?;
heart.systemic_vascular_resistance = value;
Ok(CommandOutcome::Continue(format!(
"Heart systemic vascular resistance set to {:.2} mmHg*min/L.",
value
)))
}
"ekg" => {
let tokens: Vec<_> = parts.collect();
if tokens.is_empty() {
return Err(String::from("set ekg expects one or more lead identifiers"));
}
let mut leads = Vec::with_capacity(tokens.len());
for token in tokens {
leads.push(parse_lead(token)?);
}
let summary = leads
.iter()
.map(|lead| lead_label(*lead))
.collect::<Vec<_>>()
.join(", ");
state.patient.configure_ekg_leads(leads);
Ok(CommandOutcome::Continue(format!(
"EKG leads set to {summary}."
)))
}
"glucose" => {
let raw = parts
.next()
.ok_or_else(|| String::from("set glucose expects mg/dL"))?;
let value = parse_f32(raw)?;
state.patient.blood.glucose_mg_dl = value;
Ok(CommandOutcome::Continue(format!(
"Blood glucose set to {:.1} mg/dL.",
value
)))
}
"spo2" => {
let raw = parts
.next()
.ok_or_else(|| String::from("set spo2 expects a percentage"))?;
let value = parse_f32(raw)?.clamp(0.0, 100.0);
state.patient.blood.spo2_pct = value;
Ok(CommandOutcome::Continue(format!(
"Blood SpO2 set to {:.0}%.",
value
)))
}
"bp" | "bloodpressure" => {
let systolic_raw = parts
.next()
.ok_or_else(|| String::from("set bp expects systolic and diastolic values"))?;
let diastolic_raw = parts
.next()
.ok_or_else(|| String::from("set bp expects systolic and diastolic values"))?;
let systolic = parse_u16(systolic_raw)?;
let diastolic = parse_u16(diastolic_raw)?;
if systolic <= diastolic {
return Err(String::from("systolic must be greater than diastolic"));
}
state.patient.blood_pressure = BloodPressure {
systolic,
diastolic,
};
Ok(CommandOutcome::Continue(format!(
"Blood pressure set to {}/{} mmHg.",
systolic, diastolic
)))
}
"bmi" => {
let weight_raw = parts
.next()
.ok_or_else(|| String::from("set bmi expects weight in kg"))?;
let height_raw = parts
.next()
.ok_or_else(|| String::from("set bmi expects height in m"))?;
let weight = parse_f32(weight_raw)?;
let height = parse_f32(height_raw)?;
let measurement = bmi_measurement(weight, height).map_err(|err| err.to_string())?;
let class = classify_bmi(measurement.value);
state.bmi_inputs = (weight, height);
Ok(CommandOutcome::Continue(format!(
"Tracked BMI updated -> {:.2} {} ({:?}).",
measurement.value, measurement.unit, class
)))
}
other => Err(format!("unknown field '{other}'")),
}
}
fn render_dashboard(state: &MonitorState, status: &str) {
clear_screen();
println!("{}", banner_line("MedicalLib Console Monitor"));
let overview_line = [
accent(format!("t = {:.2} s", state.sim_time)),
muted(" | "),
accent(format!("dt = {:.2} s", state.tick_seconds)),
muted(" | "),
accent(format!("Organs {}", MONITORED_ORGANS.len())),
muted(" | "),
accent(format!("Vitals {}", VITAL_SIGNS.len())),
]
.join("");
println!(" {overview_line}");
println!();
println!("{}", section_line("Simulation"));
println!(
"{}",
stat_line(
"Simulation time",
accent(format!("{:.2} s", state.sim_time))
)
);
println!(
"{}",
stat_line("Step size", accent(format!("{:.2} s", state.tick_seconds)))
);
println!(
"{}",
stat_line(
"Tracked vitals",
accent(
VITAL_SIGNS
.iter()
.map(|v| format!("{:?}", v))
.collect::<Vec<_>>()
.join(", ")
)
)
);
println!();
println!("{}", section_line("Circulation"));
let bp = state.patient.blood_pressure;
println!(
"{}",
stat_line(
"Blood pressure",
format!(
"{} {}",
accent(format!("{bp}")),
validity_tag(bp.validate())
)
)
);
let blood = &state.patient.blood;
let mut chemistry_parts = vec![
accent(format!("Hgb {:.1} g/dL", blood.hemoglobin_g_dl)),
muted(" | "),
accent(format!("Hct {:.1}%", blood.hematocrit_pct)),
muted(" | "),
accent(format!("SpO2 {:.0}%", blood.spo2_pct)),
muted(" | "),
accent(format!("Glucose {:.1} mg/dL", blood.glucose_mg_dl)),
muted(" "),
];
chemistry_parts.push(validity_tag(blood.validate()));
let chemistry = chemistry_parts.join("");
println!("{}", stat_line("Blood chemistry", chemistry));
println!("{}", section_line("Electrocardiogram"));
match state.patient.ekg_snapshot() {
Some(snapshot) => {
println!(
"{}",
stat_line(
"Rhythm",
format!(
"{:?} | {:.0} bpm | RR {:.3} s",
snapshot.rhythm, snapshot.heart_rate_bpm, snapshot.rr_interval_s
)
)
);
println!(
"{}",
stat_line(
"Axis",
format!(
"{:+.0} deg | variability {:.2}",
snapshot.frontal_axis_deg, snapshot.variability_index
)
)
);
for (idx, chunk) in snapshot.lead_samples.chunks(6).enumerate() {
let label = if idx == 0 { "Leads" } else { "" };
let body = chunk
.iter()
.map(|sample| {
format!("{} {:+.2}mV", lead_label(sample.lead), sample.amplitude_mv)
})
.collect::<Vec<_>>()
.join(" ");
println!("{}", stat_line(label, body));
}
}
None => {
let message = if state.patient.ekg_monitor().is_some() {
muted("monitor waiting for first snapshot")
} else {
muted("monitor not configured")
};
println!("{}", stat_line("Status", message));
}
}
println!();
let (weight, height) = state.bmi_inputs;
let bmi_line = match bmi_measurement(weight, height) {
Ok(measurement) => {
let class = classify_bmi(measurement.value);
vec![
accent(format!("{:.1} kg", weight)),
muted(" / "),
accent(format!("{:.2} m", height)),
muted(" -> "),
accent(format!("{:.2} {}", measurement.value, measurement.unit)),
muted(" ("),
accent(format!("{:?}", class)),
muted(")"),
]
.join("")
}
Err(err) => colorize(
COLOR_ERROR,
format!("{:.1} kg / {:.2} m -> error ({err})", weight, height),
),
};
println!("{}", stat_line("BMI inputs", bmi_line));
println!();
println!("{}", section_line("Heart"));
if let Some(heart) = state.patient.find_organ_typed::<Heart>() {
let heart_rate = vec![
accent(format!("{:.0} bpm", heart.heart_rate_bpm)),
muted(" | rhythm "),
accent(format!("{:?}", heart.rhythm_state)),
]
.join("");
println!("{}", stat_line("Heart rate", heart_rate));
let cardiac_output = vec![
accent(format!("{:.1} L/min", heart.cardiac_output_l_min)),
muted(" | tone "),
accent(format!("{:+.2}", heart.autonomic_tone)),
muted(" | SVR "),
accent(format!("{:.1}", heart.systemic_vascular_resistance)),
]
.join("");
println!("{}", stat_line("Output", cardiac_output));
let arrhythmia_tag = if heart.arrhythmia {
colorize(COLOR_WARNING, "forced arrhythmia".to_string())
} else {
colorize(COLOR_SUCCESS, "intrinsic rhythm".to_string())
};
let arrhythmia_line = vec![
arrhythmia_tag,
muted(" | EF "),
accent(format!("{:.0}%", heart.ejection_fraction * 100.0)),
muted(" | MAP ~"),
accent(format!("{:.0} mmHg", mean_arterial_pressure(bp))),
]
.join("");
println!("{}", stat_line("Arrhythmia", arrhythmia_line));
} else {
println!(
"{}",
stat_line(
"Heart",
colorize(COLOR_WARNING, "<not attached>".to_string())
)
);
}
println!();
println!("{}", section_line("Organ Snapshots"));
for organ in MONITORED_ORGANS {
println!("{}", organ_line(&state.patient, organ));
}
println!();
println!("{}", section_line("Status"));
let mut printed_status = false;
for line in status.lines() {
let styled = status_line(line);
if styled.is_empty() {
continue;
}
println!(" {}", styled);
printed_status = true;
}
if !printed_status {
println!(" {}", muted("No recent actions."));
}
println!();
println!(
" {}",
muted("Press Enter to advance once or type 'help' for commands.")
);
}
fn organ_snapshot(patient: &Patient, kind: OrganType) -> String {
patient
.organ_summary(kind)
.unwrap_or_else(|err| format!("n/a ({err})"))
}
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",
OrganType::Stomach => "Stomach",
OrganType::Liver => "Liver",
OrganType::Gallbladder => "Gallbladder",
OrganType::Pancreas => "Pancreas",
OrganType::Intestines => "Intestines",
OrganType::Esophagus => "Esophagus",
OrganType::Kidneys => "Kidneys",
OrganType::Bladder => "Bladder",
OrganType::Spleen => "Spleen",
}
}
fn parse_f32(raw: &str) -> Result<f32, String> {
raw.parse::<f32>()
.map_err(|_| format!("unable to parse '{raw}' as a decimal number"))
}
fn parse_u16(raw: &str) -> Result<u16, String> {
raw.parse::<u16>()
.map_err(|_| format!("unable to parse '{raw}' as an integer"))
}
fn parse_toggle(value: Option<&str>) -> Result<bool, String> {
let raw = value.ok_or_else(|| String::from("expected on/off"))?;
match raw.to_ascii_lowercase().as_str() {
"on" | "true" | "1" | "yes" => Ok(true),
"off" | "false" | "0" | "no" => Ok(false),
other => Err(format!("expected on/off but received '{other}'")),
}
}
fn parse_lead(raw: &str) -> Result<EkgLead, String> {
let upper = raw.trim().to_ascii_uppercase();
match upper.as_str() {
"I" => Ok(EkgLead::I),
"II" => Ok(EkgLead::II),
"III" => Ok(EkgLead::III),
"AVR" => Ok(EkgLead::AVR),
"AVL" => Ok(EkgLead::AVL),
"AVF" => Ok(EkgLead::AVF),
"V1" => Ok(EkgLead::V1),
"V2" => Ok(EkgLead::V2),
"V3" => Ok(EkgLead::V3),
"V4" => Ok(EkgLead::V4),
"V5" => Ok(EkgLead::V5),
"V6" => Ok(EkgLead::V6),
other => Err(format!("unknown lead '{other}'")),
}
}
fn lead_label(lead: EkgLead) -> &'static str {
match lead {
EkgLead::I => "I",
EkgLead::II => "II",
EkgLead::III => "III",
EkgLead::AVR => "aVR",
EkgLead::AVL => "aVL",
EkgLead::AVF => "aVF",
EkgLead::V1 => "V1",
EkgLead::V2 => "V2",
EkgLead::V3 => "V3",
EkgLead::V4 => "V4",
EkgLead::V5 => "V5",
EkgLead::V6 => "V6",
}
}
fn yes_no(value: bool) -> &'static str {
if value {
"yes"
} else {
"no"
}
}
fn mean_arterial_pressure(bp: BloodPressure) -> f32 {
let systolic = bp.systolic as f32;
let diastolic = bp.diastolic as f32;
diastolic + (systolic - diastolic) / 3.0
}
fn clear_screen() {
print!("\x1b[2J\x1b[H");
let _ = io::stdout().flush();
}
fn prompt_text() -> String {
if colors_enabled() {
format!("{} ", colorize(COLOR_PROMPT, "monitor>"))
} else {
String::from("monitor> ")
}
}
fn banner_line(title: &str) -> String {
let width = DASHBOARD_WIDTH.max(title.len() + 4);
colorize(
COLOR_TITLE,
format!("{:=^width$}", format!(" {title} "), width = width),
)
}
fn section_line(title: &str) -> String {
colorize(
COLOR_SECTION,
format!("{:-^width$}", format!(" {title} "), width = DASHBOARD_WIDTH),
)
}
fn stat_line(label: &str, value: impl Into<String>) -> String {
let label_cell = colorize(COLOR_LABEL, format!("{label:<18}:"));
format!(" {label_cell} {}", value.into())
}
fn accent(text: impl Into<String>) -> String {
colorize(COLOR_ACCENT, text)
}
fn muted(text: impl Into<String>) -> String {
colorize(COLOR_MUTED, text)
}
fn validity_tag(ok: bool) -> String {
if ok {
colorize(COLOR_SUCCESS, "[OK]".to_string())
} else {
colorize(COLOR_ERROR, "[CHECK]".to_string())
}
}
fn organ_line(patient: &Patient, kind: OrganType) -> String {
let label = colorize(COLOR_ACCENT, format!("{:<12}:", organ_label(kind)));
let summary = organ_snapshot(patient, kind);
let styled_summary = style_snapshot(&summary);
format!(" {label} {styled_summary}")
}
fn style_snapshot(text: &str) -> String {
let lowered = text.to_ascii_lowercase();
if lowered.contains("error") {
colorize(COLOR_ERROR, text.to_string())
} else if lowered.starts_with("n/a") {
colorize(COLOR_WARNING, text.to_string())
} else if lowered.contains("stable") || lowered.contains("normal") {
colorize(COLOR_SUCCESS, text.to_string())
} else {
text.to_string()
}
}
fn status_line(line: &str) -> String {
let trimmed = line.trim();
if trimmed.is_empty() {
return String::new();
}
let lowered = trimmed.to_ascii_lowercase();
if lowered.contains("error") {
colorize(COLOR_ERROR, trimmed.to_string())
} else if lowered.contains("warning") || lowered.contains("caution") {
colorize(COLOR_WARNING, trimmed.to_string())
} else if lowered.starts_with("advanced")
|| lowered.starts_with("ran")
|| lowered.starts_with("set ")
|| lowered.starts_with("blood")
|| lowered.starts_with("heart")
|| lowered.starts_with("patient")
|| lowered.starts_with("tracked")
{
colorize(COLOR_SUCCESS, trimmed.to_string())
} else {
accent(trimmed.to_string())
}
}
fn colorize(code: &str, text: impl Into<String>) -> String {
let text = text.into();
if colors_enabled() {
format!("{code}{text}{COLOR_RESET}")
} else {
text
}
}
fn colors_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
use std::io::IsTerminal;
std::env::var_os("NO_COLOR").is_none() && io::stdout().is_terminal()
})
}
+105
View File
@@ -91,6 +91,51 @@
*/
#define ML_ORGAN_SPLEEN 12
/**
* Organ code for `OrganType::Bloodstream`.
*/
#define ML_ORGAN_BLOODSTREAM 13
enum MLBladderPhase
#ifdef __cplusplus
: uint32_t
#endif // __cplusplus
{
Filling = 0,
Voiding = 1,
PostVoidRefractory = 2,
};
#ifndef __cplusplus
typedef uint32_t MLBladderPhase;
#endif // __cplusplus
enum MLMetabolicState
#ifdef __cplusplus
: uint32_t
#endif // __cplusplus
{
Aerobic = 0,
CompensatedAnaerobic = 1,
AnaerobicCrisis = 2,
};
#ifndef __cplusplus
typedef uint32_t MLMetabolicState;
#endif // __cplusplus
enum MLPerfusionState
#ifdef __cplusplus
: uint32_t
#endif // __cplusplus
{
Balanced = 0,
Compensated = 1,
Hypovolemic = 2,
Shock = 3,
};
#ifndef __cplusplus
typedef uint32_t MLPerfusionState;
#endif // __cplusplus
/**
* Opaque patient handle type for C consumers. Wraps a heap-allocated `Patient`.
*/
@@ -98,6 +143,54 @@ typedef struct MLPatient {
void *inner;
} MLPatient;
typedef struct MLBloodstreamMetrics {
MLPerfusionState perfusion_state;
MLMetabolicState metabolic_state;
float total_volume_l;
float plasma_volume_l;
float red_cell_volume_l;
float plasma_albumin_g_dl;
float plasma_globulin_g_dl;
float plasma_fibrinogen_g_dl;
float oncotic_pressure_mm_hg;
float lymphatic_return_ml_min;
float rbc_young_fraction;
float rbc_mature_fraction;
float rbc_senescent_fraction;
float erythropoiesis_rate_ml_per_day;
float erythrocyte_clearance_ml_per_day;
float iron_store_mg;
float folate_store_mg;
float b12_store_mcg;
float hif_activation;
float oxygen_supply_demand_ratio;
} MLBloodstreamMetrics;
typedef struct MLBladderMetrics {
MLBladderPhase phase;
float volume_ml;
float capacity_ml;
float pressure_cm_h2o;
float detrusor_pressure_cm_h2o;
float pressure_safety_limit_cm_h2o;
float afferent_signal;
float urgency;
float cortical_inhibition;
float voluntary_hold_command;
float cortical_gate_fraction;
float voluntary_hold_fraction;
float guarding_reflex_gain;
float pontine_storage_signal;
float pontine_micturition_signal;
float hypogastric_efferent;
float pudendal_efferent;
float parasympathetic_drive;
float sympathetic_drive;
float somatic_drive;
float pressure_stress_index;
float time_above_safety_limit_s;
} MLBladderMetrics;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
@@ -134,6 +227,17 @@ void ml_string_free(char *s);
*/
int32_t ml_patient_update(struct MLPatient *p, float dt_seconds);
/**
* Populate bloodstream metrics for the patient. Returns ML_OK on success.
*/
int32_t ml_patient_bloodstream_metrics(const struct MLPatient *p,
struct MLBloodstreamMetrics *out_metrics);
/**
* Populate bladder metrics for the patient. Returns ML_OK on success.
*/
int32_t ml_patient_bladder_metrics(const struct MLPatient *p, struct MLBladderMetrics *out_metrics);
/**
* Return organ summary by type code. See header for codes. Caller frees string.
*/
@@ -144,3 +248,4 @@ char *ml_patient_organ_summary(const struct MLPatient *p, uint32_t organ_code);
#endif // __cplusplus
#endif /* MEDICALLIB_RUST_MEDICALLIB_H */
+483
View File
@@ -0,0 +1,483 @@
//! ECG (electrocardiogram) simulation utilities tightly coupled to the heart organ.
//!
//! The monitor exposes a configurable set of leads and produces synthetic waveforms that
//! respond to the hemodynamic and electrophysiological state of the [`Heart`]. The
//! implementation intentionally mirrors common surface ECG morphology without aiming for
//! diagnostic fidelity.
use crate::organs::{CardiacRhythmState, Heart, Organ};
use core::f32::consts::TAU;
const MIN_RR_INTERVAL_S: f32 = 0.3;
const MAX_RR_INTERVAL_S: f32 = 2.5;
const DEFAULT_LEADS: [EkgLead; 12] = [
EkgLead::I,
EkgLead::II,
EkgLead::III,
EkgLead::AVR,
EkgLead::AVL,
EkgLead::AVF,
EkgLead::V1,
EkgLead::V2,
EkgLead::V3,
EkgLead::V4,
EkgLead::V5,
EkgLead::V6,
];
/// Simplified representation of the standard ECG leads.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(u8)]
pub enum EkgLead {
/// Standard limb lead I.
I = 0,
/// Standard limb lead II.
II,
/// Standard limb lead III.
III,
/// Augmented limb lead aVR.
AVR,
/// Augmented limb lead aVL.
AVL,
/// Augmented limb lead aVF.
AVF,
/// Precordial lead V1.
V1,
/// Precordial lead V2.
V2,
/// Precordial lead V3.
V3,
/// Precordial lead V4.
V4,
/// Precordial lead V5.
V5,
/// Precordial lead V6.
V6,
}
impl EkgLead {
/// Returns the canonical ordering for a 12-lead ECG.
pub const fn standard_order() -> &'static [EkgLead] {
&DEFAULT_LEADS
}
fn geometry(self) -> LeadVector {
use EkgLead::*;
match self {
I => LeadVector::new(1.0, 0.1, 0.05),
II => LeadVector::new(0.6, 0.9, 0.1),
III => LeadVector::new(-0.1, 1.0, 0.12),
AVR => LeadVector::new(-0.9, -0.3, -0.05),
AVL => LeadVector::new(0.7, -0.2, 0.02),
AVF => LeadVector::new(0.0, 1.0, 0.12),
V1 => LeadVector::new(-0.7, 0.05, 1.0),
V2 => LeadVector::new(-0.3, 0.15, 1.0),
V3 => LeadVector::new(0.0, 0.2, 0.9),
V4 => LeadVector::new(0.3, 0.25, 0.8),
V5 => LeadVector::new(0.7, 0.25, 0.7),
V6 => LeadVector::new(0.9, 0.2, 0.6),
}
}
}
#[derive(Debug, Clone, Copy)]
struct LeadVector {
lateral: f32,
inferior: f32,
anterior: f32,
}
impl LeadVector {
const fn new(lateral: f32, inferior: f32, anterior: f32) -> Self {
Self {
lateral,
inferior,
anterior,
}
}
fn dot(self, other: Self) -> f32 {
self.lateral * other.lateral
+ self.inferior * other.inferior
+ self.anterior * other.anterior
}
fn normalized(self) -> Self {
let mag = (self.lateral * self.lateral
+ self.inferior * self.inferior
+ self.anterior * self.anterior)
.sqrt();
if mag <= f32::EPSILON {
return Self::new(0.0, 0.0, 0.0);
}
Self::new(
(self.lateral / mag).clamp(-1.5, 1.5),
(self.inferior / mag).clamp(-1.5, 1.5),
(self.anterior / mag).clamp(-1.5, 1.5),
)
}
}
/// Snapshot of the cardiac state relevant for ECG generation.
#[derive(Debug, Clone)]
pub struct HeartElectricalState {
/// Current heart rate in beats per minute.
pub heart_rate_bpm: f32,
/// Sympathetic versus parasympathetic balance (negative favors vagal).
pub autonomic_tone: f32,
/// Inotropic state relative to baseline (1.0 equals nominal).
pub contractility_index: f32,
/// Fraction of beats affected by conduction irregularity.
pub arrhythmia_burden: f32,
/// Stroke volume per beat expressed in milliliters.
pub stroke_volume_ml: f32,
/// Cardiac output in liters per minute.
pub cardiac_output_l_min: f32,
/// Estimated atrial preload pressure in millimeters of mercury.
pub preload_mm_hg: f32,
/// Effective arterial afterload pressure in millimeters of mercury.
pub afterload_mm_hg: f32,
/// Venous return rate feeding the heart in liters per minute.
pub venous_return_l_min: f32,
/// Coronary perfusion pressure available for myocardial oxygenation.
pub coronary_perfusion_mm_hg: f32,
/// Fraction of ventricular volume ejected each beat (0.0-1.0).
pub ejection_fraction: f32,
/// Classified rhythm state for pacing the waveform generator.
pub rhythm: CardiacRhythmState,
}
impl From<&Heart> for HeartElectricalState {
fn from(heart: &Heart) -> Self {
Self {
heart_rate_bpm: heart.heart_rate_bpm,
autonomic_tone: heart.autonomic_tone,
contractility_index: heart.contractility_index,
arrhythmia_burden: heart.arrhythmia_burden,
stroke_volume_ml: heart.stroke_volume_ml,
cardiac_output_l_min: heart.cardiac_output_l_min,
preload_mm_hg: heart.preload_mm_hg,
afterload_mm_hg: heart.afterload_mm_hg,
venous_return_l_min: heart.venous_return_l_min,
coronary_perfusion_mm_hg: heart.coronary_perfusion_mm_hg,
ejection_fraction: heart.ejection_fraction,
rhythm: heart.rhythm_state,
}
}
}
/// Instantaneous reading for a configured lead.
#[derive(Debug, Clone)]
pub struct EkgLeadSample {
/// Lead identity associated with this sample.
pub lead: EkgLead,
/// Composite millivolt amplitude observed on the lead.
pub amplitude_mv: f32,
/// Contribution from simulated atrial depolarization (P-wave).
pub p_wave_mv: f32,
/// Contribution from simulated ventricular depolarization (QRS complex).
pub qrs_complex_mv: f32,
/// Contribution from simulated ventricular repolarization (T-wave).
pub t_wave_mv: f32,
/// ST-segment offset from the baseline isoelectric line.
pub st_deviation_mv: f32,
/// Additive high-frequency noise used to keep traces dynamic.
pub noise_mv: f32,
}
/// Most recent ECG snapshot produced by the monitor.
#[derive(Debug, Clone)]
pub struct EkgSnapshot {
/// Identifier for the associated heart organ.
pub heart_id: String,
/// Elapsed simulation time in seconds.
pub time_s: f32,
/// Estimated heart rate in beats per minute at this instant.
pub heart_rate_bpm: f32,
/// Duration of the most recent R-R interval in seconds.
pub rr_interval_s: f32,
/// Cardiac rhythm classification captured with the reading.
pub rhythm: CardiacRhythmState,
/// Frontal plane electrical axis in degrees.
pub frontal_axis_deg: f32,
/// Normalized heart rate variability metric (0.0-1.0).
pub variability_index: f32,
/// Per-lead synthesized waveform samples.
pub lead_samples: Vec<EkgLeadSample>,
}
/// Lead-tracking ECG monitor bound to a heart organ.
#[derive(Debug)]
pub struct EkgMonitor {
heart_id: String,
leads: Vec<LeadState>,
global_phase: f32,
time_elapsed_s: f32,
last_rr_interval_s: f32,
last_snapshot: Option<EkgSnapshot>,
}
impl EkgMonitor {
/// Create a new monitor for a specific heart id and lead configuration.
pub fn new(heart_id: impl Into<String>, leads: Vec<EkgLead>) -> Self {
let heart_id = heart_id.into();
let filtered = sanitize_leads(leads);
let lead_states = build_lead_states(&filtered);
Self {
heart_id,
leads: lead_states,
global_phase: 0.0,
time_elapsed_s: 0.0,
last_rr_interval_s: 60.0 / 72.0,
last_snapshot: None,
}
}
/// Construct a monitor using the heart's configured lead count.
pub fn from_heart(heart: &Heart) -> Self {
let count = heart.leads.clamp(1, DEFAULT_LEADS.len() as u8) as usize;
let leads = DEFAULT_LEADS[..count].to_vec();
Self::new(heart.id().to_string(), leads)
}
/// Identifier of the heart this monitor is following.
pub fn heart_id(&self) -> &str {
&self.heart_id
}
/// Retarget the monitor to a different heart identifier.
pub fn retarget(&mut self, heart_id: impl Into<String>) {
self.heart_id = heart_id.into();
}
/// Ordered set of leads currently simulated.
pub fn leads(&self) -> Vec<EkgLead> {
self.leads.iter().map(|state| state.lead).collect()
}
/// Replace the simulated leads while keeping accumulated phase information.
pub fn configure_leads(&mut self, leads: Vec<EkgLead>) {
let filtered = sanitize_leads(leads);
self.leads = build_lead_states(&filtered);
}
/// Update the monitor with the latest heart state.
pub fn observe(&mut self, state: &HeartElectricalState, dt_seconds: f32) {
if dt_seconds <= 0.0 || self.leads.is_empty() {
return;
}
let rr_interval = (60.0 / state.heart_rate_bpm).clamp(MIN_RR_INTERVAL_S, MAX_RR_INTERVAL_S);
let arrhythmia = state.arrhythmia_burden.clamp(0.0, 1.0);
let variability = 0.12 + arrhythmia * 0.6 + state.autonomic_tone.abs() * 0.1;
let variability = variability.clamp(0.05, 0.9);
let phase_rate =
(1.0 + (arrhythmia - 0.2) * 0.15 * (self.time_elapsed_s * 0.7).sin()).clamp(0.7, 1.3);
self.global_phase = (self.global_phase + dt_seconds / (rr_interval * phase_rate)).fract();
let axis = derive_axis(state);
let frontal_axis_rad = axis.inferior.atan2(axis.lateral);
let frontal_axis_deg = frontal_axis_rad.to_degrees();
let amplitude_scale = derive_amplitude_scale(state);
let qrs_width = 0.055 + 0.03 * arrhythmia + (state.contractility_index - 1.0).abs() * 0.015;
let qrs_width = qrs_width.clamp(0.04, 0.12);
let st_target = derive_st_deviation(state);
let mut lead_samples = Vec::with_capacity(self.leads.len());
for (idx, lead_state) in self.leads.iter_mut().enumerate() {
let lead_phase = (self.global_phase + lead_state.phase_offset).fract();
let geom = lead_state.lead.geometry().normalized();
let axis_gain = axis.dot(geom).clamp(-1.4, 1.4);
let p_center = 0.18 + geom.lateral * 0.015 - arrhythmia * 0.02;
let p_width = (0.04 + 0.015 * variability).clamp(0.03, 0.08);
let p_shape = gaussian(lead_phase, p_center, p_width);
let p_wave_mv = amplitude_scale * (0.11 + axis_gain * 0.045) * p_shape;
let qrs_center = 0.32 + geom.inferior * 0.01 - arrhythmia * 0.015;
let qrs_shape = qrs_triplet(lead_phase, qrs_center, qrs_width * 0.6);
let polarity = if axis_gain >= 0.0 { 1.0 } else { -1.0 };
let qrs_wave_mv =
polarity * amplitude_scale * (0.95 + axis_gain.abs() * 0.55) * qrs_shape;
let t_center = 0.6 + geom.anterior * 0.04 + arrhythmia * 0.03;
let t_width = (0.1 + 0.04 * variability).clamp(0.07, 0.18);
let t_shape = gaussian(lead_phase, t_center, t_width);
let t_wave_mv = amplitude_scale * (0.35 + axis_gain * 0.06) * t_shape;
let baseline_target = (st_target + axis_gain * 0.05).clamp(-0.7, 0.7);
lead_state.baseline_mv =
relax(lead_state.baseline_mv, baseline_target, dt_seconds, 1.6);
lead_state.noise_phase =
(lead_state.noise_phase + dt_seconds * (1.6 + idx as f32 * 0.35)).fract();
let noise = arrhythmia * 0.22 * (lead_state.noise_phase * TAU).sin()
+ variability * 0.11 * ((lead_state.noise_phase * TAU * 2.0).sin());
let amplitude_mv = p_wave_mv + qrs_wave_mv + t_wave_mv + lead_state.baseline_mv + noise;
lead_samples.push(EkgLeadSample {
lead: lead_state.lead,
amplitude_mv,
p_wave_mv,
qrs_complex_mv: qrs_wave_mv,
t_wave_mv,
st_deviation_mv: lead_state.baseline_mv,
noise_mv: noise,
});
}
self.time_elapsed_s += dt_seconds;
self.last_rr_interval_s = rr_interval;
self.last_snapshot = Some(EkgSnapshot {
heart_id: self.heart_id.clone(),
time_s: self.time_elapsed_s,
heart_rate_bpm: state.heart_rate_bpm,
rr_interval_s: rr_interval,
rhythm: state.rhythm,
frontal_axis_deg,
variability_index: variability,
lead_samples,
});
}
/// The most recent snapshot produced by [`observe`].
pub fn last_snapshot(&self) -> Option<&EkgSnapshot> {
self.last_snapshot.as_ref()
}
/// Last RR interval that drove waveform generation.
pub fn last_rr_interval(&self) -> f32 {
self.last_rr_interval_s
}
}
#[derive(Debug, Clone)]
struct LeadState {
lead: EkgLead,
phase_offset: f32,
noise_phase: f32,
baseline_mv: f32,
}
fn sanitize_leads(leads: Vec<EkgLead>) -> Vec<EkgLead> {
let mut result = Vec::with_capacity(leads.len().max(1));
for lead in leads {
if !result.contains(&lead) {
result.push(lead);
}
}
if result.is_empty() {
result.push(EkgLead::II);
}
result
}
fn build_lead_states(leads: &[EkgLead]) -> Vec<LeadState> {
leads
.iter()
.enumerate()
.map(|(idx, lead)| LeadState {
lead: *lead,
phase_offset: (idx as f32) * 0.035,
noise_phase: ((idx as f32 + 1.0) * 0.137).fract(),
baseline_mv: 0.0,
})
.collect()
}
fn derive_axis(state: &HeartElectricalState) -> LeadVector {
let lateral = (state.autonomic_tone * 0.6 + (state.contractility_index - 1.0) * 0.4)
- state.arrhythmia_burden * 0.15;
let inferior = (state.stroke_volume_ml - 65.0) / 35.0
+ (state.cardiac_output_l_min - 5.0) * 0.12
- state.arrhythmia_burden * 0.1
+ 0.6;
let anterior = (state.preload_mm_hg - 8.0) / 16.0 + (state.venous_return_l_min - 5.0) * 0.08
- (state.afterload_mm_hg - 95.0) / 240.0
+ 0.2;
LeadVector::new(lateral, inferior, anterior).normalized()
}
fn derive_amplitude_scale(state: &HeartElectricalState) -> f32 {
(0.9 + (state.contractility_index - 1.0) * 0.35
+ (state.ejection_fraction - 0.55) * 1.1
+ (state.cardiac_output_l_min - 5.0) * 0.08)
.clamp(0.35, 2.4)
}
fn derive_st_deviation(state: &HeartElectricalState) -> f32 {
((state.coronary_perfusion_mm_hg - 70.0) / 210.0) - (state.arrhythmia_burden * 0.3)
+ (state.autonomic_tone * 0.1)
}
fn gaussian(x: f32, center: f32, width: f32) -> f32 {
if width <= 0.0 {
return 0.0;
}
let diff = wrap_phase(x - center);
(-0.5 * (diff / width).powi(2)).exp()
}
fn qrs_triplet(phase: f32, center: f32, width: f32) -> f32 {
if width <= 0.0 {
return 0.0;
}
let q = -0.35 * gaussian(phase, center - width * 0.8, width * 0.6);
let r = 1.35 * gaussian(phase, center, width * 0.5);
let s = -0.4 * gaussian(phase, center + width * 0.9, width * 0.7);
q + r + s
}
fn wrap_phase(mut value: f32) -> f32 {
if value > 0.5 {
value -= 1.0;
} else if value < -0.5 {
value += 1.0;
}
value
}
fn relax(current: f32, target: f32, dt_seconds: f32, time_constant: f32) -> f32 {
if time_constant <= 0.0 {
target
} else {
let alpha = (dt_seconds / time_constant).clamp(0.0, 1.0);
current + (target - current) * alpha
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn monitor_tracks_heart_activity() {
let mut heart = Heart::new("ekg", 12);
// induce slight sympathetic tone to exercise dynamics
heart.autonomic_tone = 0.3;
let mut monitor = EkgMonitor::from_heart(&heart);
let dt = 0.01;
for _ in 0..2000 {
heart.update(dt);
let state = HeartElectricalState::from(&heart);
monitor.observe(&state, dt);
}
let snapshot = monitor.last_snapshot().expect("snapshot available");
assert_eq!(snapshot.lead_samples.len(), heart.leads as usize);
assert!((snapshot.heart_rate_bpm - heart.heart_rate_bpm).abs() < 1.0);
assert!(snapshot
.lead_samples
.iter()
.any(|s| s.amplitude_mv.abs() > 0.2));
}
#[test]
fn lead_configuration_can_change() {
let heart = Heart::new("ekg", 12);
let mut monitor = EkgMonitor::from_heart(&heart);
monitor.configure_leads(vec![EkgLead::V1, EkgLead::II, EkgLead::V6, EkgLead::II]);
assert_eq!(monitor.leads().len(), 3);
assert_eq!(monitor.leads(), vec![EkgLead::V1, EkgLead::II, EkgLead::V6]);
}
}
+255
View File
@@ -11,6 +11,9 @@ use core::ffi::{c_char, c_void, CStr};
use std::ffi::CString;
use std::ptr;
use crate::organs::{
BladderMetrics, BladderPhase, BloodstreamMetrics, MetabolicState, PerfusionState,
};
use crate::patient::Patient;
use crate::types::OrganType;
@@ -48,6 +51,205 @@ 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;
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MLBladderPhase {
Filling = 0,
Voiding = 1,
PostVoidRefractory = 2,
}
impl From<BladderPhase> for MLBladderPhase {
fn from(phase: BladderPhase) -> Self {
match phase {
BladderPhase::Filling => MLBladderPhase::Filling,
BladderPhase::Voiding => MLBladderPhase::Voiding,
BladderPhase::PostVoidRefractory => MLBladderPhase::PostVoidRefractory,
}
}
}
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MLPerfusionState {
Balanced = 0,
Compensated = 1,
Hypovolemic = 2,
Shock = 3,
}
impl From<PerfusionState> for MLPerfusionState {
fn from(state: PerfusionState) -> Self {
match state {
PerfusionState::Balanced => MLPerfusionState::Balanced,
PerfusionState::Compensated => MLPerfusionState::Compensated,
PerfusionState::Hypovolemic => MLPerfusionState::Hypovolemic,
PerfusionState::Shock => MLPerfusionState::Shock,
}
}
}
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MLMetabolicState {
Aerobic = 0,
CompensatedAnaerobic = 1,
AnaerobicCrisis = 2,
}
impl From<MetabolicState> for MLMetabolicState {
fn from(state: MetabolicState) -> Self {
match state {
MetabolicState::Aerobic => MLMetabolicState::Aerobic,
MetabolicState::CompensatedAnaerobic => MLMetabolicState::CompensatedAnaerobic,
MetabolicState::AnaerobicCrisis => MLMetabolicState::AnaerobicCrisis,
}
}
}
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct MLBladderMetrics {
pub phase: MLBladderPhase,
pub volume_ml: f32,
pub capacity_ml: f32,
pub pressure_cm_h2o: f32,
pub detrusor_pressure_cm_h2o: f32,
pub pressure_safety_limit_cm_h2o: f32,
pub afferent_signal: f32,
pub urgency: f32,
pub cortical_inhibition: f32,
pub voluntary_hold_command: f32,
pub cortical_gate_fraction: f32,
pub voluntary_hold_fraction: f32,
pub guarding_reflex_gain: f32,
pub pontine_storage_signal: f32,
pub pontine_micturition_signal: f32,
pub hypogastric_efferent: f32,
pub pudendal_efferent: f32,
pub parasympathetic_drive: f32,
pub sympathetic_drive: f32,
pub somatic_drive: f32,
pub pressure_stress_index: f32,
pub time_above_safety_limit_s: f32,
}
impl From<BladderMetrics> for MLBladderMetrics {
fn from(metrics: BladderMetrics) -> Self {
Self {
phase: MLBladderPhase::from(metrics.phase),
volume_ml: metrics.volume_ml,
capacity_ml: metrics.capacity_ml,
pressure_cm_h2o: metrics.pressure_cm_h2o,
detrusor_pressure_cm_h2o: metrics.detrusor_pressure_cm_h2o,
pressure_safety_limit_cm_h2o: metrics.pressure_safety_limit_cm_h2o,
afferent_signal: metrics.afferent_signal,
urgency: metrics.urgency,
cortical_inhibition: metrics.cortical_inhibition,
voluntary_hold_command: metrics.voluntary_hold_command,
cortical_gate_fraction: metrics.cortical_gate_fraction,
voluntary_hold_fraction: metrics.voluntary_hold_fraction,
guarding_reflex_gain: metrics.guarding_reflex_gain,
pontine_storage_signal: metrics.pontine_storage_signal,
pontine_micturition_signal: metrics.pontine_micturition_signal,
hypogastric_efferent: metrics.hypogastric_efferent,
pudendal_efferent: metrics.pudendal_efferent,
parasympathetic_drive: metrics.parasympathetic_drive,
sympathetic_drive: metrics.sympathetic_drive,
somatic_drive: metrics.somatic_drive,
pressure_stress_index: metrics.pressure_stress_index,
time_above_safety_limit_s: metrics.time_above_safety_limit_s,
}
}
}
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct MLBloodstreamMetrics {
pub perfusion_state: MLPerfusionState,
pub metabolic_state: MLMetabolicState,
pub total_volume_l: f32,
pub plasma_volume_l: f32,
pub red_cell_volume_l: f32,
pub plasma_albumin_g_dl: f32,
pub plasma_globulin_g_dl: f32,
pub plasma_fibrinogen_g_dl: f32,
pub oncotic_pressure_mm_hg: f32,
pub lymphatic_return_ml_min: f32,
pub rbc_young_fraction: f32,
pub rbc_mature_fraction: f32,
pub rbc_senescent_fraction: f32,
pub platelet_count_1e3_per_ul: f32,
pub platelet_activation: f32,
pub coagulation_factor_activity: f32,
pub fibrinolysis_activity: f32,
pub thrombosis_risk_index: f32,
pub bleeding_risk_index: f32,
pub wbc_count_giga_per_l: f32,
pub neutrophil_fraction: f32,
pub lymphocyte_fraction: f32,
pub monocyte_fraction: f32,
pub complement_activity: f32,
pub inflammation_index: f32,
pub bicarbonate_mmol_l: f32,
pub base_excess_mmol_l: f32,
pub anion_gap_mmol_l: f32,
pub arterial_pco2_mm_hg: f32,
pub erythropoiesis_rate_ml_per_day: f32,
pub erythrocyte_clearance_ml_per_day: f32,
pub iron_store_mg: f32,
pub folate_store_mg: f32,
pub b12_store_mcg: f32,
pub hif_activation: f32,
pub oxygen_supply_demand_ratio: f32,
}
impl From<BloodstreamMetrics> for MLBloodstreamMetrics {
fn from(metrics: BloodstreamMetrics) -> Self {
Self {
perfusion_state: MLPerfusionState::from(metrics.perfusion_state),
metabolic_state: MLMetabolicState::from(metrics.metabolic_state),
total_volume_l: metrics.total_volume_l,
plasma_volume_l: metrics.plasma_volume_l,
red_cell_volume_l: metrics.red_cell_volume_l,
plasma_albumin_g_dl: metrics.plasma_albumin_g_dl,
plasma_globulin_g_dl: metrics.plasma_globulin_g_dl,
plasma_fibrinogen_g_dl: metrics.plasma_fibrinogen_g_dl,
oncotic_pressure_mm_hg: metrics.oncotic_pressure_mm_hg,
lymphatic_return_ml_min: metrics.lymphatic_return_ml_min,
rbc_young_fraction: metrics.rbc_young_fraction,
rbc_mature_fraction: metrics.rbc_mature_fraction,
rbc_senescent_fraction: metrics.rbc_senescent_fraction,
platelet_count_1e3_per_ul: metrics.platelet_count_1e3_per_ul,
platelet_activation: metrics.platelet_activation,
coagulation_factor_activity: metrics.coagulation_factor_activity,
fibrinolysis_activity: metrics.fibrinolysis_activity,
thrombosis_risk_index: metrics.thrombosis_risk_index,
bleeding_risk_index: metrics.bleeding_risk_index,
wbc_count_giga_per_l: metrics.wbc_count_giga_per_l,
neutrophil_fraction: metrics.neutrophil_fraction,
lymphocyte_fraction: metrics.lymphocyte_fraction,
monocyte_fraction: metrics.monocyte_fraction,
complement_activity: metrics.complement_activity,
inflammation_index: metrics.inflammation_index,
bicarbonate_mmol_l: metrics.bicarbonate_mmol_l,
base_excess_mmol_l: metrics.base_excess_mmol_l,
anion_gap_mmol_l: metrics.anion_gap_mmol_l,
arterial_pco2_mm_hg: metrics.arterial_pco2_mm_hg,
erythropoiesis_rate_ml_per_day: metrics.erythropoiesis_rate_ml_per_day,
erythrocyte_clearance_ml_per_day: metrics.erythrocyte_clearance_ml_per_day,
iron_store_mg: metrics.iron_store_mg,
folate_store_mg: metrics.folate_store_mg,
b12_store_mcg: metrics.b12_store_mcg,
hif_activation: metrics.hif_activation,
oxygen_supply_demand_ratio: metrics.oxygen_supply_demand_ratio,
}
}
}
/// Opaque patient handle type for C consumers. Wraps a heap-allocated `Patient`.
#[repr(C)]
#[derive(Debug)]
@@ -152,6 +354,56 @@ pub extern "C" fn ml_patient_update(p: *mut MLPatient, dt_seconds: f32) -> i32 {
ML_OK
}
/// Populate bloodstream metrics for the patient. Returns ML_OK on success.
#[no_mangle]
pub extern "C" fn ml_patient_bloodstream_metrics(
p: *const MLPatient,
out_metrics: *mut MLBloodstreamMetrics,
) -> i32 {
if p.is_null() || out_metrics.is_null() {
return ML_EINVAL;
}
let patient_ptr = unsafe { (*p).inner as *const Patient };
if patient_ptr.is_null() {
return ML_EINVAL;
}
let patient = unsafe { &*patient_ptr };
match patient.bloodstream_metrics() {
Some(metrics) => {
unsafe {
*out_metrics = MLBloodstreamMetrics::from(metrics);
}
ML_OK
}
None => ML_ERR,
}
}
/// Populate bladder metrics for the patient. Returns ML_OK on success.
#[no_mangle]
pub extern "C" fn ml_patient_bladder_metrics(
p: *const MLPatient,
out_metrics: *mut MLBladderMetrics,
) -> i32 {
if p.is_null() || out_metrics.is_null() {
return ML_EINVAL;
}
let patient_ptr = unsafe { (*p).inner as *const Patient };
if patient_ptr.is_null() {
return ML_EINVAL;
}
let patient = unsafe { &*patient_ptr };
match patient.bladder_metrics() {
Some(metrics) => {
unsafe {
*out_metrics = MLBladderMetrics::from(metrics);
}
ML_OK
}
None => ML_ERR,
}
}
/// Return organ summary by type code. See header for codes. Caller frees string.
#[no_mangle]
pub extern "C" fn ml_patient_organ_summary(p: *const MLPatient, organ_code: u32) -> *mut c_char {
@@ -176,6 +428,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) };
@@ -187,3 +440,5 @@ pub extern "C" fn ml_patient_organ_summary(p: *const MLPatient, organ_code: u32)
Err(_) => ptr::null_mut(),
}
}
+8 -1
View File
@@ -26,6 +26,7 @@
#![deny(unsafe_code)]
#![warn(missing_docs, rust_2018_idioms, missing_debug_implementations)]
mod ekg;
mod error;
mod organs;
mod patient;
@@ -34,8 +35,12 @@ mod types;
#[cfg(feature = "ffi")]
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::{
Bladder, BladderMetrics, BladderPhase, Bloodstream, BreathingPhase, Heart, Lungs,
MetabolicState, Organ, PerfusionState,
};
pub use crate::patient::Patient;
pub use crate::types::{Blood, BloodPressure, OrganType};
@@ -158,5 +163,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);
}
}
+810 -9
View File
@@ -1,21 +1,608 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BladderPhase {
Filling,
Voiding,
PostVoidRefractory,
}
#[derive(Debug, Clone)]
struct ComplianceModel {
low_volume_compliance_ml_per_cm_h2o: f32,
mid_volume_compliance_ml_per_cm_h2o: f32,
high_volume_compliance_ml_per_cm_h2o: f32,
detrusor_safety_limit_cm_h2o: f32,
viscoelastic_relaxation: f32,
stiffness_factor: f32,
stress_adaptation_rate: f32,
recovery_rate: f32,
}
impl ComplianceModel {
fn new() -> Self {
Self {
low_volume_compliance_ml_per_cm_h2o: 32.0,
mid_volume_compliance_ml_per_cm_h2o: 22.0,
high_volume_compliance_ml_per_cm_h2o: 8.5,
detrusor_safety_limit_cm_h2o: 40.0,
viscoelastic_relaxation: 1.0,
stiffness_factor: 1.0,
stress_adaptation_rate: 0.08,
recovery_rate: 0.015,
}
}
fn safety_limit(&self) -> f32 {
self.detrusor_safety_limit_cm_h2o
}
fn effective_compliance(&self, normalized_volume: f32, filling_rate_ml_per_min: f32) -> f32 {
let nv = normalized_volume.clamp(0.0, 2.0);
let logistic_mid = 1.0 / (1.0 + (-10.0 * (nv - 0.55)).exp());
let logistic_high = 1.0 / (1.0 + (-16.0 * (nv - 0.9)).exp());
let base_mid = self.low_volume_compliance_ml_per_cm_h2o
- (self.low_volume_compliance_ml_per_cm_h2o - self.mid_volume_compliance_ml_per_cm_h2o)
* logistic_mid;
let base =
base_mid - (base_mid - self.high_volume_compliance_ml_per_cm_h2o) * logistic_high;
let rate_penalty = (1.0 - filling_rate_ml_per_min / 240.0).clamp(0.65, 1.0);
let viscoelastic = base * self.viscoelastic_relaxation * rate_penalty;
(viscoelastic / self.stiffness_factor).clamp(3.0, 80.0)
}
fn update_state(&mut self, detrusor_pressure: f32, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
let limit = self.detrusor_safety_limit_cm_h2o;
if detrusor_pressure > limit {
let overload = detrusor_pressure - limit;
self.viscoelastic_relaxation = (self.viscoelastic_relaxation
+ self.stress_adaptation_rate * dt_seconds * (1.0 + 0.02 * overload))
.clamp(0.85, 1.35);
self.stiffness_factor = (self.stiffness_factor
+ 0.15 * self.stress_adaptation_rate * dt_seconds * (1.0 + overload / limit))
.clamp(1.0, 1.8);
} else {
self.viscoelastic_relaxation =
(self.viscoelastic_relaxation - self.recovery_rate * dt_seconds).clamp(0.85, 1.35);
self.stiffness_factor =
(self.stiffness_factor - 0.25 * self.recovery_rate * dt_seconds).clamp(1.0, 1.8);
}
}
}
#[derive(Debug, Clone)]
struct ReflexState {
pelvic_afferent_rate: f32,
guarding_reflex_gain: f32,
pontine_storage_signal: f32,
pontine_micturition_signal: f32,
hypogastric_efferent: f32,
pudendal_efferent: f32,
}
impl ReflexState {
fn new() -> Self {
Self {
pelvic_afferent_rate: 0.2,
guarding_reflex_gain: 0.4,
pontine_storage_signal: 0.5,
pontine_micturition_signal: 0.1,
hypogastric_efferent: 0.6,
pudendal_efferent: 0.6,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct BladderMetrics {
pub phase: BladderPhase,
pub volume_ml: f32,
pub capacity_ml: f32,
pub pressure_cm_h2o: f32,
pub detrusor_pressure_cm_h2o: f32,
pub pressure_safety_limit_cm_h2o: f32,
pub afferent_signal: f32,
pub urgency: f32,
pub cortical_inhibition: f32,
pub voluntary_hold_command: f32,
pub cortical_gate_fraction: f32,
pub voluntary_hold_fraction: f32,
pub guarding_reflex_gain: f32,
pub pontine_storage_signal: f32,
pub pontine_micturition_signal: f32,
pub hypogastric_efferent: f32,
pub pudendal_efferent: f32,
pub parasympathetic_drive: f32,
pub sympathetic_drive: f32,
pub somatic_drive: f32,
pub pressure_stress_index: f32,
pub time_above_safety_limit_s: f32,
}
#[derive(Debug, Clone)]
pub struct Bladder {
info: OrganInfo,
/// Urine volume ml
/// Urine volume stored in the bladder (ml).
pub volume_ml: f32,
/// Pressure proxy (cmH2O)
/// Intraluminal/detrusor pressure (cmH2O).
pub pressure: f32,
/// Detected detrusor component of intravesical pressure (cmH2O).
pub detrusor_pressure_cm_h2o: f32,
/// Normalized afferent firing (0..=1) representing stretch receptor activity.
pub afferent_signal: f32,
/// Normalized urgency perception (0..=1).
pub urgency: f32,
/// Current phase of the bladder state machine.
pub phase: BladderPhase,
/// Functional capacity where continence is expected (ml).
pub capacity_ml: f32,
/// Residual volume expected after a complete void (ml).
pub residual_volume_ml: f32,
/// Baseline cystometric capacity for demographic scaling (ml).
pub baseline_capacity_ml: f32,
/// Chronological age in years influencing thresholds.
pub age_years: f32,
/// Integrity of supraspinal and spinal pathways (0..=1).
pub neurologic_integrity: f32,
/// Habitual continence training or pelvic floor conditioning (0..=1).
pub continence_training: f32,
/// Descending cortical inhibition of the micturition reflex (0..=1).
pub cortical_inhibition: f32,
/// Voluntary hold motor command (0..=1) driving pelvic floor recruitment.
pub voluntary_hold_command: f32,
/// Filtered cortical gate strength (0..=1) applied to reflex loops.
pub cortical_gate_fraction: f32,
/// Filtered voluntary hold strength (0..=1) applied to sphincter control.
pub voluntary_hold_fraction: f32,
/// Adaptive compliance model governing pressure-volume behavior.
compliance: ComplianceModel,
/// Integrated reflex pathways coordinating storage and voiding.
reflex: ReflexState,
/// Baseline pressure generated by abdominal cavity (cmH2O).
pub baseline_pressure_cm_h2o: f32,
/// Volume threshold at which a full micturition reflex is triggered (ml).
pub micturition_threshold_ml: f32,
/// Volume threshold where urge perception begins (ml).
pub urge_threshold_ml: f32,
/// Average renal inflow into the bladder (ml/min).
pub filling_rate_ml_per_min: f32,
/// Peak voluntary/automatic outflow during voiding (ml/s).
pub voiding_flow_ml_per_s: f32,
/// Tone of the internal urethral sphincter (0..=1, higher means more closed).
pub internal_sphincter_tone: f32,
/// Tone of the external urethral sphincter/pelvic floor (0..=1).
pub external_sphincter_tone: f32,
/// Parasympathetic drive to the detrusor (0..=1).
pub parasympathetic_drive: f32,
/// Sympathetic drive maintaining storage (0..=1).
pub sympathetic_drive: f32,
/// Somatic drive through the pudendal nerve to the external sphincter (0..=1).
pub somatic_drive: f32,
/// Exponentially-weighted overload index tracking detrusor pressure burden (cmH2O·s).
pub pressure_stress_index: f32,
/// Cumulative time the detrusor pressure exceeded the safety limit (seconds).
pub time_above_safety_limit_s: f32,
time_since_last_void_s: f32,
refractory_seconds: f32,
}
impl Bladder {
pub fn new(id: impl Into<String>) -> Self {
Self {
info: OrganInfo::new(id, OrganType::Bladder),
volume_ml: 100.0,
pressure: 5.0,
volume_ml: 120.0,
pressure: 8.0,
detrusor_pressure_cm_h2o: 5.0,
afferent_signal: 0.1,
urgency: 0.1,
phase: BladderPhase::Filling,
capacity_ml: 500.0,
residual_volume_ml: 30.0,
baseline_capacity_ml: 500.0,
age_years: 38.0,
neurologic_integrity: 1.0,
continence_training: 0.65,
cortical_inhibition: 0.6,
voluntary_hold_command: 0.45,
cortical_gate_fraction: 0.0,
voluntary_hold_fraction: 0.0,
compliance: ComplianceModel::new(),
reflex: ReflexState::new(),
baseline_pressure_cm_h2o: 5.0,
micturition_threshold_ml: 350.0,
urge_threshold_ml: 200.0,
filling_rate_ml_per_min: 60.0,
voiding_flow_ml_per_s: 15.0,
internal_sphincter_tone: 0.85,
external_sphincter_tone: 0.9,
parasympathetic_drive: 0.05,
sympathetic_drive: 0.8,
somatic_drive: 0.8,
pressure_stress_index: 0.0,
time_since_last_void_s: 0.0,
time_above_safety_limit_s: 0.0,
refractory_seconds: 15.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn cortical_gate_strength(&self) -> f32 {
let training = 0.4 + 0.6 * self.continence_training;
(self.cortical_inhibition * self.neurologic_integrity * training).clamp(0.0, 1.0)
}
fn voluntary_hold_strength(&self) -> f32 {
let training = 0.35 + 0.65 * self.continence_training;
(self.voluntary_hold_command * training * self.neurologic_integrity).clamp(0.0, 1.0)
}
fn update_voluntary_modulators(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
let cortical_target = self.cortical_gate_strength();
self.cortical_gate_fraction = Self::approach(
self.cortical_gate_fraction,
cortical_target,
1.2,
dt_seconds,
)
.clamp(0.0, 1.0);
let hold_target = self.voluntary_hold_strength();
self.voluntary_hold_fraction =
Self::approach(self.voluntary_hold_fraction, hold_target, 1.6, dt_seconds)
.clamp(0.0, 1.0);
}
fn update_threshold_targets(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
let baseline_capacity = self.baseline_capacity_ml.max(320.0);
let age_factor = if self.age_years <= 40.0 {
1.0 + (40.0 - self.age_years).max(0.0) * 0.0035
} else {
(1.0 - (self.age_years - 40.0) * 0.003).clamp(0.74, 1.08)
};
let neurologic_factor = (0.55 + 0.45 * self.neurologic_integrity).clamp(0.45, 1.1);
let capacity_target =
(baseline_capacity * age_factor * neurologic_factor).clamp(240.0, 720.0);
self.capacity_ml =
Self::approach(self.capacity_ml, capacity_target, 0.5, dt_seconds).clamp(160.0, 900.0);
let diuresis_ratio = (self.filling_rate_ml_per_min / 60.0).clamp(0.25, 2.5);
let diuresis_factor = diuresis_ratio.powf(-0.18).clamp(0.82, 1.18);
let voluntary_buffer = (1.0
+ 0.22 * self.cortical_gate_fraction
+ 0.25 * self.voluntary_hold_fraction
+ 0.12 * self.continence_training)
.clamp(0.8, 1.5);
let neurologic_drop = (1.0 - 0.3 * (1.0 - self.neurologic_integrity)).clamp(0.55, 1.05);
let urge_fraction = (0.38 + 0.08 * self.voluntary_hold_fraction
- 0.1 * (1.0 - self.neurologic_integrity))
.clamp(0.25, 0.6);
let mut urge_target =
capacity_target * urge_fraction * diuresis_factor * voluntary_buffer * neurologic_drop;
urge_target = urge_target.clamp(capacity_target * 0.22, capacity_target * 0.75);
let mict_fraction = (0.68 + 0.1 * self.voluntary_hold_fraction
- 0.15 * (1.0 - self.neurologic_integrity))
.clamp(0.5, 0.92);
let mut mict_target = capacity_target
* mict_fraction
* diuresis_factor.powf(0.75)
* voluntary_buffer
* neurologic_drop;
mict_target = mict_target.clamp(urge_target + 25.0, capacity_target * 0.95);
self.urge_threshold_ml =
Self::approach(self.urge_threshold_ml, urge_target, 0.8, dt_seconds).clamp(50.0, 800.0);
self.micturition_threshold_ml =
Self::approach(self.micturition_threshold_ml, mict_target, 0.7, dt_seconds)
.clamp(60.0, 850.0);
}
fn update_reflex_gate(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
let normalized_volume = (self.volume_ml / self.capacity_ml).clamp(0.0, 1.7);
let detrusor_ratio =
(self.detrusor_pressure_cm_h2o / self.compliance.safety_limit()).clamp(0.0, 2.0);
let cortical_damping = (1.0 - 0.35 * self.cortical_gate_fraction).clamp(0.55, 1.0);
let neuro_sensitivity = (1.0 + 0.25 * (1.0 - self.neurologic_integrity)).clamp(0.8, 1.3);
let pelvic_drive =
(0.32 * normalized_volume + 0.45 * self.afferent_signal + 0.23 * detrusor_ratio)
* cortical_damping
* neuro_sensitivity;
let pelvic_target = pelvic_drive.clamp(0.0, 1.2);
self.reflex.pelvic_afferent_rate = Self::approach(
self.reflex.pelvic_afferent_rate,
pelvic_target.min(1.0),
1.6,
dt_seconds,
);
let voluntary_buffer = (self.voluntary_hold_fraction * 0.45).clamp(0.0, 0.45);
let storage_modulator =
(1.0 - self.urgency * (1.0 - 0.35 * self.cortical_gate_fraction)).clamp(0.0, 1.0);
let guarding_target = if matches!(self.phase, BladderPhase::Voiding) {
0.05
} else {
((self.reflex.pelvic_afferent_rate + 0.12) * storage_modulator).powf(0.7)
+ voluntary_buffer
};
self.reflex.guarding_reflex_gain = Self::approach(
self.reflex.guarding_reflex_gain,
guarding_target.clamp(0.0, 1.0),
1.4,
dt_seconds,
);
let mut supraspinal_trigger = ((self.reflex.pelvic_afferent_rate - 0.55).max(0.0)
* self.urgency.powf(1.2)
+ (self.detrusor_pressure_cm_h2o - self.compliance.safety_limit()).max(0.0) / 40.0)
.clamp(0.0, 1.2);
supraspinal_trigger =
(supraspinal_trigger * (1.0 - 0.5 * self.cortical_gate_fraction)).clamp(0.0, 1.2);
let pontine_void_base = match self.phase {
BladderPhase::Voiding => (0.5 + 0.6 * supraspinal_trigger).clamp(0.0, 1.0),
BladderPhase::PostVoidRefractory => (0.2 * supraspinal_trigger).clamp(0.0, 0.4),
BladderPhase::Filling => (0.35 * supraspinal_trigger).clamp(0.0, 0.85),
};
let pontine_void_target =
(pontine_void_base * (1.0 - 0.55 * self.cortical_gate_fraction)).clamp(0.0, 1.0);
let pontine_storage_target = if matches!(self.phase, BladderPhase::Voiding) {
(0.1 + 0.2 * (1.0 - supraspinal_trigger) + 0.15 * self.voluntary_hold_fraction)
.clamp(0.0, 0.45)
} else {
(0.45 + 0.5 * self.reflex.guarding_reflex_gain - 0.4 * supraspinal_trigger
+ 0.35 * self.cortical_gate_fraction
+ 0.2 * self.voluntary_hold_fraction)
.clamp(0.0, 1.0)
};
self.reflex.pontine_micturition_signal = Self::approach(
self.reflex.pontine_micturition_signal,
pontine_void_target,
1.0,
dt_seconds,
)
.clamp(0.0, 1.0);
self.reflex.pontine_storage_signal = Self::approach(
self.reflex.pontine_storage_signal,
pontine_storage_target,
1.0,
dt_seconds,
)
.clamp(0.0, 1.0);
}
fn update_reflex_efferents(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
let sympathetic_bias = (0.6 + 0.2 * self.neurologic_integrity).clamp(0.4, 0.9);
let hypogastric_target = (sympathetic_bias * self.sympathetic_drive
+ 0.35 * self.reflex.pontine_storage_signal
- 0.45 * self.reflex.pontine_micturition_signal
+ 0.15 * self.cortical_gate_fraction)
.clamp(0.0, 1.0);
let pudendal_target = ((0.55 + 0.15 * self.continence_training) * self.somatic_drive
+ 0.25 * self.reflex.guarding_reflex_gain
+ 0.35 * self.voluntary_hold_fraction
- 0.55 * self.reflex.pontine_micturition_signal)
* (0.7 + 0.3 * self.neurologic_integrity);
let pudendal_target = pudendal_target.clamp(0.0, 1.0);
self.reflex.hypogastric_efferent = Self::approach(
self.reflex.hypogastric_efferent,
hypogastric_target,
1.8,
dt_seconds,
);
self.reflex.pudendal_efferent = Self::approach(
self.reflex.pudendal_efferent,
pudendal_target,
1.8,
dt_seconds,
);
}
fn update_drives(&mut self, dt_seconds: f32) {
let urgency = self.urgency;
let safety_ratio =
(self.detrusor_pressure_cm_h2o / self.compliance.safety_limit()).clamp(0.0, 2.0);
let safety_bias = (safety_ratio.powf(1.2) * 0.35).clamp(0.0, 0.4);
let storage_bias = (self.reflex.pontine_storage_signal * 0.6).clamp(0.0, 0.6);
let void_bias = self.reflex.pontine_micturition_signal;
let cortical_filter = (1.0 - 0.4 * self.cortical_gate_fraction).clamp(0.35, 1.0);
let parasym_target =
(urgency.powf(1.2) * cortical_filter + safety_bias + 0.7 * void_bias).clamp(0.0, 1.0);
let sym_target = (1.0
- 0.6 * urgency * (1.0 - 0.3 * self.cortical_gate_fraction)
- 0.3 * safety_bias
- 0.6 * void_bias
+ storage_bias
+ 0.15 * self.cortical_gate_fraction)
.clamp(0.15, 1.0);
let voluntary_boost =
(0.35 * self.voluntary_hold_fraction + 0.2 * self.continence_training).clamp(0.0, 0.45);
let somatic_target = ((0.55 + 0.25 * self.continence_training) * (1.0 - urgency * 0.55)
+ storage_bias * 0.45
+ voluntary_boost
- void_bias * 0.5)
* (0.75 + 0.25 * self.neurologic_integrity);
self.parasympathetic_drive =
Self::approach(self.parasympathetic_drive, parasym_target, 0.8, dt_seconds)
.clamp(0.0, 1.0);
self.sympathetic_drive =
Self::approach(self.sympathetic_drive, sym_target, 0.6, dt_seconds).clamp(0.0, 1.0);
self.somatic_drive = Self::approach(
self.somatic_drive,
somatic_target.clamp(0.2, 1.05),
0.9,
dt_seconds,
)
.clamp(0.0, 1.0);
}
fn update_sphincters(&mut self) {
let internal = (0.28 + 0.65 * self.sympathetic_drive + 0.1 * self.cortical_gate_fraction)
.clamp(0.0, 1.0);
let external = ((0.18 + 0.7 * self.somatic_drive + 0.25 * self.voluntary_hold_fraction)
* (0.75 + 0.25 * self.neurologic_integrity))
.clamp(0.0, 1.0);
self.internal_sphincter_tone = internal;
self.external_sphincter_tone = external;
}
fn update_afferents(&mut self) {
let low_volume_component = (self.volume_ml / 50.0).clamp(0.0, 1.0) * 0.15;
let fullness_component = if self.capacity_ml > self.urge_threshold_ml {
let denom = (self.capacity_ml - self.urge_threshold_ml).max(1.0);
((self.volume_ml - self.urge_threshold_ml) / denom).clamp(0.0, 1.0)
} else {
(self.volume_ml / self.capacity_ml.max(1.0)).clamp(0.0, 1.0)
};
let pressure_bias =
(self.detrusor_pressure_cm_h2o / self.compliance.safety_limit()).clamp(0.0, 1.5) * 0.2;
let neurologic_gain = (1.0 + 0.35 * (1.0 - self.neurologic_integrity)).clamp(1.0, 1.4);
self.afferent_signal = ((low_volume_component + fullness_component + pressure_bias)
* neurologic_gain)
.clamp(0.0, 1.2);
let cortical_relief = (1.0 - 0.45 * self.cortical_gate_fraction).clamp(0.5, 1.0);
let voluntary_relief =
(1.0 - 0.35 * self.voluntary_hold_fraction - 0.1 * self.continence_training)
.clamp(0.45, 1.0);
self.urgency =
(self.afferent_signal.powf(1.35) * cortical_relief * voluntary_relief).clamp(0.0, 1.0);
}
fn update_pressure_safety_metrics(&mut self, detrusor_pressure: f32, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
let limit = self.compliance.safety_limit();
let overload = (detrusor_pressure - limit).max(0.0);
let decay = (-dt_seconds / 600.0).exp();
if overload > 0.0 {
self.time_above_safety_limit_s += dt_seconds;
self.pressure_stress_index =
(self.pressure_stress_index * decay + overload * dt_seconds).min(5000.0);
} else {
self.pressure_stress_index = (self.pressure_stress_index * decay).max(0.0);
}
}
fn update_pressure(&mut self, dt_seconds: f32) {
let normalized_volume = (self.volume_ml / self.capacity_ml).clamp(0.0, 2.0);
let effective_compliance = self
.compliance
.effective_compliance(normalized_volume, self.filling_rate_ml_per_min);
let passive_volume_ml = (self.volume_ml - self.residual_volume_ml).max(0.0);
let passive_detrusor = if effective_compliance > f32::EPSILON {
passive_volume_ml / effective_compliance
} else {
0.0
};
let toe_region_gain = 1.0 + normalized_volume.powf(3.2) * 0.6;
let passive_detrusor = (passive_detrusor * toe_region_gain).clamp(0.0, 60.0);
let active_detrusor = (36.0 + 6.0 * normalized_volume) * self.parasympathetic_drive;
let detrusor_pressure = (passive_detrusor + active_detrusor).clamp(0.0, 95.0);
self.compliance.update_state(detrusor_pressure, dt_seconds);
let abdominal_pressure = self.baseline_pressure_cm_h2o
+ 2.0 * normalized_volume
+ 0.5 * self.internal_sphincter_tone;
self.detrusor_pressure_cm_h2o = detrusor_pressure;
self.pressure = (abdominal_pressure + detrusor_pressure).clamp(0.0, 110.0);
self.update_pressure_safety_metrics(detrusor_pressure, dt_seconds);
}
fn handle_filling_phase(&mut self, _dt_seconds: f32) {
if (self.volume_ml >= self.micturition_threshold_ml
|| self.detrusor_pressure_cm_h2o > self.compliance.safety_limit() + 5.0)
&& (self.external_sphincter_tone < 0.4 || self.urgency > 0.95)
{
self.phase = BladderPhase::Voiding;
}
let overdistention_limit = self.capacity_ml * 1.4;
if self.volume_ml > overdistention_limit {
self.volume_ml = overdistention_limit;
}
}
fn handle_voiding_phase(&mut self, dt_seconds: f32) {
let relaxation_factor = 1.0 - 0.5 * self.external_sphincter_tone;
let drive = (self.parasympathetic_drive * relaxation_factor).clamp(0.0, 1.0);
let pressure_factor = (self.detrusor_pressure_cm_h2o / 40.0).clamp(0.0, 2.5);
let outflow = self.voiding_flow_ml_per_s.max(0.0) * pressure_factor * drive * dt_seconds;
self.volume_ml = (self.volume_ml - outflow).max(self.residual_volume_ml);
if self.volume_ml <= self.residual_volume_ml + 1.0 {
self.phase = BladderPhase::PostVoidRefractory;
self.time_since_last_void_s = 0.0;
}
}
fn handle_post_void_phase(&mut self) {
if self.time_since_last_void_s >= self.refractory_seconds {
self.phase = BladderPhase::Filling;
}
}
pub fn metrics(&self) -> BladderMetrics {
BladderMetrics {
phase: self.phase,
volume_ml: self.volume_ml,
capacity_ml: self.capacity_ml,
pressure_cm_h2o: self.pressure,
detrusor_pressure_cm_h2o: self.detrusor_pressure_cm_h2o,
pressure_safety_limit_cm_h2o: self.compliance.safety_limit(),
afferent_signal: self.afferent_signal,
urgency: self.urgency,
cortical_inhibition: self.cortical_inhibition,
voluntary_hold_command: self.voluntary_hold_command,
cortical_gate_fraction: self.cortical_gate_fraction,
voluntary_hold_fraction: self.voluntary_hold_fraction,
guarding_reflex_gain: self.reflex.guarding_reflex_gain,
pontine_storage_signal: self.reflex.pontine_storage_signal,
pontine_micturition_signal: self.reflex.pontine_micturition_signal,
hypogastric_efferent: self.reflex.hypogastric_efferent,
pudendal_efferent: self.reflex.pudendal_efferent,
parasympathetic_drive: self.parasympathetic_drive,
sympathetic_drive: self.sympathetic_drive,
somatic_drive: self.somatic_drive,
pressure_stress_index: self.pressure_stress_index,
time_above_safety_limit_s: self.time_above_safety_limit_s,
}
}
}
@@ -27,16 +614,52 @@ impl Organ for Bladder {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {
// simplistic pressure-volume relation
self.pressure = (self.volume_ml / 50.0).clamp(0.0, 30.0);
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_since_last_void_s += dt_seconds;
let inflow = (self.filling_rate_ml_per_min / 60.0).max(0.0) * dt_seconds;
self.volume_ml += inflow;
self.update_voluntary_modulators(dt_seconds);
self.update_threshold_targets(dt_seconds);
self.update_afferents();
self.update_reflex_gate(dt_seconds);
self.update_drives(dt_seconds);
self.update_sphincters();
self.update_pressure(dt_seconds);
self.update_reflex_efferents(dt_seconds);
match self.phase {
BladderPhase::Filling => self.handle_filling_phase(dt_seconds),
BladderPhase::Voiding => self.handle_voiding_phase(dt_seconds),
BladderPhase::PostVoidRefractory => self.handle_post_void_phase(),
}
if matches!(self.phase, BladderPhase::Voiding)
&& self.volume_ml <= self.residual_volume_ml + 1.0
{
self.phase = BladderPhase::PostVoidRefractory;
self.time_since_last_void_s = 0.0;
}
}
fn summary(&self) -> String {
format!(
"Bladder[id={}, vol={:.0} ml, P={:.1}]",
"Bladder[id={}, phase={:?}, vol={:.0}/{:.0} ml, P={:.1} cmH2O (det={:.1}), urge={:.0}%, stress={:.0}, guard={:.0}%, void={:.0}%, gate={:.0}%, hold={:.0}%]",
self.id(),
self.phase,
self.volume_ml,
self.pressure
self.capacity_ml,
self.pressure,
self.detrusor_pressure_cm_h2o,
self.urgency * 100.0,
self.pressure_stress_index,
self.reflex.guarding_reflex_gain * 100.0,
self.reflex.pontine_micturition_signal * 100.0,
self.cortical_gate_fraction * 100.0,
self.voluntary_hold_fraction * 100.0
)
}
fn as_any(&self) -> &dyn core::any::Any {
@@ -46,3 +669,181 @@ impl Organ for Bladder {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compliance_declines_with_high_volume() {
let model = ComplianceModel::new();
let compliance_low = model.effective_compliance(0.3, 60.0);
let compliance_high = model.effective_compliance(1.3, 60.0);
assert!(compliance_low > compliance_high);
}
#[test]
fn detrusor_overload_updates_risk_metrics() {
let mut bladder = Bladder::new("test");
bladder.volume_ml = bladder.capacity_ml * 1.35;
bladder.parasympathetic_drive = 0.7;
bladder.sympathetic_drive = 0.2;
bladder.internal_sphincter_tone = 0.2;
bladder.external_sphincter_tone = 0.2;
bladder.update_pressure(1.0);
assert!(
bladder.detrusor_pressure_cm_h2o > bladder.compliance.safety_limit(),
"expected detrusor {} to exceed limit {}",
bladder.detrusor_pressure_cm_h2o,
bladder.compliance.safety_limit()
);
assert!(bladder.time_above_safety_limit_s > 0.0);
assert!(bladder.pressure_stress_index > 0.0);
}
#[test]
fn stress_index_decays_when_pressure_normalizes() {
let mut bladder = Bladder::new("test");
bladder.pressure_stress_index = 200.0;
bladder.time_above_safety_limit_s = 12.0;
bladder.volume_ml = 140.0;
bladder.parasympathetic_drive = 0.05;
bladder.sympathetic_drive = 0.9;
bladder.internal_sphincter_tone = 0.9;
bladder.external_sphincter_tone = 0.9;
bladder.update_pressure(1.5);
let after = bladder.pressure_stress_index;
bladder.update_pressure(1.5);
assert!(
after < 200.0 && bladder.pressure_stress_index <= after,
"expected stress index to decay, got {} then {}",
after,
bladder.pressure_stress_index
);
}
#[test]
fn guarding_reflex_active_during_storage() {
let mut bladder = Bladder::new("storage");
bladder.volume_ml = bladder.urge_threshold_ml + 60.0;
bladder.update(1.0);
assert!(bladder.reflex.guarding_reflex_gain > 0.2);
assert!(bladder.reflex.hypogastric_efferent > 0.5);
assert!(bladder.reflex.pudendal_efferent > 0.4);
assert!(
bladder.reflex.pontine_storage_signal > bladder.reflex.pontine_micturition_signal,
"storage center should dominate in filling"
);
}
#[test]
fn pontine_switch_reduces_guarding_during_void() {
let mut bladder = Bladder::new("void");
bladder.phase = BladderPhase::Voiding;
bladder.volume_ml = bladder.micturition_threshold_ml + 180.0;
bladder.detrusor_pressure_cm_h2o = bladder.compliance.safety_limit() + 12.0;
bladder.urgency = 0.98;
bladder.update(0.3);
bladder.update(0.2);
let pontine = bladder.reflex.pontine_micturition_signal;
let hypogastric = bladder.reflex.hypogastric_efferent;
let pudendal = bladder.reflex.pudendal_efferent;
assert!(
pontine > 0.5,
"pontine signal too low: {:.2} (guard={:.2}, aff={:.2})",
pontine,
bladder.reflex.guarding_reflex_gain,
bladder.reflex.pelvic_afferent_rate,
);
assert!(
hypogastric < 0.45,
"hypogastric stays high: {:.2}",
hypogastric
);
assert!(pudendal < 0.45, "pudendal stays high: {:.2}", pudendal);
}
#[test]
fn cortical_behavioral_inputs_reduce_urgency() {
let mut baseline = Bladder::new("baseline");
baseline.volume_ml = baseline.micturition_threshold_ml + 120.0;
baseline.cortical_inhibition = 0.0;
baseline.voluntary_hold_command = 0.0;
baseline.continence_training = 0.3;
for _ in 0..5 {
baseline.update(0.5);
}
let mut conditioned = Bladder::new("conditioned");
conditioned.volume_ml = baseline.volume_ml;
conditioned.cortical_inhibition = 0.85;
conditioned.voluntary_hold_command = 0.8;
conditioned.continence_training = 0.85;
for _ in 0..5 {
conditioned.update(0.5);
}
assert!(
conditioned.urgency < baseline.urgency,
"expected urgency {:.2} to fall below baseline {:.2}",
conditioned.urgency,
baseline.urgency
);
assert!(
conditioned.external_sphincter_tone > baseline.external_sphincter_tone,
"expected hold to raise sphincter tone {:.2} vs {:.2}",
conditioned.external_sphincter_tone,
baseline.external_sphincter_tone
);
}
#[test]
fn thresholds_reflect_age_and_neurologic_status() {
let mut young = Bladder::new("young");
young.age_years = 25.0;
young.neurologic_integrity = 1.0;
young.continence_training = 0.5;
young.cortical_inhibition = 0.0;
young.voluntary_hold_command = 0.0;
young.filling_rate_ml_per_min = 45.0;
for _ in 0..12 {
young.update(1.0);
}
let mut elderly = Bladder::new("elderly");
elderly.age_years = 78.0;
elderly.neurologic_integrity = 0.45;
elderly.continence_training = 0.5;
elderly.cortical_inhibition = 0.0;
elderly.voluntary_hold_command = 0.0;
elderly.filling_rate_ml_per_min = 95.0;
for _ in 0..12 {
elderly.update(1.0);
}
assert!(
elderly.capacity_ml < young.capacity_ml,
"expected elderly capacity {:.1} < young {:.1}",
elderly.capacity_ml,
young.capacity_ml
);
assert!(
elderly.urge_threshold_ml < young.urge_threshold_ml,
"expected elderly urge {:.1} < young {:.1}",
elderly.urge_threshold_ml,
young.urge_threshold_ml
);
assert!(
elderly.micturition_threshold_ml < young.micturition_threshold_ml,
"expected elderly mict {:.1} < young {:.1}",
elderly.micturition_threshold_ml,
young.micturition_threshold_ml
);
}
}
File diff suppressed because it is too large Load Diff
+818 -8
View File
@@ -1,23 +1,521 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
use core::f32::consts::TAU;
const CIRCADIAN_PERIOD_SECONDS: f32 = 24.0 * 3600.0;
const HOMEOSTATIC_WAKE_ACCUMULATION_S: f32 = 16.0 * 3600.0;
const HOMEOSTATIC_DISCHARGE_S: f32 = 6.0 * 3600.0;
/// Median sleep cycle duration from large-scale polysomnography study (Åkerstedt et al. 2024).
/// Sleep Health: 96 min median across 6,064 sleep cycles.
const TYPICAL_CYCLE_DURATION_S: f32 = 96.0 * 60.0;
/// REM latency typically 60-120 minutes in healthy adults (Carskadon & Dement, StatPearls 2024).
const FIRST_REM_LATENCY_S: f32 = 90.0 * 60.0;
/// Sleep architecture stages used for brain state transitions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SleepStage {
Wake,
N1,
N2,
N3,
Rem,
}
#[derive(Debug, Clone)]
pub struct Brain {
info: OrganInfo,
/// 0..=100 scale of consciousness.
/// 0..=100 index approximating global cortical consciousness/alertness.
pub consciousness: u8,
/// Simplified neural activity index.
/// Composite neural activity index blending frequency and metabolic power.
pub activity_index: f32,
/// Normalized cortical arousal (0..=1).
pub cortical_arousal: f32,
/// Brainstem autonomic output regulating MAP and respiratory drive (0..=1).
pub brainstem_autonomic_drive: f32,
/// Intracranial pressure (mmHg).
pub intracranial_pressure_mm_hg: f32,
/// Cerebral perfusion pressure (mmHg).
pub cerebral_perfusion_pressure_mm_hg: f32,
/// Cerebral blood flow (ml/100g/min).
pub cerebral_blood_flow_ml_per_100g_min: f32,
/// Fractional metabolic demand relative to resting wakefulness.
pub metabolic_demand_fraction: f32,
/// Cortical oxygen saturation (fraction 0..=1).
pub oxygenation_saturation: f32,
/// Excitatory glutamatergic tone (relative units 0.3..=1.4).
pub glutamate_level: f32,
/// Inhibitory GABAergic tone (relative units 0.4..=1.2).
pub gaba_level: f32,
/// Mesolimbic/striatal dopamine tone (0..=1).
pub dopamine_tone: f32,
/// Sleep homeostatic pressure (0..≈1.1).
pub sleep_pressure: f32,
/// Circadian phase in radians (0..TAU, midnight ≈ 0).
pub circadian_phase_radians: f32,
/// Current polysomnographic sleep stage.
pub sleep_stage: SleepStage,
/// Dominant EEG frequency (Hz).
pub eeg_dominant_frequency_hz: f32,
/// Instantaneous seizure risk (0..=1).
pub seizure_risk: f32,
/// Autonomic variability (0..=1, higher reflects sympathetic swings).
pub autonomic_variability: f32,
/// Cognitive/task load (0..=1) influencing metabolic demand.
pub cognitive_load: f32,
/// Chemoreceptor-driven urge to ventilate (0..~1.3).
pub respiratory_drive: f32,
/// Likelihood of hypocapnic syncope (0..=1).
pub syncope_propensity: f32,
/// Osmotic/volume-inspired thirst drive (0..~1.4).
pub thirst_drive: f32,
/// Nutritional hunger drive (0..~1.3).
pub hunger_drive: f32,
/// Thermoregulatory discomfort drive (0..~1.2).
pub thermoregulatory_drive: f32,
/// Aggregated nociceptive/pain drive (0..~1.5).
pub pain_drive: f32,
time_in_stage_s: f32,
/// Total time asleep in current sleep bout (seconds).
time_asleep_s: f32,
/// Cumulative count of sleep cycles completed in current sleep bout.
sleep_cycle_count: u32,
/// Sleep spindle density (count per minute) during N2 sleep.
/// Normal range: 2-5 spindles/min (Fernandez & Lüthi, Nat Neurosci 2020).
pub spindle_density_per_min: f32,
/// K-complex count per minute during N2 sleep.
/// Typically 1-3 per minute (Colrain, Sleep Med Rev 2005).
pub k_complex_density_per_min: f32,
/// Sigma band power (11-16 Hz) relative to baseline, proxy for spindle activity.
/// High correlation with spindle amplitude r=0.95 (Warby et al., Nat Methods 2014).
pub sigma_power_relative: f32,
/// Chin EMG tonic activity during REM as fraction of NREM baseline.
/// Normal REM: <10% tonic activity (Frauscher et al., Sleep 2014).
pub rem_tonic_emg_fraction: f32,
/// Chin EMG phasic burst activity during REM (% of 30-s epoch).
/// Normal: <15% phasic activity (Frauscher et al., Sleep 2014).
pub rem_phasic_emg_pct: f32,
/// REM atonia index: fraction of REM sleep with muscle atonia.
/// Normal: >85-90% atonia (McCarter et al., Sleep 2014).
pub rem_atonia_index: f32,
// Brainstem nuclei activities (0..=1 representing normalized firing rates)
/// Nucleus tractus solitarius (NTS) activity processing baroreceptor input.
/// Receives afferents from CN IX/X; inhibits RVLM (Dampney, Clin Exp Pharmacol Physiol 2016).
pub nts_activity: f32,
/// Rostral ventrolateral medulla (RVLM) sympathetic premotor neuron activity.
/// Primary source of tonic sympathetic vasomotor drive (Guyenet, Nat Rev Neurosci 2006).
pub rvlm_activity: f32,
/// Caudal ventrolateral medulla (CVLM) inhibitory interneuron activity.
/// Receives NTS input; inhibits RVLM to reduce sympathetic tone.
pub cvlm_activity: f32,
/// Nucleus ambiguus (nAmb) cardiovagal neuron activity.
/// Primary cardiac vagal efferent controlling HR via myelinated fibers (McAllen et al., J Physiol 2011).
pub namb_cardiovagal_activity: f32,
/// Dorsal motor nucleus of vagus (DMV) cardiac neuron activity.
/// ~20% of cardiovagal neurons; unmyelinated C-fibers; modulates contractility (Gourine et al., iScience 2024).
pub dmv_cardiac_activity: f32,
/// Retrotrapezoid nucleus (RTN) central chemoreceptor neuron activity.
/// CO2/pH sensitive; drives 2/3 of ventilatory CO2 response (Guyenet & Bayliss, J Neurosci 2025).
pub rtn_chemoreceptor_activity: f32,
/// Peripheral chemoreceptor signal from carotid/aortic bodies via NTS.
/// Primarily O2, also CO2/pH; ~1/3 of CO2 response (Guyenet & Bayliss, J Appl Physiol 2010).
pub peripheral_chemo_signal: f32,
// Cerebrovascular autoregulation parameters
/// Cerebrovascular resistance (mmHg·min·100g/mL).
/// Normal ~1.4 at baseline CPP=70 mmHg, CBF=50 mL/100g/min (Paulson et al., Cerebrovasc Dis 1990).
pub cerebrovascular_resistance: f32,
/// Lower autoregulation limit (mmHg CPP).
/// Normal ~50 mmHg; shifts with chronic hypertension (Drummond, BJA 2024).
pub autoregulation_lower_limit: f32,
/// Upper autoregulation limit (mmHg CPP).
/// Normal ~150 mmHg; breakthrough above this causes edema (Drummond, BJA 2024).
pub autoregulation_upper_limit: f32,
/// Autoregulation curve slope (normalized 0..=1).
/// 0 = no autoregulation (passive pressure-flow), 1 = perfect autoregulation.
pub autoregulation_efficiency: f32,
/// CO2 reactivity: ΔCBF per mmHg ΔPaCO2 (mL/100g/min per mmHg).
/// Normal 1-2 mL/100g/min/mmHg (Claassen et al., Physiol Rev 2021).
pub co2_reactivity: f32,
}
impl Brain {
pub fn new(id: impl Into<String>) -> Self {
Self {
info: OrganInfo::new(id, OrganType::Brain),
consciousness: 100,
consciousness: 95,
activity_index: 1.0,
cortical_arousal: 0.82,
brainstem_autonomic_drive: 0.9,
intracranial_pressure_mm_hg: 10.0,
cerebral_perfusion_pressure_mm_hg: 75.0,
cerebral_blood_flow_ml_per_100g_min: 52.0,
metabolic_demand_fraction: 1.05,
oxygenation_saturation: 0.98,
glutamate_level: 0.65,
gaba_level: 0.55,
dopamine_tone: 0.6,
sleep_pressure: 0.4,
circadian_phase_radians: TAU * 0.25,
sleep_stage: SleepStage::Wake,
eeg_dominant_frequency_hz: 18.0,
seizure_risk: 0.05,
autonomic_variability: 0.35,
cognitive_load: 0.35,
respiratory_drive: 0.6,
syncope_propensity: 0.05,
thirst_drive: 0.3,
hunger_drive: 0.35,
thermoregulatory_drive: 0.5,
pain_drive: 0.2,
time_in_stage_s: 0.0,
time_asleep_s: 0.0,
sleep_cycle_count: 0,
spindle_density_per_min: 3.5,
k_complex_density_per_min: 2.0,
sigma_power_relative: 1.0,
rem_tonic_emg_fraction: 0.05,
rem_phasic_emg_pct: 8.0,
rem_atonia_index: 0.92,
nts_activity: 0.5,
rvlm_activity: 0.65,
cvlm_activity: 0.45,
namb_cardiovagal_activity: 0.4,
dmv_cardiac_activity: 0.35,
rtn_chemoreceptor_activity: 0.6,
peripheral_chemo_signal: 0.55,
cerebrovascular_resistance: 1.4,
autoregulation_lower_limit: 50.0,
autoregulation_upper_limit: 150.0,
autoregulation_efficiency: 0.85,
co2_reactivity: 1.5,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn wrap_phase(phase: f32) -> f32 {
if (0.0..TAU).contains(&phase) {
return phase;
}
let mut wrapped = phase % TAU;
if wrapped < 0.0 {
wrapped += TAU;
}
wrapped
}
fn transition_stage(&mut self, stage: SleepStage) {
if self.sleep_stage != stage {
// Completing a REM period marks the end of a sleep cycle
if self.sleep_stage == SleepStage::Rem && stage != SleepStage::Wake {
self.sleep_cycle_count += 1;
}
// Waking resets sleep bout tracking
if stage == SleepStage::Wake && self.sleep_stage != SleepStage::Wake {
if self.time_asleep_s > 600.0 {
// Only reset if woke from substantial sleep (>10 min)
self.time_asleep_s = 0.0;
self.sleep_cycle_count = 0;
}
}
self.sleep_stage = stage;
self.time_in_stage_s = 0.0;
}
}
/// Adaptive cycle timing based on Åkerstedt et al. 2024 (Sleep Health) and homeostatic factors.
/// First cycle is shorter; high sleep pressure extends NREM; low pressure extends REM.
fn evaluate_sleep_stage(&mut self, base_arousal: f32) {
// Cycle modulation: first cycle is shorter (Åkerstedt 2024)
let cycle_factor = if self.sleep_cycle_count == 0 {
0.85
} else {
1.0
};
// High sleep pressure extends NREM, low pressure extends REM (Åkerstedt 2024)
let nrem_extension = (self.sleep_pressure - 0.5).max(0.0) * 1.2;
let rem_extension = (0.5 - self.sleep_pressure).max(0.0) * 1.3;
match self.sleep_stage {
SleepStage::Wake => {
if base_arousal < 0.35 && self.sleep_pressure > 0.55 {
self.transition_stage(SleepStage::N1);
}
}
SleepStage::N1 => {
// N1 typically 1-7 minutes (Carskadon & Dement, StatPearls 2024)
let n1_duration = 180.0 * cycle_factor;
if base_arousal > 0.5 {
self.transition_stage(SleepStage::Wake);
} else if self.time_in_stage_s > n1_duration && self.sleep_pressure > 0.6 {
self.transition_stage(SleepStage::N2);
}
}
SleepStage::N2 => {
// N2 typically 10-25 minutes initially, longer in later cycles
let base_n2_duration = 900.0 * cycle_factor * (1.0 + nrem_extension);
let n3_threshold = base_n2_duration * (1.0 + nrem_extension);
// First REM latency 60-120 min (typically 90 min, StatPearls 2024)
let rem_ready = if self.sleep_cycle_count == 0 {
self.time_asleep_s > FIRST_REM_LATENCY_S * 0.8
} else {
self.time_in_stage_s > base_n2_duration * 1.5
};
if base_arousal > 0.52 {
self.transition_stage(SleepStage::Wake);
} else if self.time_in_stage_s > n3_threshold && self.sleep_pressure > 0.65 {
self.transition_stage(SleepStage::N3);
} else if rem_ready {
self.transition_stage(SleepStage::Rem);
}
}
SleepStage::N3 => {
// N3 duration decreases across night; more in first cycle
let n3_duration = if self.sleep_cycle_count == 0 {
1800.0 * (1.0 + nrem_extension)
} else {
1200.0 * cycle_factor * (1.0 + nrem_extension)
};
if self.time_in_stage_s > n3_duration {
self.transition_stage(SleepStage::Rem);
} else if base_arousal > 0.45 {
self.transition_stage(SleepStage::N2);
}
}
SleepStage::Rem => {
// REM duration increases across cycles; first REM ~5-10 min, later ~20-25 min
// Positive correlation: longer REM predicts longer interval to next REM
let base_rem_duration = if self.sleep_cycle_count == 0 {
600.0 // ~10 min first REM
} else {
900.0 + (self.sleep_cycle_count as f32 * 300.0).min(600.0) // up to 25 min
};
let rem_duration = base_rem_duration * (1.0 + rem_extension);
if self.time_in_stage_s > rem_duration {
self.transition_stage(SleepStage::N2);
} else if base_arousal > 0.65 {
self.transition_stage(SleepStage::Wake);
}
}
}
}
fn stage_arousal_target(&self, base_arousal: f32) -> f32 {
match self.sleep_stage {
SleepStage::Wake => base_arousal.max(0.6),
SleepStage::N1 => base_arousal.clamp(0.3, 0.55),
SleepStage::N2 => base_arousal.clamp(0.2, 0.45),
SleepStage::N3 => base_arousal.min(0.25),
SleepStage::Rem => base_arousal.clamp(0.45, 0.7),
}
}
/// Simulate brainstem nuclei interactions for autonomic control.
/// Based on:
/// - Guyenet (Nat Rev Neurosci 2006): RVLM as sympathetic vasomotor source
/// - Dampney (Clin Exp Pharmacol Physiol 2016): NTS→CVLM→RVLM baroreflex arc
/// - Gourine et al. (iScience 2024): nAmb/DMV cardiovagal pathways
/// - Guyenet & Bayliss (J Neurosci 2025): RTN central chemoreceptors
fn update_brainstem_nuclei(&mut self, dt_seconds: f32) {
// Baroreceptor input: elevated CPP → increased NTS activity
let baroreceptor_input =
((self.cerebral_perfusion_pressure_mm_hg - 70.0) / 40.0).clamp(-0.5, 0.5) + 0.5;
// Chemoreceptor inputs
// Central chemoreceptors (RTN): sensitive to CO2/pH (proxied by respiratory drive)
let central_chemo_input = self.respiratory_drive.clamp(0.3, 1.2);
// Peripheral chemoreceptors: O2 (via oxygenation) + CO2 contribution
let o2_signal = (0.98 - self.oxygenation_saturation).max(0.0) * 3.0;
let co2_signal = (self.respiratory_drive - 0.6).max(0.0) * 0.4;
let peripheral_input = (0.5 + o2_signal + co2_signal).clamp(0.3, 1.3);
// NTS integrates baroreceptor and peripheral chemoreceptor inputs
// High baroreceptor → high NTS → inhibition of sympathetic tone
// Chemoreceptors also converge at NTS (interfere with baroreflex per 2024 study)
let nts_target =
(0.4 * baroreceptor_input + 0.35 * peripheral_input + 0.25 * central_chemo_input)
.clamp(0.2, 1.0);
self.nts_activity = Self::approach(self.nts_activity, nts_target, 1.5, dt_seconds);
// CVLM receives excitatory input from NTS and inhibits RVLM
let cvlm_target = (0.3 + 0.7 * self.nts_activity).clamp(0.2, 0.95);
self.cvlm_activity = Self::approach(self.cvlm_activity, cvlm_target, 1.2, dt_seconds);
// RVLM: tonic sympathetic drive, inhibited by CVLM
// Baseline drive adjusted by arousal and sleep stage
let arousal_drive = self.cortical_arousal * 0.4;
let sleep_suppression = match self.sleep_stage {
SleepStage::Wake => 0.0,
SleepStage::N1 => 0.05,
SleepStage::N2 => 0.1,
SleepStage::N3 => 0.18,
SleepStage::Rem => 0.08,
};
let rvlm_target = (0.65 + arousal_drive - 0.5 * self.cvlm_activity - sleep_suppression
+ self.pain_drive * 0.15
+ (self.respiratory_drive - 0.6) * 0.2)
.clamp(0.25, 1.1);
self.rvlm_activity = Self::approach(self.rvlm_activity, rvlm_target, 0.8, dt_seconds);
// Nucleus ambiguus cardiovagal neurons: primary HR control via myelinated vagal fibers
// High NTS activity (high BP) → high vagal tone → bradycardia
let vagal_excitation = self.nts_activity * 0.6;
let namb_target = (0.3 + vagal_excitation - self.cortical_arousal * 0.3
+ match self.sleep_stage {
SleepStage::Wake => 0.0,
SleepStage::N1 => 0.05,
SleepStage::N2 => 0.1,
SleepStage::N3 => 0.15,
SleepStage::Rem => -0.05, // REM has more variable autonomic tone
})
.clamp(0.15, 0.85);
self.namb_cardiovagal_activity =
Self::approach(self.namb_cardiovagal_activity, namb_target, 1.0, dt_seconds);
// DMV cardiac neurons: ~20% of cardiovagal neurons, modulate contractility via C-fibers
let dmv_target = (0.25 + 0.5 * self.nts_activity - 0.2 * self.cortical_arousal
+ match self.sleep_stage {
SleepStage::N2 | SleepStage::N3 => 0.12,
_ => 0.0,
})
.clamp(0.15, 0.75);
self.dmv_cardiac_activity =
Self::approach(self.dmv_cardiac_activity, dmv_target, 0.7, dt_seconds);
// RTN central chemoreceptors: CO2/pH drive (2/3 of ventilatory CO2 response)
let rtn_target = central_chemo_input.clamp(0.4, 1.1);
self.rtn_chemoreceptor_activity =
Self::approach(self.rtn_chemoreceptor_activity, rtn_target, 0.9, dt_seconds);
// Peripheral chemoreceptor signal (1/3 of CO2 response, primary O2 sensor)
self.peripheral_chemo_signal = Self::approach(
self.peripheral_chemo_signal,
peripheral_input,
1.2,
dt_seconds,
);
// Integrate brainstem outputs into autonomic drive and respiratory drive
// RVLM drives sympathetic tone → brainstem_autonomic_drive
// Combined vagal tone (nAmb + DMV) provides parasympathetic counterbalance
let sympathetic_component = self.rvlm_activity * 0.6;
let parasympathetic_component =
(self.namb_cardiovagal_activity * 0.8 + self.dmv_cardiac_activity * 0.2) * 0.4;
let net_autonomic =
(sympathetic_component + (1.0 - parasympathetic_component)).clamp(0.25, 1.1);
// Respiratory drive integrates RTN (central) and peripheral chemoreceptors
let chemo_drive = (self.rtn_chemoreceptor_activity * 0.65
+ self.peripheral_chemo_signal * 0.35)
.clamp(0.4, 1.3);
// Blend computed autonomic/respiratory drives with existing targets
// (existing code uses these as feedback; brainstem provides mechanistic basis)
self.brainstem_autonomic_drive = Self::approach(
self.brainstem_autonomic_drive,
net_autonomic,
0.5,
dt_seconds,
)
.clamp(0.25, 1.1);
self.respiratory_drive =
Self::approach(self.respiratory_drive, chemo_drive, 0.4, dt_seconds).clamp(0.4, 1.3);
}
/// Cerebrovascular autoregulation model with myogenic, metabolic, and CO2 reactivity.
/// Based on:
/// - Paulson et al. (Cerebrovasc Dis 1990): CVR = CPP/CBF relationship
/// - Claassen et al. (Physiol Rev 2021): comprehensive autoregulation review
/// - Drummond (BJA 2024): monitoring and clinical implications
fn update_cerebrovascular_autoregulation(&mut self, dt_seconds: f32) {
let cpp = self.cerebral_perfusion_pressure_mm_hg;
// Within autoregulation range (50-150 mmHg): resistance adjusts to maintain CBF ~50 mL/100g/min
// Below lower limit: vasodilation exhausted, passive pressure-flow
// Above upper limit: forced vasodilation (breakthrough)
let in_autoregulation_range =
cpp >= self.autoregulation_lower_limit && cpp <= self.autoregulation_upper_limit;
let myogenic_resistance = if in_autoregulation_range {
// Active autoregulation: R = CPP / target_CBF
// Target CBF = 50 mL/100g/min baseline, modulated by metabolic demand
let target_cbf = 50.0 * self.metabolic_demand_fraction;
(cpp / target_cbf).clamp(0.8, 2.5)
} else if cpp < self.autoregulation_lower_limit {
// Below lower limit: vasodilation maximally exhausted, resistance low
let exhaustion_factor = (cpp / self.autoregulation_lower_limit).clamp(0.3, 1.0);
0.8 * exhaustion_factor
} else {
// Above upper limit: forced vasodilation (breakthrough), resistance collapses
let breakthrough_factor =
1.0 - ((cpp - self.autoregulation_upper_limit) / 50.0).clamp(0.0, 0.7);
0.6 * breakthrough_factor.max(0.3)
};
// Metabolic regulation: hypoxia, hypercapnia → vasodilation (decreased resistance)
let hypoxia_factor = (0.95 - self.oxygenation_saturation).max(0.0) * 2.0;
let metabolic_resistance_reduction = hypoxia_factor * 0.25;
// CO2 reactivity: 1-2 mL/100g/min per mmHg PaCO2 change (Claassen et al., Physiol Rev 2021)
// Proxy PaCO2 via respiratory drive: baseline ~40 mmHg at respiratory_drive=0.6
let estimated_paco2 = 30.0 + (self.respiratory_drive - 0.4) * 25.0; // rough estimate
let paco2_deviation = estimated_paco2 - 40.0;
// CBF increases ~4% per mmHg CO2 (1-2 mL/100g/min at baseline 50 mL/100g/min)
// Resistance inversely related: higher CO2 → lower resistance
let co2_resistance_factor =
1.0 - (paco2_deviation * self.co2_reactivity / 50.0).clamp(-0.3, 0.4);
// Neurogenic modulation: sympathetic tone can override autoregulation partially
let sympathetic_constriction = (self.rvlm_activity - 0.65) * 0.12;
// Combine factors
let target_resistance = (myogenic_resistance * co2_resistance_factor
- metabolic_resistance_reduction
+ sympathetic_constriction)
.clamp(0.5, 3.0);
// Smooth resistance changes (myogenic response time constant ~5-15 sec)
self.cerebrovascular_resistance = Self::approach(
self.cerebrovascular_resistance,
target_resistance,
0.15,
dt_seconds,
)
.clamp(0.5, 3.5);
// Calculate CBF from CPP and resistance: CBF = CPP / CVR
let calculated_cbf = (cpp / self.cerebrovascular_resistance).clamp(25.0, 120.0);
self.cerebral_blood_flow_ml_per_100g_min = Self::approach(
self.cerebral_blood_flow_ml_per_100g_min,
calculated_cbf,
2.0,
dt_seconds,
)
.clamp(20.0, 130.0);
}
}
impl Organ for Brain {
@@ -27,15 +525,327 @@ impl Organ for Brain {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {
self.activity_index = self.activity_index.clamp(0.0, 2.0);
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_in_stage_s += dt_seconds;
// Track total time asleep for cycle calculations
let asleep = !matches!(self.sleep_stage, SleepStage::Wake);
if asleep {
self.time_asleep_s += dt_seconds;
}
let circadian_increment = TAU * (dt_seconds / CIRCADIAN_PERIOD_SECONDS);
self.circadian_phase_radians =
Self::wrap_phase(self.circadian_phase_radians + circadian_increment);
let circadian_arousal = 0.55 + 0.45 * (self.circadian_phase_radians - TAU * 0.25).sin();
let sleep_pressure_delta = if asleep {
-dt_seconds / HOMEOSTATIC_DISCHARGE_S
} else {
dt_seconds / HOMEOSTATIC_WAKE_ACCUMULATION_S
};
self.sleep_pressure = (self.sleep_pressure + sleep_pressure_delta).clamp(0.0, 1.1);
let base_arousal = (circadian_arousal - 0.55 * self.sleep_pressure).clamp(0.05, 1.0);
let thirst_component = (self.thirst_drive - 0.3).max(0.0) * 0.18;
let hunger_component = (self.hunger_drive - 0.35).max(0.0) * 0.15;
let thermo_component = (self.thermoregulatory_drive - 0.5).abs() * 0.12;
let pain_component = self.pain_drive * 0.2;
let drive_bonus = (self.respiratory_drive - 0.6).max(0.0) * 0.25
+ thirst_component
+ hunger_component
+ thermo_component
+ pain_component;
let syncope_penalty = self.syncope_propensity * 0.35;
let drive_weighted_arousal =
(base_arousal + drive_bonus - syncope_penalty).clamp(0.05, 1.0);
self.evaluate_sleep_stage(drive_weighted_arousal);
let arousal_target = self.stage_arousal_target(drive_weighted_arousal);
self.cortical_arousal =
Self::approach(self.cortical_arousal, arousal_target, 0.8, dt_seconds).clamp(0.05, 1.0);
// Update brainstem nuclei (baroreflex, chemoreflex, vagal pathways)
self.update_brainstem_nuclei(dt_seconds);
let (base_autonomic_target, base_variability_target) = match self.sleep_stage {
SleepStage::Wake => (0.9, 0.35),
SleepStage::N1 => (0.82, 0.4),
SleepStage::N2 => (0.78, 0.45),
SleepStage::N3 => (0.72, 0.3),
SleepStage::Rem => (0.86, 0.6),
};
let drive_push = (self.respiratory_drive - 0.6) * 0.35;
let syncope_pull = self.syncope_propensity * 0.45;
let autonomic_target = (base_autonomic_target + drive_push - syncope_pull).clamp(0.25, 1.1);
let variability_target = (base_variability_target
+ (self.respiratory_drive - 0.6).abs() * 0.15
+ self.syncope_propensity * 0.1
+ self.pain_drive * 0.12
+ (self.thermoregulatory_drive - 0.5).abs() * 0.08)
.clamp(0.05, 0.95);
self.brainstem_autonomic_drive = Self::approach(
self.brainstem_autonomic_drive,
autonomic_target,
0.6,
dt_seconds,
)
.clamp(0.25, 1.1);
self.autonomic_variability = Self::approach(
self.autonomic_variability,
variability_target,
0.4,
dt_seconds,
)
.clamp(0.05, 0.95);
let cognitive_target = if matches!(self.sleep_stage, SleepStage::Wake) {
(0.3 + 0.45 * self.cortical_arousal).clamp(0.15, 1.0)
} else {
0.12
};
self.cognitive_load =
Self::approach(self.cognitive_load, cognitive_target, 0.25, dt_seconds)
.clamp(0.05, 1.0);
let base_metabolic = match self.sleep_stage {
SleepStage::Wake => 1.05,
SleepStage::N1 => 0.95,
SleepStage::N2 => 0.85,
SleepStage::N3 => 0.7,
SleepStage::Rem => 1.0,
};
let metabolic_target = (base_metabolic
+ 0.25 * (self.cognitive_load - 0.2)
+ 0.1 * (self.glutamate_level - self.gaba_level)
+ 0.12 * (self.respiratory_drive - 0.6)
+ 0.14 * (self.hunger_drive - 0.35)
+ 0.06 * (self.thermoregulatory_drive - 0.5)
- 0.1 * self.syncope_propensity)
.clamp(0.6, 1.4);
self.metabolic_demand_fraction = Self::approach(
self.metabolic_demand_fraction,
metabolic_target,
0.35,
dt_seconds,
)
.clamp(0.6, 1.5);
let (glu_base, gaba_base, dopamine_base, eeg_target_base) = match self.sleep_stage {
SleepStage::Wake => (0.65, 0.55, 0.6, 18.0),
SleepStage::N1 => (0.55, 0.62, 0.5, 9.0),
SleepStage::N2 => (0.5, 0.68, 0.45, 6.0),
SleepStage::N3 => (0.45, 0.75, 0.4, 2.0),
SleepStage::Rem => (0.68, 0.6, 0.65, 10.5),
};
let arousal_offset = self.cortical_arousal - 0.5;
let glutamate_target = (glu_base + 0.15 * arousal_offset).clamp(0.3, 1.3);
let gaba_target = (gaba_base - 0.1 * arousal_offset).clamp(0.4, 1.2);
let dopamine_target = (dopamine_base + 0.05 * circadian_arousal
- 0.03 * self.sleep_pressure)
.clamp(0.35, 0.85);
let eeg_target = match self.sleep_stage {
SleepStage::Wake => eeg_target_base + 4.0 * (self.cortical_arousal - 0.7).max(0.0),
SleepStage::Rem => eeg_target_base + 2.0 * (self.cortical_arousal - 0.6).max(0.0),
_ => eeg_target_base,
};
self.glutamate_level =
Self::approach(self.glutamate_level, glutamate_target, 0.5, dt_seconds).clamp(0.3, 1.4);
self.gaba_level =
Self::approach(self.gaba_level, gaba_target, 0.45, dt_seconds).clamp(0.4, 1.2);
self.dopamine_tone =
Self::approach(self.dopamine_tone, dopamine_target, 0.3, dt_seconds).clamp(0.3, 0.9);
self.eeg_dominant_frequency_hz =
Self::approach(self.eeg_dominant_frequency_hz, eeg_target, 0.8, dt_seconds)
.clamp(0.5, 35.0);
let oxygen_target = (0.95 + 0.03 * self.brainstem_autonomic_drive
- 0.04 * (self.metabolic_demand_fraction - 1.0)
- 0.05 * self.syncope_propensity
+ 0.02 * (self.respiratory_drive - 0.6))
.clamp(0.85, 0.99);
self.oxygenation_saturation =
Self::approach(self.oxygenation_saturation, oxygen_target, 0.4, dt_seconds)
.clamp(0.8, 1.0);
// Cerebrovascular autoregulation replaces static CBF calculation
self.update_cerebrovascular_autoregulation(dt_seconds);
let stage_icp_term = match self.sleep_stage {
SleepStage::Wake => 0.0,
SleepStage::N1 => 0.5,
SleepStage::N2 => 1.0,
SleepStage::N3 => 1.8,
SleepStage::Rem => 1.2,
};
let icp_target = (10.0
+ stage_icp_term
+ 0.12 * (self.cerebral_blood_flow_ml_per_100g_min - 50.0)
+ 4.0 * (self.metabolic_demand_fraction - 1.0))
.clamp(5.0, 30.0);
self.intracranial_pressure_mm_hg = Self::approach(
self.intracranial_pressure_mm_hg,
icp_target,
0.4,
dt_seconds,
)
.clamp(4.0, 35.0);
let map_proxy = 90.0 + 15.0 * (self.brainstem_autonomic_drive - 0.8)
- 8.0 * (self.autonomic_variability - 0.4);
let cpp_target = (map_proxy - self.intracranial_pressure_mm_hg).clamp(40.0, 110.0);
self.cerebral_perfusion_pressure_mm_hg = Self::approach(
self.cerebral_perfusion_pressure_mm_hg,
cpp_target,
1.2,
dt_seconds,
);
let activity = (self.cortical_arousal * 0.6
+ self.metabolic_demand_fraction * 0.25
+ (self.glutamate_level - self.gaba_level + 0.7) * 0.1
+ self.dopamine_tone * 0.05
+ self.pain_drive * 0.08
+ (self.thirst_drive + self.hunger_drive) * 0.05)
.clamp(0.1, 2.3);
self.activity_index = activity;
let perfusion_factor = (self.cerebral_perfusion_pressure_mm_hg / 70.0).clamp(0.4, 1.3);
let oxygen_factor = (self.oxygenation_saturation / 0.96).clamp(0.5, 1.1);
let stage_bonus = match self.sleep_stage {
SleepStage::Wake => 18.0,
SleepStage::Rem => 8.0,
_ => 3.0,
};
let target_consciousness =
((self.cortical_arousal * 70.0 * perfusion_factor * oxygen_factor)
+ stage_bonus
+ (self.respiratory_drive - 0.6) * 15.0
- self.syncope_propensity * 25.0
+ self.pain_drive * 12.0
+ (self.thirst_drive + self.hunger_drive) * 6.0
- (self.thermoregulatory_drive - 0.5).abs() * 10.0)
.clamp(0.0, 100.0);
self.consciousness = target_consciousness.round() as u8;
// Polysomnographic markers (Warby et al. 2014, Frauscher et al. 2014)
match self.sleep_stage {
SleepStage::N2 => {
// Sleep spindle density 2-5/min normal (Fernandez & Lüthi, Nat Neurosci 2020)
// Spindle density correlates with sleep pressure and cycle number
let spindle_base = 3.5 + (self.sleep_pressure - 0.5) * 1.5;
let cycle_modulation = if self.sleep_cycle_count == 0 {
1.1
} else {
0.95
};
let spindle_target = (spindle_base * cycle_modulation).clamp(2.0, 5.5);
self.spindle_density_per_min = Self::approach(
self.spindle_density_per_min,
spindle_target,
0.3,
dt_seconds,
);
// K-complex density 1-3/min (Colrain, Sleep Med Rev 2005)
let k_complex_target = (2.0 + (self.sleep_pressure - 0.6) * 1.0).clamp(1.0, 3.2);
self.k_complex_density_per_min = Self::approach(
self.k_complex_density_per_min,
k_complex_target,
0.25,
dt_seconds,
);
// Sigma power (11-16 Hz) correlates strongly with spindle amplitude r=0.95
let sigma_target =
(1.0 + (self.spindle_density_per_min - 3.5) * 0.15).clamp(0.7, 1.4);
self.sigma_power_relative =
Self::approach(self.sigma_power_relative, sigma_target, 0.2, dt_seconds);
}
SleepStage::Rem => {
// REM atonia: normal >85-90% atonia (McCarter et al., Sleep 2014)
// Tonic EMG <10%, phasic <15% in normal REM (Frauscher et al., Sleep 2014)
// Arousal and sleep pressure affect atonia integrity
let atonia_disruption = (self.cortical_arousal - 0.5).max(0.0) * 0.15
+ (0.4 - self.sleep_pressure).max(0.0) * 0.08
+ self.pain_drive * 0.05;
let atonia_target = (0.92 - atonia_disruption).clamp(0.75, 0.96);
self.rem_atonia_index =
Self::approach(self.rem_atonia_index, atonia_target, 0.15, dt_seconds);
// Tonic EMG is inverse of atonia
let tonic_target = ((1.0 - self.rem_atonia_index) * 10.0).clamp(2.0, 18.0);
self.rem_tonic_emg_fraction =
Self::approach(self.rem_tonic_emg_fraction, tonic_target, 0.12, dt_seconds);
// Phasic bursts: normal <15%, increase with arousal and dream intensity
let phasic_base = 8.0 + self.cortical_arousal * 6.0 + atonia_disruption * 12.0;
let phasic_target = phasic_base.clamp(5.0, 25.0);
self.rem_phasic_emg_pct =
Self::approach(self.rem_phasic_emg_pct, phasic_target, 0.18, dt_seconds);
}
SleepStage::N1 | SleepStage::N3 => {
// Gradually return markers to baseline when not in N2 or REM
self.spindle_density_per_min =
Self::approach(self.spindle_density_per_min, 3.5, 0.2, dt_seconds);
self.k_complex_density_per_min =
Self::approach(self.k_complex_density_per_min, 2.0, 0.2, dt_seconds);
self.sigma_power_relative =
Self::approach(self.sigma_power_relative, 1.0, 0.15, dt_seconds);
self.rem_tonic_emg_fraction =
Self::approach(self.rem_tonic_emg_fraction, 0.05, 0.1, dt_seconds);
self.rem_phasic_emg_pct =
Self::approach(self.rem_phasic_emg_pct, 8.0, 0.12, dt_seconds);
self.rem_atonia_index =
Self::approach(self.rem_atonia_index, 0.92, 0.1, dt_seconds);
}
SleepStage::Wake => {
// Reset to wake baseline
self.spindle_density_per_min =
Self::approach(self.spindle_density_per_min, 0.0, 0.5, dt_seconds);
self.k_complex_density_per_min =
Self::approach(self.k_complex_density_per_min, 0.0, 0.5, dt_seconds);
self.sigma_power_relative =
Self::approach(self.sigma_power_relative, 0.5, 0.3, dt_seconds);
self.rem_tonic_emg_fraction =
Self::approach(self.rem_tonic_emg_fraction, 0.8, 0.15, dt_seconds);
self.rem_phasic_emg_pct =
Self::approach(self.rem_phasic_emg_pct, 25.0, 0.2, dt_seconds);
self.rem_atonia_index =
Self::approach(self.rem_atonia_index, 0.15, 0.15, dt_seconds);
}
}
let excitability =
(self.glutamate_level - self.gaba_level + self.cortical_arousal - 0.5).max(0.0);
let hypoxia = (0.94 - self.oxygenation_saturation).max(0.0) * 4.0;
let perfusion_deficit = (55.0 - self.cerebral_perfusion_pressure_mm_hg).max(0.0) / 40.0;
self.seizure_risk = (0.25 * excitability + hypoxia + perfusion_deficit).clamp(0.0, 1.0);
}
fn summary(&self) -> String {
format!(
"Brain[id={}, GCS~{}, activity={:.2}]",
"Brain[id={}, stage={:?}, arousal={:.2}, drives[r={:.2},th={:.2},hu={:.2},tmp={:.2},pain={:.2},syn={:.2}], ICP={:.1} mmHg, CPP={:.0} mmHg, O2={:.0}%, szrisk={:.0}%]",
self.id(),
self.consciousness,
self.activity_index
self.sleep_stage,
self.cortical_arousal,
self.respiratory_drive,
self.thirst_drive,
self.hunger_drive,
self.thermoregulatory_drive,
self.pain_drive,
self.syncope_propensity,
self.intracranial_pressure_mm_hg,
self.cerebral_perfusion_pressure_mm_hg,
self.oxygenation_saturation * 100.0,
self.seizure_risk * 100.0
)
}
fn as_any(&self) -> &dyn core::any::Any {
+267 -6
View File
@@ -1,20 +1,253 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
const ESOPHAGEAL_LENGTH_CM: f32 = 25.0;
const PRIMARY_WAVE_SPEED_CM_S: f32 = 4.5;
const SECONDARY_WAVE_SPEED_CM_S: f32 = 3.2;
const BASE_HIATAL_PRESSURE_CM_H2O: f32 = 6.0;
/// Functional sequence of the esophagus during swallowing and reflux handling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EsophagealStage {
Idle,
SwallowInitiation,
PrimaryPeristalsis,
SecondaryPeristalsis,
Clearing,
RefluxExposure,
}
#[derive(Debug, Clone)]
pub struct Esophagus {
info: OrganInfo,
/// Reflux severity 0..=100
pub reflux: u8,
/// Luminal pH along the distal esophagus.
pub luminal_ph: f32,
/// Lower esophageal sphincter tone (0..=1).
pub lower_sphincter_tone: f32,
/// Upper esophageal sphincter tone (0..=1).
pub upper_sphincter_tone: f32,
/// Distance traversed by the current peristaltic wave (cm).
pub peristaltic_progress_cm: f32,
/// Bolus volume currently descending (ml).
pub bolus_volume_ml: f32,
/// Salivary buffer content available to neutralize acid (ml).
pub saliva_buffer_ml: f32,
/// Estimated reflux episodes per hour.
pub reflux_events_per_hour: f32,
/// Fractional acid exposure burden (0..≈1.2).
pub acid_exposure_fraction: f32,
/// Mucosal integrity (0..≈1; <0.7 suggests erosive disease).
pub mucosal_integrity: f32,
/// Current functional state.
pub stage: EsophagealStage,
/// Swallow drive (0..=1) influenced by salivary demand and mucosal irritation.
pub swallow_drive: f32,
/// Peristaltic contractile strength scaling factor.
pub peristaltic_strength: f32,
/// Vagal tone modulating motility (0..=1).
pub vagal_tone: f32,
/// Time since the last initiated swallow (s).
pub time_since_last_swallow_s: f32,
time_in_stage_s: f32,
/// Target interval between swallows based on drive (s).
pub swallow_interval_target_s: f32,
/// Pressure gradient promoting reflux (cmH2O).
pub hiatal_pressure_gradient_cm_h2o: f32,
}
impl Esophagus {
pub fn new(id: impl Into<String>) -> Self {
Self {
info: OrganInfo::new(id, OrganType::Esophagus),
reflux: 0,
luminal_ph: 6.4,
lower_sphincter_tone: 0.78,
upper_sphincter_tone: 0.9,
peristaltic_progress_cm: 0.0,
bolus_volume_ml: 0.0,
saliva_buffer_ml: 1.2,
reflux_events_per_hour: 1.5,
acid_exposure_fraction: 0.08,
mucosal_integrity: 0.95,
stage: EsophagealStage::Idle,
swallow_drive: 0.35,
peristaltic_strength: 0.9,
vagal_tone: 0.65,
time_since_last_swallow_s: 0.0,
time_in_stage_s: 0.0,
swallow_interval_target_s: 22.0,
hiatal_pressure_gradient_cm_h2o: BASE_HIATAL_PRESSURE_CM_H2O,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn transition_stage(&mut self, stage: EsophagealStage) {
if self.stage != stage {
self.stage = stage;
self.time_in_stage_s = 0.0;
}
}
fn start_primary_swallow(&mut self) {
self.transition_stage(EsophagealStage::SwallowInitiation);
self.peristaltic_progress_cm = 0.0;
self.bolus_volume_ml = 4.0 + 3.0 * self.swallow_drive;
self.saliva_buffer_ml += 1.5 + 1.2 * self.swallow_drive;
self.time_since_last_swallow_s = 0.0;
}
fn update_swallow_drive(&mut self, dt_seconds: f32) {
let dryness = (self.time_since_last_swallow_s / 45.0).clamp(0.0, 1.4);
let irritation = (self.acid_exposure_fraction * 0.7) + (1.0 - self.mucosal_integrity) * 0.8;
let target_drive = (0.2 + dryness + irritation).clamp(0.05, 1.0);
self.swallow_drive = Self::approach(self.swallow_drive, target_drive, 0.35, dt_seconds);
let interval_target = (35.0 - 18.0 * self.swallow_drive).clamp(4.5, 55.0);
self.swallow_interval_target_s = Self::approach(
self.swallow_interval_target_s,
interval_target,
0.5,
dt_seconds,
);
let vagal_target =
(0.6 + 0.25 * self.swallow_drive - 0.15 * self.acid_exposure_fraction).clamp(0.35, 0.9);
self.vagal_tone = Self::approach(self.vagal_tone, vagal_target, 0.3, dt_seconds);
let strength_target = (0.85 + 0.5 * (self.vagal_tone - 0.6)).clamp(0.5, 1.3);
self.peristaltic_strength =
Self::approach(self.peristaltic_strength, strength_target, 0.4, dt_seconds);
}
fn update_sphincter_tones(&mut self, dt_seconds: f32) {
let base_les =
(0.7 + 0.18 * self.vagal_tone - 0.25 * self.acid_exposure_fraction).clamp(0.25, 0.98);
let base_ues = (0.82 + 0.1 * self.vagal_tone - 0.1 * self.swallow_drive).clamp(0.4, 0.98);
let (les_modifier, ues_modifier) = match self.stage {
EsophagealStage::Idle => (0.0, 0.0),
EsophagealStage::SwallowInitiation => (-0.4, -0.5),
EsophagealStage::PrimaryPeristalsis => (-0.25, -0.4),
EsophagealStage::SecondaryPeristalsis => (-0.18, -0.2),
EsophagealStage::Clearing => (-0.1, -0.1),
EsophagealStage::RefluxExposure => (-0.35, 0.0),
};
let les_target = (base_les + les_modifier).clamp(0.1, 0.98);
let ues_target = (base_ues + ues_modifier).clamp(0.05, 0.98);
self.lower_sphincter_tone =
Self::approach(self.lower_sphincter_tone, les_target, 1.4, dt_seconds).clamp(0.05, 1.0);
self.upper_sphincter_tone =
Self::approach(self.upper_sphincter_tone, ues_target, 2.0, dt_seconds).clamp(0.05, 1.0);
}
fn update_hiatal_gradient(&mut self, dt_seconds: f32) {
let target = (BASE_HIATAL_PRESSURE_CM_H2O
+ 2.0 * (self.swallow_drive - 0.3)
+ if matches!(self.stage, EsophagealStage::RefluxExposure) {
1.0
} else {
0.0
})
.clamp(3.0, 18.0);
self.hiatal_pressure_gradient_cm_h2o = Self::approach(
self.hiatal_pressure_gradient_cm_h2o,
target,
0.25,
dt_seconds,
);
}
fn handle_stage(&mut self, dt_seconds: f32) {
match self.stage {
EsophagealStage::Idle => {
self.peristaltic_progress_cm =
Self::approach(self.peristaltic_progress_cm, 0.0, 10.0, dt_seconds);
self.bolus_volume_ml = Self::approach(self.bolus_volume_ml, 0.0, 6.0, dt_seconds);
if self.acid_exposure_fraction > 0.35 {
self.transition_stage(EsophagealStage::RefluxExposure);
}
}
EsophagealStage::SwallowInitiation => {
if self.time_in_stage_s > 0.28 {
self.transition_stage(EsophagealStage::PrimaryPeristalsis);
}
}
EsophagealStage::PrimaryPeristalsis => {
let speed = PRIMARY_WAVE_SPEED_CM_S * self.peristaltic_strength.clamp(0.4, 1.5);
self.peristaltic_progress_cm += speed * dt_seconds;
self.bolus_volume_ml = (self.bolus_volume_ml - dt_seconds * (speed * 0.8)).max(0.6);
if self.peristaltic_progress_cm >= ESOPHAGEAL_LENGTH_CM {
if self.bolus_volume_ml > 1.2 {
self.transition_stage(EsophagealStage::SecondaryPeristalsis);
} else {
self.transition_stage(EsophagealStage::Clearing);
}
}
}
EsophagealStage::SecondaryPeristalsis => {
let speed = SECONDARY_WAVE_SPEED_CM_S * self.peristaltic_strength.clamp(0.4, 1.4);
self.peristaltic_progress_cm += speed * dt_seconds;
self.bolus_volume_ml = (self.bolus_volume_ml - dt_seconds * (speed * 0.7)).max(0.3);
if self.time_in_stage_s > 6.0 || self.bolus_volume_ml <= 0.4 {
self.transition_stage(EsophagealStage::Clearing);
}
}
EsophagealStage::Clearing => {
self.bolus_volume_ml = Self::approach(self.bolus_volume_ml, 0.0, 4.0, dt_seconds);
if self.time_in_stage_s > 2.0 {
self.transition_stage(EsophagealStage::Idle);
}
}
EsophagealStage::RefluxExposure => {
self.peristaltic_progress_cm =
Self::approach(self.peristaltic_progress_cm, 0.0, 5.0, dt_seconds);
if self.acid_exposure_fraction < 0.12 {
self.transition_stage(EsophagealStage::Idle);
}
}
}
}
fn update_acid_balance(&mut self, dt_seconds: f32) {
let reflux_propensity = (self.hiatal_pressure_gradient_cm_h2o - 4.0).max(0.0)
* (1.0 - self.lower_sphincter_tone);
let reflux_influx = reflux_propensity * 0.012 * dt_seconds;
if reflux_influx > 0.0 {
self.acid_exposure_fraction =
(self.acid_exposure_fraction + reflux_influx).clamp(0.0, 1.2);
if self.acid_exposure_fraction > 0.25 {
self.transition_stage(EsophagealStage::RefluxExposure);
}
}
let saliva_clearance =
(self.saliva_buffer_ml * 0.015 + self.peristaltic_strength * 0.01) * dt_seconds;
self.acid_exposure_fraction = (self.acid_exposure_fraction - saliva_clearance).max(0.0);
let mucosal_damage = (self.acid_exposure_fraction - 0.18).max(0.0) * 0.006 * dt_seconds;
let mucosal_healing = (self.saliva_buffer_ml * 0.003 + 0.0025) * dt_seconds;
self.mucosal_integrity =
(self.mucosal_integrity - mucosal_damage + mucosal_healing).clamp(0.55, 1.02);
let target_reflux_rate = (reflux_propensity * 11.0).clamp(0.0, 30.0);
self.reflux_events_per_hour = Self::approach(
self.reflux_events_per_hour,
target_reflux_rate,
0.12,
dt_seconds,
);
let acid_drop = (self.acid_exposure_fraction * 4.5).clamp(0.0, 6.5);
let base_recovery = (self.saliva_buffer_ml * 0.18).clamp(0.0, 3.0);
self.luminal_ph = (6.5 - acid_drop + base_recovery).clamp(1.0, 7.2);
self.saliva_buffer_ml = (self.saliva_buffer_ml - dt_seconds * 1.0).max(0.0);
}
}
impl Organ for Esophagus {
@@ -24,11 +257,39 @@ impl Organ for Esophagus {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {
self.reflux = self.reflux.min(100);
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_since_last_swallow_s += dt_seconds;
self.time_in_stage_s += dt_seconds;
self.update_swallow_drive(dt_seconds);
self.update_hiatal_gradient(dt_seconds);
self.update_sphincter_tones(dt_seconds);
if self.time_since_last_swallow_s >= self.swallow_interval_target_s
&& matches!(
self.stage,
EsophagealStage::Idle | EsophagealStage::RefluxExposure
)
{
self.start_primary_swallow();
}
self.handle_stage(dt_seconds);
self.update_acid_balance(dt_seconds);
}
fn summary(&self) -> String {
format!("Esophagus[id={}, reflux={}]", self.id(), self.reflux)
format!(
"Esophagus[id={}, stage={:?}, pH={:.1}, LES={:.0}%, reflux≈{:.1}/h]",
self.id(),
self.stage,
self.luminal_ph,
self.lower_sphincter_tone * 100.0,
self.reflux_events_per_hour
)
}
fn as_any(&self) -> &dyn core::any::Any {
self
+248 -5
View File
@@ -1,20 +1,224 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
const DEFAULT_CAPACITY_ML: f32 = 50.0;
const RESIDUAL_VOLUME_ML: f32 = 8.0;
/// Functional state of the gallbladder during the interdigestive and post-prandial cycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GallbladderPhase {
Filling,
Primed,
Contraction,
Expulsion,
Recovery,
}
#[derive(Debug, Clone)]
pub struct Gallbladder {
info: OrganInfo,
/// Bile volume ml
pub bile_ml: f32,
/// Stored bile volume (ml).
pub bile_volume_ml: f32,
/// Bile acid concentration (mmol/L).
pub bile_acid_concentration_mmol_l: f32,
/// Cholesterol saturation index (dimensionless; >1 predisposes to stones).
pub cholesterol_saturation_index: f32,
/// Flow of bile exiting via the cystic duct/common bile duct (ml/min).
pub bile_outflow_ml_per_min: f32,
/// Tone of the sphincter of Oddi (0..=1, higher = more closed).
pub sphincter_of_oddi_tone: f32,
/// Circulating cholecystokinin level (ng/mL proxy).
pub cck_level_ng_ml: f32,
/// Hepatic bile inflow (ml/min) delivered to the gallbladder when filling.
pub hepatic_bile_flow_ml_per_min: f32,
/// Total bile-acid pool currently stored in the gallbladder (mmol).
pub bile_acid_pool_mmol: f32,
/// Efficiency of enterohepatic recycling (0..=1).
pub bile_salt_recycling_efficiency: f32,
/// Vagal tone supporting coordinated contraction (0..=1).
pub vagal_tone: f32,
/// Fractional mucosal absorption rate.
pub mucosal_absorption_fraction: f32,
/// Current state in the contraction cycle.
pub phase: GallbladderPhase,
/// External or simulated meal stimulus (0..=1).
pub meal_signal: f32,
/// Internal fasting-driven feed-forward signal (0..=1).
pub internal_meal_drive: f32,
time_in_phase_s: f32,
fasting_clock_s: f32,
target_meal_interval_s: f32,
/// Stone-forming propensity index (0..≈2).
pub gallstone_nucleation_index: f32,
}
impl Gallbladder {
pub fn new(id: impl Into<String>) -> Self {
Self {
info: OrganInfo::new(id, OrganType::Gallbladder),
bile_ml: 30.0,
bile_volume_ml: 35.0,
bile_acid_concentration_mmol_l: 65.0,
cholesterol_saturation_index: 0.9,
bile_outflow_ml_per_min: 0.05,
sphincter_of_oddi_tone: 0.85,
cck_level_ng_ml: 0.2,
hepatic_bile_flow_ml_per_min: 0.55,
bile_acid_pool_mmol: 2.6,
bile_salt_recycling_efficiency: 0.92,
vagal_tone: 0.58,
mucosal_absorption_fraction: 0.03,
phase: GallbladderPhase::Filling,
meal_signal: 0.1,
internal_meal_drive: 0.12,
time_in_phase_s: 0.0,
fasting_clock_s: 0.0,
target_meal_interval_s: 4.8 * 3600.0,
gallstone_nucleation_index: 0.2,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn transition_phase(&mut self, phase: GallbladderPhase) {
if self.phase != phase {
self.phase = phase;
self.time_in_phase_s = 0.0;
}
}
fn update_meal_drives(&mut self, dt_seconds: f32) {
self.fasting_clock_s += dt_seconds;
if self.fasting_clock_s >= self.target_meal_interval_s {
self.internal_meal_drive = 1.0;
self.fasting_clock_s = 0.0;
self.target_meal_interval_s = (4.0 + 1.5 * self.vagal_tone) * 3600.0;
}
self.internal_meal_drive = (self.internal_meal_drive - dt_seconds / 900.0).clamp(0.0, 1.0);
// Allow external stimuli to decay gently if not continuously refreshed.
self.meal_signal = Self::approach(self.meal_signal, 0.1, 0.05, dt_seconds);
let stimulus = self.meal_signal.max(self.internal_meal_drive);
let cck_target = (0.15 + 1.6 * stimulus).clamp(0.1, 2.5);
self.cck_level_ng_ml =
Self::approach(self.cck_level_ng_ml, cck_target, 0.8, dt_seconds).clamp(0.05, 3.0);
let vagal_target = (0.55 + 0.35 * stimulus).clamp(0.4, 0.92);
self.vagal_tone = Self::approach(self.vagal_tone, vagal_target, 0.35, dt_seconds);
}
fn update_sphincter_tone(&mut self, dt_seconds: f32) {
let tone_target = match self.phase {
GallbladderPhase::Filling => 0.88,
GallbladderPhase::Primed => 0.75,
GallbladderPhase::Contraction => 0.45,
GallbladderPhase::Expulsion => 0.35,
GallbladderPhase::Recovery => 0.7,
} - 0.18 * (self.cck_level_ng_ml - 0.3).max(0.0);
self.sphincter_of_oddi_tone = Self::approach(
self.sphincter_of_oddi_tone,
tone_target.clamp(0.2, 0.95),
0.9,
dt_seconds,
);
}
fn hepatic_inflow(&self) -> f32 {
match self.phase {
GallbladderPhase::Contraction | GallbladderPhase::Expulsion => {
self.hepatic_bile_flow_ml_per_min * 0.4
}
_ => self.hepatic_bile_flow_ml_per_min,
}
}
fn update_bile_pool(&mut self, dt_seconds: f32, outflow_ml: f32) {
let inflow_ml = self.hepatic_inflow() * dt_seconds / 60.0;
let inflow_bile_acids = inflow_ml * 0.075 * self.bile_salt_recycling_efficiency;
let absorption_loss =
self.bile_acid_pool_mmol * self.mucosal_absorption_fraction * dt_seconds / 3600.0;
let pool_after = (self.bile_acid_pool_mmol + inflow_bile_acids - absorption_loss).max(0.5);
let volume_after = (self.bile_volume_ml + inflow_ml - outflow_ml)
.clamp(RESIDUAL_VOLUME_ML, DEFAULT_CAPACITY_ML);
let volume_ratio = if self.bile_volume_ml > 0.0 {
outflow_ml / self.bile_volume_ml
} else {
0.0
}
.clamp(0.0, 0.95);
let pool_after = (pool_after * (1.0 - volume_ratio)).max(0.5);
self.bile_volume_ml = volume_after;
self.bile_acid_pool_mmol = pool_after;
let volume_l = (self.bile_volume_ml / 1000.0).max(0.005);
self.bile_acid_concentration_mmol_l =
(self.bile_acid_pool_mmol / volume_l).clamp(20.0, 140.0);
let saturation = (1.0 + 0.32 * (self.bile_volume_ml / DEFAULT_CAPACITY_ML - 0.5)
- 0.45 * (self.bile_acid_concentration_mmol_l / 60.0 - 1.0))
.clamp(0.6, 1.6);
self.cholesterol_saturation_index = saturation;
}
fn handle_phase(&mut self, _dt_seconds: f32) {
match self.phase {
GallbladderPhase::Filling => {
self.bile_outflow_ml_per_min = 0.05 * (1.0 - self.sphincter_of_oddi_tone);
if (self.cck_level_ng_ml > 0.35 && self.bile_volume_ml > DEFAULT_CAPACITY_ML * 0.55)
|| self.mucosal_absorption_fraction < 0.02
{
self.transition_phase(GallbladderPhase::Primed);
}
}
GallbladderPhase::Primed => {
self.bile_outflow_ml_per_min = 0.2 + 1.5 * (self.cck_level_ng_ml - 0.3).max(0.0);
if self.time_in_phase_s > 60.0 || self.cck_level_ng_ml > 0.6 {
self.transition_phase(GallbladderPhase::Contraction);
}
}
GallbladderPhase::Contraction => {
self.bile_outflow_ml_per_min = (2.5
+ 8.0 * (self.cck_level_ng_ml - 0.3).max(0.0)
+ 4.5 * (self.vagal_tone - 0.5).max(0.0))
* (1.0 - self.sphincter_of_oddi_tone);
if self.bile_volume_ml < DEFAULT_CAPACITY_ML * 0.3 {
self.transition_phase(GallbladderPhase::Expulsion);
}
}
GallbladderPhase::Expulsion => {
self.bile_outflow_ml_per_min =
(1.2 + 6.0 * (1.0 - self.sphincter_of_oddi_tone)).clamp(0.6, 12.0);
if self.bile_volume_ml <= RESIDUAL_VOLUME_ML + 1.0 || self.time_in_phase_s > 180.0 {
self.transition_phase(GallbladderPhase::Recovery);
}
}
GallbladderPhase::Recovery => {
self.bile_outflow_ml_per_min = 0.1 * (1.0 - self.sphincter_of_oddi_tone);
if self.cck_level_ng_ml < 0.25 && self.time_in_phase_s > 120.0 {
self.transition_phase(GallbladderPhase::Filling);
}
}
}
}
fn update_gallstone_index(&mut self, dt_seconds: f32) {
let stasis = (1.0 - (self.bile_outflow_ml_per_min / 8.0).clamp(0.0, 1.0)).max(0.0);
let supersaturation = (self.cholesterol_saturation_index - 1.0).max(0.0);
let volume_factor = (self.bile_volume_ml / DEFAULT_CAPACITY_ML - 0.6).max(0.0);
let target =
(0.3 + 1.8 * supersaturation * (0.6 + stasis) + 0.5 * volume_factor).clamp(0.0, 2.2);
self.gallstone_nucleation_index =
Self::approach(self.gallstone_nucleation_index, target, 0.15, dt_seconds);
}
}
impl Organ for Gallbladder {
@@ -24,9 +228,48 @@ impl Organ for Gallbladder {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {}
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_in_phase_s += dt_seconds;
self.update_meal_drives(dt_seconds);
self.update_sphincter_tone(dt_seconds);
self.handle_phase(dt_seconds);
let outflow_ml = (self.bile_outflow_ml_per_min * dt_seconds / 60.0).clamp(0.0, 25.0);
self.update_bile_pool(dt_seconds, outflow_ml);
// Adjust mucosal absorption with concentration and phase.
let absorption_target = match self.phase {
GallbladderPhase::Filling => 0.035,
GallbladderPhase::Primed => 0.03,
GallbladderPhase::Contraction => 0.02,
GallbladderPhase::Expulsion => 0.015,
GallbladderPhase::Recovery => 0.028,
} * (self.bile_acid_concentration_mmol_l / 60.0).clamp(0.7, 1.4);
self.mucosal_absorption_fraction = Self::approach(
self.mucosal_absorption_fraction,
absorption_target,
0.2,
dt_seconds,
)
.clamp(0.01, 0.05);
self.update_gallstone_index(dt_seconds);
}
fn summary(&self) -> String {
format!("Gallbladder[id={}, bile={:.0} ml]", self.id(), self.bile_ml)
format!(
"Gallbladder[id={}, phase={:?}, vol={:.0}/{:.0} ml, bileAcid={:.0} mmol/L, CSI={:.2}]",
self.id(),
self.phase,
self.bile_volume_ml,
DEFAULT_CAPACITY_ML,
self.bile_acid_concentration_mmol_l,
self.cholesterol_saturation_index
)
}
fn as_any(&self) -> &dyn core::any::Any {
self
+715 -18
View File
@@ -1,18 +1,157 @@
use super::{Organ, OrganInfo};
use crate::types::{BloodPressure, OrganType};
/// Cardiac model with simple rate and arterial pressure coupling.
const BASE_SV_ML: f32 = 70.0;
const BASE_SVR_MMHG_MIN_PER_L: f32 = 18.5;
const BAROREFLEX_SET_POINT_MMHG: f32 = 93.0;
/// Rhythm archetypes representing dominant autonomic/conduction control of the heart.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardiacRhythmState {
Sinus,
SympatheticDrive,
ParasympatheticDominance,
CompensatoryTachycardia,
Arrhythmic,
}
/// Discrete cardiac cycle phases for phase-based state machine.
/// Based on: Sengupta PP et al. "The Cardiac Cycle" (2008) PMC2390899
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardiacPhase {
/// Late diastole with atrial contraction completing ventricular filling
AtrialSystole,
/// Early systole with all valves closed, pressure rising without volume change
IsovolumetricContraction,
/// Mid-systole with semilunar valves open, blood ejection
Ejection,
/// Early diastole with all valves closed, pressure falling without volume change
IsovolumetricRelaxation,
/// Mid-to-late diastole with passive ventricular filling
PassiveFilling,
}
/// Valve states for discrete valve modeling.
/// Based on: CV Physiology - Valvular function and hemodynamics
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValveState {
/// Fully closed, no flow
Closed,
/// Opening transition
Opening,
/// Fully open, forward flow
Open,
/// Closing transition
Closing,
/// Incompetent closure allowing regurgitant flow
Regurgitant,
}
/// Cardiac pump model featuring autonomic reflexes and hemodynamic coupling.
#[derive(Debug, Clone)]
pub struct Heart {
info: OrganInfo,
/// Heart rate in beats per minute.
/// Heart rate (beats per minute).
pub heart_rate_bpm: f32,
/// Arterial blood pressure snapshot.
pub arterial_bp: BloodPressure,
/// ECG lead count configured for this heart.
/// Number of ECG leads configured for monitoring.
pub leads: u8,
/// Simplified arrhythmia flag; increases HR variability.
/// Allow external systems to force arrhythmic behavior.
pub arrhythmia: bool,
/// Stroke volume (ml/beat).
pub stroke_volume_ml: f32,
/// Cardiac output (L/min).
pub cardiac_output_l_min: f32,
/// End-diastolic volume (ml).
pub end_diastolic_volume_ml: f32,
/// End-systolic volume (ml).
pub end_systolic_volume_ml: f32,
/// Ejection fraction (0..=1).
pub ejection_fraction: f32,
/// Contractility index (dimensionless relative to baseline 1.0).
pub contractility_index: f32,
/// Preload expressed as estimated left-ventricular end-diastolic pressure (mmHg).
pub preload_mm_hg: f32,
/// Afterload (mmHg) approximated from systemic vascular resistance.
pub afterload_mm_hg: f32,
/// Systemic vascular resistance (mmHg·min/L).
pub systemic_vascular_resistance: f32,
/// Venous return (L/min).
pub venous_return_l_min: f32,
/// Sinoatrial node intrinsic rate (bpm).
pub sa_node_rate_bpm: f32,
/// Atrioventricular conduction delay (ms).
pub av_delay_ms: f32,
/// Autonomic tone (-1 parasympathetic, +1 sympathetic).
pub autonomic_tone: f32,
/// Current rhythm classification.
pub rhythm_state: CardiacRhythmState,
/// Arrhythmia burden (0..=1).
pub arrhythmia_burden: f32,
/// Myocardial oxygen demand (mL O2/beat scaled).
pub myocardial_oxygen_demand: f32,
/// Myocardial oxygen supply proxy (mL O2/beat scaled).
pub myocardial_oxygen_supply: f32,
/// Coronary perfusion pressure (mmHg).
pub coronary_perfusion_mm_hg: f32,
/// Stroke work (J per beat).
pub stroke_work_joule: f32,
time_in_state_s: f32,
// === NEW: Discrete atrial and valve modeling ===
/// Left atrial pressure (mmHg).
pub left_atrial_pressure_mm_hg: f32,
/// Left atrial volume (ml).
pub left_atrial_volume_ml: f32,
/// Right atrial pressure (mmHg).
pub right_atrial_pressure_mm_hg: f32,
/// Right atrial volume (ml).
pub right_atrial_volume_ml: f32,
/// Mitral valve state.
pub mitral_valve_state: ValveState,
/// Aortic valve state.
pub aortic_valve_state: ValveState,
/// Tricuspid valve state.
pub tricuspid_valve_state: ValveState,
/// Pulmonic valve state.
pub pulmonic_valve_state: ValveState,
/// Mitral regurgitant fraction (0..=1).
pub mitral_regurgitation_fraction: f32,
/// Aortic regurgitant fraction (0..=1).
pub aortic_regurgitation_fraction: f32,
// === NEW: Extended conduction system modeling ===
/// His bundle conduction velocity (m/s). Normal: 1.0-1.5 m/s.
pub his_bundle_velocity_m_s: f32,
/// Purkinje fiber conduction velocity (m/s). Normal: 2.0-4.0 m/s (fastest in heart).
pub purkinje_velocity_m_s: f32,
/// His-Purkinje effective refractory period (ms). Normal: 350-450 ms.
pub his_purkinje_erp_ms: f32,
/// Ventricular action potential duration at 90% repolarization (ms).
pub ventricular_apd90_ms: f32,
/// Time since last ventricular depolarization (ms).
pub time_since_depolarization_ms: f32,
// === NEW: Phase-based cardiac cycle state machine ===
/// Current phase of cardiac cycle.
pub cardiac_phase: CardiacPhase,
/// Time in current cardiac phase (s).
pub phase_time_s: f32,
/// Left ventricular pressure (mmHg).
pub left_ventricular_pressure_mm_hg: f32,
/// Aortic pressure (mmHg).
pub aortic_pressure_mm_hg: f32,
// === NEW: RAAS hormonal regulation ===
/// Plasma renin activity (ng/mL/hr). Normal: 0.5-3.5.
pub plasma_renin_activity: f32,
/// Angiotensin II level (pg/mL). Normal: 10-60.
pub angiotensin_ii_pg_ml: f32,
/// Plasma aldosterone (ng/dL). Normal: 4-31.
pub aldosterone_ng_dl: f32,
/// Effective circulating volume fraction (0..=1.2). 1.0 = euvolemic.
pub effective_circulating_volume: f32,
}
impl Heart {
@@ -20,12 +159,515 @@ impl Heart {
pub fn new(id: impl Into<String>, leads: u8) -> Self {
Self {
info: OrganInfo::new(id, OrganType::Heart),
heart_rate_bpm: 70.0,
heart_rate_bpm: 72.0,
arterial_bp: BloodPressure::default(),
leads,
arrhythmia: false,
stroke_volume_ml: BASE_SV_ML,
cardiac_output_l_min: BASE_SV_ML * 72.0 / 1000.0,
end_diastolic_volume_ml: 120.0,
end_systolic_volume_ml: 50.0,
ejection_fraction: 0.58,
contractility_index: 1.0,
preload_mm_hg: 8.0,
afterload_mm_hg: BASE_SVR_MMHG_MIN_PER_L * (BASE_SV_ML * 72.0 / 1000.0),
systemic_vascular_resistance: BASE_SVR_MMHG_MIN_PER_L,
venous_return_l_min: BASE_SV_ML * 72.0 / 1000.0,
sa_node_rate_bpm: 72.0,
av_delay_ms: 160.0,
autonomic_tone: 0.0,
rhythm_state: CardiacRhythmState::Sinus,
arrhythmia_burden: 0.05,
myocardial_oxygen_demand: 9.0,
myocardial_oxygen_supply: 9.5,
coronary_perfusion_mm_hg: 70.0,
stroke_work_joule: 1.1,
time_in_state_s: 0.0,
// Atrial compartments
left_atrial_pressure_mm_hg: 8.0,
left_atrial_volume_ml: 55.0,
right_atrial_pressure_mm_hg: 4.0,
right_atrial_volume_ml: 50.0,
// Valve states
mitral_valve_state: ValveState::Open,
aortic_valve_state: ValveState::Closed,
tricuspid_valve_state: ValveState::Open,
pulmonic_valve_state: ValveState::Closed,
mitral_regurgitation_fraction: 0.0,
aortic_regurgitation_fraction: 0.0,
// Conduction system
his_bundle_velocity_m_s: 1.2,
purkinje_velocity_m_s: 3.0,
his_purkinje_erp_ms: 400.0,
ventricular_apd90_ms: 280.0,
time_since_depolarization_ms: 0.0,
// Cardiac cycle phase
cardiac_phase: CardiacPhase::PassiveFilling,
phase_time_s: 0.0,
left_ventricular_pressure_mm_hg: 8.0,
aortic_pressure_mm_hg: 80.0,
// RAAS
plasma_renin_activity: 1.5,
angiotensin_ii_pg_ml: 30.0,
aldosterone_ng_dl: 12.0,
effective_circulating_volume: 1.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn mean_arterial_pressure(&self) -> f32 {
let sys = self.arterial_bp.systolic as f32;
let dia = self.arterial_bp.diastolic as f32;
dia + (sys - dia) / 3.0
}
fn update_autonomic_state(&mut self, dt_seconds: f32) {
let map_error = self.mean_arterial_pressure() - BAROREFLEX_SET_POINT_MMHG;
let autonomic_target = (-map_error / 30.0).clamp(-1.0, 1.0);
self.autonomic_tone =
Self::approach(self.autonomic_tone, autonomic_target, 0.8, dt_seconds);
self.sa_node_rate_bpm = Self::approach(
self.sa_node_rate_bpm,
(70.0 + 45.0 * self.autonomic_tone).clamp(45.0, 150.0),
1.5,
dt_seconds,
);
self.av_delay_ms = Self::approach(
self.av_delay_ms,
(170.0 - 40.0 * self.autonomic_tone).clamp(110.0, 240.0),
5.0,
dt_seconds,
);
let svr_target = (BASE_SVR_MMHG_MIN_PER_L + 5.5 * self.autonomic_tone).clamp(10.0, 26.0);
self.systemic_vascular_resistance = Self::approach(
self.systemic_vascular_resistance,
svr_target,
0.3,
dt_seconds,
);
}
fn determine_rhythm_state(&mut self) {
let arrhythmic = self.arrhythmia || self.arrhythmia_burden > 0.45;
self.rhythm_state = if arrhythmic {
CardiacRhythmState::Arrhythmic
} else if self.autonomic_tone > 0.5 {
CardiacRhythmState::SympatheticDrive
} else if self.autonomic_tone < -0.4 {
CardiacRhythmState::ParasympatheticDominance
} else if self.venous_return_l_min < 4.2 && self.mean_arterial_pressure() < 75.0 {
CardiacRhythmState::CompensatoryTachycardia
} else {
CardiacRhythmState::Sinus
};
}
fn update_rate_and_contractility(&mut self, dt_seconds: f32) {
let mut rate_target = self.sa_node_rate_bpm;
match self.rhythm_state {
CardiacRhythmState::SympatheticDrive => rate_target += 18.0,
CardiacRhythmState::ParasympatheticDominance => rate_target -= 12.0,
CardiacRhythmState::CompensatoryTachycardia => rate_target += 22.0,
CardiacRhythmState::Arrhythmic => rate_target += 8.0,
CardiacRhythmState::Sinus => {}
}
rate_target += 8.0 * (self.arrhythmia_burden - 0.2).max(0.0);
self.heart_rate_bpm = Self::approach(
self.heart_rate_bpm,
rate_target.clamp(38.0, 190.0),
1.2,
dt_seconds,
);
let demand_scale = (self.heart_rate_bpm / 70.0).clamp(0.6, 2.0);
let afterload_penalty = (self.afterload_mm_hg - 90.0).max(0.0) / 120.0;
let contractility_target = (1.05 + 0.35 * self.autonomic_tone
- afterload_penalty
- (self.arrhythmia_burden * 0.2))
.clamp(0.5, 1.6);
self.contractility_index = Self::approach(
self.contractility_index,
contractility_target,
0.8,
dt_seconds,
);
self.myocardial_oxygen_demand = (8.5 * demand_scale
+ 0.6 * self.contractility_index * (self.afterload_mm_hg / 80.0))
.clamp(4.0, 20.0);
}
fn update_volumes_and_output(&mut self, dt_seconds: f32) {
self.preload_mm_hg = Self::approach(
self.preload_mm_hg,
(6.5 + 1.2 * (self.venous_return_l_min - 4.5)).clamp(4.0, 18.0),
0.6,
dt_seconds,
);
let edv_target = (90.0 + 6.5 * self.preload_mm_hg).clamp(80.0, 210.0);
self.end_diastolic_volume_ml =
Self::approach(self.end_diastolic_volume_ml, edv_target, 1.1, dt_seconds);
let elastance = 0.22 + 0.25 * self.contractility_index;
let esv_target = (self.end_diastolic_volume_ml * (1.0 - elastance)).clamp(30.0, 120.0);
self.end_systolic_volume_ml =
Self::approach(self.end_systolic_volume_ml, esv_target, 1.6, dt_seconds);
self.stroke_volume_ml =
(self.end_diastolic_volume_ml - self.end_systolic_volume_ml).clamp(25.0, 130.0);
self.ejection_fraction =
(self.stroke_volume_ml / self.end_diastolic_volume_ml.max(1.0)).clamp(0.2, 0.85);
self.cardiac_output_l_min =
(self.stroke_volume_ml * self.heart_rate_bpm / 1000.0).clamp(2.0, 12.0);
self.venous_return_l_min = Self::approach(
self.venous_return_l_min,
self.cardiac_output_l_min,
0.4,
dt_seconds,
);
self.afterload_mm_hg = Self::approach(
self.afterload_mm_hg,
(self.systemic_vascular_resistance * self.cardiac_output_l_min).clamp(60.0, 160.0),
0.5,
dt_seconds,
);
let map_target = self.cardiac_output_l_min * self.systemic_vascular_resistance;
let pulse_pressure = (self.stroke_volume_ml / BASE_SV_ML).clamp(0.6, 2.0) * 40.0;
let raw_diastolic = map_target - pulse_pressure / 3.0;
let raw_systolic = raw_diastolic + pulse_pressure;
let systolic = raw_systolic.clamp(80.0, 220.0);
let diastolic = raw_diastolic.clamp(40.0, (systolic - 5.0).max(40.0));
self.arterial_bp.systolic = systolic.round() as u16;
self.arterial_bp.diastolic = diastolic.round() as u16;
self.coronary_perfusion_mm_hg =
(self.arterial_bp.diastolic as f32 - self.preload_mm_hg).clamp(20.0, 120.0);
self.myocardial_oxygen_supply = (9.5 + 0.08 * self.coronary_perfusion_mm_hg
- 0.5 * (self.heart_rate_bpm - 70.0) / 40.0)
.clamp(4.0, 20.0);
self.stroke_work_joule = (self.stroke_volume_ml / 1000.0)
* (self.mean_arterial_pressure() - 5.0).max(0.0)
* 0.133;
}
fn update_arrhythmia_burden(&mut self, dt_seconds: f32) {
let supply_demand_ratio =
(self.myocardial_oxygen_supply / self.myocardial_oxygen_demand).clamp(0.4, 1.6);
let mismatch = (1.0 - supply_demand_ratio).max(0.0);
let target = if self.arrhythmia {
0.7
} else {
0.2 + 0.6 * mismatch + 0.2 * (self.autonomic_tone - 0.5).max(0.0)
}
.clamp(0.05, 0.95);
self.arrhythmia_burden = Self::approach(self.arrhythmia_burden, target, 0.3, dt_seconds);
}
/// Update RAAS hormonal cascade.
/// Based on: Fountain JH, Lappin SL. Physiology, Renin Angiotensin System. StatPearls 2024.
fn update_raas(&mut self, dt_seconds: f32) {
// Renin release triggered by:
// 1. Reduced renal perfusion (low MAP)
// 2. Sympathetic stimulation
// 3. Hypovolemia
let map_current = self.mean_arterial_pressure();
let perfusion_deficit = ((85.0 - map_current) / 30.0).clamp(0.0, 1.0);
let sympathetic_drive = (self.autonomic_tone.max(0.0) * 0.6).clamp(0.0, 1.0);
let volume_deficit = ((1.0 - self.effective_circulating_volume) * 1.2).clamp(0.0, 1.0);
let renin_stimulus = perfusion_deficit + sympathetic_drive + volume_deficit;
let renin_target = (0.5 + 3.0 * renin_stimulus).clamp(0.2, 5.0);
self.plasma_renin_activity =
Self::approach(self.plasma_renin_activity, renin_target, 0.15, dt_seconds);
// Angiotensin II production via ACE in pulmonary circulation
// Proportional to renin with enzymatic conversion delay
let ang_ii_target = (10.0 + 45.0 * (self.plasma_renin_activity / 3.5)).clamp(5.0, 120.0);
self.angiotensin_ii_pg_ml =
Self::approach(self.angiotensin_ii_pg_ml, ang_ii_target, 0.4, dt_seconds);
// Aldosterone secretion from adrenal cortex
// Driven by angiotensin II and hyperkalemia (not modeled)
let aldo_target =
(4.0 + 30.0 * (self.angiotensin_ii_pg_ml / 60.0).powf(1.2)).clamp(2.0, 50.0);
self.aldosterone_ng_dl =
Self::approach(self.aldosterone_ng_dl, aldo_target, 0.25, dt_seconds);
// Angiotensin II effects on SVR (vasoconstriction)
// Normal Ang II ~ 30 pg/mL; elevated levels increase SVR
let ang_ii_vasoconstriction = ((self.angiotensin_ii_pg_ml - 30.0) / 30.0).clamp(-0.3, 1.5);
let svr_raas_component = 2.0 * ang_ii_vasoconstriction;
// Aldosterone effects on volume (increase preload over hours, simplified here)
let volume_retention_rate = (self.aldosterone_ng_dl - 12.0) / 40.0;
self.effective_circulating_volume = (self.effective_circulating_volume
+ volume_retention_rate * dt_seconds / 3600.0)
.clamp(0.7, 1.3);
// Apply RAAS-mediated SVR adjustment
let base_svr = BASE_SVR_MMHG_MIN_PER_L + 5.5 * self.autonomic_tone;
let raas_svr_target = (base_svr + svr_raas_component).clamp(10.0, 28.0);
self.systemic_vascular_resistance = Self::approach(
self.systemic_vascular_resistance,
raas_svr_target,
0.3,
dt_seconds,
);
}
/// Update conduction system properties including His-Purkinje pathways and refractoriness.
/// Based on: Cardiac electrophysiology, refractory periods, and APD dynamics.
fn update_conduction_system(&mut self, dt_seconds: f32) {
let dt_ms = dt_seconds * 1000.0;
self.time_since_depolarization_ms += dt_ms;
// His-Purkinje conduction velocity modulated by:
// - Sympathetic tone (increased catecholamines increase velocity)
// - Ischemia/hypoxia (decreases velocity)
let supply_demand_ratio = self.myocardial_oxygen_supply / self.myocardial_oxygen_demand;
let ischemia_factor = supply_demand_ratio.clamp(0.6, 1.2);
let sympathetic_boost = 1.0 + 0.15 * self.autonomic_tone.max(0.0);
let his_target = (1.2 * ischemia_factor * sympathetic_boost).clamp(0.5, 1.8);
let purkinje_target = (3.0 * ischemia_factor * sympathetic_boost).clamp(1.2, 4.5);
self.his_bundle_velocity_m_s =
Self::approach(self.his_bundle_velocity_m_s, his_target, 0.08, dt_seconds);
self.purkinje_velocity_m_s = Self::approach(
self.purkinje_velocity_m_s,
purkinje_target,
0.15,
dt_seconds,
);
// APD and ERP modulated by rate, autonomic tone, and ischemia
// Rate adaptation: faster HR → shorter APD (protective against reentry)
let rate_adaptation_factor = 1.0 - 0.0015 * (self.heart_rate_bpm - 72.0);
let sympathetic_shortening = 1.0 - 0.12 * self.autonomic_tone.max(0.0);
let ischemia_prolongation = 1.0 + 0.15 * (1.0 - ischemia_factor);
let apd_target =
(280.0 * rate_adaptation_factor * sympathetic_shortening * ischemia_prolongation)
.clamp(200.0, 360.0);
self.ventricular_apd90_ms =
Self::approach(self.ventricular_apd90_ms, apd_target, 5.0, dt_seconds);
// ERP typically 90-95% of APD90, plus relative refractory period
let erp_target = (self.ventricular_apd90_ms * 1.1 + 80.0).clamp(280.0, 500.0);
self.his_purkinje_erp_ms =
Self::approach(self.his_purkinje_erp_ms, erp_target, 4.0, dt_seconds);
// Reset depolarization timer on each ventricular beat
let cycle_duration_ms = 60000.0 / self.heart_rate_bpm.max(30.0);
if self.time_since_depolarization_ms >= cycle_duration_ms {
self.time_since_depolarization_ms = 0.0;
}
}
/// Update cardiac cycle phase state machine and valve states.
/// Based on: Sengupta PP et al. PMC2390899 and Wiggers diagram.
fn update_cardiac_phase(&mut self, dt_seconds: f32) {
self.phase_time_s += dt_seconds;
let cycle_duration_s = 60.0 / self.heart_rate_bpm.max(30.0);
// Phase durations as fractions of cardiac cycle
// At 72 bpm (0.833s cycle): atrial systole ~0.1s, isovol contract ~0.05s,
// ejection ~0.3s, isovol relax ~0.08s, passive fill ~0.3s
let diastole_fraction = 0.6 - 0.15 * ((self.heart_rate_bpm - 72.0) / 60.0).clamp(-0.5, 1.0);
let systole_fraction = 1.0 - diastole_fraction;
let atrial_systole_duration = 0.1 * cycle_duration_s;
let isovol_contract_duration = 0.05 * cycle_duration_s;
let ejection_duration = (systole_fraction - 0.05) * cycle_duration_s;
let isovol_relax_duration = 0.08 * cycle_duration_s;
// State transitions
match self.cardiac_phase {
CardiacPhase::PassiveFilling => {
if self.phase_time_s >= (diastole_fraction - 0.1) * cycle_duration_s {
self.cardiac_phase = CardiacPhase::AtrialSystole;
self.phase_time_s = 0.0;
self.mitral_valve_state = ValveState::Open;
self.tricuspid_valve_state = ValveState::Open;
}
}
CardiacPhase::AtrialSystole => {
// Atrial contraction increases atrial and ventricular pressures
let atrial_kick_boost =
1.0 + 0.2 * (self.phase_time_s / atrial_systole_duration).min(1.0);
self.left_atrial_pressure_mm_hg = 8.0 + 4.0 * atrial_kick_boost;
self.left_ventricular_pressure_mm_hg = Self::approach(
self.left_ventricular_pressure_mm_hg,
self.preload_mm_hg * 1.15,
10.0,
dt_seconds,
);
if self.phase_time_s >= atrial_systole_duration {
self.cardiac_phase = CardiacPhase::IsovolumetricContraction;
self.phase_time_s = 0.0;
self.mitral_valve_state = ValveState::Closing;
self.tricuspid_valve_state = ValveState::Closing;
}
}
CardiacPhase::IsovolumetricContraction => {
// All valves closed, LV pressure rises rapidly
self.mitral_valve_state = ValveState::Closed;
self.tricuspid_valve_state = ValveState::Closed;
self.aortic_valve_state = ValveState::Closed;
self.pulmonic_valve_state = ValveState::Closed;
let pressure_rise_rate = 1200.0 * self.contractility_index;
self.left_ventricular_pressure_mm_hg += pressure_rise_rate * dt_seconds;
// Transition when LV pressure exceeds aortic pressure
if self.left_ventricular_pressure_mm_hg >= self.aortic_pressure_mm_hg
|| self.phase_time_s >= isovol_contract_duration
{
self.cardiac_phase = CardiacPhase::Ejection;
self.phase_time_s = 0.0;
self.aortic_valve_state = ValveState::Opening;
self.pulmonic_valve_state = ValveState::Opening;
}
}
CardiacPhase::Ejection => {
self.aortic_valve_state = ValveState::Open;
self.pulmonic_valve_state = ValveState::Open;
// LV pressure tracks aortic pressure during ejection
let systolic_peak = self.arterial_bp.systolic as f32;
self.left_ventricular_pressure_mm_hg = Self::approach(
self.left_ventricular_pressure_mm_hg,
systolic_peak * 0.98,
80.0,
dt_seconds,
);
self.aortic_pressure_mm_hg =
Self::approach(self.aortic_pressure_mm_hg, systolic_peak, 100.0, dt_seconds);
if self.phase_time_s >= ejection_duration {
self.cardiac_phase = CardiacPhase::IsovolumetricRelaxation;
self.phase_time_s = 0.0;
self.aortic_valve_state = ValveState::Closing;
self.pulmonic_valve_state = ValveState::Closing;
}
}
CardiacPhase::IsovolumetricRelaxation => {
self.mitral_valve_state = ValveState::Closed;
self.tricuspid_valve_state = ValveState::Closed;
self.aortic_valve_state = ValveState::Closed;
self.pulmonic_valve_state = ValveState::Closed;
// Rapid pressure drop with lusitropy
let relaxation_rate = -800.0;
self.left_ventricular_pressure_mm_hg += relaxation_rate * dt_seconds;
self.left_ventricular_pressure_mm_hg =
self.left_ventricular_pressure_mm_hg.max(0.0);
// Aortic pressure decays toward diastolic
self.aortic_pressure_mm_hg = Self::approach(
self.aortic_pressure_mm_hg,
self.arterial_bp.diastolic as f32,
150.0,
dt_seconds,
);
// Transition when LV pressure drops below atrial pressure
if self.left_ventricular_pressure_mm_hg <= self.left_atrial_pressure_mm_hg
|| self.phase_time_s >= isovol_relax_duration
{
self.cardiac_phase = CardiacPhase::PassiveFilling;
self.phase_time_s = 0.0;
self.mitral_valve_state = ValveState::Opening;
self.tricuspid_valve_state = ValveState::Opening;
}
}
}
// Update regurgitant valve states
if self.mitral_regurgitation_fraction > 0.05
&& self.mitral_valve_state == ValveState::Closed
{
self.mitral_valve_state = ValveState::Regurgitant;
}
if self.aortic_regurgitation_fraction > 0.05
&& self.aortic_valve_state == ValveState::Closed
{
self.aortic_valve_state = ValveState::Regurgitant;
}
// Atrial filling from venous return
let filling_rate = self.venous_return_l_min * 1000.0 / 60.0;
self.left_atrial_volume_ml += filling_rate * dt_seconds;
self.right_atrial_volume_ml += filling_rate * dt_seconds * 0.95;
// Atrial emptying during open mitral valve
if matches!(
self.mitral_valve_state,
ValveState::Open | ValveState::Opening
) {
let emptying_rate = filling_rate * 1.8;
self.left_atrial_volume_ml -= emptying_rate * dt_seconds;
}
// Clamp atrial volumes
self.left_atrial_volume_ml = self.left_atrial_volume_ml.clamp(30.0, 120.0);
self.right_atrial_volume_ml = self.right_atrial_volume_ml.clamp(28.0, 110.0);
// Update atrial pressures from volumes (compliance ~5 mL/mmHg)
self.left_atrial_pressure_mm_hg =
(4.0 + (self.left_atrial_volume_ml - 50.0) / 5.0).clamp(2.0, 25.0);
self.right_atrial_pressure_mm_hg =
(2.0 + (self.right_atrial_volume_ml - 45.0) / 6.0).clamp(1.0, 20.0);
}
/// Improve coronary perfusion model to emphasize diastolic flow.
/// Based on: Duncker DJ, Bache RJ. JACC 2021; coronary autoregulation research.
fn update_coronary_perfusion(&mut self) {
// CPP = Aortic Diastolic Pressure - LVEDP
// Most LV coronary flow occurs during diastole due to systolic compression
let diastolic_bp = self.arterial_bp.diastolic as f32;
let lvedp = self.preload_mm_hg;
self.coronary_perfusion_mm_hg = (diastolic_bp - lvedp).clamp(20.0, 140.0);
// Diastolic time fraction decreases with tachycardia
let cycle_duration_s = 60.0 / self.heart_rate_bpm.max(30.0);
let diastolic_fraction =
(0.6 - 0.15 * ((self.heart_rate_bpm - 72.0) / 60.0)).clamp(0.3, 0.7);
let diastolic_time_s = cycle_duration_s * diastolic_fraction;
// Coronary flow autoregulation maintains flow across CPP 60-180 mmHg
// Below 60 mmHg, flow becomes pressure-dependent
let autoregulation_factor = if self.coronary_perfusion_mm_hg < 60.0 {
self.coronary_perfusion_mm_hg / 60.0
} else {
1.0 + 0.1 * ((self.coronary_perfusion_mm_hg - 90.0) / 60.0).clamp(-0.5, 1.0)
};
// Metabolic vasodilation in response to increased demand
let demand_factor = (self.myocardial_oxygen_demand / 9.0).clamp(0.6, 1.8);
// Tachycardia penalty: reduced diastolic time → reduced perfusion opportunity
let time_penalty = (diastolic_time_s / 0.5).clamp(0.6, 1.2);
// Myocardial O2 supply now depends on CPP, autoregulation, and diastolic time
self.myocardial_oxygen_supply = (9.5 + 0.12 * self.coronary_perfusion_mm_hg
- 0.8 * (1.0 - autoregulation_factor)
+ 1.5 * (autoregulation_factor - 1.0).max(0.0)
- 2.0 * (1.0 - time_penalty))
.clamp(3.0, 22.0)
* demand_factor.min(1.2);
}
}
impl Organ for Heart {
@@ -36,26 +678,39 @@ impl Organ for Heart {
self.info.kind()
}
fn update(&mut self, dt_seconds: f32) {
let dt = dt_seconds.clamp(0.0, 10.0);
let target = 70.0f32;
let mut diff = target - self.heart_rate_bpm;
if self.arrhythmia {
// add variability
diff += self.heart_rate_bpm.sin() * 5.0;
if dt_seconds <= 0.0 {
return;
}
self.heart_rate_bpm += 0.1 * diff * (dt / 1.0);
// crude BP coupling to HR
let sys = (90.0 + 0.5 * self.heart_rate_bpm).clamp(80.0, 220.0) as u16;
let dia = (60.0 + 0.3 * self.heart_rate_bpm).clamp(40.0, 140.0) as u16;
self.arterial_bp.systolic = sys;
self.arterial_bp.diastolic = dia.min(sys.saturating_sub(10));
self.time_in_state_s += dt_seconds;
// Core hemodynamic control
self.update_autonomic_state(dt_seconds);
self.update_raas(dt_seconds); // NEW: RAAS hormonal regulation
self.determine_rhythm_state();
self.update_rate_and_contractility(dt_seconds);
self.update_volumes_and_output(dt_seconds);
// NEW: Phase-based cardiac cycle with discrete valves
self.update_cardiac_phase(dt_seconds);
// NEW: Extended conduction system modeling
self.update_conduction_system(dt_seconds);
// NEW: Improved coronary perfusion with diastolic emphasis
self.update_coronary_perfusion();
self.update_arrhythmia_burden(dt_seconds);
}
fn summary(&self) -> String {
format!(
"Heart[id={}, leads={}, HR={:.1} bpm, BP={}/{} mmHg]",
"Heart[id={}, leads={}, rhythm={:?}, HR={:.0} bpm, CO={:.1} L/min, EF={:.0}%, BP={}/{}]",
self.id(),
self.leads,
self.rhythm_state,
self.heart_rate_bpm,
self.cardiac_output_l_min,
self.ejection_fraction * 100.0,
self.arterial_bp.systolic,
self.arterial_bp.diastolic
)
@@ -67,3 +722,45 @@ impl Organ for Heart {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resting_state_stays_stable() {
let mut heart = Heart::new("test-heart", 12);
for _ in 0..600 {
heart.update(1.0);
}
let baseline_hr = heart.heart_rate_bpm;
let baseline_map = heart.mean_arterial_pressure();
for _ in 0..600 {
heart.update(1.0);
}
let later_hr = heart.heart_rate_bpm;
let later_map = heart.mean_arterial_pressure();
assert!(
(60.0..=90.0).contains(&baseline_hr),
"baseline heart rate out of expected range: {baseline_hr}"
);
assert!(
(68.0..=78.0).contains(&later_hr),
"resting heart rate failed to settle: initial {baseline_hr}, later {later_hr}"
);
assert!(
(90.0..=96.0).contains(&baseline_map) && (90.0..=96.0).contains(&later_map),
"mean arterial pressure unstable: baseline {baseline_map}, later {later_map}"
);
assert!(
heart.autonomic_tone.abs() <= 0.2,
"autonomic tone should remain near neutral, found {} (hr={later_hr}, map={later_map})",
heart.autonomic_tone
);
assert!(
(18.0..=20.5).contains(&heart.systemic_vascular_resistance),
"systemic vascular resistance drifted to {}",
heart.systemic_vascular_resistance
);
}
}
+337 -12
View File
@@ -1,22 +1,337 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
/// Predominant motility/functional state of the small and large intestine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IntestinalPhase {
Interdigestive,
FedProcessing,
MigratingMotorComplex,
IlealBrake,
Dysmotility,
}
#[derive(Debug, Clone)]
pub struct Intestines {
info: OrganInfo,
/// Nutrient absorption rate 0..=100
pub absorption: u8,
pub peristalsis: bool,
/// Carbohydrate absorption (g/hour).
pub carbohydrate_absorption_g_per_h: f32,
/// Fat absorption (g/hour).
pub fat_absorption_g_per_h: f32,
/// Protein absorption (g/hour).
pub protein_absorption_g_per_h: f32,
/// Electrolyte reclamation (mmol/min).
pub electrolyte_absorption_mmol_min: f32,
/// Water reabsorption rate (ml/min).
pub water_reabsorption_ml_min: f32,
/// Integrated motility index (0..=1) dominated by peristaltic waves.
pub motility_index: f32,
/// Segmentation/segmental contractions index (0..=1).
pub segmentation_index: f32,
/// Migrating motor complex activity (0..=1).
pub mmc_activity: f32,
/// Current functional phase.
pub phase: IntestinalPhase,
/// Luminal volume of chyme (ml).
pub lumen_volume_ml: f32,
/// Luminal pH.
pub chyme_ph: f32,
/// Fraction of bile acids reclaimed in the terminal ileum (0..=1).
pub bile_acid_recirculation_fraction: f32,
/// Microbiome balance (0..=1, >0.5 reflects eubiosis).
pub microbiome_balance: f32,
/// Short-chain fatty acids produced (mmol).
pub short_chain_fatty_acids_mmol: f32,
/// Mucosal integrity (0..=1).
pub mucosal_integrity: f32,
/// Inflammatory index (0..=1).
pub inflammation_index: f32,
/// Motilin level (pg/mL proxy) driving MMC.
pub hormone_motilin: f32,
/// GLP-1 level influencing ileal brake (pmol/L proxy).
pub hormone_glp1: f32,
/// Enteric nervous system tone (0..=1).
pub enteric_tone: f32,
/// Pending nutrient energy load within the lumen (kcal).
pub nutrient_energy_kcal: f32,
/// Fermentable fiber load (g).
pub fiber_load_g: f32,
/// Iron absorbed into portal circulation per day (mg).
pub iron_absorption_mg_per_day: f32,
/// Folate absorbed per day (mg).
pub folate_absorption_mg_per_day: f32,
/// Vitamin B12 absorbed per day (mcg).
pub b12_absorption_mcg_per_day: f32,
time_in_phase_s: f32,
feeding_clock_s: f32,
target_feed_interval_s: f32,
}
impl Intestines {
pub fn new(id: impl Into<String>) -> Self {
Self {
info: OrganInfo::new(id, OrganType::Intestines),
absorption: 80,
peristalsis: true,
carbohydrate_absorption_g_per_h: 45.0,
fat_absorption_g_per_h: 12.0,
protein_absorption_g_per_h: 18.0,
electrolyte_absorption_mmol_min: 2.5,
water_reabsorption_ml_min: 12.0,
motility_index: 0.55,
segmentation_index: 0.45,
mmc_activity: 0.3,
phase: IntestinalPhase::Interdigestive,
lumen_volume_ml: 350.0,
chyme_ph: 6.6,
bile_acid_recirculation_fraction: 0.92,
microbiome_balance: 0.62,
short_chain_fatty_acids_mmol: 22.0,
mucosal_integrity: 0.93,
inflammation_index: 0.12,
hormone_motilin: 110.0,
hormone_glp1: 8.0,
enteric_tone: 0.55,
nutrient_energy_kcal: 40.0,
fiber_load_g: 6.0,
iron_absorption_mg_per_day: 1.6,
folate_absorption_mg_per_day: 0.45,
b12_absorption_mcg_per_day: 5.2,
time_in_phase_s: 0.0,
feeding_clock_s: 0.0,
target_feed_interval_s: 3.8 * 3600.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn update_internal_feeding(&mut self, dt_seconds: f32) {
self.feeding_clock_s += dt_seconds;
if self.feeding_clock_s >= self.target_feed_interval_s {
self.nutrient_energy_kcal += 410.0;
self.fiber_load_g += 4.0;
self.lumen_volume_ml = (self.lumen_volume_ml + 200.0).clamp(150.0, 800.0);
self.phase = IntestinalPhase::FedProcessing;
self.time_in_phase_s = 0.0;
self.feeding_clock_s = 0.0;
self.target_feed_interval_s = (3.0 + 1.5 * (1.0 - self.microbiome_balance)) * 3600.0;
}
}
fn update_enteric_tone(&mut self, dt_seconds: f32) {
let load_factor = (self.nutrient_energy_kcal / 400.0).clamp(0.0, 2.0);
let irritation = (1.0 - self.mucosal_integrity) * 1.2 + self.inflammation_index * 0.8;
let tone_target = (0.5 + 0.35 * load_factor - 0.2 * irritation).clamp(0.2, 0.95);
self.enteric_tone = Self::approach(self.enteric_tone, tone_target, 0.4, dt_seconds);
}
fn transition_phase(&mut self, phase: IntestinalPhase) {
if self.phase != phase {
self.phase = phase;
self.time_in_phase_s = 0.0;
}
}
fn update_phase(&mut self) {
match self.phase {
IntestinalPhase::Interdigestive => {
if self.nutrient_energy_kcal > 80.0 {
self.transition_phase(IntestinalPhase::FedProcessing);
} else if self.time_in_phase_s > 90.0 * 60.0 {
self.transition_phase(IntestinalPhase::MigratingMotorComplex);
}
}
IntestinalPhase::FedProcessing => {
if self.nutrient_energy_kcal < 100.0 {
self.transition_phase(IntestinalPhase::IlealBrake);
}
}
IntestinalPhase::MigratingMotorComplex => {
if self.nutrient_energy_kcal > 40.0 {
self.transition_phase(IntestinalPhase::FedProcessing);
} else if self.time_in_phase_s > 120.0 * 60.0 {
self.transition_phase(IntestinalPhase::Interdigestive);
}
}
IntestinalPhase::IlealBrake => {
if self.hormone_glp1 < 6.0 && self.fiber_load_g < 8.0 {
self.transition_phase(IntestinalPhase::Interdigestive);
} else if self.mucosal_integrity < 0.6 {
self.transition_phase(IntestinalPhase::Dysmotility);
}
}
IntestinalPhase::Dysmotility => {
if self.mucosal_integrity > 0.75 && self.inflammation_index < 0.3 {
self.transition_phase(IntestinalPhase::Interdigestive);
}
}
}
}
fn update_motility(&mut self, dt_seconds: f32) {
let (motility_target, segmentation_target, mmc_target, glp1_target, motilin_target) =
match self.phase {
IntestinalPhase::Interdigestive => (0.45, 0.4, 0.65, 6.0, 150.0),
IntestinalPhase::FedProcessing => (0.72, 0.55, 0.25, 18.0, 80.0),
IntestinalPhase::MigratingMotorComplex => (0.6, 0.35, 0.9, 8.0, 210.0),
IntestinalPhase::IlealBrake => (0.38, 0.6, 0.2, 28.0, 70.0),
IntestinalPhase::Dysmotility => (0.28, 0.35, 0.1, 22.0, 60.0),
};
self.motility_index = Self::approach(self.motility_index, motility_target, 0.6, dt_seconds);
self.segmentation_index = Self::approach(
self.segmentation_index,
segmentation_target,
0.5,
dt_seconds,
);
self.mmc_activity = Self::approach(self.mmc_activity, mmc_target, 0.4, dt_seconds);
self.hormone_glp1 =
Self::approach(self.hormone_glp1, glp1_target, 0.2, dt_seconds).clamp(2.0, 35.0);
self.hormone_motilin =
Self::approach(self.hormone_motilin, motilin_target, 0.8, dt_seconds)
.clamp(40.0, 280.0);
}
fn update_absorption(&mut self, dt_seconds: f32) {
let motility_effect =
(self.motility_index * 1.1 + self.segmentation_index * 0.6).clamp(0.3, 1.6);
let available = self.nutrient_energy_kcal.max(0.0);
let carbs_available = available * 0.55;
let fat_available = available * 0.28;
let protein_available = available * 0.17;
let carbs_abs_target = (carbs_available / 4.0).clamp(0.0, 90.0) * motility_effect;
let fat_abs_target = (fat_available / 9.0).clamp(0.0, 35.0) * motility_effect;
let protein_abs_target = (protein_available / 4.0).clamp(0.0, 50.0) * motility_effect;
self.carbohydrate_absorption_g_per_h = Self::approach(
self.carbohydrate_absorption_g_per_h,
carbs_abs_target,
0.3,
dt_seconds,
);
self.fat_absorption_g_per_h =
Self::approach(self.fat_absorption_g_per_h, fat_abs_target, 0.3, dt_seconds);
self.protein_absorption_g_per_h = Self::approach(
self.protein_absorption_g_per_h,
protein_abs_target,
0.3,
dt_seconds,
);
let absorbed_kcal = (self.carbohydrate_absorption_g_per_h * 4.0
+ self.protein_absorption_g_per_h * 4.0
+ self.fat_absorption_g_per_h * 9.0)
* dt_seconds
/ 3600.0;
self.nutrient_energy_kcal = (self.nutrient_energy_kcal - absorbed_kcal).max(0.0);
self.electrolyte_absorption_mmol_min = Self::approach(
self.electrolyte_absorption_mmol_min,
(2.0 + 0.8 * self.motility_index + 1.2 * self.segmentation_index).clamp(1.0, 6.0),
0.2,
dt_seconds,
);
self.water_reabsorption_ml_min = Self::approach(
self.water_reabsorption_ml_min,
(10.0 + 8.0 * self.electrolyte_absorption_mmol_min / 3.0).clamp(5.0, 30.0),
0.2,
dt_seconds,
);
let micronutrient_drive = (available / 400.0).clamp(0.0, 2.0);
let mucosal_factor = self.mucosal_integrity.clamp(0.3, 1.1);
let inflammation_penalty = (self.inflammation_index * 0.5).clamp(0.0, 0.6);
let iron_target = ((1.3 + 1.4 * micronutrient_drive) * mucosal_factor
- inflammation_penalty)
.clamp(0.3, 5.5);
self.iron_absorption_mg_per_day = Self::approach(
self.iron_absorption_mg_per_day,
iron_target,
0.03,
dt_seconds,
);
let folate_target = ((0.32 + 0.3 * micronutrient_drive) * mucosal_factor
- inflammation_penalty * 0.25)
.clamp(0.1, 1.4);
self.folate_absorption_mg_per_day = Self::approach(
self.folate_absorption_mg_per_day,
folate_target,
0.03,
dt_seconds,
);
let b12_target = ((4.2 + 3.4 * micronutrient_drive) * mucosal_factor
- inflammation_penalty * 3.0)
.clamp(0.5, 15.0);
self.b12_absorption_mcg_per_day = Self::approach(
self.b12_absorption_mcg_per_day,
b12_target,
0.03,
dt_seconds,
);
let water_removed = self.water_reabsorption_ml_min * dt_seconds / 60.0;
self.lumen_volume_ml =
(self.lumen_volume_ml + absorbed_kcal * 0.2 - water_removed).clamp(120.0, 800.0);
}
fn update_microbiome(&mut self, dt_seconds: f32) {
let scfa_generation =
(self.fiber_load_g * 0.12 * self.microbiome_balance) * dt_seconds / 60.0;
self.short_chain_fatty_acids_mmol =
(self.short_chain_fatty_acids_mmol + scfa_generation).clamp(5.0, 80.0);
self.fiber_load_g = (self.fiber_load_g - scfa_generation * 0.45).max(0.0);
let ph_target = (6.6 - 0.015 * self.short_chain_fatty_acids_mmol
+ 0.2 * (1.0 - self.bile_acid_recirculation_fraction))
.clamp(5.8, 7.2);
self.chyme_ph = Self::approach(self.chyme_ph, ph_target, 0.1, dt_seconds);
let microbiome_target = (0.6 + 0.15 * (self.short_chain_fatty_acids_mmol / 30.0)
- 0.25 * self.inflammation_index)
.clamp(0.3, 0.95);
self.microbiome_balance =
Self::approach(self.microbiome_balance, microbiome_target, 0.05, dt_seconds);
}
fn update_mucosa(&mut self, dt_seconds: f32) {
let irritation = (1.0 - self.chyme_ph / 7.0).max(0.0)
+ (self.short_chain_fatty_acids_mmol / 50.0).max(0.0) * 0.2;
let mucosal_target =
(0.95 - 0.25 * irritation + 0.1 * self.microbiome_balance).clamp(0.5, 0.99);
self.mucosal_integrity =
Self::approach(self.mucosal_integrity, mucosal_target, 0.02, dt_seconds);
let inflammation_target =
(0.1 + 0.3 * (1.0 - self.mucosal_integrity) + 0.2 * (1.0 - self.microbiome_balance))
.clamp(0.05, 0.9);
self.inflammation_index = Self::approach(
self.inflammation_index,
inflammation_target,
0.03,
dt_seconds,
);
let bile_target =
(0.9 + 0.15 * self.motility_index - 0.1 * self.glp1_effect()).clamp(0.6, 0.98);
self.bile_acid_recirculation_fraction = Self::approach(
self.bile_acid_recirculation_fraction,
bile_target,
0.03,
dt_seconds,
);
}
fn glp1_effect(&self) -> f32 {
(self.hormone_glp1 / 20.0).clamp(0.0, 1.2)
}
}
impl Organ for Intestines {
@@ -27,18 +342,28 @@ impl Organ for Intestines {
self.info.kind()
}
fn update(&mut self, dt_seconds: f32) {
if self.peristalsis {
// minor oscillation around 80
let delta = (dt_seconds * 0.1).sin();
let val = (self.absorption as f32 + delta).clamp(0.0, 100.0);
self.absorption = val as u8;
if dt_seconds <= 0.0 {
return;
}
self.time_in_phase_s += dt_seconds;
self.update_internal_feeding(dt_seconds);
self.update_enteric_tone(dt_seconds);
self.update_phase();
self.update_motility(dt_seconds);
self.update_absorption(dt_seconds);
self.update_microbiome(dt_seconds);
self.update_mucosa(dt_seconds);
}
fn summary(&self) -> String {
format!(
"Intestines[id={}, absorption={}]",
"Intestines[id={}, phase={:?}, motility={:.2}, lumen={:.0} ml, pH={:.1}]",
self.id(),
self.absorption
self.phase,
self.motility_index,
self.lumen_volume_ml,
self.chyme_ph
)
}
fn as_any(&self) -> &dyn core::any::Any {
+234 -7
View File
@@ -1,23 +1,233 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
/// Renal perfusion/autoregulation status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenalAutoregulationState {
Autoregulated,
Hypoperfused,
Hyperperfused,
Obstructed,
}
#[derive(Debug, Clone)]
pub struct Kidneys {
info: OrganInfo,
/// Filtration rate ml/min
/// Glomerular filtration rate (ml/min).
pub gfr: f32,
/// Electrolyte balance index -1..=1
/// Electrolyte balance index -1..=1 (positive = hypernatremia tendency).
pub electrolyte_balance: f32,
/// Renal plasma flow (ml/min).
pub renal_plasma_flow_ml_min: f32,
/// Filtration fraction (GFR/RPF).
pub filtration_fraction: f32,
/// Urine osmolality (mOsm/kg).
pub urine_osmolality_mosm: f32,
/// Urine flow (ml/min).
pub urine_flow_ml_min: f32,
/// Renin release proxy (ng/mL/h relative).
pub renin_release: f32,
/// Aldosterone drive (0..=1).
pub aldosterone_drive: f32,
/// Antidiuretic hormone sensitivity (0..=1).
pub adh_sensitivity: f32,
/// Acid-base compensation index (-1 acidotic .. +1 alkalotic).
pub acid_base_balance: f32,
/// Erythropoietin secretion (IU/day equivalent).
pub erythropoietin_iu_per_day: f32,
/// Sympathetic tone to the juxtaglomerular apparatus (0..=1).
pub sympathetic_tone: f32,
/// Tubular sodium reabsorption fraction (0..=1).
pub tubular_na_reabsorption: f32,
/// Potassium excretion (mmol/min).
pub potassium_excretion_mmol_min: f32,
/// Urea excretion (mg/min).
pub urea_excretion_mg_min: f32,
/// Medullary tonicity (mOsm/kg).
pub medullary_tonicity_mosm: f32,
/// Serum osmolality (mOsm/kg).
pub serum_osmolality_mosm: f32,
/// Estimated plasma volume (L).
pub plasma_volume_l: f32,
/// Current autoregulation state.
pub state: RenalAutoregulationState,
time_in_state_s: f32,
}
impl Kidneys {
pub fn new(id: impl Into<String>) -> Self {
Self {
info: OrganInfo::new(id, OrganType::Kidneys),
gfr: 100.0,
gfr: 110.0,
electrolyte_balance: 0.0,
renal_plasma_flow_ml_min: 600.0,
filtration_fraction: 0.18,
urine_osmolality_mosm: 550.0,
urine_flow_ml_min: 1.2,
renin_release: 0.35,
aldosterone_drive: 0.45,
adh_sensitivity: 0.65,
acid_base_balance: 0.0,
erythropoietin_iu_per_day: 18.0,
sympathetic_tone: 0.35,
tubular_na_reabsorption: 0.99,
potassium_excretion_mmol_min: 0.11,
urea_excretion_mg_min: 550.0,
medullary_tonicity_mosm: 750.0,
serum_osmolality_mosm: 290.0,
plasma_volume_l: 3.1,
state: RenalAutoregulationState::Autoregulated,
time_in_state_s: 0.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn update_state(&mut self) {
let perfusion_ratio = self.renal_plasma_flow_ml_min / 600.0;
let obstruction = (1.0 - self.urine_flow_ml_min / 1.2).max(0.0)
+ (self.medullary_tonicity_mosm - 1000.0).max(0.0) / 800.0;
self.state = if obstruction > 0.6 {
RenalAutoregulationState::Obstructed
} else if perfusion_ratio < 0.75 {
RenalAutoregulationState::Hypoperfused
} else if perfusion_ratio > 1.3 {
RenalAutoregulationState::Hyperperfused
} else {
RenalAutoregulationState::Autoregulated
};
}
fn update_perfusion(&mut self, dt_seconds: f32) {
let sympathetic_target =
(0.3 + 0.6 * (1.0 - self.plasma_volume_l / 3.0).max(0.0)).clamp(0.1, 0.95);
self.sympathetic_tone =
Self::approach(self.sympathetic_tone, sympathetic_target, 0.2, dt_seconds);
let rpf_target = (600.0 - 220.0 * self.sympathetic_tone
+ 120.0 * (self.serum_osmolality_mosm - 285.0) / 20.0)
.clamp(300.0, 950.0);
self.renal_plasma_flow_ml_min =
Self::approach(self.renal_plasma_flow_ml_min, rpf_target, 3.0, dt_seconds);
let filtration_target = match self.state {
RenalAutoregulationState::Autoregulated => 0.18,
RenalAutoregulationState::Hypoperfused => 0.23,
RenalAutoregulationState::Hyperperfused => 0.16,
RenalAutoregulationState::Obstructed => 0.12,
};
self.filtration_fraction =
Self::approach(self.filtration_fraction, filtration_target, 0.1, dt_seconds)
.clamp(0.08, 0.3);
self.gfr = (self.renal_plasma_flow_ml_min * self.filtration_fraction).clamp(20.0, 180.0);
}
fn update_hormonal_axes(&mut self, dt_seconds: f32) {
let pressure_error = (105.0 - self.gfr).max(-40.0);
let osmo_error = (self.serum_osmolality_mosm - 285.0) / 15.0;
let renin_target =
(0.25 + 0.015 * pressure_error + 0.4 * self.sympathetic_tone).clamp(0.05, 1.6);
self.renin_release = Self::approach(self.renin_release, renin_target, 0.3, dt_seconds);
let aldosterone_target =
(0.4 + 0.5 * self.renin_release - 0.3 * self.electrolyte_balance).clamp(0.1, 1.0);
self.aldosterone_drive =
Self::approach(self.aldosterone_drive, aldosterone_target, 0.2, dt_seconds);
let adh_target =
(0.55 + 0.35 * osmo_error + 0.25 * (self.aldosterone_drive - 0.4)).clamp(0.1, 1.1);
self.adh_sensitivity = Self::approach(self.adh_sensitivity, adh_target, 0.25, dt_seconds);
}
fn update_tubular_handling(&mut self, dt_seconds: f32) {
let sodium_target = match self.state {
RenalAutoregulationState::Hypoperfused => 0.995,
RenalAutoregulationState::Autoregulated => 0.99,
RenalAutoregulationState::Hyperperfused => 0.985,
RenalAutoregulationState::Obstructed => 0.975,
} + 0.01 * (self.aldosterone_drive - 0.5);
self.tubular_na_reabsorption = Self::approach(
self.tubular_na_reabsorption,
sodium_target.clamp(0.94, 0.998),
0.05,
dt_seconds,
);
let filtered_nacl = self.gfr * 140.0 / 1000.0; // mmol/min approximated
let na_excreted = filtered_nacl * (1.0 - self.tubular_na_reabsorption);
self.electrolyte_balance = (0.5 - na_excreted / 8.0).clamp(-1.2, 1.2);
self.potassium_excretion_mmol_min = Self::approach(
self.potassium_excretion_mmol_min,
(0.08 + 0.12 * self.aldosterone_drive + 0.04 * self.electrolyte_balance)
.clamp(0.02, 0.3),
0.1,
dt_seconds,
);
let osmotic_load = 2.1 * na_excreted + self.potassium_excretion_mmol_min * 1.5;
let adh_effect = (1.3 - self.adh_sensitivity).clamp(0.2, 1.3);
self.urine_flow_ml_min = Self::approach(
self.urine_flow_ml_min,
(osmotic_load / 4.0 * adh_effect).clamp(0.2, 10.0),
0.4,
dt_seconds,
);
self.urine_osmolality_mosm = Self::approach(
self.urine_osmolality_mosm,
(550.0 + 220.0 * (self.adh_sensitivity - 0.5)
- 120.0 * (self.urine_flow_ml_min - 1.0) / 4.0)
.clamp(120.0, 1200.0),
0.6,
dt_seconds,
);
self.medullary_tonicity_mosm = Self::approach(
self.medullary_tonicity_mosm,
(750.0 + 200.0 * (self.adh_sensitivity - 0.6)).clamp(400.0, 1200.0),
0.1,
dt_seconds,
);
}
fn update_acid_base(&mut self, dt_seconds: f32) {
let acid_target = (0.1 * (self.potassium_excretion_mmol_min - 0.12)
+ 0.3 * (self.urine_osmolality_mosm - 600.0) / 400.0)
.clamp(-1.0, 1.0);
self.acid_base_balance =
Self::approach(self.acid_base_balance, -acid_target, 0.1, dt_seconds);
let urea_target = (500.0 + 1.5 * (self.gfr - 110.0)).clamp(200.0, 900.0);
self.urea_excretion_mg_min =
Self::approach(self.urea_excretion_mg_min, urea_target, 0.3, dt_seconds);
self.serum_osmolality_mosm = Self::approach(
self.serum_osmolality_mosm,
(285.0 + 5.0 * self.acid_base_balance - 4.0 * self.urine_flow_ml_min / 5.0)
.clamp(270.0, 310.0),
0.05,
dt_seconds,
);
let plasma_target = (3.1 + 0.2 * (self.urine_flow_ml_min - 1.2)
- 0.25 * self.acid_base_balance)
.clamp(2.2, 3.8);
self.plasma_volume_l =
Self::approach(self.plasma_volume_l, plasma_target, 0.04, dt_seconds);
}
fn update_erythropoietin(&mut self, dt_seconds: f32) {
let epo_target = (18.0 + 40.0 * (0.95 - self.median_oxygenation())).clamp(8.0, 45.0);
self.erythropoietin_iu_per_day =
Self::approach(self.erythropoietin_iu_per_day, epo_target, 0.05, dt_seconds);
}
fn median_oxygenation(&self) -> f32 {
(self.renal_plasma_flow_ml_min / 600.0).clamp(0.5, 1.3)
}
}
impl Organ for Kidneys {
@@ -27,12 +237,29 @@ impl Organ for Kidneys {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {
self.gfr = self.gfr.clamp(0.0, 200.0);
self.electrolyte_balance = self.electrolyte_balance.clamp(-1.0, 1.0);
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_in_state_s += dt_seconds;
self.update_state();
self.update_perfusion(dt_seconds);
self.update_state();
self.update_hormonal_axes(dt_seconds);
self.update_tubular_handling(dt_seconds);
self.update_acid_base(dt_seconds);
self.update_erythropoietin(dt_seconds);
}
fn summary(&self) -> String {
format!("Kidneys[id={}, GFR={:.0} ml/min]", self.id(), self.gfr)
format!(
"Kidneys[id={}, state={:?}, GFR={:.0} ml/min, urine={:.1} ml/min @ {:.0} mOsm]",
self.id(),
self.state,
self.gfr,
self.urine_flow_ml_min,
self.urine_osmolality_mosm
)
}
fn as_any(&self) -> &dyn core::any::Any {
self
+336 -9
View File
@@ -1,15 +1,68 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
/// High-level metabolic mode of the liver.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HepaticState {
Postabsorptive,
FedAnabolic,
FastingCatabolic,
AcutePhaseResponse,
Regenerating,
}
#[derive(Debug, Clone)]
pub struct Liver {
info: OrganInfo,
/// Detox capacity 0..=100
/// Detox capacity 0..=100.
pub detox: u8,
/// Metabolism index
/// Composite metabolic activity index.
pub metabolism: f32,
/// Enzyme production index
/// Microsomal enzyme (CYP) activity index.
pub enzymes: f32,
/// Hepatic glycogen store (g).
pub glycogen_store_g: f32,
/// Gluconeogenesis rate (mg/kg/min proxy for 70kg adult).
pub gluconeogenesis_rate: f32,
/// Glycogenolysis rate (g/hour).
pub glycogenolysis_rate_g_per_h: f32,
/// De novo lipogenesis rate (g/hour).
pub lipogenesis_rate_g_per_h: f32,
/// Beta-oxidation rate (g/hour).
pub beta_oxidation_rate_g_per_h: f32,
/// Ammonia clearance (µmol/min).
pub ammonia_clearance_umol_min: f32,
/// Plasma albumin concentration (g/dL).
pub albumin_g_dl: f32,
/// Clotting factor synthesis (% of normal).
pub clotting_factor_synthesis_pct: f32,
/// Bile acid synthesis (mg/min).
pub bile_acid_synthesis_mg_min: f32,
/// Bile secretion (ml/min).
pub bile_secretion_ml_min: f32,
/// Kupffer cell activation (0..=1).
pub kupffer_activation: f32,
/// Acute phase response magnitude (0..=1).
pub acute_phase_response: f32,
/// Hepatic blood flow (L/min).
pub hepatic_blood_flow_l_min: f32,
/// Portal venous pressure (mmHg).
pub portal_pressure_mm_hg: f32,
/// Hepatic fat fraction (%).
pub hepatic_fat_fraction_pct: f32,
/// Insulin signaling intensity (0..=1).
pub insulin_signal: f32,
/// Glucagon signaling intensity (0..=1).
pub glucagon_signal: f32,
/// Cortisol signaling (0..=1).
pub cortisol_signal: f32,
/// Oxidative stress metric (0..=1).
pub oxidative_stress_index: f32,
/// Current metabolic state.
pub state: HepaticState,
time_in_state_s: f32,
feeding_clock_s: f32,
target_meal_interval_s: f32,
}
impl Liver {
@@ -19,8 +72,269 @@ impl Liver {
detox: 100,
metabolism: 1.0,
enzymes: 1.0,
glycogen_store_g: 85.0,
gluconeogenesis_rate: 1.8,
glycogenolysis_rate_g_per_h: 6.0,
lipogenesis_rate_g_per_h: 2.5,
beta_oxidation_rate_g_per_h: 4.5,
ammonia_clearance_umol_min: 750.0,
albumin_g_dl: 4.1,
clotting_factor_synthesis_pct: 100.0,
bile_acid_synthesis_mg_min: 9.0,
bile_secretion_ml_min: 0.75,
kupffer_activation: 0.25,
acute_phase_response: 0.1,
hepatic_blood_flow_l_min: 1.35,
portal_pressure_mm_hg: 7.0,
hepatic_fat_fraction_pct: 8.0,
insulin_signal: 0.35,
glucagon_signal: 0.45,
cortisol_signal: 0.25,
oxidative_stress_index: 0.18,
state: HepaticState::Postabsorptive,
time_in_state_s: 0.0,
feeding_clock_s: 0.0,
target_meal_interval_s: 4.5 * 3600.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn simulate_hormone_inputs(&mut self, dt_seconds: f32) {
self.feeding_clock_s += dt_seconds;
if self.feeding_clock_s >= self.target_meal_interval_s {
self.insulin_signal = 0.95;
self.glucagon_signal = 0.2;
self.feeding_clock_s = 0.0;
self.target_meal_interval_s =
(4.0 + 1.5 * (self.hepatic_fat_fraction_pct / 20.0)) * 3600.0;
self.time_in_state_s = 0.0;
self.state = HepaticState::FedAnabolic;
} else {
self.insulin_signal = Self::approach(self.insulin_signal, 0.3, 0.1, dt_seconds);
self.glucagon_signal = Self::approach(self.glucagon_signal, 0.55, 0.08, dt_seconds);
}
self.cortisol_signal = Self::approach(
self.cortisol_signal,
(0.25
+ 0.3 * (self.glucagon_signal - 0.5).max(0.0)
+ 0.2 * self.oxidative_stress_index)
.clamp(0.1, 0.9),
0.05,
dt_seconds,
);
}
fn transition_state(&mut self, new_state: HepaticState) {
if self.state != new_state {
self.state = new_state;
self.time_in_state_s = 0.0;
}
}
fn update_state(&mut self) {
match self.state {
HepaticState::Postabsorptive => {
if self.oxidative_stress_index > 0.6 && self.glycogen_store_g < 40.0 {
self.transition_state(HepaticState::Regenerating);
} else if self.insulin_signal > 0.6 {
self.transition_state(HepaticState::FedAnabolic);
} else if self.glucagon_signal > 0.7 {
self.transition_state(HepaticState::FastingCatabolic);
} else if self.acute_phase_response > 0.4 {
self.transition_state(HepaticState::AcutePhaseResponse);
}
}
HepaticState::FedAnabolic => {
if self.time_in_state_s > 2.0 * 3600.0 && self.glycogen_store_g > 70.0 {
self.transition_state(HepaticState::Postabsorptive);
}
}
HepaticState::FastingCatabolic => {
if self.oxidative_stress_index > 0.7 && self.glycogen_store_g < 30.0 {
self.transition_state(HepaticState::Regenerating);
} else if self.insulin_signal > 0.55 {
self.transition_state(HepaticState::FedAnabolic);
} else if self.time_in_state_s > 12.0 * 3600.0 {
self.transition_state(HepaticState::Postabsorptive);
}
}
HepaticState::AcutePhaseResponse => {
if self.acute_phase_response < 0.2 {
self.transition_state(HepaticState::Postabsorptive);
} else if self.oxidative_stress_index > 0.65 {
self.transition_state(HepaticState::Regenerating);
}
}
HepaticState::Regenerating => {
if self.oxidative_stress_index < 0.3 && self.glycogen_store_g > 60.0 {
self.transition_state(HepaticState::Postabsorptive);
}
}
}
}
fn update_metabolic_fluxes(&mut self, dt_seconds: f32) {
let (
gluconeogenesis_target,
glycogenolysis_target,
lipogenesis_target,
beta_oxidation_target,
) = match self.state {
HepaticState::FedAnabolic => (0.8, 2.0, 6.0, 2.0),
HepaticState::Postabsorptive => (1.8, 6.0, 2.5, 4.5),
HepaticState::FastingCatabolic => (2.6, 9.0, 1.2, 6.5),
HepaticState::AcutePhaseResponse => (2.1, 5.0, 1.8, 4.0),
HepaticState::Regenerating => (1.5, 4.0, 3.5, 3.5),
};
self.gluconeogenesis_rate = Self::approach(
self.gluconeogenesis_rate,
(gluconeogenesis_target + 0.6 * (self.cortisol_signal - 0.3)).clamp(0.4, 3.5),
0.05,
dt_seconds,
);
self.glycogenolysis_rate_g_per_h = Self::approach(
self.glycogenolysis_rate_g_per_h,
(glycogenolysis_target + 4.0 * (self.glucagon_signal - self.insulin_signal))
.clamp(0.0, 14.0),
0.1,
dt_seconds,
);
self.lipogenesis_rate_g_per_h = Self::approach(
self.lipogenesis_rate_g_per_h,
(lipogenesis_target + 5.0 * (self.insulin_signal - 0.4).max(0.0)).clamp(0.5, 12.0),
0.06,
dt_seconds,
);
self.beta_oxidation_rate_g_per_h = Self::approach(
self.beta_oxidation_rate_g_per_h,
(beta_oxidation_target + 3.5 * (self.glucagon_signal - 0.5).max(0.0)).clamp(1.0, 10.0),
0.08,
dt_seconds,
);
let glycogen_change = (self.lipogenesis_rate_g_per_h * 0.2
- self.glycogenolysis_rate_g_per_h
- self.gluconeogenesis_rate * 0.6)
* dt_seconds
/ 3600.0;
self.glycogen_store_g = (self.glycogen_store_g + glycogen_change).clamp(10.0, 140.0);
self.oxidative_stress_index = Self::approach(
self.oxidative_stress_index,
(0.15
+ 0.25 * (self.beta_oxidation_rate_g_per_h - 3.0) / 7.0
+ 0.2 * self.kupffer_activation)
.clamp(0.05, 0.9),
0.02,
dt_seconds,
);
self.metabolism = (self.gluconeogenesis_rate / 1.8
+ self.lipogenesis_rate_g_per_h / 3.0
+ self.beta_oxidation_rate_g_per_h / 4.5)
/ 3.0;
}
fn update_bile_and_detox(&mut self, dt_seconds: f32) {
let bile_target = (0.75
+ 0.3 * (self.lipogenesis_rate_g_per_h / 6.0)
+ 0.2 * (self.kupffer_activation - 0.3))
.clamp(0.3, 1.5);
self.bile_secretion_ml_min =
Self::approach(self.bile_secretion_ml_min, bile_target, 0.04, dt_seconds);
self.bile_acid_synthesis_mg_min = Self::approach(
self.bile_acid_synthesis_mg_min,
(9.0 + 4.0 * (self.glucagon_signal - 0.4)).clamp(3.0, 20.0),
0.05,
dt_seconds,
);
let detox_target = (100.0 - 15.0 * self.oxidative_stress_index
+ 10.0 * (self.insulin_signal - 0.4))
.clamp(40.0, 110.0);
self.detox = detox_target.round() as u8;
self.enzymes = Self::approach(
self.enzymes,
(1.0 + 0.4 * (self.cortisol_signal - 0.3) + 0.5 * (self.oxidative_stress_index - 0.2))
.clamp(0.4, 1.8),
0.03,
dt_seconds,
);
self.ammonia_clearance_umol_min = Self::approach(
self.ammonia_clearance_umol_min,
(750.0 + 120.0 * (self.metabolism - 1.0) - 200.0 * self.oxidative_stress_index)
.clamp(200.0, 900.0),
0.2,
dt_seconds,
);
}
fn update_hemodynamics(&mut self, dt_seconds: f32) {
let flow_target = (1.35
+ 0.3 * (self.portal_pressure_mm_hg - 7.0) / 4.0
+ 0.25 * (self.metabolism - 1.0))
.clamp(0.8, 2.0);
self.hepatic_blood_flow_l_min =
Self::approach(self.hepatic_blood_flow_l_min, flow_target, 0.05, dt_seconds);
self.portal_pressure_mm_hg = Self::approach(
self.portal_pressure_mm_hg,
(7.0 + 1.5 * (self.hepatic_fat_fraction_pct / 10.0 - 0.8)
+ 0.8 * (self.kupffer_activation - 0.3))
.clamp(4.0, 16.0),
0.05,
dt_seconds,
);
self.kupffer_activation = Self::approach(
self.kupffer_activation,
(0.25 + 0.4 * self.acute_phase_response + 0.2 * self.portal_pressure_mm_hg / 15.0)
.clamp(0.1, 0.95),
0.04,
dt_seconds,
);
self.acute_phase_response = Self::approach(
self.acute_phase_response,
(0.1 + 0.6 * (self.oxidative_stress_index - 0.2).max(0.0)).clamp(0.05, 0.9),
0.03,
dt_seconds,
);
}
fn update_proteins(&mut self, dt_seconds: f32) {
self.albumin_g_dl = Self::approach(
self.albumin_g_dl,
(4.2 - 0.4 * self.acute_phase_response + 0.2 * (self.insulin_signal - 0.4))
.clamp(2.5, 4.8),
0.01,
dt_seconds,
);
self.clotting_factor_synthesis_pct = Self::approach(
self.clotting_factor_synthesis_pct,
(100.0
- 20.0 * self.oxidative_stress_index
- 15.0 * (0.5 - self.albumin_g_dl / 4.0).max(0.0))
.clamp(40.0, 120.0),
0.05,
dt_seconds,
);
}
fn update_fat_fraction(&mut self, dt_seconds: f32) {
let fat_delta = (self.lipogenesis_rate_g_per_h - self.beta_oxidation_rate_g_per_h)
* dt_seconds
/ (24.0 * 3600.0);
self.hepatic_fat_fraction_pct =
(self.hepatic_fat_fraction_pct + fat_delta * 100.0).clamp(2.0, 25.0);
}
}
impl Organ for Liver {
@@ -30,16 +344,29 @@ impl Organ for Liver {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {
self.enzymes = self.enzymes.clamp(0.0, 2.0);
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_in_state_s += dt_seconds;
self.simulate_hormone_inputs(dt_seconds);
self.update_state();
self.update_metabolic_fluxes(dt_seconds);
self.update_bile_and_detox(dt_seconds);
self.update_hemodynamics(dt_seconds);
self.update_proteins(dt_seconds);
self.update_fat_fraction(dt_seconds);
}
fn summary(&self) -> String {
format!(
"Liver[id={}, detox={}, k={:.2}, enz={:.2}]",
"Liver[id={}, state={:?}, glycogen={:.0} g, albumin={:.1} g/dL, bile={:.2} ml/min]",
self.id(),
self.detox,
self.metabolism,
self.enzymes
self.state,
self.glycogen_store_g,
self.albumin_g_dl,
self.bile_secretion_ml_min
)
}
fn as_any(&self) -> &dyn core::any::Any {
+488 -13
View File
@@ -1,7 +1,28 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
/// Pulmonary model tracking respiratory rate and oxygen saturation.
/// 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 {
Resting,
HypercapnicResponse,
HypoxicResponse,
ExerciseAugmented,
MechanicalDistress,
}
/// Pulmonary model tracking ventilation mechanics and gas exchange.
#[derive(Debug, Clone)]
pub struct Lungs {
info: OrganInfo,
@@ -9,20 +30,425 @@ pub struct Lungs {
pub respiratory_rate_bpm: f32,
/// Peripheral oxygen saturation percent.
pub spo2_pct: f32,
/// Respiratory distress flag reduces SpO2.
/// Flag indicating external distress/vq mismatch triggers.
pub distress: bool,
/// Tidal volume (ml).
pub tidal_volume_ml: f32,
/// Minute ventilation (L/min).
pub minute_ventilation_l_min: f32,
/// Dead-space fraction of each breath (0..=0.5).
pub dead_space_fraction: f32,
/// Alveolar oxygen partial pressure (mmHg).
pub alveolar_po2_mm_hg: f32,
/// Alveolar carbon dioxide partial pressure (mmHg).
pub alveolar_pco2_mm_hg: f32,
/// End tidal CO2 (mmHg).
pub end_tidal_co2_mm_hg: f32,
/// Lung compliance (ml/cmH2O).
pub compliance_ml_cm_h2o: f32,
/// Airway resistance (cmH2O·s/L).
pub airway_resistance_cm_h2o_l_s: f32,
/// Respiratory muscle drive (0..=1).
pub muscle_drive: f32,
/// Chemoreceptor drive (0..=1).
pub chemoreceptor_drive: f32,
/// Ventilation/perfusion ratio.
pub ventilation_perfusion_ratio: f32,
/// Shunt fraction (0..=0.4).
pub shunt_fraction: f32,
/// Pulmonary artery pressure (mmHg).
pub pulmonary_artery_pressure_mm_hg: f32,
/// Pulmonary capillary wedge pressure (mmHg).
pub pcwp_mm_hg: f32,
/// Oxygen delivery (ml O2/min).
pub oxygen_delivery_ml_min: f32,
/// CO2 elimination (ml/min).
pub co2_elimination_ml_min: f32,
/// Functional state.
pub state: VentilatoryState,
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,
minute_ventilation_l_min: 6.5,
dead_space_fraction: 0.28,
alveolar_po2_mm_hg: 100.0,
alveolar_pco2_mm_hg: 38.0,
end_tidal_co2_mm_hg: 36.0,
compliance_ml_cm_h2o: 110.0,
airway_resistance_cm_h2o_l_s: 2.0,
muscle_drive: 0.45,
chemoreceptor_drive: 0.4,
ventilation_perfusion_ratio: 0.96,
shunt_fraction: 0.03,
pulmonary_artery_pressure_mm_hg: 18.0,
pcwp_mm_hg: 9.0,
oxygen_delivery_ml_min: 960.0,
co2_elimination_ml_min: 180.0,
state: VentilatoryState::Resting,
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,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn update_metabolic_demand(&mut self, dt_seconds: f32) {
let exercise_factor =
matches!(self.state, VentilatoryState::ExerciseAugmented) as u8 as f32;
let distress_factor = if self.distress { 0.2 } else { 0.0 };
let o2_target = 250.0 * (1.0 + 0.8 * exercise_factor + distress_factor);
let co2_target = 200.0 * (1.0 + 0.9 * exercise_factor + distress_factor);
self.metabolic_o2_consumption_ml_min = Self::approach(
self.metabolic_o2_consumption_ml_min,
o2_target,
0.4,
dt_seconds,
);
self.metabolic_co2_production_ml_min = Self::approach(
self.metabolic_co2_production_ml_min,
co2_target,
0.4,
dt_seconds,
);
}
fn update_state(&mut self) {
self.state = if self.distress {
VentilatoryState::MechanicalDistress
} else if self.alveolar_pco2_mm_hg > 45.0 {
VentilatoryState::HypercapnicResponse
} else if self.alveolar_po2_mm_hg < 70.0 {
VentilatoryState::HypoxicResponse
} else if self.metabolic_o2_consumption_ml_min > 300.0 {
VentilatoryState::ExerciseAugmented
} else {
VentilatoryState::Resting
};
}
fn chemoreceptor_targets(&self) -> (f32, f32) {
match self.state {
VentilatoryState::Resting => (0.45, 0.48),
VentilatoryState::HypercapnicResponse => (0.8, 0.75),
VentilatoryState::HypoxicResponse => (0.85, 0.82),
VentilatoryState::ExerciseAugmented => (0.9, 0.7),
VentilatoryState::MechanicalDistress => (0.95, 0.65),
}
}
fn update_drives(&mut self, dt_seconds: f32) {
let (chemo_target, muscle_target) = self.chemoreceptor_targets();
let hypoxia_error = (90.0 - self.alveolar_po2_mm_hg).max(0.0) / 40.0;
let hypercapnia_error = (self.alveolar_pco2_mm_hg - 40.0).max(0.0) / 20.0;
let drive_boost = (hypoxia_error + hypercapnia_error).clamp(0.0, 1.0);
self.chemoreceptor_drive = Self::approach(
self.chemoreceptor_drive,
(chemo_target + 0.6 * drive_boost).clamp(0.2, 1.0),
0.8,
dt_seconds,
);
self.muscle_drive = Self::approach(
self.muscle_drive,
(muscle_target + 0.5 * drive_boost).clamp(0.2, 1.0),
0.6,
dt_seconds,
);
}
fn update_mechanics(&mut self, dt_seconds: f32) {
let rate_target = (12.0
+ 18.0 * self.muscle_drive
+ 6.0 * (self.chemoreceptor_drive - 0.5).max(0.0)
+ 8.0 * matches!(self.state, VentilatoryState::MechanicalDistress) as i32 as f32)
.clamp(8.0, 40.0);
self.respiratory_rate_bpm =
Self::approach(self.respiratory_rate_bpm, rate_target, 1.5, dt_seconds);
let compliance_target = if self.distress {
65.0
} else {
110.0 - 20.0 * (self.muscle_drive - 0.5).max(0.0)
}
.clamp(40.0, 140.0);
self.compliance_ml_cm_h2o = Self::approach(
self.compliance_ml_cm_h2o,
compliance_target,
0.2,
dt_seconds,
);
let resistance_target = if self.distress {
4.5
} else {
2.0 + 1.5 * (0.4 - self.compliance_ml_cm_h2o / 150.0).max(0.0)
}
.clamp(1.2, 6.0);
self.airway_resistance_cm_h2o_l_s = Self::approach(
self.airway_resistance_cm_h2o_l_s,
resistance_target,
0.3,
dt_seconds,
);
let tidal_target = (450.0 + 160.0 * (self.muscle_drive - 0.4)
- 50.0 * self.airway_resistance_cm_h2o_l_s)
.clamp(250.0, 900.0);
self.tidal_volume_ml = Self::approach(self.tidal_volume_ml, tidal_target, 30.0, dt_seconds);
self.dead_space_fraction = Self::approach(
self.dead_space_fraction,
(0.28
+ 0.15 * (self.airway_resistance_cm_h2o_l_s - 2.0).max(0.0)
+ 0.1 * self.shunt_fraction)
.clamp(0.15, 0.5),
0.2,
dt_seconds,
);
let alveolar_ventilation = (self.tidal_volume_ml * (1.0 - self.dead_space_fraction)
/ 1000.0)
* self.respiratory_rate_bpm;
self.minute_ventilation_l_min =
(self.tidal_volume_ml / 1000.0 * self.respiratory_rate_bpm).clamp(3.0, 25.0);
self.ventilation_perfusion_ratio = Self::approach(
self.ventilation_perfusion_ratio,
(alveolar_ventilation / 5.0).clamp(0.4, 1.4),
0.3,
dt_seconds,
);
}
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);
let po2_target = (100.0 + 12.0 * (effective_ventilation - 5.5)
- 30.0 * self.shunt_fraction
- 15.0 * (1.0 - self.ventilation_perfusion_ratio))
.clamp(40.0, 120.0);
let pco2_target = (40.0 - 5.0 * (effective_ventilation - 6.0) + 10.0 * self.shunt_fraction)
.clamp(25.0, 60.0);
self.alveolar_po2_mm_hg =
Self::approach(self.alveolar_po2_mm_hg, po2_target, 0.5, dt_seconds);
self.alveolar_pco2_mm_hg =
Self::approach(self.alveolar_pco2_mm_hg, pco2_target, 0.5, dt_seconds);
self.end_tidal_co2_mm_hg = Self::approach(
self.end_tidal_co2_mm_hg,
self.alveolar_pco2_mm_hg,
1.2,
dt_seconds,
);
let spo2_target = (97.0 + 8.0 * (self.alveolar_po2_mm_hg - 90.0) / 40.0
- 12.0 * self.shunt_fraction
- 5.0 * (self.metabolic_o2_consumption_ml_min - 250.0) / 200.0)
.clamp(70.0, 100.0);
self.spo2_pct = Self::approach(self.spo2_pct, spo2_target, 0.6, dt_seconds);
self.shunt_fraction = Self::approach(
self.shunt_fraction,
(0.03
+ 0.2 * (1.0 - self.ventilation_perfusion_ratio).max(0.0)
+ if self.distress { 0.12 } else { 0.0 })
.clamp(0.0, 0.35),
0.4,
dt_seconds,
);
self.oxygen_delivery_ml_min = Self::approach(
self.oxygen_delivery_ml_min,
self.spo2_pct * 10.0,
2.0,
dt_seconds,
);
self.co2_elimination_ml_min = Self::approach(
self.co2_elimination_ml_min,
(self.metabolic_co2_production_ml_min
* (self.minute_ventilation_l_min / 6.0).clamp(0.5, 2.0))
.clamp(80.0, 600.0),
1.5,
dt_seconds,
);
}
fn update_pressures(&mut self, dt_seconds: f32) {
let pap_target = (18.0
+ 8.0 * (self.shunt_fraction - 0.05).max(0.0)
+ 4.0 * (self.minute_ventilation_l_min - 6.0) / 6.0)
.clamp(12.0, 35.0);
self.pulmonary_artery_pressure_mm_hg = Self::approach(
self.pulmonary_artery_pressure_mm_hg,
pap_target,
0.2,
dt_seconds,
);
self.pcwp_mm_hg = Self::approach(
self.pcwp_mm_hg,
(8.0 + 0.5 * (self.pulmonary_artery_pressure_mm_hg - 18.0)).clamp(5.0, 18.0),
0.2,
dt_seconds,
);
}
}
impl Organ for Lungs {
@@ -33,22 +459,29 @@ impl Organ for Lungs {
self.info.kind()
}
fn update(&mut self, dt_seconds: f32) {
let dt = dt_seconds.clamp(0.0, 10.0);
let target_rr = 14.0;
self.respiratory_rate_bpm += 0.1 * (target_rr - self.respiratory_rate_bpm) * (dt / 1.0);
// distress drifts SpO2 downward
if self.distress {
self.spo2_pct -= 0.5 * (dt / 1.0);
if dt_seconds <= 0.0 {
return;
}
// keep spo2 in [70, 100]
self.spo2_pct = self.spo2_pct.clamp(70.0, 100.0);
self.time_in_state_s += dt_seconds;
self.update_metabolic_demand(dt_seconds);
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={}, RR={:.1} bpm, SpO2={:.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.spo2_pct
self.diaphragm_position,
self.tidal_volume_ml,
self.spo2_pct,
self.alveolar_po2_mm_hg
)
}
fn as_any(&self) -> &dyn core::any::Any {
@@ -58,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"
);
}
}
+7 -5
View File
@@ -42,6 +42,7 @@ pub trait Organ: Debug + Send {
}
mod bladder;
mod bloodstream;
mod brain;
mod esophagus;
mod gallbladder;
@@ -55,15 +56,16 @@ mod spinal_cord;
mod spleen;
mod stomach;
pub use bladder::Bladder;
pub use brain::Brain;
pub use esophagus::Esophagus;
pub use bladder::{Bladder, BladderMetrics, BladderPhase};
pub use bloodstream::{Bloodstream, BloodstreamMetrics, MetabolicState, PerfusionState};
pub use brain::{Brain, SleepStage};
pub use esophagus::{EsophagealStage, Esophagus};
pub use gallbladder::Gallbladder;
pub use heart::Heart;
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;
+207 -4
View File
@@ -1,20 +1,206 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
/// Dominant endocrine/exocrine activity mode of the pancreas.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PancreaticState {
Basal,
PostprandialAnabolic,
HypoglycemicCounterregulation,
BetaCellExhaustion,
}
#[derive(Debug, Clone)]
pub struct Pancreas {
info: OrganInfo,
/// Insulin secretion index
/// Insulin secretion index (µU/mL proxy).
pub insulin: f32,
/// Glucagon secretion index (pg/mL proxy).
pub glucagon: f32,
/// Somatostatin output (pg/mL proxy).
pub somatostatin: f32,
/// Pancreatic polypeptide level.
pub pancreatic_polypeptide: f32,
/// Enzyme output (kIU/min).
pub digestive_enzyme_output: f32,
/// Bicarbonate secretion (mmol/min).
pub bicarbonate_output_mmol_min: f32,
/// Estimated beta-cell functional mass fraction (0..=1).
pub beta_cell_mass_fraction: f32,
/// Islet stress index (0..=1).
pub islet_stress_index: f32,
/// Acinar secretion flow (ml/min).
pub acinar_flow_ml_min: f32,
/// Ductal pressure (cmH2O).
pub duct_pressure_cm_h2o: f32,
/// Blood glucose sensed by islets (mg/dL).
pub blood_glucose_mg_dl: f32,
/// Incretin stimulus (0..=1).
pub incretin_signal: f32,
/// Autonomic tone (-1 vagal, +1 sympathetic).
pub autonomic_tone: f32,
/// Current pancreas state.
pub state: PancreaticState,
time_in_state_s: f32,
feeding_clock_s: f32,
target_meal_interval_s: f32,
}
impl Pancreas {
pub fn new(id: impl Into<String>) -> Self {
Self {
info: OrganInfo::new(id, OrganType::Pancreas),
insulin: 1.0,
insulin: 12.0,
glucagon: 60.0,
somatostatin: 20.0,
pancreatic_polypeptide: 120.0,
digestive_enzyme_output: 18.0,
bicarbonate_output_mmol_min: 1.8,
beta_cell_mass_fraction: 0.92,
islet_stress_index: 0.25,
acinar_flow_ml_min: 0.7,
duct_pressure_cm_h2o: 6.0,
blood_glucose_mg_dl: 95.0,
incretin_signal: 0.2,
autonomic_tone: 0.0,
state: PancreaticState::Basal,
time_in_state_s: 0.0,
feeding_clock_s: 0.0,
target_meal_interval_s: 4.2 * 3600.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn simulate_meals(&mut self, dt_seconds: f32) {
self.feeding_clock_s += dt_seconds;
if self.feeding_clock_s >= self.target_meal_interval_s {
self.blood_glucose_mg_dl = 155.0;
self.incretin_signal = 0.85;
self.autonomic_tone = -0.4; // vagal dominance
self.feeding_clock_s = 0.0;
self.state = PancreaticState::PostprandialAnabolic;
self.time_in_state_s = 0.0;
self.target_meal_interval_s = (3.5 + 1.2 * self.islet_stress_index) * 3600.0;
} else {
self.incretin_signal = Self::approach(self.incretin_signal, 0.15, 0.06, dt_seconds);
self.autonomic_tone = Self::approach(self.autonomic_tone, 0.1, 0.08, dt_seconds);
}
self.blood_glucose_mg_dl = Self::approach(
self.blood_glucose_mg_dl,
90.0 + 12.0 * (-self.autonomic_tone).max(0.0),
0.1,
dt_seconds,
);
}
fn update_state(&mut self) {
self.state = if self.beta_cell_mass_fraction < 0.6 || self.islet_stress_index > 0.75 {
PancreaticState::BetaCellExhaustion
} else if self.blood_glucose_mg_dl < 70.0 {
PancreaticState::HypoglycemicCounterregulation
} else if self.blood_glucose_mg_dl > 130.0 || self.incretin_signal > 0.5 {
PancreaticState::PostprandialAnabolic
} else {
PancreaticState::Basal
};
}
fn update_endocrine(&mut self, dt_seconds: f32) {
let insulin_target = match self.state {
PancreaticState::PostprandialAnabolic => {
8.0 + 0.6 * (self.blood_glucose_mg_dl - 90.0).max(0.0) + 25.0 * self.incretin_signal
}
PancreaticState::Basal => 10.0 + 0.2 * (self.blood_glucose_mg_dl - 90.0),
PancreaticState::HypoglycemicCounterregulation => 4.0,
PancreaticState::BetaCellExhaustion => 6.0,
};
self.insulin = Self::approach(
self.insulin,
(insulin_target * self.beta_cell_mass_fraction).clamp(2.0, 80.0),
0.5,
dt_seconds,
);
let glucagon_target = match self.state {
PancreaticState::HypoglycemicCounterregulation => 150.0,
PancreaticState::Basal => 70.0,
PancreaticState::PostprandialAnabolic => 40.0,
PancreaticState::BetaCellExhaustion => 110.0,
};
self.glucagon = Self::approach(
self.glucagon,
(glucagon_target + 20.0 * self.autonomic_tone.max(0.0)).clamp(20.0, 200.0),
0.4,
dt_seconds,
);
let somatostatin_target = (20.0
+ 15.0 * (self.incretin_signal - 0.3).max(0.0)
+ 0.3 * (self.blood_glucose_mg_dl - 90.0))
.clamp(10.0, 80.0);
self.somatostatin = Self::approach(self.somatostatin, somatostatin_target, 0.5, dt_seconds);
self.pancreatic_polypeptide = Self::approach(
self.pancreatic_polypeptide,
(100.0 + 80.0 * (-self.autonomic_tone).max(0.0) + 40.0 * self.incretin_signal)
.clamp(60.0, 260.0),
0.3,
dt_seconds,
);
self.islet_stress_index = Self::approach(
self.islet_stress_index,
(0.2 + 0.4 * (self.blood_glucose_mg_dl - 100.0).max(0.0) / 80.0
+ 0.3 * (self.autonomic_tone).max(0.0))
.clamp(0.05, 0.95),
0.04,
dt_seconds,
);
self.beta_cell_mass_fraction = (self.beta_cell_mass_fraction
- 0.00002 * dt_seconds * (self.islet_stress_index - 0.3).max(0.0)
+ 0.000015 * dt_seconds * (0.5 - self.islet_stress_index).max(0.0))
.clamp(0.4, 1.05);
}
fn update_exocrine(&mut self, dt_seconds: f32) {
let enzyme_target =
(15.0 + 25.0 * self.incretin_signal + 10.0 * (-self.autonomic_tone).max(0.0))
.clamp(5.0, 60.0);
self.digestive_enzyme_output =
Self::approach(self.digestive_enzyme_output, enzyme_target, 0.5, dt_seconds);
let bicarb_target =
(1.5 + 2.5 * self.incretin_signal - 0.5 * self.islet_stress_index).clamp(0.5, 5.0);
self.bicarbonate_output_mmol_min = Self::approach(
self.bicarbonate_output_mmol_min,
bicarb_target,
0.4,
dt_seconds,
);
self.acinar_flow_ml_min = Self::approach(
self.acinar_flow_ml_min,
(0.6 + 0.5 * self.incretin_signal + 0.3 * (-self.autonomic_tone).max(0.0))
.clamp(0.3, 2.0),
0.4,
dt_seconds,
);
self.duct_pressure_cm_h2o = Self::approach(
self.duct_pressure_cm_h2o,
(6.0 + 4.0 * (self.acinar_flow_ml_min - 0.7)).clamp(4.0, 18.0),
0.3,
dt_seconds,
);
}
}
impl Organ for Pancreas {
@@ -24,9 +210,26 @@ impl Organ for Pancreas {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {}
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_in_state_s += dt_seconds;
self.simulate_meals(dt_seconds);
self.update_state();
self.update_endocrine(dt_seconds);
self.update_exocrine(dt_seconds);
}
fn summary(&self) -> String {
format!("Pancreas[id={}, insulin={:.2}]", self.id(), self.insulin)
format!(
"Pancreas[id={}, state={:?}, insulin={:.1}, glucagon={:.0}, enzymes={:.1} kIU/min]",
self.id(),
self.state,
self.insulin,
self.glucagon,
self.digestive_enzyme_output
)
}
fn as_any(&self) -> &dyn core::any::Any {
self
+170 -5
View File
@@ -1,12 +1,45 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
/// Functional state of spinal cord circuitry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpinalCordState {
Intact,
Concussed,
Inflammatory,
Ischemic,
NeurogenicShock,
}
#[derive(Debug, Clone)]
pub struct SpinalCord {
info: OrganInfo,
/// 0..=100 nerve signal integrity.
pub signal_integrity: u8,
pub injury: bool,
/// Ascending conduction velocity (m/s).
pub ascending_conduction_velocity: f32,
/// Descending motor conduction (m/s).
pub descending_conduction_velocity: f32,
/// Segmental reflex gain (dimensionless 0..=2).
pub reflex_gain: f32,
/// Sympathetic preganglionic output (0..=1).
pub sympathetic_outflow: f32,
/// Parasympathetic sacral outflow (0..=1).
pub parasympathetic_outflow: f32,
/// Central pattern generator tone (0..=1).
pub locomotor_cpg_tone: f32,
/// Nociceptive facilitation (0..=1).
pub nociceptive_facilitation: f32,
/// Glial scar formation index (0..=1).
pub glial_scar_index: f32,
/// Cord perfusion pressure (mmHg).
pub cord_perfusion_pressure_mm_hg: f32,
/// Inflammation marker (0..=1).
pub inflammation_index: f32,
/// State of spinal cord.
pub state: SpinalCordState,
time_in_state_s: f32,
}
impl SpinalCord {
@@ -15,8 +48,131 @@ impl SpinalCord {
info: OrganInfo::new(id, OrganType::SpinalCord),
signal_integrity: 100,
injury: false,
ascending_conduction_velocity: 54.0,
descending_conduction_velocity: 60.0,
reflex_gain: 1.0,
sympathetic_outflow: 0.6,
parasympathetic_outflow: 0.55,
locomotor_cpg_tone: 0.65,
nociceptive_facilitation: 0.2,
glial_scar_index: 0.05,
cord_perfusion_pressure_mm_hg: 75.0,
inflammation_index: 0.1,
state: SpinalCordState::Intact,
time_in_state_s: 0.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn update_state(&mut self) {
self.state = if self.cord_perfusion_pressure_mm_hg < 60.0 {
SpinalCordState::Ischemic
} else if self.injury && self.time_in_state_s < 6.0 * 3600.0 {
SpinalCordState::NeurogenicShock
} else if self.inflammation_index > 0.5 {
SpinalCordState::Inflammatory
} else if self.glial_scar_index > 0.4 {
SpinalCordState::Concussed
} else {
SpinalCordState::Intact
};
}
fn update_integrity(&mut self, dt_seconds: f32) {
if self.injury {
let drop = (0.03 * dt_seconds).min(self.signal_integrity as f32);
self.signal_integrity = self.signal_integrity.saturating_sub(drop as u8);
self.glial_scar_index = (self.glial_scar_index + 0.00005 * dt_seconds).clamp(0.0, 1.0);
} else {
self.signal_integrity = (self.signal_integrity + 1).min(100);
self.glial_scar_index = Self::approach(self.glial_scar_index, 0.05, 0.0002, dt_seconds);
}
}
fn update_conduction(&mut self, dt_seconds: f32) {
let integrity_factor = self.signal_integrity as f32 / 100.0;
let scar_penalty = self.glial_scar_index * 20.0;
self.ascending_conduction_velocity = Self::approach(
self.ascending_conduction_velocity,
(54.0 * integrity_factor - scar_penalty).clamp(10.0, 60.0),
0.2,
dt_seconds,
);
self.descending_conduction_velocity = Self::approach(
self.descending_conduction_velocity,
(60.0 * integrity_factor - scar_penalty * 1.2).clamp(15.0, 70.0),
0.2,
dt_seconds,
);
}
fn update_autonomic_outflow(&mut self, dt_seconds: f32) {
let (sym_target, parasym_target, reflex_target, nocice_target) = match self.state {
SpinalCordState::Intact => (0.6, 0.55, 1.0, 0.2),
SpinalCordState::Concussed => (0.5, 0.45, 0.8, 0.35),
SpinalCordState::Inflammatory => (0.65, 0.4, 1.1, 0.6),
SpinalCordState::Ischemic => (0.45, 0.35, 0.6, 0.7),
SpinalCordState::NeurogenicShock => (0.3, 0.25, 0.4, 0.5),
};
self.sympathetic_outflow =
Self::approach(self.sympathetic_outflow, sym_target, 0.4, dt_seconds);
self.parasympathetic_outflow = Self::approach(
self.parasympathetic_outflow,
parasym_target,
0.4,
dt_seconds,
);
self.reflex_gain = Self::approach(self.reflex_gain, reflex_target, 0.3, dt_seconds);
self.nociceptive_facilitation = Self::approach(
self.nociceptive_facilitation,
nocice_target,
0.3,
dt_seconds,
);
self.locomotor_cpg_tone = Self::approach(
self.locomotor_cpg_tone,
(0.65 * (self.reflex_gain / 1.0) * (self.descending_conduction_velocity / 60.0))
.clamp(0.2, 0.9),
0.2,
dt_seconds,
);
}
fn update_perfusion(&mut self, dt_seconds: f32) {
let perfusion_target = (75.0 - 10.0 * (self.sympathetic_outflow - 0.6)
+ 6.0 * (self.parasympathetic_outflow - 0.5)
- 15.0 * (1.0 - self.signal_integrity as f32 / 100.0))
.clamp(40.0, 90.0);
self.cord_perfusion_pressure_mm_hg = Self::approach(
self.cord_perfusion_pressure_mm_hg,
perfusion_target,
0.2,
dt_seconds,
);
self.inflammation_index = Self::approach(
self.inflammation_index,
(0.1 + 0.8 * (1.0 - self.cord_perfusion_pressure_mm_hg / 80.0).max(0.0)
+ 0.5 * self.glial_scar_index)
.clamp(0.05, 1.0),
0.02,
dt_seconds,
);
}
}
impl Organ for SpinalCord {
@@ -26,16 +182,25 @@ impl Organ for SpinalCord {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {
if self.injury {
self.signal_integrity = self.signal_integrity.saturating_sub(1);
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_in_state_s += dt_seconds;
self.update_integrity(dt_seconds);
self.update_perfusion(dt_seconds);
self.update_state();
self.update_conduction(dt_seconds);
self.update_autonomic_outflow(dt_seconds);
}
fn summary(&self) -> String {
format!(
"SpinalCord[id={}, integrity={}]",
"SpinalCord[id={}, state={:?}, integrity={}, sym={:.2}, reflex={:.2}]",
self.id(),
self.signal_integrity
self.state,
self.signal_integrity,
self.sympathetic_outflow,
self.reflex_gain
)
}
fn as_any(&self) -> &dyn core::any::Any {
+161 -3
View File
@@ -1,11 +1,40 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
/// Functional status of the spleen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplenicState {
Homeostatic,
SympatheticContraction,
HyperimmuneActivation,
Sequestration,
Hypofunction,
}
#[derive(Debug, Clone)]
pub struct Spleen {
info: OrganInfo,
/// Immune activity 0..=100
/// Immune activity 0..=100.
pub immune_activity: u8,
/// Red pulp blood volume (ml).
pub red_pulp_volume_ml: f32,
/// White pulp lymphocyte activation (0..=1).
pub white_pulp_activation: f32,
/// Platelet reservoir (10^9 cells/L contribution).
pub platelet_reservoir: f32,
/// Sympathetic tone (0..=1).
pub sympathetic_tone: f32,
/// Cytokine output (relative units).
pub cytokine_output: f32,
/// Filtered aged erythrocytes (10^6 cells/min).
pub erythrocyte_culling_rate: f32,
/// IgM production (mg/dL).
pub igm_production_mg_dl: f32,
/// Splenic contraction level (0..=1).
pub contraction_fraction: f32,
/// Current spleen state.
pub state: SplenicState,
time_in_state_s: f32,
}
impl Spleen {
@@ -13,8 +42,121 @@ impl Spleen {
Self {
info: OrganInfo::new(id, OrganType::Spleen),
immune_activity: 80,
red_pulp_volume_ml: 180.0,
white_pulp_activation: 0.45,
platelet_reservoir: 70.0,
sympathetic_tone: 0.35,
cytokine_output: 0.2,
erythrocyte_culling_rate: 2.5,
igm_production_mg_dl: 95.0,
contraction_fraction: 0.1,
state: SplenicState::Homeostatic,
time_in_state_s: 0.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn update_state(&mut self) {
self.state = if self.sympathetic_tone > 0.7 {
SplenicState::SympatheticContraction
} else if self.white_pulp_activation > 0.75 || self.cytokine_output > 0.6 {
SplenicState::HyperimmuneActivation
} else if self.red_pulp_volume_ml > 260.0 {
SplenicState::Sequestration
} else if self.white_pulp_activation < 0.2 {
SplenicState::Hypofunction
} else {
SplenicState::Homeostatic
};
}
fn update_sympathetic_tone(&mut self, dt_seconds: f32) {
self.sympathetic_tone = Self::approach(
self.sympathetic_tone,
(0.3 + 0.5 * (self.contraction_fraction - 0.3).max(0.0)).clamp(0.2, 0.95),
0.3,
dt_seconds,
);
}
fn update_contraction(&mut self, dt_seconds: f32) {
let contraction_target = match self.state {
SplenicState::SympatheticContraction => 0.85,
SplenicState::HyperimmuneActivation => 0.45,
SplenicState::Sequestration => 0.15,
SplenicState::Hypofunction => 0.1,
SplenicState::Homeostatic => 0.3,
};
self.contraction_fraction = Self::approach(
self.contraction_fraction,
contraction_target,
0.4,
dt_seconds,
);
let volume_target = (180.0 + 120.0 * (0.4 - self.contraction_fraction)).clamp(80.0, 320.0);
self.red_pulp_volume_ml =
Self::approach(self.red_pulp_volume_ml, volume_target, 0.8, dt_seconds);
self.platelet_reservoir = Self::approach(
self.platelet_reservoir,
(70.0 + 40.0 * (self.red_pulp_volume_ml - 180.0) / 120.0).clamp(20.0, 160.0),
0.6,
dt_seconds,
);
}
fn update_immune_activity(&mut self, dt_seconds: f32) {
let activation_target = match self.state {
SplenicState::HyperimmuneActivation => 0.85,
SplenicState::Sequestration => 0.55,
SplenicState::Hypofunction => 0.18,
SplenicState::SympatheticContraction => 0.4,
SplenicState::Homeostatic => 0.45,
};
self.white_pulp_activation = Self::approach(
self.white_pulp_activation,
activation_target,
0.3,
dt_seconds,
);
self.immune_activity = ((self.white_pulp_activation * 120.0)
+ (self.cytokine_output * 40.0))
.clamp(10.0, 160.0) as u8;
self.cytokine_output = Self::approach(
self.cytokine_output,
(0.2 + 0.8 * (self.white_pulp_activation - 0.3).max(0.0)).clamp(0.05, 1.2),
0.1,
dt_seconds,
);
self.erythrocyte_culling_rate = Self::approach(
self.erythrocyte_culling_rate,
(2.0 + 1.5 * (self.red_pulp_volume_ml - 180.0) / 100.0 + 1.2 * self.cytokine_output)
.clamp(0.5, 8.0),
0.2,
dt_seconds,
);
self.igm_production_mg_dl = Self::approach(
self.igm_production_mg_dl,
(90.0 + 60.0 * self.white_pulp_activation - 20.0 * self.sympathetic_tone)
.clamp(30.0, 220.0),
0.4,
dt_seconds,
);
}
}
impl Organ for Spleen {
@@ -24,9 +166,25 @@ impl Organ for Spleen {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {}
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_in_state_s += dt_seconds;
self.update_state();
self.update_contraction(dt_seconds);
self.update_sympathetic_tone(dt_seconds);
self.update_immune_activity(dt_seconds);
}
fn summary(&self) -> String {
format!("Spleen[id={}, immune={}]", self.id(), self.immune_activity)
format!(
"Spleen[id={}, state={:?}, immune={}, redpulp={:.0} ml, platelets={:.0}]",
self.id(),
self.state,
self.immune_activity,
self.red_pulp_volume_ml,
self.platelet_reservoir
)
}
fn as_any(&self) -> &dyn core::any::Any {
self
+250 -3
View File
@@ -1,11 +1,52 @@
use super::{Organ, OrganInfo};
use crate::types::OrganType;
/// Gastric functional phase.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GastricPhase {
Fasting,
Cephalic,
Gastric,
Intestinal,
DelayedEmptying,
}
#[derive(Debug, Clone)]
pub struct Stomach {
info: OrganInfo,
/// Acid level 0..=100
/// Acid level 0..=100 (higher = more secretion).
pub acid_level: u8,
/// Gastric lumen pH.
pub ph: f32,
/// Current gastric volume (ml).
pub volume_ml: f32,
/// Gastric motility index (0..=1).
pub motility_index: f32,
/// Antral pump strength (0..=1).
pub antral_pump_strength: f32,
/// Gastric emptying rate (ml/min).
pub emptying_rate_ml_min: f32,
/// Ghrelin level (pg/mL proxy).
pub ghrelin: f32,
/// Gastrin level (pg/mL proxy).
pub gastrin: f32,
/// Histamine release (relative units).
pub histamine: f32,
/// Somatostatin brake (relative units).
pub somatostatin: f32,
/// Protective mucus production (g/hour).
pub mucus_production_g_per_h: f32,
/// Intrinsic factor secretion (relative units).
pub intrinsic_factor: f32,
/// Vagal tone (0..=1).
pub vagal_tone: f32,
/// Gastric phase.
pub phase: GastricPhase,
/// Pending meal caloric load (kcal).
pub nutrient_load_kcal: f32,
time_in_phase_s: f32,
fasting_clock_s: f32,
target_meal_interval_s: f32,
}
impl Stomach {
@@ -13,8 +54,197 @@ impl Stomach {
Self {
info: OrganInfo::new(id, OrganType::Stomach),
acid_level: 50,
ph: 2.2,
volume_ml: 120.0,
motility_index: 0.35,
antral_pump_strength: 0.3,
emptying_rate_ml_min: 1.5,
ghrelin: 950.0,
gastrin: 80.0,
histamine: 0.4,
somatostatin: 0.3,
mucus_production_g_per_h: 15.0,
intrinsic_factor: 0.6,
vagal_tone: 0.4,
phase: GastricPhase::Fasting,
nutrient_load_kcal: 60.0,
time_in_phase_s: 0.0,
fasting_clock_s: 0.0,
target_meal_interval_s: 4.5 * 3600.0,
}
}
fn approach(current: f32, target: f32, rate_per_second: f32, dt_seconds: f32) -> f32 {
let rate = rate_per_second.max(0.0);
if rate == 0.0 || dt_seconds <= 0.0 {
return current;
}
let delta = target - current;
let max_step = rate * dt_seconds;
if delta > max_step {
current + max_step
} else if delta < -max_step {
current - max_step
} else {
target
}
}
fn simulate_meals(&mut self, dt_seconds: f32) {
self.fasting_clock_s += dt_seconds;
if self.fasting_clock_s >= self.target_meal_interval_s {
self.phase = GastricPhase::Cephalic;
self.time_in_phase_s = 0.0;
self.vagal_tone = 0.85;
self.ghrelin = 600.0;
self.gastrin = 160.0;
self.nutrient_load_kcal = 650.0;
self.volume_ml = (self.volume_ml + 450.0).clamp(80.0, 1600.0);
self.target_meal_interval_s =
(4.0 + 1.0 * (self.mucus_production_g_per_h / 15.0)) * 3600.0;
self.fasting_clock_s = 0.0;
} else {
self.vagal_tone = Self::approach(self.vagal_tone, 0.35, 0.04, dt_seconds);
self.ghrelin = Self::approach(self.ghrelin, 1200.0, 1.0, dt_seconds);
}
}
fn update_phase(&mut self) {
self.phase = match self.phase {
GastricPhase::Cephalic => {
if self.time_in_phase_s > 300.0 {
GastricPhase::Gastric
} else {
GastricPhase::Cephalic
}
}
GastricPhase::Gastric => {
if self.volume_ml < 200.0 {
GastricPhase::Intestinal
} else {
GastricPhase::Gastric
}
}
GastricPhase::Intestinal => {
if self.nutrient_load_kcal < 80.0 {
GastricPhase::Fasting
} else if self.emptying_rate_ml_min < 1.0 {
GastricPhase::DelayedEmptying
} else {
GastricPhase::Intestinal
}
}
GastricPhase::DelayedEmptying => {
if self.emptying_rate_ml_min > 1.5 {
GastricPhase::Fasting
} else {
GastricPhase::DelayedEmptying
}
}
GastricPhase::Fasting => {
if self.nutrient_load_kcal > 120.0 {
GastricPhase::Cephalic
} else {
GastricPhase::Fasting
}
}
};
}
fn update_secretions(&mut self, dt_seconds: f32) {
let gastrin_target = match self.phase {
GastricPhase::Cephalic => 180.0,
GastricPhase::Gastric => 220.0,
GastricPhase::Intestinal => 120.0,
GastricPhase::DelayedEmptying => 160.0,
GastricPhase::Fasting => 60.0,
};
self.gastrin = Self::approach(
self.gastrin,
(gastrin_target + 0.5 * (self.volume_ml - 250.0).max(0.0)).clamp(40.0, 320.0),
0.5,
dt_seconds,
);
self.histamine = Self::approach(
self.histamine,
(0.3 + 0.004 * self.gastrin + 0.2 * (self.vagal_tone - 0.4).max(0.0)).clamp(0.1, 2.0),
0.3,
dt_seconds,
);
self.somatostatin = Self::approach(
self.somatostatin,
(0.25
+ 0.2 * (self.ph - 2.0).max(0.0)
+ 0.3 * (self.phase == GastricPhase::Intestinal) as i32 as f32)
.clamp(0.1, 2.0),
0.4,
dt_seconds,
);
let acid_drive =
(self.gastrin / 200.0 + self.histamine - self.somatostatin).clamp(0.0, 2.0);
let acid_numeric = (50.0 + 35.0 * acid_drive).clamp(10.0, 100.0);
self.acid_level = acid_numeric.round() as u8;
self.ph = Self::approach(
self.ph,
(7.0 - 0.045 * self.acid_level as f32 + 0.4 * (self.volume_ml / 500.0)).clamp(1.2, 6.5),
0.6,
dt_seconds,
);
self.mucus_production_g_per_h = Self::approach(
self.mucus_production_g_per_h,
(15.0
+ 6.0 * (self.acid_level as f32 / 60.0)
+ 4.0 * (self.somatostatin - 0.3).max(0.0))
.clamp(8.0, 40.0),
0.2,
dt_seconds,
);
self.intrinsic_factor = Self::approach(
self.intrinsic_factor,
(0.6 + 0.4 * (self.acid_level as f32 / 80.0)).clamp(0.2, 1.2),
0.2,
dt_seconds,
);
}
fn update_motility(&mut self, dt_seconds: f32) {
let motility_target = match self.phase {
GastricPhase::Cephalic => 0.4,
GastricPhase::Gastric => 0.75,
GastricPhase::Intestinal => 0.6,
GastricPhase::DelayedEmptying => 0.35,
GastricPhase::Fasting => 0.3,
};
self.motility_index = Self::approach(self.motility_index, motility_target, 0.5, dt_seconds);
self.antral_pump_strength = Self::approach(
self.antral_pump_strength,
(0.3 + 0.5 * self.motility_index + 0.3 * self.vagal_tone).clamp(0.2, 0.95),
0.5,
dt_seconds,
);
self.emptying_rate_ml_min = Self::approach(
self.emptying_rate_ml_min,
(1.5 + 3.5 * self.antral_pump_strength
- 1.0 * (self.ph - 3.0).max(0.0)
- 0.5 * (self.nutrient_load_kcal / 300.0))
.clamp(0.2, 9.0),
0.4,
dt_seconds,
);
}
fn update_volume(&mut self, dt_seconds: f32) {
let emptied = self.emptying_rate_ml_min * dt_seconds / 60.0;
let metabolic_use = (self.nutrient_load_kcal * 0.3) * dt_seconds / 3600.0;
self.volume_ml = (self.volume_ml - emptied).clamp(30.0, 1800.0);
self.nutrient_load_kcal = (self.nutrient_load_kcal - metabolic_use).max(0.0);
self.ghrelin = Self::approach(
self.ghrelin,
(1200.0 - 0.8 * self.volume_ml).clamp(200.0, 1400.0),
0.4,
dt_seconds,
);
}
}
impl Organ for Stomach {
@@ -24,9 +254,26 @@ impl Organ for Stomach {
fn organ_type(&self) -> OrganType {
self.info.kind()
}
fn update(&mut self, _dt_seconds: f32) {}
fn update(&mut self, dt_seconds: f32) {
if dt_seconds <= 0.0 {
return;
}
self.time_in_phase_s += dt_seconds;
self.simulate_meals(dt_seconds);
self.update_phase();
self.update_secretions(dt_seconds);
self.update_motility(dt_seconds);
self.update_volume(dt_seconds);
}
fn summary(&self) -> String {
format!("Stomach[id={}, acid={}]", self.id(), self.acid_level)
format!(
"Stomach[id={}, phase={:?}, vol={:.0} ml, pH={:.1}, acid={}]",
self.id(),
self.phase,
self.volume_ml,
self.ph,
self.acid_level
)
}
fn as_any(&self) -> &dyn core::any::Any {
self
+1982 -31
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -10,6 +10,8 @@ pub enum OrganType {
Heart,
/// Lungs
Lungs,
/// Circulating blood volume and transport network
Bloodstream,
/// Brain
Brain,
/// Spinal cord
+70
View File
@@ -0,0 +1,70 @@
use medicallib_rust::{Bladder, Organ, OrganType, Patient};
#[test]
fn bladder_metrics_match_patient_api() {
let patient = Patient::new("metrics")
.unwrap()
.initialize_default()
.with_organ(OrganType::Bladder);
let metrics_via_patient = patient.bladder_metrics().expect("bladder present");
let bladder = patient.find_organ_typed::<Bladder>().unwrap();
let direct = bladder.metrics();
assert!((metrics_via_patient.urgency - direct.urgency).abs() < 1e-6);
assert_eq!(metrics_via_patient.phase, direct.phase);
assert_eq!(metrics_via_patient.capacity_ml, direct.capacity_ml);
}
#[test]
fn voluntary_hold_updates_metrics() {
let mut patient = Patient::new("hold")
.unwrap()
.initialize_default()
.with_organ(OrganType::Bladder);
{
let bladder = patient.find_organ_typed_mut::<Bladder>().unwrap();
bladder.volume_ml = bladder.micturition_threshold_ml + 140.0;
bladder.urgency = 0.82;
bladder.cortical_inhibition = 0.1;
bladder.voluntary_hold_command = 0.1;
bladder.continence_training = 0.45;
bladder.update(0.5);
}
let baseline = patient.bladder_metrics().unwrap();
{
let bladder = patient.find_organ_typed_mut::<Bladder>().unwrap();
bladder.cortical_inhibition = 0.9;
bladder.voluntary_hold_command = 0.9;
bladder.continence_training = 0.9;
bladder.update(0.5);
}
let hold = patient.bladder_metrics().unwrap();
assert!(hold.voluntary_hold_fraction > baseline.voluntary_hold_fraction);
assert!(hold.cortical_gate_fraction > baseline.cortical_gate_fraction);
assert!(hold.cortical_gate_fraction <= 1.0);
assert!(hold.voluntary_hold_fraction <= 1.0);
}
#[cfg(feature = "ffi")]
#[test]
fn ffi_exposes_bladder_metrics() {
use core::mem::MaybeUninit;
use medicallib_rust::ffi::{
ml_patient_bladder_metrics, ml_patient_free, ml_patient_new, ml_patient_update,
MLBladderMetrics, MLPatient, ML_ERR, ML_OK,
};
use std::ffi::CString;
unsafe {
let id = CString::new("ffi-metrics").unwrap();
let patient: *mut MLPatient = ml_patient_new(id.as_ptr());
assert!(!patient.is_null());
assert_eq!(ml_patient_update(patient, 0.5), ML_OK);
let mut metrics = MaybeUninit::<MLBladderMetrics>::zeroed();
let status = ml_patient_bladder_metrics(patient as *const MLPatient, metrics.as_mut_ptr());
assert_eq!(status, ML_ERR);
ml_patient_free(patient);
}
}
+21
View File
@@ -29,3 +29,24 @@ fn ffi_errors() {
let s = medicallib_rust::ffi::ml_patient_summary(std::ptr::null());
assert!(s.is_null());
}
#[cfg(feature = "ffi")]
#[test]
fn ffi_bloodstream_metrics() {
use medicallib_rust::ffi::*;
use std::ffi::CString;
let id = CString::new("ffi-blood").unwrap();
let p = ml_patient_new(id.as_ptr());
assert!(!p.is_null());
let mut metrics = std::mem::MaybeUninit::<MLBloodstreamMetrics>::uninit();
assert_eq!(ml_patient_update(p, 0.5), ML_OK);
let rc = ml_patient_bloodstream_metrics(p, metrics.as_mut_ptr());
assert_eq!(rc, ML_OK);
let metrics = unsafe { metrics.assume_init() };
assert!(metrics.oncotic_pressure_mm_hg > 15.0);
assert!(metrics.rbc_mature_fraction > 0.4);
ml_patient_free(p);
}
+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"));
}