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,616 @@
//! 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<f64>,
/// Triangular faces [n_faces, 3]
faces: Array2<u32>,
},
}
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<String>, 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<String>, 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<String>, 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>,
/// Sensor positions as array [n_sensors, 3]
positions: Array2<f64>,
/// Sensor orientations as array [n_sensors, 3] (for MEG)
orientations: Option<Array2<f64>>,
}
impl SensorArray {
/// Create from list of sensors
pub fn new(sensors: Vec<Sensor>) -> 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<Sensor> = 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<f64> {
&self.positions
}
/// Get sensor orientations
pub fn orientations(&self) -> Option<&Array2<f64>> {
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<Item = &Sensor> {
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<f64> {
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<f64>) -> Array1<f64> {
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<f64>,
/// Source orientations [n_sources, 3] (optional, for fixed orientation)
pub orientations: Option<Array2<f64>>,
/// Whether orientations are fixed or free
pub fixed_orientation: bool,
}
impl SourceSpace {
/// Create source space from positions
pub fn new(positions: Array2<f64>) -> Self {
Self {
positions,
orientations: None,
fixed_orientation: false,
}
}
/// Create with fixed orientations
pub fn with_orientations(
positions: Array2<f64>,
orientations: Array2<f64>,
) -> PinnResult<Self> {
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]));
}
}