Initial commit
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
//! Digital twin main module.
|
||||
//!
|
||||
//! This module provides the high-level `DigitalTwin` interface that
|
||||
//! combines geometry, tissue properties, physics, and interventions.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{DigitalTwinError, Result};
|
||||
use crate::geometry::OrganGeometry;
|
||||
use crate::intervention::Intervention;
|
||||
use crate::physics::{BioheatModel, BioheatParams, BoundaryCondition, SimulationResult};
|
||||
|
||||
/// Configuration for digital twin.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TwinConfig {
|
||||
/// Bioheat simulation parameters
|
||||
pub bioheat_params: BioheatParams,
|
||||
/// Default boundary condition
|
||||
pub boundary_condition: BoundaryCondition,
|
||||
/// Enable automatic damage calculation
|
||||
pub compute_damage: bool,
|
||||
}
|
||||
|
||||
impl Default for TwinConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bioheat_params: BioheatParams::default(),
|
||||
boundary_condition: BoundaryCondition::Temperature(37.0),
|
||||
compute_damage: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Medical digital twin for patient-specific organ simulation.
|
||||
///
|
||||
/// The digital twin combines:
|
||||
/// - Patient-specific geometry from medical imaging
|
||||
/// - Tissue property database
|
||||
/// - Physics simulation (bioheat equation)
|
||||
/// - Intervention modeling (ablation probes, HIFU, etc.)
|
||||
/// - What-if analysis for treatment planning
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use rtx_digital_twin::{DigitalTwin, OrganGeometry, AblationProbe};
|
||||
///
|
||||
/// // Create geometry from segmentation
|
||||
/// let geometry = OrganGeometry::from_labels(&labels, [64, 64, 64], [1.0, 1.0, 1.0])?;
|
||||
///
|
||||
/// // Create digital twin
|
||||
/// let mut twin = DigitalTwin::new(geometry);
|
||||
///
|
||||
/// // Add ablation probe
|
||||
/// let probe = AblationProbe::new([32.0, 32.0, 32.0], 50.0);
|
||||
///
|
||||
/// // Run simulation
|
||||
/// let result = twin.simulate_intervention(&probe, 60.0)?;
|
||||
///
|
||||
/// println!("Max temperature: {:.1}°C", result.max_temperature);
|
||||
/// println!("Damaged volume: {:.1} mm³", result.damaged_volume);
|
||||
/// ```
|
||||
pub struct DigitalTwin {
|
||||
/// Patient-specific geometry
|
||||
geometry: OrganGeometry,
|
||||
/// Configuration
|
||||
config: TwinConfig,
|
||||
/// Bioheat model
|
||||
bioheat: BioheatModel,
|
||||
/// Latest simulation result
|
||||
last_result: Option<SimulationResult>,
|
||||
}
|
||||
|
||||
impl DigitalTwin {
|
||||
/// Create a new digital twin with the given geometry.
|
||||
pub fn new(geometry: OrganGeometry) -> Self {
|
||||
let config = TwinConfig::default();
|
||||
let bioheat = BioheatModel::new(config.bioheat_params.clone());
|
||||
|
||||
Self {
|
||||
geometry,
|
||||
config,
|
||||
bioheat,
|
||||
last_result: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom configuration.
|
||||
pub fn with_config(geometry: OrganGeometry, config: TwinConfig) -> Self {
|
||||
let bioheat = BioheatModel::new(config.bioheat_params.clone());
|
||||
|
||||
Self {
|
||||
geometry,
|
||||
config,
|
||||
bioheat,
|
||||
last_result: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get reference to geometry.
|
||||
pub fn geometry(&self) -> &OrganGeometry {
|
||||
&self.geometry
|
||||
}
|
||||
|
||||
/// Get mutable reference to geometry.
|
||||
pub fn geometry_mut(&mut self) -> &mut OrganGeometry {
|
||||
&mut self.geometry
|
||||
}
|
||||
|
||||
/// Get configuration.
|
||||
pub fn config(&self) -> &TwinConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Update configuration.
|
||||
pub fn set_config(&mut self, config: TwinConfig) {
|
||||
self.bioheat = BioheatModel::new(config.bioheat_params.clone());
|
||||
self.config = config;
|
||||
}
|
||||
|
||||
/// Get the last simulation result.
|
||||
pub fn last_result(&self) -> Option<&SimulationResult> {
|
||||
self.last_result.as_ref()
|
||||
}
|
||||
|
||||
/// Run steady-state bioheat simulation without intervention.
|
||||
///
|
||||
/// Computes the natural temperature distribution in the tissue
|
||||
/// based on blood perfusion, metabolic heat, and boundary conditions.
|
||||
pub fn simulate_baseline(&mut self) -> Result<&SimulationResult> {
|
||||
self.bioheat.clear_heat_source();
|
||||
|
||||
let result = self
|
||||
.bioheat
|
||||
.solve_steady_state(&self.geometry, &self.config.boundary_condition)?;
|
||||
|
||||
self.last_result = Some(result);
|
||||
Ok(self.last_result.as_ref().unwrap())
|
||||
}
|
||||
|
||||
/// Run transient simulation with an intervention.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `intervention` - The therapeutic intervention (probe, HIFU, etc.)
|
||||
/// * `duration` - Simulation duration in seconds
|
||||
///
|
||||
/// # Returns
|
||||
/// Simulation result with temperature and damage fields
|
||||
pub fn simulate_intervention<I: Intervention>(
|
||||
&mut self,
|
||||
intervention: &I,
|
||||
duration: f32,
|
||||
) -> Result<&SimulationResult> {
|
||||
// Generate heat source from intervention
|
||||
let heat_source = intervention.generate_heat_source(&self.geometry)?;
|
||||
self.bioheat.set_heat_source(heat_source);
|
||||
|
||||
// Run transient simulation
|
||||
let result = self.bioheat.solve_transient(
|
||||
&self.geometry,
|
||||
&self.config.boundary_condition,
|
||||
duration,
|
||||
)?;
|
||||
|
||||
self.last_result = Some(result);
|
||||
Ok(self.last_result.as_ref().unwrap())
|
||||
}
|
||||
|
||||
/// Run steady-state simulation with an intervention.
|
||||
///
|
||||
/// Useful for quick assessment of temperature distribution.
|
||||
pub fn simulate_intervention_steady<I: Intervention>(
|
||||
&mut self,
|
||||
intervention: &I,
|
||||
) -> Result<&SimulationResult> {
|
||||
let heat_source = intervention.generate_heat_source(&self.geometry)?;
|
||||
self.bioheat.set_heat_source(heat_source);
|
||||
|
||||
let result = self
|
||||
.bioheat
|
||||
.solve_steady_state(&self.geometry, &self.config.boundary_condition)?;
|
||||
|
||||
self.last_result = Some(result);
|
||||
Ok(self.last_result.as_ref().unwrap())
|
||||
}
|
||||
|
||||
/// Perform what-if analysis for an intervention.
|
||||
///
|
||||
/// Runs the simulation and returns a summary of expected outcomes.
|
||||
pub fn what_if<I: Intervention>(
|
||||
&mut self,
|
||||
intervention: &I,
|
||||
duration: f32,
|
||||
) -> Result<WhatIfResult> {
|
||||
// Get geometry info before simulation
|
||||
let voxel_volume = self.geometry.spacing().iter().product::<f32>();
|
||||
let shape = self.geometry.shape();
|
||||
let n = shape[0] * shape[1] * shape[2];
|
||||
|
||||
// Run simulation
|
||||
self.simulate_intervention(intervention, duration)?;
|
||||
|
||||
// Clone result to avoid borrow issues
|
||||
let sim_result = self.last_result.clone().ok_or_else(|| {
|
||||
DigitalTwinError::SimulationError("No result after simulation".to_string())
|
||||
})?;
|
||||
|
||||
// Analyze results
|
||||
let max_temp = sim_result.max_temperature;
|
||||
let damaged_volume = sim_result.damaged_volume;
|
||||
|
||||
// Count voxels at different damage levels
|
||||
let mut severe_damage_volume = 0.0;
|
||||
let mut moderate_damage_volume = 0.0;
|
||||
|
||||
for &damage in &sim_result.damage {
|
||||
if damage > 4.6 {
|
||||
// CEM43 > 240 min equivalent
|
||||
severe_damage_volume += voxel_volume;
|
||||
} else if damage > 1.0 {
|
||||
moderate_damage_volume += voxel_volume;
|
||||
}
|
||||
}
|
||||
|
||||
// Check safety margins (temperature at boundary)
|
||||
let mut max_boundary_temp = 0.0f32;
|
||||
|
||||
for z in [0, shape[2] - 1] {
|
||||
for y in 0..shape[1] {
|
||||
for x in 0..shape[0] {
|
||||
let idx = z * shape[0] * shape[1] + y * shape[0] + x;
|
||||
if idx < n {
|
||||
max_boundary_temp = max_boundary_temp.max(sim_result.temperature[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let safety_margin_ok = max_boundary_temp < 45.0; // Below tissue damage threshold
|
||||
|
||||
Ok(WhatIfResult {
|
||||
intervention_type: format!("{:?}", intervention.intervention_type()),
|
||||
intervention_power: intervention.power(),
|
||||
duration,
|
||||
max_temperature: max_temp,
|
||||
total_damaged_volume: damaged_volume,
|
||||
severe_damage_volume,
|
||||
moderate_damage_volume,
|
||||
max_boundary_temperature: max_boundary_temp,
|
||||
safety_margin_ok,
|
||||
iterations: sim_result.iterations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Update geometry with simulation results.
|
||||
///
|
||||
/// Copies temperature and damage from last simulation into geometry.
|
||||
pub fn apply_result_to_geometry(&mut self) -> Result<()> {
|
||||
if let Some(ref result) = self.last_result {
|
||||
self.geometry.set_temperature_field(&result.temperature)?;
|
||||
|
||||
// Also update damage
|
||||
for (i, voxel) in self.geometry.data_mut().iter_mut().enumerate() {
|
||||
voxel.damage = result.damage[i];
|
||||
}
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DigitalTwinError::SimulationError(
|
||||
"No simulation result available".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset temperature field to body temperature.
|
||||
pub fn reset_temperature(&mut self) {
|
||||
for voxel in self.geometry.data_mut() {
|
||||
voxel.temperature = 37.0;
|
||||
voxel.damage = 0.0;
|
||||
}
|
||||
self.last_result = None;
|
||||
}
|
||||
|
||||
/// Get summary statistics of the current geometry.
|
||||
pub fn geometry_summary(&self) -> GeometrySummary {
|
||||
let shape = self.geometry.shape();
|
||||
let spacing = self.geometry.spacing();
|
||||
let dims = self.geometry.dimensions();
|
||||
let histogram = self.geometry.tissue_histogram();
|
||||
|
||||
let total_voxels = self.geometry.num_voxels();
|
||||
let tissue_voxels = total_voxels
|
||||
- histogram
|
||||
.get(&crate::tissue::TissueType::Air)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
let voxel_volume = spacing[0] * spacing[1] * spacing[2];
|
||||
let tissue_volume = tissue_voxels as f32 * voxel_volume;
|
||||
|
||||
GeometrySummary {
|
||||
shape,
|
||||
spacing,
|
||||
dimensions: dims,
|
||||
total_voxels,
|
||||
tissue_voxels,
|
||||
tissue_volume,
|
||||
num_tissue_types: histogram.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of what-if analysis.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WhatIfResult {
|
||||
/// Type of intervention
|
||||
pub intervention_type: String,
|
||||
/// Power of intervention [W]
|
||||
pub intervention_power: f32,
|
||||
/// Simulation duration [s]
|
||||
pub duration: f32,
|
||||
/// Maximum temperature reached [°C]
|
||||
pub max_temperature: f32,
|
||||
/// Total volume with damage > 1 [mm³]
|
||||
pub total_damaged_volume: f32,
|
||||
/// Volume with severe damage [mm³]
|
||||
pub severe_damage_volume: f32,
|
||||
/// Volume with moderate damage [mm³]
|
||||
pub moderate_damage_volume: f32,
|
||||
/// Maximum temperature at boundary [°C]
|
||||
pub max_boundary_temperature: f32,
|
||||
/// Whether safety margins are satisfied
|
||||
pub safety_margin_ok: bool,
|
||||
/// Number of simulation iterations
|
||||
pub iterations: usize,
|
||||
}
|
||||
|
||||
impl WhatIfResult {
|
||||
/// Generate a summary report.
|
||||
pub fn report(&self) -> String {
|
||||
format!(
|
||||
r"What-If Analysis Report
|
||||
======================
|
||||
Intervention: {}
|
||||
Power: {:.1} W
|
||||
Duration: {:.1} s
|
||||
|
||||
Temperature Results:
|
||||
- Maximum: {:.1}°C
|
||||
- Boundary max: {:.1}°C
|
||||
- Safety OK: {}
|
||||
|
||||
Damage Assessment:
|
||||
- Total damaged: {:.1} mm³
|
||||
- Severe damage: {:.1} mm³
|
||||
- Moderate damage: {:.1} mm³
|
||||
|
||||
Simulation:
|
||||
- Iterations: {}
|
||||
",
|
||||
self.intervention_type,
|
||||
self.intervention_power,
|
||||
self.duration,
|
||||
self.max_temperature,
|
||||
self.max_boundary_temperature,
|
||||
if self.safety_margin_ok {
|
||||
"Yes"
|
||||
} else {
|
||||
"NO - REVIEW REQUIRED"
|
||||
},
|
||||
self.total_damaged_volume,
|
||||
self.severe_damage_volume,
|
||||
self.moderate_damage_volume,
|
||||
self.iterations,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of geometry statistics.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeometrySummary {
|
||||
/// Volume shape [x, y, z]
|
||||
pub shape: [usize; 3],
|
||||
/// Voxel spacing [mm]
|
||||
pub spacing: [f32; 3],
|
||||
/// Physical dimensions [mm]
|
||||
pub dimensions: [f32; 3],
|
||||
/// Total number of voxels
|
||||
pub total_voxels: usize,
|
||||
/// Number of tissue voxels (non-air)
|
||||
pub tissue_voxels: usize,
|
||||
/// Total tissue volume [mm³]
|
||||
pub tissue_volume: f32,
|
||||
/// Number of different tissue types
|
||||
pub num_tissue_types: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geometry::TissueLabel;
|
||||
use crate::intervention::AblationProbe;
|
||||
use crate::tissue::TissueType;
|
||||
|
||||
fn create_test_twin() -> DigitalTwin {
|
||||
// Create a 20x20x20 geometry with liver in the center
|
||||
let mut geometry = OrganGeometry::new([20, 20, 20], [1.0, 1.0, 1.0]);
|
||||
|
||||
// Fill interior with liver
|
||||
for z in 2..18 {
|
||||
for y in 2..18 {
|
||||
for x in 2..18 {
|
||||
geometry.set_label(x, y, z, TissueLabel::from(TissueType::Liver));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DigitalTwin::new(geometry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_twin_creation() {
|
||||
let twin = create_test_twin();
|
||||
|
||||
assert_eq!(twin.geometry().shape(), [20, 20, 20]);
|
||||
assert!(twin.last_result().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_baseline_simulation() {
|
||||
let mut twin = create_test_twin();
|
||||
|
||||
let result = twin.simulate_baseline().unwrap();
|
||||
|
||||
// Should converge without errors
|
||||
assert!(result.iterations > 0);
|
||||
// Temperature should be around body temp
|
||||
assert!(result.max_temperature < 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intervention_simulation() {
|
||||
let mut twin = create_test_twin();
|
||||
|
||||
// Use higher power and larger active region for more noticeable heating
|
||||
let probe = AblationProbe::new([10.0, 10.0, 10.0], 50.0)
|
||||
.with_active_length(6.0)
|
||||
.with_diameter(3.0);
|
||||
|
||||
// Run steady-state instead of transient (more stable numerically)
|
||||
let result = twin.simulate_intervention_steady(&probe).unwrap();
|
||||
|
||||
// Should complete without error and have valid results
|
||||
assert!(result.iterations > 0, "Should have run some iterations");
|
||||
// Temperature should be at least body temperature
|
||||
assert!(
|
||||
result.max_temperature >= 37.0,
|
||||
"Temperature should be at least body temp, got {}",
|
||||
result.max_temperature
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_what_if_analysis() {
|
||||
let mut twin = create_test_twin();
|
||||
|
||||
let probe = AblationProbe::new([10.0, 10.0, 10.0], 30.0);
|
||||
|
||||
let what_if = twin.what_if(&probe, 5.0).unwrap();
|
||||
|
||||
// Should have results
|
||||
assert!(what_if.max_temperature > 37.0);
|
||||
assert!(what_if.iterations > 0);
|
||||
|
||||
// Report should be generated
|
||||
let report = what_if.report();
|
||||
assert!(report.contains("What-If Analysis"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_geometry_summary() {
|
||||
let twin = create_test_twin();
|
||||
let summary = twin.geometry_summary();
|
||||
|
||||
assert_eq!(summary.shape, [20, 20, 20]);
|
||||
assert_eq!(summary.total_voxels, 8000);
|
||||
assert!(summary.tissue_voxels > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_temperature() {
|
||||
let mut twin = create_test_twin();
|
||||
|
||||
// Set some non-standard temperature
|
||||
twin.geometry_mut().set_temperature(10, 10, 10, 50.0);
|
||||
|
||||
twin.reset_temperature();
|
||||
|
||||
// Should be back to body temp
|
||||
let voxel = twin.geometry().get(10, 10, 10).unwrap();
|
||||
assert_eq!(voxel.temperature, 37.0);
|
||||
assert_eq!(voxel.damage, 0.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user