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.
- Overview
- Features
- Installation
- Quick Start
- Multi-Subject Study Processing
- Spectral and Connectivity Analysis
- Configuration Presets
- Atlas Selection
- Usage
- What the outputs are
- Visualizations
- Model catalogue
- Validation
- Pipeline Architecture
- Troubleshooting
- Citation
- Package Structure
- History
📖 Documentation site:
docs/— guides (adding an atlas, electrode setup, Monte Carlo source sampling) and known issues you should read before interpreting output. Preview locally withuv 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.
- 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'selectrode_categories
- BIDS-inspired folder hierarchy, YAML study configuration
- Batch processing with parallel jobs, status reporting, QC
- Group-level result collection
- Band power per ROI using MNE's Welch PSD
- Connectivity via MNE-Connectivity (coherence, PLV, wPLI, imcoh)
- Automatic epoching of continuous data for connectivity
- 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: leadfieldit 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_analysisbut is not part of the pipeline; the earlier claim that it was has been removed.
- Per-step QC figures and an HTML report from every run
- Interpolated heatmaps, ROI parcellation overlays, connectivity matrices and
chord diagrams in
source_analysis
- Python >= 3.9 and < 3.14
- Virtual environment manager (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]"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.
source-localization --help
source-localization study --help
python -c "import source_localization; print(source_localization.__version__)"# 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.htmlPresets 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.
# 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.yamlThe study module provides a BIDS-inspired framework for organizing and processing multi-subject EEG studies.
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
# 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.yamlWith 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.
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
)The analysis module wraps MNE-Python and MNE-Connectivity for batch analysis of processed subjects.
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_dbThere 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.
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)| 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 |
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 \
--verboseSource 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.
| 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 |
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 ./resultsIt 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: fixedIt 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.
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.2Measured 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.
- 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).
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.
# 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.
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'}
)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.
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')- 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_*.csvandsource_power_*.csvrather than.setfiles, 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.
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
)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)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.
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.
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.
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 whenvalidation.backgroundis 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.
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 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# 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.
| 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 |
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.
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).
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)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
"recording lacks N registered electrode(s)"
- The
.setfile 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_surfacewith a non-Allen atlas. Use--atlas allen32or 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: falsein config
"mne-connectivity not found"
- Install with:
pip install mne-connectivity
"Module not found"
source .venv/bin/activate
uv pip install -e .@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}
}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
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_solutiontakes 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.pychecks 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 batchneither 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
resultsextra 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.pypins 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.pychecks every file underdata/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;threadpoolctlis 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,MirrorPairPosteriorand the map calibrationsVOLUMETRICandSURFACE, invalidation/ensemble_posterior.py - Model catalogue. Every location posterior, prior and calibration is declared in
methods.pywith an id, a version, its priors and values and its validity domain; docs/methods/CATALOGUE.md is generated from it, andsource-localization list --catalogueprints it source_localization.locate(phase F1): the location posteriors moved here fromvalidation/(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 andLocationSpace, 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-16source_localization.field(phase F2): field templates (schemasource_localization.field_template/1: maps, channel order, sign rules, catalogued definitions, sha256;freezenever 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: fieldandsource-localization field projectproject epoched recordings onto a frozen template (field.project_inner, the default, orfield.project_joint), writing one virtual channel per field in the layout source-analytics reads, the residual's trial average, andfield_run.json(template sha256, projection id and version).field showvalidates 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), andlocate.field_model.FieldLocatorrefuses 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);summarizeandreport_response_svd_fieldreport 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, andmodel_filter_pair(field.model_filter_pair) the filter of a located pair; both reproduce their study counterparts with no difference.locate.calibratemeasures 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 (
soland_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.
studybatches skip subjects whose outputs exist, so clear old outputs first forward.average_reference: falserestores the earlier behaviour, to reproduce old runs only. Each run'sconfig_resolved.yamlrecords the setting
Notable changes in 0.6.0:
- Validation regime. The validation runner defaults to the
realisticregime: 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 andsource_localization.validation.regime - Validation numbers change: realistic-regime results are not comparable with earlier ones
validate --batchandRobustnessTestfollow 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_analysisreads affines throughutils.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;allen26added - Monte Carlo ROI operator
MIT License
Alex Edmondson - Primary Developer Ernest Pedapati, MD - Principal Investigator
Cincinnati Children's Hospital Medical Center