Repository: NeuroTechX/moabb

Issue:
c-VEP datasets
Hello,

Have you considered adding c-VEP (i.e. code-modulated VEP, or random VEP, or broad-band VEP) datasets to MOABB? They are currently one of the fastest types of BCI.

5 public datasets (but only 2 unprocessed) are already referenced in this review: https://www.doi.org/10.1088/1741-2552/ac38cf

The main difficulty I would see in adding cVEP to MOABB is that the decoding algorithms typically need to know the stimulation sequences used (see https://doi.org/10.1371/journal.pone.0133797).
1. The sequences could be passed to the pipelines at their initialisation, but each dataset uses its own sequences, so we would have to re-initialize the pipelines for every new dataset.
2. Or the sequences could be passed to the pipelines along with the EEG channels.

Neither of the two solutions seems ideal though...

Discussion:
PierreGtch: - [ ] update whats_new
- [ ] add your name to list of contributors
- [ ] update the list of datasets in the doc
- [ ] update the list of paradigms in the doc
- [ ] update the datasets_summary file in the doc
bruAristimunha: I will fix the pytest issue
bruAristimunha: Thank you @PierreGtch and @thijor =)

Patch:
diff --git a/docs/source/dataset_summary.rst b/docs/source/dataset_summary.rst
index 326f84a7..4d726411 100644
--- a/docs/source/dataset_summary.rst
+++ b/docs/source/dataset_summary.rst
@@ -83,6 +83,26 @@ SSVEP
    :class:`Wang2016`,34,62,40,6,5s,250Hz,1
 
 
+c-VEP
+======================
+
+Include neuro experiments where the participant is presented with psuedo-random noise-codes,
+such as m-sequences, Gold codes, or any arbitrary "pseudo-random" code. Specifically, the
+difference with SSVEP is that SSVEP presents periodic stimuli, while c-VEP presents
+non-periodic stimuli. For a review of c-VEP BCI, see:
+
+Martínez-Cagigal, V., Thielen, J., Santamaria-Vazquez, E., Pérez-Velasco, S., Desain, P.,&
+Hornero, R. (2021). Brain–computer interfaces based on code-modulated visual evoked
+potentials (c-VEP): A literature review. Journal of Neural Engineering, 18(6), 061002.
+DOI: https://doi.org/10.1088/1741-2552/ac38cf
+
+.. csv-table::
+   :header: Dataset, #Subj, #Chan, #Classes, #Trials / class, Trials length, #Epochs / class, Sampling rate, #Sessions, Codes, Presentation rate
+   :class: sortable
+
+   :class:`Thielen2021`,30,8,20,5,31.5s,18900 NT / 18900 T,512Hz,1,Gold codes,60Hz
+
+
 Resting States
 ======================
 
diff --git a/docs/source/datasets.rst b/docs/source/datasets.rst
index fefacc93..d4fe1cf1 100644
--- a/docs/source/datasets.rst
+++ b/docs/source/datasets.rst
@@ -75,6 +75,17 @@ SSVEP Datasets
     Lee2019_SSVEP
 
 
+--------------
+c-VEP Datasets
+--------------
+
+.. autosummary::
+    :toctree: generated/
+    :template: class.rst
+
+    Thielen2021
+
+
 ----------------------
 Resting State Datasets
 ----------------------
diff --git a/docs/source/paradigms.rst b/docs/source/paradigms.rst
index b66dd4c4..46097782 100644
--- a/docs/source/paradigms.rst
+++ b/docs/source/paradigms.rst
@@ -42,6 +42,19 @@ SSVEP Paradigms
     SSVEP
     FilterBankSSVEP
 
+
+---------------
+c-VEP Paradigms
+---------------
+
+.. autosummary::
+    :toctree: generated/
+    :template: class.rst
+
+    CVEP
+    FilterBankCVEP
+
+
 --------------
 Fixed Interval Windows Processings
 --------------
diff --git a/docs/source/whats_new.rst b/docs/source/whats_new.rst
index a2974136..79f4e7e8 100644
--- a/docs/source/whats_new.rst
+++ b/docs/source/whats_new.rst
@@ -44,6 +44,7 @@ Enhancements
 - Rename many dataset class names to standardize and deprecate old names (:gh:`455` by `Pierre Guetschel`_)
 - Change many dataset codes to match the class names (:gh:`455` by `Pierre Guetschel`_)
 - Add :obj:`moabb.datasets.compound_dataset.utils.compound_dataset_list`  (:gh:`455` by `Pierre Guetschel`_)
+- Add c-VEP paradigm and Thielen2021 c-VEP dataset (:gh:`463` by `Jordy Thielen`_)
 
 Bugs
 ~~~~
@@ -376,3 +377,4 @@ API changes
 .. _Pierre Guetschel: https://github.com/PierreGtch
 .. _Ludovic Darmet: https://github.com/ludovicdmt
 .. _Thomas Moreau: https://github.com/tommoral
+.. _Jordy Thielen: https://github.com/thijor
diff --git a/moabb/datasets/__init__.py b/moabb/datasets/__init__.py
index 38bc1cd1..aa7c3130 100644
--- a/moabb/datasets/__init__.py
+++ b/moabb/datasets/__init__.py
@@ -65,6 +65,7 @@ from .ssvep_exo import Kalunga2016
 from .ssvep_mamem import MAMEM1, MAMEM2, MAMEM3
 from .ssvep_nakanishi import Nakanishi2015
 from .ssvep_wang import Wang2016
+from .thielen2021 import Thielen2021
 from .upper_limb import Ofner2017
 from .utils import _init_dataset_list
 from .Weibo2014 import Weibo2014
diff --git a/moabb/datasets/fake.py b/moabb/datasets/fake.py
index e4e0b62f..d7bc860e 100644
--- a/moabb/datasets/fake.py
+++ b/moabb/datasets/fake.py
@@ -1,3 +1,4 @@
+import re
 import tempfile
 from pathlib import Path
 
@@ -49,7 +50,7 @@ class FakeDataset(BaseDataset):
         self.channels = channels
         code = (
             f"{code}-{paradigm.lower()}-{n_subjects}-{n_sessions}-{n_runs}-"
-            f"{''.join([e.replace('_', '').lower() for e in event_list])}-"
+            f"{''.join([re.sub('[^A-Za-z0-9]', '', e).lower() for e in event_list])}-"
             f"{''.join([c.lower() for c in channels])}"
         )
         super().__init__(
diff --git a/moabb/datasets/thielen2021.py b/moabb/datasets/thielen2021.py
new file mode 100644
index 00000000..5d8c98f6
--- /dev/null
+++ b/moabb/datasets/thielen2021.py
@@ -0,0 +1,324 @@
+import h5py
+import mne
+import numpy as np
+from mne import create_info
+from mne.io import RawArray
+from scipy.io import loadmat
+
+from moabb.datasets import download as dl
+from moabb.datasets.base import BaseDataset
+
+
+Thielen2021_URL = "https://public.data.donders.ru.nl/dcc/DSC_2018.00122_448_v3"
+
+# The default electrode locations in the raw file are wrong. We used the ExG channels on the Biosemi with a custom 8
+# channel set, according to an optimization as published in the following article:
+# Ahmadi, S., Borhanazad, M., Tump, D., Farquhar, J., & Desain, P. (2019). Low channel count montages using sensor
+# tying for VEP-based BCI. Journal of Neural Engineering, 16(6), 066038. DOI: https://doi.org/10.1088/1741-2552/ab4057
+ELECTRODE_MAPPING = {
+    "AF3": "Fpz",
+    "F3": "T7",
+    "FC5": "O1",
+    "P7": "POz",
+    "P8": "Oz",
+    "FC6": "Iz",
+    "F4": "O2",
+    "AF4": "T8",
+}
+
+# Individual sessions of each of the 30 individual participants in the dataset
+SESSIONS = (
+    "20181128",
+    "20181206",
+    "20181217",
+    "20181217",
+    "20181217",
+    "20181218",
+    "20181218",
+    "20181219",
+    "20181219",
+    "20181220",
+    "20181220",
+    "20181220",
+    "20190107",
+    "20190107",
+    "20190110",
+    "20190110",
+    "20190110",
+    "20190117",
+    "20190117",
+    "20190118",
+    "20190118",
+    "20190118",
+    "20190220",
+    "20190222",
+    "20190225",
+    "20190301",
+    "20190307",
+    "20190308",
+    "20190311",
+    "20190311",
+)
+
+# Each session consisted of 5 blocks (i.e., runs)
+NR_BLOCKS = 5
+
+# Each trial contained 15 cycles of a 2.1 second code
+NR_CYCLES_PER_TRIAL = 15
+
+# Codes were presented at a 60 Hz monitor refresh rate
+PRESENTATION_RATE = 60
+
+
+class Thielen2021(BaseDataset):
+    """c-VEP dataset from Thielen et al. (2021)
+
+    Dataset [1]_ from the study on zero-training c-VEP [2]_.
+
+    .. admonition:: Dataset summary
+
+        =============  =======  =======  ==================  ===============  ===============  ===========
+        Name             #Subj    #Chan     #Trials / class  Trials length    Sampling rate      #Sessions
+        =============  =======  =======  ==================  ===============  ===============  ===========
+        Thielen2021         30        8  18900 NT / 18900 T  0.3s             512Hz                      1
+        =============  =======  =======  ==================  ===============  ===============  ===========
+
+    **Dataset description**
+
+    EEG recordings were acquired at a sampling rate of 512 Hz, employing 8 Ag/AgCl electrodes. The Biosemi ActiveTwo EEG
+    amplifier was utilized during the experiment. The electrode array consisted of Fz, T7, O1, POz, Oz, Iz, O2, and T8,
+    connected as EXG channels. This is a custom electrode montage as optimized in a previous study for c-VEP, see [3]_.
+
+    During the experimental sessions, participants engaged in passive operation (i.e., without feedback) of a 4 x 5
+    visual speller brain-computer interface (BCI) comprising 20 distinct classes. Each cell of the symbol grid
+    underwent luminance modulation at full contrast, accomplished through pseudo-random noise-codes derived from a
+    collection of modulated Gold codes. These codes are binary, have a balanced distribution of ones and zeros, and
+    adhere to a limited run-length pattern (maximum run-length of 2 bits). The codes were presented at a presentation
+    rate of 60 Hz. As one cycle of these modulated Gold codes contains 126 bits, the duration of one cycle is 2.1
+    seconds.
+
+    For each of the five blocks, a trial started with a cueing phase, during which the target symbol was highlighted in
+    a green hue for a duration of 1 second. Following this, participants maintained their gaze fixated on the target
+    symbol while all symbols flashed in accordance with their respective pseudo-random noise-codes for a duration of
+    31.5 seconds (i.e., 15 code cycles). Each block encompassed 20 trials, presented in a randomized sequence, thereby
+    ensuring that each symbol was attended to once within the span of a block.
+
+    Note, here, we only load the offline data of this study and ignore the online phase.
+
+    References
+    ----------
+
+    .. [1] Thielen, J. (Jordy), Pieter Marsman, Jason Farquhar, Desain, P.W.M. (Peter) (2023): From full calibration to
+           zero training for a code-modulated visual evoked potentials brain computer interface. Version 3. Radboud
+           University. (dataset).
+           DOI: https://doi.org/10.34973/9txv-z787
+
+    .. [2] Thielen, J., Marsman, P., Farquhar, J., & Desain, P. (2021). From full calibration to zero training for a
+           code-modulated visual evoked potentials for brain–computer interface. Journal of Neural Engineering, 18(5),
+           056007.
+           DOI: https://doi.org/10.1088/1741-2552/abecef
+
+    .. [3] Ahmadi, S., Borhanazad, M., Tump, D., Farquhar, J., & Desain, P. (2019). Low channel count montages using
+           sensor tying for VEP-based BCI. Journal of Neural Engineering, 16(6), 066038.
+           DOI: https://doi.org/10.1088/1741-2552/ab4057
+
+    Notes
+    -----
+
+    .. versionadded:: 0.6.0
+
+    """
+
+    def __init__(self):
+        super().__init__(
+            subjects=list(range(1, 30 + 1)),
+            sessions_per_subject=1,
+            events={"1.0": 101, "0.0": 100},
+            code="Thielen2021",
+            interval=(0, 0.3),
+            paradigm="cvep",
+            doi="10.34973/9txv-z787",
+        )
+
+    def _add_stim_channel_trial(
+        self, raw, onsets, labels, offset=200, ch_name="stim_trial"
+    ):
+        """
+        Add a stimulus channel with trial onsets and their labels.
+
+        Parameters
+        ----------
+        raw: mne.Raw
+            The raw object to add the stimulus channel to.
+        onsets: List | np.ndarray
+            The onsets of the trials in sample numbers.
+        labels: List | np.ndarray
+            The labels of the trials.
+        offset: int (default: 200)
+            The integer value to start markers with. For instance, if 200, then label 0 will be marker 200, label 1
+            will be be marker 201, etc.
+        ch_name: str (default: "stim_trial")
+            The name of the added stimulus channel.
+        Returns
+        -------
+        mne.Raw
+            The raw object with the added stimulus channel.
+        """
+        stim_chan = np.zeros((1, len(raw)))
+        for onset, label in zip(onsets, labels):
+            stim_chan[0, onset] = offset + label
+        info = create_info(
+            ch_names=["stim_trial"],
+            ch_types=["stim"],
+            sfreq=raw.info["sfreq"],
+            verbose=False,
+        )
+        raw = raw.add_channels([RawArray(data=stim_chan, info=info, verbose=False)])
+        return raw
+
+    def _add_stim_channel_epoch(
+        self,
+        raw,
+        onsets,
+        labels,
+        codes,
+        presentation_rate=60,
+        offset=100,
+        ch_name="stim_epoch",
+    ):
+        """
+        Add a stimulus channel with epoch onsets and their labels, which are the values of the presented code for each
+        of the trials.
+
+        Parameters
+        ----------
+        raw: mne.Raw
+            The raw object to add the stimulus channel to.
+        onsets: List | np.ndarray
+            The onsets of the trials in sample numbers.
+        labels: List | np.ndarray
+            The labels of the trials.
+        codes: np.ndarray
+            The codebook containing each presented code of shape (nr_bits, nr_codes), sampled at the presentation rate.
+        presentation_rate: int (default: 60):
+            The presentation rate (e.g., frame rate) at which the codes were presented in Hz.
+        offset: int (default: 100)
+            The integer value to start markers with. For instance, if 100, then label 0 will be marker 100, label 1
+            will be be marker 101, etc.
+        ch_name: str (default: "stim_epoch")
+            The name of the added stimulus channel.
+        Returns
+        -------
+        mne.Raw
+            The raw object with the added stimulus channel.
+        """
+        stim_chan = np.zeros((1, len(raw)))
+        for onset, label in zip(onsets, labels):
+            idx = np.round(
+                onset + np.arange(codes.shape[0]) / presentation_rate * raw.info["sfreq"]
+            ).astype("int")
+            stim_chan[0, idx] = offset + codes[:, label]
+        info = create_info(
+            ch_names=[ch_name],
+            ch_types=["stim"],
+            sfreq=raw.info["sfreq"],
+            verbose=False,
+        )
+        raw = raw.add_channels([RawArray(data=stim_chan, info=info, verbose=False)])
+        return raw
+
+    def _get_single_subject_data(self, subject):
+        """Return the data of a single subject."""
+        file_path_list = self.data_path(subject)
+
+        # Codes
+        codes = np.tile(loadmat(file_path_list[-2])["codes"], (NR_CYCLES_PER_TRIAL, 1))
+
+        # Channels
+        montage = mne.channels.read_custom_montage(file_path_list[-1])
+
+        # There is only one session, each of 5 blocks (i.e., runs)
+        sessions = {"session_1": {}}
+        for i_b in range(NR_BLOCKS):
+            # EEG
+            raw = mne.io.read_raw_gdf(
+                file_path_list[2 * i_b],
+                stim_channel="status",
+                preload=True,
+                verbose=False,
+            )
+
+            # The default electrode locations in the raw file are wrong. We used the ExG channels on the Biosemi with a
+            # custom 8 channel set, according to an optimization as published in the following article:
+            # Ahmadi, S., Borhanazad, M., Tump, D., Farquhar, J., & Desain, P. (2019). Low channel count montages using
+            # sensor tying for VEP-based BCI. Journal of Neural Engineering, 16(6), 066038.
+            # DOI: https://doi.org/10.1088/1741-2552/ab4057
+            mne.rename_channels(raw.info, ELECTRODE_MAPPING)
+            raw.set_montage(montage)
+
+            # Labels at trial level (i.e., symbols)
+            trial_labels = (
+                np.array(h5py.File(file_path_list[2 * i_b + 1], "r")["v"])
+                .astype("uint8")
+                .flatten()
+                - 1
+            )
+
+            # Find onsets of trials
+            # Note, every 2.1 seconds an event was generated: 15 times per trial, plus one 16th "leaking epoch". This
+            # "leaking epoch" is not always present, so taking epoch[::16, :] won't work.
+            events = mne.find_events(raw, verbose=False)
+            cond = np.logical_or(
+                np.diff(events[:, 0]) < 1.8 * raw.info["sfreq"],
+                np.diff(events[:, 0]) > 2.4 * raw.info["sfreq"],
+            )
+            idx = np.concatenate(([0], 1 + np.where(cond)[0]))
+            trial_onsets = events[idx, 0]
+
+            # Create stim channel with trial information (i.e., symbols)
+            # Specifically: 200 = symbol-0, 201 = symbol-1, 202 = symbol-2, etc.
+            raw = self._add_stim_channel_trial(
+                raw, trial_onsets, trial_labels, offset=200
+            )
+
+            # Create stim channel with epoch information (i.e., 1 / 0, or on / off)
+            # Specifically: 100 = "0", 101 = "1"
+            raw = self._add_stim_channel_epoch(
+                raw, trial_onsets, trial_labels, codes, PRESENTATION_RATE, offset=100
+            )
+
+            # Add data as a new run
+            sessions["session_1"][f"run_{1 + i_b:02d}"] = raw
+
+        return sessions
+
+    def data_path(
+        self, subject, path=None, force_update=False, update_path=None, verbose=None
+    ):
+        """Return the data paths of a single subject."""
+        if subject not in self.subject_list:
+            raise (ValueError("Invalid subject number"))
+
+        sub = f"sub-{subject:02d}"
+        ses = SESSIONS[subject - 1]
+        subject_paths = []
+        for i_b in range(NR_BLOCKS):
+            blk = f"block_{1 + i_b:d}"
+
+            # EEG
+            url = f"{Thielen2021_URL:s}/sourcedata/offline/{sub}/{blk}/{sub}_{ses}_{blk}_main_eeg.gdf"
+            subject_paths.append(dl.data_dl(url, self.code, path, force_update, verbose))
+
+            # Labels at trial level (i.e., symbols)
+            url = f"{Thielen2021_URL:s}/sourcedata/offline/{sub}/{blk}/trainlabels.mat"
+            subject_paths.append(dl.data_dl(url, self.code, path, force_update, verbose))
+
+        # Codes
+        url = f"{Thielen2021_URL:s}/resources/mgold_61_6521_flip_balanced_20.mat"
+        subject_paths.append(dl.data_dl(url, self.code, path, force_update, verbose))
+
+        # Channel locations
+        url = f"{Thielen2021_URL:s}/resources/nt_cap8.loc"
+        subject_paths.append(dl.data_dl(url, self.code, path, force_update, verbose))
+
+        return subject_paths
diff --git a/moabb/paradigms/__init__.py b/moabb/paradigms/__init__.py
index 02fc0a52..c2f9fc8d 100644
--- a/moabb/paradigms/__init__.py
+++ b/moabb/paradigms/__init__.py
@@ -5,10 +5,10 @@ This is a function of the paradigm used, i.e. in motor imagery one can
 have two-class, multi-class, or continuous paradigms; similarly,
 different preprocessing is necessary for ERP vs ERD paradigms.
 """
+# flake8: noqa
+from moabb.paradigms.cvep import *
 from moabb.paradigms.fixed_interval_windows import *
 from moabb.paradigms.motor_imagery import *
-
-# flake8: noqa
 from moabb.paradigms.p300 import *
 from moabb.paradigms.resting_state import *
 from moabb.paradigms.ssvep import *
diff --git a/moabb/paradigms/cvep.py b/moabb/paradigms/cvep.py
new file mode 100644
index 00000000..5bedf2ed
--- /dev/null
+++ b/moabb/paradigms/cvep.py
@@ -0,0 +1,277 @@
+"""c-VEP Paradigms"""
+
+import logging
+
+from moabb.datasets import utils
+from moabb.datasets.fake import FakeDataset
+from moabb.paradigms.base import BaseParadigm
+
+
+log = logging.getLogger(__name__)
+
+
+class BaseCVEP(BaseParadigm):
+    """Base c-VEP paradigm for epoch-level decoding.
+
+    Please use one of the child classes.
+
+    This paradigm is meant to be used for epoch-level decoding of c-VEP
+    datasets; this means that the goal of the classifiers with this paradigm
+    is to predict, for every stimulation one by one, the amplitude of
+    the target code. The code value is between 0  (i.e., stimulation off)
+    and 1 (i.e., maximal stimulation).
+
+    Parameters
+    ----------
+
+    filters: tuple of tuple (defaults ((1.0, 45.0),))
+        Bank of bandpass filter to apply.
+
+    events: List of str | None (default None)
+        Event to use for epoching. Note, we stick to a convention where the
+        intensity level is encoded as float. For example, a binary sequence
+        would have events 1.0 (i.e., on) and 0.0 (i.e., off). If None, default
+        to all events defined in the dataset.
+
+    n_classes: int or None (default None)
+        Number of classes each dataset must have. All dataset classes if None.
+
+    tmin: float (default 0.0)
+        Start time (in second) of the epoch, relative to the dataset specific
+        task interval e.g. tmin = 1 would mean the epoch will start 1 second
+        after the beginning of the task as defined by the dataset.
+
+    tmax: float | None (default None)
+        End time (in second) of the epoch, relative to the beginning of the
+        dataset specific task interval. tmax = 5 would mean the epoch will end
+        5 second after the beginning of the task as defined in the dataset. If
+        None, use the dataset value.
+
+    baseline: None | tuple of length 2 (default None)
+        The time interval to consider as “baseline” when applying baseline
+        correction. If None, do not apply baseline correction.
+        If a tuple (a, b), the interval is between a and b (in seconds),
+        including the endpoints.
+        Correction is applied by computing the mean of the baseline period
+        and subtracting it from the data (see mne.Epochs)
+
+    channels: list of str | None (default None)
+        list of channel to select. If None, use all EEG channels available in
+        the dataset.
+
+    resample: float | None (default None)
+        If not None, resample the eeg data with the sampling rate provided.
+    """
+
+    def __init__(
+        self,
+        filters=((1.0, 45.0),),
+        events=None,
+        n_classes=None,
+        tmin=0.0,
+        tmax=None,
+        baseline=None,
+        channels=None,
+        resample=None,
+    ):
+        super().__init__(
+            filters=filters,
+            events=events,
+            channels=channels,
+            baseline=baseline,
+            resample=resample,
+            tmin=tmin,
+            tmax=tmax,
+        )
+
+        self.n_classes = n_classes
+        if self.events is None:
+            log.warning(
+                f"Choosing the first {n_classes} classes from all possible events."
+            )
+        else:
+            assert n_classes <= len(self.events), "More classes than events specified"
+
+    def is_valid(self, dataset):
+        """Check if dataset is valid for the c-VEP paradigm."""
+        ret = True
+        if not (dataset.paradigm == "cvep"):
+            ret = False
+
+        # check if dataset has required events
+        if self.events:
+            if not set(self.events) <= set(dataset.event_id.keys()):
+                ret = False
+
+        return ret
+
+    def used_events(self, dataset):
+        """Return the mne events used for the dataset."""
+        out = {}
+        if self.events is None:
+            for k, v in dataset.event_id.items():
+                out[k] = v
+                if self.n_classes and len(out) == self.n_classes:
+                    break
+        else:
+            for event in self.events:
+                if event in dataset.event_id.keys():
+                    out[event] = dataset.event_id[event]
+                if self.n_classes and len(out) == self.n_classes:
+                    break
+        if self.n_classes and len(out) < self.n_classes:
+            raise (
+                ValueError(
+                    f"Dataset {dataset.code} did not have enough "
+                    f"events in {self.events} to run analysis"
+                )
+            )
+        return out
+
+    @property
+    def datasets(self):
+        """List of datasets valid for the paradigm."""
+        if self.tmax is None:
+            interval = None
+        else:
+            interval = self.tmax - self.tmin
+        return utils.dataset_search(
+            paradigm="cvep", events=self.events, interval=interval, has_all_events=True
+        )
+
+    @property
+    def scoring(self):
+        """Return the default scoring method for this paradigm.
+
+        If n_classes==2 use the roc_auc, else use accuracy. More details
+        about this default scoring method can be found in the original
+        moabb paper.
+        """
+        if self.n_classes and self.n_classes == 2:
+            return "roc_auc"
+        else:
+            return "accuracy"
+
+
+class CVEP(BaseCVEP):
+    """Single bandpass c-VEP paradigm for epoch-level decoding.
+
+    c-VEP paradigm with only one bandpass filter (default 1 to 45 Hz)
+    Metric is 'roc-auc' if 2 classes and 'accuracy' if more.
+    This paradigm is meant to be used for epoch-level decoding of c-VEP
+    datasets; this means that the goal of the classifiers with this paradigm
+    is to predict, for every stimulation one by one, the amplitude of
+    the target code. The code value is between 0  (i.e., stimulation off)
+    and 1 (i.e., maximal stimulation).
+
+
+    Parameters
+    ----------
+    fmin: float (default 1.0)
+        cutoff frequency (Hz) for the highpass filter
+
+    fmax: float (default 45.0)
+        cutoff frequency (Hz) for the lowpass filter
+
+    events: List of str | None (default None)
+        Event to use for epoching. Note, we stick to a convention where the
+        intensity level is encoded as float. For example, a binary sequence
+        would have events 1.0 (i.e., on) and 0.0 (i.e., off). If None, default
+        to all events defined in the dataset.
+
+    n_classes: int or None (default None)
+        Number of classes each dataset must have. All dataset classes if None
+
+    tmin: float (default 0.0)
+        Start time (in second) of the epoch, relative to the dataset specific
+        task interval e.g. tmin = 1 would mean the epoch will start 1 second
+        after the beginning of the task as defined by the dataset.
+
+    tmax: float | None, (default None)
+        End time (in second) of the epoch, relative to the beginning of the
+        dataset specific task interval. tmax = 5 would mean the epoch will end
+        5 second after the beginning of the task as defined in the dataset. If
+        None, use the dataset value.
+
+    baseline: None | tuple of length 2 (default None)
+        The time interval to consider as “baseline” when applying baseline
+        correction. If None, do not apply baseline correction.
+        If a tuple (a, b), the interval is between a and b (in seconds),
+        including the endpoints.
+        Correction is applied by computing the mean of the baseline period
+        and subtracting it from the data (see mne.Epochs)
+
+    channels: list of str | None (default None)
+        List of channel to select. If None, use all EEG channels available in
+        the dataset.
+
+    resample: float | None (default None)
+        If not None, resample the eeg data with the sampling rate provided.
+    """
+
+    def __init__(self, fmin=1.0, fmax=45.0, **kwargs):
+        if "filters" in kwargs.keys():
+            raise (ValueError("c-VEP does not take argument filters"))
+        super().__init__(filters=((fmin, fmax),), **kwargs)
+
+
+class FilterBankCVEP(BaseCVEP):
+    """Filterbank c-VEP paradigm for epoch-level decoding.
+
+    c-VEP paradigm with multiple bandpass filters.
+    Metric is 'roc-auc' if 2 classes and 'accuracy' if more.
+    This paradigm is meant to be used for epoch-level decoding of c-VEP
+    datasets; this means that the goal of the classifiers with this paradigm
+    is to predict, for every stimulation one by one, the amplitude of
+    the target code. The code value is between 0  (i.e., stimulation off)
+    and 1 (i.e., maximal stimulation).
+
+    Parameters
+    ----------
+    filters: tuple of tuple | None (default ((1., 45.), (12., 45.), (30., 45.)))
+        Bank of bandpass filter to apply.
+    events: List of str | None (default None)
+        Event to use for epoching. Note, we stick to a convention where the
+        intensity level is encoded as float. For example, a binary sequence
+        would have events 1.0 (i.e., on) and 0.0 (i.e., off). If None, default
+        to all events defined in the dataset.
+    n_classes: int or None (default None)
+        Number of classes each dataset must have. All dataset classes if None
+    tmin: float (default 0.0)
+        Start time (in second) of the epoch, relative to the dataset specific
+        task interval e.g. tmin = 1 would mean the epoch will start 1 second
+        after the beginning of the task as defined by the dataset.
+    tmax: float | None, (default None)
+        End time (in second) of the epoch, relative to the beginning of the
+        dataset specific task interval. tmax = 5 would mean the epoch will end
+        5 second after the beginning of the task as defined in the dataset. If
+        None, use the dataset value.
+    baseline: None | tuple of length 2 (default None)
+        The time interval to consider as “baseline” when applying baseline
+        correction. If None, do not apply baseline correction.
+        If a tuple (a, b), the interval is between a and b (in seconds),
+        including the endpoints.
+        Correction is applied by computing the mean of the baseline period
+        and subtracting it from the data (see mne.Epochs)
+    channels: list of str | None (default None)
+        List of channel to select. If None, use all EEG channels available in
+        the dataset.
+    resample: float | None (default None)
+        If not None, resample the eeg data with the sampling rate provided.
+    """
+
+    def __init__(self, filters=((1.0, 45.0), (12.0, 45.0), (30.0, 45.0)), **kwargs):
+        super().__init__(filters=filters, **kwargs)
+
+
+class FakeCVEPParadigm(BaseCVEP):
+    """Fake c-VEP paradigm."""
+
+    @property
+    def datasets(self):
+        """Return a fake dataset with event list 1.0 and 0.0."""
+        return [FakeDataset(event_list=["1.0", "0.0"], paradigm="cvep")]
+
+    def is_valid(self, dataset):
+        """Overwrite the original function, always True in FakeDataset."""
+        return dataset.paradigm == "cvep"
diff --git a/poetry.lock b/poetry.lock
index 84db8586..d08a45f7 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -479,6 +479,21 @@ files = [
 [package.dependencies]
 numpy = ">=1.17"
 
+[[package]]
+name = "exceptiongroup"
+version = "1.1.3"
+description = "Backport of PEP 654 (exception groups)"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"},
+    {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
 [[package]]
 name = "filelock"
 version = "3.12.0"
@@ -783,6 +798,18 @@ zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
 docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
 testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
 
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+    {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
 [[package]]
 name = "jax"
 version = "0.4.10"
@@ -936,7 +963,6 @@ optional = false
 python-versions = "*"
 files = [
     {file = "libclang-15.0.6.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:8621795e07b87e17fc7aac9f071bc7fe6b52ed6110c0a96a9975d8113c8c2527"},
-    {file = "libclang-15.0.6.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:0bf192c48a8d2992fc5034393ddc99e772ac30e105df84927d62fc88ef8a659f"},
     {file = "libclang-15.0.6.1-py2.py3-none-manylinux2010_x86_64.whl", hash = "sha256:69b01a23ab543908a661532595daa23cf88bd96d80e41f58ba0eaa6a378fe0d8"},
     {file = "libclang-15.0.6.1-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:4a5188184b937132c198ee9de9a8a2316d5fdd1a825398d5ad1a8f5e06f9b40e"},
     {file = "libclang-15.0.6.1-py2.py3-none-manylinux2014_armv7l.whl", hash = "sha256:f7ffa02ac5e586cfffde039dcccc439d88d0feac7d77bf9426d9ba7543d16545"},
@@ -1520,6 +1546,22 @@ files = [
 docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.2.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"]
 test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"]
 
+[[package]]
+name = "pluggy"
+version = "1.2.0"
+description = "plugin and hook calling mechanisms for python"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"},
+    {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
 [[package]]
 name = "pooch"
 version = "1.7.0"
@@ -1729,6 +1771,29 @@ scipy = "*"
 docs = ["mne", "numpydoc", "seaborn", "sphinx-bootstrap_theme", "sphinx-gallery"]
 tests = ["flake8", "pytest", "seaborn"]
 
+[[package]]
+name = "pytest"
+version = "7.4.0"
+description = "pytest: simple powerful testing with Python"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"},
+    {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
 [[package]]
 name = "python-dateutil"
 version = "2.8.2"
@@ -1981,6 +2046,7 @@ stats = ["scipy (>=1.3)", "statsmodels (>=0.10)"]
 name = "setuptools"
 version = "67.8.0"
 description = "Easily download, build, install, upgrade, and uninstall Python packages"
+category = "dev"
 optional = false
 python-versions = ">=3.7"
 files = [
@@ -2432,6 +2498,18 @@ files = [
     {file = "threadpoolctl-3.1.0.tar.gz", hash = "sha256:a335baacfaa4400ae1f0d8e3a58d6674d2f8828e3716bb2802c44955ad391380"},
 ]
 
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+    {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
 [[package]]
 name = "torch"
 version = "1.13.1"
diff --git a/pyproject.toml b/pyproject.toml
index 2c7df5e0..cc8a59eb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ tqdm = "^4.64.1"
 coverage = "^7.0.1"
 memory-profiler = "^0.61.0"
 edflib-python = "^1.0.6"
+pytest = "^7.4.0"
 mne-bids = "^0.13"
 
 [tool.poetry.group.carbonemission]


Test patch:
diff --git a/moabb/tests/benchmark.py b/moabb/tests/benchmark.py
index c838c484..5419360a 100644
--- a/moabb/tests/benchmark.py
+++ b/moabb/tests/benchmark.py
@@ -24,6 +24,7 @@ class TestBenchmark(unittest.TestCase):
                 "FakeDataset-imagery-10-2-2-lefthandrighthand-c3czc4",
                 "FakeDataset-p300-10-2-2-targetnontarget-c3czc4",
                 "FakeDataset-ssvep-10-2-2-1315-c3czc4",
+                "FakeDataset-cvep-10-2-2-1000-c3czc4",
             ],
             overwrite=True,
         )
@@ -37,6 +38,7 @@ class TestBenchmark(unittest.TestCase):
                 FakeDataset(["left_hand", "right_hand"], paradigm="imagery"),
                 FakeDataset(["Target", "NonTarget"], paradigm="p300"),
                 FakeDataset(["13", "15"], paradigm="ssvep"),
+                FakeDataset(["1.0", "0.0"], paradigm="cvep"),
             ],
             overwrite=True,
         )
diff --git a/moabb/tests/datasets.py b/moabb/tests/datasets.py
index f53ffb6e..b3f09e59 100644
--- a/moabb/tests/datasets.py
+++ b/moabb/tests/datasets.py
@@ -70,7 +70,7 @@ class Test_Datasets(unittest.TestCase):
         n_sessions = 2
         n_runs = 2
 
-        for paradigm in ["imagery", "p300", "ssvep"]:
+        for paradigm in ["imagery", "p300", "ssvep", "cvep"]:
             ds = FakeDataset(
                 n_sessions=n_sessions,
                 n_runs=n_runs,
diff --git a/moabb/tests/download.py b/moabb/tests/download.py
index 294c98d2..720d3be5 100644
--- a/moabb/tests/download.py
+++ b/moabb/tests/download.py
@@ -27,6 +27,7 @@ from moabb.datasets.bbci_eeg_fnirs import BaseShin2017
 # from moabb.datasets.ssvep_mamem import MAMEM1, MAMEM2, MAMEM3
 # from moabb.datasets.ssvep_nakanishi import Nakanishi2015
 # from moabb.datasets.ssvep_wang import Wang2016
+# from moabb.datasets.thielen2021 import Thielen2021
 
 
 class Test_Downloads(unittest.TestCase):
@@ -135,6 +136,9 @@ class Test_Downloads(unittest.TestCase):
     # def test_wang2016(self):
     #     self.run_dataset(Wang2016)
 
+    # def test_thielen2021(self):
+    #     self.run_dataset(Thielen2021)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/moabb/tests/paradigms.py b/moabb/tests/paradigms.py
index 57ab15e0..b1a212e9 100644
--- a/moabb/tests/paradigms.py
+++ b/moabb/tests/paradigms.py
@@ -5,16 +5,21 @@ import unittest
 from math import ceil
 
 import numpy as np
+import pytest
 from mne import BaseEpochs
 from mne.io import BaseRaw
 
 from moabb.datasets.fake import FakeDataset
 from moabb.paradigms import (
+    CVEP,
     P300,
     SSVEP,
+    BaseCVEP,
     BaseMotorImagery,
     BaseP300,
     BaseSSVEP,
+    FakeCVEPParadigm,
+    FilterBankCVEP,
     FilterBankFixedIntervalWindowsProcessing,
     FilterBankLeftRightImagery,
     FilterBankMotorImagery,
@@ -184,7 +189,8 @@ class Test_MotorImagery(unittest.TestCase):
             "No cache found at",
             "Attempting to retrieve cache .* datatype-eeg",  # raw_pipeline
             "No cache found at",
-            "Attempting to retrieve cache .* datatype-eeg",  # SetRawAnnotations pipeline
+            "Attempting to retrieve cache .* datatype-eeg",
+            # SetRawAnnotations pipeline
             "No cache found at",
             "Starting caching .* datatype-eeg",
             "Finished caching .* datatype-eeg",
@@ -783,3 +789,286 @@ class Test_FixedIntervalWindowsProcessing(unittest.TestCase):
                     return_epochs=True,
                     return_raws=True,
                 )
+
+
+# Define the FakeLogger class outside of the test class
+class FakeLogger:
+    def __init__(self):
+        self.messages = []
+
+    def warning(self, message):
+        self.messages.append(message)
+
+    def format_warning(self, message):
+        return f"WARNING: {message}"
+
+
+class Test_CVEP:
+    @pytest.fixture
+    def fix_paradigm(self):
+        return BaseCVEP()
+
+    @pytest.fixture
+    def fix_fake_dataset(self):
+        return FakeDataset(event_list=["1.0", "0.0"], paradigm="cvep")
+
+    @pytest.fixture
+    def fix_cvep_paradigm(self):
+        return CVEP()
+
+    @pytest.fixture(params=[BaseCVEP(), CVEP()])
+    def fix_test_paradigm(self, request):
+        return request.param
+
+    @pytest.fixture(params=[BaseCVEP, FilterBankCVEP])
+    def fix_test_filters_paradigm(self, request):
+        return request.param
+
+    @pytest.fixture
+    def fix_two_fake_dataset(self):
+        datasetA = FakeDataset(paradigm="cvep", channels=["C3", "Cz", "C4"])
+        datasetB = FakeDataset(paradigm="cvep", channels=["Cz", "C4", "C3"])
+        paradigm = BaseCVEP(channels=["C4", "C3", "Cz"])
+
+        ep1, _, _ = paradigm.get_data(datasetA, subjects=[1], return_epochs=True)
+        ep2, _, _ = paradigm.get_data(datasetB, subjects=[1], return_epochs=True)
+        return ep1, ep2
+
+    def test_base_cvep_init(self):
+        # Test without events
+        base_cvep = BaseCVEP(n_classes=2)
+        assert base_cvep
+
+        # Test with events
+        base_cvep = BaseCVEP(events=["event1", "event2"], n_classes=2)
+        assert base_cvep
+
+        # Test with more classes than events (should raise an exception)
+        with pytest.raises(AssertionError):
+            _ = BaseCVEP(events=["event1"], n_classes=2)
+
+    def test_base_cvep_is_valid(self, fix_fake_dataset) -> None:
+        base_cvep = BaseCVEP()
+        assert base_cvep.is_valid(fix_fake_dataset)
+
+    def test_is_valid_with_valid_dataset(self, fix_fake_dataset):
+        base_cvep = BaseCVEP(events=["1.0", "0.0"], n_classes=2)
+        assert base_cvep.is_valid(fix_fake_dataset)
+
+    def test_is_valid_with_invalid_paradigm(self):
+        invalid_dataset = FakeDataset(paradigm="ssvep")
+        base_cvep = BaseCVEP(events=["1.0", "0.0"], n_classes=2)
+        assert not base_cvep.is_valid(invalid_dataset)
+
+    def test_is_valid_with_missing_events(self):
+        valid_dataset = FakeDataset(paradigm="cvep", event_list=["1.0"])
+        base_cvep = BaseCVEP(events=["1.0", "0.0"], n_classes=2)
+        assert not base_cvep.is_valid(valid_dataset)
+
+    def test_cvep_init(self):
+        cvep = CVEP()
+        assert cvep
+
+    def test_used_events_with_all_events(self, fix_fake_dataset):
+        base_cvep = BaseCVEP(events=None, n_classes=2)
+        used_events = base_cvep.used_events(fix_fake_dataset)
+        assert used_events == {"1.0": 1, "0.0": 2}
+
+    def test_used_events_with_specific_events(self, fix_fake_dataset):
+        base_cvep = BaseCVEP(events=["1.0"], n_classes=1)
+        used_events = base_cvep.used_events(fix_fake_dataset)
+        assert used_events == {"1.0": 1}
+
+    def test_used_events_with_insufficient_events(self, fix_fake_dataset):
+        base_cvep = BaseCVEP(events=["1.0", "0.0", "2.0"], n_classes=3)
+        with pytest.raises(ValueError):
+            base_cvep.used_events(fix_fake_dataset)
+
+    def test_filterbank_cvep_init(self):
+        filterbank_cvep = FilterBankCVEP()
+        assert filterbank_cvep
+
+    def test_fake_cvep_paradigm_datasets(self) -> None:
+        fake_cvep = FakeCVEPParadigm()
+        datasets = fake_cvep.datasets
+        assert len(datasets) == 1
+        assert isinstance(datasets[0], FakeDataset)
+
+    def test_get_data_with_paradigm(self, fix_test_paradigm, fix_fake_dataset) -> None:
+        X, labels, metadata = fix_test_paradigm.get_data(fix_fake_dataset, subjects=[1])
+
+        # Verify that they have the same length
+        assert len(X) == len(labels) == len(metadata)
+        # X must be a 3D array
+        assert len(X.shape) == 3
+        # labels must contain 2 values
+        assert len(np.unique(labels)) == 2
+        # metadata must have subjects, sessions, runs
+        assert "subject" in metadata.columns
+        assert "session" in metadata.columns
+        assert "run" in metadata.columns
+        # Only one subject in the metadata
+        assert len(np.unique(metadata.subject)) == 1
+        # we should have two sessions in the metadata, n_classes = 2 as default
+        assert len(np.unique(metadata.session)) == 2
+
+        epochs, _, _ = fix_test_paradigm.get_data(
+            fix_fake_dataset, subjects=[1], return_epochs=True
+        )
+        assert isinstance(epochs, BaseEpochs)
+
+        raws, _, _ = fix_test_paradigm.get_data(
+            fix_fake_dataset, subjects=[1], return_raws=True
+        )
+        for raw in raws:
+            assert isinstance(raw, BaseRaw)
+
+        with pytest.raises(ValueError):
+            fix_test_paradigm.get_data(
+                fix_fake_dataset, subjects=[1], return_epochs=True, return_raws=True
+            )
+
+    def test_channel_order_consistency(self, fix_two_fake_dataset) -> None:
+        ep1, ep2 = fix_two_fake_dataset
+        assert ep1.info["ch_names"] == ep2.info["ch_names"]
+
+    def test_channel_location_consistency(self, fix_two_fake_dataset) -> None:
+        """Checking if location vector is equal"""
+        ep1, ep2 = fix_two_fake_dataset
+
+        locs_ep1 = np.array([ch["loc"] for ch in ep1.info["chs"]])
+        locs_ep2 = np.array([ch["loc"] for ch in ep2.info["chs"]])
+
+        for ch1, ch2 in zip(locs_ep1, locs_ep2):
+            assert (ch1[:6] == ch2[:6]).all()
+
+    def test_invalid_tmin_tmax_raises_error(self) -> None:
+        with pytest.raises(ValueError):
+            BaseCVEP(tmin=1, tmax=0)
+
+    def test_get_data_with_filters(
+        self, fix_fake_dataset, fix_test_filters_paradigm
+    ) -> None:
+        filters = ((1.0, 45.0), (12.0, 45.0))
+        paradigm = fix_test_filters_paradigm(filters=filters)
+        X, _, _ = paradigm.get_data(fix_fake_dataset, subjects=[1])
+
+        # X must be a 4D array
+        assert len(X.shape) == 4
+        # Last dim should be 2 as the number of filters
+        assert X.shape[-1] == len(filters)
+        # should return MNE epochs format
+        epochs, _, _ = paradigm.get_data(
+            fix_fake_dataset, subjects=[1], return_epochs=True
+        )
+        assert isinstance(epochs, BaseEpochs)
+
+    def test_default_classes_extraction(self, fix_fake_dataset, fix_paradigm) -> None:
+        X, labels, _ = fix_paradigm.get_data(fix_fake_dataset, subjects=[1])
+
+        assert len(np.unique(labels)) == 2
+
+    def test_specified_classes_extraction(self) -> None:
+        # Set the number of classes
+        n_classes = 3
+        paradigm = BaseCVEP(n_classes=3)
+        dataset = FakeDataset(
+            event_list=["0.0", "0.25", "0.5", "0.75", "1.0"], paradigm="cvep"
+        )
+        X, labels, metadata = paradigm.get_data(dataset, subjects=[1])
+        # labels must contain 3 values
+        assert len(np.unique(labels)) == n_classes
+
+    def test_too_many_classes_error(self) -> None:
+        paradigm = BaseCVEP(n_classes=4)
+        dataset = FakeDataset(event_list=["1.0", "0.0"], paradigm="cvep")
+        with pytest.raises(ValueError):
+            paradigm.get_data(dataset)
+
+    def test_more_classes_than_events_error(self) -> None:
+        with pytest.raises(AssertionError):
+            BaseCVEP(n_classes=3, events=["1.0", "0.0"])
+
+    def test_dropped_events_handling(self, fix_fake_dataset) -> None:
+        tmax = fix_fake_dataset.interval[1]
+        paradigm_regular = BaseCVEP(tmax=tmax)
+        paradigm_large = BaseCVEP(tmax=10 * tmax)
+
+        # Test with epochs
+        epochs_regular, labels_regular, metadata_regular = paradigm_regular.get_data(
+            fix_fake_dataset, return_epochs=True
+        )
+        epochs_large, labels_large, metadata_large = paradigm_large.get_data(
+            fix_fake_dataset, return_epochs=True
+        )
+
+        assert len(epochs_regular) == len(labels_regular) == len(metadata_regular)
+        assert len(epochs_large) == len(labels_large) == len(metadata_large)
+        assert len(epochs_regular) > len(epochs_large)
+
+        # Test with np.array
+        X_regular, labels_regular, metadata_regular = paradigm_regular.get_data(
+            fix_fake_dataset
+        )
+        X_large, labels_large, metadata_large = paradigm_large.get_data(fix_fake_dataset)
+
+        assert len(X_regular) == len(labels_regular) == len(metadata_regular)
+        assert len(X_large) == len(labels_large) == len(metadata_large)
+        assert len(X_regular) > len(X_large)
+
+    def test_epochs_metadata_equality(self, fix_fake_dataset, fix_paradigm) -> None:
+        epochs, _, metadata = fix_paradigm.get_data(fix_fake_dataset, return_epochs=True)
+        # does not work with multiple filters:
+        assert metadata.equals(epochs.metadata)
+
+    def test_no_event_overlap_error(self) -> None:
+        # Assert error if events from paradigm and dataset dont overlap
+        paradigm = CVEP(events=["1.0", "0.0"], n_classes=2)
+        dataset = FakeDataset(event_list=["13", "14"], paradigm="cvep")
+        with pytest.raises(AssertionError):
+            paradigm.get_data(dataset)
+
+    def test_single_pass_filter_configuration(self, fix_fake_dataset) -> None:
+        # Accept only single pass filter
+        paradigm = CVEP(fmin=2.0, fmax=40.0)
+        X, labels, metadata = paradigm.get_data(fix_fake_dataset, subjects=[1])
+
+        # Verify that they have the same length
+        assert len(X) == len(labels) == len(metadata)
+        # X must be a 3D array
+        assert len(X.shape) == 3
+        # labels must contain all 3 classes of dataset,
+        # as n_classes is "None" by default (taking all classes)
+        assert len(np.unique(labels)) == 2
+        # should return epochs
+        epochs, _, _ = paradigm.get_data(
+            fix_fake_dataset, subjects=[1], return_epochs=True
+        )
+        isinstance(epochs, BaseEpochs)
+
+    def test_multiple_filters_error(self) -> None:
+        # Do not accept multiple filters
+        with pytest.raises(ValueError):
+            CVEP(filters=[(1.0, 45.0), (12.0, 45.0)])
+
+    def test_filter_bank_data_generation(self, fix_fake_dataset) -> None:
+        # can work with filter bank
+        paradigm = FilterBankCVEP(filters=((1.0, 45.0), (12.0, 45.0)))
+        X, labels, metadata = paradigm.get_data(fix_fake_dataset, subjects=[1])
+
+        # X must be a 4D array with d=2 as last dimension for the 2 filters
+        assert len(X.shape) == 4
+        assert X.shape[-1] == 2
+        # should return epochs
+        epochs, _, _ = paradigm.get_data(
+            fix_fake_dataset, subjects=[1], return_epochs=True
+        )
+        isinstance(epochs, BaseEpochs)
+
+    def test_default_scoring_values(self):
+        assert CVEP().scoring == "accuracy"
+        assert CVEP(n_classes=3).scoring == "accuracy"
+        assert CVEP(n_classes=2).scoring == "roc_auc"
+        assert CVEP(events=["1.0", "0.0"], n_classes=2).scoring == "roc_auc"
+        assert CVEP(events=["1.0", "0.5", "0.0"], n_classes=3).scoring == "accuracy"
+        assert CVEP(events=["1.0", "0.5", "0.0"], n_classes=2).scoring == "roc_auc"