Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 29 additions & 28 deletions eegnb/devices/eeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@
import numpy as np
import pandas as pd

from brainflow import BoardShim, BoardIds, BrainFlowInputParams
from brainflow.board_shim import BoardShim, BoardIds, BrainFlowInputParams
from muselsl import stream, list_muses, record, constants as mlsl_cnsts
from pylsl import StreamInfo, StreamOutlet, StreamInlet, resolve_byprop

from eegnb.devices.utils import get_openbci_usb, create_stim_array,SAMPLE_FREQS,EEG_INDICES,EEG_CHANNELS
from eegnb.devices.utils import (
get_openbci_usb,
create_stim_array,
SAMPLE_FREQS,
EEG_INDICES,
EEG_CHANNELS,
)


logger = logging.getLogger(__name__)
Expand All @@ -39,18 +45,19 @@
"notion2",
"freeeeg32",
"crown",
"museS_bfn", # bfn = brainflow with native bluetooth;
"museS_bfb", # bfb = brainflow with BLED dongle bluetooth
"museS_bfn", # bfn = brainflow with native bluetooth;
"museS_bfb", # bfb = brainflow with BLED dongle bluetooth
"muse2_bfn",
"muse2_bfb",
"muse2016_bfn",
"muse2016_bfb"
"muse2016_bfb",
]


class EEG:
device_name: str
stream_started: bool = False

def __init__(
self,
device=None,
Expand Down Expand Up @@ -85,8 +92,8 @@ def initialize_backend(self):
self.timestamp_channel = BoardShim.get_timestamp_channel(self.brainflow_id)
elif self.backend == "muselsl":
self._init_muselsl()
self._muse_get_recent() # run this at initialization to get some
# stream metadata into the eeg class
self._muse_get_recent() # run this at initialization to get some
# stream metadata into the eeg class

def _get_backend(self, device_name):
if device_name in brainflow_devices:
Expand Down Expand Up @@ -126,7 +133,7 @@ def _start_muse(self, duration):
print("will save to file: %s" % self.save_fn)
self.recording = Process(target=record, args=(duration, self.save_fn))
self.recording.start()

time.sleep(5)
self.stream_started = True
self.push_sample([99], timestamp=time.time())
Expand All @@ -137,7 +144,7 @@ def _stop_muse(self):
def _muse_push_sample(self, marker, timestamp):
self.muse_StreamOutlet.push_sample(marker, timestamp)

def _muse_get_recent(self, n_samples: int=256, restart_inlet: bool=False):
def _muse_get_recent(self, n_samples: int = 256, restart_inlet: bool = False):
if self._muse_recent_inlet and not restart_inlet:
inlet = self._muse_recent_inlet
else:
Expand All @@ -157,9 +164,8 @@ def _muse_get_recent(self, n_samples: int=256, restart_inlet: bool=False):
self.info = info
self.n_chans = n_chans

timeout = (n_samples/sfreq)+0.5
samples, timestamps = inlet.pull_chunk(timeout=timeout,
max_samples=n_samples)
timeout = (n_samples / sfreq) + 0.5
samples, timestamps = inlet.pull_chunk(timeout=timeout, max_samples=n_samples)

samples = np.array(samples)
timestamps = np.array(timestamps)
Expand Down Expand Up @@ -260,13 +266,13 @@ def _init_brainflow(self):

elif self.device_name == "muse2_bfn":
self.brainflow_id = BoardIds.MUSE_2_BOARD.value

elif self.device_name == "muse2_bfb":
self.brainflow_id = BoardIds.MUSE_2_BLED_BOARD.value

elif self.device_name == "muse2016_bfn":
self.brainflow_id = BoardIds.MUSE_2016_BOARD.value

elif self.device_name == "muse2016_bfb":
self.brainflow_id = BoardIds.MUSE_2016_BLED_BOARD.value

Expand All @@ -291,11 +297,13 @@ def _start_brainflow(self):
# only start stream if non exists
if not self.stream_started:
self.board.start_stream()

self.stream_started = True

# wait for signal to settle
if (self.device_name.find("cyton") != -1) or (self.device_name.find("ganglion") != -1):
if (self.device_name.find("cyton") != -1) or (
self.device_name.find("ganglion") != -1
):
# wait longer for openbci cyton / ganglion
sleep(10)
else:
Expand All @@ -314,21 +322,19 @@ def _stop_brainflow(self):

# Create a column for the stimuli to append to the EEG data
stim_array = create_stim_array(timestamps, self.markers)
timestamps = timestamps[ ..., None ]
timestamps = timestamps[..., None]

# Add an additional dimension so that shapes match
total_data = np.append(timestamps, eeg_data, 1)

# Append the stim array to data.
total_data = np.append(total_data, stim_array, 1)
total_data = np.append(total_data, stim_array, 1)

# Subtract five seconds of settling time from beginning
total_data = total_data[5 * self.sfreq :]
data_df = pd.DataFrame(total_data, columns=["timestamps"] + ch_names + ["stim"])
data_df.to_csv(self.save_fn, index=False)



def _brainflow_extract(self, data):
"""
Formats the data returned from brainflow to get
Expand Down Expand Up @@ -357,14 +363,12 @@ def _brainflow_extract(self, data):
eeg_data = data[:, BoardShim.get_eeg_channels(self.brainflow_id)]
timestamps = data[:, BoardShim.get_timestamp_channel(self.brainflow_id)]

return ch_names,eeg_data,timestamps

return ch_names, eeg_data, timestamps

def _brainflow_push_sample(self, marker):
last_timestamp = self.board.get_current_board_data(1)[self.timestamp_channel][0]
self.markers.append([marker, last_timestamp])


def _brainflow_get_recent(self, n_samples=256):

# initialize brainflow if not set
Expand Down Expand Up @@ -405,7 +409,6 @@ def start(self, fn, duration=None):
elif self.backend == "muselsl":
self._start_muse(duration)


def push_sample(self, marker, timestamp):
"""
Universal method for pushing a marker and its timestamp to store alongside the EEG data.
Expand Down Expand Up @@ -445,6 +448,4 @@ def get_recent(self, n_samples: int = 256):
sorted_cols = sorted(df.columns)
df = df[sorted_cols]


return df

14 changes: 7 additions & 7 deletions eegnb/devices/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import platform
import serial

from brainflow import BoardShim, BoardIds
from brainflow.board_shim import BoardShim, BoardIds


# Default channel names for the various EEG devices.
EEG_CHANNELS = {
"muse2016": ['TP9', 'AF7', 'AF8', 'TP10', 'Right AUX'],
"muse2": ['TP9', 'AF7', 'AF8', 'TP10', 'Right AUX'],
"museS": ['TP9', 'AF7', 'AF8', 'TP10', 'Right AUX'],
"muse2016": ["TP9", "AF7", "AF8", "TP10", "Right AUX"],
"muse2": ["TP9", "AF7", "AF8", "TP10", "Right AUX"],
"museS": ["TP9", "AF7", "AF8", "TP10", "Right AUX"],
"muse2016_bfn": BoardShim.get_eeg_names(BoardIds.MUSE_2016_BOARD.value),
"muse2016_bfb": BoardShim.get_eeg_names(BoardIds.MUSE_2016_BLED_BOARD.value),
"muse2_bfn": BoardShim.get_eeg_names(BoardIds.MUSE_2_BOARD.value),
Expand All @@ -26,7 +26,7 @@
"notion1": BoardShim.get_eeg_names(BoardIds.NOTION_1_BOARD.value),
"notion2": BoardShim.get_eeg_names(BoardIds.NOTION_2_BOARD.value),
"crown": BoardShim.get_eeg_names(BoardIds.CROWN_BOARD.value),
"freeeeg32": [f'eeg_{i}' for i in range(0,32)],
"freeeeg32": [f"eeg_{i}" for i in range(0, 32)],
}

BRAINFLOW_CHANNELS = {
Expand Down Expand Up @@ -62,9 +62,9 @@
"muse2016": 256,
"muse2": 256,
"museS": 256,
"muse2016_bfn": BoardShim.get_sampling_rate(BoardIds.MUSE_2016_BOARD.value),
"muse2016_bfn": BoardShim.get_sampling_rate(BoardIds.MUSE_2016_BOARD.value),
"muse2016_bfb": BoardShim.get_sampling_rate(BoardIds.MUSE_2016_BLED_BOARD.value),
"muse2_bfn": BoardShim.get_sampling_rate(BoardIds.MUSE_2_BOARD.value),
"muse2_bfn": BoardShim.get_sampling_rate(BoardIds.MUSE_2_BOARD.value),
"muse2_bfb": BoardShim.get_sampling_rate(BoardIds.MUSE_2_BLED_BOARD.value),
"museS_bfn": BoardShim.get_sampling_rate(BoardIds.MUSE_S_BOARD.value),
"museS_bfb": BoardShim.get_sampling_rate(BoardIds.MUSE_S_BLED_BOARD.value),
Expand Down
133 changes: 133 additions & 0 deletions eegnb/experiments/Experiment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
Initial run of the Experiment Class Refactor base class
Specific experiments are implemented as sub classes that overload a load_stimulus and present_stimulus method
Running each experiment:
obj = VisualP300({parametrs})
obj.present()
"""

from abc import ABC, abstractmethod
from psychopy import prefs
#change the pref libraty to PTB and set the latency mode to high precision
prefs.hardware['audioLib'] = 'PTB'
prefs.hardware['audioLatencyMode'] = 3
Comment on lines +14 to +15
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these safe to set globally? @JohnGriffiths

Feels like we set them like this in more places, in which case they would conflict.

Edit: Indeed, but looks like they all set the same values? Should prob be consolidated. All hits: https://github.com/NeuroTechX/eeg-notebooks/search?q=prefs.hardware


import os
from time import time
from glob import glob
from random import choice
from optparse import OptionParser
import random

import numpy as np
from pandas import DataFrame
from psychopy import visual, core, event

from eegnb import generate_save_fn
from eegnb.devices.eeg import EEG

class Experiment:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see comment below. I think

class ExperimentBase

or

class BaseExperiment

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the other experiments are named P300Experiment for example, then it should prob be BaseExperiment

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you guys, gonna change it to BaseExperiment
I faced some namespace errors when trying to get rid of the redundant imports so this would need to be done anyway.


def __init__(self, exp_name, duration, eeg, save_fn, n_trials, iti, soa, jitter):
""" Anything that must be passed as a minimum for the experiment should be initialized here """

self.exp_name = exp_name
self.instruction_text = """\nWelcome to the {} experiment!\nStay still, focus on the centre of the screen, and try not to blink. \nThis block will run for %s seconds.\n
Press spacebar to continue. \n""".format(self.exp_name)
self.duration = duration
self.eeg = eeg
self.save_fn = save_fn
self.n_trials = n_trials
self.iti = iti
self.soa = soa
self.jitter = jitter

@abstractmethod
def load_stimulus(self):
""" Needs to be overwritten by specific experiment """
raise NotImplementedError

@abstractmethod
def present_stimulus(self):
raise NotImplementedError

def setup(self):

self.record_duration = np.float32(self.duration)
self.markernames = [1, 2]

# Setup Trial list -> Common in most (csv in Unicorn)
self.parameter = np.random.binomial(1, 0.5, self.n_trials)
self.trials = DataFrame(dict(parameter=self.parameter, timestamp=np.zeros(self.n_trials)))

# Setup Graphics
self.mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True)

# Needs to be overwritten by specific experiment
self.stim = self.load_stimulus()

# Show Instruction Screen
self.show_instructions()

if self.eeg:
if save_fn is None: # If no save_fn passed, generate a new unnamed save file
random_id = random.randint(1000,10000)
self.save_fn = generate_save_fn(self.eeg.device_name, "visual_n170", random_id, random_id, "unnamed")
print(
f"No path for a save file was passed to the experiment. Saving data to {self.save_fn}"
)

def present(self):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually don't like the name present for this method. Never did.

I think this 'run experiment' method should be

def run(self):

this would also help avoid any confusion with the present_stimulus method

""" Do the present operation for a bunch of experiments """

self.setup()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, let's add an argument here so that instructions can optionally be skipped. Default behaviour should be to show however.


# Start EEG Stream, wait for signal to settle, and then pull timestamp for start point
if self.eeg:
self.eeg.start(self.save_fn, duration=self.record_duration + 5)

start = time()

# Iterate through the events
for ii, trial in self.trials.iterrows():

# Intertrial interval
core.wait(self.iti + np.random.rand() * self.jitter)

# Some form of presenting the stimulus - sometimes order changed in lower files like ssvep
self.present_stimulus(ii)

# Offset
core.wait(self.soa)
self.mywin.flip()
if len(event.getKeys()) > 0 or (time() - start) > self.record_duration:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also as discussed, this part (of the original code) has always looked a bit ugly to me. The alternative is to pre-calculate all of the itis, soas, jitters in advance, and then it will be known before the loop starts what the duration will be, and so the decision of whether or it is the duration param or the completion of the trial set will be clear and not determined on the fly at the end.

However, I suggest we have a stab at this small adjustment at a later point, it isn't the focus or priority of the current PR.

break
event.clearEvents()

# Close the EEG stream
if self.eeg:
self.eeg.stop()

# Close the window
self.mywin.close()


def show_instructions(self):

self.instruction_text = self.instruction_text % self.duration

# graphics
#mywin = visual.Window([1600, 900], monitor="testMonitor", units="deg", fullscr=True)
self.mywin.mouseVisible = False
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name it window instead of mywin

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


# Instructions
text = visual.TextStim(win=self.mywin, text=self.instruction_text, color=[-1, -1, -1])
text.draw()
self.mywin.flip()
event.waitKeys(keyList="space")

self.mywin.mouseVisible = True

13 changes: 13 additions & 0 deletions eegnb/experiments/Experiment_readme.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@


Looking for a general implementation structure where base class implements and passes the following functions,

def load_stimulus() -> stim (some form of dd array)

def present_stimulus() -> given trial details does specific thing for experiment

** Slight issue is that a lot of parameters will have to be passed which is not the best in practice

Stuff that can be overwritten in general ...
instruction_text
parameter/trial
Loading