//! Boundary Element Method (BEM) forward model. //! //! BEM provides accurate EEG forward modeling by accounting for the //! realistic geometry of the head's conducting layers. //! //! ## Head Model //! //! The BEM model uses three surfaces: //! - **Inner skull**: Boundary between brain and skull //! - **Outer skull**: Boundary between skull and scalp //! - **Scalp**: Outer head surface //! //! ## Algorithm //! //! Uses the symmetric BEM formulation: //! 1. Compute BEM matrix from surface geometries //! 2. Solve linear system for surface potentials //! 3. Compute lead field from surface potentials to electrodes //! //! ## Reference //! //! Gramfort, A., et al. (2010). OpenMEEG: opensource software for quasistatic //! bioelectromagnetics. Biomedical engineering online, 9(1), 45. use crate::gain::GainMatrix; use crate::sensors::{SensorArray, SensorType}; use crate::source_space::SourceSpace; use crate::{ForwardError, ForwardResult, Orientation, Position, cross, dot, norm, normalize}; use nalgebra::{DMatrix, DVector, Vector3}; use std::f64::consts::PI; /// A triangular mesh surface for BEM #[derive(Debug, Clone)] pub struct BemSurface { /// Vertex positions vertices: Vec, /// Triangle indices (each triangle is 3 vertex indices) triangles: Vec<[usize; 3]>, /// Surface name (e.g., "inner_skull", "outer_skull", "scalp") name: String, /// Precomputed triangle centroids centroids: Vec, /// Precomputed triangle normals normals: Vec, /// Precomputed triangle areas areas: Vec, } impl BemSurface { /// Create a BEM surface from vertices and triangles pub fn new( vertices: Vec<[f64; 3]>, triangles: Vec<[usize; 3]>, name: &str, ) -> ForwardResult { if vertices.is_empty() { return Err(ForwardError::InvalidGeometry( "Empty vertex list".to_string(), )); } if triangles.is_empty() { return Err(ForwardError::InvalidGeometry( "Empty triangle list".to_string(), )); } let vertices: Vec = vertices .into_iter() .map(|v| Vector3::new(v[0], v[1], v[2])) .collect(); // Validate triangle indices let n_vertices = vertices.len(); for tri in &triangles { for &idx in tri { if idx >= n_vertices { return Err(ForwardError::InvalidGeometry(format!( "Triangle index {} out of bounds (n_vertices={})", idx, n_vertices ))); } } } // Precompute triangle properties let mut centroids = Vec::with_capacity(triangles.len()); let mut normals = Vec::with_capacity(triangles.len()); let mut areas = Vec::with_capacity(triangles.len()); for tri in &triangles { let v0 = &vertices[tri[0]]; let v1 = &vertices[tri[1]]; let v2 = &vertices[tri[2]]; // Centroid let centroid = (v0 + v1 + v2) / 3.0; centroids.push(centroid); // Normal (cross product of two edges) let e1 = v1 - v0; let e2 = v2 - v0; let n = cross(&e1, &e2); let area = norm(&n) / 2.0; let normal = if area > 1e-15 { normalize(&n) } else { Vector3::zeros() }; normals.push(normal); areas.push(area); } Ok(Self { vertices, triangles, name: name.to_string(), centroids, normals, areas, }) } /// Get number of triangles pub fn n_triangles(&self) -> usize { self.triangles.len() } /// Get number of vertices pub fn n_vertices(&self) -> usize { self.vertices.len() } /// Get surface name pub fn name(&self) -> &str { &self.name } /// Get triangle centroids pub fn centroids(&self) -> &[Position] { &self.centroids } /// Get triangle normals pub fn normals(&self) -> &[Orientation] { &self.normals } /// Get triangle areas pub fn areas(&self) -> &[f64] { &self.areas } /// Get vertices pub fn vertices(&self) -> &[Position] { &self.vertices } /// Create a spherical BEM surface for testing pub fn sphere( center: [f64; 3], radius: f64, n_subdivisions: usize, name: &str, ) -> ForwardResult { // Create icosphere let (vertices, triangles) = Self::create_icosphere(center, radius, n_subdivisions); Self::new(vertices, triangles, name) } /// Create an icosphere (subdivided icosahedron) fn create_icosphere( center: [f64; 3], radius: f64, subdivisions: usize, ) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) { // Golden ratio let phi = f64::midpoint(1.0, 5.0_f64.sqrt()); let len = (1.0 + phi * phi).sqrt(); // Icosahedron vertices let mut vertices: Vec<[f64; 3]> = vec![ [-1.0 / len, phi / len, 0.0], [1.0 / len, phi / len, 0.0], [-1.0 / len, -phi / len, 0.0], [1.0 / len, -phi / len, 0.0], [0.0, -1.0 / len, phi / len], [0.0, 1.0 / len, phi / len], [0.0, -1.0 / len, -phi / len], [0.0, 1.0 / len, -phi / len], [phi / len, 0.0, -1.0 / len], [phi / len, 0.0, 1.0 / len], [-phi / len, 0.0, -1.0 / len], [-phi / len, 0.0, 1.0 / len], ]; // Scale to radius and translate to center for v in &mut vertices { let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); v[0] = center[0] + radius * v[0] / n; v[1] = center[1] + radius * v[1] / n; v[2] = center[2] + radius * v[2] / n; } // Icosahedron faces let mut triangles: Vec<[usize; 3]> = vec![ [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], ]; // Subdivide for _ in 0..subdivisions { let mut new_triangles = Vec::new(); let mut midpoint_cache: std::collections::HashMap<(usize, usize), usize> = std::collections::HashMap::new(); for tri in &triangles { // Get midpoints let a = Self::get_midpoint( &mut vertices, &mut midpoint_cache, tri[0], tri[1], center, radius, ); let b = Self::get_midpoint( &mut vertices, &mut midpoint_cache, tri[1], tri[2], center, radius, ); let c = Self::get_midpoint( &mut vertices, &mut midpoint_cache, tri[2], tri[0], center, radius, ); // Create 4 new triangles new_triangles.push([tri[0], a, c]); new_triangles.push([tri[1], b, a]); new_triangles.push([tri[2], c, b]); new_triangles.push([a, b, c]); } triangles = new_triangles; } (vertices, triangles) } /// Get or create midpoint vertex fn get_midpoint( vertices: &mut Vec<[f64; 3]>, cache: &mut std::collections::HashMap<(usize, usize), usize>, i1: usize, i2: usize, center: [f64; 3], radius: f64, ) -> usize { let key = if i1 < i2 { (i1, i2) } else { (i2, i1) }; if let Some(&idx) = cache.get(&key) { return idx; } let v1 = &vertices[i1]; let v2 = &vertices[i2]; // Midpoint let mut mid = [ f64::midpoint(v1[0], v2[0]), f64::midpoint(v1[1], v2[1]), f64::midpoint(v1[2], v2[2]), ]; // Project to sphere let dx = mid[0] - center[0]; let dy = mid[1] - center[1]; let dz = mid[2] - center[2]; let len = (dx * dx + dy * dy + dz * dz).sqrt(); mid[0] = center[0] + radius * dx / len; mid[1] = center[1] + radius * dy / len; mid[2] = center[2] + radius * dz / len; let idx = vertices.len(); vertices.push(mid); cache.insert(key, idx); idx } } /// Configuration for BEM head model #[derive(Debug, Clone)] pub struct BemConfig { /// Conductivity values [brain, skull, scalp] in S/m pub conductivities: [f64; 3], /// Use symmetric BEM formulation pub symmetric: bool, /// Integration order for computing BEM coefficients pub integration_order: usize, } impl Default for BemConfig { fn default() -> Self { Self { // Standard conductivity values (S/m) conductivities: [0.33, 0.0042, 0.33], // brain, skull, scalp symmetric: true, integration_order: 3, } } } impl BemConfig { /// Create config with custom conductivities pub fn with_conductivities(brain: f64, skull: f64, scalp: f64) -> Self { Self { conductivities: [brain, skull, scalp], ..Self::default() } } } /// BEM forward model for EEG #[derive(Debug)] pub struct BemModel { /// Surfaces (ordered: inner_skull, outer_skull, scalp) surfaces: Vec, /// Configuration config: BemConfig, /// BEM system matrix (precomputed) bem_matrix: Option>, /// Total number of collocation points n_collocation: usize, } impl BemModel { /// Create a BEM model from three surfaces /// /// # Arguments /// * `inner_skull` - Brain-skull boundary surface /// * `outer_skull` - Skull-scalp boundary surface /// * `scalp` - Outer head surface /// * `config` - Optional configuration pub fn new( inner_skull: BemSurface, outer_skull: BemSurface, scalp: BemSurface, config: Option, ) -> ForwardResult { let config = config.unwrap_or_default(); let n_collocation = inner_skull.n_triangles() + outer_skull.n_triangles() + scalp.n_triangles(); Ok(Self { surfaces: vec![inner_skull, outer_skull, scalp], config, bem_matrix: None, n_collocation, }) } /// Create a three-shell spherical BEM model for testing pub fn three_shell_sphere( center: [f64; 3], radii: [f64; 3], // [inner_skull, outer_skull, scalp] n_triangles: usize, config: Option, ) -> ForwardResult { // Determine subdivision level to get approximately n_triangles let subdivisions = ((n_triangles as f64 / 20.0).log2() / 2.0).ceil() as usize; let inner_skull = BemSurface::sphere(center, radii[0], subdivisions, "inner_skull")?; let outer_skull = BemSurface::sphere(center, radii[1], subdivisions, "outer_skull")?; let scalp = BemSurface::sphere(center, radii[2], subdivisions, "scalp")?; Self::new(inner_skull, outer_skull, scalp, config) } /// Compute the BEM system matrix /// /// This precomputes the matrix needed for the forward solution. /// Call this before computing gain matrices. pub fn make_bem_matrix(&mut self) -> ForwardResult<()> { let n = self.n_collocation; let mut matrix = DMatrix::zeros(n, n); let sigma = &self.config.conductivities; // Compute sigma ratios // sigma_minus / (sigma_plus + sigma_minus) for each interface let sigma_ratios = [ sigma[0] / (sigma[0] + sigma[1]), // inner skull: brain-skull sigma[1] / (sigma[1] + sigma[2]), // outer skull: skull-scalp 1.0, // scalp: scalp-air (sigma_air ≈ 0) ]; // Fill BEM matrix using linear collocation let mut row_offset = 0; for (i, surf_i) in self.surfaces.iter().enumerate() { let mut col_offset = 0; for (j, surf_j) in self.surfaces.iter().enumerate() { // Compute interaction block let block = self.compute_bem_block(surf_i, surf_j, i == j); // Scale by conductivity ratio let scale = if i == j { 0.5 - sigma_ratios[i] } else if j < i { -sigma_ratios[j] } else { sigma_ratios[i] - 1.0 }; // Copy block to matrix for (bi, _ti) in surf_i.centroids().iter().enumerate() { for (bj, _tj) in surf_j.centroids().iter().enumerate() { matrix[(row_offset + bi, col_offset + bj)] = block[(bi, bj)] * scale; } } // Add identity on diagonal for same surface if i == j { for bi in 0..surf_i.n_triangles() { matrix[(row_offset + bi, col_offset + bi)] += 0.5; } } col_offset += surf_j.n_triangles(); } row_offset += surf_i.n_triangles(); } self.bem_matrix = Some(matrix); Ok(()) } /// Compute the gain matrix for EEG electrodes pub fn compute_gain( &self, sources: &SourceSpace, sensors: &SensorArray, ) -> ForwardResult { let bem_matrix = self.bem_matrix.as_ref().ok_or_else(|| { ForwardError::ComputationError( "BEM matrix not computed. Call make_bem_matrix() first".to_string(), ) })?; // Invert BEM matrix let bem_inv = Self::invert_matrix(bem_matrix)?; let n_sensors = sensors.len(); let n_sources = sources.len(); let free_ori = !sources.is_fixed_orientation(); let n_columns = if free_ori { 3 * n_sources } else { n_sources }; let mut gain_data = vec![vec![0.0; n_columns]; n_sensors]; // Get EEG sensor positions let sensor_positions: Vec<&Position> = sensors .iter() .filter(|s| matches!(s.sensor_type(), SensorType::Eeg)) .map(super::sensors::Sensor::position) .collect(); // For each source, compute the lead field for (src_idx, source) in sources.iter().enumerate() { let dipole_pos = source.position(); if free_ori { // Compute for each orientation for ori_idx in 0..3 { let dipole_ori = match ori_idx { 0 => Vector3::new(1.0, 0.0, 0.0), 1 => Vector3::new(0.0, 1.0, 0.0), _ => Vector3::new(0.0, 0.0, 1.0), }; // Compute source contribution to each surface let source_pot = self.compute_source_potential(dipole_pos, &dipole_ori); // Solve BEM system let surface_pot = &bem_inv * &source_pot; // Interpolate to electrodes for (s_idx, electrode_pos) in sensor_positions.iter().enumerate() { let pot = self.interpolate_potential(&surface_pot, electrode_pos); gain_data[s_idx][3 * src_idx + ori_idx] = pot; } } } else { let dipole_ori = source .orientation() .unwrap_or_else(|| Vector3::new(0.0, 0.0, 1.0)); let source_pot = self.compute_source_potential(dipole_pos, &dipole_ori); let surface_pot = &bem_inv * &source_pot; for (s_idx, electrode_pos) in sensor_positions.iter().enumerate() { let pot = self.interpolate_potential(&surface_pot, electrode_pos); gain_data[s_idx][src_idx] = pot; } } } let sensor_names: Vec = sensors .iter() .filter(|s| matches!(s.sensor_type(), SensorType::Eeg)) .map(|s| s.name().to_string()) .collect(); GainMatrix::new(gain_data, free_ori, sensor_names) } /// Get number of surfaces pub fn n_surfaces(&self) -> usize { self.surfaces.len() } /// Get total number of triangles pub fn n_triangles(&self) -> usize { self.surfaces.iter().map(BemSurface::n_triangles).sum() } /// Get conductivities pub fn conductivities(&self) -> &[f64; 3] { &self.config.conductivities } // ========== Private methods ========== /// Compute BEM interaction block between two surfaces fn compute_bem_block( &self, surf_i: &BemSurface, surf_j: &BemSurface, same: bool, ) -> DMatrix { let ni = surf_i.n_triangles(); let nj = surf_j.n_triangles(); let mut block = DMatrix::zeros(ni, nj); for (i, ((ri, _ni), _ai)) in surf_i .centroids() .iter() .zip(surf_i.normals().iter()) .zip(surf_i.areas().iter()) .enumerate() { for (j, ((rj, nj_vec), aj)) in surf_j .centroids() .iter() .zip(surf_j.normals().iter()) .zip(surf_j.areas().iter()) .enumerate() { if same && i == j { // Self-term: solid angle / (2π) block[(i, j)] = *aj / (2.0 * PI); } else { // Off-diagonal: compute solid angle let solid_angle = self.compute_solid_angle(ri, rj, nj_vec, *aj); block[(i, j)] = solid_angle / (4.0 * PI); } } } block } /// Compute solid angle subtended by a triangle at a point fn compute_solid_angle( &self, point: &Position, center: &Position, normal: &Orientation, area: f64, ) -> f64 { let r = center - point; let r_norm = norm(&r); if r_norm < 1e-10 { return 2.0 * PI; // Point is at the triangle } // Approximate solid angle for small triangle let r_hat = r / r_norm; let cos_theta = dot(&r_hat, normal); area * cos_theta / (r_norm * r_norm) } /// Compute source contribution to surface potentials fn compute_source_potential( &self, dipole_pos: &Position, dipole_ori: &Orientation, ) -> DVector { let mut pot = DVector::zeros(self.n_collocation); let sigma_brain = self.config.conductivities[0]; let mut idx = 0; for surface in &self.surfaces { for (centroid, normal) in surface.centroids().iter().zip(surface.normals().iter()) { // Infinite medium potential at the surface let r = centroid - dipole_pos; let r_norm = norm(&r); if r_norm > 1e-10 { // V = (1 / 4πσ) * (p · r) / r³ let p_dot_r = dot(dipole_ori, &r); pot[idx] = p_dot_r / (4.0 * PI * sigma_brain * r_norm.powi(3)); // Add normal derivative contribution let dn = dot(&r, normal) / r_norm; pot[idx] += dn * p_dot_r / (4.0 * PI * sigma_brain * r_norm.powi(3)); } idx += 1; } } pot } /// Interpolate potential at electrode position from surface potentials fn interpolate_potential(&self, surface_pot: &DVector, electrode_pos: &Position) -> f64 { // Find nearest scalp triangle (scalp is the last surface) let scalp = &self.surfaces[2]; let offset: usize = self.surfaces[0].n_triangles() + self.surfaces[1].n_triangles(); let mut min_dist = f64::INFINITY; let mut nearest_idx = 0; for (i, centroid) in scalp.centroids().iter().enumerate() { let dist = norm(&(electrode_pos - centroid)); if dist < min_dist { min_dist = dist; nearest_idx = i; } } // Simple nearest-neighbor interpolation // (A more sophisticated implementation would use barycentric interpolation) surface_pot[offset + nearest_idx] } /// Invert a matrix using SVD fn invert_matrix(m: &DMatrix) -> ForwardResult> { let svd = m.clone().svd(true, true); let u = svd .u .ok_or_else(|| ForwardError::ComputationError("SVD failed: no U matrix".to_string()))?; let vt = svd .v_t .ok_or_else(|| ForwardError::ComputationError("SVD failed: no V matrix".to_string()))?; let s = svd.singular_values; let n = s.len(); // Compute pseudoinverse let tol = 1e-10 * s[0]; let s_inv = DMatrix::from_diagonal(&DVector::from_fn(n, |i, _| { if s[i] > tol { 1.0 / s[i] } else { 0.0 } })); Ok(vt.transpose() * &s_inv * u.transpose()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_bem_surface_sphere() { let surface = BemSurface::sphere([0.0, 0.0, 0.04], 0.08, 2, "test").unwrap(); assert!(surface.n_triangles() > 0); assert!(surface.n_vertices() > 0); assert_eq!(surface.name(), "test"); } #[test] fn test_bem_surface_properties() { let surface = BemSurface::sphere([0.0, 0.0, 0.0], 0.1, 1, "sphere").unwrap(); // Check that we have centroids, normals, and areas assert_eq!(surface.centroids().len(), surface.n_triangles()); assert_eq!(surface.normals().len(), surface.n_triangles()); assert_eq!(surface.areas().len(), surface.n_triangles()); // All normals should be unit vectors for n in surface.normals() { let len = norm(n); assert!( (len - 1.0).abs() < 1e-6 || len < 1e-10, "Normal length: {}", len ); } // All areas should be positive for &a in surface.areas() { assert!(a > 0.0); } } #[test] fn test_bem_config() { let config = BemConfig::default(); assert!((config.conductivities[0] - 0.33).abs() < 1e-10); assert!((config.conductivities[1] - 0.0042).abs() < 1e-10); assert!((config.conductivities[2] - 0.33).abs() < 1e-10); } #[test] fn test_bem_model_creation() { let model = BemModel::three_shell_sphere( [0.0, 0.0, 0.04], [0.06, 0.065, 0.08], // inner_skull, outer_skull, scalp 80, None, ) .unwrap(); assert_eq!(model.n_surfaces(), 3); assert!(model.n_triangles() > 0); } #[test] fn test_bem_matrix_computation() { let mut model = BemModel::three_shell_sphere([0.0, 0.0, 0.04], [0.06, 0.065, 0.08], 80, None).unwrap(); // This should complete without error model.make_bem_matrix().unwrap(); assert!(model.bem_matrix.is_some()); } #[test] fn test_bem_gain_matrix() { let mut model = BemModel::three_shell_sphere([0.0, 0.0, 0.04], [0.06, 0.065, 0.08], 80, None).unwrap(); model.make_bem_matrix().unwrap(); // Create EEG electrode array let sensors = SensorArray::eeg_10_20(0.085); // Create simple source space let sources = SourceSpace::create_spherical_shell([0.0, 0.0, 0.04], 0.03, 10); let gain = model.compute_gain(&sources, &sensors).unwrap(); assert_eq!(gain.n_sensors(), sensors.len()); assert_eq!(gain.n_source_columns(), sources.len()); // Fixed orientation } }