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:
reticulate call into Python is a
non-deterministic boundary; a native R op is not.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.
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)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:
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).
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.
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.