PhysioCrossModal provides tools for analysing coupling, connectivity, and synchrony between physiological signals of different modalities. While single-modality analysis (e.g., EEG coherence between channels) is handled by PhysioAnalysis, PhysioCrossModal focuses on the relationship between distinct signal types – for example, quantifying how cortical EEG activity drives peripheral EMG during a motor task.
The package introduces:
MultiPhysioExperiment – a container
that holds multiple PhysioExperiment objects recorded
simultaneously at potentially different sampling rates.couplingAnalysis() wrapper
that dispatches to any of the above methods with a single function
call.This vignette walks through a cortico-muscular coherence (CMC) analysis as a worked example – a common paradigm in motor neuroscience where the goal is to quantify the functional coupling between cortical EEG and peripheral EMG during sustained isometric contraction.
References: Mima & Hallett (1999), Halliday et al. (1995).
In a real experiment you would import data with
PhysioCore::readEDF() or similar I/O functions. Here we
generate synthetic signals with a known 20 Hz coupling (the beta-band
CMC peak typically observed during steady-force tasks).
Note: In real CMC research, coherence values are typically in the range 0.1–0.4. We use a higher coupling strength here for pedagogical clarity. Additionally, real EMG data should be rectified (full-wave rectification) before CMC analysis.
library(PhysioCrossModal)
#> Loading required package: PhysioCore
set.seed(42)
# --- Parameters ---
sr_eeg <- 500 # EEG sampling rate (Hz)
sr_emg <- 1000 # EMG sampling rate (Hz)
n_sec <- 10 # recording duration (seconds)
cmc_freq <- 20 # coupling frequency (Hz)
cmc_strength <- 0.7 # coupling strength (0 = none, 1 = perfect)
n_eeg <- as.integer(sr_eeg * n_sec)
n_emg <- as.integer(sr_emg * n_sec)
t_eeg <- seq(0, by = 1 / sr_eeg, length.out = n_eeg)
t_emg <- seq(0, by = 1 / sr_emg, length.out = n_emg)
# Shared 20 Hz oscillation (the "cortical drive")
driver_eeg <- sin(2 * pi * cmc_freq * t_eeg)
driver_emg <- sin(2 * pi * cmc_freq * t_emg)
# Four EEG channels -- only channel 1 carries the CMC signal
n_eeg_ch <- 4
eeg_data <- matrix(rnorm(n_eeg * n_eeg_ch), nrow = n_eeg, ncol = n_eeg_ch)
eeg_data[, 1] <- eeg_data[, 1] + cmc_strength * driver_eeg
# Two EMG channels -- only channel 1 carries the CMC signal
n_emg_ch <- 2
emg_data <- matrix(rnorm(n_emg * n_emg_ch) * 0.5, nrow = n_emg, ncol = n_emg_ch)
emg_data[, 1] <- emg_data[, 1] + cmc_strength * driver_emgEach modality is stored as a standard PhysioExperiment
from the PhysioCore package. The key fields are the signal matrix (time
x channels), channel metadata in colData, and the sampling
rate.
pe_eeg <- PhysioExperiment(
assays = list(raw = eeg_data),
colData = S4Vectors::DataFrame(
label = paste0("EEG", seq_len(n_eeg_ch)),
type = rep("EEG", n_eeg_ch)
),
samplingRate = sr_eeg
)
pe_emg <- PhysioExperiment(
assays = list(raw = emg_data),
colData = S4Vectors::DataFrame(
label = paste0("EMG", seq_len(n_emg_ch)),
type = rep("EMG", n_emg_ch)
),
samplingRate = sr_emg
)The MultiPhysioExperiment container bundles the two
modalities together with temporal alignment metadata. Note that the EEG
and EMG have different sampling rates (500 vs 1000 Hz) – the container
preserves both natively.
mpe <- MultiPhysioExperiment(EEG = pe_eeg, EMG = pe_emg)
mpe
#> class: MultiPhysioExperiment
#> modalities(2): EEG, EMG
#> samplingRates: EEG=500Hz, EMG=1000Hz
#> EEG: 5000 timepoints x 4 channels
#> EMG: 10000 timepoints x 2 channelsYou can query the container:
The core analysis: magnitude-squared coherence between EEG channel 1 and EMG channel 1. Coherence quantifies the linear frequency-domain relationship and ranges from 0 (no coupling) to 1 (perfect coupling).
Both coherence (Welch’s method) and Granger causality assume weak stationarity. For a 10-second sustained isometric contraction this is plausible, but researchers working with dynamic tasks should verify this assumption.
coh_result <- coherence(
mpe,
modality_x = "EEG",
modality_y = "EMG",
channels_x = 1L,
channels_y = 1L,
nperseg = 256L
)
str(coh_result)
#> List of 4
#> $ coherence : num [1:129] 0.02535 0.01142 0.00946 0.00515 0.11206 ...
#> $ frequencies : num [1:129] 0 1.95 3.91 5.86 7.81 ...
#> $ confidence_limit: num 0.0778
#> $ n_segments : int 38
# Find the peak coherence near 20 Hz
peak_idx <- which.max(coh_result$coherence)
cat(sprintf("Peak coherence = %.3f at %.1f Hz\n",
coh_result$coherence[peak_idx],
coh_result$frequencies[peak_idx]))
#> Peak coherence = 0.933 at 19.5 HzThe confidence_limit field provides a 95% significance
threshold based on the number of segments in the Welch estimation
(Halliday et al. 1995). Coherence values above this line are unlikely to
arise by chance.
You can restrict the output to a frequency range of interest:
PLV (Lachaux et al. 1999) measures the consistency of the phase difference between two signals within a specified frequency band. A PLV near 1 indicates that the two signals maintain a stable phase relationship; a PLV near 0 indicates random phase differences.
Granger causality (Granger 1969; Geweke 1982) quantifies directed coupling – it tells you whether the past of signal X helps predict the future of signal Y, beyond what Y’s own past can predict. This is particularly relevant for CMC because cortico-muscular drive is thought to be predominantly (though not exclusively) descending (EEG drives EMG).
gc_result <- grangerCausality(
mpe,
modality_x = "EEG",
modality_y = "EMG",
order = 10L # AR model order
)
cat(sprintf("GC (EEG -> EMG) = %.4f\n", gc_result$gc_xy))
#> GC (EEG -> EMG) = 0.0181
cat(sprintf("GC (EMG -> EEG) = %.4f\n", gc_result$gc_yx))
#> GC (EMG -> EEG) = 0.0966
cat(sprintf("Net GC = %.4f\n", gc_result$net_gc))
#> Net GC = -0.0784
# Positive net_gc indicates EEG drives EMG more than the reverseNote: for Granger causality to work correctly, both signals must be
resampled to the same rate. When you pass a
MultiPhysioExperiment, the internal signal extraction
handles this automatically.
If you want a uniform interface to all coupling methods, use
couplingAnalysis(). It dispatches to the appropriate
function based on the method argument:
# Coherence via wrapper
res_coh <- couplingAnalysis(
mpe,
modality_x = "EEG",
modality_y = "EMG",
method = "coherence"
)
# Cross-correlation via wrapper
res_cc <- couplingAnalysis(
mpe,
modality_x = "EEG",
modality_y = "EMG",
method = "crosscorrelation"
)
cat(sprintf("Peak cross-correlation = %.3f at lag = %d samples (%.1f ms)\n",
res_cc$peak_correlation,
res_cc$peak_lag,
res_cc$peak_lag_seconds * 1000))
#> Peak cross-correlation = -0.381 at lag = 37 samples (74.0 ms)The wrapper accepts the same x/y and
mpe arguments as the individual functions, plus
method-specific parameters via ....
All coupling functions also accept plain numeric vectors with an
explicit sr argument. This is useful for quick one-off
analyses or when your data is not yet stored in a PhysioExperiment:
set.seed(123)
sr <- 500
t <- seq(0, 10, length.out = sr * 10)
x <- sin(2 * pi * 20 * t) + 0.2 * rnorm(length(t))
y <- 0.8 * sin(2 * pi * 20 * t) + 0.2 * rnorm(length(t))
# Direct vector interface
coh <- coherence(x, y, sr = sr)
gc <- grangerCausality(x, y, sr = sr, order = 10)
cc <- crossCorrelation(x, y, sr = sr)When modalities have different sampling rates, you may need to resample before certain analyses. PhysioCrossModal provides helper functions:
# Resample a single PE to a target rate
pe_emg_500 <- alignToRate(pe_emg, target_rate = 500)
# Align multiple PEs to the lowest rate and bundle into an MPE
mpe_aligned <- alignSignals(EEG = pe_eeg, EMG = pe_emg,
method = "lowest_rate")
# Merge two same-rate PEs into a single PE (column-bind)
merged_pe <- mergePhysio(pe_eeg, alignToRate(pe_emg, target_rate = 500),
prefix = c("EEG_", "EMG_"))PhysioCrossModal v0.2.0 provides two complementary approaches for assessing the statistical significance of coupling estimates: surrogate-based null-hypothesis testing and block-bootstrap confidence intervals.
surrogateTest() generates a null distribution by
randomising the phase spectrum (or circularly shifting) one signal,
thereby destroying the cross-signal relationship while preserving
autocorrelation:
bootstrapCI() uses the moving-block bootstrap
(preserving temporal autocorrelation) to construct confidence
intervals:
While Welch-based coherence gives a single frequency-domain estimate
averaged over the entire recording, wavelet coherence
(waveletCoherence()) reveals time-frequency
coupling dynamics. This is particularly useful when coupling strength
varies over time (e.g. during movement onset or task transitions).
wcoh <- waveletCoherence(
mpe,
modality_x = "EEG",
modality_y = "EMG",
frequencies = seq(10, 35, by = 1),
n_cycles = 7,
smoothing_cycles = 3
)
# wcoh$coherence is a [time x frequency] matrix
# wcoh$phase contains the phase differences
# wcoh$coi contains the Cone of Influence frequencies
# Wavelet PLV is also available:
wplv <- waveletPLV(
mpe,
modality_x = "EEG",
modality_y = "EMG",
frequencies = seq(10, 35, by = 1)
)When you want to compute coupling across all channel pairs (e.g., to
identify which EEG electrode shows the strongest coherence with which
EMG channel), use coherenceMatrix() or the generic
couplingMatrix():
mat <- coherenceMatrix(
mpe,
modality_x = "EEG",
modality_y = "EMG",
nperseg = 256L
)
# mat$matrix is a [n_eeg_channels x n_emg_channels] matrix
# Visualise as a heatmap
if (requireNamespace("ggplot2", quietly = TRUE)) {
plotCouplingMatrix(mat$matrix, title = "EEG-EMG Peak Coherence")
}The generic couplingMatrix() works with any coupling
method:
| Method | Function | What it measures |
|---|---|---|
| Spectral coherence | coherence() |
Linear frequency-domain coupling [0, 1] |
| Cross-spectral density | crossSpectrum() |
Magnitude and phase of frequency coupling |
| Phase Locking Value | phaseLockingValue() |
Phase consistency in a frequency band [0, 1] |
| Phase Lag Index | phaseLagIndex() |
Phase coupling robust to volume conduction [0, 1] |
| Weighted PLI | weightedPLI() |
Noise-weighted phase coupling [0, 1] |
| Granger causality | grangerCausality() |
Directed (causal) coupling |
| Cross-correlation | crossCorrelation() |
Time-domain coupling with lag estimation |
| Sliding cross-correlation | slidingCrossCorrelation() |
Time-varying coupling dynamics |
| Wavelet coherence | waveletCoherence() |
Time-frequency coherence via Morlet wavelets |
| Wavelet PLV | waveletPLV() |
Time-frequency phase locking |
| Surrogate test | surrogateTest() |
Null-hypothesis significance testing |
| Bootstrap CI | bootstrapCI() |
Confidence intervals via block bootstrap |
| Multitaper coherence | multitaperCoherence() |
Coherence via DPSS tapers (low variance) |
| Coherence matrix | coherenceMatrix() |
Multi-channel coherence across all pairs |
| Coupling matrix | couplingMatrix() |
Multi-channel coupling (any method) |
| Matrix significance | surrogateMatrixTest() |
Surrogate test with FDR correction |
| Wavelet plot | plotWaveletCoherence() |
Time-frequency heatmap with COI |
| Unified wrapper | couplingAnalysis() |
Dispatches to any of the above |
For further details on each function, see the package reference
manual (?coherence, ?phaseLockingValue,
etc.).