exg_source/lib.rs
1//! # exg-source — EEG Source Localization in Pure Rust
2//!
3//! [](https://crates.io/crates/exg-source)
4//! [](https://docs.rs/exg-source)
5//!
6//! Complete EEG source localization pipeline ported from
7//! [MNE-Python](https://mne.tools), implemented in pure Rust with no C/BLAS
8//! dependencies. Uses [`faer`](https://crates.io/crates/faer) for SVD and
9//! eigendecomposition.
10//!
11//! Part of the [`exg`](https://crates.io/crates/exg) workspace.
12//! Can be used **standalone** or via `exg`'s default `source` feature.
13//! See also [`exg-luna`](https://crates.io/crates/exg-luna) for the LUNA
14//! seizure-detection preprocessing pipeline.
15//!
16//! ## End-to-end example
17//!
18//! ```no_run
19//! use exg_source::*;
20//! use exg_source::covariance::Regularization;
21//! use ndarray::Array2;
22//!
23//! // 1. Define source space — 162 dipoles on a cortical sphere
24//! let (src_pos, src_nn) = ico_source_space(2, 0.06, [0.0, 0.0, 0.04]);
25//!
26//! // 2. Define electrode positions (e.g., from a 10-20 montage)
27//! let n_elec = 32;
28//! let electrodes = Array2::<f64>::zeros((n_elec, 3)); // your positions here
29//!
30//! // 3. Compute forward model (3-shell spherical head)
31//! let sphere = SphereModel::default(); // brain/skull/scalp
32//! let fwd = make_sphere_forward(&electrodes, &src_pos, &src_nn, &sphere);
33//!
34//! // 4. Estimate noise covariance from a baseline recording
35//! let baseline = Array2::<f64>::zeros((n_elec, 5000));
36//! let noise_cov = compute_covariance(&baseline, Regularization::ShrunkIdentity(None));
37//!
38//! // 5. Build the inverse operator
39//! let inv = make_inverse_operator(&fwd, &noise_cov, Some(0.8)).unwrap();
40//!
41//! // 6. Apply to EEG data
42//! let data = Array2::<f64>::zeros((n_elec, 1000));
43//! let lambda2 = 1.0 / 9.0; // SNR² = 9
44//! let stc = apply_inverse(&data, &inv, lambda2, InverseMethod::SLORETA).unwrap();
45//!
46//! // 7. Assess solution quality
47//! let (snr, snr_est) = estimate_snr(&data, &inv);
48//! let r = make_resolution_matrix(&inv, &fwd, lambda2, InverseMethod::SLORETA).unwrap();
49//! let errors = peak_localisation_error(&r);
50//! ```
51//!
52//! ## Modules
53//!
54//! | Module | Description |
55//! |--------|-------------|
56//! | [`source_space`] | Icosahedron and grid source space generation |
57//! | [`forward`] | Spherical head model (Berg & Scherg) forward computation |
58//! | [`covariance`] | Noise covariance estimation (empirical, Ledoit–Wolf, diagonal) |
59//! | [`inverse`] | Inverse operator construction and application |
60//! | [`eloreta`] | eLORETA iterative weight solver |
61//! | [`resolution`] | Resolution matrix, PSF, CTF, and spatial metrics |
62//! | [`snr`] | SNR estimation from whitened data |
63//! | [`linalg`] | SVD, eigendecomposition, whitener (faer backend) |
64//!
65//! ## Inverse methods
66//!
67//! | Method | Noise-normalised? | Iterative? | Best for |
68//! |--------|:-:|:-:|----------|
69//! | **MNE** | ✗ | ✗ | Raw current density estimates |
70//! | **dSPM** | ✓ | ✗ | Statistical maps, group studies |
71//! | **sLORETA** | ✓ | ✗ | Localisation with low bias |
72//! | **eLORETA** | ✓ | ✓ | Exact zero-bias localisation |
73//!
74//! ## Mathematical background
75//!
76//! The whitened gain matrix is decomposed via SVD:
77//!
78//! ```text
79//! G_w = W @ G @ R^{1/2} = U @ S @ V^T
80//! ```
81//!
82//! where **W** is the whitener (`C_noise^{-1/2}`), **R** is the source
83//! covariance (depth / orientation priors), and **U**, **S**, **V** are the
84//! SVD factors.
85//!
86//! The inverse kernel is:
87//!
88//! ```text
89//! K = R^{1/2} @ V @ diag(s / (s² + λ²)) @ U^T @ W
90//! ```
91//!
92//! Noise normalisation divides each source by its noise sensitivity:
93//!
94//! - **dSPM**: `norm_k = ‖V_k @ diag(reginv)‖`
95//! - **sLORETA**: `norm_k = ‖V_k @ diag(reginv × √(1 + s²/λ²))‖`
96//! - **eLORETA**: iterative reweighting to achieve `R = I` (see [`eloreta`])
97
98pub mod covariance;
99pub mod eloreta;
100pub mod forward;
101pub mod inverse;
102pub mod linalg;
103pub mod resolution;
104pub mod snr;
105pub mod source_space;
106
107use ndarray::{Array1, Array2};
108
109// ── Public types ───────────────────────────────────────────────────────────
110
111/// Choice of inverse method.
112///
113/// See the [module-level docs](crate) for a comparison table.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum InverseMethod {
116 /// Minimum-norm estimate (no noise normalisation).
117 MNE,
118 /// Dynamic statistical parametric mapping (noise-normalised).
119 DSPM,
120 /// Standardised low-resolution electromagnetic tomography.
121 SLORETA,
122 /// Exact low-resolution electromagnetic tomography (iterative).
123 ELORETA,
124}
125
126/// Source orientation constraint.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum SourceOrientation {
129 /// Fixed orientation: one value per source (`n_orient = 1`).
130 Fixed,
131 /// Free orientation: three Cartesian components per source (`n_orient = 3`).
132 Free,
133}
134
135/// Forward operator (gain matrix + metadata).
136///
137/// The gain matrix maps source activations to sensor measurements:
138/// `x(t) = G @ j(t) + noise`.
139///
140/// # Construction
141///
142/// - From a raw gain matrix: [`ForwardOperator::new_fixed`] or [`ForwardOperator::new_free`].
143/// - From electrode positions + source space: [`forward::make_sphere_forward`].
144#[derive(Debug, Clone)]
145pub struct ForwardOperator {
146 /// Gain matrix, shape `[n_channels, n_sources × n_orient]`.
147 pub gain: Array2<f64>,
148 /// Source normals/orientations, shape `[n_sources × n_orient, 3]`.
149 pub source_nn: Array2<f64>,
150 /// Number of source locations.
151 pub n_sources: usize,
152 /// Orientation mode.
153 pub orientation: SourceOrientation,
154 /// Depth-weighting exponent (0.8 for MEG, 2–5 for EEG). `None` = no depth weighting.
155 pub depth_exp: Option<f64>,
156}
157
158impl ForwardOperator {
159 /// Create a fixed-orientation forward operator from a gain matrix
160 /// `[n_channels, n_sources]`.
161 ///
162 /// Source normals are initialised to zero; set [`ForwardOperator::source_nn`]
163 /// manually if you need proper normals for `PickOri::Normal`.
164 pub fn new_fixed(gain: Array2<f64>) -> Self {
165 let n_src = gain.ncols();
166 let source_nn = Array2::zeros((n_src, 3));
167 Self {
168 gain,
169 source_nn,
170 n_sources: n_src,
171 orientation: SourceOrientation::Fixed,
172 depth_exp: None,
173 }
174 }
175
176 /// Create a free-orientation forward operator from a gain matrix
177 /// `[n_channels, n_sources × 3]`.
178 ///
179 /// Panics if `gain.ncols()` is not divisible by 3.
180 pub fn new_free(gain: Array2<f64>) -> Self {
181 let n_cols = gain.ncols();
182 assert!(
183 n_cols % 3 == 0,
184 "Free-orientation gain must have 3N columns, got {n_cols}"
185 );
186 let n_src = n_cols / 3;
187 let mut source_nn = Array2::zeros((n_cols, 3));
188 for i in 0..n_src {
189 source_nn[[i * 3, 0]] = 1.0;
190 source_nn[[i * 3 + 1, 1]] = 1.0;
191 source_nn[[i * 3 + 2, 2]] = 1.0;
192 }
193 Self {
194 gain,
195 source_nn,
196 n_sources: n_src,
197 orientation: SourceOrientation::Free,
198 depth_exp: None,
199 }
200 }
201
202 /// Number of orientations per source (1 for fixed, 3 for free).
203 pub fn n_orient(&self) -> usize {
204 match self.orientation {
205 SourceOrientation::Fixed => 1,
206 SourceOrientation::Free => 3,
207 }
208 }
209}
210
211/// Noise covariance matrix.
212///
213/// Can be a full `[n_channels, n_channels]` matrix or a diagonal vector.
214///
215/// # Construction
216///
217/// - Directly: [`NoiseCov::full`] or [`NoiseCov::diagonal`].
218/// - From data: [`covariance::compute_covariance`] or [`covariance::compute_covariance_epochs`].
219#[derive(Debug, Clone)]
220pub struct NoiseCov {
221 /// Full covariance matrix `[n, n]`, or a diagonal stored as `[n, 1]`.
222 data: Array2<f64>,
223 /// If true, `data` is `[n, 1]` (diagonal elements only).
224 pub diag: bool,
225}
226
227impl NoiseCov {
228 /// Create from a full covariance matrix `[n, n]`.
229 pub fn full(data: Array2<f64>) -> Self {
230 assert_eq!(data.nrows(), data.ncols(), "Covariance must be square");
231 Self { data, diag: false }
232 }
233
234 /// Create from diagonal variances (channel noise powers).
235 pub fn diagonal(variances: Vec<f64>) -> Self {
236 let n = variances.len();
237 let data = Array2::from_shape_vec((n, 1), variances).unwrap();
238 Self { data, diag: true }
239 }
240
241 /// Number of channels.
242 pub fn n_channels(&self) -> usize {
243 self.data.nrows()
244 }
245
246 /// Return the full covariance matrix (expanding diagonal if needed).
247 pub fn to_full(&self) -> Array2<f64> {
248 if self.diag {
249 let n = self.data.nrows();
250 let mut out = Array2::zeros((n, n));
251 for i in 0..n {
252 out[[i, i]] = self.data[[i, 0]];
253 }
254 out
255 } else {
256 self.data.clone()
257 }
258 }
259
260 /// Return the diagonal elements (channel variances).
261 pub fn diag_elements(&self) -> Array1<f64> {
262 if self.diag {
263 self.data.column(0).to_owned()
264 } else {
265 self.data.diag().to_owned()
266 }
267 }
268}
269
270/// Prepared inverse operator, ready for application to data.
271///
272/// Created by [`make_inverse_operator`] and consumed by [`apply_inverse`].
273/// Contains the SVD decomposition of the whitened gain matrix plus
274/// all metadata needed to reconstruct source currents.
275#[derive(Debug, Clone)]
276pub struct InverseOperator {
277 /// Left singular vectors transposed: `U^T`, shape `[n_nzero, n_channels]`.
278 pub eigen_fields: Array2<f64>,
279 /// Singular values, length `n_nzero`.
280 pub sing: Array1<f64>,
281 /// Right singular vectors: `V`, shape `[n_sources × n_orient, n_nzero]`.
282 pub eigen_leads: Array2<f64>,
283 /// Source covariance diagonal (depth + orient priors), length `n_sources × n_orient`.
284 pub source_cov: Array1<f64>,
285 /// Whether `eigen_leads` already includes `√source_cov` (set by eLORETA).
286 pub eigen_leads_weighted: bool,
287 /// Number of source locations.
288 pub n_sources: usize,
289 /// Orientation mode.
290 pub orientation: SourceOrientation,
291 /// Source normals, shape `[n_sources × n_orient, 3]`.
292 pub source_nn: Array2<f64>,
293 /// Whitener matrix, shape `[n_nzero, n_channels]`.
294 pub whitener: Array2<f64>,
295 /// Number of non-zero eigenvalues in the noise covariance.
296 pub n_nzero: usize,
297 /// Noise covariance (retained for eLORETA computation).
298 pub noise_cov: NoiseCov,
299}
300
301/// Source-space estimate produced by [`apply_inverse`].
302///
303/// Contains source time courses and metadata about the source space.
304#[derive(Debug, Clone)]
305pub struct SourceEstimate {
306 /// Source time courses.
307 ///
308 /// Shape depends on [`PickOri`]:
309 /// - `PickOri::None` / `PickOri::Normal` → `[n_sources, n_times]`
310 /// - `PickOri::Vector` → `[n_sources × 3, n_times]`
311 pub data: Array2<f64>,
312 /// Number of source locations.
313 pub n_sources: usize,
314 /// Orientation mode of the inverse operator.
315 pub orientation: SourceOrientation,
316}
317
318/// How to handle source orientations in free-orientation inverse solutions.
319///
320/// Only relevant when the inverse operator has [`SourceOrientation::Free`].
321/// For fixed-orientation operators, all variants behave identically.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum PickOri {
324 /// Combine XYZ components as `√(x² + y² + z²)` per source (default).
325 None,
326 /// Extract only the component normal to the cortical surface.
327 /// Requires a free-orientation inverse operator.
328 Normal,
329 /// Return all three orientation components without combining.
330 /// Output has shape `[n_sources × 3, n_times]`.
331 Vector,
332}
333
334/// Options for the eLORETA iterative solver.
335///
336/// See [`inverse::apply_inverse_full`] and [`eloreta`] for details.
337#[derive(Debug, Clone)]
338pub struct EloretaOptions {
339 /// Convergence threshold (default: `1e-6`).
340 pub eps: f64,
341 /// Maximum number of iterations (default: `20`).
342 pub max_iter: usize,
343 /// Force equal weights across XYZ orientations at each source.
344 ///
345 /// - `None` — automatic: `true` for fixed, `false` for free orientation.
346 /// - `Some(true)` — uniform weights (like dSPM/sLORETA), recommended for loose orientation.
347 /// - `Some(false)` — independent 3×3 weights per source (reference eLORETA).
348 pub force_equal: Option<bool>,
349}
350
351impl Default for EloretaOptions {
352 fn default() -> Self {
353 Self {
354 eps: 1e-6,
355 max_iter: 20,
356 force_equal: None,
357 }
358 }
359}
360
361// ── Public API re-exports ──────────────────────────────────────────────────
362
363// Inverse operator
364pub use inverse::{
365 apply_inverse, apply_inverse_epochs, apply_inverse_epochs_full, apply_inverse_full,
366 apply_inverse_with_options, make_inverse_operator, prepare_inverse,
367};
368
369// Covariance estimation
370pub use covariance::{compute_covariance, compute_covariance_epochs, Regularization};
371
372// Resolution analysis
373pub use resolution::{
374 get_cross_talk, get_point_spread, make_resolution_matrix, peak_localisation_error,
375 relative_amplitude, spatial_spread,
376};
377
378// SNR estimation
379pub use snr::estimate_snr;
380
381// Forward model
382pub use forward::{make_sphere_forward, make_sphere_forward_free, SphereModel};
383
384// Source space
385pub use source_space::{grid_source_space, ico_n_vertices, ico_source_space};