Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user