Skip to content

Commit 0b5f210

Browse files
authored
ENH: control amplitude envelope of the coupled waveform (#87)
1 parent a17516a commit 0b5f210

7 files changed

Lines changed: 285 additions & 67 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ noise ([#58](https://github.com/ctrltz/meegsim/pull/58))
1919
- A method for setting phase-phase coupling by adding noise to the shifted copy of input waveform ([#71](https://github.com/ctrltz/meegsim/pull/71))
2020
- Function to convert the sources to mne.Label ([#73](https://github.com/ctrltz/meegsim/pull/73))
2121
- Quick list-like access to the simulated sources ([#82](https://github.com/ctrltz/meegsim/pull/82))
22+
- Control over the amplitude envelope of the coupled waveform ([#87](https://github.com/ctrltz/meegsim/pull/87))
2223

2324
### Changed
2425

docs/api/coupling.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ Coupling methods
66
.. autosummary::
77
:toctree: ../generated/
88

9-
ppc_shifted_copy_with_noise
9+
ppc_constant_phase_shift
1010
ppc_von_mises
11-
constant_phase_shift
11+
ppc_shifted_copy_with_noise

src/meegsim/coupling.py

Lines changed: 97 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,48 @@
77
from scipy.stats import vonmises
88
from scipy.signal import butter, filtfilt, hilbert
99

10-
from meegsim._check import check_numeric
10+
from meegsim._check import check_numeric, check_option
1111
from meegsim.snr import get_variance, amplitude_adjustment_factor
1212
from meegsim.utils import normalize_variance
1313
from meegsim.waveform import narrowband_oscillation, white_noise
1414

1515

16-
def constant_phase_shift(waveform, sfreq, phase_lag, m=1, n=1, random_state=None):
16+
def _get_envelope(waveform, envelope, sfreq, fmin=None, fmax=None, random_state=None):
17+
check_option(
18+
"the amplitude envelope of the coupled waveform", envelope, ["same", "random"]
19+
)
20+
if not np.iscomplexobj(waveform):
21+
waveform = hilbert(waveform)
22+
23+
if envelope == "same":
24+
return np.abs(waveform)
25+
26+
if fmin is None or fmax is None:
27+
raise ValueError(
28+
"Frequency limits are required for generating the envelope of the coupled waveform"
29+
)
30+
times = np.arange(waveform.size) / sfreq
31+
random_waveform = narrowband_oscillation(
32+
1, times, fmin=fmin, fmax=fmax, random_state=random_state
33+
)
34+
random_waveform = hilbert(random_waveform)
35+
36+
# TODO: here we could also mix original and random envelope with different
37+
# values of SNR to achieve smooth control over the resulting envelope correlation
38+
return np.abs(random_waveform)
39+
40+
41+
def ppc_constant_phase_shift(
42+
waveform,
43+
sfreq,
44+
phase_lag,
45+
fmin=None,
46+
fmax=None,
47+
envelope="random",
48+
m=1,
49+
n=1,
50+
random_state=None,
51+
):
1752
"""
1853
Generate a time series that is phase coupled to the input time series with
1954
a constant phase lag.
@@ -32,21 +67,32 @@ def constant_phase_shift(waveform, sfreq, phase_lag, m=1, n=1, random_state=None
3267
The input signal to be processed. It can be a real or complex time series.
3368
3469
sfreq : float
35-
Sampling frequency of the signal, in Hz. This argument is not used in this
36-
function but is accepted for consistency with other coupling methods.
70+
Sampling frequency of the signal, in Hz.
3771
3872
phase_lag : float
3973
Constant phase lag to apply to the waveform in radians.
4074
41-
m : int, optional
75+
envelope : str, {"same", "random"}
76+
Controls the amplitude envelope of the coupled waveform to be either randomly
77+
generated (default) or to be the same as the envelope of the input waveform.
78+
79+
fmin : float, optional
80+
Lower cutoff frequency for the oscillation that gives rise to the random
81+
amplitude envelope (only if the ``envelope`` is set to ``"random"``).
82+
83+
fmax : float, optional
84+
Upper cutoff frequency for the oscillation that gives rise to the random
85+
amplitude envelope (only if the ``envelope`` is set to ``"random"``).
86+
87+
m : float, optional
4288
Multiplier for the base frequency of the output oscillation, default is 1.
4389
44-
n : int, optional
90+
n : float, optional
4591
Multiplier for the base frequency of the input oscillation, default is 1.
4692
4793
random_state : None, optional
48-
This parameter is accepted for consistency with other coupling functions
49-
but not used since no randomness is involved.
94+
Random state can be fixed to provide reproducible results if the envelope
95+
is generated randomly. If not set, the results may differ between function calls.
5096
5197
Returns
5298
-------
@@ -56,16 +102,36 @@ def constant_phase_shift(waveform, sfreq, phase_lag, m=1, n=1, random_state=None
56102
if not np.iscomplexobj(waveform):
57103
waveform = hilbert(waveform)
58104

59-
waveform_amp = np.abs(waveform)
105+
waveform_amp = _get_envelope(waveform, envelope, sfreq, fmin, fmax, random_state)
60106
waveform_angle = np.angle(waveform)
61-
waveform_coupled = waveform_amp * np.exp(
62-
1j * m / n * waveform_angle + 1j * phase_lag
107+
waveform_coupled = np.real(
108+
waveform_amp * np.exp(1j * m / n * waveform_angle + 1j * phase_lag)
109+
)
110+
if envelope == "same":
111+
return normalize_variance(waveform_coupled)
112+
113+
# NOTE: if the envelope was modified, we filter the result again in the target
114+
# frequency range to suppress possible distortions due to merging amplitude
115+
# envelope and phase from different time series
116+
b, a = butter(
117+
N=2, Wn=np.array([m / n * fmin, m / n * fmax]) / sfreq * 2, btype="bandpass"
63118
)
64-
return normalize_variance(np.real(waveform_coupled))
119+
waveform_coupled = filtfilt(b, a, waveform_coupled)
120+
121+
return normalize_variance(waveform_coupled)
65122

66123

67124
def ppc_von_mises(
68-
waveform, sfreq, phase_lag, kappa, fmin, fmax, m=1, n=1, random_state=None
125+
waveform,
126+
sfreq,
127+
phase_lag,
128+
kappa,
129+
fmin,
130+
fmax,
131+
envelope="random",
132+
m=1,
133+
n=1,
134+
random_state=None,
69135
):
70136
"""
71137
Generate a time series that is phase coupled to the input time series with
@@ -102,10 +168,14 @@ def ppc_von_mises(
102168
fmax: float
103169
Upper cutoff frequency of the base frequency harmonic (in Hz).
104170
105-
m : int, optional
171+
envelope : str, {"same", "random"}
172+
Controls the amplitude envelope of the coupled waveform to be either randomly
173+
generated (default) or to be the same as the envelope of the input waveform.
174+
175+
m : float, optional
106176
Multiplier for the base frequency of the output oscillation, default is 1.
107177
108-
n : int, optional
178+
n : float, optional
109179
Multiplier for the base frequency of the input oscillation, default is 1.
110180
111181
random_state : None (default) or int
@@ -121,23 +191,26 @@ def ppc_von_mises(
121191
if not np.iscomplexobj(waveform):
122192
waveform = hilbert(waveform)
123193

124-
waveform_amp = np.abs(waveform)
194+
waveform_amp = _get_envelope(waveform, envelope, sfreq, fmin, fmax, random_state)
125195
waveform_angle = np.angle(waveform)
126-
n_samples = len(waveform)
196+
n_samples = waveform.size
127197

128198
ph_distr = vonmises.rvs(
129199
kappa, loc=phase_lag, size=n_samples, random_state=random_state
130200
)
131-
tmp_waveform = np.real(
201+
waveform_coupled = np.real(
132202
waveform_amp * np.exp(1j * m / n * waveform_angle + 1j * ph_distr)
133203
)
204+
205+
# NOTE: we filter the result again in the target frequency range to suppress
206+
# possible distortions due to separate adjustment of the phase and amplitude
207+
# of the coupled time series
134208
b, a = butter(
135209
N=2, Wn=np.array([m / n * fmin, m / n * fmax]) / sfreq * 2, btype="bandpass"
136210
)
137-
tmp_waveform = filtfilt(b, a, tmp_waveform)
138-
waveform_coupled = waveform_amp * np.exp(1j * np.angle(hilbert(tmp_waveform)))
211+
waveform_coupled = filtfilt(b, a, waveform_coupled)
139212

140-
return normalize_variance(np.real(waveform_coupled))
213+
return normalize_variance(waveform_coupled)
141214

142215

143216
def _shifted_copy_with_noise(
@@ -148,7 +221,9 @@ def _shifted_copy_with_noise(
148221
waveform and (2) mixing it with noise to achieve a desired level of signal-to-noise
149222
ratio, which determines the resulting phase-phase and amplitude-amplitude coupling.
150223
"""
151-
shifted_waveform = constant_phase_shift(waveform, sfreq, phase_lag)
224+
shifted_waveform = ppc_constant_phase_shift(
225+
waveform, sfreq, phase_lag, envelope="same"
226+
)
152227
signal_var = get_variance(shifted_waveform, sfreq, fmin, fmax, filter=True)
153228

154229
# NOTE: to make coupling band-limited (substantial only in the band of interest),

src/meegsim/utils.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import warnings
44

55
from mne.io.constants import FIFF
6-
from scipy.special import i1, i0
76

87

98
logger = logging.getLogger("meegsim")
@@ -79,15 +78,16 @@ def normalize_variance(data):
7978
8079
Returns
8180
-------
82-
data: array
81+
data_norm: array
8382
Normalized time series. The variance of each row is equal to 1.
8483
"""
84+
# NOTE: make a copy to keep the original waveform intact
85+
data_norm = data.copy()
86+
if data_norm.ndim == 1:
87+
return data_norm / np.std(data_norm)
8588

86-
if data.ndim == 1:
87-
return data / np.std(data)
88-
89-
data /= np.std(data, axis=-1)[:, np.newaxis]
90-
return data
89+
data_norm /= np.std(data_norm, axis=-1)[:, np.newaxis]
90+
return data_norm
9191

9292

9393
def _extract_hemi(src):
@@ -195,10 +195,6 @@ def unpack_vertices(vertices_lists):
195195
return unpacked_vertices
196196

197197

198-
def theoretical_plv(kappa):
199-
return i1(kappa) / i0(kappa)
200-
201-
202198
def vertices_to_mne(vertices, src):
203199
"""
204200
Convert the vertices to the MNE format (list of lists).

0 commit comments

Comments
 (0)