Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 31a0b8a485 | |||
| 5cf6bbda48 | |||
| bf1e547a8c | |||
| a74f9c408b | |||
| 0e6365bf7f | |||
| a7638c411a | |||
| 886484919d | |||
| 21b9ca894f | |||
| d849f71127 | |||
| f439894864 | |||
| dea5049be5 | |||
| 052eeb447c | |||
| 8f4fabd630 | |||
| e548c13f8c | |||
| cde8b70bd3 |
@@ -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
|
||||
|
||||
@@ -84,16 +84,45 @@ jobs:
|
||||
echo "Last tag/commit: $LAST_TAG"
|
||||
|
||||
- name: Generate release notes
|
||||
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
|
||||
|
||||
# Generate simple release notes
|
||||
cat > release_notes.md << EOF
|
||||
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 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 and Changes:
|
||||
$GIT_LOG"
|
||||
|
||||
RESPONSE=$(curl -s -X POST "https://api.openai.com/v1/responses" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||
-d "{
|
||||
\"model\": \"gpt-5\",
|
||||
\"input\": $(echo "$PROMPT" | jq -Rs .)
|
||||
}")
|
||||
|
||||
# Check if the API call was successful and extract the response
|
||||
if echo "$RESPONSE" | jq -e '.output[1].content[0].text' > /dev/null 2>&1; then
|
||||
echo "$RESPONSE" | jq -r '.output[1].content[0].text' > release_notes.md
|
||||
echo "Generated AI release notes successfully"
|
||||
else
|
||||
echo "AI generation failed, falling back to simple notes"
|
||||
echo "API Response: $RESPONSE"
|
||||
# Fallback to simple notes
|
||||
cat > release_notes.md << EOF
|
||||
## Release ${{ steps.version.outputs.version }}
|
||||
|
||||
### Changes
|
||||
@@ -102,8 +131,22 @@ jobs:
|
||||
---
|
||||
*Generated automatically from commit history*
|
||||
EOF
|
||||
fi
|
||||
else
|
||||
echo "No OpenAI API key provided, generating simple release notes"
|
||||
# Fallback to simple notes
|
||||
cat > release_notes.md << EOF
|
||||
## Release ${{ steps.version.outputs.version }}
|
||||
|
||||
echo "Generated release notes:"
|
||||
### Changes
|
||||
$GIT_LOG
|
||||
|
||||
---
|
||||
*Generated automatically from commit history*
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Final release notes:"
|
||||
cat release_notes.md
|
||||
|
||||
- name: Prepare release assets
|
||||
|
||||
@@ -17,3 +17,5 @@ coverage/
|
||||
|
||||
|
||||
.claude/settings.local.json
|
||||
ffi/medicallib.h
|
||||
todo.md
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "medicallib_rust"
|
||||
version = "0.1.0"
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 pressure–volume curve.[58]
|
||||
- Diffusing capacity is estimated by linear clamps, whereas normal DL(O₂) ≈ 20–25 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 75–95 mmHg for 3–7 days and targeting spinal cord perfusion pressure ≥60–65 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 1–2 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 (hours–weeks) 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 ~2–3 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]
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@ pub enum OrganType {
|
||||
Heart,
|
||||
/// Lungs
|
||||
Lungs,
|
||||
/// Circulating blood volume and transport network
|
||||
Bloodstream,
|
||||
/// Brain
|
||||
Brain,
|
||||
/// Spinal cord
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user