Skip to content

Commit 2c3165b

Browse files
authored
Merge pull request #354 from neurodsp-tools/fft
[ENH] - Extend spectral power with more FFT options
2 parents 50f3e5a + 423f133 commit 2c3165b

6 files changed

Lines changed: 133 additions & 19 deletions

File tree

doc/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ Spectral Power
111111
:toctree: generated/
112112

113113
compute_spectrum
114+
compute_spectrum_fft
114115
compute_spectrum_welch
115116
compute_spectrum_wavelet
116117
compute_spectrum_medfilt

neurodsp/spectral/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Spectral module, for calculating power spectra, spectral variance, etc."""
22

3-
from .power import (compute_spectrum, compute_spectrum_welch, compute_spectrum_wavelet,
4-
compute_spectrum_medfilt, compute_spectrum_multitaper)
3+
from .power import (compute_spectrum, compute_spectrum_fft, compute_spectrum_welch,
4+
compute_spectrum_wavelet, compute_spectrum_medfilt,
5+
compute_spectrum_multitaper)
56
from .measures import compute_absolute_power, compute_relative_power, compute_band_ratio
67
from .variance import compute_scv, compute_scv_rs, compute_spectral_hist
78
from .utils import trim_spectrum, trim_spectrogram

neurodsp/spectral/power.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@
77
"""
88

99
import numpy as np
10+
from scipy.signal import spectrogram, medfilt
1011
from scipy.signal import welch, spectrogram, medfilt
12+
from scipy.signal.windows import get_window
1113

1214
from neurodsp.utils.core import get_avg_func
1315
from neurodsp.utils.data import create_freqs
1416
from neurodsp.utils.decorators import multidim
1517
from neurodsp.utils.checks import check_param_options
1618
from neurodsp.utils.outliers import discard_outliers
1719
from neurodsp.timefrequency.wavelets import compute_wavelet_transform
18-
from neurodsp.spectral.utils import trim_spectrum
20+
from neurodsp.spectral.utils import trim_spectrum, pad_signal
1921
from neurodsp.spectral.checks import check_windowing_settings, check_mt_settings
2022

2123
###################################################################################################
@@ -70,9 +72,10 @@ def compute_spectrum(sig, fs, method='welch', **kwargs):
7072

7173

7274
SPECTRUM_INPUTS = {
75+
'wavelet' : ['freqs', 'avg_type', 'n_cycles', 'scaling', 'norm'],
76+
'fft' : ['window', 'f_range'],
7377
'welch' : ['avg_type', 'window', 'nperseg', 'noverlap', \
7478
'nfft', 'fast_len', 'f_range'],
75-
'wavelet' : ['freqs', 'avg_type', 'n_cycles', 'scaling', 'norm'],
7679
'medfilt' : ['filt_len', 'f_range'],
7780
}
7881

@@ -136,6 +139,50 @@ def compute_spectrum_wavelet(sig, fs, freqs, avg_type='mean', **kwargs):
136139
return freqs, spectrum
137140

138141

142+
@multidim(select=[0])
143+
def compute_spectrum_fft(sig, fs, window=None, nfft=None, f_range=None):
144+
"""Compute the power spectrum based on a single FFT.
145+
146+
Parameters
147+
----------
148+
sig : array
149+
Time series.
150+
fs : float
151+
Sampling rate, in Hz.
152+
window : str or tuple or float, optional
153+
Window function to apply to signal.
154+
Typically, this is a string of the name of the window to use (e.g. 'hann' or 'hamming').
155+
See `scipy.signal.windows.get_window` for details.
156+
nfft : int, optional
157+
Number of samples per for the FFT estimation.
158+
If provided and nfft > len(sig), then the signal is zero-padded to this length.
159+
f_range : list of [float, float], optional
160+
Frequency range to sub-select from the power spectrum.
161+
162+
Returns
163+
-------
164+
freqs : 1d array
165+
Frequencies at which the measure was calculated.
166+
spectrum : array
167+
Power spectral density.
168+
"""
169+
170+
if window is not None:
171+
sig = sig * get_window(window, len(sig))
172+
173+
if nfft is not None:
174+
sig = pad_signal(sig, nfft)
175+
176+
# Compute the FFT and convert to power & compute corresponding frequency vector
177+
spectrum = np.abs(np.fft.rfft(sig)) ** 2.
178+
freqs = np.fft.rfftfreq(len(sig), 1. / fs)
179+
180+
if f_range:
181+
freqs, spectrum = trim_spectrum(freqs, spectrum, f_range)
182+
183+
return freqs, spectrum
184+
185+
139186
def compute_spectrum_welch(sig, fs, avg_type='mean', window='hann', nperseg=None,
140187
noverlap=None, nfft=None, fast_len=False, f_range=None):
141188
"""Compute the power spectral density using Welch's method.
@@ -243,16 +290,15 @@ def compute_spectrum_medfilt(sig, fs, filt_len=1., f_range=None):
243290
>>> freqs, spec = compute_spectrum_medfilt(sig, fs=500)
244291
"""
245292

246-
# Take the positive half of the spectrum, since it's symmetrical
247-
ft = np.fft.fft(sig)[:int(np.ceil(len(sig) / 2.))]
248-
freqs = np.fft.fftfreq(len(sig), 1. / fs)[:int(np.ceil(len(sig) / 2.))]
293+
# Compute spectrum estimate as a single FFT
294+
freqs, spectrum = compute_spectrum_fft(sig, fs)
249295

250296
# Convert median filter length from Hz to samples, and make sure it is odd
251297
filt_len_samp = int(filt_len / (freqs[1] - freqs[0]))
252298
if filt_len_samp % 2 == 0:
253299
filt_len_samp += 1
254300

255-
spectrum = medfilt(np.abs(ft)**2. / (fs * len(sig)), filt_len_samp)
301+
spectrum = medfilt(spectrum / (fs * len(sig)), filt_len_samp)
256302

257303
if f_range:
258304
freqs, spectrum = trim_spectrum(freqs, spectrum, f_range)
@@ -319,7 +365,7 @@ def compute_spectrum_multitaper(sig, fs, bandwidth=None, n_tapers=None,
319365
"Could not compute spectrum with low_bias=True.")
320366

321367
# Compute Fourier transform on signal weighted by each slepian sequence
322-
freqs = np.fft.rfftfreq(sig_len, 1. /fs)
368+
freqs = np.fft.rfftfreq(sig_len, 1. / fs)
323369
spectra = np.abs(np.fft.rfft(slepian_sequences[:, np.newaxis] * sig)) ** 2
324370

325371
# combine estimates to compute final spectrum

neurodsp/spectral/utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,38 @@ def trim_spectrogram(freqs, times, spg, f_range=None, t_range=None):
128128
times_ext = times
129129

130130
return freqs_ext, times_ext, spg_ext
131+
132+
133+
def pad_signal(sig, length, fast_len=False):
134+
"""Pad a signal to a desired length.
135+
136+
Parameters
137+
----------
138+
sig : 1d array
139+
Signal to pad.
140+
length : int
141+
Output length to pad the signal to.
142+
fast_len : bool, optional, default: False
143+
If True, updates length to the next fastest length to reduce computation time.
144+
See scipy.fft.next_fast_len for details.
145+
146+
Returns
147+
-------
148+
sig : 1d array
149+
Padded signal.
150+
151+
Notes
152+
-----
153+
This approach pads the signal evenly on the left and right side with 0s.
154+
If the padding length ends up being odd, this approach will split to padding to
155+
have one less at the front / left side pad, and one more on the right / end side pad.
156+
"""
157+
158+
if length > len(sig):
159+
if fast_len:
160+
length = next_fast_len(length)
161+
npad_total = length - len(sig)
162+
npad_left, npad_right = int(np.floor(npad_total / 2)), int(np.ceil(npad_total / 2))
163+
sig = np.pad(sig, (npad_left, npad_right), mode='constant', constant_values=0)
164+
165+
return sig

neurodsp/tests/spectral/test_power.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,20 @@ def test_compute_spectrum_2d(tsig2d):
5555
assert freqs.shape[-1] == spectrum.shape[-1]
5656
assert spectrum.ndim == 2
5757

58+
def test_compute_spectrum_fft(tsig, tsig_sine):
59+
60+
freqs1, spectrum1 = compute_spectrum_fft(tsig, FS)
61+
assert freqs1.shape == spectrum1.shape
62+
63+
# Test applying a window function
64+
freqs2, spectrum2 = compute_spectrum_fft(tsig, FS, window='hann')
65+
assert freqs2.shape == spectrum2.shape
66+
67+
# Test padding signal
68+
freqs3, spectrum3 = compute_spectrum_fft(tsig, FS, nfft=1.5*len(tsig))
69+
assert freqs3.shape == spectrum3.shape
70+
assert freqs2.shape != freqs3.shape
71+
5872
def test_compute_spectrum_welch(tsig, tsig_sine):
5973

6074
freqs, spectrum = compute_spectrum_welch(tsig, FS, avg_type='mean')
@@ -66,8 +80,7 @@ def test_compute_spectrum_welch(tsig, tsig_sine):
6680
# Use a rectangular window with a width of one period/cycle and no overlap
6781
# The spectrum should just be a dirac spike at the first frequency
6882
window = np.ones(FS)
69-
_, psd_welch = compute_spectrum(tsig_sine, FS, method='welch',
70-
nperseg=FS, noverlap=0, window=window)
83+
_, psd_welch = compute_spectrum_welch(tsig_sine, FS, nperseg=FS, noverlap=0, window=window)
7184

7285
# Spike at frequency 1
7386
assert np.abs(psd_welch[FREQ_SINE] - 0.5) < EPS
@@ -81,7 +94,7 @@ def test_compute_spectrum_welch(tsig, tsig_sine):
8194
assert np.allclose(psd_welch[0:FREQ_SINE], expected_answer, atol=EPS)
8295

8396
# Test zero padding
84-
freqs, spectrum = compute_spectrum(
97+
freqs, spectrum = compute_spectrum_welch(
8598
np.tile(tsig, (2, 1)), FS, nperseg=100, noverlap=0, nfft=1000, f_range=(1, 200)
8699
)
87100
assert np.all(spectrum[0] == spectrum[1])
@@ -99,16 +112,13 @@ def test_compute_spectrum_medfilt(tsig, tsig_sine):
99112
freqs, spectrum = compute_spectrum_medfilt(tsig, FS)
100113
assert freqs.shape == spectrum.shape
101114

102-
# Compute raw estimate of psd using fourier transform
103-
# Only look at the spectrum up to the Nyquist frequency
115+
# Compute raw estimate of psd using FFT
104116
sig_len = len(tsig_sine)
105-
nyq_freq = sig_len//2
106-
sig_ft = np.fft.fft(tsig_sine)[:nyq_freq]
107-
psd = np.abs(sig_ft)**2/(FS * sig_len)
117+
psd = np.abs(np.fft.rfft(tsig_sine))**2 / (FS * sig_len)
108118

109119
# The medfilt here should be taking the median of a window with one sample
110120
# Therefore, it should match the estimate of psd from above
111-
_, psd_medfilt = compute_spectrum(tsig_sine, FS, method='medfilt', filt_len=0.1)
121+
_, psd_medfilt = compute_spectrum_medfilt(tsig_sine, FS, filt_len=0.1)
112122
assert np.allclose(psd, psd_medfilt, atol=EPS)
113123

114124
def test_compute_spectrum_multitaper(tsig_sine, tsig2d):
@@ -126,4 +136,3 @@ def test_compute_spectrum_multitaper(tsig_sine, tsig2d):
126136
idx_freq_sine = np.argmin(np.abs(freqs - FREQ_SINE))
127137
idx_peak = np.argmax(spectrum)
128138
assert idx_freq_sine == idx_peak
129-

neurodsp/tests/spectral/test_utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,25 @@ def test_trim_spectrogram():
4242
f_ext, t_ext, p_ext = trim_spectrogram(freqs, times, pows, f_range=[6, 8], t_range=None)
4343
assert_equal(f_ext, np.array([6, 7, 8]))
4444
assert_equal(t_ext, times)
45+
46+
def test_pad_signal():
47+
48+
# Test case: odd length, even number added per side
49+
length = 5
50+
out1 = pad_signal(np.array([1, 2, 3]), length)
51+
assert len(out1) == length
52+
53+
# Test case: even length, even number added per side
54+
length = 6
55+
out2 = pad_signal(np.array([1, 2]), length)
56+
assert len(out2) == length
57+
58+
# Test case: odd length, uneven number added per side
59+
length = 5
60+
out3 = pad_signal(np.array([1, 2]), length)
61+
assert len(out3) == length
62+
63+
# Test case: even length, uneven number added per side
64+
length = 6
65+
out4 = pad_signal(np.array([1, 2, 3]), length)
66+
assert len(out4) == length

0 commit comments

Comments
 (0)