Skip to content
Open
32 changes: 32 additions & 0 deletions .github/actions/setup-conda-env/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Set up conda env
description: >
Install Miniconda and create/activate an EEG-ExPy conda environment from
the given env yml. Shared by the Test and Typecheck jobs so the two
don't drift apart. Environment name is not set in the yml files so local
installs can use any name they like.

inputs:
environment-file:
required: true
description: Path to the conda environment yml file to install from.
activate-environment:
required: true
description: Name to give the created environment.
python-version:
required: false
description: >
Python version to pin (e.g. '3.8'). Overrides the version conda would
otherwise resolve from the environment file's constraints. When omitted,
conda resolves freely within the environment file's range.

runs:
using: composite
steps:
- uses: conda-incubator/setup-miniconda@v3
with:
environment-file: ${{ inputs.environment-file }}
activate-environment: ${{ inputs.activate-environment }}
python-version: ${{ inputs.python-version }}
auto-activate-base: false
channels: conda-forge
miniconda-version: "latest"
37 changes: 12 additions & 25 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,20 @@ on:
jobs:
build:
runs-on: ubuntu-22.04
defaults:
run:
shell: bash -el {0}
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.8

- name: Install dependencies
run: |
make install-deps-apt
python -m pip install --upgrade pip wheel
python -m pip install attrdict

make install-deps-wxpython

- name: Build project
run: |
make install-docs-build-dependencies
fetch-depth: 0

- name: Set up conda env
uses: ./.github/actions/setup-conda-env
with:
environment-file: environments/eeg-expy-docsbuild.yml
activate-environment: eeg-expy-docsbuild

- name: Get list of changed files
id: changes
Expand All @@ -40,21 +31,20 @@ jobs:
git diff --name-only origin/master...HEAD > changed_files.txt
cat changed_files.txt


- name: Determine build mode
id: mode
run: |
if grep -vqE '^examples/.*\.py$' changed_files.txt; then
echo "FULL_BUILD=true" >> $GITHUB_ENV
echo "Detected non-example file change. Full build triggered."
else
CHANGED_EXAMPLES=$(grep '^examples/.*\.py$' changed_files.txt | paste -sd '|' -)
# || true prevents grep's exit code 1 (no matches) from aborting the step
CHANGED_EXAMPLES=$(grep '^examples/.*\.py$' changed_files.txt | paste -sd '|' - || true)
echo "FULL_BUILD=false" >> $GITHUB_ENV
echo "CHANGED_EXAMPLES=$CHANGED_EXAMPLES" >> $GITHUB_ENV
echo "Changed examples: $CHANGED_EXAMPLES"
fi


- name: Cache built documentation
id: cache-docs
uses: actions/cache@v4
Expand All @@ -65,12 +55,9 @@ jobs:
restore-keys: |
${{ runner.os }}-sphinx-


- name: Build docs
run: |
make docs
run: make docs


- name: Deploy Docs
uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/master' # TODO: Deploy seperate develop-version of docs?
Expand Down
58 changes: 32 additions & 26 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,38 +13,47 @@ jobs:
defaults:
run:
shell: bash -el {0}
continue-on-error: ${{ matrix.experimental == true }}
strategy:
fail-fast: false
matrix:
os: ['ubuntu-22.04', windows-latest, macOS-latest]
python_version: ['3.8']
os: [ubuntu-22.04, windows-latest, macOS-latest]
python_version: ['3.10']
env_file: [environments/eeg-expy-full.yml]
env_name: [eeg-expy-full]
include:
# PsychoPy currently restricted to <= 3.10
# Experimental Full Build: Catch regressions on 3.11 early
- os: ubuntu-22.04
python_version: '3.10'
python_version: '3.11'
experimental: true
env_file: environments/eeg-expy-full.yml
env_name: eeg-expy-full

# Experimental Streaming Builds: Verify acquisition/analysis on 3.12+
- os: ubuntu-22.04
python_version: '3.12'
experimental: true
env_file: environments/eeg-expy-streaming.yml
env_name: eeg-expy-streaming
- os: ubuntu-22.04
python_version: '3.13'
experimental: true
env_file: environments/eeg-expy-streaming.yml
env_name: eeg-expy-streaming

steps:
- uses: actions/checkout@v2
- name: Install APT dependencies
if: "startsWith(runner.os, 'Linux')"
run: |
make install-deps-apt
- name: Install conda
uses: conda-incubator/setup-miniconda@v3
- name: Set up conda env
uses: ./.github/actions/setup-conda-env
with:
environment-file: environments/eeg-expy-full.yml
auto-activate-base: false
environment-file: ${{ matrix.env_file }}
activate-environment: ${{ matrix.env_name }}
python-version: ${{ matrix.python_version }}
activate-environment: eeg-expy-full
channels: conda-forge
miniconda-version: "latest"

- name: Fix PsychXR numpy dependency DLL issues (Windows only)
if: matrix.os == 'windows-latest'
run: |
conda install --force-reinstall numpy

- name: Run eegnb install test
- name: Run eeg-expy install test
run: |
if [ "$RUNNER_OS" == "Linux" ]; then
Xvfb :0 -screen 0 1024x768x24 -ac +extension GLX +render -noreset &> xvfb.log &
Expand All @@ -58,7 +67,7 @@ jobs:
Xvfb :0 -screen 0 1024x768x24 -ac +extension GLX +render -noreset &> xvfb.log &
export DISPLAY=:0
fi
make test PYTEST_ARGS="--ignore=tests/test_run_experiments.py"
make test


typecheck:
Expand All @@ -71,19 +80,16 @@ jobs:
fail-fast: false
matrix:
os: ['ubuntu-22.04']
python_version: [3.9]
python_version: ['3.10']

steps:
- uses: actions/checkout@v2
- name: Install conda
uses: conda-incubator/setup-miniconda@v3
- name: Set up conda env
uses: ./.github/actions/setup-conda-env
with:
environment-file: environments/eeg-expy-full.yml
auto-activate-base: false
python-version: ${{ matrix.python_version }}
activate-environment: eeg-expy-full
channels: conda-forge
miniconda-version: "latest"
python-version: ${{ matrix.python_version }}
- name: Typecheck
run: |
make typecheck
19 changes: 19 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import importlib.util


def _is_available(module_name: str) -> bool:
try:
return importlib.util.find_spec(module_name) is not None
except (ImportError, ValueError):
return False


collect_ignore: list[str] = []

if not _is_available("psychopy"):
collect_ignore += [
"eegnb/experiments",
"eegnb/devices/vr.py",
]
elif not _is_available("psychxr"):
collect_ignore += ["eegnb/devices/vr.py"]
3 changes: 2 additions & 1 deletion eegnb/cli/introprompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from eegnb import generate_save_fn, DATA_DIR
from eegnb.devices.eeg import EEG
from .utils import run_experiment, get_exp_desc, experiments
from .utils import run_experiment, get_exp_desc, get_experiments

eegnb_sites = ['eegnb_examples', 'grifflab_dev', 'jadinlab_home']

Expand Down Expand Up @@ -87,6 +87,7 @@ def device_prompt() -> EEG:


def exp_prompt(runorzip:str='run') -> str:
experiments = get_experiments()
print("\nPlease select which experiment you would like to %s: \n" %runorzip)
print(
"\n".join(
Expand Down
75 changes: 43 additions & 32 deletions eegnb/cli/utils.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,42 @@

#change the pref libraty to PTB and set the latency mode to high precision
from psychopy import prefs
prefs.hardware['audioLib'] = 'PTB'
prefs.hardware['audioLatencyMode'] = 3
try:
#change the pref libraty to PTB and set the latency mode to high precision
from psychopy import prefs
prefs.hardware['audioLib'] = 'PTB'
prefs.hardware['audioLatencyMode'] = 3
except ImportError:
pass


from eegnb.devices.eeg import EEG

from eegnb.experiments import VisualN170, Experiment
from eegnb.experiments import VisualP300
from eegnb.experiments import VisualSSVEP
from eegnb.experiments import AuditoryOddball
from eegnb.experiments.visual_cueing import cueing
from eegnb.experiments.visual_codeprose import codeprose
from eegnb.experiments.auditory_oddball import diaconescu
from eegnb.experiments.auditory_ssaep import ssaep, ssaep_onefreq
from typing import Optional

def get_experiments():
from eegnb.experiments import VisualN170, Experiment
from eegnb.experiments import VisualP300
from eegnb.experiments import VisualSSVEP
from eegnb.experiments import AuditoryOddball
from eegnb.experiments.visual_cueing import cueing
from eegnb.experiments.visual_codeprose import codeprose
from eegnb.experiments.auditory_oddball import diaconescu
from eegnb.experiments.auditory_ssaep import ssaep, ssaep_onefreq

# New Experiment Class structure has a different initilization, to be noted
experiments = {
"visual-N170": VisualN170(),
"visual-P300": VisualP300(),
"visual-SSVEP": VisualSSVEP(),
"visual-cue": cueing,
"visual-codeprose": codeprose,
"auditory-SSAEP orig": ssaep,
"auditory-SSAEP onefreq": ssaep_onefreq,
"auditory-oddball orig": AuditoryOddball(),
"auditory-oddball diaconescu": diaconescu,
}
# New Experiment Class structure has a different initilization, to be noted
return {
"visual-N170": VisualN170,
"visual-P300": VisualP300,
"visual-SSVEP": VisualSSVEP,
"visual-cue": cueing,
"visual-codeprose": codeprose,
"auditory-SSAEP orig": ssaep,
"auditory-SSAEP onefreq": ssaep_onefreq,
"auditory-oddball orig": AuditoryOddball,
"auditory-oddball diaconescu": diaconescu,
}


def get_exp_desc(exp: str):
experiments = get_experiments()
if exp in experiments:
module = experiments[exp]
if hasattr(module, "__title__"):
Expand All @@ -43,17 +47,24 @@ def get_exp_desc(exp: str):
def run_experiment(
experiment: str, eeg_device: EEG, record_duration: Optional[float] = None, save_fn=None
):
experiments = get_experiments()
if experiment in experiments:
module = experiments[experiment]
exp_item = experiments[experiment]

from eegnb.experiments import Experiment

# Condition added for different run types of old and new experiment class structure
if isinstance(module, Experiment.BaseExperiment):
module.duration = record_duration
module.eeg = eeg_device
module.save_fn = save_fn
module.run()
# If it's a class (BaseExperiment subclass), instantiate it
if isinstance(exp_item, type) and issubclass(exp_item, Experiment.BaseExperiment):
# Concrete subclasses supply defaults for BaseExperiment's required args; mypy can't see which subclass.
exp_instance = exp_item() # type: ignore[call-arg]
exp_instance.duration = record_duration
exp_instance.eeg = eeg_device
exp_instance.save_fn = save_fn
exp_instance.run()
else:
module.present(duration=record_duration, eeg=eeg_device, save_fn=save_fn) # type: ignore
# Otherwise it's an old-style module
exp_item.present(duration=record_duration, eeg=eeg_device, save_fn=save_fn) # type: ignore
else:
print("\nError: Unknown experiment '{}'".format(experiment))
print("\nExperiment can be one of:")
Expand Down
11 changes: 10 additions & 1 deletion eegnb/devices/eeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@

from serial import Serial, EIGHTBITS, PARITY_NONE, STOPBITS_ONE

import pyxid2
from eegnb.utils.missing import missing_module

try:
import pyxid2
except (ImportError, OSError):
pyxid2 = missing_module(
"pyxid2",
"The Cedrus XID backend (NIRSport2 and other Cedrus stimulus-marker devices)",
"xid",
)

from eegnb.devices.utils import (
get_openbci_usb,
Expand Down
Loading
Loading