//! Spectral coherence estimation. //! //! Coherence measures the linear relationship between two signals in the frequency domain. //! //! ## Mathematical Background //! //! Magnitude-squared coherence: Cxy(f) = |Sxy(f)|² / (Sxx(f) * Syy(f)) //! //! where Sxy is cross-spectral density and Sxx, Syy are auto-spectral densities. use crate::{ConnectivityError, ConnectivityResult, utils}; use num_complex::Complex64; /// Compute coherence between two signals /// /// # Arguments /// * `x` - First signal [n_samples] /// * `y` - Second signal [n_samples] /// * `sfreq` - Sampling frequency (Hz) /// * `n_fft` - FFT length (None = signal length) /// /// # Returns /// (coherence, frequencies) where coherence is [n_freqs] pub fn coherence( x: &[f64], y: &[f64], sfreq: f64, n_fft: Option, ) -> ConnectivityResult<(Vec, Vec)> { if x.len() != y.len() { return Err(ConnectivityError::DimensionMismatch( "Signals must have same length".to_string(), )); } let n = x.len(); let n_fft = n_fft.unwrap_or(n); // Apply window let window = utils::hanning_window(n); let x_win = utils::apply_window(x, &window); let y_win = utils::apply_window(y, &window); // Compute FFT let x_fft = utils::rfft(&x_win, n_fft); let y_fft = utils::rfft(&y_win, n_fft); // Cross and auto spectral densities let sxy = utils::cross_spectral_density(&x_fft, &y_fft); let sxx = utils::power_spectral_density(&x_fft); let syy = utils::power_spectral_density(&y_fft); // Coherence: |Sxy|² / (Sxx * Syy) // Clamp to [0, 1] due to numerical precision let coh: Vec = sxy .iter() .zip(sxx.iter()) .zip(syy.iter()) .map(|((xy, &xx), &yy)| { let denom = xx * yy; if denom > 1e-15 { (xy.norm_sqr() / denom).clamp(0.0, 1.0) } else { 0.0 } }) .collect(); let freqs = utils::fft_freqs(sfreq, n_fft, 0.0, sfreq / 2.0); Ok((coh, freqs)) } /// Compute coherence between specific channel pairs /// /// # Arguments /// * `x` - First signal [n_samples] /// * `y` - Second signal [n_samples] /// * `sfreq` - Sampling frequency /// * `fmin` - Minimum frequency /// * `fmax` - Maximum frequency /// * `n_fft` - FFT length pub fn coherence_pairs( x: &[f64], y: &[f64], sfreq: f64, fmin: f64, fmax: f64, n_fft: usize, ) -> ConnectivityResult> { let (coh, freqs) = coherence(x, y, sfreq, Some(n_fft))?; // Filter to frequency range let result: Vec = coh .iter() .zip(freqs.iter()) .filter(|&(_, f)| *f >= fmin && *f <= fmax) .map(|(&c, _)| c) .collect(); Ok(result) } /// Compute imaginary coherence (robust to volume conduction) /// /// Imaginary coherence only considers the imaginary part of the cross-spectrum, /// which is zero for volume-conducted signals (zero phase lag). pub fn imaginary_coherence( x: &[f64], y: &[f64], sfreq: f64, n_fft: Option, ) -> ConnectivityResult<(Vec, Vec)> { if x.len() != y.len() { return Err(ConnectivityError::DimensionMismatch( "Signals must have same length".to_string(), )); } let n = x.len(); let n_fft = n_fft.unwrap_or(n); let window = utils::hanning_window(n); let x_win = utils::apply_window(x, &window); let y_win = utils::apply_window(y, &window); let x_fft = utils::rfft(&x_win, n_fft); let y_fft = utils::rfft(&y_win, n_fft); let sxy = utils::cross_spectral_density(&x_fft, &y_fft); let sxx = utils::power_spectral_density(&x_fft); let syy = utils::power_spectral_density(&y_fft); // Imaginary coherence: Im(Sxy)² / (Sxx * Syy) // Clamp to [0, 1] let imcoh: Vec = sxy .iter() .zip(sxx.iter()) .zip(syy.iter()) .map(|((xy, &xx), &yy)| { let denom = xx * yy; if denom > 1e-15 { ((xy.im * xy.im) / denom).clamp(0.0, 1.0) } else { 0.0 } }) .collect(); let freqs = utils::fft_freqs(sfreq, n_fft, 0.0, sfreq / 2.0); Ok((imcoh, freqs)) } /// Compute coherence across all channel pairs for epoched data pub fn coherence_all_pairs( epochs: &[Vec>], sfreq: f64, fmin: f64, fmax: f64, n_fft: usize, ) -> ConnectivityResult>> { if epochs.is_empty() { return Err(ConnectivityError::InsufficientData( "No epochs provided".to_string(), )); } let n_channels = epochs[0].len(); let _n_epochs = epochs.len(); let freq_indices = utils::freq_indices(sfreq, n_fft, fmin, fmax); let _n_freqs = freq_indices.len(); // All channel pairs let mut pairs: Vec<(usize, usize)> = Vec::new(); for i in 0..n_channels { for j in (i + 1)..n_channels { pairs.push((i, j)); } } let window = utils::hanning_window(epochs[0][0].len()); // Accumulate cross and auto spectra across epochs let mut sxy_sum: Vec> = vec![vec![Complex64::new(0.0, 0.0); n_fft / 2 + 1]; pairs.len()]; let mut sxx_sum: Vec> = vec![vec![0.0; n_fft / 2 + 1]; n_channels]; let _syy_sum: Vec> = vec![vec![0.0; n_fft / 2 + 1]; n_channels]; for epoch in epochs { // FFT all channels let ffts: Vec> = epoch .iter() .map(|ch| { let windowed = utils::apply_window(ch, &window); utils::rfft(&windowed, n_fft) }) .collect(); // Accumulate auto-spectra for (ch, fft) in ffts.iter().enumerate() { for (i, c) in fft.iter().enumerate() { sxx_sum[ch][i] += c.norm_sqr(); } } // Accumulate cross-spectra for (p, &(i, j)) in pairs.iter().enumerate() { for (k, (a, b)) in ffts[i].iter().zip(&ffts[j]).enumerate() { sxy_sum[p][k] += a * b.conj(); } } } // Compute coherence let mut result = Vec::with_capacity(pairs.len()); for (p, &(i, j)) in pairs.iter().enumerate() { let coh: Vec = freq_indices .iter() .map(|&k| { let denom = sxx_sum[i][k] * sxx_sum[j][k]; if denom > 1e-15 { (sxy_sum[p][k].norm_sqr() / denom).clamp(0.0, 1.0) } else { 0.0 } }) .collect(); result.push(coh); } Ok(result) } /// Compute imaginary coherence across all channel pairs pub fn imag_coherence_all_pairs( epochs: &[Vec>], sfreq: f64, fmin: f64, fmax: f64, n_fft: usize, ) -> ConnectivityResult>> { if epochs.is_empty() { return Err(ConnectivityError::InsufficientData( "No epochs provided".to_string(), )); } let n_channels = epochs[0].len(); let freq_indices = utils::freq_indices(sfreq, n_fft, fmin, fmax); let mut pairs: Vec<(usize, usize)> = Vec::new(); for i in 0..n_channels { for j in (i + 1)..n_channels { pairs.push((i, j)); } } let window = utils::hanning_window(epochs[0][0].len()); let mut sxy_sum: Vec> = vec![vec![Complex64::new(0.0, 0.0); n_fft / 2 + 1]; pairs.len()]; let mut sxx_sum: Vec> = vec![vec![0.0; n_fft / 2 + 1]; n_channels]; for epoch in epochs { let ffts: Vec> = epoch .iter() .map(|ch| { let windowed = utils::apply_window(ch, &window); utils::rfft(&windowed, n_fft) }) .collect(); for (ch, fft) in ffts.iter().enumerate() { for (i, c) in fft.iter().enumerate() { sxx_sum[ch][i] += c.norm_sqr(); } } for (p, &(i, j)) in pairs.iter().enumerate() { for (k, (a, b)) in ffts[i].iter().zip(&ffts[j]).enumerate() { sxy_sum[p][k] += a * b.conj(); } } } let mut result = Vec::with_capacity(pairs.len()); for (p, &(i, j)) in pairs.iter().enumerate() { let imcoh: Vec = freq_indices .iter() .map(|&k| { let denom = sxx_sum[i][k] * sxx_sum[j][k]; if denom > 1e-15 { let im = sxy_sum[p][k].im; ((im * im) / denom).clamp(0.0, 1.0) } else { 0.0 } }) .collect(); result.push(imcoh); } Ok(result) } #[cfg(test)] mod tests { use super::*; use std::f64::consts::PI; fn create_test_signals() -> (Vec, Vec) { let n = 500; let sfreq = 100.0; // Two signals with known coherence let x: Vec = (0..n) .map(|i| { let t = i as f64 / sfreq; (2.0 * PI * 10.0 * t).sin() + 0.5 * (2.0 * PI * 20.0 * t).sin() }) .collect(); let y: Vec = (0..n) .map(|i| { let t = i as f64 / sfreq; 0.8 * (2.0 * PI * 10.0 * t).sin() + 0.3 * (2.0 * PI * 30.0 * t).sin() }) .collect(); (x, y) } #[test] fn test_coherence() { let (x, y) = create_test_signals(); let (coh, freqs) = coherence(&x, &y, 100.0, None).unwrap(); // Should have positive coherence assert!(coh.len() > 0); assert!(coh.iter().all(|&c| c >= 0.0 && c <= 1.0)); // Coherence at 10 Hz should be higher (shared component) let idx_10hz = freqs.iter().position(|&f| (f - 10.0).abs() < 1.0).unwrap(); assert!(coh[idx_10hz] > 0.5); } #[test] fn test_perfect_coherence() { let n = 500; // Use a pure sinusoid at a specific frequency let x: Vec = (0..n) .map(|i| (2.0 * PI * 10.0 * i as f64 / 100.0).sin()) .collect(); let y = x.clone(); // Perfect copy let (coh, freqs) = coherence(&x, &y, 100.0, None).unwrap(); // Coherence at the signal frequency should be very high let idx_10hz = freqs.iter().position(|&f| (f - 10.0).abs() < 1.0).unwrap(); assert!( coh[idx_10hz] > 0.95, "Coherence at signal frequency should be ~1.0, got {}", coh[idx_10hz] ); } #[test] fn test_imaginary_coherence() { let (x, y) = create_test_signals(); let (imcoh, _) = imaginary_coherence(&x, &y, 100.0, None).unwrap(); // Imaginary coherence should be bounded [0, 1] assert!(imcoh.iter().all(|&c| c >= 0.0 && c <= 1.0)); } #[test] fn test_coherence_all_pairs() { let n = 200; let sfreq = 100.0; // Create 3-channel epochs let epochs: Vec>> = (0..5) .map(|_| { (0..3) .map(|ch| { (0..n) .map(|i| { let t = i as f64 / sfreq; (2.0 * PI * (10.0 + ch as f64) * t).sin() }) .collect() }) .collect() }) .collect(); let result = coherence_all_pairs(&epochs, sfreq, 5.0, 30.0, n).unwrap(); // 3 channels = 3 pairs assert_eq!(result.len(), 3); // All coherence values should be valid assert!( result .iter() .all(|pair| pair.iter().all(|&c| c >= 0.0 && c <= 1.0)) ); } }