Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Binary file removed .DS_Store
Binary file not shown.
4 changes: 3 additions & 1 deletion .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ max-complexity = 18
select = B,C,E,F,W,T4,B9
exclude =
__init__.py
build
build
dist
*venv*
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ dist/
plugins/FeatureExtractorSSVEP
session_saves/*.pkl
*.log
*.npz
*.npz
*venv*
22 changes: 22 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## Makefile for BCI Essentials Python package. Requires make installed on the system. This is available by default on Linux and macOS, and can be installed on Windows via WSL or other means.
.PHONY: help install dev-install test lint format

help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-10s %s\n", $$1, $$2}'

install: ## Install package and dev dependencies
pip install .

dev-install: ## Install development dependencies (black, flake8)
pip install -e .
pip install black flake8

test: ## Run tests
python -m unittest

lint: ## Run black and flake8
black --check .
flake8

format: ## Auto-format code with black
black .
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,42 @@ python examples/mi_offline_test.py
Online processing requires an EEG stream and a marker stream. These can both be simulated using eeg_lsl_sim.py and marker_lsl_sim.py.
Real EEG streams come from a headset connected over LSL. Real marker streams come from the application in the Unity frontend.
Once these streams are running, simply begin the backend processing script ( ie. mi_unity_backend.py, p300_unity_backend.py, etc.)
It is recommended to save the EEG, marker, and response (created by the backend processing script) streams using
It is recommended to save the EEG, marker, and response (created by the backend processing script) streams using
[Lab Recorder](https://github.com/labstreaminglayer/App-LabRecorder) for later offline processing.
```
python examples/mi_unity_backend.py
```

### Serial port triggers
As an alternative to LSL marker streams, markers can be delivered via a hardware trigger box
(e.g. MMBT-S) connected to the EEG amplifier's stim channel. Unity's `SerialTriggerWriter`
sends byte values over a serial port, the trigger box forwards them to the amplifier, and
`LslStimMarkerSource` reads them from the EEG stream's stim channel.

```python
from bci_essentials.io.lsl_sources import LslEegSource
from bci_essentials.io.lsl_stim_marker_source import LslStimMarkerSource
from bci_essentials.triggers import make_p300_trigger_map

eeg_source = LslEegSource()
marker_source = LslStimMarkerSource(
stim_channel_name="TRG",
trigger_map=make_p300_trigger_map(n_options=6),
)
```

Trigger maps for MI and SSVEP paradigms are also available via `make_mi_trigger_map` and
`make_ssvep_trigger_map` in `bci_essentials.triggers`.

## Development

```
make install # install package
make dev-install # install package in editable mode (pip install -e .) and development dependencies (black, flake8)
make test # run tests (python -m unittest)
make lint # run black --check and flake8 (same as CI)
```

## Directory
### bci_essentials
The main package containing modules for BCI processing.
Expand Down
Binary file removed bci_essentials/.DS_Store
Binary file not shown.
11 changes: 1 addition & 10 deletions bci_essentials/bci_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,20 @@
import time
import os
import numpy as np
from enum import Enum

from .paradigm.paradigm import Paradigm
from .data_tank.data_tank import DataTank
from .classification.generic_classifier import GenericClassifier
from .io.sources import EegSource, MarkerSource
from .io.messenger import Messenger
from .triggers import MarkerTypes
from .utils.logger import Logger

# Instantiate a logger for the module at the default level of logging.INFO
# Logs to bci_essentials.__module__) where __module__ is the name of the module
logger = Logger(name=__name__)


class MarkerTypes(Enum):
TRIAL_STARTED = "Trial Started"
TRIAL_ENDS = "Trial Ends"
TRAINING_COMPLETE = "Training Complete"
TRAIN_CLASSIFIER = "Train Classifier"
DONE_RS_COLLECTION = "Done with all RS collection"
UPDATE_CLASSIFIER = "Update Classifier"


# EEG data
class BciController:
"""
Expand Down
Binary file removed bci_essentials/io/.DS_Store
Binary file not shown.
44 changes: 44 additions & 0 deletions bci_essentials/io/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""BCI Essentials I/O — data sources and messengers.

Quick imports::

from bci_essentials.io import LslEegSource, LslMarkerSource, LslMessenger
from bci_essentials.io import XdfEegSource, XdfMarkerSource
from bci_essentials.io import LslStimMarkerSource
"""

# Abstract base classes
from .sources import EegSource, MarkerSource
from .messenger import Messenger

# LSL (online)
from .lsl_sources import LslEegSource, LslMarkerSource
from .lsl_messenger import LslMessenger
from .lsl_stim_marker_source import LslStimMarkerSource

# XDF (offline)
from .xdf_sources import XdfEegSource, XdfMarkerSource

# Testing
from .fake_stim_source import FakeStimEegSource

# Wrapper (testing / offline stim-channel extraction)
from .eeg_trigger_sources import EegStimTriggerMarkerSource

__all__ = [
# ABCs
"EegSource",
"MarkerSource",
"Messenger",
# LSL
"LslEegSource",
"LslMarkerSource",
"LslMessenger",
"LslStimMarkerSource",
# XDF
"XdfEegSource",
"XdfMarkerSource",
# Testing
"FakeStimEegSource",
"EegStimTriggerMarkerSource",
]
218 changes: 218 additions & 0 deletions bci_essentials/io/eeg_trigger_sources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
from .sources import EegSource, MarkerSource
from ..triggers import TriggerDetector
from ..utils.logger import Logger # Logger wrapper

# Re-export trigger map helpers for backward compatibility
from ..triggers import ( # noqa: F401
DEFAULT_TRIGGER_MAP,
SIMPLE_TRIGGER_MAP,
make_p300_trigger_map,
make_mi_trigger_map,
make_ssvep_trigger_map,
make_simple_trigger_map,
)
from ..triggers import TriggerByte as _TriggerByte # noqa: F401

# Backward compat aliases
TARGET_BYTE: int = _TriggerByte.TARGET
NON_TARGET_BYTE: int = _TriggerByte.NON_TARGET

# Instantiate a logger for the module at the default level of logging.INFO
# Logs to bci_essentials.__module__) where __module__ is the name of the module
logger = Logger(name=__name__)

__all__ = [
"EegStimTriggerMarkerSource",
# Re-exports from triggers.py (backward compat)
"make_p300_trigger_map",
"make_mi_trigger_map",
"make_ssvep_trigger_map",
"make_simple_trigger_map",
"DEFAULT_TRIGGER_MAP",
"SIMPLE_TRIGGER_MAP",
]


class EegStimTriggerMarkerSource(EegSource, MarkerSource):
"""Wraps an EegSource and extracts trigger events from its stim channel.

Implements both EegSource and MarkerSource. EEG samples pass through
unchanged; the stim channel is scanned for trigger events on each
get_samples() call. For online LSL use, prefer LslStimMarkerSource.
"""

def __init__(
self,
eeg_source: EegSource,
stim_channel_name: str,
trigger_map: dict[int, str] | None = None,
detect_mode: str = "rise",
include_unmapped: bool = False,
enabled: bool = True,
):
"""Create an EegStimTriggerMarkerSource that wraps an EegSource.

Parameters
----------
eeg_source : EegSource
Underlying EEG source (e.g. FakeStimEegSource).
stim_channel_name : str
Label of the stim channel in the EEG stream.
trigger_map : dict[int, str], *optional*
Byte to marker string mapping. Defaults to DEFAULT_TRIGGER_MAP.
detect_mode : str, *optional*
"rise" (default) or "change".
include_unmapped : bool, *optional*
If True, unmapped bytes produce "trigger_{value}".
enabled : bool, *optional*
Set False to skip trigger detection at runtime.
"""
if not isinstance(eeg_source, EegSource):
raise TypeError(
f"eeg_source must be an EegSource, got {type(eeg_source).__name__}"
)
if not isinstance(stim_channel_name, str) or not stim_channel_name:
raise ValueError(
f"stim_channel_name must be a non-empty string, got {stim_channel_name!r}"
)
if detect_mode not in ("rise", "change"):
raise ValueError(
f"detect_mode must be 'rise' or 'change', got {detect_mode!r}"
)

if trigger_map is not None:
for key in trigger_map:
if not isinstance(key, int) or key < 0 or key > 255:
raise ValueError(
f"trigger_map keys must be integers in 0-255, got {key!r}"
)

self._eeg = eeg_source
self._stim_channel_name = stim_channel_name
tmap = trigger_map if trigger_map is not None else dict(DEFAULT_TRIGGER_MAP)
self._enabled = bool(enabled)
self._detector = TriggerDetector(tmap, detect_mode, include_unmapped)

self._pending: list[tuple[list[str], float]] = []
self._resolved_stim_idx: int | None = None

self._samples_scanned: int = 0
self._triggers_detected: int = 0
self._warned_no_triggers: bool = False

@property
def enabled(self) -> bool:
return self._enabled

@enabled.setter
def enabled(self, value: bool) -> None:
self._enabled = bool(value)

@property
def stim_channel_name(self) -> str:
return self._stim_channel_name

@stim_channel_name.setter
def stim_channel_name(self, name: str) -> None:
if not isinstance(name, str) or not name:
raise ValueError(f"stim_channel_name must be non-empty, got {name!r}")
self._stim_channel_name = name
self._resolved_stim_idx = None

@property
def name(self) -> str:
return self._eeg.name

@property
def fsample(self) -> float:
return self._eeg.fsample

@property
def n_channels(self) -> int:
return self._eeg.n_channels

@property
def channel_types(self) -> list[str]:
return self._eeg.channel_types

@property
def channel_units(self) -> list[str]:
return self._eeg.channel_units

@property
def channel_labels(self) -> list[str]:
return self._eeg.channel_labels

def get_samples(self) -> tuple[list[list], list]:
try:
samples, timestamps = self._eeg.get_samples()
except Exception:
logger.error(
"EegStimTriggerMarkerSource: error reading EEG source",
exc_info=True,
)
return [[]], []

if not self._enabled or not timestamps:
return samples, timestamps

stim_idx = self._resolve_stim_index()
if stim_idx is None:
return samples, timestamps

for sample, ts in zip(samples, timestamps):
if stim_idx >= len(sample):
continue
self._samples_scanned += 1
try:
stim_val = int(sample[stim_idx])
except (ValueError, TypeError):
continue

marker_str = self._detector.detect(stim_val)
if marker_str is not None:
self._triggers_detected += 1
self._pending.append(([marker_str], ts))

self._check_no_trigger_warning()
return samples, timestamps

def time_correction(self) -> float:
return self._eeg.time_correction()

def get_markers(self) -> tuple[list[list], list]:
if not self._enabled or not self._pending:
return [[]], []
markers = [m for m, _ in self._pending]
timestamps = [t for _, t in self._pending]
self._pending.clear()
return markers, timestamps

def _resolve_stim_index(self) -> int | None:
if self._resolved_stim_idx is not None:
return self._resolved_stim_idx
try:
labels = self._eeg.channel_labels
except Exception:
return None
if self._stim_channel_name in labels:
self._resolved_stim_idx = labels.index(self._stim_channel_name)
return self._resolved_stim_idx
logger.warning(
f"Stim channel '{self._stim_channel_name}' not found in {labels}"
)
return None

def _check_no_trigger_warning(self) -> None:
if self._warned_no_triggers or self._triggers_detected > 0:
return
try:
threshold = int(self._eeg.fsample * 10)
except Exception:
threshold = 2560
if self._samples_scanned >= threshold:
logger.warning(
f"Scanned {self._samples_scanned} samples with no triggers. "
f"Check stim_channel_name, trigger_map, and hardware connections."
)
self._warned_no_triggers = True
Loading