Initial commit
This commit is contained in:
@@ -0,0 +1,935 @@
|
||||
//! Overlapping spheres MEG forward model.
|
||||
//!
|
||||
//! This model fits a local sphere to the head surface for each MEG sensor,
|
||||
//! providing more accurate forward modeling than a single global sphere.
|
||||
//! The method accounts for the fact that the MEG helmet is not perfectly
|
||||
//! spherical and that different sensors "see" different local head geometries.
|
||||
//!
|
||||
//! ## Algorithm
|
||||
//!
|
||||
//! For each MEG sensor:
|
||||
//! 1. Find nearby scalp surface points (within a neighborhood)
|
||||
//! 2. Fit a sphere to these local points using least-squares
|
||||
//! 3. Use the Sarvas formula with this local sphere center
|
||||
//!
|
||||
//! ## Reference
|
||||
//!
|
||||
//! Huang, M. X., Mosher, J. C., & Leahy, R. M. (1999).
|
||||
//! A sensor-weighted overlapping-sphere head model and exhaustive head model
|
||||
//! comparison for MEG. Physics in Medicine & Biology, 44(2), 423.
|
||||
|
||||
use crate::gain::GainMatrix;
|
||||
use crate::sensors::{Sensor, SensorArray, SensorType};
|
||||
use crate::source_space::SourceSpace;
|
||||
use crate::{ForwardError, ForwardResult, Orientation, Position, cross, dot, norm, normalize};
|
||||
use nalgebra::Vector3;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Magnetic permeability of free space (H/m)
|
||||
const MU_0: f64 = 4.0 * PI * 1e-7;
|
||||
|
||||
/// Configuration for overlapping spheres model
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OverlappingSpheresConfig {
|
||||
/// Neighborhood radius for selecting surface points (meters)
|
||||
pub neighborhood_radius: f64,
|
||||
/// Minimum number of points required for sphere fitting
|
||||
pub min_points: usize,
|
||||
/// Maximum iterations for sphere fitting
|
||||
pub max_iter: usize,
|
||||
/// Convergence tolerance for sphere fitting
|
||||
pub tol: f64,
|
||||
}
|
||||
|
||||
impl Default for OverlappingSpheresConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
neighborhood_radius: 0.05, // 5 cm
|
||||
min_points: 20,
|
||||
max_iter: 100,
|
||||
tol: 1e-6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fitted local sphere for a single sensor
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalSphere {
|
||||
/// Center of the fitted sphere
|
||||
pub center: Position,
|
||||
/// Radius of the fitted sphere
|
||||
pub radius: f64,
|
||||
/// Goodness of fit (RMS error)
|
||||
pub fit_error: f64,
|
||||
/// Number of points used for fitting
|
||||
pub n_points: usize,
|
||||
}
|
||||
|
||||
/// Surface mesh representation for sphere fitting
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SurfaceMesh {
|
||||
/// Vertex positions
|
||||
vertices: Vec<Position>,
|
||||
/// Optional normals at each vertex
|
||||
normals: Option<Vec<Orientation>>,
|
||||
}
|
||||
|
||||
impl SurfaceMesh {
|
||||
/// Create a new surface mesh from vertices
|
||||
pub fn new(vertices: Vec<[f64; 3]>) -> Self {
|
||||
let vertices: Vec<Position> = vertices
|
||||
.into_iter()
|
||||
.map(|v| Vector3::new(v[0], v[1], v[2]))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
vertices,
|
||||
normals: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with normals
|
||||
pub fn with_normals(vertices: Vec<[f64; 3]>, normals: Vec<[f64; 3]>) -> Self {
|
||||
let vertices: Vec<Position> = vertices
|
||||
.into_iter()
|
||||
.map(|v| Vector3::new(v[0], v[1], v[2]))
|
||||
.collect();
|
||||
|
||||
let normals: Vec<Orientation> = normals
|
||||
.into_iter()
|
||||
.map(|n| normalize(&Vector3::new(n[0], n[1], n[2])))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
vertices,
|
||||
normals: Some(normals),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get vertices
|
||||
pub fn vertices(&self) -> &[Position] {
|
||||
&self.vertices
|
||||
}
|
||||
|
||||
/// Get number of vertices
|
||||
pub fn len(&self) -> usize {
|
||||
self.vertices.len()
|
||||
}
|
||||
|
||||
/// Check if empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.vertices.is_empty()
|
||||
}
|
||||
|
||||
/// Create a synthetic spherical surface for testing
|
||||
pub fn sphere(center: [f64; 3], radius: f64, n_points: usize) -> Self {
|
||||
let c = Vector3::new(center[0], center[1], center[2]);
|
||||
let golden_ratio = f64::midpoint(1.0, 5.0_f64.sqrt());
|
||||
|
||||
let mut vertices = Vec::with_capacity(n_points);
|
||||
let mut normals = Vec::with_capacity(n_points);
|
||||
|
||||
for i in 0..n_points {
|
||||
let theta = 2.0 * PI * i as f64 / golden_ratio;
|
||||
let phi = ((2.0 * i as f64 + 1.0) / (2.0 * n_points as f64) - 1.0).acos();
|
||||
|
||||
let x = phi.sin() * theta.cos();
|
||||
let y = phi.sin() * theta.sin();
|
||||
let z = phi.cos();
|
||||
|
||||
let n = Vector3::new(x, y, z);
|
||||
vertices.push(c + n * radius);
|
||||
normals.push(n);
|
||||
}
|
||||
|
||||
Self {
|
||||
vertices,
|
||||
normals: Some(normals),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a scalp-like surface (upper hemisphere)
|
||||
pub fn scalp(center: [f64; 3], radius: f64, n_points: usize) -> Self {
|
||||
let c = Vector3::new(center[0], center[1], center[2]);
|
||||
let golden_ratio = f64::midpoint(1.0, 5.0_f64.sqrt());
|
||||
|
||||
let mut vertices = Vec::new();
|
||||
let mut normals = Vec::new();
|
||||
|
||||
for i in 0..n_points {
|
||||
let theta = 2.0 * PI * i as f64 / golden_ratio;
|
||||
// Only upper hemisphere (z > 0.3 * center.z for scalp)
|
||||
let phi = PI * 0.4 * (i as f64 / n_points as f64);
|
||||
|
||||
let x = phi.sin() * theta.cos();
|
||||
let y = phi.sin() * theta.sin();
|
||||
let z = phi.cos();
|
||||
|
||||
let n = Vector3::new(x, y, z);
|
||||
vertices.push(c + n * radius);
|
||||
normals.push(n);
|
||||
}
|
||||
|
||||
Self {
|
||||
vertices,
|
||||
normals: Some(normals),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlapping spheres MEG forward model
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OverlappingSpheres {
|
||||
/// Local sphere for each sensor
|
||||
local_spheres: Vec<LocalSphere>,
|
||||
/// Sensor positions (for reference)
|
||||
sensor_positions: Vec<Position>,
|
||||
/// Global fallback sphere center
|
||||
global_center: Position,
|
||||
/// Global fallback sphere radius
|
||||
global_radius: f64,
|
||||
/// Configuration used
|
||||
config: OverlappingSpheresConfig,
|
||||
}
|
||||
|
||||
impl OverlappingSpheres {
|
||||
/// Create overlapping spheres model from head surface and sensors
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `surface` - Head surface mesh (scalp)
|
||||
/// * `sensors` - MEG sensor array
|
||||
/// * `config` - Optional configuration (uses defaults if None)
|
||||
pub fn fit_from_surface(
|
||||
surface: &SurfaceMesh,
|
||||
sensors: &SensorArray,
|
||||
config: Option<OverlappingSpheresConfig>,
|
||||
) -> ForwardResult<Self> {
|
||||
let config = config.unwrap_or_default();
|
||||
|
||||
if surface.is_empty() {
|
||||
return Err(ForwardError::InvalidGeometry(
|
||||
"Empty surface mesh".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if sensors.is_empty() {
|
||||
return Err(ForwardError::SensorError("No sensors provided".to_string()));
|
||||
}
|
||||
|
||||
// Compute global sphere as fallback
|
||||
let (global_center, global_radius) = Self::fit_global_sphere(surface)?;
|
||||
|
||||
// Fit local sphere for each MEG sensor
|
||||
let mut local_spheres = Vec::with_capacity(sensors.len());
|
||||
let mut sensor_positions = Vec::with_capacity(sensors.len());
|
||||
|
||||
for sensor in sensors.iter() {
|
||||
let sensor_pos = *sensor.position();
|
||||
sensor_positions.push(sensor_pos);
|
||||
|
||||
// Only fit local spheres for MEG sensors
|
||||
match sensor.sensor_type() {
|
||||
SensorType::MegMag | SensorType::MegGrad => {
|
||||
let local = Self::fit_local_sphere(
|
||||
surface,
|
||||
&sensor_pos,
|
||||
&config,
|
||||
&global_center,
|
||||
global_radius,
|
||||
)?;
|
||||
local_spheres.push(local);
|
||||
}
|
||||
_ => {
|
||||
// Non-MEG sensors use global sphere
|
||||
local_spheres.push(LocalSphere {
|
||||
center: global_center,
|
||||
radius: global_radius,
|
||||
fit_error: 0.0,
|
||||
n_points: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
local_spheres,
|
||||
sensor_positions,
|
||||
global_center,
|
||||
global_radius,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create with manually specified local spheres
|
||||
pub fn from_local_spheres(
|
||||
centers: Vec<[f64; 3]>,
|
||||
radii: Vec<f64>,
|
||||
sensor_positions: Vec<[f64; 3]>,
|
||||
) -> ForwardResult<Self> {
|
||||
if centers.len() != radii.len() || centers.len() != sensor_positions.len() {
|
||||
return Err(ForwardError::DimensionMismatch(
|
||||
"Mismatched number of centers, radii, and sensors".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let local_spheres: Vec<LocalSphere> = centers
|
||||
.iter()
|
||||
.zip(radii.iter())
|
||||
.map(|(c, &r)| LocalSphere {
|
||||
center: Vector3::new(c[0], c[1], c[2]),
|
||||
radius: r,
|
||||
fit_error: 0.0,
|
||||
n_points: 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sensor_positions: Vec<Position> = sensor_positions
|
||||
.iter()
|
||||
.map(|p| Vector3::new(p[0], p[1], p[2]))
|
||||
.collect();
|
||||
|
||||
// Compute global sphere from local spheres
|
||||
let global_center = if !local_spheres.is_empty() {
|
||||
let sum: Position = local_spheres.iter().map(|s| &s.center).sum();
|
||||
sum / local_spheres.len() as f64
|
||||
} else {
|
||||
Vector3::zeros()
|
||||
};
|
||||
|
||||
let global_radius = if !local_spheres.is_empty() {
|
||||
local_spheres.iter().map(|s| s.radius).sum::<f64>() / local_spheres.len() as f64
|
||||
} else {
|
||||
0.08
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
local_spheres,
|
||||
sensor_positions,
|
||||
global_center,
|
||||
global_radius,
|
||||
config: OverlappingSpheresConfig::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get local sphere for a sensor
|
||||
pub fn get_local_sphere(&self, sensor_idx: usize) -> Option<&LocalSphere> {
|
||||
self.local_spheres.get(sensor_idx)
|
||||
}
|
||||
|
||||
/// Get all local sphere centers
|
||||
pub fn sphere_centers(&self) -> Vec<[f64; 3]> {
|
||||
self.local_spheres
|
||||
.iter()
|
||||
.map(|s| [s.center.x, s.center.y, s.center.z])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get global sphere center
|
||||
pub fn global_center(&self) -> &Position {
|
||||
&self.global_center
|
||||
}
|
||||
|
||||
/// Get global sphere radius
|
||||
pub fn global_radius(&self) -> f64 {
|
||||
self.global_radius
|
||||
}
|
||||
|
||||
/// Compute the magnetic field using the local sphere for a specific sensor
|
||||
pub fn compute_field(
|
||||
&self,
|
||||
dipole_pos: &Position,
|
||||
dipole_moment: &Vector3<f64>,
|
||||
sensor_idx: usize,
|
||||
sensor_pos: &Position,
|
||||
) -> ForwardResult<Vector3<f64>> {
|
||||
let local = self.local_spheres.get(sensor_idx).ok_or_else(|| {
|
||||
ForwardError::SensorError(format!("Invalid sensor index: {}", sensor_idx))
|
||||
})?;
|
||||
|
||||
// Use Sarvas formula with local sphere center
|
||||
self.sarvas_field(
|
||||
dipole_pos,
|
||||
dipole_moment,
|
||||
sensor_pos,
|
||||
&local.center,
|
||||
local.radius,
|
||||
)
|
||||
}
|
||||
|
||||
/// Compute gain matrix using overlapping spheres
|
||||
pub fn compute_gain(
|
||||
&self,
|
||||
sources: &SourceSpace,
|
||||
sensors: &SensorArray,
|
||||
) -> ForwardResult<GainMatrix> {
|
||||
let n_sensors = sensors.len();
|
||||
let n_sources = sources.len();
|
||||
|
||||
if n_sensors != self.local_spheres.len() {
|
||||
return Err(ForwardError::DimensionMismatch(format!(
|
||||
"Number of sensors ({}) doesn't match fitted spheres ({})",
|
||||
n_sensors,
|
||||
self.local_spheres.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Determine output dimensions
|
||||
let n_columns = if sources.is_fixed_orientation() {
|
||||
n_sources
|
||||
} else {
|
||||
3 * n_sources
|
||||
};
|
||||
|
||||
let mut gain_data = vec![vec![0.0; n_columns]; n_sensors];
|
||||
|
||||
if sources.is_fixed_orientation() {
|
||||
for (s_idx, (sensor, local)) in sensors.iter().zip(&self.local_spheres).enumerate() {
|
||||
for (src_idx, source) in sources.iter().enumerate() {
|
||||
let dipole_pos = source.position();
|
||||
let dipole_ori = source
|
||||
.orientation()
|
||||
.unwrap_or_else(|| Vector3::new(0.0, 0.0, 1.0));
|
||||
|
||||
let field = self.compute_sensor_field(
|
||||
sensor,
|
||||
dipole_pos,
|
||||
&dipole_ori,
|
||||
&local.center,
|
||||
local.radius,
|
||||
)?;
|
||||
|
||||
gain_data[s_idx][src_idx] = field;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Free orientation: 3 columns per source
|
||||
for (s_idx, (sensor, local)) in sensors.iter().zip(&self.local_spheres).enumerate() {
|
||||
for (src_idx, source) in sources.iter().enumerate() {
|
||||
let dipole_pos = source.position();
|
||||
|
||||
for (ori_idx, dipole_ori) in [
|
||||
Vector3::new(1.0, 0.0, 0.0),
|
||||
Vector3::new(0.0, 1.0, 0.0),
|
||||
Vector3::new(0.0, 0.0, 1.0),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let field = self.compute_sensor_field(
|
||||
sensor,
|
||||
dipole_pos,
|
||||
dipole_ori,
|
||||
&local.center,
|
||||
local.radius,
|
||||
)?;
|
||||
|
||||
gain_data[s_idx][3 * src_idx + ori_idx] = field;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sensor_names: Vec<String> = sensors.iter().map(|s| s.name().to_string()).collect();
|
||||
|
||||
GainMatrix::new(gain_data, !sources.is_fixed_orientation(), sensor_names)
|
||||
}
|
||||
|
||||
/// Compute gain matrix in parallel
|
||||
pub fn compute_gain_parallel(
|
||||
&self,
|
||||
sources: &SourceSpace,
|
||||
sensors: &SensorArray,
|
||||
) -> ForwardResult<GainMatrix> {
|
||||
let n_sensors = sensors.len();
|
||||
let n_sources = sources.len();
|
||||
|
||||
if n_sensors != self.local_spheres.len() {
|
||||
return Err(ForwardError::DimensionMismatch(format!(
|
||||
"Number of sensors ({}) doesn't match fitted spheres ({})",
|
||||
n_sensors,
|
||||
self.local_spheres.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let free_orientation = !sources.is_fixed_orientation();
|
||||
let sources_vec: Vec<_> = sources.iter().collect();
|
||||
|
||||
let gain_data: Vec<Vec<f64>> = sensors
|
||||
.iter()
|
||||
.zip(&self.local_spheres)
|
||||
.map(|(sensor, local)| {
|
||||
if free_orientation {
|
||||
let mut row = vec![0.0; 3 * n_sources];
|
||||
for (src_idx, source) in sources_vec.iter().enumerate() {
|
||||
let dipole_pos = source.position();
|
||||
|
||||
for (ori_idx, dipole_ori) in [
|
||||
Vector3::new(1.0, 0.0, 0.0),
|
||||
Vector3::new(0.0, 1.0, 0.0),
|
||||
Vector3::new(0.0, 0.0, 1.0),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let field = self
|
||||
.compute_sensor_field(
|
||||
sensor,
|
||||
dipole_pos,
|
||||
dipole_ori,
|
||||
&local.center,
|
||||
local.radius,
|
||||
)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
row[3 * src_idx + ori_idx] = field;
|
||||
}
|
||||
}
|
||||
row
|
||||
} else {
|
||||
sources_vec
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let dipole_pos = source.position();
|
||||
let dipole_ori = source
|
||||
.orientation()
|
||||
.unwrap_or_else(|| Vector3::new(0.0, 0.0, 1.0));
|
||||
|
||||
self.compute_sensor_field(
|
||||
sensor,
|
||||
dipole_pos,
|
||||
&dipole_ori,
|
||||
&local.center,
|
||||
local.radius,
|
||||
)
|
||||
.unwrap_or(0.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sensor_names: Vec<String> = sensors.iter().map(|s| s.name().to_string()).collect();
|
||||
|
||||
GainMatrix::new(gain_data, free_orientation, sensor_names)
|
||||
}
|
||||
|
||||
// ========== Private methods ==========
|
||||
|
||||
/// Fit a global sphere to all surface points
|
||||
fn fit_global_sphere(surface: &SurfaceMesh) -> ForwardResult<(Position, f64)> {
|
||||
let n = surface.len();
|
||||
if n < 4 {
|
||||
return Err(ForwardError::InvalidGeometry(
|
||||
"Need at least 4 points to fit a sphere".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Initial estimate: centroid
|
||||
let centroid: Position = surface.vertices().iter().sum::<Vector3<f64>>() / n as f64;
|
||||
|
||||
// Initial radius estimate
|
||||
let radius: f64 = surface
|
||||
.vertices()
|
||||
.iter()
|
||||
.map(|v| norm(&(v - centroid)))
|
||||
.sum::<f64>()
|
||||
/ n as f64;
|
||||
|
||||
// Iterative refinement using Gauss-Newton
|
||||
let mut center = centroid;
|
||||
let mut r = radius;
|
||||
|
||||
for _ in 0..50 {
|
||||
let (new_center, new_r) = Self::sphere_fit_iteration(surface.vertices(), ¢er, r);
|
||||
|
||||
let center_change = norm(&(new_center - center));
|
||||
center = new_center;
|
||||
r = new_r;
|
||||
|
||||
if center_change < 1e-8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((center, r))
|
||||
}
|
||||
|
||||
/// Fit a local sphere to surface points near a sensor
|
||||
fn fit_local_sphere(
|
||||
surface: &SurfaceMesh,
|
||||
sensor_pos: &Position,
|
||||
config: &OverlappingSpheresConfig,
|
||||
global_center: &Position,
|
||||
global_radius: f64,
|
||||
) -> ForwardResult<LocalSphere> {
|
||||
// Find points within neighborhood
|
||||
let local_points: Vec<&Position> = surface
|
||||
.vertices()
|
||||
.iter()
|
||||
.filter(|v| norm(&(*v - sensor_pos)) < config.neighborhood_radius)
|
||||
.collect();
|
||||
|
||||
if local_points.len() < config.min_points {
|
||||
// Fall back to global sphere
|
||||
return Ok(LocalSphere {
|
||||
center: *global_center,
|
||||
radius: global_radius,
|
||||
fit_error: 0.0,
|
||||
n_points: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let n_points = local_points.len();
|
||||
|
||||
// Initial estimate from local points
|
||||
let centroid: Position =
|
||||
local_points.iter().copied().sum::<Vector3<f64>>() / n_points as f64;
|
||||
let radius: f64 = local_points
|
||||
.iter()
|
||||
.map(|v| norm(&(*v - centroid)))
|
||||
.sum::<f64>()
|
||||
/ n_points as f64;
|
||||
|
||||
// Iterative refinement
|
||||
let mut center = centroid;
|
||||
let mut r = radius;
|
||||
|
||||
for _ in 0..config.max_iter {
|
||||
let (new_center, new_r) = Self::local_sphere_fit_iteration(&local_points, ¢er, r);
|
||||
|
||||
let center_change = norm(&(new_center - center));
|
||||
center = new_center;
|
||||
r = new_r;
|
||||
|
||||
if center_change < config.tol {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute fit error (RMS)
|
||||
let fit_error = (local_points
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let d = norm(&(*v - center)) - r;
|
||||
d * d
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ n_points as f64)
|
||||
.sqrt();
|
||||
|
||||
Ok(LocalSphere {
|
||||
center,
|
||||
radius: r,
|
||||
fit_error,
|
||||
n_points,
|
||||
})
|
||||
}
|
||||
|
||||
/// One iteration of sphere fitting using Gauss-Newton
|
||||
fn sphere_fit_iteration(
|
||||
points: &[Position],
|
||||
center: &Position,
|
||||
radius: f64,
|
||||
) -> (Position, f64) {
|
||||
let n = points.len() as f64;
|
||||
|
||||
let mut sum_center = Vector3::zeros();
|
||||
let mut sum_radius = 0.0;
|
||||
|
||||
for p in points {
|
||||
let d = p - center;
|
||||
let d_norm = norm(&d);
|
||||
|
||||
if d_norm > 1e-15 {
|
||||
// Direction from center to point
|
||||
let dir = d / d_norm;
|
||||
// Project center update
|
||||
sum_center += p - dir * radius;
|
||||
}
|
||||
|
||||
sum_radius += d_norm;
|
||||
}
|
||||
|
||||
let new_center = sum_center / n;
|
||||
let new_radius = sum_radius / n;
|
||||
|
||||
(new_center, new_radius)
|
||||
}
|
||||
|
||||
/// One iteration for local sphere fitting
|
||||
fn local_sphere_fit_iteration(
|
||||
points: &[&Position],
|
||||
center: &Position,
|
||||
radius: f64,
|
||||
) -> (Position, f64) {
|
||||
let n = points.len() as f64;
|
||||
|
||||
let mut sum_center = Vector3::zeros();
|
||||
let mut sum_radius = 0.0;
|
||||
|
||||
for p in points {
|
||||
let d = *p - center;
|
||||
let d_norm = norm(&d);
|
||||
|
||||
if d_norm > 1e-15 {
|
||||
let dir = d / d_norm;
|
||||
sum_center += *p - dir * radius;
|
||||
}
|
||||
|
||||
sum_radius += d_norm;
|
||||
}
|
||||
|
||||
let new_center = sum_center / n;
|
||||
let new_radius = sum_radius / n;
|
||||
|
||||
(new_center, new_radius)
|
||||
}
|
||||
|
||||
/// Sarvas formula for magnetic field
|
||||
fn sarvas_field(
|
||||
&self,
|
||||
dipole_pos: &Position,
|
||||
dipole_moment: &Vector3<f64>,
|
||||
sensor_pos: &Position,
|
||||
sphere_center: &Position,
|
||||
sphere_radius: f64,
|
||||
) -> ForwardResult<Vector3<f64>> {
|
||||
// Convert to sphere-centered coordinates
|
||||
let r_q = dipole_pos - sphere_center;
|
||||
let r_p = sensor_pos - sphere_center;
|
||||
|
||||
let r_q_norm = norm(&r_q);
|
||||
|
||||
// Check if dipole is inside the sphere
|
||||
if r_q_norm >= sphere_radius {
|
||||
return Err(ForwardError::SourceOutsideHead(format!(
|
||||
"Dipole at distance {:.4} m is outside local sphere of radius {:.4} m",
|
||||
r_q_norm, sphere_radius
|
||||
)));
|
||||
}
|
||||
|
||||
// Compute Sarvas formula components
|
||||
let a = r_p - r_q;
|
||||
let a_norm = norm(&a);
|
||||
let r_p_norm = norm(&r_p);
|
||||
|
||||
if a_norm < 1e-15 || r_p_norm < 1e-15 {
|
||||
return Ok(Vector3::zeros());
|
||||
}
|
||||
|
||||
// F = a * (r_p * a + r_p^2 - r_q . r_p)
|
||||
let f_scalar = a_norm * (r_p_norm * a_norm + r_p_norm * r_p_norm - dot(&r_q, &r_p));
|
||||
|
||||
if f_scalar.abs() < 1e-30 {
|
||||
return Ok(Vector3::zeros());
|
||||
}
|
||||
|
||||
// grad_F
|
||||
let a_dot_rp = dot(&a, &r_p);
|
||||
let term1 = a_norm * a_norm / r_p_norm + a_dot_rp / a_norm + 2.0 * a_norm + 2.0 * r_p_norm;
|
||||
let term2 = a_norm + 2.0 * r_p_norm + a_dot_rp / a_norm;
|
||||
let grad_f = r_p * term1 - r_q * term2;
|
||||
|
||||
// B = (mu_0 / 4*pi) * (F * (Q x r_q) - (Q x r_q . r_p) * grad_F) / F^2
|
||||
let q_cross_rq = cross(dipole_moment, &r_q);
|
||||
let q_cross_rq_dot_rp = dot(&q_cross_rq, &r_p);
|
||||
|
||||
let numerator = q_cross_rq * f_scalar - grad_f * q_cross_rq_dot_rp;
|
||||
let field = numerator * (MU_0 / (4.0 * PI * f_scalar * f_scalar));
|
||||
|
||||
Ok(field)
|
||||
}
|
||||
|
||||
/// Compute field for a sensor (handles magnetometer vs gradiometer)
|
||||
fn compute_sensor_field(
|
||||
&self,
|
||||
sensor: &Sensor,
|
||||
dipole_pos: &Position,
|
||||
dipole_moment: &Vector3<f64>,
|
||||
sphere_center: &Position,
|
||||
sphere_radius: f64,
|
||||
) -> ForwardResult<f64> {
|
||||
match sensor {
|
||||
Sensor::Magnetometer {
|
||||
position,
|
||||
orientation,
|
||||
..
|
||||
} => {
|
||||
let b = self.sarvas_field(
|
||||
dipole_pos,
|
||||
dipole_moment,
|
||||
position,
|
||||
sphere_center,
|
||||
sphere_radius,
|
||||
)?;
|
||||
Ok(dot(&b, orientation))
|
||||
}
|
||||
Sensor::Gradiometer { coil, .. } => {
|
||||
// Compute field at each integration point
|
||||
let mut total_flux = 0.0;
|
||||
for (pos, weight) in coil.integration_points() {
|
||||
let field = self.sarvas_field(
|
||||
dipole_pos,
|
||||
dipole_moment,
|
||||
pos,
|
||||
sphere_center,
|
||||
sphere_radius,
|
||||
)?;
|
||||
let flux = dot(&field, coil.orientation()) * weight;
|
||||
total_flux += flux;
|
||||
}
|
||||
Ok(total_flux)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::source_space::SourceSpace;
|
||||
|
||||
#[test]
|
||||
fn test_surface_mesh_sphere() {
|
||||
let surface = SurfaceMesh::sphere([0.0, 0.0, 0.04], 0.08, 500);
|
||||
assert_eq!(surface.len(), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_global_sphere_fit() {
|
||||
// Create a perfect sphere and verify fitting
|
||||
let surface = SurfaceMesh::sphere([0.0, 0.0, 0.04], 0.08, 500);
|
||||
let (center, radius) = OverlappingSpheres::fit_global_sphere(&surface).unwrap();
|
||||
|
||||
// Sphere fitting should be reasonably close to the true values
|
||||
// (Fibonacci sphere distribution isn't perfectly uniform)
|
||||
assert!((center.x - 0.0).abs() < 0.005, "center.x = {}", center.x);
|
||||
assert!((center.y - 0.0).abs() < 0.005, "center.y = {}", center.y);
|
||||
assert!((center.z - 0.04).abs() < 0.005, "center.z = {}", center.z);
|
||||
assert!((radius - 0.08).abs() < 0.005, "radius = {}", radius);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overlapping_spheres_creation() {
|
||||
let surface = SurfaceMesh::scalp([0.0, 0.0, 0.04], 0.08, 500);
|
||||
let sensors = SensorArray::meg_helmet(50, 0.12);
|
||||
|
||||
let model = OverlappingSpheres::fit_from_surface(&surface, &sensors, None).unwrap();
|
||||
|
||||
assert_eq!(model.local_spheres.len(), sensors.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_overlapping_spheres_field() {
|
||||
let surface = SurfaceMesh::scalp([0.0, 0.0, 0.04], 0.08, 500);
|
||||
let sensors = SensorArray::meg_helmet(50, 0.12);
|
||||
|
||||
let model = OverlappingSpheres::fit_from_surface(&surface, &sensors, None).unwrap();
|
||||
|
||||
// Test field computation
|
||||
let dipole_pos = Vector3::new(0.0, 0.0, 0.06);
|
||||
let dipole_moment = Vector3::new(1e-9, 0.0, 0.0); // 1 nAm, tangential
|
||||
|
||||
if let Some(sensor) = sensors.get(0) {
|
||||
let field = model
|
||||
.compute_field(&dipole_pos, &dipole_moment, 0, sensor.position())
|
||||
.unwrap();
|
||||
|
||||
// Tangential dipole should produce non-zero field
|
||||
assert!(norm(&field) > 1e-30);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_radial_dipole_zero_field() {
|
||||
// Use full sphere for consistent sphere fitting
|
||||
let surface = SurfaceMesh::sphere([0.0, 0.0, 0.04], 0.08, 500);
|
||||
let sensors = SensorArray::meg_helmet(50, 0.12);
|
||||
|
||||
let model = OverlappingSpheres::fit_from_surface(&surface, &sensors, None).unwrap();
|
||||
|
||||
// For a radial dipole test, we need to use the LOCAL sphere center
|
||||
// for that specific sensor, not the global center
|
||||
if let (Some(sensor), Some(local_sphere)) = (sensors.get(0), model.get_local_sphere(0)) {
|
||||
// Dipole position inside local sphere
|
||||
let dipole_pos = Vector3::new(0.0, 0.0, 0.06);
|
||||
|
||||
// Radial direction with respect to local sphere center
|
||||
let radial_dir = normalize(&(dipole_pos - &local_sphere.center));
|
||||
let dipole_moment = radial_dir * 1e-9; // 1 nAm radial
|
||||
|
||||
let field = model
|
||||
.compute_field(&dipole_pos, &dipole_moment, 0, sensor.position())
|
||||
.unwrap();
|
||||
|
||||
// Radial dipole should produce very small field
|
||||
// (not exactly zero due to numerical precision)
|
||||
assert!(norm(&field) < 1e-18, "Field magnitude: {:e}", norm(&field));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_manual_spheres() {
|
||||
let centers = vec![[0.0, 0.0, 0.04], [0.01, 0.0, 0.04], [-0.01, 0.0, 0.04]];
|
||||
let radii = vec![0.08, 0.079, 0.081];
|
||||
let sensor_positions = vec![[0.0, 0.1, 0.08], [0.05, 0.08, 0.08], [-0.05, 0.08, 0.08]];
|
||||
|
||||
let model =
|
||||
OverlappingSpheres::from_local_spheres(centers, radii, sensor_positions).unwrap();
|
||||
|
||||
assert_eq!(model.local_spheres.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_gain_matrix() {
|
||||
// Use manual spheres with known radii to avoid fitting issues
|
||||
let n_sensors = 20;
|
||||
let mut centers = Vec::new();
|
||||
let mut radii = Vec::new();
|
||||
let mut sensor_positions = Vec::new();
|
||||
|
||||
// Place sensors in a helmet pattern and assign each a sphere with center at origin
|
||||
let golden_ratio = (1.0 + 5.0_f64.sqrt()) / 2.0;
|
||||
for i in 0..n_sensors {
|
||||
let theta = 2.0 * std::f64::consts::PI * i as f64 / golden_ratio;
|
||||
let phi = (1.0 - (i as f64 + 0.5) / n_sensors as f64).acos();
|
||||
|
||||
if phi < std::f64::consts::PI / 2.0 {
|
||||
let x = 0.12 * phi.sin() * theta.cos();
|
||||
let y = 0.12 * phi.sin() * theta.sin();
|
||||
let z = 0.12 * phi.cos();
|
||||
|
||||
sensor_positions.push([x, y, z]);
|
||||
centers.push([0.0, 0.0, 0.04]); // All spheres centered at head center
|
||||
radii.push(0.08); // 8cm radius
|
||||
}
|
||||
}
|
||||
|
||||
let model =
|
||||
OverlappingSpheres::from_local_spheres(centers, radii, sensor_positions.clone())
|
||||
.unwrap();
|
||||
|
||||
// Create a source space at a safe distance from center (3cm radius shell)
|
||||
let sources = SourceSpace::create_spherical_shell([0.0, 0.0, 0.04], 0.03, 20);
|
||||
|
||||
// Create sensor array
|
||||
let mut sensors = SensorArray::new(SensorType::MegMag);
|
||||
for (i, pos) in sensor_positions.iter().enumerate() {
|
||||
let ori = [-pos[0], -pos[1], -pos[2]]; // Point toward center
|
||||
sensors.add(Sensor::meg_magnetometer(&format!("MEG{:03}", i), *pos, ori));
|
||||
}
|
||||
|
||||
let gain = model.compute_gain(&sources, &sensors).unwrap();
|
||||
|
||||
// Fixed orientation: n_sources columns
|
||||
assert_eq!(gain.n_sensors(), sensors.len());
|
||||
assert_eq!(gain.n_source_columns(), sources.len()); // Fixed orientation
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_defaults() {
|
||||
let config = OverlappingSpheresConfig::default();
|
||||
assert!((config.neighborhood_radius - 0.05).abs() < 1e-10);
|
||||
assert_eq!(config.min_points, 20);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user