//! Head geometry and sensor array models for MEG/EEG. //! //! Provides head models and sensor configurations for PINN-based //! source localization. use crate::conductivity::{ConductivityModel, LayeredConductivity, TissueLayer, TissueType}; use crate::error::{PinnError, PinnResult}; use ndarray::{Array1, Array2}; use serde::{Deserialize, Serialize}; /// Head geometry representation #[derive(Debug, Clone, Serialize, Deserialize)] pub enum HeadGeometry { /// Spherical head model Spherical { /// Center of sphere center: [f64; 3], /// Outer radius (scalp surface) radius: f64, }, /// Realistic mesh-based model Mesh { /// Vertices [n_vertices, 3] vertices: Array2, /// Triangular faces [n_faces, 3] faces: Array2, }, } impl HeadGeometry { /// Create a spherical head geometry pub fn sphere(center: [f64; 3], radius: f64) -> Self { HeadGeometry::Spherical { center, radius } } /// Check if a point is inside the head pub fn contains(&self, point: &[f64; 3]) -> bool { match self { HeadGeometry::Spherical { center, radius } => { let dx = point[0] - center[0]; let dy = point[1] - center[1]; let dz = point[2] - center[2]; let r = (dx * dx + dy * dy + dz * dz).sqrt(); r <= *radius } HeadGeometry::Mesh { vertices, .. } => { // Simple bounding box check for now let min_x = vertices.column(0).fold(f64::MAX, |a, &b| a.min(b)); let max_x = vertices.column(0).fold(f64::MIN, |a, &b| a.max(b)); let min_y = vertices.column(1).fold(f64::MAX, |a, &b| a.min(b)); let max_y = vertices.column(1).fold(f64::MIN, |a, &b| a.max(b)); let min_z = vertices.column(2).fold(f64::MAX, |a, &b| a.min(b)); let max_z = vertices.column(2).fold(f64::MIN, |a, &b| a.max(b)); point[0] >= min_x && point[0] <= max_x && point[1] >= min_y && point[1] <= max_y && point[2] >= min_z && point[2] <= max_z } } } /// Get center of head pub fn center(&self) -> [f64; 3] { match self { HeadGeometry::Spherical { center, .. } => *center, HeadGeometry::Mesh { vertices, .. } => { let cx = vertices.column(0).mean().unwrap_or(0.0); let cy = vertices.column(1).mean().unwrap_or(0.0); let cz = vertices.column(2).mean().unwrap_or(0.0); [cx, cy, cz] } } } /// Get approximate radius pub fn approximate_radius(&self) -> f64 { match self { HeadGeometry::Spherical { radius, .. } => *radius, HeadGeometry::Mesh { vertices, .. } => { let center = self.center(); vertices .rows() .into_iter() .map(|row| { let dx = row[0] - center[0]; let dy = row[1] - center[1]; let dz = row[2] - center[2]; (dx * dx + dy * dy + dz * dz).sqrt() }) .fold(0.0f64, f64::max) } } } } /// Sensor modality #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SensorType { /// EEG electrode Eeg, /// MEG gradiometer MegGrad, /// MEG magnetometer MegMag, } /// A single sensor #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Sensor { /// Sensor name pub name: String, /// Sensor type pub sensor_type: SensorType, /// Position [x, y, z] in meters pub position: [f64; 3], /// Orientation (for MEG) - unit vector pub orientation: Option<[f64; 3]>, } impl Sensor { /// Create an EEG electrode pub fn eeg(name: impl Into, position: [f64; 3]) -> Self { Self { name: name.into(), sensor_type: SensorType::Eeg, position, orientation: None, } } /// Create a MEG magnetometer pub fn meg_mag(name: impl Into, position: [f64; 3], orientation: [f64; 3]) -> Self { // Normalize orientation let len = (orientation[0].powi(2) + orientation[1].powi(2) + orientation[2].powi(2)).sqrt(); let norm = if len > 1e-10 { [ orientation[0] / len, orientation[1] / len, orientation[2] / len, ] } else { [0.0, 0.0, 1.0] }; Self { name: name.into(), sensor_type: SensorType::MegMag, position, orientation: Some(norm), } } /// Create a MEG gradiometer pub fn meg_grad(name: impl Into, position: [f64; 3], orientation: [f64; 3]) -> Self { let len = (orientation[0].powi(2) + orientation[1].powi(2) + orientation[2].powi(2)).sqrt(); let norm = if len > 1e-10 { [ orientation[0] / len, orientation[1] / len, orientation[2] / len, ] } else { [0.0, 0.0, 1.0] }; Self { name: name.into(), sensor_type: SensorType::MegGrad, position, orientation: Some(norm), } } } /// Sensor array configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SensorArray { /// List of sensors sensors: Vec, /// Sensor positions as array [n_sensors, 3] positions: Array2, /// Sensor orientations as array [n_sensors, 3] (for MEG) orientations: Option>, } impl SensorArray { /// Create from list of sensors pub fn new(sensors: Vec) -> Self { let n = sensors.len(); let mut positions = Array2::zeros((n, 3)); let mut orientations = Array2::zeros((n, 3)); let mut has_orientations = false; for (i, sensor) in sensors.iter().enumerate() { positions[[i, 0]] = sensor.position[0]; positions[[i, 1]] = sensor.position[1]; positions[[i, 2]] = sensor.position[2]; if let Some(orient) = sensor.orientation { orientations[[i, 0]] = orient[0]; orientations[[i, 1]] = orient[1]; orientations[[i, 2]] = orient[2]; has_orientations = true; } } Self { sensors, positions, orientations: if has_orientations { Some(orientations) } else { None }, } } /// Create standard 10-20 EEG montage on spherical head pub fn standard_1020(head_radius: f64) -> Self { let electrode_names = [ "Fp1", "Fp2", "F7", "F3", "Fz", "F4", "F8", "T3", "C3", "Cz", "C4", "T4", "T5", "P3", "Pz", "P4", "T6", "O1", "Oz", "O2", ]; // Approximate 10-20 positions (normalized to unit sphere) let theta_phi = [ (0.3, -0.4), (0.3, 0.4), // Fp1, Fp2 (0.6, -0.8), (0.5, -0.4), (0.5, 0.0), (0.5, 0.4), (0.6, 0.8), // F7-F8 (1.0, -1.0), (0.9, -0.4), (0.9, 0.0), (0.9, 0.4), (1.0, 1.0), // T3-T4 (1.4, -0.8), (1.3, -0.4), (1.3, 0.0), (1.3, 0.4), (1.4, 0.8), // T5-T6 (1.7, -0.4), (1.7, 0.0), (1.7, 0.4), // O1, Oz, O2 ]; let sensors: Vec = electrode_names .iter() .zip(theta_phi.iter()) .map(|(&name, &(theta, phi)): (&&str, &(f64, f64))| { let x = head_radius * theta.sin() * phi.cos(); let y = head_radius * theta.sin() * phi.sin(); let z = head_radius * theta.cos(); Sensor::eeg(name, [x, y, z]) }) .collect(); SensorArray::new(sensors) } /// Create MEG helmet array pub fn meg_helmet(n_sensors: usize, head_radius: f64, helmet_distance: f64) -> Self { let sensor_radius = head_radius + helmet_distance; let mut sensors = Vec::with_capacity(n_sensors); // Distribute sensors using Fibonacci sphere let golden_ratio = f64::midpoint(1.0, 5.0_f64.sqrt()); for i in 0..n_sensors { let theta = 2.0 * std::f64::consts::PI * i as f64 / golden_ratio; let phi = (1.0 - 2.0 * (i as f64 + 0.5) / n_sensors as f64).acos(); // Only upper hemisphere (z > 0) if phi < std::f64::consts::PI / 2.0 + 0.2 { let x = sensor_radius * phi.sin() * theta.cos(); let y = sensor_radius * phi.sin() * theta.sin(); let z = sensor_radius * phi.cos(); // Orientation pointing toward head center let len = (x * x + y * y + z * z).sqrt(); let orient = [-x / len, -y / len, -z / len]; sensors.push(Sensor::meg_mag(format!("MEG{:03}", i), [x, y, z], orient)); } } SensorArray::new(sensors) } /// Number of sensors pub fn n_sensors(&self) -> usize { self.sensors.len() } /// Get sensor positions pub fn positions(&self) -> &Array2 { &self.positions } /// Get sensor orientations pub fn orientations(&self) -> Option<&Array2> { self.orientations.as_ref() } /// Get sensor by name pub fn get(&self, name: &str) -> Option<&Sensor> { self.sensors.iter().find(|s| s.name == name) } /// Get sensor by index pub fn get_by_index(&self, idx: usize) -> Option<&Sensor> { self.sensors.get(idx) } /// Iterate over sensors pub fn iter(&self) -> impl Iterator { self.sensors.iter() } /// Get sensor names pub fn names(&self) -> Vec<&str> { self.sensors.iter().map(|s| s.name.as_str()).collect() } } /// Complete head model with geometry, conductivity, and sensors #[derive(Debug, Clone)] pub struct HeadModel { /// Head geometry geometry: HeadGeometry, /// Tissue conductivity model conductivity: LayeredConductivity, /// Number of layers n_layers: usize, } impl HeadModel { /// Create a spherical head model pub fn spherical(n_layers: usize) -> Self { let center = [0.0, 0.0, 0.0]; let geometry = HeadGeometry::sphere(center, 0.1); // 10 cm radius let conductivity = match n_layers { 1 => { let mut model = LayeredConductivity::new(center); model.add_layer( TissueLayer::new(TissueType::GrayMatter, "brain").with_radii(0.0, 0.1), ); model } 3 => LayeredConductivity::three_layer(center, 0.08, 0.007, 0.006), 4 => LayeredConductivity::four_layer(center, 0.078, 0.002, 0.007, 0.006), _ => LayeredConductivity::three_layer(center, 0.08, 0.007, 0.006), }; Self { geometry, conductivity, n_layers: n_layers.max(1).min(4), } } /// Set conductivity for a tissue type pub fn with_conductivity(mut self, tissue_name: &str, sigma: f64) -> Self { for layer in self.conductivity.layers_mut() { if layer.name == tissue_name { layer.conductivity = sigma; } } self } /// Make a tissue conductivity learnable pub fn with_learnable(mut self, tissue_name: &str) -> Self { for layer in self.conductivity.layers_mut() { if layer.name == tissue_name { layer.learnable = true; } } self } /// Get geometry pub fn geometry(&self) -> &HeadGeometry { &self.geometry } /// Get conductivity model pub fn conductivity(&self) -> &LayeredConductivity { &self.conductivity } /// Get mutable conductivity model pub fn conductivity_mut(&mut self) -> &mut LayeredConductivity { &mut self.conductivity } /// Number of tissue layers pub fn n_layers(&self) -> usize { self.n_layers } /// Check if point is inside brain pub fn is_in_brain(&self, point: &[f64; 3]) -> bool { let layer = self.conductivity.layer_at(point); matches!(layer, Some(l) if l.tissue_type == TissueType::GrayMatter || l.tissue_type == TissueType::WhiteMatter) } /// Sample random points inside brain pub fn sample_brain_points(&self, n_points: usize) -> Array2 { let mut points = Array2::zeros((n_points, 3)); let center = self.geometry.center(); // Get brain outer radius (first layer for spherical model) let brain_radius = self .conductivity .layers() .iter() .find(|l| l.tissue_type == TissueType::GrayMatter) .and_then(|l| l.outer_radius) .unwrap_or(0.08); // Simple uniform sampling in sphere let mut count = 0; let mut seed = 12345u64; while count < n_points { // LCG random number generator seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); let u1 = (seed as f64) / (u64::MAX as f64); seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); let u2 = (seed as f64) / (u64::MAX as f64); seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); let u3 = (seed as f64) / (u64::MAX as f64); // Map to [-1, 1] let x = (u1 * 2.0 - 1.0) * brain_radius; let y = (u2 * 2.0 - 1.0) * brain_radius; let z = (u3 * 2.0 - 1.0) * brain_radius; let r = (x * x + y * y + z * z).sqrt(); if r <= brain_radius { points[[count, 0]] = center[0] + x; points[[count, 1]] = center[1] + y; points[[count, 2]] = center[2] + z; count += 1; } } points } /// Get conductivity at points pub fn conductivity_at_points(&self, points: &Array2) -> Array1 { self.conductivity.conductivity_field(points) } } /// Source space (grid of potential source locations) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SourceSpace { /// Source positions [n_sources, 3] pub positions: Array2, /// Source orientations [n_sources, 3] (optional, for fixed orientation) pub orientations: Option>, /// Whether orientations are fixed or free pub fixed_orientation: bool, } impl SourceSpace { /// Create source space from positions pub fn new(positions: Array2) -> Self { Self { positions, orientations: None, fixed_orientation: false, } } /// Create with fixed orientations pub fn with_orientations( positions: Array2, orientations: Array2, ) -> PinnResult { if positions.nrows() != orientations.nrows() { return Err(PinnError::DimensionMismatch( "Positions and orientations must have same number of sources".into(), )); } Ok(Self { positions, orientations: Some(orientations), fixed_orientation: true, }) } /// Create grid source space inside brain pub fn grid(head: &HeadModel, spacing: f64) -> Self { let center = head.geometry().center(); let radius = head.geometry().approximate_radius(); // Find brain radius let brain_radius = head .conductivity() .layers() .iter() .find(|l| l.tissue_type == TissueType::GrayMatter) .and_then(|l| l.outer_radius) .unwrap_or(radius * 0.8); let n_per_dim = ((2.0 * brain_radius / spacing) as usize).max(1); let mut positions = Vec::new(); for i in 0..n_per_dim { for j in 0..n_per_dim { for k in 0..n_per_dim { let x = center[0] + (i as f64 - n_per_dim as f64 / 2.0) * spacing; let y = center[1] + (j as f64 - n_per_dim as f64 / 2.0) * spacing; let z = center[2] + (k as f64 - n_per_dim as f64 / 2.0) * spacing; let r = ((x - center[0]).powi(2) + (y - center[1]).powi(2) + (z - center[2]).powi(2)) .sqrt(); if r <= brain_radius { positions.push([x, y, z]); } } } } let n = positions.len(); let mut pos_array = Array2::zeros((n, 3)); for (i, pos) in positions.iter().enumerate() { pos_array[[i, 0]] = pos[0]; pos_array[[i, 1]] = pos[1]; pos_array[[i, 2]] = pos[2]; } SourceSpace::new(pos_array) } /// Number of sources pub fn n_sources(&self) -> usize { self.positions.nrows() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_spherical_head() { let head = HeadModel::spherical(3); assert_eq!(head.n_layers(), 3); // Point inside brain assert!(head.is_in_brain(&[0.0, 0.0, 0.05])); // Point in skull assert!(!head.is_in_brain(&[0.0, 0.0, 0.085])); } #[test] fn test_sensor_array_1020() { let sensors = SensorArray::standard_1020(0.1); assert_eq!(sensors.n_sensors(), 20); assert!(sensors.get("Cz").is_some()); } #[test] fn test_meg_helmet() { let sensors = SensorArray::meg_helmet(100, 0.1, 0.02); assert!(sensors.n_sensors() > 0); assert!(sensors.orientations().is_some()); } #[test] fn test_sample_brain_points() { let head = HeadModel::spherical(3); let points = head.sample_brain_points(100); assert_eq!(points.nrows(), 100); assert_eq!(points.ncols(), 3); // All points should be inside brain for i in 0..100 { let point = [points[[i, 0]], points[[i, 1]], points[[i, 2]]]; assert!(head.is_in_brain(&point)); } } #[test] fn test_source_space_grid() { let head = HeadModel::spherical(3); let sources = SourceSpace::grid(&head, 0.02); assert!(sources.n_sources() > 0); assert_eq!(sources.positions.ncols(), 3); } #[test] fn test_geometry_contains() { let geom = HeadGeometry::sphere([0.0, 0.0, 0.0], 0.1); assert!(geom.contains(&[0.0, 0.0, 0.0])); assert!(geom.contains(&[0.05, 0.05, 0.05])); assert!(!geom.contains(&[0.2, 0.0, 0.0])); } }