//! Monodomain equation solver for cardiac electrophysiology. //! //! Solves the monodomain equation: //! ∂V/∂t = ∇·(D∇V) + Iion(V, w) //! //! where V is transmembrane voltage, D is diffusion tensor, and Iion is ionic current. use cardiosim_shared::{ ConductivityParams, HeartMesh, IonicModel, SimulationConfig, StimulationProtocol, Vector3D, VoltageField, }; use crate::CardioSimError; use crate::ionic_models::IonicState; /// Monodomain solver. #[derive(Debug)] pub struct MonodomainSolver { ionic_model: IonicModel, dt: f32, total_time: f32, output_interval: f32, conductivity: ConductivityParams, } impl MonodomainSolver { /// Create a new monodomain solver. #[must_use] pub fn new(config: &SimulationConfig) -> Self { Self { ionic_model: config.ionic_model, dt: config.dt, total_time: config.total_time, output_interval: config.output_interval, conductivity: config.conductivity.clone(), } } /// Solve monodomain equation on heart mesh. pub fn solve( &self, mesh: &HeartMesh, protocol: &StimulationProtocol, ) -> Result, CardioSimError> { let n_vertices = mesh.vertices.len(); let n_steps = (self.total_time / self.dt) as usize; let output_steps = (self.output_interval / self.dt) as usize; // Initialize state let mut voltage = vec![-85.0_f32; n_vertices]; let mut ionic_states: Vec = (0..n_vertices) .map(|_| IonicState::new(self.ionic_model)) .collect(); // Precompute diffusion operator let diffusion = self.compute_diffusion_operator(mesh); let mut output = Vec::new(); for step in 0..n_steps { let t = step as f32 * self.dt; // Apply stimulation let stim = self.compute_stimulus(mesh, protocol, t); // Diffusion step (implicit or semi-implicit) let dv_diffusion = self.apply_diffusion(&voltage, &diffusion); // Ionic current step let (dv_ionic, dw) = self.compute_ionic_currents(&voltage, &ionic_states); // Update voltage for i in 0..n_vertices { voltage[i] += self.dt * (dv_diffusion[i] + dv_ionic[i] + stim[i]); } // Update ionic state for (i, state) in ionic_states.iter_mut().enumerate() { state.recovery += self.dt * dw[i]; } // Output if step % output_steps == 0 { output.push(VoltageField { time: t, voltages: voltage.clone(), }); } } Ok(output) } fn compute_diffusion_operator(&self, mesh: &HeartMesh) -> DiffusionOperator { // Build sparse Laplacian with fiber anisotropy let n = mesh.vertices.len(); let mut neighbors: Vec> = vec![Vec::new(); n]; // Build neighbor list from triangles for tri in &mesh.triangles { for i in 0..3 { let v1 = tri[i]; let v2 = tri[(i + 1) % 3]; let p1 = &mesh.vertices[v1]; let p2 = &mesh.vertices[v2]; let dist = p1.distance_to(p2); // Get fiber-aligned conductivity let fiber = &mesh.fibers[v1]; let edge = Vector3D::new(p2.x - p1.x, p2.y - p1.y, p2.z - p1.z); let cos_angle = (fiber.x * edge.x + fiber.y * edge.y + fiber.z * edge.z) / (dist + 1e-8); // Interpolate between longitudinal and transverse conductivity let sigma = self.conductivity.sigma_l * cos_angle.abs() + self.conductivity.sigma_t * (1.0 - cos_angle.abs()); let weight = sigma / (dist * dist + 1e-8); neighbors[v1].push((v2, weight)); } } DiffusionOperator { neighbors } } fn apply_diffusion(&self, voltage: &[f32], op: &DiffusionOperator) -> Vec { let n = voltage.len(); let mut result = vec![0.0_f32; n]; for i in 0..n { let mut sum = 0.0; let mut total_weight = 0.0; for &(j, w) in &op.neighbors[i] { sum += w * (voltage[j] - voltage[i]); total_weight += w; } // Normalize and scale if total_weight > 0.0 { result[i] = sum / total_weight.max(1e-8); } } result } fn compute_ionic_currents( &self, voltage: &[f32], states: &[IonicState], ) -> (Vec, Vec) { let n = voltage.len(); let mut dv = vec![0.0_f32; n]; let mut dw = vec![0.0_f32; n]; for i in 0..n { let (ion_dv, ion_dw) = states[i].compute_currents(voltage[i]); dv[i] = ion_dv; dw[i] = ion_dw; } (dv, dw) } fn compute_stimulus( &self, mesh: &HeartMesh, protocol: &StimulationProtocol, t: f32, ) -> Vec { let n = mesh.vertices.len(); let mut stim = vec![0.0_f32; n]; for site in &protocol.sites { // Check if we're within a stimulus pulse let mut is_active = false; for &stim_time in &site.times { if t >= stim_time && t < stim_time + site.duration { is_active = true; break; } } if is_active { // Apply stimulus to vertices within radius for (i, vertex) in mesh.vertices.iter().enumerate() { let dist = vertex.distance_to(&site.center); if dist < site.radius { stim[i] = site.current; } } } } stim } } /// Sparse diffusion operator. #[derive(Debug)] struct DiffusionOperator { neighbors: Vec>, } #[cfg(test)] mod tests { use super::*; #[test] fn test_solver_creation() { let config = SimulationConfig::default(); let solver = MonodomainSolver::new(&config); assert!(solver.dt > 0.0); } #[test] fn test_solve() { let config = SimulationConfig { total_time: 10.0, output_interval: 5.0, ..Default::default() }; let solver = MonodomainSolver::new(&config); let mesh = cardiosim_shared::get_sample_heart_mesh(); let protocol = cardiosim_shared::get_sample_protocol(); let result = solver.solve(&mesh, &protocol); assert!(result.is_ok()); let fields = result.unwrap(); assert!(fields.len() >= 2); } #[test] fn test_diffusion_operator() { let config = SimulationConfig::default(); let solver = MonodomainSolver::new(&config); let mesh = cardiosim_shared::get_sample_heart_mesh(); let op = solver.compute_diffusion_operator(&mesh); assert_eq!(op.neighbors.len(), mesh.vertices.len()); } #[test] fn test_stimulus() { let config = SimulationConfig::default(); let solver = MonodomainSolver::new(&config); let mesh = cardiosim_shared::get_sample_heart_mesh(); let protocol = cardiosim_shared::get_sample_protocol(); let stim = solver.compute_stimulus(&mesh, &protocol, 0.5); assert!(!stim.is_empty()); // Some vertices should be stimulated assert!(stim.iter().any(|&s| s > 0.0)); } }