From 0502c756c1c3796b82cd81d747539a75b758ffc1 Mon Sep 17 00:00:00 2001 From: Zack3D Date: Sun, 21 Sep 2025 02:14:44 -0700 Subject: [PATCH] init --- .gitignore | 17 ++++ ARCHITECTURE.md | 16 ++++ Cargo.toml | 37 ++++++++ INSTALL.md | 13 +++ MIGRATION.md | 17 ++++ Makefile | 6 ++ README.md | 46 ++++++++++ benches/heart.rs | 14 +++ examples/c/ffi_example.c | 26 ++++++ examples/patient.rs | 8 ++ examples/tracing_demo.rs | 18 ++++ examples/usage.rs | 8 ++ ffi/medicallib.h | 55 ++++++++++++ src/error.rs | 23 +++++ src/ffi.rs | 117 +++++++++++++++++++++++++ src/lib.rs | 162 +++++++++++++++++++++++++++++++++++ src/organs/bladder.rs | 29 +++++++ src/organs/brain.rs | 34 ++++++++ src/organs/esophagus.rs | 26 ++++++ src/organs/gallbladder.rs | 25 ++++++ src/organs/heart.rs | 56 ++++++++++++ src/organs/intestines.rs | 32 +++++++ src/organs/kidneys.rs | 29 +++++++ src/organs/liver.rs | 30 +++++++ src/organs/lungs.rs | 39 +++++++++ src/organs/mod.rs | 58 +++++++++++++ src/organs/pancreas.rs | 25 ++++++ src/organs/spinal_cord.rs | 27 ++++++ src/organs/spleen.rs | 25 ++++++ src/organs/stomach.rs | 25 ++++++ src/patient.rs | 176 ++++++++++++++++++++++++++++++++++++++ src/types.rs | 92 ++++++++++++++++++++ tests/basic.rs | 8 ++ tests/ffi.rs | 35 ++++++++ tests/patient.rs | 8 ++ 35 files changed, 1362 insertions(+) create mode 100644 .gitignore create mode 100644 ARCHITECTURE.md create mode 100644 Cargo.toml create mode 100644 INSTALL.md create mode 100644 MIGRATION.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 benches/heart.rs create mode 100644 examples/c/ffi_example.c create mode 100644 examples/patient.rs create mode 100644 examples/tracing_demo.rs create mode 100644 examples/usage.rs create mode 100644 ffi/medicallib.h create mode 100644 src/error.rs create mode 100644 src/ffi.rs create mode 100644 src/lib.rs create mode 100644 src/organs/bladder.rs create mode 100644 src/organs/brain.rs create mode 100644 src/organs/esophagus.rs create mode 100644 src/organs/gallbladder.rs create mode 100644 src/organs/heart.rs create mode 100644 src/organs/intestines.rs create mode 100644 src/organs/kidneys.rs create mode 100644 src/organs/liver.rs create mode 100644 src/organs/lungs.rs create mode 100644 src/organs/mod.rs create mode 100644 src/organs/pancreas.rs create mode 100644 src/organs/spinal_cord.rs create mode 100644 src/organs/spleen.rs create mode 100644 src/organs/stomach.rs create mode 100644 src/patient.rs create mode 100644 src/types.rs create mode 100644 tests/basic.rs create mode 100644 tests/ffi.rs create mode 100644 tests/patient.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d08c861 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +target/ +dist/ +**/*.rs.bk +Cargo.lock +.DS_Store +*.pdb +*.dSYM/ +*.exe +*.dll +*.so +*.dylib +.idea/ +.vscode/ +*.log +node_modules/ +coverage/ + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..da3d510 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,16 @@ +# Architecture Overview + +- Crate facade in `src/lib.rs` re-exports curated types. +- Error handling with `thiserror` via `MedicalError` and `type Result`. +- Core domain types in `src/types.rs` (e.g., `BloodPressure`, `Blood`). +- Organ system: + - Trait and common `OrganInfo` in `src/organs/mod.rs` + - Per-organ modules (Heart, Lungs, Brain, etc.) implement `Organ` +- `Patient` in `src/patient.rs` owns a vector of `Box` and coordinates updates. +- FFI (feature `ffi`) in `src/ffi.rs`, header `ffi/medicallib.h`. +- Examples in `examples/`, benches in `benches/` (criterion). + +Inter-organ communication examples: +- Lungs SpO2 influences Heart rate. +- Kidneys GFR produces urine into Bladder volume. + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aecd405 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "medicallib_rust" +version = "0.1.0" +edition = "2021" +description = "MedicalSim core library rewrite in Rust: basic clinical calculations and types." +authors = ["MedicalSim Team"] +license = "UNLICENSED" +readme = "README.md" + +[lib] +crate-type = ["rlib", "cdylib"] + +[features] +default = [] +serde = ["dep:serde"] +ffi = [] + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"], optional = true } +tracing = { version = "0.1", optional = true } + +[dev-dependencies] +criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } +tracing-subscriber = { version = "0.3" } + +[[bench]] +name = "heart" +harness = false + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 3 + +[profile.dev] +opt-level = 1 diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..a98508b --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,13 @@ +# Installation and Usage (C/C++) + +1. Build the cdylib: + - `cargo build --features ffi --release` + - Outputs under `target/release/` (`.so`/`.dll`/`.dylib`) +2. Include header `ffi/medicallib.h` in your C/C++ project. +3. Link your app against the produced library. +4. See `examples/c/ffi_example.c` for usage. + +Notes: +- Free all strings returned by the library with `ml_string_free`. +- Use the organ type codes (ML_ORGAN_*) to query specific organ summaries. + diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..6cde82f --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,17 @@ +# Migration Guide: C++ MedicalSim -> medicallib_rust + +This guide summarizes the mapping and differences between the legacy C++ library and the new Rust core. + +- Organ base class -> Rust `Organ` trait (`src/organs/mod.rs`) +- Organ implementations -> dedicated structs per organ (e.g., Heart, Lungs) +- `initializePatient()` -> `Patient::initialize_default()` or `Patient::with_heart(leads)` +- `updatePatient(dt)` -> `Patient::update(dt_seconds)` +- Template organ getters -> `Patient::find_organ_typed::()` and `find_organ_typed_mut::()` +- C API -> feature `ffi`, header `ffi/medicallib.h`, with `MLPatient*` opaque handle + +Notable differences: +- Memory safety and ownership are enforced by Rust; no raw pointer ownership in the library +- Errors use `thiserror` with `Result` +- Logging is not emitted by the library by default; use `tracing` in binaries/examples +- Additional organ systems are modeled with simplified states as a baseline + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3dad6aa --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +PACKAGE_TARGET?= + +.PHONY: package +package: + @bash ../scripts/package.sh $(PACKAGE_TARGET) + diff --git a/README.md b/README.md new file mode 100644 index 0000000..81746bb --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# medicallib_rust + +MedicalSim core library rewrite in Rust. + +- Safe, minimal API for medical simulation primitives +- Optional `serde` feature for serialization +- Optional `ffi` feature to expose a stable C ABI (`cdylib`) + +## Build + +- Build: `cargo build` (tests: `cargo test`) +- FFI build: `cargo build --features ffi --release` +- Format/Lint: `cargo fmt --all` and `cargo clippy -D warnings` + +## Examples + +- Rust: `cargo run --example usage` and `cargo run --example patient` +- C FFI: see `examples/c/ffi_example.c`, header in `ffi/medicallib.h` + +## FFI ABI + +- Opaque `MLPatient` handle +- Functions: `medicallib_bmi`, `ml_patient_new/free/update`, `ml_patient_summary`, `ml_patient_organ_summary` +- All allocated strings must be freed with `ml_string_free` + +## Version Matrix + +- Rust 1.70+ (Edition 2021) +- OS: Linux, macOS, Windows (see GitHub Actions CI) + +## Docs + +- Architecture: `ARCHITECTURE.md` +- Migration (C++ -> Rust): `MIGRATION.md` +- C/C++ install: `INSTALL.md` + +## Packaging (binary distribution) + +- Linux/macOS: run `../scripts/package.sh [target-triple]` from `medicallib_rust/` (or `make package`). +- Windows: run `..\scripts\package.ps1 [target-triple]` from `medicallib_rust\`. +- Artifacts are placed in `medicallib_rust/dist/` as both `.tar.gz` (Unix) and `.zip`. + +## CI: GitHub + Gitea + +- GitHub Actions workflow: `.github/workflows/ci.yml` +- Gitea Actions workflow: `.gitea/workflows/ci.yml` diff --git a/benches/heart.rs b/benches/heart.rs new file mode 100644 index 0000000..c2c7f66 --- /dev/null +++ b/benches/heart.rs @@ -0,0 +1,14 @@ +use criterion::{criterion_group, criterion_main, Criterion}; + +fn bench_heart_update(c: &mut Criterion) { + c.bench_function("heart_update_1s", |b| { + b.iter(|| { + let mut h = medicallib_rust::Heart::new("h", 12); + h.update(1.0); + }) + }); +} + +criterion_group!(benches, bench_heart_update); +criterion_main!(benches); + diff --git a/examples/c/ffi_example.c b/examples/c/ffi_example.c new file mode 100644 index 0000000..4ab1a7e --- /dev/null +++ b/examples/c/ffi_example.c @@ -0,0 +1,26 @@ +#include +#include +#include "../../ffi/medicallib.h" + +int main(void) { + float bmi = 0.0f; + if (medicallib_bmi(70.0f, 1.75f, &bmi) != ML_OK) { + fprintf(stderr, "BMI error\n"); + return 1; + } + printf("BMI: %.2f\n", bmi); + + MLPatient* p = ml_patient_new("ffi-01"); + if (!p) { fprintf(stderr, "patient new failed\n"); return 1; } + ml_patient_update(p, 0.5f); + + char* sum = ml_patient_summary(p); + if (sum) { printf("%s\n", sum); ml_string_free(sum); } + + char* h = ml_patient_organ_summary(p, ML_ORGAN_HEART); + if (h) { printf("%s\n", h); ml_string_free(h); } + + ml_patient_free(p); + return 0; +} + diff --git a/examples/patient.rs b/examples/patient.rs new file mode 100644 index 0000000..a579ba3 --- /dev/null +++ b/examples/patient.rs @@ -0,0 +1,8 @@ +fn main() { + let mut p = medicallib_rust::Patient::new("demo-01") + .expect("valid id") + .initialize_default(); + p.update(0.5); + println!("{}", p.patient_summary()); +} + diff --git a/examples/tracing_demo.rs b/examples/tracing_demo.rs new file mode 100644 index 0000000..a92ed73 --- /dev/null +++ b/examples/tracing_demo.rs @@ -0,0 +1,18 @@ +fn main() { + // Consumer decides logging; library remains silent by default. + #[cfg(feature = "tracing")] + { + use tracing::{info, Level}; + use tracing_subscriber::FmtSubscriber; + let subscriber = FmtSubscriber::builder().with_max_level(Level::INFO).finish(); + let _ = tracing::subscriber::set_global_default(subscriber); + info!("starting simulation"); + } + + let mut p = medicallib_rust::Patient::new("trace-demo").unwrap() + .initialize_default() + .with_lungs(); + p.update(0.2); + println!("{}", p.patient_summary()); +} + diff --git a/examples/usage.rs b/examples/usage.rs new file mode 100644 index 0000000..78a8f93 --- /dev/null +++ b/examples/usage.rs @@ -0,0 +1,8 @@ +fn main() { + let weight = 70.0_f32; + let height = 1.75_f32; + let bmi = medicallib_rust::calculate_bmi(weight, height).expect("valid inputs"); + let class = medicallib_rust::classify_bmi(bmi); + println!("BMI: {:.2} kg/m^2 — {:?}", bmi, class); +} + diff --git a/ffi/medicallib.h b/ffi/medicallib.h new file mode 100644 index 0000000..bb6ae00 --- /dev/null +++ b/ffi/medicallib.h @@ -0,0 +1,55 @@ +// Minimal C header for medicallib_rust FFI +// Build with: cargo build --features ffi --release +// Link your C app against the produced cdylib and ensure the header codes +// match the Rust definitions. + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +// Return codes +#define ML_OK 0 +#define ML_ERR 1 +#define ML_EINVAL 2 + +typedef struct MLPatient MLPatient; // opaque + +// Organ codes (keep in sync with Rust) +#define ML_ORGAN_HEART 0 +#define ML_ORGAN_LUNGS 1 +#define ML_ORGAN_BRAIN 2 +#define ML_ORGAN_SPINAL_CORD 3 +#define ML_ORGAN_STOMACH 4 +#define ML_ORGAN_LIVER 5 +#define ML_ORGAN_GALLBLADDER 6 +#define ML_ORGAN_PANCREAS 7 +#define ML_ORGAN_INTESTINES 8 +#define ML_ORGAN_ESOPHAGUS 9 +#define ML_ORGAN_KIDNEYS 10 +#define ML_ORGAN_BLADDER 11 +#define ML_ORGAN_SPLEEN 12 + +// BMI computation. Returns 0 on success, writes to out_bmi. +int32_t medicallib_bmi(float weight_kg, float height_m, float* out_bmi); + +// Patient lifecycle +MLPatient* ml_patient_new(const char* id); +void ml_patient_free(MLPatient* p); + +// Returns a newly-allocated C string. Free with ml_string_free. +char* ml_patient_summary(const MLPatient* p); +void ml_string_free(char* s); + +// Advance simulation by dt seconds. +int32_t ml_patient_update(MLPatient* p, float dt_seconds); + +// Get summary for a specific organ type code. +char* ml_patient_organ_summary(const MLPatient* p, uint32_t organ_code); + +#ifdef __cplusplus +} +#endif diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..3ede28f --- /dev/null +++ b/src/error.rs @@ -0,0 +1,23 @@ +//! Error types used by the `medicallib_rust` library. + +use thiserror::Error; + +/// Library-wide error type. +#[derive(Debug, Error)] +pub enum MedicalError { + /// Input validation failed (e.g., negative or zero values). + #[error("invalid input: {0}")] + InvalidInput(&'static str), + /// Domain validation failed with a message. + #[error("validation error: {0}")] + Validation(&'static str), + /// Requested item not found (e.g., organ by type or id). + #[error("not found: {0}")] + NotFound(&'static str), + /// Parsing or conversion error for textual inputs. + #[error("parse error: {0}")] + Parse(&'static str), + /// FFI boundary error with a message safe to expose cross-language. + #[error("ffi error: {0}")] + Ffi(&'static str), +} diff --git a/src/ffi.rs b/src/ffi.rs new file mode 100644 index 0000000..99baf17 --- /dev/null +++ b/src/ffi.rs @@ -0,0 +1,117 @@ +//! C-compatible FFI interface. Enable with `--features ffi`. +//! +//! Safety: All functions validate pointers and return error codes where +//! applicable. Callers must free returned strings via `ml_string_free` and +//! destroy opaque handles via their corresponding `free` functions. + +#![allow(unsafe_code)] +#![allow(clippy::not_unsafe_ptr_arg_deref)] + +use core::ffi::{c_char, CStr}; +use std::ffi::CString; +use std::ptr; + +use crate::patient::Patient; +use crate::types::OrganType; + +/// Successful return code. +pub const ML_OK: i32 = 0; +/// Generic error. +pub const ML_ERR: i32 = 1; +/// Invalid argument. +pub const ML_EINVAL: i32 = 2; + +/// Opaque patient handle type for C consumers. +#[repr(C)] +pub struct MLPatient { + inner: Patient, +} + +/// Compute BMI; returns 0 on success, non-zero on error. +/// On success writes result to `out_bmi`. +#[no_mangle] +pub extern "C" fn medicallib_bmi(weight_kg: f32, height_m: f32, out_bmi: *mut f32) -> i32 { + if out_bmi.is_null() { return ML_EINVAL; } + match crate::calculate_bmi(weight_kg, height_m) { + Ok(v) => unsafe { + *out_bmi = v; + ML_OK + }, + Err(_) => ML_ERR, + } +} + +/// Create a new patient with id string. Returns null on error. +#[no_mangle] +pub extern "C" fn ml_patient_new(id: *const c_char) -> *mut MLPatient { + if id.is_null() { return ptr::null_mut(); } + let cstr = unsafe { CStr::from_ptr(id) }; + match cstr.to_str() { + Ok(s) => match Patient::new(s) { + Ok(p) => Box::into_raw(Box::new(MLPatient { inner: p.initialize_default() })), + Err(_) => ptr::null_mut(), + }, + Err(_) => ptr::null_mut(), + } +} + +/// Destroy a patient handle. Accepts null. +#[no_mangle] +pub extern "C" fn ml_patient_free(p: *mut MLPatient) { + if p.is_null() { return; } + unsafe { drop(Box::from_raw(p)); } +} + +/// Get a newly-allocated C string summary for the patient. +/// Returns null on error. Free the returned string with `ml_string_free`. +#[no_mangle] +pub extern "C" fn ml_patient_summary(p: *const MLPatient) -> *mut c_char { + if p.is_null() { return ptr::null_mut(); } + let summary = unsafe { (*p).inner.patient_summary() }; + match CString::new(summary) { + Ok(s) => s.into_raw(), + Err(_) => ptr::null_mut(), + } +} + +/// Frees a C string previously returned by this library. Accepts null. +#[no_mangle] +pub extern "C" fn ml_string_free(s: *mut c_char) { + if s.is_null() { return; } + unsafe { drop(CString::from_raw(s)); } +} + +/// Advance patient simulation by dt seconds. +#[no_mangle] +pub extern "C" fn ml_patient_update(p: *mut MLPatient, dt_seconds: f32) -> i32 { + if p.is_null() { return ML_EINVAL; } + unsafe { (*p).inner.update(dt_seconds); } + ML_OK +} + +/// 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 { + if p.is_null() { return ptr::null_mut(); } + let kind = match organ_code { + 0 => OrganType::Heart, + 1 => OrganType::Lungs, + 2 => OrganType::Brain, + 3 => OrganType::SpinalCord, + 4 => OrganType::Stomach, + 5 => OrganType::Liver, + 6 => OrganType::Gallbladder, + 7 => OrganType::Pancreas, + 8 => OrganType::Intestines, + 9 => OrganType::Esophagus, + 10 => OrganType::Kidneys, + 11 => OrganType::Bladder, + 12 => OrganType::Spleen, + _ => return ptr::null_mut(), + }; + let summary = unsafe { (*p).inner.organ_summary(kind) }; + match summary { + Ok(s) => match CString::new(s) { Ok(cs) => cs.into_raw(), Err(_) => ptr::null_mut() }, + Err(_) => ptr::null_mut(), + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..346e21f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,162 @@ +//! MedicalSim core library rewrite in Rust. +//! +//! This crate provides foundational types and utilities for medical +//! simulations and calculations. The initial surface focuses on +//! basic, well-defined helpers (e.g., BMI) and composable domain types, +//! with room to expand to vitals processing, scoring systems, and +//! scenarios. +//! +//! Examples +//! ``` +//! // Calculate BMI and classify it. +//! let bmi = medicallib_rust::calculate_bmi(70.0, 1.75).unwrap(); +//! assert!((bmi - 22.857145).abs() < 1e-5); +//! let class = medicallib_rust::classify_bmi(bmi); +//! assert_eq!(class, medicallib_rust::BMIClassification::Normal); +//! ``` +//! +//! ``` +//! // Initialize a patient with a heart and update. +//! let mut p = medicallib_rust::Patient::new("case-01").unwrap().initialize_default(); +//! p.update(0.1); +//! let summary = p.patient_summary(); +//! assert!(summary.contains("Patient[id=case-01")); +//! ``` + +#![deny(unsafe_code)] +#![warn(missing_docs, rust_2018_idioms, missing_debug_implementations)] + +mod error; +mod types; +mod organs; +mod patient; + +#[cfg(feature = "ffi")] +pub mod ffi; + +pub use crate::error::MedicalError; +pub use crate::organs::{Heart, Organ}; +pub use crate::patient::Patient; +pub use crate::types::{Blood, BloodPressure, OrganType}; + +/// Convenient result alias for this library. +pub type Result = core::result::Result; + +/// Common vital signs. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum VitalSign { + /// Heart rate (beats per minute). + HeartRate, + /// Respiratory rate (breaths per minute). + RespiratoryRate, + /// Systolic blood pressure (mmHg). + SystolicBP, + /// Diastolic blood pressure (mmHg). + DiastolicBP, + /// Body temperature (°C). + TemperatureC, + /// Peripheral capillary oxygen saturation (%). + SpO2, +} + +/// A simple measurement wrapper carrying a unit label. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct Measurement { + /// Numeric value of the measurement. + pub value: T, + /// Unit string (e.g., "bpm", "mmHg", "kg/m^2"). + pub unit: &'static str, +} + +impl Measurement { + /// Creates a new measurement value with a unit. + pub const fn new(value: T, unit: &'static str) -> Self { + Self { value, unit } + } +} + +/// WHO BMI classification bands. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BMIClassification { + /// BMI < 18.5 + Underweight, + /// 18.5 ≤ BMI < 25.0 + Normal, + /// 25.0 ≤ BMI < 30.0 + Overweight, + /// 30.0 ≤ BMI < 35.0 + ObesityClass1, + /// 35.0 ≤ BMI < 40.0 + ObesityClass2, + /// BMI ≥ 40.0 + ObesityClass3, +} + +/// Calculate Body Mass Index (BMI) given weight (kg) and height (m). +/// +/// Returns an error when `weight_kg <= 0` or `height_m <= 0`. +/// +/// The result is dimensionally `kg/m^2`. +pub fn calculate_bmi(weight_kg: f32, height_m: f32) -> Result { + if !(weight_kg.is_finite() && height_m.is_finite()) { + return Err(MedicalError::InvalidInput("inputs must be finite numbers")); + } + if weight_kg <= 0.0 { + return Err(MedicalError::InvalidInput("weight_kg must be > 0")); + } + if height_m <= 0.0 { + return Err(MedicalError::InvalidInput("height_m must be > 0")); + } + Ok(weight_kg / (height_m * height_m)) +} + +/// Classify BMI according to WHO bands. +pub fn classify_bmi(bmi: f32) -> BMIClassification { + match bmi { + x if x < 18.5 => BMIClassification::Underweight, + x if x < 25.0 => BMIClassification::Normal, + x if x < 30.0 => BMIClassification::Overweight, + x if x < 35.0 => BMIClassification::ObesityClass1, + x if x < 40.0 => BMIClassification::ObesityClass2, + _ => BMIClassification::ObesityClass3, + } +} + +/// Helper to expose BMI as a typed measurement with unit `kg/m^2`. +pub fn bmi_measurement(weight_kg: f32, height_m: f32) -> Result> { + calculate_bmi(weight_kg, height_m).map(|v| Measurement::new(v, "kg/m^2")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bmi_calculation_and_classification() { + let bmi = calculate_bmi(70.0, 1.75).unwrap(); + assert!((bmi - 22.857145).abs() < 1e-5); + assert_eq!(classify_bmi(bmi), BMIClassification::Normal); + let m = bmi_measurement(70.0, 1.75).unwrap(); + assert_eq!(m.unit, "kg/m^2"); + } + + #[test] + fn bmi_invalid_inputs() { + assert!(calculate_bmi(0.0, 1.7).is_err()); + assert!(calculate_bmi(70.0, 0.0).is_err()); + assert!(calculate_bmi(f32::NAN, 1.7).is_err()); + } + + #[test] + fn patient_and_organs() { + let mut p = Patient::new("case_01").unwrap().initialize_default(); + p.update(0.2); + let s = p.patient_summary(); + assert!(s.contains("Patient[id=case_01")); + let heart = p.find_organ_typed::().unwrap(); + assert_eq!(heart.organ_type(), OrganType::Heart); + } +} diff --git a/src/organs/bladder.rs b/src/organs/bladder.rs new file mode 100644 index 0000000..4286330 --- /dev/null +++ b/src/organs/bladder.rs @@ -0,0 +1,29 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Bladder { + info: OrganInfo, + /// Urine volume ml + pub volume_ml: f32, + /// Pressure proxy (cmH2O) + pub pressure: f32, +} + +impl Bladder { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Bladder), volume_ml: 100.0, pressure: 5.0 } + } +} + +impl Organ for Bladder { + fn id(&self) -> &str { self.info.id() } + 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 summary(&self) -> String { format!("Bladder[id={}, vol={:.0} ml, P={:.1}]", self.id(), self.volume_ml, self.pressure) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} diff --git a/src/organs/brain.rs b/src/organs/brain.rs new file mode 100644 index 0000000..28e9326 --- /dev/null +++ b/src/organs/brain.rs @@ -0,0 +1,34 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Brain { + info: OrganInfo, + /// 0..=100 scale of consciousness. + pub consciousness: u8, + /// Simplified neural activity index. + pub activity_index: f32, +} + +impl Brain { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Brain), consciousness: 100, activity_index: 1.0 } + } +} + +impl Organ for Brain { + fn id(&self) -> &str { self.info.id() } + 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 summary(&self) -> String { + format!( + "Brain[id={}, GCS~{}, activity={:.2}]", + self.id(), self.consciousness, self.activity_index + ) + } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} + diff --git a/src/organs/esophagus.rs b/src/organs/esophagus.rs new file mode 100644 index 0000000..a5fb5d7 --- /dev/null +++ b/src/organs/esophagus.rs @@ -0,0 +1,26 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Esophagus { + info: OrganInfo, + /// Reflux severity 0..=100 + pub reflux: u8, +} + +impl Esophagus { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Esophagus), reflux: 0 } + } +} + +impl Organ for Esophagus { + fn id(&self) -> &str { self.info.id() } + fn organ_type(&self) -> OrganType { self.info.kind() } + fn update(&mut self, _dt_seconds: f32) { + self.reflux = self.reflux.min(100); + } + fn summary(&self) -> String { format!("Esophagus[id={}, reflux={}]", self.id(), self.reflux) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} diff --git a/src/organs/gallbladder.rs b/src/organs/gallbladder.rs new file mode 100644 index 0000000..78b9c59 --- /dev/null +++ b/src/organs/gallbladder.rs @@ -0,0 +1,25 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Gallbladder { + info: OrganInfo, + /// Bile volume ml + pub bile_ml: f32, +} + +impl Gallbladder { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Gallbladder), bile_ml: 30.0 } + } +} + +impl Organ for Gallbladder { + fn id(&self) -> &str { self.info.id() } + fn organ_type(&self) -> OrganType { self.info.kind() } + fn update(&mut self, _dt_seconds: f32) {} + fn summary(&self) -> String { format!("Gallbladder[id={}, bile={:.0} ml]", self.id(), self.bile_ml) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} + diff --git a/src/organs/heart.rs b/src/organs/heart.rs new file mode 100644 index 0000000..1dbcb69 --- /dev/null +++ b/src/organs/heart.rs @@ -0,0 +1,56 @@ +use super::{Organ, OrganInfo}; +use crate::types::{BloodPressure, OrganType}; + +#[derive(Debug, Clone)] +pub struct Heart { + info: OrganInfo, + pub heart_rate_bpm: f32, + pub arterial_bp: BloodPressure, + pub leads: u8, + /// Simplified arrhythmia flag; increases HR variability. + pub arrhythmia: bool, +} + +impl Heart { + pub fn new(id: impl Into, leads: u8) -> Self { + Self { + info: OrganInfo::new(id, OrganType::Heart), + heart_rate_bpm: 70.0, + arterial_bp: BloodPressure::default(), + leads, + arrhythmia: false, + } + } +} + +impl Organ for Heart { + fn id(&self) -> &str { self.info.id() } + fn organ_type(&self) -> OrganType { self.info.kind() } + fn update(&mut self, dt_seconds: f32) { + let dt = dt_seconds.max(0.0).min(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); + } + 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)); + } + fn summary(&self) -> String { + format!( + "Heart[id={}, leads={}, HR={:.1} bpm, BP={}/{} mmHg]", + self.id(), + self.leads, + self.heart_rate_bpm, + self.arterial_bp.systolic, + self.arterial_bp.diastolic + ) + } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} diff --git a/src/organs/intestines.rs b/src/organs/intestines.rs new file mode 100644 index 0000000..31fdf3e --- /dev/null +++ b/src/organs/intestines.rs @@ -0,0 +1,32 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Intestines { + info: OrganInfo, + /// Nutrient absorption rate 0..=100 + pub absorption: u8, + pub peristalsis: bool, +} + +impl Intestines { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Intestines), absorption: 80, peristalsis: true } + } +} + +impl Organ for Intestines { + fn id(&self) -> &str { self.info.id() } + fn organ_type(&self) -> OrganType { 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; + } + } + fn summary(&self) -> String { format!("Intestines[id={}, absorption={}]", self.id(), self.absorption) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} diff --git a/src/organs/kidneys.rs b/src/organs/kidneys.rs new file mode 100644 index 0000000..713bc41 --- /dev/null +++ b/src/organs/kidneys.rs @@ -0,0 +1,29 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Kidneys { + info: OrganInfo, + /// Filtration rate ml/min + pub gfr: f32, + /// Electrolyte balance index -1..=1 + pub electrolyte_balance: f32, +} + +impl Kidneys { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Kidneys), gfr: 100.0, electrolyte_balance: 0.0 } + } +} + +impl Organ for Kidneys { + fn id(&self) -> &str { self.info.id() } + 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 summary(&self) -> String { format!("Kidneys[id={}, GFR={:.0} ml/min]", self.id(), self.gfr) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} diff --git a/src/organs/liver.rs b/src/organs/liver.rs new file mode 100644 index 0000000..4536981 --- /dev/null +++ b/src/organs/liver.rs @@ -0,0 +1,30 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Liver { + info: OrganInfo, + /// Detox capacity 0..=100 + pub detox: u8, + /// Metabolism index + pub metabolism: f32, + /// Enzyme production index + pub enzymes: f32, +} + +impl Liver { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Liver), detox: 100, metabolism: 1.0, enzymes: 1.0 } + } +} + +impl Organ for Liver { + fn id(&self) -> &str { self.info.id() } + 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 summary(&self) -> String { format!("Liver[id={}, detox={}, k={:.2}, enz={:.2}]", self.id(), self.detox, self.metabolism, self.enzymes) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} diff --git a/src/organs/lungs.rs b/src/organs/lungs.rs new file mode 100644 index 0000000..081de08 --- /dev/null +++ b/src/organs/lungs.rs @@ -0,0 +1,39 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Lungs { + info: OrganInfo, + pub respiratory_rate_bpm: f32, + pub spo2_pct: f32, + /// Respiratory distress flag reduces SpO2. + pub distress: bool, +} + +impl Lungs { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Lungs), respiratory_rate_bpm: 14.0, spo2_pct: 98.0, distress: false } + } +} + +impl Organ for Lungs { + fn id(&self) -> &str { self.info.id() } + fn organ_type(&self) -> OrganType { self.info.kind() } + fn update(&mut self, dt_seconds: f32) { + let dt = dt_seconds.max(0.0).min(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); } + // keep spo2 in [70, 100] + self.spo2_pct = self.spo2_pct.clamp(70.0, 100.0); + } + fn summary(&self) -> String { + format!( + "Lungs[id={}, RR={:.1} bpm, SpO2={:.0}%]", + self.id(), self.respiratory_rate_bpm, self.spo2_pct + ) + } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} diff --git a/src/organs/mod.rs b/src/organs/mod.rs new file mode 100644 index 0000000..0808e29 --- /dev/null +++ b/src/organs/mod.rs @@ -0,0 +1,58 @@ +//! Organ system trait and implementations. + +use crate::types::{BloodPressure, OrganType}; +use core::fmt::Debug; + +/// Common metadata for organs. +#[derive(Debug, Clone)] +pub struct OrganInfo { + id: String, + kind: OrganType, +} + +impl OrganInfo { + pub fn new(id: impl Into, kind: OrganType) -> Self { + Self { id: id.into(), kind } + } + pub fn id(&self) -> &str { &self.id } + pub fn kind(&self) -> OrganType { self.kind } +} + +/// Trait implemented by all organs. +pub trait Organ: Debug + Send { + fn id(&self) -> &str; + fn organ_type(&self) -> OrganType; + fn update(&mut self, dt_seconds: f32); + fn summary(&self) -> String; + fn as_any(&self) -> &dyn core::any::Any; + fn as_any_mut(&mut self) -> &mut dyn core::any::Any; +} + +mod heart; +mod lungs; +mod brain; +mod spinal_cord; +mod stomach; +mod liver; +mod gallbladder; +mod pancreas; +mod intestines; +mod esophagus; +mod kidneys; +mod bladder; +mod spleen; + +pub use heart::Heart; +pub use lungs::Lungs; +pub use brain::Brain; +pub use spinal_cord::SpinalCord; +pub use stomach::Stomach; +pub use liver::Liver; +pub use gallbladder::Gallbladder; +pub use pancreas::Pancreas; +pub use intestines::Intestines; +pub use esophagus::Esophagus; +pub use kidneys::Kidneys; +pub use bladder::Bladder; +pub use spleen::Spleen; + diff --git a/src/organs/pancreas.rs b/src/organs/pancreas.rs new file mode 100644 index 0000000..5650b44 --- /dev/null +++ b/src/organs/pancreas.rs @@ -0,0 +1,25 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Pancreas { + info: OrganInfo, + /// Insulin secretion index + pub insulin: f32, +} + +impl Pancreas { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Pancreas), insulin: 1.0 } + } +} + +impl Organ for Pancreas { + fn id(&self) -> &str { self.info.id() } + fn organ_type(&self) -> OrganType { self.info.kind() } + fn update(&mut self, _dt_seconds: f32) {} + fn summary(&self) -> String { format!("Pancreas[id={}, insulin={:.2}]", self.id(), self.insulin) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} + diff --git a/src/organs/spinal_cord.rs b/src/organs/spinal_cord.rs new file mode 100644 index 0000000..13d7aa7 --- /dev/null +++ b/src/organs/spinal_cord.rs @@ -0,0 +1,27 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct SpinalCord { + info: OrganInfo, + /// 0..=100 nerve signal integrity. + pub signal_integrity: u8, + pub injury: bool, +} + +impl SpinalCord { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::SpinalCord), signal_integrity: 100, injury: false } + } +} + +impl Organ for SpinalCord { + fn id(&self) -> &str { self.info.id() } + 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 summary(&self) -> String { format!("SpinalCord[id={}, integrity={}]", self.id(), self.signal_integrity) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} diff --git a/src/organs/spleen.rs b/src/organs/spleen.rs new file mode 100644 index 0000000..67923bc --- /dev/null +++ b/src/organs/spleen.rs @@ -0,0 +1,25 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Spleen { + info: OrganInfo, + /// Immune activity 0..=100 + pub immune_activity: u8, +} + +impl Spleen { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Spleen), immune_activity: 80 } + } +} + +impl Organ for Spleen { + fn id(&self) -> &str { self.info.id() } + fn organ_type(&self) -> OrganType { self.info.kind() } + fn update(&mut self, _dt_seconds: f32) {} + fn summary(&self) -> String { format!("Spleen[id={}, immune={}]", self.id(), self.immune_activity) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} + diff --git a/src/organs/stomach.rs b/src/organs/stomach.rs new file mode 100644 index 0000000..1ef6ec9 --- /dev/null +++ b/src/organs/stomach.rs @@ -0,0 +1,25 @@ +use super::{Organ, OrganInfo}; +use crate::types::OrganType; + +#[derive(Debug, Clone)] +pub struct Stomach { + info: OrganInfo, + /// Acid level 0..=100 + pub acid_level: u8, +} + +impl Stomach { + pub fn new(id: impl Into) -> Self { + Self { info: OrganInfo::new(id, OrganType::Stomach), acid_level: 50 } + } +} + +impl Organ for Stomach { + fn id(&self) -> &str { self.info.id() } + fn organ_type(&self) -> OrganType { self.info.kind() } + fn update(&mut self, _dt_seconds: f32) {} + fn summary(&self) -> String { format!("Stomach[id={}, acid={}]", self.id(), self.acid_level) } + fn as_any(&self) -> &dyn core::any::Any { self } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { self } +} + diff --git a/src/patient.rs b/src/patient.rs new file mode 100644 index 0000000..87dfdd5 --- /dev/null +++ b/src/patient.rs @@ -0,0 +1,176 @@ +//! Patient type holding organs and core physiology snapshots. + +use crate::error::MedicalError; +use crate::organs::{Heart, Lungs, Organ}; +use crate::types::{Blood, BloodPressure, OrganType}; + +/// Patient container and simulation entry. +#[derive(Debug)] +pub struct Patient { + id: String, + organs: Vec>, + pub blood: Blood, + pub blood_pressure: BloodPressure, +} + +impl Patient { + /// Construct a new patient with validated id. + pub fn new(id: impl Into) -> crate::Result { + let id = id.into(); + if !is_valid_id(&id) { + return Err(MedicalError::Validation("patient id must be [A-Za-z0-9_-]+ and 1..64 chars")); + } + Ok(Self { + id, + organs: Vec::with_capacity(4), + blood: Blood::default(), + blood_pressure: BloodPressure::default(), + }) + } + + /// Patient identifier. + pub fn id(&self) -> &str { &self.id } + + /// Add an organ to the patient. + pub fn add_organ(&mut self, organ: impl Organ + 'static) { + self.organs.push(Box::new(organ)); + } + + /// Find the first organ matching the given type. + pub fn find_organ(&self, kind: OrganType) -> Option<&dyn Organ> { + self.organs.iter().map(|b| b.as_ref()).find(|o| o.organ_type() == kind) + } + + /// Find a typed organ by downcasting. + pub fn find_organ_typed(&self) -> Option<&T> { + self.organs + .iter() + .filter_map(|o| o.as_any().downcast_ref::()) + .next() + } + + /// Mutable typed organ lookup. + pub fn find_organ_typed_mut(&mut self) -> Option<&mut T> { + self.organs + .iter_mut() + .filter_map(|o| o.as_any_mut().downcast_mut::()) + .next() + } + + /// Initialize a patient with a 12-lead heart. + pub fn initialize_default(self) -> Self { + self.with_heart(12) + } + + /// Initialize a patient with a heart with `leads`. + pub fn with_heart(mut self, leads: u8) -> Self { + let id = format!("{}-heart", self.id); + self.add_organ(Heart::new(id, leads)); + self + } + + /// Attach default lungs. + pub fn with_lungs(mut self) -> Self { + let id = format!("{}-lungs", self.id); + self.add_organ(Lungs::new(id)); + self + } + + /// Attach a default organ by type. + pub fn with_organ(mut self, kind: OrganType) -> Self { + match kind { + OrganType::Heart => { let id = format!("{}-heart", self.id); self.add_organ(Heart::new(id, 12)); } + OrganType::Lungs => { let id = format!("{}-lungs", self.id); self.add_organ(Lungs::new(id)); } + OrganType::Brain => { let id = format!("{}-brain", self.id); self.add_organ(crate::organs::Brain::new(id)); } + OrganType::SpinalCord => { let id = format!("{}-sc", self.id); self.add_organ(crate::organs::SpinalCord::new(id)); } + OrganType::Stomach => { let id = format!("{}-stomach", self.id); self.add_organ(crate::organs::Stomach::new(id)); } + OrganType::Liver => { let id = format!("{}-liver", self.id); self.add_organ(crate::organs::Liver::new(id)); } + OrganType::Gallbladder => { let id = format!("{}-gb", self.id); self.add_organ(crate::organs::Gallbladder::new(id)); } + OrganType::Pancreas => { let id = format!("{}-pancreas", self.id); self.add_organ(crate::organs::Pancreas::new(id)); } + OrganType::Intestines => { let id = format!("{}-intestines", self.id); self.add_organ(crate::organs::Intestines::new(id)); } + OrganType::Esophagus => { let id = format!("{}-eso", self.id); self.add_organ(crate::organs::Esophagus::new(id)); } + OrganType::Kidneys => { let id = format!("{}-kidneys", self.id); self.add_organ(crate::organs::Kidneys::new(id)); } + OrganType::Bladder => { let id = format!("{}-bladder", self.id); self.add_organ(crate::organs::Bladder::new(id)); } + OrganType::Spleen => { let id = format!("{}-spleen", self.id); self.add_organ(crate::organs::Spleen::new(id)); } + } + self + } + + /// Advance simulation by `dt_seconds`. + pub fn update(&mut self, dt_seconds: f32) { + for organ in &mut self.organs { organ.update(dt_seconds); } + // Simple inter-organ signaling: low SpO2 nudges heart rate higher. + if let Some(lungs) = self.find_organ_typed::() { + if let Some(heart) = self.find_organ_typed_mut::() { + let target = if lungs.spo2_pct < 92.0 { 90.0 } else { 70.0 }; + let diff = target - heart.heart_rate_bpm; + heart.heart_rate_bpm += 0.05 * diff; + } + } + // Kidneys produce urine into bladder + if let (Some(kidneys), Some(bladder)) = ( + self.find_organ_typed::(), + self.find_organ_typed_mut::(), + ) { + let produced = (kidneys.gfr * (dt_seconds / 60.0)).max(0.0) * 0.5; // ml + bladder.volume_ml += produced; + } + } + + /// One-line summary of a specific organ by type. + pub fn organ_summary(&self, kind: OrganType) -> crate::Result { + match self.find_organ(kind) { + Some(o) => Ok(o.summary()), + None => Err(MedicalError::NotFound("organ not present")), + } + } + + /// Aggregate summary of patient and all organs. + pub fn patient_summary(&self) -> String { + let mut parts = Vec::with_capacity(self.organs.len() + 2); + parts.push(format!( + "Patient[id={}, BP={}, Hgb={:.1} g/dL, SpO2={:.0}%]", + self.id, self.blood_pressure, self.blood.hemoglobin_g_dl, self.blood.spo2_pct + )); + for o in &self.organs { + parts.push(o.summary()); + } + parts.join(" | ") + } +} + +impl Default for Patient { + fn default() -> Self { + // A benign default id that passes validation. + Self::new("patient-0").unwrap().initialize_default() + } +} + +fn is_valid_id(id: &str) -> bool { + let len = id.len(); + if !(1..=64).contains(&len) { return false; } + id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::OrganType; + + #[test] + fn patient_lifecycle() { + let mut p = Patient::new("alice-01").unwrap().initialize_default(); + assert!(p.organ_summary(OrganType::Heart).unwrap().contains("Heart")); + p.update(0.5); + assert!(p.patient_summary().contains("Patient[id=alice-01")); + // Downcast to Heart and tweak + let h = p.find_organ_typed::().unwrap(); + assert_eq!(h.organ_type(), OrganType::Heart); + } + + #[test] + fn invalid_id() { + assert!(Patient::new("").is_err()); + assert!(Patient::new("bad id").is_err()); + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..8467430 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,92 @@ +//! Core domain types (vitals, blood, organ categories). + +use core::fmt; + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// Known organ categories. +pub enum OrganType { + Heart, + Lungs, + Brain, + SpinalCord, + Stomach, + Liver, + Gallbladder, + Pancreas, + Intestines, + Esophagus, + Kidneys, + Bladder, + Spleen, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Blood pressure in mmHg. +pub struct BloodPressure { + /// Systolic pressure (mmHg). + pub systolic: u16, + /// Diastolic pressure (mmHg). + pub diastolic: u16, +} + +impl Default for BloodPressure { + fn default() -> Self { + Self { + systolic: 120, + diastolic: 80, + } + } +} + +impl BloodPressure { + /// Validate physiological plausibility: systolic > diastolic, reasonable ranges. + pub fn validate(&self) -> bool { + let (sys, dia) = (self.systolic, self.diastolic); + sys > dia && (40..=300).contains(&sys) && (20..=180).contains(&dia) + } +} + +impl fmt::Display for BloodPressure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{} mmHg", self.systolic, self.diastolic) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq)] +/// Simplified blood chemistry panel. +pub struct Blood { + /// Hemoglobin (g/dL). + pub hemoglobin_g_dl: f32, + /// Hematocrit (%). + pub hematocrit_pct: f32, + /// Oxygen saturation (%). + pub spo2_pct: f32, + /// Glucose (mg/dL). + pub glucose_mg_dl: f32, +} + +impl Default for Blood { + fn default() -> Self { + Self { + hemoglobin_g_dl: 14.0, + hematocrit_pct: 42.0, + spo2_pct: 98.0, + glucose_mg_dl: 90.0, + } + } +} + +impl Blood { + /// Returns true when fields are within typical healthy ranges. + pub fn validate(&self) -> bool { + let hgb_ok = (3.0..=25.0).contains(&self.hemoglobin_g_dl); + let hct_ok = (10.0..=70.0).contains(&self.hematocrit_pct); + let spo2_ok = (50.0..=100.0).contains(&self.spo2_pct); + let glucose_ok = (40.0..=500.0).contains(&self.glucose_mg_dl); + hgb_ok && hct_ok && spo2_ok && glucose_ok + } +} + diff --git a/tests/basic.rs b/tests/basic.rs new file mode 100644 index 0000000..cd2ef5b --- /dev/null +++ b/tests/basic.rs @@ -0,0 +1,8 @@ +#[test] +fn bmi_works() { + let bmi = medicallib_rust::calculate_bmi(70.0, 1.75).unwrap(); + assert!((bmi - 22.857145).abs() < 1e-5); + let cls = medicallib_rust::classify_bmi(bmi); + assert_eq!(cls, medicallib_rust::BMIClassification::Normal); +} + diff --git a/tests/ffi.rs b/tests/ffi.rs new file mode 100644 index 0000000..be45eb8 --- /dev/null +++ b/tests/ffi.rs @@ -0,0 +1,35 @@ +#[cfg(feature = "ffi")] +#[test] +fn ffi_bmi_and_patient() { + unsafe { + let mut out: f32 = 0.0; + let rc = medicallib_rust::ffi::medicallib_bmi(70.0, 1.75, &mut out as *mut f32); + assert_eq!(rc, medicallib_rust::ffi::ML_OK); + assert!(out > 10.0); + + use std::ffi::CString; + let id = CString::new("ffi-test").unwrap(); + let p = medicallib_rust::ffi::ml_patient_new(id.as_ptr()); + assert!(!p.is_null()); + let _ = medicallib_rust::ffi::ml_patient_update(p, 0.1); + let s = medicallib_rust::ffi::ml_patient_summary(p); + assert!(!s.is_null()); + medicallib_rust::ffi::ml_string_free(s); + medicallib_rust::ffi::ml_patient_free(p); + } +} + +#[cfg(feature = "ffi")] +#[test] +fn ffi_errors() { + unsafe { + // Null out pointer arguments should error gracefully + let rc = medicallib_rust::ffi::medicallib_bmi(70.0, 1.75, std::ptr::null_mut()); + assert_eq!(rc, medicallib_rust::ffi::ML_EINVAL); + let p = medicallib_rust::ffi::ml_patient_new(std::ptr::null()); + assert!(p.is_null()); + // Summary with null patient returns null + let s = medicallib_rust::ffi::ml_patient_summary(std::ptr::null()); + assert!(s.is_null()); + } +} diff --git a/tests/patient.rs b/tests/patient.rs new file mode 100644 index 0000000..aed3bed --- /dev/null +++ b/tests/patient.rs @@ -0,0 +1,8 @@ +#[test] +fn default_patient_heart() { + let mut p = medicallib_rust::Patient::new("int-01").unwrap().initialize_default(); + p.update(0.1); + let s = p.patient_summary(); + assert!(s.contains("Heart")); +} +