Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -0,0 +1,792 @@
//! 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<Position>,
/// 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<Position>,
/// Precomputed triangle normals
normals: Vec<Orientation>,
/// Precomputed triangle areas
areas: Vec<f64>,
}
impl BemSurface {
/// Create a BEM surface from vertices and triangles
pub fn new(
vertices: Vec<[f64; 3]>,
triangles: Vec<[usize; 3]>,
name: &str,
) -> ForwardResult<Self> {
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<Position> = 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<Self> {
// 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<BemSurface>,
/// Configuration
config: BemConfig,
/// BEM system matrix (precomputed)
bem_matrix: Option<DMatrix<f64>>,
/// 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<BemConfig>,
) -> ForwardResult<Self> {
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<BemConfig>,
) -> ForwardResult<Self> {
// 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<GainMatrix> {
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<String> = 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<f64> {
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<f64> {
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<f64>, 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<f64>) -> ForwardResult<DMatrix<f64>> {
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
}
}