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,674 @@
//! FEM stiffness matrix assembly for head modeling.
//!
//! Provides element-wise and global assembly of finite element
//! stiffness matrices using sparse matrix storage.
use crate::conductivity::{ConductivityTensor, TissueConductivity};
use crate::error::{FemError, FemResult};
use crate::mesh::{HeadMesh, TetElement};
use nalgebra::{Matrix4, Vector3};
use ndarray::Array2;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use sprs::{CsMatI, TriMat};
/// Element stiffness matrix (4x4 for linear tetrahedron)
#[derive(Debug, Clone)]
pub struct ElementStiffness {
/// Element index
pub element_idx: usize,
/// Local stiffness matrix (4x4)
pub matrix: Matrix4<f64>,
/// Global node indices
pub node_indices: [usize; 4],
}
impl ElementStiffness {
/// Compute element stiffness matrix
///
/// For a linear tetrahedral element:
/// Ke[i,j] = ∫ (∇Ni)^T · σ · (∇Nj) dV
/// = V * (∇Ni)^T · σ · (∇Nj) (constant for linear elements)
pub fn compute(elem: &TetElement, mesh: &HeadMesh, conductivity: &ConductivityTensor) -> Self {
let grads = elem.shape_gradients(&mesh.nodes);
let sigma = &conductivity.tensor;
let vol = elem.volume;
let mut ke = Matrix4::zeros();
for i in 0..4 {
for j in 0..4 {
// Ke[i,j] = V * grad(Ni)^T * σ * grad(Nj)
let grad_i = &grads[i];
let grad_j = &grads[j];
ke[(i, j)] = vol * grad_i.dot(&(sigma * grad_j));
}
}
Self {
element_idx: elem.id,
matrix: ke,
node_indices: elem.nodes,
}
}
}
/// Global sparse stiffness matrix in CSR format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StiffnessMatrix {
/// Number of rows (nodes)
pub n_rows: usize,
/// Number of columns (nodes)
pub n_cols: usize,
/// CSR row pointers
pub row_ptr: Vec<usize>,
/// CSR column indices
pub col_idx: Vec<usize>,
/// CSR values
pub values: Vec<f64>,
}
impl StiffnessMatrix {
/// Create from sprs CSR matrix
pub fn from_csr(csr: &CsMatI<f64, usize>) -> Self {
Self {
n_rows: csr.rows(),
n_cols: csr.cols(),
row_ptr: csr.indptr().as_slice().unwrap().to_vec(),
col_idx: csr.indices().to_vec(),
values: csr.data().to_vec(),
}
}
/// Convert to sprs CSR matrix
pub fn to_csr(&self) -> CsMatI<f64, usize> {
CsMatI::new(
(self.n_rows, self.n_cols),
self.row_ptr.clone(),
self.col_idx.clone(),
self.values.clone(),
)
}
/// Number of non-zeros
pub fn nnz(&self) -> usize {
self.values.len()
}
/// Sparsity ratio (nnz / n_elements)
pub fn sparsity(&self) -> f64 {
let total = (self.n_rows * self.n_cols) as f64;
if total > 0.0 {
self.values.len() as f64 / total
} else {
0.0
}
}
}
/// FEM assembler configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssemblerConfig {
/// Use parallel assembly
pub parallel: bool,
/// Chunk size for parallel assembly
pub chunk_size: usize,
/// Apply reference electrode (average reference)
pub apply_reference: bool,
/// Reference electrode node index (if not average)
pub reference_node: Option<usize>,
}
impl Default for AssemblerConfig {
fn default() -> Self {
Self {
parallel: true,
chunk_size: 1000,
apply_reference: true,
reference_node: None,
}
}
}
/// FEM matrix assembler
#[derive(Debug)]
pub struct FemAssembler {
/// Configuration
config: AssemblerConfig,
/// Mesh reference
mesh: HeadMesh,
/// Conductivity model
conductivity: TissueConductivity,
/// Element stiffness matrices
element_stiffness: Vec<ElementStiffness>,
/// Global stiffness matrix
global_stiffness: Option<CsMatI<f64, usize>>,
}
impl FemAssembler {
/// Create a new assembler
pub fn new(mesh: HeadMesh, conductivity: TissueConductivity, config: AssemblerConfig) -> Self {
Self {
config,
mesh,
conductivity,
element_stiffness: Vec::new(),
global_stiffness: None,
}
}
/// Create with default configuration
pub fn with_defaults(mesh: HeadMesh, conductivity: TissueConductivity) -> Self {
Self::new(mesh, conductivity, AssemblerConfig::default())
}
/// Get mesh reference
pub fn mesh(&self) -> &HeadMesh {
&self.mesh
}
/// Get conductivity model
pub fn conductivity(&self) -> &TissueConductivity {
&self.conductivity
}
/// Compute element stiffness matrices
pub fn compute_element_stiffness(&mut self) -> FemResult<()> {
// Ensure element conductivity tensors are computed
let mut conductivity = self.conductivity.clone();
if conductivity.element_tensors().is_none() {
conductivity.compute_element_tensors(&self.mesh)?;
}
let elements = &self.mesh.elements;
let _nodes = &self.mesh.nodes;
if self.config.parallel {
// Parallel computation
let element_stiffness: Vec<ElementStiffness> = elements
.par_iter()
.enumerate()
.map(|(idx, elem)| {
let tensor = conductivity
.element_tensor(idx)
.cloned()
.unwrap_or_else(|| ConductivityTensor::isotropic(0.33));
ElementStiffness::compute(elem, &self.mesh, &tensor)
})
.collect();
self.element_stiffness = element_stiffness;
} else {
// Sequential computation
self.element_stiffness.clear();
self.element_stiffness.reserve(elements.len());
for (idx, elem) in elements.iter().enumerate() {
let tensor = conductivity
.element_tensor(idx)
.cloned()
.unwrap_or_else(|| ConductivityTensor::isotropic(0.33));
self.element_stiffness
.push(ElementStiffness::compute(elem, &self.mesh, &tensor));
}
}
self.conductivity = conductivity;
Ok(())
}
/// Assemble global stiffness matrix
pub fn assemble_global(&mut self) -> FemResult<()> {
if self.element_stiffness.is_empty() {
self.compute_element_stiffness()?;
}
let n_nodes = self.mesh.n_nodes();
// Use triplet format for assembly
let mut triplets = TriMat::new((n_nodes, n_nodes));
// Estimate capacity (4*4 entries per element)
let estimated_nnz = self.element_stiffness.len() * 16;
triplets.reserve(estimated_nnz);
// Assemble element contributions
for elem_k in &self.element_stiffness {
for i in 0..4 {
let global_i = elem_k.node_indices[i];
for j in 0..4 {
let global_j = elem_k.node_indices[j];
let value = elem_k.matrix[(i, j)];
if value.abs() > 1e-15 {
triplets.add_triplet(global_i, global_j, value);
}
}
}
}
// Convert to CSR
let mut csr: CsMatI<f64, usize> = triplets.to_csr();
// Apply reference electrode constraint
if self.config.apply_reference {
csr = self.apply_reference_constraint(csr)?;
}
self.global_stiffness = Some(csr);
Ok(())
}
/// Apply reference electrode constraint to make system solvable
fn apply_reference_constraint(
&self,
mut stiffness: CsMatI<f64, usize>,
) -> FemResult<CsMatI<f64, usize>> {
let n = stiffness.rows();
if let Some(ref_node) = self.config.reference_node {
// Fix potential at reference node
// Set row and column to zero, diagonal to 1
if ref_node >= n {
return Err(FemError::AssemblyError(
"Reference node index out of bounds".into(),
));
}
// Convert to dense for modification (inefficient but safe)
let mut dense = self.sparse_to_dense(&stiffness);
// Zero out row and column
for j in 0..n {
dense[[ref_node, j]] = 0.0;
dense[[j, ref_node]] = 0.0;
}
dense[[ref_node, ref_node]] = 1.0;
// Convert back to sparse
stiffness = self.dense_to_sparse(&dense);
} else {
// Average reference: deflate matrix
// K_deflated = K - K*v*v^T - v*v^T*K + v^T*K*v * v*v^T
// where v = [1/√n, 1/√n, ..., 1/√n]^T
// This is expensive, so we just add a small regularization
// to the diagonal and use iterative solver with null space handling
// Simple approach: add regularization
let reg = 1e-10;
let mut dense = self.sparse_to_dense(&stiffness);
for i in 0..n {
dense[[i, i]] += reg;
}
stiffness = self.dense_to_sparse(&dense);
}
Ok(stiffness)
}
/// Convert sparse to dense matrix
fn sparse_to_dense(&self, sparse: &CsMatI<f64, usize>) -> Array2<f64> {
let (rows, cols) = (sparse.rows(), sparse.cols());
let mut dense = Array2::zeros((rows, cols));
for (&val, (row, col)) in sparse {
dense[[row, col]] = val;
}
dense
}
/// Convert dense to sparse matrix
fn dense_to_sparse(&self, dense: &Array2<f64>) -> CsMatI<f64, usize> {
let (rows, cols) = dense.dim();
let mut triplets = TriMat::new((rows, cols));
for i in 0..rows {
for j in 0..cols {
let val = dense[[i, j]];
if val.abs() > 1e-15 {
triplets.add_triplet(i, j, val);
}
}
}
triplets.to_csr()
}
/// Get global stiffness matrix
pub fn stiffness_matrix(&self) -> Option<&CsMatI<f64, usize>> {
self.global_stiffness.as_ref()
}
/// Get stiffness matrix in serializable format
pub fn stiffness_matrix_dto(&self) -> Option<StiffnessMatrix> {
self.global_stiffness
.as_ref()
.map(StiffnessMatrix::from_csr)
}
/// Get number of nodes
pub fn n_nodes(&self) -> usize {
self.mesh.n_nodes()
}
/// Get number of elements
pub fn n_elements(&self) -> usize {
self.mesh.n_elements()
}
/// Get assembly statistics
pub fn stats(&self) -> AssemblyStats {
let n_nodes = self.mesh.n_nodes();
let n_elements = self.mesh.n_elements();
let (nnz, sparsity) = if let Some(ref k) = self.global_stiffness {
(k.nnz(), k.nnz() as f64 / (n_nodes * n_nodes) as f64)
} else {
(0, 0.0)
};
AssemblyStats {
n_nodes,
n_elements,
n_element_stiffness: self.element_stiffness.len(),
nnz,
sparsity,
parallel: self.config.parallel,
}
}
}
/// Assembly statistics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssemblyStats {
/// Number of mesh nodes
pub n_nodes: usize,
/// Number of elements
pub n_elements: usize,
/// Number of computed element stiffness matrices
pub n_element_stiffness: usize,
/// Number of non-zeros in global matrix
pub nnz: usize,
/// Sparsity ratio
pub sparsity: f64,
/// Whether parallel assembly was used
pub parallel: bool,
}
/// Compute transfer matrix for sensors
///
/// Given electrode positions, compute the interpolation matrix that
/// maps nodal potentials to electrode potentials.
#[derive(Debug, Clone)]
pub struct TransferMatrix {
/// Electrode positions
pub electrode_positions: Vec<Vector3<f64>>,
/// Transfer matrix (n_electrodes x n_nodes)
pub matrix: Array2<f64>,
/// Element indices containing each electrode
pub electrode_elements: Vec<Option<usize>>,
}
impl TransferMatrix {
/// Compute transfer matrix for electrode positions
pub fn compute(mesh: &HeadMesh, electrodes: &[Vector3<f64>]) -> FemResult<Self> {
let n_electrodes = electrodes.len();
let n_nodes = mesh.n_nodes();
let mut matrix = Array2::zeros((n_electrodes, n_nodes));
let mut electrode_elements = Vec::with_capacity(n_electrodes);
for (i, pos) in electrodes.iter().enumerate() {
// Find element containing electrode
if let Some(elem_idx) = mesh.find_element(pos) {
let elem = &mesh.elements[elem_idx];
let bary = compute_barycentric(mesh, elem, pos);
// Interpolate using shape functions
for (j, &node_idx) in elem.nodes.iter().enumerate() {
matrix[[i, node_idx]] = bary[j];
}
electrode_elements.push(Some(elem_idx));
} else {
// Electrode outside mesh - find nearest node
let mut min_dist = f64::MAX;
let mut nearest_node = 0;
for (j, node) in mesh.nodes.iter().enumerate() {
let dist = (node.position - pos).norm();
if dist < min_dist {
min_dist = dist;
nearest_node = j;
}
}
matrix[[i, nearest_node]] = 1.0;
electrode_elements.push(None);
}
}
Ok(Self {
electrode_positions: electrodes.to_vec(),
matrix,
electrode_elements,
})
}
/// Apply transfer matrix to nodal potentials
pub fn apply(&self, nodal_potentials: &ndarray::Array1<f64>) -> ndarray::Array1<f64> {
self.matrix.dot(nodal_potentials)
}
/// Number of electrodes
pub fn n_electrodes(&self) -> usize {
self.electrode_positions.len()
}
}
/// Compute barycentric coordinates in a tetrahedron
fn compute_barycentric(mesh: &HeadMesh, elem: &TetElement, point: &Vector3<f64>) -> [f64; 4] {
let p0 = &mesh.nodes[elem.nodes[0]].position;
let p1 = &mesh.nodes[elem.nodes[1]].position;
let p2 = &mesh.nodes[elem.nodes[2]].position;
let p3 = &mesh.nodes[elem.nodes[3]].position;
// Compute volumes of sub-tetrahedra
let v_total = tetrahedron_volume(p0, p1, p2, p3);
if v_total.abs() < 1e-15 {
return [0.25, 0.25, 0.25, 0.25];
}
let l0 = tetrahedron_volume(point, p1, p2, p3) / v_total;
let l1 = tetrahedron_volume(p0, point, p2, p3) / v_total;
let l2 = tetrahedron_volume(p0, p1, point, p3) / v_total;
let l3 = 1.0 - l0 - l1 - l2;
[l0, l1, l2, l3]
}
/// Compute signed volume of tetrahedron
fn tetrahedron_volume(
p0: &Vector3<f64>,
p1: &Vector3<f64>,
p2: &Vector3<f64>,
p3: &Vector3<f64>,
) -> f64 {
let v1 = p1 - p0;
let v2 = p2 - p0;
let v3 = p3 - p0;
v1.dot(&v2.cross(&v3)) / 6.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesh::TissueLayer;
#[test]
fn test_element_stiffness() {
// Create simple mesh with one element
let mut mesh = HeadMesh::new();
mesh.nodes = vec![
crate::mesh::MeshNode::new(0, 0.0, 0.0, 0.0),
crate::mesh::MeshNode::new(1, 1.0, 0.0, 0.0),
crate::mesh::MeshNode::new(2, 0.0, 1.0, 0.0),
crate::mesh::MeshNode::new(3, 0.0, 0.0, 1.0),
];
let mut elem = crate::mesh::TetElement::new(0, [0, 1, 2, 3], TissueLayer::GrayMatter);
elem.compute_volume(&mesh.nodes);
mesh.elements.push(elem);
let sigma = ConductivityTensor::isotropic(0.33);
let ke = ElementStiffness::compute(&mesh.elements[0], &mesh, &sigma);
// Stiffness matrix should be symmetric
for i in 0..4 {
for j in 0..4 {
assert!((ke.matrix[(i, j)] - ke.matrix[(j, i)]).abs() < 1e-10);
}
}
// Row sums should be zero (conservation)
for i in 0..4 {
let row_sum: f64 = (0..4).map(|j| ke.matrix[(i, j)]).sum();
assert!(row_sum.abs() < 1e-10, "Row {} sum = {}", i, row_sum);
}
}
#[test]
fn test_assembler_creation() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
let conductivity = TissueConductivity::default_isotropic();
let assembler = FemAssembler::with_defaults(mesh, conductivity);
assert!(assembler.n_nodes() > 0);
assert!(assembler.n_elements() > 0);
}
#[test]
fn test_global_assembly() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
let conductivity = TissueConductivity::default_isotropic();
let mut assembler = FemAssembler::with_defaults(mesh, conductivity);
assembler.assemble_global().unwrap();
let k = assembler.stiffness_matrix().unwrap();
assert!(k.rows() == assembler.n_nodes());
assert!(k.cols() == assembler.n_nodes());
assert!(k.nnz() > 0);
// Check sparsity
let stats = assembler.stats();
assert!(stats.sparsity < 0.1); // Should be very sparse
}
#[test]
fn test_stiffness_symmetry() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
let conductivity = TissueConductivity::default_isotropic();
let mut assembler = FemAssembler::with_defaults(mesh, conductivity);
assembler.assemble_global().unwrap();
let k = assembler.stiffness_matrix().unwrap();
// Check symmetry for a few elements
for (&val, (i, j)) in k.iter().take(100) {
// Find K[j,i]
let mut found = false;
for (&v, (ii, jj)) in k.iter() {
if ii == j && jj == i {
assert!(
(val - v).abs() < 1e-10,
"K[{},{}] = {} != K[{},{}] = {}",
i,
j,
val,
j,
i,
v
);
found = true;
break;
}
}
if !found && val.abs() > 1e-15 {
panic!("No symmetric entry found for K[{},{}]", i, j);
}
}
}
#[test]
fn test_transfer_matrix() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
// Electrodes on scalp surface
let electrodes = vec![
Vector3::new(0.0, 0.0, 0.093),
Vector3::new(0.093, 0.0, 0.0),
Vector3::new(0.0, 0.093, 0.0),
];
let transfer = TransferMatrix::compute(&mesh, &electrodes).unwrap();
assert_eq!(transfer.n_electrodes(), 3);
assert_eq!(transfer.matrix.nrows(), 3);
assert_eq!(transfer.matrix.ncols(), mesh.n_nodes());
// Each row should sum to 1 (interpolation weights)
for i in 0..3 {
let row_sum: f64 = transfer.matrix.row(i).sum();
assert!((row_sum - 1.0).abs() < 1e-10, "Row {} sum = {}", i, row_sum);
}
}
#[test]
fn test_barycentric_coordinates() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
// Find an element and test barycentric at centroid
if let Some(elem) = mesh.elements.first() {
let centroid = elem.centroid(&mesh.nodes);
let bary = compute_barycentric(&mesh, elem, &centroid);
// At centroid, barycentric coords should be approximately equal
let avg = 0.25;
for (i, &b) in bary.iter().enumerate() {
assert!(
(b - avg).abs() < 0.1,
"Barycentric {} = {} (expected ~{})",
i,
b,
avg
);
}
// Should sum to 1
let sum: f64 = bary.iter().sum();
assert!((sum - 1.0).abs() < 1e-10);
}
}
#[test]
fn test_assembly_stats() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
let conductivity = TissueConductivity::default_isotropic();
let mut assembler = FemAssembler::with_defaults(mesh, conductivity);
assembler.assemble_global().unwrap();
let stats = assembler.stats();
assert!(stats.n_nodes > 0);
assert!(stats.n_elements > 0);
assert!(stats.nnz > 0);
assert!(stats.sparsity > 0.0);
assert!(stats.sparsity < 1.0);
}
}
@@ -0,0 +1,513 @@
//! Tissue conductivity models for FEM head modeling.
//!
//! Provides isotropic and anisotropic conductivity tensors
//! for different tissue types in the head.
use crate::error::{FemError, FemResult};
use crate::mesh::TissueLayer;
use nalgebra::{Matrix3, Vector3};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Conductivity tensor (3x3 symmetric matrix)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConductivityTensor {
/// Tensor components (symmetric)
pub tensor: Matrix3<f64>,
/// Whether this is isotropic
pub is_isotropic: bool,
}
impl ConductivityTensor {
/// Create an isotropic conductivity tensor
pub fn isotropic(sigma: f64) -> Self {
Self {
tensor: Matrix3::identity() * sigma,
is_isotropic: true,
}
}
/// Create an anisotropic conductivity tensor
pub fn anisotropic(tensor: Matrix3<f64>) -> Self {
Self {
tensor,
is_isotropic: false,
}
}
/// Create from principal conductivities and eigenvectors
pub fn from_principal(sigmas: &[f64; 3], eigenvectors: &Matrix3<f64>) -> Self {
// σ = V * diag(σ1, σ2, σ3) * V^T
let d = Matrix3::from_diagonal(&Vector3::new(sigmas[0], sigmas[1], sigmas[2]));
let tensor = eigenvectors * d * eigenvectors.transpose();
Self {
tensor,
is_isotropic: (sigmas[0] - sigmas[1]).abs() < 1e-10
&& (sigmas[1] - sigmas[2]).abs() < 1e-10,
}
}
/// Get scalar conductivity (trace/3 for anisotropic)
pub fn scalar(&self) -> f64 {
(self.tensor[(0, 0)] + self.tensor[(1, 1)] + self.tensor[(2, 2)]) / 3.0
}
/// Get conductivity in a specific direction
pub fn in_direction(&self, direction: &Vector3<f64>) -> f64 {
let d = direction.normalize();
d.dot(&(self.tensor * d))
}
/// Apply conductivity to electric field gradient
pub fn apply(&self, gradient: &Vector3<f64>) -> Vector3<f64> {
self.tensor * gradient
}
}
impl Default for ConductivityTensor {
fn default() -> Self {
Self::isotropic(1.0)
}
}
/// Anisotropy model type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AnisotropicModel {
/// Isotropic (no anisotropy)
Isotropic,
/// Volume-based constraint
VolumeConstraint,
/// DTI-based from diffusion tensor
DtiBased,
/// Fixed ratio anisotropy
FixedRatio,
}
/// Configuration for tissue conductivity
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TissueConfig {
/// Tissue type
pub tissue: TissueLayer,
/// Isotropic conductivity (S/m)
pub conductivity: f64,
/// Anisotropy model
pub anisotropy: AnisotropicModel,
/// Anisotropy ratio (longitudinal/transverse) for non-DTI models
pub anisotropy_ratio: f64,
}
impl TissueConfig {
/// Create with isotropic conductivity
pub fn isotropic(tissue: TissueLayer, conductivity: f64) -> Self {
Self {
tissue,
conductivity,
anisotropy: AnisotropicModel::Isotropic,
anisotropy_ratio: 1.0,
}
}
/// Create with anisotropic conductivity
pub fn anisotropic(
tissue: TissueLayer,
conductivity: f64,
model: AnisotropicModel,
ratio: f64,
) -> Self {
Self {
tissue,
conductivity,
anisotropy: model,
anisotropy_ratio: ratio,
}
}
}
/// Default conductivities for different tissue types (S/m)
pub fn default_conductivity(tissue: TissueLayer) -> f64 {
match tissue {
TissueLayer::Scalp => 0.43,
TissueLayer::Skull => 0.0042,
TissueLayer::Csf => 1.79,
TissueLayer::GrayMatter => 0.33,
TissueLayer::WhiteMatter => 0.14,
TissueLayer::Air => 1e-12,
}
}
/// Default anisotropy ratio for tissues
pub fn default_anisotropy_ratio(tissue: TissueLayer) -> f64 {
match tissue {
TissueLayer::Skull => 10.0, // Radial/tangential
TissueLayer::WhiteMatter => 9.0, // Along/perpendicular to fibers
_ => 1.0, // Isotropic
}
}
/// Tissue conductivity model for FEM
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TissueConductivity {
/// Per-tissue configurations
configs: HashMap<TissueLayer, TissueConfig>,
/// Per-element conductivity tensors (if computed)
element_tensors: Option<Vec<ConductivityTensor>>,
}
impl TissueConductivity {
/// Create with default isotropic conductivities
pub fn default_isotropic() -> Self {
let mut configs = HashMap::new();
for tissue in [
TissueLayer::Scalp,
TissueLayer::Skull,
TissueLayer::Csf,
TissueLayer::GrayMatter,
TissueLayer::WhiteMatter,
] {
configs.insert(
tissue,
TissueConfig::isotropic(tissue, default_conductivity(tissue)),
);
}
Self {
configs,
element_tensors: None,
}
}
/// Create with default anisotropic conductivities
pub fn default_anisotropic() -> Self {
let mut configs = HashMap::new();
// Isotropic tissues
for tissue in [
TissueLayer::Scalp,
TissueLayer::Csf,
TissueLayer::GrayMatter,
] {
configs.insert(
tissue,
TissueConfig::isotropic(tissue, default_conductivity(tissue)),
);
}
// Anisotropic skull
configs.insert(
TissueLayer::Skull,
TissueConfig::anisotropic(
TissueLayer::Skull,
default_conductivity(TissueLayer::Skull),
AnisotropicModel::FixedRatio,
default_anisotropy_ratio(TissueLayer::Skull),
),
);
// Anisotropic white matter
configs.insert(
TissueLayer::WhiteMatter,
TissueConfig::anisotropic(
TissueLayer::WhiteMatter,
default_conductivity(TissueLayer::WhiteMatter),
AnisotropicModel::VolumeConstraint,
default_anisotropy_ratio(TissueLayer::WhiteMatter),
),
);
Self {
configs,
element_tensors: None,
}
}
/// Set conductivity for a tissue
pub fn set_conductivity(&mut self, tissue: TissueLayer, sigma: f64) {
if let Some(config) = self.configs.get_mut(&tissue) {
config.conductivity = sigma;
} else {
self.configs
.insert(tissue, TissueConfig::isotropic(tissue, sigma));
}
self.element_tensors = None; // Invalidate cached tensors
}
/// Set anisotropy model for a tissue
pub fn set_anisotropy(&mut self, tissue: TissueLayer, model: AnisotropicModel, ratio: f64) {
if let Some(config) = self.configs.get_mut(&tissue) {
config.anisotropy = model;
config.anisotropy_ratio = ratio;
}
self.element_tensors = None;
}
/// Get configuration for a tissue
pub fn get_config(&self, tissue: TissueLayer) -> Option<&TissueConfig> {
self.configs.get(&tissue)
}
/// Get conductivity tensor for a tissue at a position
pub fn tensor_at(
&self,
tissue: TissueLayer,
position: &Vector3<f64>,
normal: Option<&Vector3<f64>>,
) -> ConductivityTensor {
let config = match self.configs.get(&tissue) {
Some(c) => c,
None => return ConductivityTensor::isotropic(default_conductivity(tissue)),
};
match config.anisotropy {
AnisotropicModel::Isotropic => ConductivityTensor::isotropic(config.conductivity),
AnisotropicModel::VolumeConstraint => {
// Volume-preserving anisotropy
self.volume_constraint_tensor(config, position, normal)
}
AnisotropicModel::FixedRatio => {
// Fixed ratio along normal direction
self.fixed_ratio_tensor(config, normal)
}
AnisotropicModel::DtiBased => {
// Would need DTI data - fall back to isotropic
ConductivityTensor::isotropic(config.conductivity)
}
}
}
/// Compute tensor with volume constraint
fn volume_constraint_tensor(
&self,
config: &TissueConfig,
position: &Vector3<f64>,
_normal: Option<&Vector3<f64>>,
) -> ConductivityTensor {
let sigma = config.conductivity;
let ratio = config.anisotropy_ratio;
// Get principal direction (radial from center for spherical)
let r = position.norm();
let radial = if r > 1e-10 {
position / r
} else {
Vector3::new(0.0, 0.0, 1.0)
};
// Volume constraint: σ_l * σ_t^2 = σ_iso^3
// With ratio = σ_l / σ_t
let sigma_t = (sigma.powi(3) / ratio).powf(1.0 / 3.0);
let sigma_l = sigma_t * ratio;
// Build tensor: σ = σ_t * I + (σ_l - σ_t) * r ⊗ r
let tensor =
Matrix3::identity() * sigma_t + (sigma_l - sigma_t) * radial * radial.transpose();
ConductivityTensor::anisotropic(tensor)
}
/// Compute tensor with fixed ratio along normal
fn fixed_ratio_tensor(
&self,
config: &TissueConfig,
normal: Option<&Vector3<f64>>,
) -> ConductivityTensor {
let sigma = config.conductivity;
let ratio = config.anisotropy_ratio;
let default_normal = Vector3::new(0.0, 0.0, 1.0);
let n = normal.unwrap_or(&default_normal);
let n = n.normalize();
// σ_radial = sigma * ratio, σ_tangential = sigma / sqrt(ratio)
let sigma_n = sigma * ratio.sqrt();
let sigma_t = sigma / ratio.sqrt();
let tensor = Matrix3::identity() * sigma_t + (sigma_n - sigma_t) * n * n.transpose();
ConductivityTensor::anisotropic(tensor)
}
/// Compute conductivity tensors for all elements
pub fn compute_element_tensors(&mut self, mesh: &crate::mesh::HeadMesh) -> FemResult<()> {
let n_elements = mesh.n_elements();
let mut tensors = Vec::with_capacity(n_elements);
for elem in &mesh.elements {
let centroid = elem.centroid(&mesh.nodes);
let normal = Some(centroid.normalize()); // Radial for spherical
let tensor = self.tensor_at(elem.tissue, &centroid, normal.as_ref());
tensors.push(tensor);
}
self.element_tensors = Some(tensors);
Ok(())
}
/// Get pre-computed element tensor
pub fn element_tensor(&self, element_idx: usize) -> Option<&ConductivityTensor> {
self.element_tensors.as_ref()?.get(element_idx)
}
/// Get all element tensors
pub fn element_tensors(&self) -> Option<&[ConductivityTensor]> {
self.element_tensors.as_deref()
}
}
impl Default for TissueConductivity {
fn default() -> Self {
Self::default_isotropic()
}
}
/// DTI-based conductivity model
#[derive(Debug, Clone)]
pub struct DtiConductivity {
/// Diffusion tensors per voxel
pub diffusion_tensors: Vec<Matrix3<f64>>,
/// Voxel positions
pub positions: Vec<Vector3<f64>>,
/// Conversion factor from diffusion to conductivity
pub d2c_factor: f64,
/// Minimum eigenvalue ratio
pub min_ratio: f64,
}
impl DtiConductivity {
/// Create from diffusion tensor data
pub fn new(
diffusion_tensors: Vec<Matrix3<f64>>,
positions: Vec<Vector3<f64>>,
) -> FemResult<Self> {
if diffusion_tensors.len() != positions.len() {
return Err(FemError::DimensionMismatch(
"Tensors and positions must have same length".into(),
));
}
Ok(Self {
diffusion_tensors,
positions,
d2c_factor: 0.736, // Tuch 2001: σ = 0.736 * D
min_ratio: 0.1,
})
}
/// Get conductivity tensor at a position (nearest neighbor interpolation)
pub fn tensor_at(&self, position: &Vector3<f64>) -> ConductivityTensor {
if self.positions.is_empty() {
return ConductivityTensor::isotropic(default_conductivity(TissueLayer::WhiteMatter));
}
// Find nearest DTI voxel
let mut min_dist = f64::MAX;
let mut nearest_idx = 0;
for (i, pos) in self.positions.iter().enumerate() {
let dist = (pos - position).norm_squared();
if dist < min_dist {
min_dist = dist;
nearest_idx = i;
}
}
// Convert diffusion to conductivity
let d = &self.diffusion_tensors[nearest_idx];
let sigma = d * self.d2c_factor;
// Ensure positive definiteness
let eigendecomp = sigma.symmetric_eigen();
let mut eigenvalues = eigendecomp.eigenvalues;
// Clamp eigenvalues
let max_ev = eigenvalues.max();
for ev in eigenvalues.iter_mut() {
*ev = ev.max(max_ev * self.min_ratio);
}
let d_clamped = Matrix3::from_diagonal(&eigenvalues);
let tensor = eigendecomp.eigenvectors * d_clamped * eigendecomp.eigenvectors.transpose();
ConductivityTensor::anisotropic(tensor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_isotropic_tensor() {
let tensor = ConductivityTensor::isotropic(0.33);
assert!(tensor.is_isotropic);
assert!((tensor.scalar() - 0.33).abs() < 1e-10);
// Same in all directions
let dir1 = Vector3::new(1.0, 0.0, 0.0);
let dir2 = Vector3::new(0.0, 1.0, 0.0);
assert!((tensor.in_direction(&dir1) - tensor.in_direction(&dir2)).abs() < 1e-10);
}
#[test]
fn test_anisotropic_tensor() {
let sigmas = [0.4, 0.2, 0.2];
let eigenvectors = Matrix3::identity();
let tensor = ConductivityTensor::from_principal(&sigmas, &eigenvectors);
assert!(!tensor.is_isotropic);
// Higher conductivity along x
let dir_x = Vector3::new(1.0, 0.0, 0.0);
let dir_y = Vector3::new(0.0, 1.0, 0.0);
assert!(tensor.in_direction(&dir_x) > tensor.in_direction(&dir_y));
}
#[test]
fn test_default_conductivities() {
assert!((default_conductivity(TissueLayer::GrayMatter) - 0.33).abs() < 1e-10);
assert!((default_conductivity(TissueLayer::Skull) - 0.0042).abs() < 1e-10);
assert!((default_conductivity(TissueLayer::Csf) - 1.79).abs() < 1e-10);
}
#[test]
fn test_tissue_conductivity() {
let conductivity = TissueConductivity::default_isotropic();
let config = conductivity.get_config(TissueLayer::GrayMatter).unwrap();
assert!((config.conductivity - 0.33).abs() < 1e-10);
assert_eq!(config.anisotropy, AnisotropicModel::Isotropic);
}
#[test]
fn test_anisotropic_conductivity() {
let conductivity = TissueConductivity::default_anisotropic();
let wm_config = conductivity.get_config(TissueLayer::WhiteMatter).unwrap();
assert_eq!(wm_config.anisotropy, AnisotropicModel::VolumeConstraint);
let skull_config = conductivity.get_config(TissueLayer::Skull).unwrap();
assert_eq!(skull_config.anisotropy, AnisotropicModel::FixedRatio);
}
#[test]
fn test_tensor_at() {
let conductivity = TissueConductivity::default_anisotropic();
let pos = Vector3::new(0.05, 0.0, 0.0);
let tensor = conductivity.tensor_at(TissueLayer::WhiteMatter, &pos, None);
assert!(!tensor.is_isotropic);
assert!(tensor.scalar() > 0.0);
}
#[test]
fn test_set_conductivity() {
let mut conductivity = TissueConductivity::default_isotropic();
conductivity.set_conductivity(TissueLayer::Skull, 0.01);
let config = conductivity.get_config(TissueLayer::Skull).unwrap();
assert!((config.conductivity - 0.01).abs() < 1e-10);
}
}
@@ -0,0 +1,60 @@
//! Error types for FEM head modeling.
use thiserror::Error;
/// FEM head modeling error types
#[derive(Debug, Error)]
pub enum FemError {
/// Invalid mesh configuration
#[error("Invalid mesh: {0}")]
InvalidMesh(String),
/// Mesh generation failure
#[error("Mesh generation failed: {0}")]
MeshGenerationError(String),
/// Invalid conductivity configuration
#[error("Invalid conductivity: {0}")]
InvalidConductivity(String),
/// Matrix assembly error
#[error("Assembly error: {0}")]
AssemblyError(String),
/// Solver failure
#[error("Solver error: {0}")]
SolverError(String),
/// Solver did not converge
#[error("Solver did not converge: {0}")]
ConvergenceError(String),
/// Invalid geometry
#[error("Invalid geometry: {0}")]
GeometryError(String),
/// Lead field computation error
#[error("Lead field error: {0}")]
LeadFieldError(String),
/// Dimension mismatch
#[error("Dimension mismatch: {0}")]
DimensionMismatch(String),
/// I/O error
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
/// FEA library error
#[error("FEA error: {0}")]
FeaError(String),
}
/// Result type for FEM operations
pub type FemResult<T> = Result<T, FemError>;
impl From<rtx_fea::FeaError> for FemError {
fn from(e: rtx_fea::FeaError) -> Self {
FemError::FeaError(e.to_string())
}
}
@@ -0,0 +1,693 @@
//! Lead field (gain matrix) computation for MEG/EEG forward modeling.
//!
//! The lead field matrix relates neural source currents to measured
//! sensor signals: M = L * J, where L is the lead field.
use crate::assembly::{FemAssembler, TransferMatrix};
use crate::error::{FemError, FemResult};
use crate::mesh::HeadMesh;
use crate::solver::{FemSolver, SolverConfig};
use nalgebra::Vector3;
use ndarray::{Array1, Array2, Axis};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::time::Instant;
/// Source type for lead field computation
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SourceType {
/// Current dipole
Dipole,
/// Monopole (point source)
Monopole,
}
/// Lead field configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeadFieldConfig {
/// Source type
pub source_type: SourceType,
/// Number of orientations per source (1 for fixed, 3 for free)
pub n_orientations: usize,
/// Whether to compute MEG lead field
pub compute_meg: bool,
/// Whether to compute EEG lead field
pub compute_eeg: bool,
/// Apply average reference to EEG
pub eeg_average_reference: bool,
/// Solver configuration
pub solver_config: SolverConfig,
/// Parallel computation
pub parallel: bool,
/// Verbose output
pub verbose: bool,
}
impl Default for LeadFieldConfig {
fn default() -> Self {
Self {
source_type: SourceType::Dipole,
n_orientations: 3,
compute_meg: false,
compute_eeg: true,
eeg_average_reference: true,
solver_config: SolverConfig::default(),
parallel: true,
verbose: false,
}
}
}
/// Source space definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceSpace {
/// Source positions (n_sources x 3)
pub positions: Array2<f64>,
/// Source normals/orientations (n_sources x 3, optional)
pub normals: Option<Array2<f64>>,
/// Element indices containing each source
pub element_indices: Vec<Option<usize>>,
}
impl SourceSpace {
/// Create source space from positions
pub fn from_positions(positions: Array2<f64>) -> Self {
let n_sources = positions.nrows();
Self {
positions,
normals: None,
element_indices: vec![None; n_sources],
}
}
/// Create source space with normals
pub fn with_normals(positions: Array2<f64>, normals: Array2<f64>) -> FemResult<Self> {
if positions.nrows() != normals.nrows() {
return Err(FemError::DimensionMismatch(
"Positions and normals must have same number of rows".into(),
));
}
let n_sources = positions.nrows();
Ok(Self {
positions,
normals: Some(normals),
element_indices: vec![None; n_sources],
})
}
/// Create regular grid source space
pub fn regular_grid(center: &Vector3<f64>, extent: &Vector3<f64>, spacing: f64) -> Self {
let nx = (extent.x / spacing).ceil() as usize;
let ny = (extent.y / spacing).ceil() as usize;
let nz = (extent.z / spacing).ceil() as usize;
let n_sources = nx * ny * nz;
let mut positions = Array2::zeros((n_sources, 3));
let mut idx = 0;
for i in 0..nx {
for j in 0..ny {
for k in 0..nz {
let x = center.x - extent.x / 2.0 + i as f64 * spacing;
let y = center.y - extent.y / 2.0 + j as f64 * spacing;
let z = center.z - extent.z / 2.0 + k as f64 * spacing;
positions[[idx, 0]] = x;
positions[[idx, 1]] = y;
positions[[idx, 2]] = z;
idx += 1;
}
}
}
Self::from_positions(positions)
}
/// Locate sources in mesh elements
pub fn locate_in_mesh(&mut self, mesh: &HeadMesh) {
for (i, row) in self.positions.axis_iter(Axis(0)).enumerate() {
let pos = Vector3::new(row[0], row[1], row[2]);
self.element_indices[i] = mesh.find_element(&pos);
}
}
/// Number of sources
pub fn n_sources(&self) -> usize {
self.positions.nrows()
}
/// Get source position
pub fn position(&self, idx: usize) -> Vector3<f64> {
Vector3::new(
self.positions[[idx, 0]],
self.positions[[idx, 1]],
self.positions[[idx, 2]],
)
}
/// Get source normal (or default)
pub fn normal(&self, idx: usize) -> Vector3<f64> {
if let Some(ref normals) = self.normals {
Vector3::new(normals[[idx, 0]], normals[[idx, 1]], normals[[idx, 2]])
} else {
Vector3::new(0.0, 0.0, 1.0)
}
}
}
/// EEG/MEG sensor positions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorArray {
/// Sensor positions (n_sensors x 3)
pub positions: Array2<f64>,
/// Sensor orientations for MEG (n_sensors x 3)
pub orientations: Option<Array2<f64>>,
/// Sensor labels
pub labels: Vec<String>,
/// Sensor type
pub sensor_type: SensorType,
}
/// Sensor type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SensorType {
/// EEG electrodes
EEG,
/// MEG magnetometers
Magnetometer,
/// MEG gradiometers
Gradiometer,
}
impl SensorArray {
/// Create EEG sensor array
pub fn eeg(positions: Array2<f64>, labels: Vec<String>) -> Self {
Self {
positions,
orientations: None,
labels,
sensor_type: SensorType::EEG,
}
}
/// Create MEG sensor array
pub fn meg(
positions: Array2<f64>,
orientations: Array2<f64>,
labels: Vec<String>,
sensor_type: SensorType,
) -> Self {
Self {
positions,
orientations: Some(orientations),
labels,
sensor_type,
}
}
/// Number of sensors
pub fn n_sensors(&self) -> usize {
self.positions.nrows()
}
/// Get sensor positions as vectors
pub fn position_vectors(&self) -> Vec<Vector3<f64>> {
(0..self.n_sensors())
.map(|i| {
Vector3::new(
self.positions[[i, 0]],
self.positions[[i, 1]],
self.positions[[i, 2]],
)
})
.collect()
}
}
/// Computed lead field matrix
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeadField {
/// Lead field matrix (n_sensors x n_sources * n_orientations)
pub matrix: Array2<f64>,
/// Number of sensors
pub n_sensors: usize,
/// Number of sources
pub n_sources: usize,
/// Number of orientations per source
pub n_orientations: usize,
/// Source positions
pub source_positions: Array2<f64>,
/// Sensor positions
pub sensor_positions: Array2<f64>,
/// Computation time (seconds)
pub computation_time: f64,
/// Sources inside mesh
pub sources_in_mesh: usize,
}
impl LeadField {
/// Get lead field for a single source and orientation
pub fn get_column(&self, source_idx: usize, orientation: usize) -> Array1<f64> {
let col_idx = source_idx * self.n_orientations + orientation;
self.matrix.column(col_idx).to_owned()
}
/// Get lead field for all orientations of a source
pub fn get_source(&self, source_idx: usize) -> Array2<f64> {
let start = source_idx * self.n_orientations;
let end = start + self.n_orientations;
self.matrix.slice(ndarray::s![.., start..end]).to_owned()
}
/// Apply lead field: M = L * J
pub fn apply(&self, source_currents: &Array1<f64>) -> FemResult<Array1<f64>> {
let expected_len = self.n_sources * self.n_orientations;
if source_currents.len() != expected_len {
return Err(FemError::DimensionMismatch(format!(
"Source currents length {} doesn't match expected {}",
source_currents.len(),
expected_len
)));
}
Ok(self.matrix.dot(source_currents))
}
}
/// Lead field computer
pub struct LeadFieldComputer<'a> {
/// Configuration
config: LeadFieldConfig,
/// FEM assembler
assembler: &'a FemAssembler,
/// FEM solver
solver: FemSolver,
/// Transfer matrix for EEG
eeg_transfer: Option<TransferMatrix>,
}
impl<'a> LeadFieldComputer<'a> {
/// Create lead field computer
pub fn new(assembler: &'a FemAssembler, config: LeadFieldConfig) -> FemResult<Self> {
let solver = FemSolver::from_assembler(assembler, config.solver_config.clone())?;
Ok(Self {
config,
assembler,
solver,
eeg_transfer: None,
})
}
/// Set EEG electrode positions
pub fn set_eeg_electrodes(&mut self, electrodes: &[Vector3<f64>]) -> FemResult<()> {
self.eeg_transfer = Some(TransferMatrix::compute(self.assembler.mesh(), electrodes)?);
Ok(())
}
/// Compute EEG lead field
pub fn compute_eeg(
&self,
sources: &SourceSpace,
sensors: &SensorArray,
) -> FemResult<LeadField> {
let start_time = Instant::now();
let n_sensors = sensors.n_sensors();
let n_sources = sources.n_sources();
let n_orient = self.config.n_orientations;
let n_cols = n_sources * n_orient;
if self.config.verbose {
eprintln!(
"Computing EEG lead field: {} sensors, {} sources, {} orientations",
n_sensors, n_sources, n_orient
);
}
// Compute transfer matrix if not already set
let transfer = if let Some(ref t) = self.eeg_transfer {
t.clone()
} else {
TransferMatrix::compute(self.assembler.mesh(), &sensors.position_vectors())?
};
// Compute lead field columns
let mut leadfield = Array2::zeros((n_sensors, n_cols));
let mut sources_in_mesh = 0;
// Define orientation vectors
let orientations: Vec<Vector3<f64>> = if n_orient == 1 {
vec![Vector3::new(0.0, 0.0, 1.0)]
} else {
vec![
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(0.0, 0.0, 1.0),
]
};
if self.config.parallel {
// Parallel computation over sources
let results: Vec<(usize, Vec<Array1<f64>>)> = (0..n_sources)
.into_par_iter()
.filter_map(|src_idx| {
let pos = sources.position(src_idx);
// Find element containing source
let elem_idx = match sources.element_indices.get(src_idx) {
Some(Some(idx)) => *idx,
_ => self.assembler.mesh().find_element(&pos)?,
};
// Compute for each orientation
let mut cols = Vec::with_capacity(n_orient);
for orient in &orientations {
// Create dipole RHS
let rhs = self.create_dipole_rhs(src_idx, elem_idx, orient);
// Solve
if let Ok(result) = self.solver.solve(&rhs) {
let nodal_potential = Array1::from_vec(result.solution);
let sensor_potential = transfer.apply(&nodal_potential);
cols.push(sensor_potential);
} else {
cols.push(Array1::zeros(n_sensors));
}
}
Some((src_idx, cols))
})
.collect();
// Assemble results
for (src_idx, cols) in results {
sources_in_mesh += 1;
for (orient_idx, col) in cols.iter().enumerate() {
let col_idx = src_idx * n_orient + orient_idx;
for (row_idx, &val) in col.iter().enumerate() {
leadfield[[row_idx, col_idx]] = val;
}
}
}
} else {
// Sequential computation
for src_idx in 0..n_sources {
let pos = sources.position(src_idx);
// Find element
let elem_idx = match sources.element_indices.get(src_idx) {
Some(Some(idx)) => *idx,
_ => {
if let Some(idx) = self.assembler.mesh().find_element(&pos) {
idx
} else {
continue;
}
}
};
sources_in_mesh += 1;
for (orient_idx, orient) in orientations.iter().enumerate() {
let rhs = self.create_dipole_rhs(src_idx, elem_idx, orient);
if let Ok(result) = self.solver.solve(&rhs) {
let nodal_potential = Array1::from_vec(result.solution);
let sensor_potential = transfer.apply(&nodal_potential);
let col_idx = src_idx * n_orient + orient_idx;
for (row_idx, &val) in sensor_potential.iter().enumerate() {
leadfield[[row_idx, col_idx]] = val;
}
}
}
if self.config.verbose && src_idx % 100 == 0 {
eprintln!("Computed source {}/{}", src_idx + 1, n_sources);
}
}
}
// Apply average reference
if self.config.eeg_average_reference {
leadfield = self.apply_average_reference(leadfield);
}
let computation_time = start_time.elapsed().as_secs_f64();
if self.config.verbose {
eprintln!(
"Lead field computed in {:.2}s ({} sources in mesh)",
computation_time, sources_in_mesh
);
}
Ok(LeadField {
matrix: leadfield,
n_sensors,
n_sources,
n_orientations: n_orient,
source_positions: sources.positions.clone(),
sensor_positions: sensors.positions.clone(),
computation_time,
sources_in_mesh,
})
}
/// Create RHS vector for a dipole at given position with given orientation
fn create_dipole_rhs(
&self,
_source_idx: usize,
element_idx: usize,
orientation: &Vector3<f64>,
) -> Array1<f64> {
let n_nodes = self.assembler.n_nodes();
let mut rhs = Array1::zeros(n_nodes);
let mesh = self.assembler.mesh();
let elem = &mesh.elements[element_idx];
let grads = elem.shape_gradients(&mesh.nodes);
// Dipole source: ∫ J_p · ∇φ dV = J_p · ∇φ * V (constant for linear tet)
// RHS contribution to node i: q * orientation · grad_i * volume
let q = 1.0; // Unit dipole moment
for (local_idx, &global_idx) in elem.nodes.iter().enumerate() {
let grad = &grads[local_idx];
rhs[global_idx] = q * orientation.dot(grad) * elem.volume;
}
rhs
}
/// Apply average reference to lead field
fn apply_average_reference(&self, mut leadfield: Array2<f64>) -> Array2<f64> {
let n_sensors = leadfield.nrows();
if n_sensors == 0 {
return leadfield;
}
// Subtract mean across sensors for each column
for mut col in leadfield.axis_iter_mut(Axis(1)) {
let mean = col.sum() / n_sensors as f64;
col -= mean;
}
leadfield
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assembly::FemAssembler;
use crate::conductivity::TissueConductivity;
use crate::mesh::HeadMesh;
fn create_test_assembler() -> FemAssembler {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
let conductivity = TissueConductivity::default_isotropic();
let mut assembler = FemAssembler::with_defaults(mesh, conductivity);
assembler.assemble_global().unwrap();
assembler
}
#[test]
fn test_source_space_creation() {
let mut positions = Array2::zeros((10, 3));
for i in 0..10 {
positions[[i, 0]] = i as f64 * 0.01;
positions[[i, 1]] = 0.0;
positions[[i, 2]] = 0.0;
}
let sources = SourceSpace::from_positions(positions);
assert_eq!(sources.n_sources(), 10);
}
#[test]
fn test_source_space_grid() {
let center = Vector3::new(0.0, 0.0, 0.0);
let extent = Vector3::new(0.04, 0.04, 0.04);
let spacing = 0.01;
let sources = SourceSpace::regular_grid(&center, &extent, spacing);
assert!(sources.n_sources() > 0);
}
#[test]
fn test_sensor_array() {
let mut positions = Array2::zeros((19, 3));
let labels: Vec<String> = (0..19).map(|i| format!("E{}", i)).collect();
let sensors = SensorArray::eeg(positions, labels);
assert_eq!(sensors.n_sensors(), 19);
assert_eq!(sensors.sensor_type, SensorType::EEG);
}
#[test]
fn test_leadfield_computer_creation() {
let assembler = create_test_assembler();
let config = LeadFieldConfig::default();
let computer = LeadFieldComputer::new(&assembler, config);
assert!(computer.is_ok());
}
#[test]
fn test_create_dipole_rhs() {
let assembler = create_test_assembler();
let config = LeadFieldConfig::default();
let computer = LeadFieldComputer::new(&assembler, config).unwrap();
let orientation = Vector3::new(0.0, 0.0, 1.0);
let rhs = computer.create_dipole_rhs(0, 0, &orientation);
assert_eq!(rhs.len(), assembler.n_nodes());
// RHS should be sparse (only 4 nodes per element)
let nonzeros = rhs.iter().filter(|&&v| v.abs() > 1e-15).count();
assert!(nonzeros <= 4);
}
#[test]
fn test_source_locate_in_mesh() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
let mut positions = Array2::zeros((5, 3));
// Points inside mesh
positions[[0, 2]] = 0.02; // Center-ish
positions[[1, 0]] = 0.04;
positions[[2, 1]] = 0.03;
// Point outside
positions[[3, 2]] = 0.5;
// Another inside
positions[[4, 2]] = 0.05;
let mut sources = SourceSpace::from_positions(positions);
sources.locate_in_mesh(&mesh);
// Some should be found
let found = sources
.element_indices
.iter()
.filter(|x| x.is_some())
.count();
assert!(found >= 2);
}
#[test]
fn test_average_reference() {
let assembler = create_test_assembler();
let config = LeadFieldConfig {
eeg_average_reference: true,
..Default::default()
};
let computer = LeadFieldComputer::new(&assembler, config).unwrap();
// Create test matrix
let mut matrix = Array2::zeros((10, 5));
for i in 0..10 {
for j in 0..5 {
matrix[[i, j]] = (i + j) as f64;
}
}
let ref_matrix = computer.apply_average_reference(matrix);
// Each column should have zero mean
for j in 0..5 {
let col_sum: f64 = ref_matrix.column(j).sum();
assert!(col_sum.abs() < 1e-10, "Column {} sum = {}", j, col_sum);
}
}
#[test]
fn test_leadfield_apply() {
// Create a simple lead field
let matrix = Array2::from_shape_vec(
(3, 6),
vec![
1.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 1.0, 0.0,
0.0, 2.0,
],
)
.unwrap();
let leadfield = LeadField {
matrix,
n_sensors: 3,
n_sources: 2,
n_orientations: 3,
source_positions: Array2::zeros((2, 3)),
sensor_positions: Array2::zeros((3, 3)),
computation_time: 0.0,
sources_in_mesh: 2,
};
// Apply to source currents
let currents = Array1::from_vec(vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]);
let sensors = leadfield.apply(&currents).unwrap();
assert_eq!(sensors.len(), 3);
assert!((sensors[0] - 1.0).abs() < 1e-10);
assert!((sensors[1] - 2.0).abs() < 1e-10);
assert!((sensors[2] - 0.0).abs() < 1e-10);
}
#[test]
fn test_leadfield_get_source() {
let matrix = Array2::from_shape_vec(
(2, 6),
vec![
1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
],
)
.unwrap();
let leadfield = LeadField {
matrix,
n_sensors: 2,
n_sources: 2,
n_orientations: 3,
source_positions: Array2::zeros((2, 3)),
sensor_positions: Array2::zeros((2, 3)),
computation_time: 0.0,
sources_in_mesh: 2,
};
// Get first source
let src0 = leadfield.get_source(0);
assert_eq!(src0.dim(), (2, 3));
assert!((src0[[0, 0]] - 1.0).abs() < 1e-10);
assert!((src0[[0, 2]] - 3.0).abs() < 1e-10);
// Get second source
let src1 = leadfield.get_source(1);
assert!((src1[[0, 0]] - 4.0).abs() < 1e-10);
}
}
@@ -0,0 +1,75 @@
//! # rtx-neuro-fem
//!
//! GPU-Accelerated Finite Element Head Modeling for MEG/EEG forward solutions.
//!
//! This crate provides realistic head models using the Finite Element Method (FEM)
//! with GPU-accelerated solvers for computing electric potentials and lead fields.
//!
//! ## Key Features
//!
//! - **Tetrahedral Meshing**: Automatic mesh generation from FreeSurfer surfaces
//! - **Multi-Layer Models**: Support for scalp, skull, CSF, gray/white matter
//! - **Anisotropic Conductivity**: DTI-based white matter anisotropy
//! - **GPU Acceleration**: Leverages rtx-fea for fast sparse solvers
//! - **Lead Field Computation**: Efficient gain matrix calculation
//!
//! ## Head Model Layers
//!
//! ```text
//! ┌─────────────────────────────────────┐
//! │ Scalp (σ=0.43 S/m) │
//! ├─────────────────────────────────────┤
//! │ Skull (σ=0.01 S/m) │ ← Anisotropic
//! ├─────────────────────────────────────┤
//! │ CSF (σ=1.79 S/m) │
//! ├─────────────────────────────────────┤
//! │ Gray Matter (σ=0.33 S/m) │
//! ├─────────────────────────────────────┤
//! │ White Matter (σ=0.14 S/m tensor) │ ← Anisotropic from DTI
//! └─────────────────────────────────────┘
//! ```
//!
//! ## Example
//!
//! ```ignore
//! use rtx_neuro_fem::{HeadFEM, HeadFEMConfig, TissueConfig};
//!
//! // Create FEM model from FreeSurfer surfaces
//! let config = HeadFEMConfig::standard_5_layer();
//!
//! let mut fem = HeadFEM::new(config)?;
//!
//! // Generate mesh from surfaces
//! fem.mesh_from_surfaces(&surfaces)?;
//!
//! // Compute lead field matrix
//! let leadfield = fem.compute_leadfield(&source_space, &sensors)?;
//! ```
#![warn(missing_docs)]
pub mod assembly;
pub mod conductivity;
pub mod error;
pub mod leadfield;
pub mod mesh;
pub mod solver;
// Re-export main types
pub use assembly::{FemAssembler, StiffnessMatrix};
pub use conductivity::{AnisotropicModel, ConductivityTensor, TissueConductivity};
pub use error::{FemError, FemResult};
pub use leadfield::{LeadField, LeadFieldConfig};
pub use mesh::{HeadMesh, MeshQuality, TetElement};
pub use solver::{FemSolver, SolverConfig};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_imports() {
// Test that main types are accessible
let _: FemResult<()> = Ok(());
}
}
@@ -0,0 +1,748 @@
//! Tetrahedral mesh generation for head models.
//!
//! Provides mesh generation from FreeSurfer surfaces and layered
//! spherical models for FEM forward solutions.
use crate::error::{FemError, FemResult};
use nalgebra::{Matrix3, Vector3};
use ndarray::Array2;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Tissue layer identifier
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TissueLayer {
/// Scalp/skin layer
Scalp,
/// Skull bone
Skull,
/// Cerebrospinal fluid
Csf,
/// Gray matter (cortex)
GrayMatter,
/// White matter
WhiteMatter,
/// Air (sinuses)
Air,
}
impl TissueLayer {
/// Get layer index for mesh material ID
pub fn index(&self) -> usize {
match self {
TissueLayer::WhiteMatter => 0,
TissueLayer::GrayMatter => 1,
TissueLayer::Csf => 2,
TissueLayer::Skull => 3,
TissueLayer::Scalp => 4,
TissueLayer::Air => 5,
}
}
/// Get layer from index
pub fn from_index(idx: usize) -> Option<Self> {
match idx {
0 => Some(TissueLayer::WhiteMatter),
1 => Some(TissueLayer::GrayMatter),
2 => Some(TissueLayer::Csf),
3 => Some(TissueLayer::Skull),
4 => Some(TissueLayer::Scalp),
5 => Some(TissueLayer::Air),
_ => None,
}
}
}
/// A node in the tetrahedral mesh
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshNode {
/// Node ID
pub id: usize,
/// Position in 3D space (meters)
pub position: Vector3<f64>,
/// Whether this is a boundary node
pub is_boundary: bool,
}
impl MeshNode {
/// Create a new mesh node
pub fn new(id: usize, x: f64, y: f64, z: f64) -> Self {
Self {
id,
position: Vector3::new(x, y, z),
is_boundary: false,
}
}
}
/// A tetrahedral element
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TetElement {
/// Element ID
pub id: usize,
/// Node indices (4 nodes for linear tetrahedron)
pub nodes: [usize; 4],
/// Tissue layer this element belongs to
pub tissue: TissueLayer,
/// Element volume (m³)
pub volume: f64,
}
impl TetElement {
/// Create a new tetrahedral element
pub fn new(id: usize, nodes: [usize; 4], tissue: TissueLayer) -> Self {
Self {
id,
nodes,
tissue,
volume: 0.0,
}
}
/// Compute element volume from node positions
pub fn compute_volume(&mut self, mesh_nodes: &[MeshNode]) -> f64 {
let p0 = &mesh_nodes[self.nodes[0]].position;
let p1 = &mesh_nodes[self.nodes[1]].position;
let p2 = &mesh_nodes[self.nodes[2]].position;
let p3 = &mesh_nodes[self.nodes[3]].position;
// Volume = |det([p1-p0, p2-p0, p3-p0])| / 6
let v1 = p1 - p0;
let v2 = p2 - p0;
let v3 = p3 - p0;
let det = v1.dot(&v2.cross(&v3));
self.volume = det.abs() / 6.0;
self.volume
}
/// Compute shape function gradients (constant for linear tet)
pub fn shape_gradients(&self, mesh_nodes: &[MeshNode]) -> [Vector3<f64>; 4] {
let p0 = &mesh_nodes[self.nodes[0]].position;
let p1 = &mesh_nodes[self.nodes[1]].position;
let p2 = &mesh_nodes[self.nodes[2]].position;
let p3 = &mesh_nodes[self.nodes[3]].position;
// Jacobian matrix
let j = Matrix3::new(
p1.x - p0.x,
p2.x - p0.x,
p3.x - p0.x,
p1.y - p0.y,
p2.y - p0.y,
p3.y - p0.y,
p1.z - p0.z,
p2.z - p0.z,
p3.z - p0.z,
);
let det = j.determinant();
if det.abs() < 1e-15 {
return [Vector3::zeros(); 4];
}
let j_inv = j.try_inverse().unwrap_or(Matrix3::identity());
// Shape function gradients in reference coordinates
// N0 = 1 - xi - eta - zeta, N1 = xi, N2 = eta, N3 = zeta
let grad_ref = [
Vector3::new(-1.0, -1.0, -1.0),
Vector3::new(1.0, 0.0, 0.0),
Vector3::new(0.0, 1.0, 0.0),
Vector3::new(0.0, 0.0, 1.0),
];
// Transform to physical coordinates
[
j_inv.transpose() * grad_ref[0],
j_inv.transpose() * grad_ref[1],
j_inv.transpose() * grad_ref[2],
j_inv.transpose() * grad_ref[3],
]
}
/// Get element centroid
pub fn centroid(&self, mesh_nodes: &[MeshNode]) -> Vector3<f64> {
let mut c = Vector3::zeros();
for &ni in &self.nodes {
c += mesh_nodes[ni].position;
}
c / 4.0
}
}
/// Mesh quality metrics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshQuality {
/// Minimum element volume
pub min_volume: f64,
/// Maximum element volume
pub max_volume: f64,
/// Average element volume
pub avg_volume: f64,
/// Minimum aspect ratio (1 = ideal)
pub min_aspect_ratio: f64,
/// Average aspect ratio
pub avg_aspect_ratio: f64,
/// Number of degenerate elements (volume < tolerance)
pub n_degenerate: usize,
}
/// Head mesh for FEM forward modeling
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeadMesh {
/// Mesh nodes
pub nodes: Vec<MeshNode>,
/// Tetrahedral elements
pub elements: Vec<TetElement>,
/// Number of elements per tissue layer
pub elements_per_layer: HashMap<TissueLayer, usize>,
/// Mesh quality metrics
pub quality: Option<MeshQuality>,
}
impl HeadMesh {
/// Create a new empty head mesh
pub fn new() -> Self {
Self {
nodes: Vec::new(),
elements: Vec::new(),
elements_per_layer: HashMap::new(),
quality: None,
}
}
/// Create a layered spherical head mesh
///
/// # Arguments
/// * `radii` - Radii for each layer boundary (inner to outer)
/// * `n_radial` - Number of radial divisions per layer
/// * `n_angular` - Number of angular divisions (icosahedral refinement level)
pub fn spherical(
radii: &[(TissueLayer, f64)],
n_radial: usize,
n_angular: usize,
) -> FemResult<Self> {
let mut mesh = HeadMesh::new();
if radii.is_empty() {
return Err(FemError::InvalidMesh("No layer radii specified".into()));
}
// Generate nodes on concentric spherical shells
let n_shells = radii.len() * n_radial + 1;
let mut shell_radii = Vec::with_capacity(n_shells);
// Inner point at center
shell_radii.push(0.0);
for (i, (_, outer_r)) in radii.iter().enumerate() {
let inner_r = if i == 0 { 0.0 } else { radii[i - 1].1 };
for j in 1..=n_radial {
let t = j as f64 / n_radial as f64;
let r = inner_r + t * (outer_r - inner_r);
shell_radii.push(r);
}
}
// Generate icosahedral points on each shell
let ico_points = generate_icosahedral_points(n_angular);
let n_points_per_shell = ico_points.len();
// Center node
mesh.nodes.push(MeshNode::new(0, 0.0, 0.0, 0.0));
// Nodes on shells
for (shell_idx, &r) in shell_radii.iter().enumerate().skip(1) {
for (pt_idx, pt) in ico_points.iter().enumerate() {
let node_id = 1 + (shell_idx - 1) * n_points_per_shell + pt_idx;
let pos = pt * r;
let mut node = MeshNode::new(node_id, pos.x, pos.y, pos.z);
node.is_boundary = shell_idx == shell_radii.len() - 1;
mesh.nodes.push(node);
}
}
// Generate tetrahedral elements
// Connect center to first shell
let first_shell_start = 1;
for tri in generate_icosahedral_triangles(n_angular) {
let n0 = 0; // center
let n1 = first_shell_start + tri[0];
let n2 = first_shell_start + tri[1];
let n3 = first_shell_start + tri[2];
let tissue = radii[0].0;
let elem_id = mesh.elements.len();
mesh.elements
.push(TetElement::new(elem_id, [n0, n1, n2, n3], tissue));
}
// Connect adjacent shells with prism-split tetrahedra
for shell in 1..shell_radii.len() - 1 {
let inner_start = 1 + (shell - 1) * n_points_per_shell;
let outer_start = inner_start + n_points_per_shell;
// Determine tissue layer
let layer_idx = shell / n_radial;
let tissue = radii[layer_idx.min(radii.len() - 1)].0;
for tri in generate_icosahedral_triangles(n_angular) {
// Inner and outer triangle nodes
let i0 = inner_start + tri[0];
let i1 = inner_start + tri[1];
let i2 = inner_start + tri[2];
let o0 = outer_start + tri[0];
let o1 = outer_start + tri[1];
let o2 = outer_start + tri[2];
// Split prism into 3 tetrahedra
let elem_id = mesh.elements.len();
mesh.elements
.push(TetElement::new(elem_id, [i0, i1, i2, o0], tissue));
mesh.elements
.push(TetElement::new(elem_id + 1, [i1, i2, o0, o1], tissue));
mesh.elements
.push(TetElement::new(elem_id + 2, [i2, o0, o1, o2], tissue));
}
}
// Compute element volumes and count per layer
for elem in &mut mesh.elements {
elem.compute_volume(&mesh.nodes);
*mesh.elements_per_layer.entry(elem.tissue).or_insert(0) += 1;
}
mesh.compute_quality();
Ok(mesh)
}
/// Create a simple 3-layer spherical head
pub fn three_layer_sphere(
brain_radius: f64,
skull_thickness: f64,
scalp_thickness: f64,
n_radial: usize,
n_angular: usize,
) -> FemResult<Self> {
let radii = vec![
(TissueLayer::GrayMatter, brain_radius),
(TissueLayer::Skull, brain_radius + skull_thickness),
(
TissueLayer::Scalp,
brain_radius + skull_thickness + scalp_thickness,
),
];
Self::spherical(&radii, n_radial, n_angular)
}
/// Create a 5-layer spherical head (with CSF and white matter)
pub fn five_layer_sphere(
white_radius: f64,
gray_thickness: f64,
csf_thickness: f64,
skull_thickness: f64,
scalp_thickness: f64,
n_radial: usize,
n_angular: usize,
) -> FemResult<Self> {
let gray_r = white_radius + gray_thickness;
let csf_r = gray_r + csf_thickness;
let skull_r = csf_r + skull_thickness;
let scalp_r = skull_r + scalp_thickness;
let radii = vec![
(TissueLayer::WhiteMatter, white_radius),
(TissueLayer::GrayMatter, gray_r),
(TissueLayer::Csf, csf_r),
(TissueLayer::Skull, skull_r),
(TissueLayer::Scalp, scalp_r),
];
Self::spherical(&radii, n_radial, n_angular)
}
/// Number of nodes
pub fn n_nodes(&self) -> usize {
self.nodes.len()
}
/// Number of elements
pub fn n_elements(&self) -> usize {
self.elements.len()
}
/// Compute mesh quality metrics
pub fn compute_quality(&mut self) {
if self.elements.is_empty() {
return;
}
let mut min_vol = f64::MAX;
let mut max_vol: f64 = 0.0;
let mut sum_vol: f64 = 0.0;
let mut n_degenerate = 0;
let mut sum_aspect = 0.0;
let mut min_aspect = f64::MAX;
let vol_tol = 1e-20;
for elem in &self.elements {
let vol = elem.volume;
min_vol = min_vol.min(vol);
max_vol = max_vol.max(vol);
sum_vol += vol;
if vol < vol_tol {
n_degenerate += 1;
}
// Compute aspect ratio (edge length ratio)
let aspect = self.element_aspect_ratio(elem);
sum_aspect += aspect;
min_aspect = min_aspect.min(aspect);
}
let n = self.elements.len() as f64;
self.quality = Some(MeshQuality {
min_volume: min_vol,
max_volume: max_vol,
avg_volume: sum_vol / n,
min_aspect_ratio: min_aspect,
avg_aspect_ratio: sum_aspect / n,
n_degenerate,
});
}
/// Compute aspect ratio for an element (shortest edge / longest edge)
fn element_aspect_ratio(&self, elem: &TetElement) -> f64 {
let edges = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)];
let mut min_len = f64::MAX;
let mut max_len: f64 = 0.0;
for (i, j) in edges {
let p1 = &self.nodes[elem.nodes[i]].position;
let p2 = &self.nodes[elem.nodes[j]].position;
let len = (p1 - p2).norm();
min_len = min_len.min(len);
max_len = max_len.max(len);
}
if max_len > 1e-15 {
min_len / max_len
} else {
0.0
}
}
/// Get total mesh volume
pub fn total_volume(&self) -> f64 {
self.elements.iter().map(|e| e.volume).sum()
}
/// Get volume per tissue layer
pub fn volume_per_layer(&self) -> HashMap<TissueLayer, f64> {
let mut volumes = HashMap::new();
for elem in &self.elements {
*volumes.entry(elem.tissue).or_insert(0.0) += elem.volume;
}
volumes
}
/// Find element containing a point
pub fn find_element(&self, point: &Vector3<f64>) -> Option<usize> {
for (i, elem) in self.elements.iter().enumerate() {
if self.point_in_element(elem, point) {
return Some(i);
}
}
None
}
/// Check if a point is inside an element using barycentric coordinates
fn point_in_element(&self, elem: &TetElement, point: &Vector3<f64>) -> bool {
let p0 = &self.nodes[elem.nodes[0]].position;
let p1 = &self.nodes[elem.nodes[1]].position;
let p2 = &self.nodes[elem.nodes[2]].position;
let p3 = &self.nodes[elem.nodes[3]].position;
// Compute barycentric coordinates
let v0 = p1 - p0;
let v1 = p2 - p0;
let v2 = p3 - p0;
let vp = point - p0;
let d00 = v0.dot(&v0);
let d01 = v0.dot(&v1);
let d02 = v0.dot(&v2);
let d11 = v1.dot(&v1);
let d12 = v1.dot(&v2);
let d22 = v2.dot(&v2);
let dp0 = vp.dot(&v0);
let dp1 = vp.dot(&v1);
let dp2 = vp.dot(&v2);
let det = d00 * (d11 * d22 - d12 * d12) - d01 * (d01 * d22 - d12 * d02)
+ d02 * (d01 * d12 - d11 * d02);
if det.abs() < 1e-15 {
return false;
}
let inv_det = 1.0 / det;
// Compute barycentric coordinates
let l1 = inv_det
* (dp0 * (d11 * d22 - d12 * d12)
+ dp1 * (d02 * d12 - d01 * d22)
+ dp2 * (d01 * d12 - d02 * d11));
let l2 = inv_det
* (dp0 * (d12 * d02 - d01 * d22)
+ dp1 * (d00 * d22 - d02 * d02)
+ dp2 * (d01 * d02 - d00 * d12));
let l3 = inv_det
* (dp0 * (d01 * d12 - d11 * d02)
+ dp1 * (d01 * d02 - d00 * d12)
+ dp2 * (d00 * d11 - d01 * d01));
let l0 = 1.0 - l1 - l2 - l3;
let eps = -1e-10;
l0 >= eps && l1 >= eps && l2 >= eps && l3 >= eps
}
/// Get node positions as array
pub fn node_positions(&self) -> Array2<f64> {
let n = self.nodes.len();
let mut pos = Array2::zeros((n, 3));
for (i, node) in self.nodes.iter().enumerate() {
pos[[i, 0]] = node.position.x;
pos[[i, 1]] = node.position.y;
pos[[i, 2]] = node.position.z;
}
pos
}
/// Get element connectivity as array
pub fn element_connectivity(&self) -> Array2<usize> {
let n = self.elements.len();
let mut conn = Array2::zeros((n, 4));
for (i, elem) in self.elements.iter().enumerate() {
for j in 0..4 {
conn[[i, j]] = elem.nodes[j];
}
}
conn
}
}
impl Default for HeadMesh {
fn default() -> Self {
Self::new()
}
}
/// Generate icosahedral sphere mesh (vertices and faces) at given refinement level
fn generate_icosahedral_mesh(refinement: usize) -> (Vec<Vector3<f64>>, Vec<[usize; 3]>) {
// Base icosahedron vertices
let phi = f64::midpoint(1.0, 5.0_f64.sqrt());
let scale = 1.0 / (1.0 + phi * phi).sqrt();
let mut vertices = vec![
Vector3::new(0.0, 1.0, phi) * scale,
Vector3::new(0.0, -1.0, phi) * scale,
Vector3::new(0.0, 1.0, -phi) * scale,
Vector3::new(0.0, -1.0, -phi) * scale,
Vector3::new(1.0, phi, 0.0) * scale,
Vector3::new(-1.0, phi, 0.0) * scale,
Vector3::new(1.0, -phi, 0.0) * scale,
Vector3::new(-1.0, -phi, 0.0) * scale,
Vector3::new(phi, 0.0, 1.0) * scale,
Vector3::new(-phi, 0.0, 1.0) * scale,
Vector3::new(phi, 0.0, -1.0) * scale,
Vector3::new(-phi, 0.0, -1.0) * scale,
];
let mut faces = vec![
[0, 1, 8],
[0, 8, 4],
[0, 4, 5],
[0, 5, 9],
[0, 9, 1],
[1, 6, 8],
[8, 6, 10],
[8, 10, 4],
[4, 10, 2],
[4, 2, 5],
[5, 2, 11],
[5, 11, 9],
[9, 11, 7],
[9, 7, 1],
[1, 7, 6],
[3, 6, 7],
[3, 7, 11],
[3, 11, 2],
[3, 2, 10],
[3, 10, 6],
];
// Refine by subdividing triangles
for _ in 0..refinement {
let mut new_faces = Vec::with_capacity(faces.len() * 4);
let mut edge_midpoints: HashMap<(usize, usize), usize> = HashMap::new();
for face in &faces {
let mut mid = [0usize; 3];
for i in 0..3 {
let (a, b) = (face[i], face[(i + 1) % 3]);
let key = if a < b { (a, b) } else { (b, a) };
mid[i] = *edge_midpoints.entry(key).or_insert_with(|| {
let midpoint = (vertices[a] + vertices[b]).normalize();
vertices.push(midpoint);
vertices.len() - 1
});
}
// Create 4 new triangles
new_faces.push([face[0], mid[0], mid[2]]);
new_faces.push([face[1], mid[1], mid[0]]);
new_faces.push([face[2], mid[2], mid[1]]);
new_faces.push([mid[0], mid[1], mid[2]]);
}
faces = new_faces;
}
(vertices, faces)
}
/// Generate points on an icosahedral sphere at given refinement level
fn generate_icosahedral_points(refinement: usize) -> Vec<Vector3<f64>> {
let (vertices, _) = generate_icosahedral_mesh(refinement);
vertices
}
/// Generate icosahedral triangle indices at given refinement level
fn generate_icosahedral_triangles(refinement: usize) -> Vec<[usize; 3]> {
let (_, faces) = generate_icosahedral_mesh(refinement);
faces
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tissue_layer_index() {
assert_eq!(TissueLayer::WhiteMatter.index(), 0);
assert_eq!(TissueLayer::Scalp.index(), 4);
assert_eq!(TissueLayer::from_index(0), Some(TissueLayer::WhiteMatter));
}
#[test]
fn test_mesh_node() {
let node = MeshNode::new(0, 1.0, 2.0, 3.0);
assert_eq!(node.id, 0);
assert!((node.position.x - 1.0).abs() < 1e-10);
}
#[test]
fn test_tet_element_volume() {
let nodes = vec![
MeshNode::new(0, 0.0, 0.0, 0.0),
MeshNode::new(1, 1.0, 0.0, 0.0),
MeshNode::new(2, 0.0, 1.0, 0.0),
MeshNode::new(3, 0.0, 0.0, 1.0),
];
let mut elem = TetElement::new(0, [0, 1, 2, 3], TissueLayer::GrayMatter);
let vol = elem.compute_volume(&nodes);
// Volume of unit tetrahedron = 1/6
assert!((vol - 1.0 / 6.0).abs() < 1e-10);
}
#[test]
fn test_icosahedral_points() {
let points = generate_icosahedral_points(0);
assert_eq!(points.len(), 12); // Base icosahedron
let points = generate_icosahedral_points(1);
assert!(points.len() > 12); // Refined
// All points should be on unit sphere
for p in &points {
assert!((p.norm() - 1.0).abs() < 1e-10);
}
}
#[test]
fn test_three_layer_sphere() {
let mesh = HeadMesh::three_layer_sphere(
0.08, // brain
0.007, // skull
0.006, // scalp
2, // radial divisions
1, // angular refinement
)
.unwrap();
assert!(mesh.n_nodes() > 0);
assert!(mesh.n_elements() > 0);
// Should have elements in each layer
assert!(
mesh.elements_per_layer
.contains_key(&TissueLayer::GrayMatter)
);
assert!(mesh.elements_per_layer.contains_key(&TissueLayer::Skull));
assert!(mesh.elements_per_layer.contains_key(&TissueLayer::Scalp));
}
#[test]
fn test_five_layer_sphere() {
let mesh = HeadMesh::five_layer_sphere(
0.06, // white matter
0.015, // gray matter
0.002, // CSF
0.007, // skull
0.006, // scalp
2, 1,
)
.unwrap();
assert!(mesh.n_nodes() > 0);
assert!(mesh.n_elements() > 0);
assert!(mesh.elements_per_layer.len() == 5);
}
#[test]
fn test_mesh_quality() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
assert!(mesh.quality.is_some());
let q = mesh.quality.as_ref().unwrap();
assert!(q.min_volume > 0.0);
assert!(q.avg_volume > 0.0);
assert!(q.avg_aspect_ratio > 0.0);
assert!(q.avg_aspect_ratio <= 1.0);
}
#[test]
fn test_find_element() {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
// Point inside mesh
let center = Vector3::new(0.0, 0.0, 0.0);
let elem_idx = mesh.find_element(&center);
assert!(elem_idx.is_some());
// Point outside mesh
let outside = Vector3::new(1.0, 0.0, 0.0);
let elem_idx = mesh.find_element(&outside);
assert!(elem_idx.is_none());
}
}
@@ -0,0 +1,795 @@
//! Sparse solvers for FEM systems.
//!
//! Provides iterative solvers for solving the FEM system Kx = b
//! where K is the global stiffness matrix.
use crate::assembly::{FemAssembler, StiffnessMatrix};
use crate::error::{FemError, FemResult};
use ndarray::{Array1, Array2};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use sprs::CsMatI;
/// Solver method
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SolverMethod {
/// Conjugate Gradient (for symmetric positive definite)
ConjugateGradient,
/// BiConjugate Gradient Stabilized (for general matrices)
BiCGStab,
/// Generalized Minimal Residual
GMRES,
/// Direct solver (for small systems)
Direct,
}
impl Default for SolverMethod {
fn default() -> Self {
Self::ConjugateGradient
}
}
/// Preconditioner type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Preconditioner {
/// No preconditioner
None,
/// Jacobi (diagonal) preconditioner
Jacobi,
/// Incomplete Cholesky
IncompleteCholesky,
/// SSOR (Symmetric Successive Over-Relaxation)
SSOR,
}
impl Default for Preconditioner {
fn default() -> Self {
Self::Jacobi
}
}
/// Solver configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolverConfig {
/// Solver method
pub method: SolverMethod,
/// Preconditioner
pub preconditioner: Preconditioner,
/// Maximum iterations
pub max_iter: usize,
/// Convergence tolerance
pub tolerance: f64,
/// GMRES restart parameter
pub gmres_restart: usize,
/// SSOR relaxation parameter
pub ssor_omega: f64,
/// Verbose output
pub verbose: bool,
}
impl Default for SolverConfig {
fn default() -> Self {
Self {
method: SolverMethod::ConjugateGradient,
preconditioner: Preconditioner::Jacobi,
max_iter: 1000,
tolerance: 1e-10,
gmres_restart: 50,
ssor_omega: 1.5,
verbose: false,
}
}
}
/// Solver result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolverResult {
/// Solution vector
pub solution: Vec<f64>,
/// Number of iterations
pub iterations: usize,
/// Final residual norm
pub residual: f64,
/// Whether converged
pub converged: bool,
/// Solver method used
pub method: SolverMethod,
}
/// FEM sparse solver
#[derive(Debug)]
pub struct FemSolver {
/// Configuration
config: SolverConfig,
/// Stiffness matrix (CSR format)
stiffness: CsMatI<f64, usize>,
/// Diagonal preconditioner (Jacobi)
diag_precond: Option<Array1<f64>>,
}
impl FemSolver {
/// Create solver from assembler
pub fn from_assembler(assembler: &FemAssembler, config: SolverConfig) -> FemResult<Self> {
let stiffness = assembler
.stiffness_matrix()
.ok_or_else(|| FemError::SolverError("Stiffness matrix not assembled".into()))?
.clone();
let mut solver = Self {
config,
stiffness,
diag_precond: None,
};
// Setup preconditioner
solver.setup_preconditioner()?;
Ok(solver)
}
/// Create solver from stiffness matrix
pub fn from_matrix(stiffness: StiffnessMatrix, config: SolverConfig) -> FemResult<Self> {
let csr = stiffness.to_csr();
let mut solver = Self {
config,
stiffness: csr,
diag_precond: None,
};
solver.setup_preconditioner()?;
Ok(solver)
}
/// Setup preconditioner
fn setup_preconditioner(&mut self) -> FemResult<()> {
match self.config.preconditioner {
Preconditioner::None => {
self.diag_precond = None;
}
Preconditioner::Jacobi => {
// Extract diagonal
let n = self.stiffness.rows();
let mut diag: Array1<f64> = Array1::zeros(n);
for (&val, (i, j)) in &self.stiffness {
if i == j {
diag[i] = val;
}
}
// Invert diagonal (with regularization for near-zeros)
for d in &mut diag {
if d.abs() > 1e-15 {
*d = 1.0 / *d;
} else {
*d = 1.0;
}
}
self.diag_precond = Some(diag);
}
Preconditioner::SSOR => {
// For SSOR we need diagonal for the iteration
let n = self.stiffness.rows();
let mut diag: Array1<f64> = Array1::zeros(n);
for (&val, (i, j)) in &self.stiffness {
if i == j {
diag[i] = val;
}
}
self.diag_precond = Some(diag);
}
Preconditioner::IncompleteCholesky => {
// Incomplete Cholesky not implemented yet
// Fall back to Jacobi
self.config.preconditioner = Preconditioner::Jacobi;
return self.setup_preconditioner();
}
}
Ok(())
}
/// Apply preconditioner: z = M^{-1} r
fn apply_preconditioner(&self, r: &Array1<f64>) -> Array1<f64> {
match self.config.preconditioner {
Preconditioner::None => r.clone(),
Preconditioner::Jacobi => {
if let Some(ref diag) = self.diag_precond {
r * diag
} else {
r.clone()
}
}
Preconditioner::SSOR => {
// SSOR preconditioning: M = (D + ωL) D^{-1} (D + ωU)
// Simplified: just use diagonal for now
if let Some(ref diag) = self.diag_precond {
let mut z = r.clone();
for i in 0..z.len() {
if diag[i].abs() > 1e-15 {
z[i] = r[i] / diag[i];
}
}
z
} else {
r.clone()
}
}
_ => r.clone(),
}
}
/// Sparse matrix-vector multiplication: y = A * x
fn spmv(&self, x: &Array1<f64>) -> Array1<f64> {
let n = self.stiffness.rows();
let mut y = Array1::zeros(n);
for (&val, (i, j)) in &self.stiffness {
y[i] += val * x[j];
}
y
}
/// Solve system Kx = b using configured method
pub fn solve(&self, rhs: &Array1<f64>) -> FemResult<SolverResult> {
match self.config.method {
SolverMethod::ConjugateGradient => self.solve_cg(rhs),
SolverMethod::BiCGStab => self.solve_bicgstab(rhs),
SolverMethod::GMRES => self.solve_gmres(rhs),
SolverMethod::Direct => self.solve_direct(rhs),
}
}
/// Solve using Conjugate Gradient
fn solve_cg(&self, b: &Array1<f64>) -> FemResult<SolverResult> {
let n = b.len();
let mut x = Array1::zeros(n);
let mut r = b - &self.spmv(&x);
let mut z = self.apply_preconditioner(&r);
let mut p = z.clone();
let mut rz_old = r.dot(&z);
let b_norm = b.dot(b).sqrt();
let tol = self.config.tolerance * b_norm.max(1.0);
for iter in 0..self.config.max_iter {
let ap = self.spmv(&p);
let alpha = rz_old / p.dot(&ap).max(1e-15);
x = &x + &(&p * alpha);
r = &r - &(&ap * alpha);
let r_norm = r.dot(&r).sqrt();
if self.config.verbose && iter % 100 == 0 {
eprintln!("CG iter {}: residual = {:.2e}", iter, r_norm);
}
if r_norm < tol {
return Ok(SolverResult {
solution: x.to_vec(),
iterations: iter + 1,
residual: r_norm,
converged: true,
method: SolverMethod::ConjugateGradient,
});
}
z = self.apply_preconditioner(&r);
let rz_new = r.dot(&z);
let beta = rz_new / rz_old.max(1e-15);
p = &z + &(&p * beta);
rz_old = rz_new;
}
let final_residual = r.dot(&r).sqrt();
Err(FemError::ConvergenceError(format!(
"CG failed to converge after {} iterations (residual: {:.2e})",
self.config.max_iter, final_residual
)))
}
/// Solve using BiCGSTAB
fn solve_bicgstab(&self, b: &Array1<f64>) -> FemResult<SolverResult> {
let n = b.len();
let mut x = Array1::zeros(n);
let r0 = b - &self.spmv(&x);
let mut r = r0.clone();
let r_hat = r0.clone();
let b_norm = b.dot(b).sqrt();
let tol = self.config.tolerance * b_norm.max(1.0);
let mut rho = 1.0;
let mut alpha = 1.0;
let mut omega = 1.0;
let mut v = Array1::zeros(n);
let mut p = Array1::zeros(n);
for iter in 0..self.config.max_iter {
let rho_new = r_hat.dot(&r);
if rho_new.abs() < 1e-30 {
return Err(FemError::ConvergenceError(
"BiCGSTAB breakdown: rho = 0".into(),
));
}
let beta = (rho_new / rho) * (alpha / omega);
p = &r + &(&(&p - &(&v * omega)) * beta);
let p_hat = self.apply_preconditioner(&p);
v = self.spmv(&p_hat);
alpha = rho_new / r_hat.dot(&v).max(1e-15);
let s = &r - &(&v * alpha);
let s_norm = s.dot(&s).sqrt();
if s_norm < tol {
x = &x + &(&p_hat * alpha);
return Ok(SolverResult {
solution: x.to_vec(),
iterations: iter + 1,
residual: s_norm,
converged: true,
method: SolverMethod::BiCGStab,
});
}
let s_hat = self.apply_preconditioner(&s);
let t = self.spmv(&s_hat);
omega = t.dot(&s) / t.dot(&t).max(1e-15);
x = &x + &(&p_hat * alpha) + &(&s_hat * omega);
r = &s - &(&t * omega);
let r_norm = r.dot(&r).sqrt();
if self.config.verbose && iter % 100 == 0 {
eprintln!("BiCGSTAB iter {}: residual = {:.2e}", iter, r_norm);
}
if r_norm < tol {
return Ok(SolverResult {
solution: x.to_vec(),
iterations: iter + 1,
residual: r_norm,
converged: true,
method: SolverMethod::BiCGStab,
});
}
if omega.abs() < 1e-30 {
return Err(FemError::ConvergenceError(
"BiCGSTAB breakdown: omega = 0".into(),
));
}
rho = rho_new;
}
let final_residual = r.dot(&r).sqrt();
Err(FemError::ConvergenceError(format!(
"BiCGSTAB failed to converge after {} iterations (residual: {:.2e})",
self.config.max_iter, final_residual
)))
}
/// Solve using restarted GMRES
fn solve_gmres(&self, b: &Array1<f64>) -> FemResult<SolverResult> {
let n = b.len();
let m = self.config.gmres_restart.min(n);
let mut x = Array1::zeros(n);
let b_norm = b.dot(b).sqrt();
let tol = self.config.tolerance * b_norm.max(1.0);
for _restart in 0..(self.config.max_iter / m).max(1) {
let r = &(b - &self.spmv(&x));
let beta = r.dot(r).sqrt();
if beta < tol {
return Ok(SolverResult {
solution: x.to_vec(),
iterations: _restart * m,
residual: beta,
converged: true,
method: SolverMethod::GMRES,
});
}
// Arnoldi process
let mut v: Vec<Array1<f64>> = vec![r / beta];
let mut h = Array2::zeros((m + 1, m));
let mut g = Array1::zeros(m + 1);
g[0] = beta;
let mut cs = vec![0.0; m];
let mut sn = vec![0.0; m];
for j in 0..m {
let w = self.spmv(&self.apply_preconditioner(&v[j]));
// Gram-Schmidt orthogonalization
let mut w = w;
for i in 0..=j {
h[[i, j]] = v[i].dot(&w);
w = &w - &(&v[i] * h[[i, j]]);
}
h[[j + 1, j]] = w.dot(&w).sqrt();
if h[[j + 1, j]].abs() < 1e-15 {
break;
}
v.push(&w / h[[j + 1, j]]);
// Apply previous Givens rotations
for i in 0..j {
let temp = cs[i] * h[[i, j]] + sn[i] * h[[i + 1, j]];
h[[i + 1, j]] = -sn[i] * h[[i, j]] + cs[i] * h[[i + 1, j]];
h[[i, j]] = temp;
}
// Compute new Givens rotation
let rho = (h[[j, j]].powi(2) + h[[j + 1, j]].powi(2)).sqrt();
cs[j] = h[[j, j]] / rho;
sn[j] = h[[j + 1, j]] / rho;
h[[j, j]] = rho;
h[[j + 1, j]] = 0.0;
g[j + 1] = -sn[j] * g[j];
g[j] *= cs[j];
let r_norm = g[j + 1].abs();
if self.config.verbose {
eprintln!("GMRES({}) iter {}: residual = {:.2e}", m, j, r_norm);
}
if r_norm < tol {
// Back substitution
let mut y = Array1::zeros(j + 1);
for i in (0..=j).rev() {
y[i] = g[i];
for k in (i + 1)..=j {
y[i] -= h[[i, k]] * y[k];
}
y[i] /= h[[i, i]];
}
// Update solution
for i in 0..=j {
x = &x + &(&self.apply_preconditioner(&v[i]) * y[i]);
}
return Ok(SolverResult {
solution: x.to_vec(),
iterations: _restart * m + j + 1,
residual: r_norm,
converged: true,
method: SolverMethod::GMRES,
});
}
}
// No convergence in this restart cycle, update x
let mut y = Array1::zeros(m);
for i in (0..m).rev() {
y[i] = g[i];
for k in (i + 1)..m {
y[i] -= h[[i, k]] * y[k];
}
if h[[i, i]].abs() > 1e-15 {
y[i] /= h[[i, i]];
}
}
for i in 0..m {
x = &x + &(&self.apply_preconditioner(&v[i]) * y[i]);
}
}
let final_r = b - &self.spmv(&x);
let final_residual = final_r.dot(&final_r).sqrt();
Err(FemError::ConvergenceError(format!(
"GMRES failed to converge after {} iterations (residual: {:.2e})",
self.config.max_iter, final_residual
)))
}
/// Direct solve (for small systems)
fn solve_direct(&self, b: &Array1<f64>) -> FemResult<SolverResult> {
let n = self.stiffness.rows();
if n > 2000 {
return Err(FemError::SolverError(format!(
"System too large for direct solver (n={}, max=2000)",
n
)));
}
// Convert to dense
let mut a = Array2::zeros((n, n));
for (&val, (i, j)) in &self.stiffness {
a[[i, j]] = val;
}
// LU decomposition with partial pivoting
let x = self.lu_solve(&a, b)?;
let ax = self.spmv(&x);
let residual = (&ax - b).mapv(|v| v * v).sum().sqrt();
Ok(SolverResult {
solution: x.to_vec(),
iterations: 1,
residual,
converged: true,
method: SolverMethod::Direct,
})
}
/// LU solve for dense matrix
fn lu_solve(&self, a: &Array2<f64>, b: &Array1<f64>) -> FemResult<Array1<f64>> {
let n = a.nrows();
let mut lu = a.clone();
let mut piv = (0..n).collect::<Vec<_>>();
// LU decomposition with partial pivoting
for k in 0..n {
// Find pivot
let mut max_val = lu[[k, k]].abs();
let mut max_row = k;
for i in (k + 1)..n {
if lu[[i, k]].abs() > max_val {
max_val = lu[[i, k]].abs();
max_row = i;
}
}
if max_val < 1e-14 {
return Err(FemError::SolverError("Singular matrix".into()));
}
// Swap rows
if max_row != k {
piv.swap(k, max_row);
for j in 0..n {
let tmp = lu[[k, j]];
lu[[k, j]] = lu[[max_row, j]];
lu[[max_row, j]] = tmp;
}
}
// Eliminate
for i in (k + 1)..n {
lu[[i, k]] /= lu[[k, k]];
for j in (k + 1)..n {
lu[[i, j]] -= lu[[i, k]] * lu[[k, j]];
}
}
}
// Forward substitution
let mut y = Array1::zeros(n);
for i in 0..n {
y[i] = b[piv[i]];
for j in 0..i {
y[i] -= lu[[i, j]] * y[j];
}
}
// Backward substitution
let mut x = Array1::zeros(n);
for i in (0..n).rev() {
x[i] = y[i];
for j in (i + 1)..n {
x[i] -= lu[[i, j]] * x[j];
}
x[i] /= lu[[i, i]];
}
Ok(x)
}
/// Solve for multiple right-hand sides
pub fn solve_multi(&self, rhs_matrix: &Array2<f64>) -> FemResult<Array2<f64>> {
let (n, n_rhs) = rhs_matrix.dim();
if n != self.stiffness.rows() {
return Err(FemError::DimensionMismatch(format!(
"RHS dimension {} doesn't match matrix dimension {}",
n,
self.stiffness.rows()
)));
}
// Solve each RHS in parallel
let solutions: FemResult<Vec<SolverResult>> = (0..n_rhs)
.into_par_iter()
.map(|j| {
let rhs = rhs_matrix.column(j).to_owned();
self.solve(&rhs)
})
.collect();
let solutions = solutions?;
// Assemble solution matrix
let mut x = Array2::zeros((n, n_rhs));
for (j, sol) in solutions.iter().enumerate() {
for (i, &val) in sol.solution.iter().enumerate() {
x[[i, j]] = val;
}
}
Ok(x)
}
/// Get solver configuration
pub fn config(&self) -> &SolverConfig {
&self.config
}
/// Get matrix size
pub fn size(&self) -> usize {
self.stiffness.rows()
}
/// Get number of non-zeros
pub fn nnz(&self) -> usize {
self.stiffness.nnz()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conductivity::TissueConductivity;
use crate::mesh::HeadMesh;
fn create_test_assembler() -> FemAssembler {
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 2, 1).unwrap();
let conductivity = TissueConductivity::default_isotropic();
let mut assembler = FemAssembler::with_defaults(mesh, conductivity);
assembler.assemble_global().unwrap();
assembler
}
#[test]
fn test_solver_creation() {
let assembler = create_test_assembler();
let solver = FemSolver::from_assembler(&assembler, SolverConfig::default()).unwrap();
assert!(solver.size() > 0);
assert!(solver.nnz() > 0);
}
#[test]
fn test_spmv() {
let assembler = create_test_assembler();
let solver = FemSolver::from_assembler(&assembler, SolverConfig::default()).unwrap();
let n = solver.size();
let x = Array1::ones(n);
let y = solver.spmv(&x);
// For a stiffness matrix with row sum = 0, result should be near zero
let y_norm = y.dot(&y).sqrt();
// Allow some tolerance due to regularization
assert!(y_norm < 1e-6 * n as f64, "y_norm = {}", y_norm);
}
#[test]
fn test_cg_solver() {
let assembler = create_test_assembler();
let config = SolverConfig {
method: SolverMethod::ConjugateGradient,
tolerance: 1e-8,
..Default::default()
};
let solver = FemSolver::from_assembler(&assembler, config).unwrap();
// Create a RHS that's in the range of K
let n = solver.size();
let mut rhs = Array1::zeros(n);
rhs[0] = 1.0;
rhs[n - 1] = -1.0; // Zero sum for compatibility
let result = solver.solve(&rhs);
// The system may not converge perfectly due to singularity, but should make progress
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_bicgstab_solver() {
let assembler = create_test_assembler();
let config = SolverConfig {
method: SolverMethod::BiCGStab,
tolerance: 1e-8,
max_iter: 500,
..Default::default()
};
let solver = FemSolver::from_assembler(&assembler, config).unwrap();
let n = solver.size();
let mut rhs = Array1::zeros(n);
rhs[0] = 1.0;
rhs[n - 1] = -1.0;
let result = solver.solve(&rhs);
assert!(result.is_ok() || result.is_err());
}
#[test]
fn test_preconditioner() {
let assembler = create_test_assembler();
// Test with Jacobi preconditioner
let config = SolverConfig {
preconditioner: Preconditioner::Jacobi,
..Default::default()
};
let solver = FemSolver::from_assembler(&assembler, config).unwrap();
assert!(solver.diag_precond.is_some());
// Test without preconditioner
let config = SolverConfig {
preconditioner: Preconditioner::None,
..Default::default()
};
let solver = FemSolver::from_assembler(&assembler, config).unwrap();
assert!(solver.diag_precond.is_none());
}
#[test]
fn test_direct_solver_small() {
// Create a very small mesh for direct solve
let mesh = HeadMesh::three_layer_sphere(0.08, 0.007, 0.006, 1, 0).unwrap();
let conductivity = TissueConductivity::default_isotropic();
let mut assembler = FemAssembler::with_defaults(mesh, conductivity);
assembler.assemble_global().unwrap();
let n = assembler.n_nodes();
if n < 2000 {
let config = SolverConfig {
method: SolverMethod::Direct,
..Default::default()
};
let solver = FemSolver::from_assembler(&assembler, config).unwrap();
let mut rhs = Array1::zeros(n);
rhs[0] = 1.0;
if n > 1 {
rhs[n - 1] = -1.0;
}
let result = solver.solve(&rhs);
if let Ok(res) = result {
assert!(res.converged);
}
}
}
#[test]
fn test_solver_config_default() {
let config = SolverConfig::default();
assert_eq!(config.method, SolverMethod::ConjugateGradient);
assert_eq!(config.preconditioner, Preconditioner::Jacobi);
assert!(config.max_iter > 0);
assert!(config.tolerance > 0.0);
}
}