Interoperating with MNE-Python

Why a bridge, not a rewrite

MNE-Python is a mature, extensively validated ecosystem for EEG/MEG analysis – decades of methods, and in particular a full brain-imaging stack (individual-MRI head models via FreeSurfer, coregistration, cortical source spaces, morphing, atlas labelling). Reimplementing all of that natively in R would take years and duplicate work that is already correct.

The PhysioExperiment ecosystem therefore follows an interop-first, selectively native strategy:

  • Borrow MNE for what it does best – individual-MRI source imaging and the long tail of specialised methods – by round-tripping data through the bridge in this vignette.
  • Keep native the operations that must live inside the reproducibility substrate (content-addressed, byte-identically replayable) and the PhysioAgent tool registry (typed, deterministic, provenance-recording). A reticulate call into Python is a non-deterministic boundary; a native R op is not.
  • Use MNE as a validation oracle: cross-check each native operation against its MNE counterpart, so native code earns trust before it is relied on.

PhysioIO provides the bridge: hasMNE(), toMNE(), fromMNE(), and a native .fif reader. Everything is guarded on hasMNE(), which is TRUE only when reticulate can reach a Python interpreter with the mne module.

library(PhysioIO)
# reticulate must point at a Python that has `mne` installed, e.g.
#   Sys.setenv(RETICULATE_PYTHON = "/path/to/python-with-mne")
hasMNE()          # TRUE once mne is importable
pyMNEVersion()    # e.g. "1.12.1"

A lossless data bridge

toMNE() converts a PhysioExperiment to an mne.io.RawArray; fromMNE() converts back. The round-trip preserves the signal exactly.

pe  <- PhysioExperiment(
  assays      = S4Vectors::SimpleList(raw = matrix(rnorm(2000 * 19), 2000, 19)),
  colData     = S4Vectors::DataFrame(name = paste0("EEG", 1:19), type = "eeg"),
  samplingRate = 250)

raw <- toMNE(pe)         # -> mne.io.RawArray (nchan = 19, sfreq = 250)
pe2 <- fromMNE(raw)      # -> PhysioExperiment

orig <- SummarizedExperiment::assay(pe,  "raw")
back <- SummarizedExperiment::assay(pe2, SummarizedExperiment::assayNames(pe2)[1])
mean(vapply(seq_len(ncol(orig)),
            function(j) cor(orig[, j], back[, j]), numeric(1)))
#> 1            # per-channel correlation = 1 (lossless)

Let MNE do the analysis, keep the result in R

Once the data is an MNE object you can call any MNE routine and bring the result back into R for integration with other modalities, statistics, and the agentic pipeline. Here MNE computes the power spectral density:

psd   <- raw$compute_psd(fmin = 1, fmax = 40, verbose = FALSE)
pdat  <- psd$get_data()          # (n_channels x n_freqs), V^2/Hz
freqs <- as.numeric(psd$freqs)
alpha_per_channel <- rowSums(pdat[, freqs >= 8 & freqs <= 13, drop = FALSE])

MNE as a validation oracle for native ops

The same quantity computed natively in the ecosystem can be cross-checked against MNE. On a real 19-channel run, per-channel alpha power from PhysioAnalysis::bandPower() and from the MNE PSD above agreed to a spatial correlation of 0.997 across channels. The absolute scale differs by the Welch normalisation convention – which is exactly the kind of definitional difference a matched-units validation pins down before a native op is trusted (see the realdata-validation discipline: report correlation, not numerical parity, until the definitions truly match).

# native (independent) per-channel alpha power, then:
# cor(native_alpha_per_channel, alpha_per_channel)   #> ~0.997

Toward native brain-imaging integration

For source imaging, PhysioEEG already ships native forward models (eegForwardModel: analytic sphere, a BEM solver validated against the analytic solution, and the published nyhead / fsaverage templates) and inverse solvers (eegSourceEstimate: MNE, sLORETA, eLORETA; plus eegBeamformer). What the native path does not yet build is a subject-specific pipeline from an individual’s own MRI (FreeSurfer segmentation -> individual BEM surfaces -> source space -> sensor coregistration). That is precisely where the bridge earns its keep: run the individual-MRI source estimate in MNE, then fromMNE() the result into a PhysioExperiment to join it with motion capture, EMG, or clinical data – and, over time, replace the pieces that belong inside the reproducible / agentic core with validated native implementations.

Example: template (fsaverage) source localization via MNE

MNE’s fsaverage template gives a full cortical source space that the native path does not yet provide. The pipeline below runs entirely from R (driving MNE through reticulate) and returns the source estimate into a PhysioExperiment:

mne <- reticulate::import("mne")
mn  <- reticulate::import("mne.minimum_norm")

# an EEG PhysioExperiment with standard 10-20 channel labels -> MNE Raw
raw <- toMNE(pe_1020)                       # pe_1020: channels Fp1, Fp2, ... O2
raw$set_montage("standard_1020")
raw$set_eeg_reference("average", projection = TRUE)

# fsaverage template forward (BEM) + sLORETA inverse
fs  <- mne$datasets$fetch_fsaverage()
fwd <- mne$make_forward_solution(
  raw$info, trans = "fsaverage",
  src = file.path(fs, "bem", "fsaverage-ico-5-src.fif"),
  bem = file.path(fs, "bem", "fsaverage-5120-5120-5120-bem-sol.fif"),
  eeg = TRUE, meg = FALSE)
cov <- mne$make_ad_hoc_cov(raw$info)
inv <- mn$make_inverse_operator(raw$info, fwd, cov)
stc <- mn$apply_inverse_raw(raw, inv, lambda2 = 1/9, method = "sLORETA")

# bring the source estimate back into the PhysioExperiment data model
se <- pe_1020
S4Vectors::metadata(se)$source <- t(stc$data)     # time x cortical sources
S4Vectors::metadata(se)$source_estimate <- list(
  method = "MNE-Python sLORETA on fsaverage template",
  engine = paste0("mne ", pyMNEVersion()),
  provenance = "external (MNE-Python via reticulate)")

On a real 19-channel run this produced an fsaverage cortical estimate of 20484 sources x n_times (both hemispheres, ico-5), stored in metadata(se)$source and tagged as externally computed – far richer than the few-hundred-dipole native template source spaces, and now joinable with motion capture, EMG, or clinical data in the same object. Because the computation ran in MNE, the run manifest should record the Python/MNE environment and mark the step as external: the substrate content-addresses the result, not MNE’s internal computation. Migrating this to a native, reproducible individual-MRI pipeline is future work; until then, the bridge delivers the capability today.