//! Learnable tissue conductivity models for head modeling. //! //! Provides models for tissue conductivity that can be learned //! jointly with source localization or used with fixed values. use crate::error::{PinnError, PinnResult}; use ndarray::{Array1, Array2}; use serde::{Deserialize, Serialize}; /// Standard tissue types in the head #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum TissueType { /// Brain gray matter GrayMatter, /// Brain white matter WhiteMatter, /// Cerebrospinal fluid Csf, /// Skull bone Skull, /// Scalp/skin Scalp, /// Air (e.g., sinuses) Air, /// Eye tissue Eye, /// Muscle tissue Muscle, } impl TissueType { /// Default isotropic conductivity (S/m) pub fn default_conductivity(&self) -> f64 { match self { TissueType::GrayMatter => 0.33, TissueType::WhiteMatter => 0.14, TissueType::Csf => 1.79, TissueType::Skull => 0.0042, TissueType::Scalp => 0.43, TissueType::Air => 1e-12, TissueType::Eye => 1.5, TissueType::Muscle => 0.2, } } /// Conductivity range (min, max) for learning pub fn conductivity_range(&self) -> (f64, f64) { match self { TissueType::GrayMatter => (0.1, 0.6), TissueType::WhiteMatter => (0.05, 0.3), TissueType::Csf => (1.0, 2.5), TissueType::Skull => (0.001, 0.02), TissueType::Scalp => (0.2, 0.7), TissueType::Air => (1e-15, 1e-10), TissueType::Eye => (0.5, 2.5), TissueType::Muscle => (0.1, 0.4), } } } /// A tissue layer with conductivity #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TissueLayer { /// Tissue type pub tissue_type: TissueType, /// Name/label pub name: String, /// Isotropic conductivity (S/m) pub conductivity: f64, /// Inner radius (for spherical models) pub inner_radius: Option, /// Outer radius (for spherical models) pub outer_radius: Option, /// Whether conductivity is learnable pub learnable: bool, } impl TissueLayer { /// Create a new tissue layer pub fn new(tissue_type: TissueType, name: impl Into) -> Self { Self { tissue_type, name: name.into(), conductivity: tissue_type.default_conductivity(), inner_radius: None, outer_radius: None, learnable: false, } } /// Set conductivity pub fn with_conductivity(mut self, sigma: f64) -> Self { self.conductivity = sigma; self } /// Set radii for spherical model pub fn with_radii(mut self, inner: f64, outer: f64) -> Self { self.inner_radius = Some(inner); self.outer_radius = Some(outer); self } /// Make learnable pub fn learnable(mut self) -> Self { self.learnable = true; self } /// Check if point is inside this layer (spherical model) pub fn contains(&self, r: f64) -> bool { match (self.inner_radius, self.outer_radius) { (Some(inner), Some(outer)) => r >= inner && r < outer, (Some(inner), None) => r >= inner, (None, Some(outer)) => r < outer, (None, None) => true, } } } /// Conductivity model trait pub trait ConductivityModel { /// Get conductivity at a point fn conductivity_at(&self, point: &[f64; 3]) -> f64; /// Get conductivity field at multiple points fn conductivity_field(&self, points: &Array2) -> Array1 { let n = points.nrows(); let mut field = Array1::zeros(n); for i in 0..n { field[i] = self.conductivity_at(&[points[[i, 0]], points[[i, 1]], points[[i, 2]]]); } field } /// Whether this model has learnable parameters fn is_learnable(&self) -> bool { false } /// Get learnable parameters fn get_parameters(&self) -> Vec { Vec::new() } /// Set learnable parameters fn set_parameters(&mut self, _params: &[f64]) -> PinnResult<()> { Ok(()) } } /// Homogeneous (constant) conductivity #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HomogeneousConductivity { /// Conductivity value (S/m) sigma: f64, /// Whether learnable learnable: bool, } impl HomogeneousConductivity { /// Create with fixed conductivity pub fn new(sigma: f64) -> Self { Self { sigma, learnable: false, } } /// Create with learnable conductivity pub fn learnable(initial: f64) -> Self { Self { sigma: initial, learnable: true, } } /// Get conductivity value pub fn sigma(&self) -> f64 { self.sigma } } impl ConductivityModel for HomogeneousConductivity { fn conductivity_at(&self, _point: &[f64; 3]) -> f64 { self.sigma } fn is_learnable(&self) -> bool { self.learnable } fn get_parameters(&self) -> Vec { if self.learnable { vec![self.sigma] } else { Vec::new() } } fn set_parameters(&mut self, params: &[f64]) -> PinnResult<()> { if self.learnable { if params.is_empty() { return Err(PinnError::DimensionMismatch( "Expected 1 parameter for learnable conductivity".into(), )); } self.sigma = params[0].max(1e-6); // Ensure positive } Ok(()) } } /// Layered spherical conductivity model #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LayeredConductivity { /// Tissue layers (ordered from inside to outside) layers: Vec, /// Center of the spherical model center: [f64; 3], } impl LayeredConductivity { /// Create a new layered model pub fn new(center: [f64; 3]) -> Self { Self { layers: Vec::new(), center, } } /// Add a layer pub fn add_layer(&mut self, layer: TissueLayer) { self.layers.push(layer); // Sort by outer radius self.layers.sort_by(|a, b| { let ra = a.outer_radius.unwrap_or(f64::MAX); let rb = b.outer_radius.unwrap_or(f64::MAX); ra.partial_cmp(&rb).unwrap() }); } /// Create standard 3-layer head model pub fn three_layer( center: [f64; 3], brain_radius: f64, skull_thickness: f64, scalp_thickness: f64, ) -> Self { let skull_outer = brain_radius + skull_thickness; let scalp_outer = skull_outer + scalp_thickness; let mut model = Self::new(center); model.add_layer( TissueLayer::new(TissueType::GrayMatter, "brain").with_radii(0.0, brain_radius), ); model.add_layer( TissueLayer::new(TissueType::Skull, "skull").with_radii(brain_radius, skull_outer), ); model.add_layer( TissueLayer::new(TissueType::Scalp, "scalp").with_radii(skull_outer, scalp_outer), ); model } /// Create standard 4-layer head model (with CSF) pub fn four_layer( center: [f64; 3], brain_radius: f64, csf_thickness: f64, skull_thickness: f64, scalp_thickness: f64, ) -> Self { let csf_outer = brain_radius + csf_thickness; let skull_outer = csf_outer + skull_thickness; let scalp_outer = skull_outer + scalp_thickness; let mut model = Self::new(center); model.add_layer( TissueLayer::new(TissueType::GrayMatter, "brain").with_radii(0.0, brain_radius), ); model.add_layer( TissueLayer::new(TissueType::Csf, "csf").with_radii(brain_radius, csf_outer), ); model.add_layer( TissueLayer::new(TissueType::Skull, "skull").with_radii(csf_outer, skull_outer), ); model.add_layer( TissueLayer::new(TissueType::Scalp, "scalp").with_radii(skull_outer, scalp_outer), ); model } /// Get layer containing a point pub fn layer_at(&self, point: &[f64; 3]) -> Option<&TissueLayer> { let dx = point[0] - self.center[0]; let dy = point[1] - self.center[1]; let dz = point[2] - self.center[2]; let r = (dx * dx + dy * dy + dz * dz).sqrt(); self.layers .iter() .find(|&layer| layer.contains(r)) .map(|v| v as _) } /// Get all layers pub fn layers(&self) -> &[TissueLayer] { &self.layers } /// Get mutable layers pub fn layers_mut(&mut self) -> &mut [TissueLayer] { &mut self.layers } /// Number of layers pub fn n_layers(&self) -> usize { self.layers.len() } } impl ConductivityModel for LayeredConductivity { fn conductivity_at(&self, point: &[f64; 3]) -> f64 { self.layer_at(point).map_or(0.0, |l| l.conductivity) } fn is_learnable(&self) -> bool { self.layers.iter().any(|l| l.learnable) } fn get_parameters(&self) -> Vec { self.layers .iter() .filter(|l| l.learnable) .map(|l| l.conductivity) .collect() } fn set_parameters(&mut self, params: &[f64]) -> PinnResult<()> { let learnable_count = self.layers.iter().filter(|l| l.learnable).count(); if params.len() != learnable_count { return Err(PinnError::DimensionMismatch(format!( "Expected {} parameters, got {}", learnable_count, params.len() ))); } let mut idx = 0; for layer in &mut self.layers { if layer.learnable { let (min, max) = layer.tissue_type.conductivity_range(); layer.conductivity = params[idx].clamp(min, max); idx += 1; } } Ok(()) } } /// Neural network-based learnable conductivity #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LearnableConductivity { /// Base layered model base: LayeredConductivity, /// Perturbation weights [n_basis, n_layers] perturbation_weights: Array2, /// Radial basis function centers rbf_centers: Array1, /// RBF width rbf_width: f64, /// Maximum perturbation magnitude max_perturbation: f64, } impl LearnableConductivity { /// Create learnable conductivity from base model pub fn new(base: LayeredConductivity, n_basis: usize) -> Self { let n_layers = base.n_layers(); // Distribute RBF centers radially let max_radius = base .layers() .iter() .filter_map(|l| l.outer_radius) .fold(0.0f64, f64::max); let mut rbf_centers = Array1::zeros(n_basis); for i in 0..n_basis { rbf_centers[i] = max_radius * (i as f64 + 0.5) / n_basis as f64; } Self { base, perturbation_weights: Array2::zeros((n_basis, n_layers)), rbf_centers, rbf_width: max_radius / (n_basis as f64 * 2.0), max_perturbation: 0.1, // 10% max perturbation } } /// Compute RBF values at radius fn rbf_values(&self, r: f64) -> Array1 { self.rbf_centers.mapv(|c| { let d = (r - c) / self.rbf_width; (-0.5 * d * d).exp() }) } /// Get perturbation at radius for each layer fn perturbation(&self, r: f64) -> Array1 { let rbf = self.rbf_values(r); let mut perturb: Array1 = Array1::zeros(self.base.n_layers()); for j in 0..self.base.n_layers() { for i in 0..self.rbf_centers.len() { perturb[j] += self.perturbation_weights[[i, j]] * rbf[i]; } // Clamp perturbation perturb[j] = perturb[j].tanh() * self.max_perturbation; } perturb } /// Get base model pub fn base(&self) -> &LayeredConductivity { &self.base } } impl ConductivityModel for LearnableConductivity { fn conductivity_at(&self, point: &[f64; 3]) -> f64 { let dx = point[0] - self.base.center[0]; let dy = point[1] - self.base.center[1]; let dz = point[2] - self.base.center[2]; let r = (dx * dx + dy * dy + dz * dz).sqrt(); // Find which layer and apply perturbation let perturb = self.perturbation(r); for (i, layer) in self.base.layers().iter().enumerate() { if layer.contains(r) { let (min, max) = layer.tissue_type.conductivity_range(); let sigma = layer.conductivity * (1.0 + perturb[i]); return sigma.clamp(min, max); } } 0.0 } fn is_learnable(&self) -> bool { true } fn get_parameters(&self) -> Vec { self.perturbation_weights.iter().copied().collect() } fn set_parameters(&mut self, params: &[f64]) -> PinnResult<()> { if params.len() != self.perturbation_weights.len() { return Err(PinnError::DimensionMismatch(format!( "Expected {} parameters, got {}", self.perturbation_weights.len(), params.len() ))); } for (i, &p) in params.iter().enumerate() { let (row, col) = (i / self.base.n_layers(), i % self.base.n_layers()); self.perturbation_weights[[row, col]] = p; } Ok(()) } } /// Anisotropic conductivity tensor #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AnisotropicConductivity { /// Conductivity tensor [3, 3] at each layer tensors: Vec>, /// Base layered model for geometry geometry: LayeredConductivity, } impl AnisotropicConductivity { /// Create from isotropic layered model pub fn from_isotropic(geometry: LayeredConductivity) -> Self { let tensors = geometry .layers() .iter() .map(|l| { let sigma = l.conductivity; Array2::from_diag(&ndarray::arr1(&[sigma, sigma, sigma])) }) .collect(); Self { tensors, geometry } } /// Set anisotropic tensor for a layer pub fn set_tensor(&mut self, layer_idx: usize, tensor: Array2) -> PinnResult<()> { if layer_idx >= self.tensors.len() { return Err(PinnError::InvalidConfig(format!( "Layer index {} out of range", layer_idx ))); } if tensor.shape() != [3, 3] { return Err(PinnError::DimensionMismatch("Tensor must be 3x3".into())); } self.tensors[layer_idx] = tensor; Ok(()) } /// Get conductivity tensor at a point pub fn tensor_at(&self, point: &[f64; 3]) -> Array2 { let dx = point[0] - self.geometry.center[0]; let dy = point[1] - self.geometry.center[1]; let dz = point[2] - self.geometry.center[2]; let r = (dx * dx + dy * dy + dz * dz).sqrt(); for (i, layer) in self.geometry.layers().iter().enumerate() { if layer.contains(r) { return self.tensors[i].clone(); } } Array2::zeros((3, 3)) } /// Compute σ∇Φ where σ is tensor pub fn apply_to_gradient(&self, point: &[f64; 3], gradient: &[f64; 3]) -> [f64; 3] { let tensor = self.tensor_at(point); let grad = ndarray::arr1(gradient); let result = tensor.dot(&grad); [result[0], result[1], result[2]] } } impl ConductivityModel for AnisotropicConductivity { fn conductivity_at(&self, point: &[f64; 3]) -> f64 { // Return trace / 3 as effective isotropic conductivity let tensor = self.tensor_at(point); (tensor[[0, 0]] + tensor[[1, 1]] + tensor[[2, 2]]) / 3.0 } } #[cfg(test)] mod tests { use super::*; #[test] fn test_tissue_types() { assert!((TissueType::GrayMatter.default_conductivity() - 0.33).abs() < 1e-10); assert!((TissueType::Skull.default_conductivity() - 0.0042).abs() < 1e-10); } #[test] fn test_homogeneous() { let model = HomogeneousConductivity::new(0.33); assert!((model.conductivity_at(&[0.0, 0.0, 0.0]) - 0.33).abs() < 1e-10); assert!((model.conductivity_at(&[1.0, 2.0, 3.0]) - 0.33).abs() < 1e-10); } #[test] fn test_layered_conductivity() { let model = LayeredConductivity::three_layer( [0.0, 0.0, 0.0], 0.08, // brain radius 0.007, // skull thickness 0.006, // scalp thickness ); assert_eq!(model.n_layers(), 3); // Inside brain let sigma = model.conductivity_at(&[0.0, 0.0, 0.05]); assert!((sigma - 0.33).abs() < 1e-10); // Inside skull let sigma = model.conductivity_at(&[0.0, 0.0, 0.082]); assert!((sigma - 0.0042).abs() < 1e-10); // Inside scalp let sigma = model.conductivity_at(&[0.0, 0.0, 0.09]); assert!((sigma - 0.43).abs() < 1e-10); } #[test] fn test_four_layer_model() { let model = LayeredConductivity::four_layer( [0.0, 0.0, 0.0], 0.078, // brain 0.002, // CSF 0.007, // skull 0.006, // scalp ); assert_eq!(model.n_layers(), 4); // CSF layer let sigma = model.conductivity_at(&[0.0, 0.0, 0.079]); assert!((sigma - 1.79).abs() < 1e-10); } #[test] fn test_learnable_conductivity() { let base = LayeredConductivity::three_layer([0.0, 0.0, 0.0], 0.08, 0.007, 0.006); let model = LearnableConductivity::new(base, 5); // Should return values close to base model let sigma = model.conductivity_at(&[0.0, 0.0, 0.05]); assert!((sigma - 0.33).abs() < 0.05); // Has learnable parameters assert!(model.is_learnable()); assert!(model.get_parameters().len() > 0); } #[test] fn test_anisotropic_conductivity() { let geometry = LayeredConductivity::three_layer([0.0, 0.0, 0.0], 0.08, 0.007, 0.006); let model = AnisotropicConductivity::from_isotropic(geometry); // Effective conductivity should equal isotropic value let sigma = model.conductivity_at(&[0.0, 0.0, 0.05]); assert!((sigma - 0.33).abs() < 1e-10); // Tensor should be diagonal let tensor = model.tensor_at(&[0.0, 0.0, 0.05]); assert!((tensor[[0, 0]] - 0.33).abs() < 1e-10); assert!(tensor[[0, 1]].abs() < 1e-10); } }