//! Pennes Bioheat Equation residual computation //! //! The Pennes equation models heat transfer in biological tissue: //! ```text //! ρc(∂T/∂t) = k∇²T + ωb·ρb·cb·(Ta - T) + Qm + Qs //! ``` //! //! Rearranged as residual (should equal zero): //! ```text //! R = ρc·∂T/∂t - k·∇²T - ωb·ρb·cb·(Ta - T) - Qm - Qs = 0 //! ``` use bioheat_shared::BioheatParams; use serde::{Deserialize, Serialize}; /// Pennes bioheat equation residual computer #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PennesResidual { /// Tissue and blood properties pub params: BioheatParams, } /// Derivatives of temperature field needed for residual computation #[derive(Debug, Clone, Copy)] pub struct TemperatureDerivatives { /// Temperature value T pub t: f32, /// Time derivative ∂T/∂t pub dt_dt: f32, /// First spatial derivatives pub dt_dx: f32, pub dt_dy: f32, pub dt_dz: f32, /// Second spatial derivatives (for Laplacian) pub d2t_dx2: f32, pub d2t_dy2: f32, pub d2t_dz2: f32, } impl TemperatureDerivatives { /// Compute the Laplacian ∇²T = ∂²T/∂x² + ∂²T/∂y² + ∂²T/∂z² #[must_use] pub fn laplacian(&self) -> f32 { self.d2t_dx2 + self.d2t_dy2 + self.d2t_dz2 } /// Create derivatives for a constant temperature field #[must_use] pub fn constant(t: f32) -> Self { Self { t, dt_dt: 0.0, dt_dx: 0.0, dt_dy: 0.0, dt_dz: 0.0, d2t_dx2: 0.0, d2t_dy2: 0.0, d2t_dz2: 0.0, } } } impl PennesResidual { /// Create a new Pennes residual computer with given parameters #[must_use] pub fn new(params: BioheatParams) -> Self { Self { params } } /// Compute the Pennes equation residual at a single point /// /// # Arguments /// * `derivs` - Temperature and its derivatives at the point /// * `heat_source` - External heat source Qs (W/m³) from probe /// /// # Returns /// The residual value (should be zero for a correct solution) #[must_use] pub fn compute(&self, derivs: &TemperatureDerivatives, heat_source: f32) -> f32 { let tissue = &self.params.tissue; let blood = &self.params.blood; // Volumetric heat capacity ρc (J/m³/K) let rho_c = tissue.density * tissue.specific_heat; // Thermal conductivity k (W/m/K) let k = tissue.thermal_conductivity; // Perfusion coefficient ωb·ρb·cb (W/m³/K) let perfusion_coeff = self.params.perfusion_coefficient(); // Metabolic heat Qm (W/m³) let q_m = tissue.metabolic_heat; // Arterial temperature Ta (°C) let t_a = blood.arterial_temperature; // Compute each term: // 1. Time derivative term: ρc·∂T/∂t let time_term = rho_c * derivs.dt_dt; // 2. Diffusion term: k·∇²T let diffusion_term = k * derivs.laplacian(); // 3. Perfusion term: ωb·ρb·cb·(Ta - T) // Note: This is a cooling term when T > Ta let perfusion_term = perfusion_coeff * (t_a - derivs.t); // 4. Total source: Qm + Qs let source_term = q_m + heat_source; // Residual: ρc·∂T/∂t - k·∇²T - perfusion - Qm - Qs = 0 time_term - diffusion_term - perfusion_term - source_term } /// Compute residual for steady-state (∂T/∂t = 0) #[must_use] pub fn compute_steady_state(&self, derivs: &TemperatureDerivatives, heat_source: f32) -> f32 { let mut steady_derivs = *derivs; steady_derivs.dt_dt = 0.0; self.compute(&steady_derivs, heat_source) } /// Compute residual for batch of points pub fn compute_batch( &self, derivs_batch: &[TemperatureDerivatives], heat_sources: &[f32], ) -> Vec { derivs_batch .iter() .zip(heat_sources) .map(|(d, &q)| self.compute(d, q)) .collect() } /// Get the characteristic time scale (thermal diffusion time) /// τ = L² / α where α = k/(ρc) is thermal diffusivity #[must_use] pub fn thermal_time_scale(&self, length_scale: f32) -> f32 { let alpha = self.params.tissue.thermal_diffusivity(); length_scale * length_scale / alpha } /// Get the perfusion time scale /// τ_p = (ρc) / (ωb·ρb·cb) #[must_use] pub fn perfusion_time_scale(&self) -> f32 { let rho_c = self.params.tissue.volumetric_heat_capacity(); let perf_coeff = self.params.perfusion_coefficient(); if perf_coeff > 1e-10 { rho_c / perf_coeff } else { f32::INFINITY // No perfusion } } } /// Analytical test case: 1D heat conduction with constant source /// Useful for validating the residual computation pub mod analytical { /// 1D steady-state solution with constant heat source /// For boundary conditions T(0) = T(L) = T_boundary /// and constant source Q, the solution is: /// T(x) = T_boundary + (Q/2k) * x * (L - x) pub struct SteadyStateBar { pub length: f32, pub t_boundary: f32, pub heat_source: f32, pub k: f32, } impl SteadyStateBar { /// Temperature at position x #[must_use] pub fn temperature(&self, x: f32) -> f32 { let q_over_2k = self.heat_source / (2.0 * self.k); self.t_boundary + q_over_2k * x * (self.length - x) } /// First derivative dT/dx #[must_use] pub fn dt_dx(&self, x: f32) -> f32 { let q_over_2k = self.heat_source / (2.0 * self.k); q_over_2k * (self.length - 2.0 * x) } /// Second derivative d²T/dx² #[must_use] pub fn d2t_dx2(&self) -> f32 { -self.heat_source / self.k } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_pennes_residual_equilibrium() { // At equilibrium with no heat source and T = Ta, residual should be ~0 let params = BioheatParams::liver_ablation(); let residual = PennesResidual::new(params.clone()); let derivs = TemperatureDerivatives { t: params.blood.arterial_temperature, // T = Ta dt_dt: 0.0, // Steady state dt_dx: 0.0, dt_dy: 0.0, dt_dz: 0.0, d2t_dx2: 0.0, d2t_dy2: 0.0, d2t_dz2: 0.0, }; // Only metabolic heat contributes let r = residual.compute(&derivs, 0.0); assert!((r + params.tissue.metabolic_heat).abs() < 1.0); } #[test] fn test_laplacian() { let derivs = TemperatureDerivatives { t: 50.0, dt_dt: 0.0, dt_dx: 1.0, dt_dy: 2.0, dt_dz: 3.0, d2t_dx2: 1.0, d2t_dy2: 2.0, d2t_dz2: 3.0, }; assert!((derivs.laplacian() - 6.0).abs() < 1e-6); } #[test] fn test_analytical_bar() { use analytical::SteadyStateBar; let bar = SteadyStateBar { length: 1.0, t_boundary: 37.0, heat_source: 1000.0, k: 0.5, }; // At x=0 and x=L, T should equal boundary assert!((bar.temperature(0.0) - 37.0).abs() < 1e-6); assert!((bar.temperature(1.0) - 37.0).abs() < 1e-6); // Maximum at center let t_center = bar.temperature(0.5); assert!(t_center > 37.0); // Derivative at center should be 0 assert!(bar.dt_dx(0.5).abs() < 1e-6); } #[test] fn test_time_scales() { let params = BioheatParams::liver_ablation(); let residual = PennesResidual::new(params); // Thermal time scale for 1cm domain let tau_thermal = residual.thermal_time_scale(0.01); assert!(tau_thermal > 0.0); // Perfusion time scale let tau_perf = residual.perfusion_time_scale(); assert!(tau_perf > 0.0); assert!(tau_perf < f32::INFINITY); } }