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 spectrum first

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)
Then the bands

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)
Result

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.

Two knobs, both the FFT’s

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.

Summary

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}")