diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index eb09e561..97eb7698 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,17 +17,18 @@ jobs: python-version: 3.8 - name: Install dependencies run: | + make install-deps-apt python -m pip install --upgrade pip wheel python -m pip install attrdict - # Install wxPython wheels since they are distribution-specific and therefore not on PyPI - # See: https://wxpython.org/pages/downloads/index.html - pip install -U -f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-22.04 wxPython + make install-deps-wxpython + - name: Build project + run: | + make build - pip install . - name: Build docs run: | - cd doc && make html + make docs - name: Deploy Docs uses: peaceiris/actions-gh-pages@v3 if: github.ref == 'refs/heads/master' # TODO: Deploy seperate develop-version of docs? diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae7f9302..678c8b4f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ on: jobs: test: - name: ${{ matrix.os }}, py-${{ matrix.python_version }} + name: test (${{ matrix.os }}, py-${{ matrix.python_version }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -44,29 +44,17 @@ jobs: - name: Install APT dependencies if: "startsWith(runner.os, 'Linux')" run: | - # update archive links - sudo apt-get update - # xvfb is a dependency to create a virtual display - # libgtk-3-dev is a requirement for wxPython - # freeglut3-dev is a requirement for a wxPython dependency - sudo apt-get -y install xvfb libgtk-3-dev freeglut3-dev + make install-deps-apt + - name: Upgrade pip + run: | + python -m pip install --upgrade pip wheel - name: Install Linux dependencies if: "startsWith(runner.os, 'Linux')" run: | - python -m pip install --upgrade pip wheel - - # Install wxPython wheels since they are distribution-specific and therefore not on PyPI - # See: https://wxpython.org/pages/downloads/index.html - pip install -U -f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-22.04 wxPython - - pip install . - - - name: Install MacOS/Windows dependencies + make install-deps-wxpython + - name: Install dependencies run: | - python -m pip install --upgrade pip wheel - pip install . - pip install -U psychtoolbox # JG_ADD - # EB: needed? + make build - name: Run eegnb install test shell: bash run: | @@ -83,9 +71,10 @@ jobs: Xvfb :0 -screen 0 1024x768x24 -ac +extension GLX +render -noreset &> xvfb.log & export DISPLAY=:0 fi - pytest + make test typecheck: + name: typecheck (${{ matrix.os }}, py-${{ matrix.python_version }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -102,27 +91,17 @@ jobs: - name: Install APT dependencies if: "startsWith(runner.os, 'Linux')" run: | - # update archive links - sudo apt-get update - # xvfb is a dependency to create a virtual display - # libgtk-3-dev is a requirement for wxPython - # freeglut3-dev is a requirement for a wxPython dependency - sudo apt-get -y install xvfb libgtk-3-dev freeglut3-dev + make install-deps-apt + - name: Upgrade pip + run: | + python -m pip install --upgrade pip wheel - name: Install Linux dependencies if: "startsWith(runner.os, 'Linux')" run: | - python -m pip install --upgrade pip wheel - - # Install wxPython wheels since they are distribution-specific and therefore not on PyPI - # See: https://wxpython.org/pages/downloads/index.html - pip install -U -f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-22.04 wxPython - - pip install . - - name: Install MacOS/Windows dependencies + make install-deps-wxpython + - name: Install dependencies run: | - python -m pip install --upgrade pip wheel - pip install . + make build - name: Typecheck run: | - # Exclude visual_cueing due to errors - python -m mypy --exclude 'examples/visual_cueing' + make typecheck diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..29e53347 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +build: + pip install . + +test: + pytest + +typecheck: + # Exclude visual_cueing due to errors + python -m mypy --exclude 'examples/visual_cueing' + +docs: + cd doc && make html + +clean: + cd doc && make clean + +install-deps-apt: + sudo apt-get update # update archive links + + # xvfb is a dependency to create a virtual display + # libgtk-3-dev is a requirement for wxPython + # freeglut3-dev is a requirement for a wxPython dependency + # portaudio19-dev *might* be required to import psychopy on Ubuntu + # pulseaudio *might* be required to actually run the tests (on PsychoPy import) + # libpulse-dev required to build pocketsphinx (speech recognition dependency of psychopy) + # libsdl2-dev required by psychopy + # libnotify4 is so we can have the libnotify.so module used in wxPython working + sudo apt-get -y install xvfb libgtk-3-dev freeglut3-dev portaudio19-dev libpulse-dev pulseaudio libsdl2-dev libnotify4 + + # configure dynamic links + sudo ldconfig + + UPDATED_LIBPATH=$(sudo find / -name libnotify.so) + LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$UPDATED_LIBPATH + +install-deps-wxpython: + # Install wxPython wheels since they are distribution-specific and therefore not on PyPI + # See: https://wxpython.org/pages/downloads/index.html + pip install -U -f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-22.04 wxPython diff --git a/doc/conf.py b/doc/conf.py index 426a57b2..d4503b43 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -284,7 +284,7 @@ def setup(app): 'backreferences_dir': 'generated', # Where to drop linking files between examples & API 'doc_module': ('eeg-notebooks',), 'reference_url': {'eeg-notebooksS': None}, - 'remove_conffig_comments': True, + 'remove_config_comments': True, } """ diff --git a/eegnb/analysis/utils.py b/eegnb/analysis/utils.py index 4a0fa53d..67338ef0 100644 --- a/eegnb/analysis/utils.py +++ b/eegnb/analysis/utils.py @@ -2,6 +2,7 @@ from copy import deepcopy import math import logging +import sys from collections import OrderedDict from glob import glob from typing import Union, List#, Dict @@ -20,6 +21,7 @@ from mne.channels import make_standard_montage from mne.filter import create_filter from matplotlib import pyplot as plt +from scipy import stats from scipy.signal import lfilter, lfilter_zi from eegnb import _get_recording_dir @@ -221,7 +223,8 @@ def plot_conditions( ylim=(-6, 6), diff_waveform=(1, 2), channel_count=4, - channel_order=None): + channel_order=None, +): """Plot ERP conditions. Args: epochs (mne.epochs): EEG epochs @@ -247,10 +250,9 @@ def plot_conditions( """ if channel_order: - channel_order = np.array(channel_order) + channel_order = np.array(channel_order) else: - channel_order = np.array(range(channel_count)) - + channel_order = np.array(range(channel_count)) if isinstance(conditions, dict): conditions = OrderedDict(conditions) @@ -260,7 +262,7 @@ def plot_conditions( X = epochs.get_data() * 1e6 - X = X[:,channel_order] + X = X[:, channel_order] times = epochs.times y = pd.Series(epochs.events[:, -1]) diff --git a/eegnb/analysis/utils_old.py b/eegnb/analysis/utils_old.py deleted file mode 100755 index 16f01ffe..00000000 --- a/eegnb/analysis/utils_old.py +++ /dev/null @@ -1,294 +0,0 @@ -# -*- coding: utf-8 -*- - -from glob import glob -import os -from collections import OrderedDict - -from mne import create_info, concatenate_raws -from mne.io import RawArray -from mne.channels import make_standard_montage -import pandas as pd -import numpy as np -import seaborn as sns -from matplotlib import pyplot as plt - - -sns.set_context("talk") -sns.set_style("white") - - -def load_muse_csv_as_raw( - filename, - sfreq=256.0, - ch_ind=[0, 1, 2, 3], - stim_ind=5, - replace_ch_names=None, - verbose=1, -): - """Load CSV files into a Raw object. - - Args: - filename (str or list): path or paths to CSV files to load - - Keyword Args: - subject_nb (int or str): subject number. If 'all', load all - subjects. - session_nb (int or str): session number. If 'all', load all - sessions. - sfreq (float): EEG sampling frequency - ch_ind (list): indices of the EEG channels to keep - stim_ind (int): index of the stim channel - replace_ch_names (dict or None): dictionary containing a mapping to - rename channels. Useful when an external electrode was used. - - Returns: - (mne.io.array.array.RawArray): loaded EEG - """ - n_channel = len(ch_ind) - - raw = [] - print(filename) - for fname in filename: - print(fname) - # read the file - data = pd.read_csv(fname, index_col=0) - - # name of each channels - ch_names = list(data.columns)[0:n_channel] + ["Stim"] - - if replace_ch_names is not None: - ch_names = [ - c if c not in replace_ch_names.keys() else replace_ch_names[c] - for c in ch_names - ] - - # type of each channels - ch_types = ["eeg"] * n_channel + ["stim"] - montage = make_standard_montage("standard_1005") - - # get data and exclude Aux channel - data = data.values[:, ch_ind + [stim_ind]].T - - # convert in Volts (from uVolts) - data[:-1] *= 1e-6 - - # create MNE object - info = create_info( - ch_names=ch_names, - ch_types=ch_types, - sfreq=sfreq, - montage=montage, - verbose=verbose, - ) - raw.append(RawArray(data=data, info=info, verbose=verbose)) - - # concatenate all raw objects - print("raw is") - print(raw) - raws = concatenate_raws(raw, verbose=verbose) - - return raws - - -def load_data( - data_dir, - site="eegnb_examples", - experiment="visual_n170", - device="muse2016", - subject_nb=1, - session_nb=1, - sfreq=256.0, - ch_ind=[0, 1, 2, 3], - stim_ind=5, - replace_ch_names=None, - verbose=1, -): - - """Load CSV files from the /data directory into a Raw object. - - Args: - data_dir (str): directory inside /data that contains the - CSV files to load, e.g., 'auditory/P300' - - Keyword Args: - subject_nb (int or str): subject number. If 'all', load all - subjects. - session_nb (int or str): session number. If 'all', load all - sessions. - sfreq (float): EEG sampling frequency - ch_ind (list): indices of the EEG channels to keep - stim_ind (int): index of the stim channel - replace_ch_names (dict or None): dictionary containing a mapping to - rename channels. Useful when an external electrode was used. - - Returns: - (mne.io.array.array.RawArray): loaded EEG - """ - if subject_nb == "all": - subject_nb = "*" - if session_nb == "all": - session_nb = "*" - - subject_nb_str = "%04.f" % subject_nb - session_nb_str = "%03.f" % session_nb - subsess = "subject{}/session{}/*.csv".format(subject_nb_str, session_nb_str) - data_path = os.path.join(data_dir, experiment, site, device, subsess) - fnames = glob(data_path) - - return load_muse_csv_as_raw( - fnames, - sfreq=sfreq, - ch_ind=ch_ind, - stim_ind=stim_ind, - replace_ch_names=replace_ch_names, - verbose=verbose, - ) - - -def plot_conditions( - epochs, - conditions=OrderedDict(), - ci=97.5, - n_boot=1000, - title="", - palette=None, - ylim=(-6, 6), - diff_waveform=(1, 2), -): - """Plot ERP conditions. - - Args: - epochs (mne.epochs): EEG epochs - - Keyword Args: - conditions (OrderedDict): dictionary that contains the names of the - conditions to plot as keys, and the list of corresponding marker - numbers as value. E.g., - - conditions = {'Non-target': [0, 1], - 'Target': [2, 3, 4]} - - ci (float): confidence interval in range [0, 100] - n_boot (int): number of bootstrap samples - title (str): title of the figure - palette (list): color palette to use for conditions - ylim (tuple): (ymin, ymax) - diff_waveform (tuple or None): tuple of ints indicating which - conditions to subtract for producing the difference waveform. - If None, do not plot a difference waveform - - Returns: - (matplotlib.figure.Figure): figure object - (list of matplotlib.axes._subplots.AxesSubplot): list of axes - """ - if isinstance(conditions, dict): - conditions = OrderedDict(conditions) - - if palette is None: - palette = sns.color_palette("hls", len(conditions) + 1) - - X = epochs.get_data() * 1e6 - times = epochs.times - y = pd.Series(epochs.events[:, -1]) - - fig, axes = plt.subplots(2, 2, figsize=[12, 6], sharex=True, sharey=True) - axes = [axes[1, 0], axes[0, 0], axes[0, 1], axes[1, 1]] - - for ch in range(4): - for cond, color in zip(conditions.values(), palette): - sns.tsplot( - X[y.isin(cond), ch], - time=times, - color=color, - n_boot=n_boot, - ci=ci, - ax=axes[ch], - ) - - if diff_waveform: - diff = np.nanmean(X[y == diff_waveform[1], ch], axis=0) - np.nanmean( - X[y == diff_waveform[0], ch], axis=0 - ) - axes[ch].plot(times, diff, color="k", lw=1) - - axes[ch].set_title(epochs.ch_names[ch]) - axes[ch].set_ylim(ylim) - axes[ch].axvline( - x=0, ymin=ylim[0], ymax=ylim[1], color="k", lw=1, label="_nolegend_" - ) - - axes[0].set_xlabel("Time (s)") - axes[0].set_ylabel("Amplitude (uV)") - axes[-1].set_xlabel("Time (s)") - axes[1].set_ylabel("Amplitude (uV)") - - if diff_waveform: - legend = ["{} - {}".format(diff_waveform[1], diff_waveform[0])] + list( - conditions.keys() - ) - else: - legend = conditions.keys() - axes[-1].legend(legend, loc="lower right") - sns.despine() - plt.tight_layout() - - if title: - fig.suptitle(title, fontsize=20) - - return fig, axes - - -def plot_highlight_regions( - x, y, hue, hue_thresh=0, xlabel="", ylabel="", legend_str=() -): - """Plot a line with highlighted regions based on additional value. - - Plot a line and highlight ranges of x for which an additional value - is lower than a threshold. For example, the additional value might be - pvalues, and the threshold might be 0.05. - - Args: - x (array_like): x coordinates - y (array_like): y values of same shape as `x` - - Keyword Args: - hue (array_like): values to be plotted as hue based on `hue_thresh`. - Must be of the same shape as `x` and `y`. - hue_thresh (float): threshold to be applied to `hue`. Regions for which - `hue` is lower than `hue_thresh` will be highlighted. - xlabel (str): x-axis label - ylabel (str): y-axis label - legend_str (tuple): legend for the line and the highlighted regions - - Returns: - (matplotlib.figure.Figure): figure object - (list of matplotlib.axes._subplots.AxesSubplot): list of axes - """ - fig, axes = plt.subplots(1, 1, figsize=(10, 5), sharey=True) - - axes.plot(x, y, lw=2, c="k") - plt.xlabel(xlabel) - plt.ylabel(ylabel) - - kk = 0 - a = [] - while kk < len(hue): - if hue[kk] < hue_thresh: - b = kk - kk += 1 - while kk < len(hue): - if hue[kk] > hue_thresh: - break - else: - kk += 1 - a.append([b, kk - 1]) - else: - kk += 1 - - st = (x[1] - x[0]) / 2.0 - for p in a: - axes.axvspan(x[p[0]] - st, x[p[1]] + st, facecolor="g", alpha=0.5) - plt.legend(legend_str) - sns.despine() - - return fig, axes diff --git a/eegnb/cli/__main__.py b/eegnb/cli/__main__.py index 9a196e8e..bdd43bb8 100644 --- a/eegnb/cli/__main__.py +++ b/eegnb/cli/__main__.py @@ -1,9 +1,7 @@ -from eegnb import DATA_DIR import click -from time import sleep -from os import path import os -import shutil + +from eegnb import DATA_DIR from eegnb.datasets.datasets import zip_data_folders from .introprompt import intro_prompt, analysis_intro_prompt diff --git a/eegnb/datasets/datasets.py b/eegnb/datasets/datasets.py index 3fc7ae87..21add031 100644 --- a/eegnb/datasets/datasets.py +++ b/eegnb/datasets/datasets.py @@ -1,11 +1,17 @@ -import os,sys,glob,shutil,numpy as np, pandas as pd -import requests, zipfile,gdown +import os +import glob +import shutil +import zipfile + +import requests +import gdown + from datetime import datetime from eegnb import DATA_DIR # eegnb example data sites. do not select these when zipping recordings -eegnb_sites = ['eegnb_examples', 'grifflab_dev', 'jadinlab_home'] +eegnb_sites = ["eegnb_examples", "grifflab_dev", "jadinlab_home"] def fetch_dataset( @@ -64,7 +70,7 @@ def fetch_dataset( } # If no non-default top-level data path specified, use default - if data_dir == None: + if data_dir is None: data_dir = DATA_DIR # check parameter entries @@ -85,12 +91,9 @@ def fetch_dataset( destination = os.path.join(data_dir, "downloaded_data.zip") if download_method == "gdown": - URL = "https://drive.google.com/uc?id=" + gdrive_locs[experiment] gdown.download(URL, destination, quiet=False) - elif download_method == "requests": - URL = "https://docs.google.com/uc?export=download" session = requests.Session() @@ -114,6 +117,8 @@ def fetch_dataset( for chunk in response.iter_content(CHUNK_SIZE): if chunk: f.write(chunk) + else: + raise ValueError("download_method not supported") # unzip the file with zipfile.ZipFile(destination, "r") as zip_ref: @@ -160,9 +165,7 @@ def fetch_dataset( return fnames - -def zip_data_folders(experiment: str, - site: str="local"): +def zip_data_folders(experiment: str, site: str = "local"): """ Run data zipping diff --git a/eegnb/experiments/visual_vep/vep.py b/eegnb/experiments/visual_vep/vep.py index fd28844e..9cfcef96 100644 --- a/eegnb/experiments/visual_vep/vep.py +++ b/eegnb/experiments/visual_vep/vep.py @@ -1,10 +1,11 @@ from time import time, strftime, gmtime from pylsl import StreamInfo, StreamOutlet -from eegnb.experiments.Experiment import Experiment from typing import Optional +from eegnb.experiments import Experiment +from eegnb.devices.eeg import EEG -class VisualVEP(Experiment): +class VisualVEP(Experiment.BaseExperiment): def __init__(self, duration=120, eeg: Optional[EEG]=None, save_fn=None, n_trials = 2000, iti = 0.2, soa = 0.2, jitter = 0.1): diff --git a/requirements.txt b/requirements.txt index ec501766..145d58ac 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ scikit-learn>=0.23.2 pandas>=1.1.4 numpy>=1.19.4,<1.24 # due to outdated libs not changing the names after: https://github.com/numpy/numpy/pull/22607 mne>=0.20.8 -seaborn>=0.9.0 +seaborn>=0.11.0 pyriemann>=0.2.7 jupyter muselsl>=2.0.2 @@ -28,9 +28,6 @@ attrdict3 wxPython>=4.0 ; platform_system == "Linux" click -# can be removed once minimum version is Python 3.7 -dataclasses; python_version == '3.6' - # pywinhook needs some special treatment since there are only wheels on PyPI for Python 3.7-3.8, and building requires special tools (swig, VS C++ tools) # See issue: https://github.com/NeuroTechX/eeg-notebooks/issues/29 pywinhook>=1.6.0 ; platform_system == "Windows" and (python_version == "3.7" or python_version == "3.8")