Episode 11: Band Power
Reduce a spectrum to a handful of named bands with BandPower, each output labelled, so a decision can ask for “alpha” by name.
A spectrum has one number per frequency bin, which is far more than any decision needs. Almost every online BCI reduces it to a handful of named bands first, how much alpha, how much beta, and works with those.
A 10 Hz sine, squarely inside the alpha band, and an FFT with a window of 250 samples: one second at 250 Hz, so the bins are 1 Hz apart.
16 p = gp.Pipeline()
17 source = gp.Generator(sampling_rate=SAMPLING_RATE, channel_count=1,
18 signal_frequency=SIGNAL_HZ, signal_amplitude=1.0,
19 noise_amplitude=0.1)
20
21 spectrum = gp.FFT(window_size=WINDOW)
BandPower sits after the FFT and emits one value per band per channel: the band’s power, the sum of the squared spectrum amplitudes over its bins: in the input’s unit squared, µV² for EEG, as amplitude squared, so a sine of amplitude A reports A², and not a density per hertz. With no argument it takes the five conventional EEG bands, delta, theta, alpha, beta and gamma (30 to 45 Hz, the low gamma band); bands=[[8, 12]] gives explicit ranges, and names and ranges mix.
22 # No argument: the five conventional EEG bands. Or bands=[[8, 12]]
23 power = gp.BandPower()
24 collected = gp.Collector(name="band_power")
25
26 p.connect(source, spectrum)
27 p.connect(spectrum, power)
28 p.connect(power, collected)
Run the script. The mean over the windows that arrived, one line per band, the largest marked:
a 10 Hz sine: the power should sit in alpha
Ch01:delta ...
Ch01:theta ...
Ch01:alpha ... <--
Ch01:beta ...
Ch01:gamma ...
The labels are the point. Each output names itself, so a CsvWriter downstream writes a header that is still readable a year later, and a node selecting a feature can ask for it by name instead of by column index.
window_size sets the frequency resolution, and, with the default overlap of 0.5, how often an answer appears: every half window, 125 samples here. An application cannot react faster than that.
In this episode, you learned how to:
reduce a spectrum to named bands with BandPower
pick the built-in bands or state your own ranges
read the FFT’s window as both resolution and update rate
Proceed to Episode 12, where a Threshold turns a feature into a decision.
Nodes in this episode: Generator, FFT, BandPower, Collector
File s2e11_band_power.py – View file on GitHub
1"""Season 2, Episode 11: BandPower reduces a spectrum to a few named bands."""
2
3import time
4
5import numpy as np
6
7import gpype as gp
8
9SAMPLING_RATE = 250
10WINDOW = 250 # one second, so the bins are 1 Hz apart
11SIGNAL_HZ = 10 # squarely inside alpha (8-13 Hz)
12
13
14if __name__ == "__main__":
15
16 p = gp.Pipeline()
17 source = gp.Generator(sampling_rate=SAMPLING_RATE, channel_count=1,
18 signal_frequency=SIGNAL_HZ, signal_amplitude=1.0,
19 noise_amplitude=0.1)
20
21 spectrum = gp.FFT(window_size=WINDOW)
22 # No argument: the five conventional EEG bands. Or bands=[[8, 12]]
23 power = gp.BandPower()
24 collected = gp.Collector(name="band_power")
25
26 p.connect(source, spectrum)
27 p.connect(spectrum, power)
28 p.connect(power, collected)
29
30 p.start()
31 time.sleep(3.0)
32 p.stop()
33 p.close()
34
35 result = collected.result
36 print(f"a {SIGNAL_HZ} Hz sine: the power should sit in alpha")
37 if result is None:
38 print("nothing was collected")
39 else:
40 # The mean over the windows that arrived, so one noisy window
41 # does not decide the answer
42 block = result.data
43 mean = np.asarray(block).reshape(len(block), -1).mean(axis=0)
44 labels = result.labels or [f"col{i}" for i in range(len(mean))]
45 biggest = int(np.argmax(mean))
46 for i, (label, value) in enumerate(zip(labels, mean)):
47 mark = " <--" if i == biggest else ""
48 print(f" {label:<24} {value:10.4f}{mark}")