Skip to main content

exg_source/
lib.rs

1//! # exg-source — EEG Source Localization in Pure Rust
2//!
3//! [![Crate](https://img.shields.io/crates/v/exg-source.svg)](https://crates.io/crates/exg-source)
4//! [![Docs](https://docs.rs/exg-source/badge.svg)](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};