//! Phase-Amplitude Coupling (PAC) analysis. //! //! PAC measures the relationship between the phase of low-frequency oscillations //! and the amplitude of high-frequency oscillations. //! //! ## Mathematical Background //! //! Modulation Index (MI): MI = KL(P || U) / log(N) //! where P is the distribution of amplitudes over phase bins and U is uniform. use crate::{ConnectivityError, ConnectivityResult, utils}; use num_complex::Complex64; use std::f64::consts::PI; /// PAC estimation method #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PacMethod { /// Modulation Index (Tort et al.) ModulationIndex, /// Mean Vector Length MeanVectorLength, /// Phase-Locking Value between phase and amplitude envelope PlvPac, } impl Default for PacMethod { fn default() -> Self { Self::ModulationIndex } } /// PAC result for a comodulogram #[derive(Debug, Clone)] pub struct PacResult { /// PAC values [n_phase_freqs x n_amp_freqs] pub data: Vec>, /// Phase frequencies pub phase_freqs: Vec, /// Amplitude frequencies pub amp_freqs: Vec, /// Method used pub method: PacMethod, } /// Compute Phase-Amplitude Coupling /// /// # Arguments /// * `signal` - Time series [n_samples] /// * `sfreq` - Sampling frequency /// * `phase_freq` - (low, high) frequency band for phase /// * `amp_freq` - (low, high) frequency band for amplitude /// * `method` - PAC estimation method /// /// # Returns /// PAC value pub fn phase_amplitude_coupling( signal: &[f64], sfreq: f64, phase_freq: (f64, f64), amp_freq: (f64, f64), method: PacMethod, ) -> ConnectivityResult { if signal.len() < 100 { return Err(ConnectivityError::InsufficientData( "Signal too short for PAC analysis".to_string(), )); } if phase_freq.1 >= amp_freq.0 { return Err(ConnectivityError::InvalidParameters( "Phase frequency band must be lower than amplitude frequency band".to_string(), )); } // Bandpass filter for phase and amplitude let phase_signal = utils::bandpass_fft(signal, sfreq, phase_freq.0, phase_freq.1); let amp_signal = utils::bandpass_fft(signal, sfreq, amp_freq.0, amp_freq.1); // Get phase from low-frequency signal let phase_analytic = utils::hilbert(&phase_signal); let phase = utils::instantaneous_phase(&phase_analytic); // Get amplitude envelope from high-frequency signal let amp_analytic = utils::hilbert(&_signal); let amplitude: Vec = amp_analytic.iter().map(|c| c.norm()).collect(); match method { PacMethod::ModulationIndex => modulation_index(&phase, &litude), PacMethod::MeanVectorLength => mean_vector_length(&phase, &litude), PacMethod::PlvPac => plv_pac(&phase, &litude), } } /// Compute comodulogram (PAC across multiple frequency pairs) /// /// # Arguments /// * `signal` - Time series /// * `sfreq` - Sampling frequency /// * `phase_freqs` - Center frequencies for phase (low frequency) /// * `amp_freqs` - Center frequencies for amplitude (high frequency) /// * `bandwidth` - Bandwidth for bandpass filters /// * `method` - PAC method pub fn comodulogram( signal: &[f64], sfreq: f64, phase_freqs: &[f64], amp_freqs: &[f64], bandwidth: f64, method: PacMethod, ) -> ConnectivityResult { let mut data = Vec::with_capacity(phase_freqs.len()); for &f_phase in phase_freqs { let mut row = Vec::with_capacity(amp_freqs.len()); for &f_amp in amp_freqs { // Ensure non-overlapping frequency bands if f_phase + bandwidth / 2.0 >= f_amp - bandwidth / 2.0 { row.push(0.0); continue; } let phase_band = (f_phase - bandwidth / 2.0, f_phase + bandwidth / 2.0); let amp_band = (f_amp - bandwidth / 2.0, f_amp + bandwidth / 2.0); let pac = phase_amplitude_coupling(signal, sfreq, phase_band, amp_band, method) .unwrap_or(0.0); row.push(pac); } data.push(row); } Ok(PacResult { data, phase_freqs: phase_freqs.to_vec(), amp_freqs: amp_freqs.to_vec(), method, }) } /// Modulation Index (Tort et al., 2010) /// /// Measures deviation from uniform distribution of amplitudes across phase bins fn modulation_index(phase: &[f64], amplitude: &[f64]) -> ConnectivityResult { let n_bins = 18; // 20-degree bins let bin_width = 2.0 * PI / n_bins as f64; // Bin amplitudes by phase let mut bin_sums = vec![0.0; n_bins]; let mut bin_counts = vec![0usize; n_bins]; for (&p, &a) in phase.iter().zip(amplitude) { // Normalize phase to [0, 2π) let p_norm = ((p % (2.0 * PI)) + 2.0 * PI) % (2.0 * PI); let bin = ((p_norm / bin_width) as usize).min(n_bins - 1); bin_sums[bin] += a; bin_counts[bin] += 1; } // Compute mean amplitude per bin let bin_means: Vec = bin_sums .iter() .zip(&bin_counts) .map(|(&s, &c)| if c > 0 { s / c as f64 } else { 0.0 }) .collect(); // Normalize to probability distribution let total: f64 = bin_means.iter().sum(); if total < 1e-15 { return Ok(0.0); } let p_dist: Vec = bin_means.iter().map(|&m| m / total).collect(); // KL divergence from uniform distribution let uniform = 1.0 / n_bins as f64; let mut kl = 0.0; for &p in &p_dist { if p > 1e-15 { kl += p * (p / uniform).ln(); } } // Normalize by log(N) to get MI in [0, 1] let mi = kl / (n_bins as f64).ln(); Ok(mi.max(0.0)) } /// Mean Vector Length (MVL) /// /// Measures the length of the mean vector of amplitude-weighted phase vectors fn mean_vector_length(phase: &[f64], amplitude: &[f64]) -> ConnectivityResult { let n = phase.len(); if n == 0 { return Ok(0.0); } // Compute amplitude-weighted mean vector let mut sum_cos = 0.0; let mut sum_sin = 0.0; let mut sum_amp = 0.0; for (&p, &a) in phase.iter().zip(amplitude) { sum_cos += a * p.cos(); sum_sin += a * p.sin(); sum_amp += a; } if sum_amp < 1e-15 { return Ok(0.0); } let mean_cos = sum_cos / sum_amp; let mean_sin = sum_sin / sum_amp; let mvl = (mean_cos * mean_cos + mean_sin * mean_sin).sqrt(); Ok(mvl) } /// PLV-based PAC /// /// Computes PLV between low-frequency phase and high-frequency amplitude envelope fn plv_pac(phase: &[f64], amplitude: &[f64]) -> ConnectivityResult { let n = phase.len(); if n == 0 { return Ok(0.0); } // Get phase of amplitude envelope (via Hilbert) let amp_analytic = utils::hilbert(amplitude); let amp_phase: Vec = amp_analytic.iter().map(|c| c.arg()).collect(); // Compute PLV between phases let mut sum = Complex64::new(0.0, 0.0); for (&p1, &p2) in phase.iter().zip(&_phase) { let diff = p1 - p2; sum += Complex64::new(diff.cos(), diff.sin()); } let plv = sum.norm() / n as f64; Ok(plv) } /// Compute PAC for epoched data pub fn pac_epochs( epochs: &[Vec], sfreq: f64, phase_freq: (f64, f64), amp_freq: (f64, f64), method: PacMethod, ) -> ConnectivityResult { if epochs.is_empty() { return Err(ConnectivityError::InsufficientData( "No epochs provided".to_string(), )); } // Average PAC across epochs let mut pac_sum = 0.0; let mut count = 0; for epoch in epochs { match phase_amplitude_coupling(epoch, sfreq, phase_freq, amp_freq, method) { Ok(pac) => { pac_sum += pac; count += 1; } Err(_) => continue, } } if count > 0 { Ok(pac_sum / count as f64) } else { Err(ConnectivityError::ComputationError( "Failed to compute PAC for any epoch".to_string(), )) } } /// Surrogate-based statistical testing for PAC /// /// Shuffles amplitude time series to create null distribution pub fn pac_permutation_test( signal: &[f64], sfreq: f64, phase_freq: (f64, f64), amp_freq: (f64, f64), method: PacMethod, n_permutations: usize, ) -> ConnectivityResult<(f64, f64, f64)> { // Compute observed PAC let pac_obs = phase_amplitude_coupling(signal, sfreq, phase_freq, amp_freq, method)?; // Get phase and amplitude time series let phase_signal = utils::bandpass_fft(signal, sfreq, phase_freq.0, phase_freq.1); let amp_signal = utils::bandpass_fft(signal, sfreq, amp_freq.0, amp_freq.1); let phase_analytic = utils::hilbert(&phase_signal); let phase = utils::instantaneous_phase(&phase_analytic); let amp_analytic = utils::hilbert(&_signal); let amplitude: Vec = amp_analytic.iter().map(|c| c.norm()).collect(); // Generate surrogate distribution let n = amplitude.len(); let mut surrogate_pacs = Vec::with_capacity(n_permutations); for i in 0..n_permutations { // Circular shift of amplitude let shift = (n * (i + 1) / (n_permutations + 1)).max(n / 10); let amp_shifted: Vec = amplitude .iter() .cycle() .skip(shift) .take(n) .copied() .collect(); let pac = match method { PacMethod::ModulationIndex => modulation_index(&phase, &_shifted), PacMethod::MeanVectorLength => mean_vector_length(&phase, &_shifted), PacMethod::PlvPac => plv_pac(&phase, &_shifted), }; if let Ok(p) = pac { surrogate_pacs.push(p); } } if surrogate_pacs.is_empty() { return Err(ConnectivityError::ComputationError( "Failed to generate surrogates".to_string(), )); } // Compute p-value let n_greater = surrogate_pacs.iter().filter(|&&p| p >= pac_obs).count(); let p_value = n_greater as f64 / surrogate_pacs.len() as f64; // Compute z-score let mean_surr: f64 = surrogate_pacs.iter().sum::() / surrogate_pacs.len() as f64; let var_surr: f64 = surrogate_pacs .iter() .map(|&p| (p - mean_surr) * (p - mean_surr)) .sum::() / surrogate_pacs.len() as f64; let std_surr = var_surr.sqrt(); let z_score = if std_surr > 1e-15 { (pac_obs - mean_surr) / std_surr } else { 0.0 }; Ok((pac_obs, p_value, z_score)) } #[cfg(test)] mod tests { use super::*; fn create_pac_signal( n: usize, sfreq: f64, phase_freq: f64, amp_freq: f64, coupling: f64, ) -> Vec { (0..n) .map(|i| { let t = i as f64 / sfreq; // Low frequency carrier let phase = 2.0 * PI * phase_freq * t; // High frequency with amplitude modulated by low frequency phase let amp_mod = 1.0 + coupling * phase.cos(); let hf = amp_mod * (2.0 * PI * amp_freq * t).sin(); phase.sin() + hf }) .collect() } #[test] fn test_pac_with_coupling() { let signal = create_pac_signal(2000, 500.0, 6.0, 60.0, 0.8); let pac = phase_amplitude_coupling( &signal, 500.0, (4.0, 8.0), (50.0, 70.0), PacMethod::ModulationIndex, ); assert!(pac.is_ok()); let pac_val = pac.unwrap(); assert!( pac_val > 0.05, "PAC with coupling should be detectable, got {}", pac_val ); } #[test] fn test_pac_without_coupling() { // Pure noise - no coupling let signal: Vec = (0..2000) .map(|i| ((i as f64 * 0.1).sin() * 100.0).sin()) .collect(); let pac = phase_amplitude_coupling( &signal, 500.0, (4.0, 8.0), (50.0, 70.0), PacMethod::ModulationIndex, ); assert!(pac.is_ok()); let pac_val = pac.unwrap(); // Should be low without coupling assert!(pac_val < 0.3); } #[test] fn test_mvl() { let signal = create_pac_signal(2000, 500.0, 6.0, 60.0, 0.8); let pac = phase_amplitude_coupling( &signal, 500.0, (4.0, 8.0), (50.0, 70.0), PacMethod::MeanVectorLength, ); assert!(pac.is_ok()); let pac_val = pac.unwrap(); assert!(pac_val >= 0.0 && pac_val <= 1.0); } #[test] fn test_comodulogram() { let signal = create_pac_signal(2000, 500.0, 8.0, 80.0, 0.5); let phase_freqs: Vec = (4..=12).step_by(2).map(|f| f as f64).collect(); let amp_freqs: Vec = (50..=100).step_by(10).map(|f| f as f64).collect(); let result = comodulogram( &signal, 500.0, &phase_freqs, &_freqs, 4.0, PacMethod::ModulationIndex, ); assert!(result.is_ok()); let comod = result.unwrap(); assert_eq!(comod.data.len(), phase_freqs.len()); assert_eq!(comod.data[0].len(), amp_freqs.len()); } #[test] fn test_pac_epochs() { let epochs: Vec> = (0..5) .map(|_| create_pac_signal(500, 250.0, 6.0, 60.0, 0.6)) .collect(); let pac = pac_epochs( &epochs, 250.0, (4.0, 8.0), (50.0, 70.0), PacMethod::ModulationIndex, ); assert!(pac.is_ok()); } #[test] fn test_invalid_freq_bands() { let signal = create_pac_signal(1000, 500.0, 6.0, 60.0, 0.5); // Phase freq higher than amp freq - should error let result = phase_amplitude_coupling( &signal, 500.0, (50.0, 60.0), (4.0, 8.0), PacMethod::ModulationIndex, ); assert!(result.is_err()); } }