Skip to content

About

EEG source localization pipeline for mouse models using the Antwerp Mouse Brain Atlas. Supports multiple BEM head models, inverse methods (sLORETA, dSPM, MNE, beamformers), and multi-subject batch processing.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Mouse EEG Source Localization Package

Created: 2025-11-26 Last Updated: 2026-11-09 Version: 0.9.0 Status: Alpha

A Python pipeline for mouse EEG source localization: a 30-channel scalp recording in, per-ROI source time series and whole-brain source estimates out. Ships with several registered brain parcellations (Antwerp and Allen CCFv3 derived), multi-subject batch processing, MNE-based spectral and connectivity analysis, a dipole-simulation validation framework, and figure utilities.

The version and status above come from pyproject.toml and are checked by tests/test_readme_consistency.py, as are the atlas and preset tables below. If this file and the code disagree, the code is right and the test is broken.


Table of Contents


Overview

📖 Documentation site: docs/ — guides (adding an atlas, electrode setup, Monte Carlo source sampling) and known issues you should read before interpreting output. Preview locally with uv run --no-project --with "mkdocs-material>=9.5,<10" mkdocs serve.

Source localization solves the EEG inverse problem: given electrode measurements on the scalp, estimate the locations and strengths of neural activity inside the brain. This package does that for mouse recordings from a fixed 30-channel array, and reports the result over an atlas parcellation so that it can feed ROI-level statistics.

It is a research codebase under active development. The inverse problem is ill-posed and 30 sensors cap the effective rank of any solution at 30 whatever the source count, so read Validation and the known issues before treating a per-ROI number as a claim about anatomy.


Features

Core Pipeline

  • 11 pipeline presets covering 2 BEM types and 4 source-space families
  • 2 BEM types: sphere (analytical, fast) and ellipsoid (numerical, fitted to the brain mask)
  • 4 source-space families:
    • Surface: anatomical cortical mid-ribbon cut from the Allen32 parcellation (default), or a geometric icosphere
    • ROI-based: sources placed inside each atlas parcel
    • Cartesian: 3D volumetric grid
    • Shell: concentric geometry-matched shells
  • Inverse methods: custom MNE, dSPM, sLORETA and eLORETA with mouse-scale regularization; stock MNE-Python LCMV and DICS beamformers (see known issues for the beamformer caveat)
  • Orientation constraint: fixed to the cortical normal on the anatomical surface, free elsewhere
  • Registered atlases selectable with --atlas (see Atlas Selection)
  • 30-channel electrode array coordinates included, with the canonical six electrode regions (Left/Right Frontal, Medial, Posterior) in data/electrodes/mouse_array_regions.yaml; copy that block into a study's electrode_categories

Multi-Subject Study Processing

  • BIDS-inspired folder hierarchy, YAML study configuration
  • Batch processing with parallel jobs, status reporting, QC
  • Group-level result collection

MNE-Based Analysis

  • Band power per ROI using MNE's Welch PSD
  • Connectivity via MNE-Connectivity (coherence, PLV, wPLI, imcoh)
  • Automatic epoching of continuous data for connectivity

ROI Extraction

  • The pipeline's ROI step assigns each source to a parcel (by the source space's own parcel labels where it has them, otherwise nearest labeled voxel) and takes the unweighted mean of the sources in each parcel.
  • Two variants are written: magnitude (always positive) and signed. Under the fixed-orientation anatomical surface the signed value is the component along the cortical normal. Under free orientation it is, by default, the component with the largest variance within each epoch. Neither is an SVD. With inverse.signed_orientation: leadfield it is instead the projection onto each source's fixed leadfield direction, signs aligned within each parcel; see "What the outputs are".
  • Depth-weighted extraction exists in source_analysis.roi_analysis but is not part of the pipeline; the earlier claim that it was has been removed.

Visualizations

  • Per-step QC figures and an HTML report from every run
  • Interpolated heatmaps, ROI parcellation overlays, connectivity matrices and chord diagrams in source_analysis

Installation

Prerequisites

  • Python >= 3.9 and < 3.14
  • Virtual environment manager (uv recommended)

Using uv (Recommended)

# Install uv if needed
curl -LsSf https://astral.sh/uv/install.sh | sh

# Clone and install
git clone https://github.com/alexedmon1/source-localization.git
cd source-localization
uv venv && source .venv/bin/activate
uv pip install -e .

# For connectivity analysis (optional)
uv pip install -e ".[connectivity]"

# For the study's localization summary in the shared results format (optional, Python 3.10+)
uv pip install -e ".[results]"

Reproducing a published analysis

A plain install resolves whatever versions are current, which is not what produced any published result — MNE in particular resolves to 1.12.1 rather than the 1.11.0 used throughout. Install against a lockfile instead:

uv venv .venv
uv pip install --python .venv -r <lockfile>
uv pip install --python .venv --no-deps "source-localization @ git+https://github.com/alexedmon1/source-localization.git@<tag>"

--no-deps is deliberate: the lockfile is the authority on versions, and letting the package re-resolve its own dependencies is what drifts the environment.

Release tags used by published work:

tag what it is
v0.2.1 the reconstruction cited by the Fmr1 KO vs WT manuscript
v0.2.3 v0.2.1 plus one line of packaging metadata — declares scikit-learn, which source_space/roi_based.py imports but v0.2.1 never declared. git diff v0.2.1 v0.2.3 touches pyproject.toml and nothing else, so every analysis path is byte-identical. Install this; cite v0.2.1
v0.4.1 corrected brain mask — sources are placed inside the brain rather than the meningeal rim
v0.4.2 v0.4.1 plus the same packaging fix. Install this; cite v0.4.1

Every tag before v0.9.0 solves surface BEMs in the wrong surface order (see History, 0.9.0): forward models from the ellipsoid presets and the numerical sphere are physically inconsistent and wrong in amplitude by orders of magnitude. The analytic sphere is unaffected. Reproduce an older result with its tag, but rerun anything new on v0.9.0 or later.

Note that versions before 0.5.1 did not reorder EEG channels to match the electrode registration. Recordings stored in a channel order other than E1..E30 were silently misaligned with the leadfield. Check epochs.ch_names on your input files before relying on an older tag.

Verify Installation

source-localization --help
source-localization study --help
python -c "import source_localization; print(source_localization.__version__)"

Quick Start

Single Subject

# Anatomical surface, ellipsoid BEM, Allen32 parcels (the preset carries its atlas)
source-localization run --preset ellipsoid_surface --eeg /path/to/data.set --output ./results

# ROI-based sources on the Allen32 atlas (what the FORGE study used)
source-localization run --preset roi_based_ellipsoid --atlas allen32 --eeg /path/to/data.set --output ./results

# View results
open results/pipeline_report.html

Presets other than the two anatomical-surface ones default to the Antwerp atlas when --atlas is omitted. Prefer --atlas allen32 for anything that ends in a per-ROI claim; see Atlas Selection.

Multi-Subject Study

# 1. Initialize study from a folder of EEG files
source-localization study init /path/to/eeg_data --name "MyStudy"

# 2. Process all subjects (4 parallel jobs)
source-localization study run study_config.yaml --jobs 4

# 3. Run spectral and connectivity analysis
source-localization study analyze study_config.yaml --verbose

# 4. Check status
source-localization study status study_config.yaml

Multi-Subject Study Processing

The study module provides a BIDS-inspired framework for organizing and processing multi-subject EEG studies.

Folder Hierarchy

study_folder/
├── study_config.yaml           # Study configuration
├── sourcedata/                 # Raw EEG files (or symlinks)
│   ├── sub-001.set
│   └── sub-002.set
├── participants.csv            # Subject metadata (optional)
└── derivatives/
    └── source_localization/
        ├── sub-001/
        │   ├── pipeline/       # Source localization outputs
        │   ├── roi_timeseries/ # ROI time series (.set files)
        │   └── analysis/       # Band power, connectivity
        └── group/
            ├── subjects.csv            # written by `study collect`
            ├── group_band_power.csv    # written by `study analyze`
            └── connectivity_*.csv

CLI Commands

# Initialize study from folder (default preset: roi_based_ellipsoid)
source-localization study init /path/to/data --name "MyStudy" --preset roi_based_ellipsoid

# Run source localization pipeline
source-localization study run study_config.yaml --jobs 4 --verbose

# Run spectral/connectivity analysis (uses MNE)
source-localization study analyze study_config.yaml --bands delta theta alpha beta low_gamma high_gamma --connectivity coherence

# Check processing status
source-localization study status study_config.yaml

# Collect the per-subject table into group/subjects.csv
source-localization study collect study_config.yaml

# Quality control report
source-localization study qc study_config.yaml

The study's localization summary

With the optional results extra installed (Lumière, the results specification shared with source-analytics and neurofaune), study qc also writes the study's localization as one diagnostic analysis in qc/, beside qc_report.html, so a study gallery or any reader can show how the recordings were localized without this package:

file what it holds
analysis.json role: diagnostic, domain: localization, the atlas, and caveats: recordings that failed or are missing, a recipe that differs between recordings, parcels the montage cannot resolve, QC outliers
recordings.csv (+ .json) one row per recording in the study config: subject, session, recording, group, its output folder, status (and the logged error), the paths of its pipeline_report.html, electrode-registration check and step figures, the version that localized it, its QC metrics and, under Monte Carlo sampling, its lowest coverage and gain and its count of collinear and undersampled parcels
parcel_coverage.csv (+ .json) Monte Carlo only: one row per parcel over the recordings: coverage, gain, its highest collinearity and the parcel it is most often collinear with, and reliable (never collinear at r >= 0.99, never below 0.5 coverage)
provenance.json the localization recipe (preset, atlas, BEM, source space, sampling, inverse) and whether every recording used the same one

Paths are relative to qc/, and each recording is named by its fields, so a reader never parses folder names such as sub-801_ses-01. Check it with lumiere check qc/. Without the extra, study qc is unchanged.

Python API

from source_localization.study import (
    StudyConfig,
    process_study,
    create_study_from_folder,
    analyze_study
)

# Create study from folder
config = create_study_from_folder(
    folder='/path/to/eeg_data',
    name='MyStudy',
    preset='roi_based_ellipsoid'
)

# Process all subjects
result = process_study(config, n_jobs=4)
print(f"Processed {result.n_successful} subjects, {result.n_failed} failed")

# Run analysis
df = analyze_study(
    config,
    bands={'low_gamma': (30, 55), 'theta': (4, 10)},
    connectivity_methods=['coherence', 'plv'],
    n_jobs=4
)

Spectral and Connectivity Analysis

The analysis module wraps MNE-Python and MNE-Connectivity for batch analysis of processed subjects.

Band Power Analysis

Computes power spectral density per ROI using Welch's method:

from source_localization.study import analyze_subject, DEFAULT_BANDS

# DEFAULT_BANDS: delta, theta, alpha, beta, low_gamma, high_gamma
result = analyze_subject(
    subject_dir='derivatives/source_localization/sub-001',
    bands=DEFAULT_BANDS,
    overwrite=False
)

# Output: analysis/band_power.csv
# Columns: roi, band, fmin, fmax, power, power_db

There is no band called gamma. The defaults split it into low_gamma (30-55 Hz) and high_gamma (65-100 Hz). Unknown band names are rejected.

Connectivity Analysis

Computes ROI-to-ROI connectivity using MNE-Connectivity:

# Requires: pip install mne-connectivity
result = analyze_subject(
    subject_dir='derivatives/source_localization/sub-001',
    connectivity_methods=['coherence', 'plv', 'wpli'],
    connectivity_bands=['low_gamma', 'theta'],
    epoch_length=2.0  # For continuous data, create 2s epochs
)

# Output: analysis/connectivity_coherence_low_gamma.csv (n_roi x n_roi matrix)

Available Methods

Method Description Use Case
coherence Magnitude-squared coherence Default, linear relationships
plv Phase-locking value Phase synchronization
wpli Weighted phase-lag index Volume conduction robust
imcoh Imaginary coherence Zero-lag artifact removal

CLI Usage

source-localization study analyze study_config.yaml \
    --bands delta theta alpha beta low_gamma high_gamma \
    --connectivity coherence plv \
    --epoch-length 2.0 \
    --jobs 4 \
    --verbose

Configuration Presets

Available Presets (12 total)

Source counts were measured on 2026-09-03 with the Allen32 atlas. ROI-based counts depend on the atlas (e.g. roi_based_sphere places 184 sources on Antwerp). Presets in src/source_localization/config/presets/ are the source of truth for every setting; the CLI lists them under --preset.

Preset BEM Source space Sources Notes
ellipsoid_surface Ellipsoid (extended) Anatomical surface, 0.5 mm 1310 Cortical mid-ribbon, fixed orientation. Carries the Allen32 atlas
ellipsoid_surface_anatomical Ellipsoid (extended) Anatomical surface, 0.5 mm 1310 Same configuration as ellipsoid_surface, kept under its old name
sphere_surface Sphere Icosphere, ico 3 305 Geometric surface, free orientation. Fast
roi_based_ellipsoid Ellipsoid ROI-based 204 Sources inside each parcel. Used by the FORGE study with --atlas allen32
roi_based_sphere Sphere ROI-based 196 Fast ROI-based. The sphere does not contain all ROI sources: 8 of 204 dropped on allen32, 22 of 206 on antwerp (tests/test_preset_containment.py)
shell_ellipsoid Ellipsoid Shell 187 Concentric shells; see shell coverage known issue
shell_ellipsoid_extended Ellipsoid (extended) Shell 115 Conductor extended over the olfactory bulbs
shell_sphere Sphere Shell 91 Fast shell-based
ellipsoid_cartesian Ellipsoid Cartesian grid 141 Volumetric grid, auto spacing
ellipsoid_cartesian_extended Ellipsoid (extended) Cartesian grid 150 Conductor extended over the olfactory bulbs
sphere_cartesian Sphere Cartesian grid 80 Volumetric grid, auto spacing
ellipsoid_hybrid Ellipsoid (extended) Hybrid: anatomical surface + deep volume 84 per draw (pool 10,267 on allen26) Monte Carlo only, 48 surface + 36 deep sources per draw, 100 draws. See Hybrid source space

"Extended" ellipsoids shift the centre anteriorly and lengthen the Y semi-axis so the olfactory bulbs fall inside the conductor. Without that, MNE drops bulb sources from the forward. Since 0.5.1 the forward step reports any dropped sources and restricts every per-source array to the survivors, so a drop can no longer misalign downstream indexing, but it still discards anatomy.

Source Space Types

Type Description Orientation Best for
Surface (anatomical) Cortical mid-ribbon from Allen32, sources carry their parcel Fixed to cortical normal Cortical activity, signed measures
Surface (icosphere) Geometric mesh inset from the brain surface Free Baseline comparisons
ROI-based Sources placed inside each atlas parcel Free ROI-level statistics, mixed models
Cartesian 3D volumetric grid Free Whole-brain coverage
Shell Concentric geometry-matched shells Free Depth-stratified parametric mapping
Hybrid Anatomical surface for cortex, cerebellum and olfactory bulb, plus a volume grid for the deep structures no surface reaches. Monte Carlo sampling only Surface: fixed to cortical normal; volume: dominant direction Data with deep generators that a cortex-only space would push onto cortex

Hybrid source space

source_type: hybrid combines two parts in one source space. Cortex is represented once, by the surface.

  • Surface: the anatomical cortical mid-ribbon (as in ellipsoid_surface), sources fixed along the cortical normal.
  • Volume: a Cartesian grid restricted to the atlas categories the surface leaves out (for allen26: brainstem, thalamic, hypothalamic, hippocampal and subcortical), each source collapsed to its dominant orientation.

It exists because a cortex-only space has nowhere to put a deep response, so the inverse spreads it over the nearest cortex. On the MEA30 dorsal array the auditory responses are dominated by a posterior-midline (deep) generator, and a surface space assigned them to cerebellum, retrosplenial and lateral cortex.

Monte Carlo only. A fixed-grid inverse applies one orientation rule to every source, so source_sampling: fixed raises for a hybrid space.

The ellipsoid_hybrid preset is the configuration below. It is ellipsoid_surface's BEM and surface plus the deep volume, with Monte Carlo sampling:

source-localization run --preset ellipsoid_hybrid --atlas allen26 --eeg /path/to/data.set --output ./results

It reproduces the SL statistical-learning analysis's hybrid runs exactly (parcel series equal to 1.7e-14). The keys, for a config of your own:

pipeline:
  source_type: hybrid
  bem_type: ellipsoid
source_space:
  surface:
    method: anatomical
    spacing_mm: 0.5
    categories: [cortical, cerebellum, olfactory]
  hybrid:
    volume_categories: [brainstem, thalamic, hypothalamic, hippocampal, subcortical]
    volume_spacing_mm: 0.4
  source_sampling: monte_carlo
  monte_carlo:
    n_sources: 48            # surface sources per draw
    n_sources_volume: 36     # deep sources added to each draw (stratified)
    hybrid_draw: stratified  # or "union": n_sources drawn from the combined pool
    n_draws: 100
    seed: 20260821
    pool_spacing_mm: 0.2
    align_draws: true        # recommended for new analyses
inverse:
  method: sLORETA
  orientation: fixed

It runs on the Allen atlases (allen26, allen32, allen64); Antwerp and coarse22 cannot carry the anatomical surface. volume_categories defaults to every category not in surface.categories, which is what the preset uses.

Option Meaning
hybrid.volume_categories Atlas categories filled by the volume grid
hybrid.volume_spacing_mm Volume grid spacing (0.4 mm matches the Cartesian Monte Carlo pool)
monte_carlo.hybrid_draw stratified (default): each draw takes n_sources surface sources exactly as a surface-only run would, plus n_sources_volume deep ones. union: n_sources from the combined pool, the two parts competing
monte_carlo.n_sources_volume Deep sources per draw; required for stratified. 36 matches the surface draw's spacing (median nearest-neighbour 2.04 mm vs 2.03 mm)

Read the deep parcels as a "not cortex" bin. On the dorsal array, deep and cortical electrode patterns are near-collinear (Brainstem_Tectum vs Cerebellum |cos| 0.997), and the best-fitting deep structure changes with a 1 mm registration shift. The deep parcels take deep signal off cortex, but their individual labels cannot be read. The split between the parts is largely the inverse's, not the data's.

Measured so far, on MEA30 data:

  • In simulation it separates two planted between-animal effects slightly better than the electrodes (+0.02 to +0.03 on the unmixing index); the surface-only space is slightly worse (about -0.02).
  • In the SL statistical-learning cohort it beat both the surface-only and the ROI-based spaces on every comparison: phase locking, effect detection and genotype separation.
  • It did not beat the electrodes themselves.

Treat it as an anatomical view of the electrode information, not as added resolution. Implementation: source_space/hybrid.py, steps/monte_carlo_roi.py; tests in tests/test_hybrid.py.

Parcel-Subspace Sampling

source_sampling: parcel_subspace builds the same dense pool as Monte Carlo, but instead of averaging many sparse draws it represents each parcel by the top n_patterns patterns of its pool leadfield and solves one inverse over all of them. Deterministic: no draws, seed or sign alignment. Works with surface, Cartesian, shell and hybrid source spaces.

source_space:
  source_sampling: parcel_subspace
  parcel_subspace:
    n_patterns: 3        # patterns per parcel
    method: MNE          # MNE | sLORETA
    pool_spacing_mm: 0.2

Measured in simulation on the 30-electrode mouse array (one head model, 10 dB), as MS1's peak displacement over 15 cortical parcels: the deployed Monte Carlo operator (48 x 100, sLORETA) 3.05 mm, ROI-based 1.85, this operator (r = 3, MNE) 1.95; the floor set by the exact Bayesian posterior was 0.98. MNE localizes better than sLORETA here; per component the two have the same filter and differ only in scale, so ITC, phase and within-parcel effect sizes are identical under either. More than 3 patterns helped the large dorsal parcels and cost the rest.

Output per parcel: the dominant pattern's series as signed, the root-sum-square over components as magnitude (provisional), and every component (step6_roi_components.pkl) and the operator (parcel_subspace_operator.npz). Implementation: source_space/parcel_subspace.py, steps/parcel_subspace_roi.py; tests in tests/test_parcel_subspace.py.

Which Preset Should I Use?

  • ROI statistics on an existing design: roi_based_ellipsoid --atlas allen32.
  • Cortical, signed, orientation-aware analysis: ellipsoid_surface.
  • Whole-brain parametric mapping: shell_ellipsoid, with the coverage caveat linked above.
  • Fast iteration: sphere_surface.
  • Responses that may have deep generators (e.g. auditory responses on a dorsal array): ellipsoid_hybrid --atlas allen26 (Monte Carlo), reading the deep parcels as one "not cortex" bin.

All inverse methods can be set per preset or with --method. sLORETA is the default in every preset and the most robust to mouse-scale leadfields (see the regularization known issue).


Atlas Selection

Atlases are declared in src/source_localization/data/atlas/registry.yaml and selected with --atlas on the CLI or atlas= in the Python API. All of them share one voxel grid and one coordinate space, so BEM geometry and electrode positions are identical across atlases; only the label volume and the ROI mapping change.

Atlas Parcels Description
antwerp 46 UAntwerpen C57BL/6 MRI atlas: 46 named structures plus background. Labels only ~31% of the brain mask, so nearest-label assignment can move a source up to 2.6 mm. Default for presets that do not carry their own atlas
allen32 32 Allen CCFv3 registered into Antwerp space, 16 parcels per hemisphere (ids 1-16 left, 17-32 right). Tiles grey matter. Each parcel carries a tier and category. Prefer this for per-ROI claims
allen 32 Alias of allen32 (identical files)
allen64 64 Finer Allen parcellation, 32 per hemisphere. No roi_categories file
allen26 26 Allen32 with six bilateral pairs merged that the 30-channel array cannot separate
coarse22 22 The 46 Antwerp structures regrouped into 22 regions. Inherits Antwerp's sparse coverage. Accepted as coarse_22roi by the validation CLI
# CLI
source-localization run --preset shell_ellipsoid --atlas allen32 --eeg data.set --output results/

# Python API
pipeline = Pipeline.from_preset('shell_ellipsoid', atlas='allen32')

The anatomical-surface presets (ellipsoid_surface, ellipsoid_surface_anatomical) are cut from the Allen32 parcellation and carry Allen32 parcel ids on every source. They run with allen32, allen, or allen64; asking for antwerp with them raises a clear error rather than mislabelling the sources.

Every other preset works with every registered atlas.


Usage

Command Line Interface

# Run pipeline with preset
source-localization run --preset roi_based_ellipsoid --atlas allen32 --eeg data.set --output ./results

# Override parameters
source-localization run --preset roi_based_ellipsoid --atlas allen32 --eeg data.set \
    --snr 5.0 --method sLORETA --output ./results

# Include optional post-processing
source-localization run --preset roi_based_ellipsoid --atlas allen32 --eeg data.set \
    --spectral --visualize --output ./results

--method on the CLI accepts MNE, dSPM, and sLORETA. eLORETA, LCMV, and DICS can be set through inverse.method in a config file or an API override.

Python API

from source_localization import Pipeline

# Create and run pipeline
pipeline = Pipeline.from_preset('roi_based_ellipsoid', atlas='allen32')
results = pipeline.run(eeg_file='data.set', output_dir='./results')

# Access outputs
stc = results['inverse_solution']['stc_signed']          # mne.VolSourceEstimate
roi_timeseries = results['roi_extraction']['roi_stcs_signed']  # dict: roi name -> array

# With parameter overrides (dotted keys)
pipeline = Pipeline.from_preset(
    'roi_based_ellipsoid', atlas='allen32',
    **{'inverse.snr': 5.0, 'inverse.method': 'sLORETA'}
)

Input requirements

The EEG file must be an EEGLAB .set (epoched or continuous) whose channel names are the electrode labels in data/electrodes/mouse_array_coords.csv (E1..E30). Channel order in the file does not matter: the EEG step reorders channels to match the electrode registration and refuses a file that lacks a registered electrode. Channels not in the registration are dropped with a message.

Output Files

results/
├── data/
│   ├── roi_timeseries_signed.set     # ROI time series (for connectivity)
│   ├── roi_timeseries_magnitude.set  # Absolute values (for power)
│   ├── source_timeseries_*.set       # Per-source time series
│   └── step*_*.pkl                   # Intermediate results
├── figures/
│   └── *.png                         # Per-step QC figures
└── pipeline_report.html              # Summary report

Load in MNE:

import mne
epochs = mne.io.read_epochs_eeglab('results/data/roi_timeseries_signed.set')
# Or for continuous data:
raw = mne.io.read_raw_eeglab('results/data/roi_timeseries_signed.set')

What the outputs are

  • Magnitude is the L2 norm across orientation components per source (or the absolute value under fixed orientation), averaged over the sources in each parcel. Use it for power.
  • Signed keeps polarity. Under the anatomical surface it is the projection onto the cortical normal, so sign is anatomically meaningful. Under free orientation it is, by default, the max-variance component per epoch, whose sign is arbitrary between epochs and sources; use it for within-epoch connectivity, not for polarity claims.
  • The default free-orientation signed series costs phase locking. A source can switch axis between epochs, and where its components have opposite signs each switch flips its series by 180 degrees. On real mouse 40 Hz ASSR recordings this happened in 15% of epochs and lowered ROI-based inter-trial coherence (wild-type mean 0.563 against 0.671). For phase-based measures (ITC, phase, phase connectivity) set inverse.signed_orientation: leadfield: every epoch is projected onto each source's fixed leadfield direction (steps.inverse_solution.leadfield_directions), with signs aligned within each parcel. It depends on the head model only, so it is the same for every recording. The default is unchanged so existing results reproduce. Monte Carlo, parcel-subspace and fixed-orientation outputs are not affected.
  • DICS produces a single band-power value per source, not a time series. The pipeline writes it as roi_power_*.csv and source_power_*.csv rather than .set files, which cannot hold one sample. DICS does not run on the surface presets; see the regularization known issue.
  • ROIs with no assigned source are omitted from the outputs rather than written as zeros. The run log lists them.

Visualizations

Source Map Visualization

from source_localization.source_analysis import (
    SourceMapVisualizer, PRESETS, apply_style
)

apply_style('publication')
viz = SourceMapVisualizer(source_coords, brain_surface)
fig = viz.plot_surface_heatmap_smooth(
    gamma_power,
    view='dorsal',
    cmap='hot_black',
    show_all_sources=True
)

Connectivity Visualization

from source_localization.source_analysis import (
    ConnectivityVisualizer, extract_mne_connectivity
)
from mne_connectivity import spectral_connectivity_epochs

conn = spectral_connectivity_epochs(epochs, method='coh', fmin=30, fmax=55)
matrix = extract_mne_connectivity(conn, freq_band=(30, 55))
viz = ConnectivityVisualizer(roi_labels=epochs.ch_names)
fig = viz.plot_connectivity_matrix(matrix, cluster_order=True)
fig = viz.plot_chord_diagram(matrix, threshold_percentile=90)

ROI Visualization

from source_localization.source_analysis import ROIVisualizer

roi_viz = ROIVisualizer(atlas_path, roi_mapping_path)
fig = roi_viz.plot_roi_map(roi_values, show_boundaries=True, show_labels=True)

ROIVisualizer, AtlasLookup, and CorticalSourceSpace read label volumes through utils.atlas.get_true_affine(), which detects whether a file's header is 10x inflated. Do not scale affines by hand; see CLAUDE.md for which bundled files are in which convention.


Model catalogue

Every probability model the package uses to say where a signal comes from (the location posteriors, the priors they rest on, and the calibrations applied to their maps) is declared once in source_localization/methods.py, with a stable id, a version, its priors and values, and the domain where its calibration holds. The list is in docs/methods/CATALOGUE.md, generated from that declaration; source-localization list --catalogue prints the same. Results name the ids and versions that produced them. tests/test_catalogue.py fails if the page is stale, an id repeats, a prior is not catalogued or an implementation does not import.

Validation

Full guide: docs/validation/README.md. It covers which tool answers which question, validating a new atlas step by step, what to report, and which earlier results not to quote. Planned changes are in docs/validation/DESIGN_validation_upgrade.md.

The package includes a dipole simulation framework for validating source localization accuracy without EEG data.

How Validation Works

Validation uses forward-inverse testing: a known dipole is placed at a specific location, its scalp EEG is simulated using the forward model, then the inverse solution attempts to recover the original location. Metrics include:

  • Localization error (mm): Euclidean distance between true and estimated positions
  • ROI accuracy (%): whether the estimated source is in the correct parcel
  • Depth-stratified analysis: performance by source depth from electrodes

Every run states its regime (validation.regime, or --regime):

  • realistic (default from 0.6.0): truths at the requested positions through head models drawn from a prior (registration shift, skull conductivity), recorded background noise when validation.background is set, and a noise-only control.
  • legacy: the v0.5.x assumptions (the inversion's own forward model at grid-snapped positions, the "inverse crime"; generated noise; no noise-only control). Kept only to reproduce pre-0.6.0 numbers, which are best-case and not comparable with realistic ones. Results go to <config>_legacy/.

metrics.json records the regime under validation_regime. The validation guide defines both regimes and their options.

Setting Up a Validation Study

my_validation/
├── configs/                          # Your validation configs
│   ├── ellipsoid_shell_sLORETA.yaml
│   ├── sphere_cartesian_MNE.yaml
│   └── ...
└── results/                          # Created automatically
    └── ellipsoid_shell_sLORETA/
        ├── metrics.json
        ├── validation_report.html
        └── figures/

Validation Config Format

Validation configs are standard pipeline configs with an optional validation section:

pipeline:
  name: ellipsoid_shell_sLORETA
  bem_type: ellipsoid       # sphere or ellipsoid
  source_type: shell        # shell, cartesian, surface, or roi_based

inputs:
  brain_volume: data/atlas/Atlas_3DRois.nii
  brain_labels: data/atlas/allen/allen_labels.nii.gz
  roi_mapping: data/atlas/allen/roi_mapping.json
  electrodes_csv: data/electrodes/mouse_array_coords.csv
  eeg_file: null  # Not needed for validation

inverse:
  method: sLORETA           # MNE, sLORETA, dSPM, eLORETA, LCMV, DICS
  snr: 3.0
  depth_weighting: 0.8

bem:
  ellipsoid:
    n_layers: 3
    conductivities: [0.33, 0.0042, 0.33]  # brain, skull, scalp
    radii_ratios: [0.87, 0.92, 1.0]
    ellipsoid_method: axis_aligned
    ellipsoid_margin: 1.23
    use_cache: true

source_space:
  shell:
    n_shells: 3
    min_points_per_shell: 20
    max_points_per_shell: 100
    distribution: fibonacci
    filter_exterior: true

validation:
  snr_levels: [10]          # SNR levels to test (dB)
  n_trials: 25              # Trials per test position
  test_mode: combined       # combined (recommended), roi_centroids, or uniform_grid
  grid_spacing_mm: 1.0
  grid_margin_mm: 0.2
  scale_factor: 1.0
  dipole:
    amplitude_nAm: 50.0
    duration_s: 1.0
    sfreq: 500.0

outputs:
  dir: null                 # Auto-set by validation runner
  save_intermediate: true
  figure_format: png
  figure_dpi: 100

Running Validation via CLI

# List available configs
source-localization validate --test-dir ./my_validation --config configs/ --list

# Run all configs
source-localization validate --test-dir ./my_validation --config configs/ --all

# Run specific config
source-localization validate --test-dir ./my_validation --config configs/ellipsoid_shell_sLORETA.yaml

# Quick test mode (5 ROIs, 1 trial, SNR=10)
source-localization validate --test-dir ./my_validation --config configs/ --all --quick

# Override SNR and trials
source-localization validate --test-dir ./my_validation --config configs/ --all \
    --snr 5 10 20 --trials 50

# Choose the test mode
source-localization validate --test-dir ./my_validation --config configs/ --all \
    --test-mode combined

# Choose the atlas (registry names; `full` and `coarse_22roi` are accepted legacy aliases)
source-localization validate --test-dir ./my_validation --config configs/ --all \
    --atlas allen32

# Summarize existing results
source-localization validate --summarize ./my_validation/results/

# Compare multiple configs
source-localization validate --compare \
    ./my_validation/results/config1/ \
    ./my_validation/results/config2/

The validation CLI defaults to the Antwerp atlas. Selecting anything else suffixes the output directory with the atlas name.

Test Modes

Mode Description Use Case
roi_centroids Test at parcel centroid positions ROI accuracy
uniform_grid Test on a uniform 3D grid across the brain Localization error, depth analysis
combined Both: ROI accuracy from centroids, localization error from the grid Recommended

Understanding Results

results/config_name/
├── metrics.json              # Full metrics (JSON)
├── validation_report.html    # Interactive HTML report
├── data/                     # Intermediate pipeline outputs
└── figures/
    ├── localization_error_map_snr10.png
    └── depth_error_snr10.png

metrics.json holds, per SNR level, localization error statistics, ROI accuracy (exact hits over test positions), and depth-stratified error.

What the validation numbers mean

Earlier versions of this file quoted a single headline accuracy for the recommended preset. Those figures came from one configuration on the Antwerp atlas under the default inverse crime, and the shallowest depth bin was being reported as if it were the whole brain. Accuracy depends strongly on depth, atlas, and source space, so run the validation for the configuration you intend to use and report the depth-stratified result. The Monte Carlo sampling guide describes how to integrate over source-grid placement. validation.displacement scores any parcel operator by peak displacement, the expected distance in mm between the parcel a source is in and the parcel it is credited to (the statistic of the published resting-state paper, reproduced exactly against its table).

Python API for Validation

from source_localization.validation import ValidationRunner, run_validation
from pathlib import Path

# Run multiple configs
results = run_validation(
    test_dir='/path/to/my_validation',
    config_files=[Path('configs/config1.yaml'), Path('configs/config2.yaml')],
    snr_levels=[5, 10, 20],
    n_trials=25,
    test_mode='uniform_grid',
    verbose=True
)

# Run a single config with more control
runner = ValidationRunner(
    config_path='configs/ellipsoid_shell_sLORETA.yaml',
    output_dir='results/ellipsoid_shell_sLORETA',
    verbose=True
)
runner.setup()
metrics = runner.run(snr_levels=[10], n_trials=25)
runner.save_results(metrics)

Pipeline Architecture

1. Electrode Registration  → MNE Info with 30 channel positions
2. EEG Data Loading       → EEGLAB .set → epochs, channels reordered to the registration
3. BEM Model              → Sphere (analytical) or Ellipsoid (numerical)
4. Source Space           → Surface / ROI-based / Cartesian / Shell
5. Forward Solution       → Leadfield G; per-source arrays restricted to kept sources
6. Inverse Solution       → MNE / dSPM / sLORETA / eLORETA / LCMV / DICS
7. ROI Extraction         → One time series per parcel with at least one source

Optional (--spectral, --visualize):
8. Spectral Analysis      → Band power per ROI
9. Visualization          → Figures and HTML report

Troubleshooting

"recording lacks N registered electrode(s)"

  • The .set file is missing channels that the electrode CSV registers. The forward is built for all 30, so the data cannot be aligned. Check the file's channel names.

"The anatomical surface is built from a parcellation whose roi_mapping.json defines categories ..."

  • You asked for ellipsoid_surface with a non-Allen atlas. Use --atlas allen32 or a non-surface preset.

"BEM CONTAINMENT: N sources fall outside the inner skull"

  • The conductor is smaller than the source space. Downstream indexing stays correct, but those sources are gone. Use an "extended" preset or a larger ellipsoid margin.

"Electrodes inside head model"

  • Use bem.sphere.fit_to_electrodes: false in config

"mne-connectivity not found"

  • Install with: pip install mne-connectivity

"Module not found"

source .venv/bin/activate
uv pip install -e .

Citation

@software{mouse_eeg_source_localization,
  author = {Edmondson, Alex and Pedapati, Ernest},
  title = {Mouse EEG Source Localization Package},
  year = {2025},
  publisher = {GitHub},
  url = {https://github.com/alexedmon1/source-localization}
}

Package Structure

source_localization/
├── src/source_localization/
│   ├── pipeline.py              # Main orchestrator
│   ├── cli.py                   # CLI entry point
│   ├── config.py                # Configuration and atlas registry
│   ├── steps/                   # Pipeline step implementations
│   ├── bem/                     # BEM models (sphere, ellipsoid)
│   ├── source_space/            # Source space types
│   ├── study/                   # Multi-subject processing
│   │   ├── config.py            # StudyConfig class
│   │   ├── batch.py             # Batch processing
│   │   └── analysis.py          # MNE wrapper analysis
│   ├── source_analysis/         # Figures, atlas lookup, depth-weighted ROI utilities
│   ├── methods.py               # Method vocabulary and the model catalogue (docs/methods/CATALOGUE.md)
│   ├── locate/                  # Location posteriors, head-model ensembles, whitening, regions
│   ├── field/                   # Field templates and field definitions (the field method)
│   ├── validation/              # Dipole simulation validation
│   ├── utils/atlas.py           # Affine-convention detection; always go through this
│   ├── config/presets/          # 11 YAML presets
│   └── data/                    # Atlas and electrode files
└── pyproject.toml

History

The package version is 0.9.0. Earlier revisions of this README carried a separate 1.x numbering that never corresponded to a package release; it has been dropped. Release tags are listed under Reproducing a published analysis, and git log is the changelog.

Notable changes in 0.9.0 (2026-11-14). Every forward model from a surface BEM changes, so results from earlier versions are not reproduced and should be rerun:

  • Surface BEMs are solved outermost surface first (bem/solution.py). mne.make_bem_solution takes surfaces in list order and puts zero conductivity outside the first; the ellipsoid and numerical-sphere builders produce them inner first, and every earlier version passed them on in that order, so each surface BEM was a physically inconsistent model. Against an exact multi-shell series solution the corrected solve is within mesh error (preset sphere ico-4: topography error 0.004, amplitude 0.98) and the old one is not (1.41, 0.007). Affects the ellipsoid presets and the numerical sphere (the default); the analytic sphere (numerical: false) is unchanged. Builders keep their inner-first order.
  • Surface nesting is checked before every solve: ids that disagree with the geometry (caches from before 2026-08-14) or descending sphere radii raise instead of being solved in the wrong order.
  • A failed analytic-sphere fit is refused (bem.sphere.check_sphere_fit) instead of returning NaN leadfields. tests/test_bem_order.py checks the surface BEM against the exact series.
  • Every forward model records its solver, and saved ones without a current tag are refused (StaleForwardError): load_forward, the validation readers, study qc (warns and skips), cached operators (DipolePosterior.load) and head-ensemble exports (export version 2). study batch neither skips nor overwrites a subject whose outputs predate the tag; use a new output directory or --force.
  • The probability-atlas Phase 9/10 reproduction tests are expected to fail until those references are rebuilt with the corrected solve.
  • The results extra pins Lumière (formerly neurobox-dev).

Notable changes in 0.8.1 (2026-11-10):

  • Hippocampus_Ant / Hippocampus_Post names corrected in allen32 (IDs 7/23 vs 8/24) and allen26 (4/14 vs 5/15). The split put "Ant" at Y < split as if y grew caudally; in this volume it grows rostrally. Names and descriptions are swapped in the mappings; IDs and voxels are unchanged. Outputs written before 0.8.1 name the opposite half. tests/test_atlas_registry.py pins the direction (ATLAS_PERENS_ANTWERP_REVIEW.md §1a).
  • Bundled atlases now ship in the wheel. data/atlas/* did not recurse, so a non-editable install had no Allen files at all. tests/test_package_data.py checks every file under data/atlas.
  • Head-model ensemble and noise whitening (validation/head_ensemble.py, validation/whitening.py), ported from probability-atlas.
  • Pool workers cap their BLAS/OpenMP threads (utils/parallel.py; threadpoolctl is now a declared dependency). n workers on n cores used to run about n² threads. The last bits of a float result can change.
  • Canonical electrode regions for the 30-site array in data/electrodes/mouse_array_regions.yaml (Left/Right Frontal, Medial, Posterior), checked against the coordinates.

Notable changes in 0.8.0 (2026-11-09; the field method, bayes-sl reports/2026-11-09_field_method_library_plan.md). No existing output changes: source mode is untouched, and every new step reproduces the study code it replaces:

  • Head-model-ensemble location posteriors (merged from feat/bayes-group): EnsemblePosterior, SourceSetPosterior, MirrorPairPosterior and the map calibrations VOLUMETRIC and SURFACE, in validation/ensemble_posterior.py
  • Model catalogue. Every location posterior, prior and calibration is declared in methods.py with an id, a version, its priors and values and its validity domain; docs/methods/CATALOGUE.md is generated from it, and source-localization list --catalogue prints it
  • source_localization.locate (phase F1): the location posteriors moved here from validation/ (the old import path still works); the head-model prior and named ensembles (ENSEMBLE_PA_PHASE2: nominal + 32 draws, seeded), ensemble leadfields with their cache, the head geometry, noise whitening (four catalogued estimators), region labelling and LocationSpace, which bundles them for one study's head and records which ensemble and labelling it used. Ported from probability-atlas, which is no longer needed: draws, whiteners and labels are identical, and the 33 leadfields equal its cached ensemble to a relative 2e-16
  • source_localization.field (phase F2): field templates (schema source_localization.field_template/1: maps, channel order, sign rules, catalogued definitions, sha256; freeze never overwrites; templates written before the schema load unchanged) and field definitions from group data (field.response_svd, .phase_aligned, .phase_free, .csd_leading). Re-defining surface-evoked's frozen Sentinel fields from their defining data reproduces all four exactly. See docs/methods/FIELD_TEMPLATES.md
  • Field mode (phase F3): pipeline.mode: field and source-localization field project project epoched recordings onto a frozen template (field.project_inner, the default, or field.project_joint), writing one virtual channel per field in the layout source-analytics reads, the residual's trial average, and field_run.json (template sha256, projection id and version). field show validates a template. Source mode is unchanged. For all 192 held-out Sentinel recordings the series equal surface-evoked's to a relative 6e-16
  • Locating a field (phase F4): calibrations carry the domain they were measured in (array, ensemble, grid, positions; the MEA30 array as data/calibration/mea30_sentinel.json), and locate.field_model.FieldLocator refuses a space outside it. The locator fits the whitener and the single-source and mirror-pair posteriors, reads calibrated maps, evidence and P(pair), and chooses the location (locate.field_choice); summarize and report_response_svd_field report it. Reproduces surface-evoked's location report of the frozen chirp fields exactly. FieldModel (locate.field_model_map) gives the located mass over resamples of the subjects for evoked, steady-state and ongoing data, and model_filter_pair (field.model_filter_pair) the filter of a located pair; both reproduce their study counterparts with no difference. locate.calibrate measures a calibration for a new space (calibration.procedure; it re-chooses the catalogued settings from their original scores). region_fields / field region-values (phase F5) give each located region's field and each subject's band power along it (field.region_field, field.region_power_db), reproducing bayes-sl L3's tables to 3e-14 dB. See docs/methods/FIELD_LOCATION.md

Notable changes in 0.7.0:

  • One reference for the leadfield and the data (X57). The forward step average-references the EEG leadfield (sol and _orig_sol), and the EEG step applies the average-reference projector it adds, which until now was added and never applied. Before this, every hand-rolled inverse was built from an unreferenced leadfield (channel mean a median 0.75-0.84 of each column's RMS) and applied to data in whatever reference the file carried
  • Every number changes. ROI and source outputs from earlier versions are not comparable with 0.7.0 ones; re-run rather than mix. study batches skip subjects whose outputs exist, so clear old outputs first
  • forward.average_reference: false restores the earlier behaviour, to reproduce old runs only. Each run's config_resolved.yaml records the setting

Notable changes in 0.6.0:

  • Validation regime. The validation runner defaults to the realistic regime: truths at the requested positions through head models drawn from a prior (registration shift, skull conductivity), recorded background noise when configured, and a noise-only control. legacy (--regime legacy) reproduces the v0.5.x assumptions for old numbers only; its results go to <config>_legacy/. See docs/validation/README.md and source_localization.validation.regime
  • Validation numbers change: realistic-regime results are not comparable with earlier ones
  • validate --batch and RobustnessTest follow the regime too (the sweep scripts take --regime); the posterior and ROI-certainty scripts write a noise-only control
  • Planted-network validation (validation.networks, validate --networks spec.yaml): which networks a montage, source model and atlas can resolve, with your own connectivity metrics injected; optional directed stage. validation.connectivity (no ground truth) is deprecated

Notable changes in 0.5.1 (v0.5.1):

  • EEG channels reordered to the electrode registration; the inverse refuses a mismatched order. Runs on files stored in a non-E1..E30 order before this release were silently misaligned
  • Forward step restricts per-source arrays to the sources MNE kept, instead of downstream steps front-slicing them
  • Anatomical-surface presets carry the Allen32 atlas
  • DICS written as power tables; eLORETA in the validation runner; unknown band names are errors; source_analysis reads affines through utils.atlas
  • README rebuilt from the code and tested against it

Notable changes in 0.5.0:

  • Anatomical cortical mid-ribbon surface source space with fixed orientation
  • Atlas registry (registry.yaml) driving both CLIs; allen26 added
  • Monte Carlo ROI operator

License

MIT License

Authors

Alex Edmondson - Primary Developer Ernest Pedapati, MD - Principal Investigator

Cincinnati Children's Hospital Medical Center

About

EEG source localization pipeline for mouse models using the Antwerp Mouse Brain Atlas. Supports multiple BEM head models, inverse methods (sLORETA, dSPM, MNE, beamformers), and multi-subject batch processing.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages