commit 0502c756c1c3796b82cd81d747539a75b758ffc1 Author: Zack3D Date: Sun Sep 21 02:14:44 2025 -0700 init 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")); +} +