mnextend 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mnextend-0.1.0/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ © MNEXTEND developers
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: mnextend
3
+ Version: 0.1.0
4
+ Summary: Additional functionality for MNE-Python
5
+ Keywords: EEG,MEG,MNE-Python,XDF,electrophysiology
6
+ Author: Clemens Brunner
7
+ Author-email: Clemens Brunner <clemens.brunner@gmail.com>
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: edfio>=0.4.13
16
+ Requires-Dist: mne>=1.12.1
17
+ Requires-Dist: numpy>=2.2.6
18
+ Requires-Dist: pybv>=0.8.1
19
+ Requires-Dist: pybvrf>=0.1.3
20
+ Requires-Dist: pyxdf>=1.17.5
21
+ Requires-Dist: scipy>=1.15.3
22
+ Requires-Python: >=3.12
23
+ Project-URL: homepage, https://github.com/cbrnr/mnextend
24
+ Project-URL: documentation, https://github.com/cbrnr/mnextend/blob/main/README.md
25
+ Project-URL: repository, https://github.com/cbrnr/mnextend
26
+ Project-URL: changelog, https://github.com/cbrnr/mnextend/blob/main/CHANGELOG.md
27
+ Description-Content-Type: text/markdown
28
+
29
+ # MNExtend
30
+
31
+ This package provides additional functionality for working with [MNE-Python](https://mne.tools/), the most popular Python package for processing electrophysiological data (EEG, MEG, ...).
32
+
33
+ ## Features
34
+
35
+ ### Reading additional file formats
36
+
37
+ MNExtend provides readers for the following file formats that are not natively supported by MNE-Python:
38
+
39
+ - [XDF](https://github.com/sccn/xdf/wiki/Specifications) (Extensible Data Format)
40
+ - [MAT](https://www.mathworks.com/help/matlab/import_export/mat-file-versions.html) (MATLAB)
41
+ - [NPY](https://numpy.org/doc/stable/reference/generated/numpy.lib.format.html) (NumPy)
42
+
43
+ In addition, MNExtend adds the following readers from third-party packages:
44
+
45
+ - [BVRF](https://www.brainproducts.com/support-resources/brainvision-recording-format/) (via [PyBVRF](https://github.com/cbrnr/pybvrf))
46
+
47
+ Together with the native MNE-Python readers, `read_raw()` and `read_epochs()` provide a unified interface for reading electrophysiological data from a wide range of file formats, so all you have to do is:
48
+
49
+ ```python
50
+ from mnextend import read_raw, read_epochs
51
+
52
+ raw = read_raw("my_data-raw.xdf", stream_ids=[1, 2, 3])
53
+ epochs = read_epochs("my_data-epochs.fif.gz")
54
+ ```
55
+
56
+ ### Writing raw data
57
+
58
+ Writing raw data is supported via `write_raw()`, which does not implement any new file formats, but provides a unified interface for writing raw data to the file formats that are natively supported by MNE-Python:
59
+
60
+ ```python
61
+ from mnextend import write_raw
62
+
63
+ write_raw("my_data-raw.fif.gz", raw)
64
+ ```
@@ -0,0 +1,36 @@
1
+ # MNExtend
2
+
3
+ This package provides additional functionality for working with [MNE-Python](https://mne.tools/), the most popular Python package for processing electrophysiological data (EEG, MEG, ...).
4
+
5
+ ## Features
6
+
7
+ ### Reading additional file formats
8
+
9
+ MNExtend provides readers for the following file formats that are not natively supported by MNE-Python:
10
+
11
+ - [XDF](https://github.com/sccn/xdf/wiki/Specifications) (Extensible Data Format)
12
+ - [MAT](https://www.mathworks.com/help/matlab/import_export/mat-file-versions.html) (MATLAB)
13
+ - [NPY](https://numpy.org/doc/stable/reference/generated/numpy.lib.format.html) (NumPy)
14
+
15
+ In addition, MNExtend adds the following readers from third-party packages:
16
+
17
+ - [BVRF](https://www.brainproducts.com/support-resources/brainvision-recording-format/) (via [PyBVRF](https://github.com/cbrnr/pybvrf))
18
+
19
+ Together with the native MNE-Python readers, `read_raw()` and `read_epochs()` provide a unified interface for reading electrophysiological data from a wide range of file formats, so all you have to do is:
20
+
21
+ ```python
22
+ from mnextend import read_raw, read_epochs
23
+
24
+ raw = read_raw("my_data-raw.xdf", stream_ids=[1, 2, 3])
25
+ epochs = read_epochs("my_data-epochs.fif.gz")
26
+ ```
27
+
28
+ ### Writing raw data
29
+
30
+ Writing raw data is supported via `write_raw()`, which does not implement any new file formats, but provides a unified interface for writing raw data to the file formats that are natively supported by MNE-Python:
31
+
32
+ ```python
33
+ from mnextend import write_raw
34
+
35
+ write_raw("my_data-raw.fif.gz", raw)
36
+ ```
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["uv_build >= 0.11.24, < 0.12.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "mnextend"
7
+ version = "0.1.0"
8
+ description = "Additional functionality for MNE-Python"
9
+ license = "BSD-3-Clause"
10
+ license-files = ["LICENSE"]
11
+ authors = [
12
+ {name = "Clemens Brunner", email = "clemens.brunner@gmail.com"},
13
+ ]
14
+ readme = "README.md"
15
+ requires-python = ">=3.12"
16
+ classifiers = [
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ ]
23
+ keywords = ["EEG", "MEG", "MNE-Python", "XDF", "electrophysiology"]
24
+ dependencies = [
25
+ "edfio>=0.4.13",
26
+ "mne>=1.12.1",
27
+ "numpy>=2.2.6",
28
+ "pybv>=0.8.1",
29
+ "pybvrf>=0.1.3",
30
+ "pyxdf>=1.17.5",
31
+ "scipy>=1.15.3",
32
+ ]
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest >= 9.1.1",
37
+ "ruff >= 0.15.19",
38
+ ]
39
+
40
+ [project.urls]
41
+ homepage = "https://github.com/cbrnr/mnextend"
42
+ documentation = "https://github.com/cbrnr/mnextend/blob/main/README.md"
43
+ repository = "https://github.com/cbrnr/mnextend"
44
+ changelog = "https://github.com/cbrnr/mnextend/blob/main/CHANGELOG.md"
45
+
46
+ [tool.ruff.lint]
47
+ select = ["C4", "E", "F", "I", "PERF", "UP", "W"]
@@ -0,0 +1,9 @@
1
+ # © MNEXTEND developers
2
+ #
3
+ # License: BSD (3-clause)
4
+
5
+ from mnextend.io.readers import read_epochs, read_raw
6
+ from mnextend.io.utils import split_name_ext
7
+ from mnextend.io.writers import write_epochs, write_raw
8
+
9
+ __all__ = ["read_epochs", "read_raw", "split_name_ext", "write_epochs", "write_raw"]
@@ -0,0 +1,9 @@
1
+ # © MNEXTEND developers
2
+ #
3
+ # License: BSD (3-clause)
4
+
5
+ from mnextend.io.readers import read_epochs, read_raw
6
+ from mnextend.io.utils import split_name_ext
7
+ from mnextend.io.writers import write_epochs, write_raw
8
+
9
+ __all__ = ["read_epochs", "read_raw", "split_name_ext", "write_epochs", "write_raw"]
@@ -0,0 +1,93 @@
1
+ # © MNEXTEND developers
2
+ #
3
+ # License: BSD (3-clause)
4
+
5
+ import re
6
+
7
+ import numpy as np
8
+ from mne import create_info
9
+ from mne.io import BaseRaw
10
+ from numpy import atleast_2d
11
+ from scipy.io import loadmat
12
+
13
+
14
+ class RawMAT(BaseRaw):
15
+ """Raw data from .mat file."""
16
+
17
+ def __init__(self, fname, variable, fs, transpose=False):
18
+ """Read raw data from .mat file.
19
+
20
+ Parameters
21
+ ----------
22
+ fname : str
23
+ File name to load.
24
+ variable : str
25
+ Name of the variable to use. If nested within a struct, separate all names
26
+ with dots. For example, `y.X` corresponds to a variable `X` contained in a
27
+ struct `y`. If the cell array has multiple items, use `y.[0].X` to access
28
+ the first item named `X`, `y.[1].X` to access the second item named `X`, and
29
+ so on.
30
+ fs : float
31
+ Sampling frequency (in Hz).
32
+ transpose : bool
33
+ Whether to transpose the data; set to `True` if the original shape is *not*
34
+ (channels, samples).
35
+ """
36
+ mat = loadmat(fname, simplify_cells=True)
37
+ data = atleast_2d(_get_dict_value(mat, variable.split(".")))
38
+ if transpose:
39
+ data = data.T
40
+ info = create_info(data.shape[0], fs, "eeg")
41
+ super().__init__(preload=data, info=info, filenames=[fname])
42
+
43
+
44
+ def read_raw_mat(fname, variable, fs, transpose=False, *args, **kwargs):
45
+ """Read raw data from .mat file.
46
+
47
+ Parameters
48
+ ----------
49
+ fname : str
50
+ File name to load.
51
+ variable : str
52
+ Name of the variable to use. If nested within a struct, separate all names with
53
+ dots. For example, `y.X` corresponds to a variable `X` contained in a struct
54
+ `y`. If the cell array has multiple items, use `y.[0].X` to access the first
55
+ item named `X`, `y.[1].X` to access the second item named `X`, and so on.
56
+ fs : float
57
+ Sampling frequency (in Hz).
58
+ transpose : bool
59
+ Whether to transpose the data, the data should be of shape (channels, samples).
60
+
61
+ Returns
62
+ -------
63
+ RawMAT
64
+ The raw data.
65
+ """
66
+ return RawMAT(fname, variable, fs, transpose)
67
+
68
+
69
+ def parse_mat(fname):
70
+ """Remove dunder variables from dict returned by scipy.io.loadmat()."""
71
+ mat = loadmat(fname, simplify_cells=True)
72
+ return {
73
+ k: v for k, v in mat.items() if not k.startswith("__") and not k.endswith("__")
74
+ }
75
+
76
+
77
+ def _get_dict_value(d, keys):
78
+ """Get dictionary value from nested dictionary keys."""
79
+ if isinstance(keys, str): # no nesting
80
+ return d[keys]
81
+ value = d
82
+ for key in keys:
83
+ if match := re.search(r"\[(\d+)\]", key): # list element
84
+ idx = int(match.group(1))
85
+ if not isinstance(value, (list, np.ndarray)):
86
+ raise ValueError(
87
+ f"Expected a list or array at '{key}', got {type(value).__name__}."
88
+ " Use a plain name (without brackets) to access a struct field."
89
+ )
90
+ value = value[idx]
91
+ else: # dict element
92
+ value = value[key]
93
+ return value
@@ -0,0 +1,76 @@
1
+ # © MNEXTEND developers
2
+ #
3
+ # License: BSD (3-clause)
4
+
5
+ import mne
6
+ import numpy as np
7
+ from mne.io import BaseRaw
8
+
9
+
10
+ class RawNPY(BaseRaw):
11
+ """Raw data from .npy file."""
12
+
13
+ def __init__(self, fname, fs, transpose=False):
14
+ """Read raw data from .npy file.
15
+
16
+ Parameters
17
+ ----------
18
+ fname : str
19
+ File name to load.
20
+ fs : float
21
+ Sampling frequency (in Hz).
22
+ transpose : bool
23
+ Whether to transpose the data; set to `True` if the original shape is *not*
24
+ (channels, samples).
25
+ """
26
+ data = np.load(fname)
27
+ if transpose:
28
+ data = data.T
29
+ if data.ndim != 2:
30
+ raise ValueError(f"Array must have two dimensions (got {data.ndim}).")
31
+ info = mne.create_info(data.shape[0], fs)
32
+ super().__init__(preload=data, info=info, filenames=[fname])
33
+
34
+
35
+ def read_raw_npy(fname, fs, transpose=False, *args, **kwargs):
36
+ """Read raw data from .npy file.
37
+
38
+ Parameters
39
+ ----------
40
+ fname : str
41
+ File name to load.
42
+ fs : float
43
+ Sampling frequency (in Hz).
44
+ transpose : bool
45
+ Whether to transpose the data, the data should be of shape (channels, samples).
46
+
47
+ Returns
48
+ -------
49
+ RawNPY
50
+ The raw data.
51
+ """
52
+ return RawNPY(fname, fs, transpose)
53
+
54
+
55
+ def parse_npy(fname):
56
+ """Return shape of array contained in .npy file.
57
+
58
+ Parameters
59
+ ----------
60
+ fname : str
61
+ File name to load.
62
+
63
+ Returns
64
+ -------
65
+ shape : tuple[int, int]
66
+ The shape of the array.
67
+ """
68
+ with open(fname, "rb") as f:
69
+ major, _ = np.lib.format.read_magic(f)
70
+ read_header = (
71
+ np.lib.format.read_array_header_2_0
72
+ if major == 2
73
+ else np.lib.format.read_array_header_1_0
74
+ )
75
+ shape, _, _ = read_header(f)
76
+ return shape
@@ -0,0 +1,107 @@
1
+ # © MNEXTEND developers
2
+ #
3
+ # License: BSD (3-clause)
4
+
5
+ from functools import partial
6
+ from pathlib import Path
7
+
8
+ import mne
9
+ from pybvrf import read_raw_bvrf
10
+
11
+ from mnextend.io.mat import read_raw_mat
12
+ from mnextend.io.npy import read_raw_npy
13
+ from mnextend.io.utils import split_name_ext
14
+ from mnextend.io.xdf import read_raw_xdf
15
+
16
+
17
+ def _read_unsupported(fname, *, suggest=None, **kwargs):
18
+ ext = "".join(Path(fname).suffixes)
19
+ msg = f"Unsupported file type ({ext})."
20
+ if suggest is not None:
21
+ msg += f" Try reading a {suggest} file instead."
22
+ raise ValueError(msg)
23
+
24
+
25
+ # known file formats for raw (continuous) data
26
+ raw_readers = {
27
+ ".edf": mne.io.read_raw_edf,
28
+ ".bdf": mne.io.read_raw_bdf,
29
+ ".gdf": mne.io.read_raw_gdf,
30
+ ".vhdr": mne.io.read_raw_brainvision,
31
+ ".fif": mne.io.read_raw_fif,
32
+ ".set": mne.io.read_raw_eeglab,
33
+ ".cnt": mne.io.read_raw_cnt,
34
+ ".mff": mne.io.read_raw_egi,
35
+ ".nxe": mne.io.read_raw_eximia,
36
+ ".hdr": mne.io.read_raw_nirx,
37
+ ".snirf": mne.io.read_raw_snirf,
38
+ ".mat": read_raw_mat,
39
+ ".npy": read_raw_npy,
40
+ **dict.fromkeys([".fif.gz"], mne.io.read_raw_fif),
41
+ **dict.fromkeys([".xdf", ".xdfz", ".xdf.gz"], read_raw_xdf),
42
+ **dict.fromkeys([".bvrh", ".bvrd", ".bvrm", ".bvri"], read_raw_bvrf),
43
+ **dict.fromkeys([".vmrk", ".eeg"], partial(_read_unsupported, suggest=".vhdr")),
44
+ }
45
+
46
+ # known file formats for epochs (segmented) data
47
+ epochs_readers = {
48
+ ".fif": mne.read_epochs,
49
+ ".fif.gz": mne.read_epochs,
50
+ ".set": mne.read_epochs_eeglab,
51
+ }
52
+
53
+
54
+ def _read(fname, readers, *args, **kwargs):
55
+ """Read file using appropriate reader based on file extension."""
56
+ fname = Path(fname).expanduser()
57
+ _, ext = split_name_ext(fname, readers)
58
+ if ext is not None:
59
+ return readers[ext](fname, *args, **kwargs)
60
+ ext = "".join(Path(fname).suffixes).lower()
61
+ raise ValueError(
62
+ f"Unsupported file type ({ext})." if ext else "Unsupported file type."
63
+ )
64
+
65
+
66
+ def read_raw(fname, *args, **kwargs):
67
+ """Read raw (continuous) data file.
68
+
69
+ Parameters
70
+ ----------
71
+ fname : str | Path
72
+ File name to load.
73
+
74
+ Returns
75
+ -------
76
+ raw : mne.io.Raw
77
+ Raw object.
78
+
79
+ Notes
80
+ -----
81
+ This function supports reading raw data from different file formats. It uses the
82
+ `raw_readers` dict to dispatch the appropriate read function for a supported file
83
+ type.
84
+ """
85
+ return _read(fname, raw_readers, *args, **kwargs)
86
+
87
+
88
+ def read_epochs(fname, *args, **kwargs):
89
+ """Read epochs (segmented) data file.
90
+
91
+ Parameters
92
+ ----------
93
+ fname : str | Path
94
+ File name to load.
95
+
96
+ Returns
97
+ -------
98
+ epochs : mne.Epochs
99
+ Epochs object.
100
+
101
+ Notes
102
+ -----
103
+ This function supports reading epochs data from different file formats. It uses the
104
+ `epochs_readers` dict to dispatch the appropriate read function for a supported file
105
+ type.
106
+ """
107
+ return _read(fname, epochs_readers, *args, **kwargs)
@@ -0,0 +1,16 @@
1
+ # © MNEXTEND developers
2
+ #
3
+ # License: BSD (3-clause)
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def split_name_ext(fname, readers):
9
+ """Return name and supported file extension."""
10
+ maxsuffixes = max(ext.count(".") for ext in readers)
11
+ suffixes = Path(fname).suffixes
12
+ for n in range(maxsuffixes, 0, -1):
13
+ ext = "".join(suffixes[-n:]).lower()
14
+ if ext in readers:
15
+ return Path(fname).name[: -len(ext)], ext
16
+ return Path(fname).name, None
@@ -0,0 +1,156 @@
1
+ # © MNEXTEND developers
2
+ #
3
+ # License: BSD (3-clause)
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ from numpy.rec import fromarrays
9
+ from scipy.io import savemat
10
+
11
+ from mnextend.io.utils import split_name_ext
12
+
13
+
14
+ def write_fif(fname, raw):
15
+ raw.save(fname, overwrite=True)
16
+
17
+
18
+ def write_set(fname, raw):
19
+ """Export raw to EEGLAB .set file."""
20
+ data = raw.get_data() * 1e6 # convert to microvolts
21
+ fs = raw.info["sfreq"]
22
+ times = raw.times
23
+ ch_names = raw.info["ch_names"]
24
+ chanlocs = fromarrays([ch_names], names=["labels"])
25
+ events = fromarrays(
26
+ [
27
+ raw.annotations.description,
28
+ raw.annotations.onset * fs + 1,
29
+ raw.annotations.duration * fs,
30
+ ],
31
+ names=["type", "latency", "duration"],
32
+ )
33
+ savemat(
34
+ fname,
35
+ {
36
+ "EEG": {
37
+ "data": data,
38
+ "setname": str(fname),
39
+ "nbchan": data.shape[0],
40
+ "pnts": data.shape[1],
41
+ "trials": 1,
42
+ "srate": fs,
43
+ "xmin": times[0],
44
+ "xmax": times[-1],
45
+ "chanlocs": chanlocs,
46
+ "event": events,
47
+ "icawinv": [],
48
+ "icasphere": [],
49
+ "icaweights": [],
50
+ }
51
+ },
52
+ appendmat=False,
53
+ )
54
+
55
+
56
+ def write_bdf_edf(fname, raw):
57
+ """Export raw to EDF file."""
58
+ raw.export(fname, overwrite=True)
59
+
60
+
61
+ def write_bv(fname, raw):
62
+ """Export data to BrainVision EEG/VHDR/VMRK file (requires pybv)."""
63
+ raw.export(fname=Path(fname).with_suffix(".vhdr"), overwrite=True)
64
+
65
+
66
+ # These dicts contain each supported file extension as a key; the corresponding value is
67
+ # a list with two elements: (1) the writer function and (2) the full file format name.
68
+ raw_writers = {
69
+ ".bdf": [write_bdf_edf, "Biosemi Data Format"],
70
+ ".edf": [write_bdf_edf, "European Data Format"],
71
+ ".eeg": [write_bv, "BrainVision"],
72
+ ".fif": [write_fif, "Elekta Neuromag"],
73
+ ".fif.gz": [write_fif, "Elekta Neuromag"],
74
+ ".set": [write_set, "EEGLAB"],
75
+ }
76
+
77
+
78
+ def write_epochs_set(fname, epochs):
79
+ """Export epochs to EEGLAB .set file."""
80
+ data = epochs.get_data() * 1e6 # (n_epochs, n_channels, n_times), convert to µV
81
+ data = data.transpose(1, 2, 0) # EEGLAB expects (n_channels, n_times, n_epochs)
82
+
83
+ n_epochs = len(epochs)
84
+ n_times = len(epochs.times)
85
+ fs = epochs.info["sfreq"]
86
+
87
+ chanlocs = fromarrays([epochs.ch_names], names=["labels"])
88
+
89
+ id_to_name = {v: k for k, v in epochs.event_id.items()}
90
+ event_types = np.array(
91
+ [id_to_name.get(eid, str(eid)) for eid in epochs.events[:, 2]]
92
+ )
93
+ # latency in samples (1-based) within the concatenated epoch data
94
+ offset = round(abs(epochs.tmin) * fs)
95
+ latencies = (np.arange(n_epochs) * n_times + offset + 1).astype(float)
96
+ epoch_indices = np.arange(1, n_epochs + 1, dtype=float)
97
+
98
+ events = fromarrays(
99
+ [event_types, latencies, np.zeros(n_epochs), epoch_indices],
100
+ names=["type", "latency", "duration", "epoch"],
101
+ )
102
+ # per-epoch struct: eventlatency in ms (0 = time-locking event)
103
+ epoch_struct = fromarrays(
104
+ [epoch_indices, event_types, np.zeros(n_epochs), np.zeros(n_epochs)],
105
+ names=["event", "eventtype", "eventlatency", "eventduration"],
106
+ )
107
+
108
+ savemat(
109
+ fname,
110
+ {
111
+ "EEG": {
112
+ "data": data,
113
+ "setname": str(fname),
114
+ "nbchan": data.shape[0],
115
+ "pnts": n_times,
116
+ "trials": n_epochs,
117
+ "srate": fs,
118
+ "xmin": epochs.tmin,
119
+ "xmax": epochs.tmax,
120
+ "chanlocs": chanlocs,
121
+ "event": events,
122
+ "epoch": epoch_struct,
123
+ "icawinv": [],
124
+ "icasphere": [],
125
+ "icaweights": [],
126
+ }
127
+ },
128
+ appendmat=False,
129
+ )
130
+
131
+
132
+ epochs_writers = {
133
+ ".fif": [write_fif, "Elekta Neuromag"],
134
+ ".fif.gz": [write_fif, "Elekta Neuromag"],
135
+ ".set": [write_epochs_set, "EEGLAB"],
136
+ }
137
+
138
+
139
+ def _write(fname, data, writer_dict):
140
+ """Write data using appropriate writer based on file extension."""
141
+ fname = Path(fname).expanduser()
142
+ _, ext = split_name_ext(fname, writer_dict)
143
+ if ext is not None:
144
+ return writer_dict[ext][0](fname, data)
145
+ ext = "".join(Path(fname).suffixes).lower()
146
+ raise ValueError(
147
+ f"Unsupported file type ({ext})." if ext else "Unsupported file type."
148
+ )
149
+
150
+
151
+ def write_raw(fname, raw):
152
+ return _write(fname, raw, raw_writers)
153
+
154
+
155
+ def write_epochs(fname, epochs):
156
+ return _write(fname, epochs, epochs_writers)
@@ -0,0 +1,480 @@
1
+ # © MNEXTEND developers
2
+ #
3
+ # License: BSD (3-clause)
4
+
5
+ import struct
6
+ import xml.etree.ElementTree as ETree
7
+ from collections import defaultdict
8
+ from datetime import UTC, datetime
9
+
10
+ import mne
11
+ import numpy as np
12
+ import scipy.signal
13
+ from mne.io import BaseRaw, get_channel_type_constants
14
+ from pyxdf import load_xdf, resolve_streams
15
+ from pyxdf.pyxdf import _read_varlen_int, open_xdf
16
+
17
+
18
+ class RawXDF(BaseRaw):
19
+ """Raw data from .xdf file."""
20
+
21
+ def __init__(
22
+ self,
23
+ fname,
24
+ stream_ids,
25
+ marker_ids=None,
26
+ prefix_markers=False,
27
+ fs_new=None,
28
+ gap_threshold=0.0,
29
+ *args,
30
+ **kwargs,
31
+ ):
32
+ """Read raw data from .xdf file.
33
+
34
+ Parameters
35
+ ----------
36
+ fname : str | Path
37
+ File name to load.
38
+ stream_ids : int | list[int]
39
+ ID(s) of streams to load. Use `pyxdf.resolve_streams(fname)` to list
40
+ available streams.
41
+ marker_ids : list[int] | None
42
+ IDs of marker streams to load. If `None`, load all marker streams. A marker
43
+ stream is a stream with a nominal sampling frequency of 0 Hz.
44
+ prefix_markers : bool
45
+ Whether to prefix marker streams with their corresponding stream ID.
46
+ fs_new : float | None
47
+ Target sampling frequency in Hz (required when reading multiple streams). If
48
+ only one stream is provided, this can be `None`, in which case the stream's
49
+ original sampling rate is used.
50
+ gap_threshold : float
51
+ Detect gaps in timestamps larger than this value (in seconds) and mark those
52
+ samples as NaN. Set to 0.0 to disable gap detection. If `gap_threshold > 0`,
53
+ linear interpolation is used instead of resampling, and `fs_new` must be
54
+ specified.
55
+
56
+ Notes
57
+ -----
58
+ Resampling depends on whether gap detection is requested or not:
59
+ - If `gap_threshold > 0`, uses linear interpolation to resample to the new
60
+ sampling frequency `fs_new`. This method will detect gaps in the original
61
+ timestamps and mark those samples as NaN.
62
+ - If `gap_threshold == 0`, uses Fourier-based resampling if `fs_new` is provided
63
+ or does not resample at all if `fs_new` is `None`. This method assumes that
64
+ the original timestamps are regular and does not account for any gaps.
65
+ By default, gap detection is disabled.
66
+ """
67
+ if len(stream_ids) == 0:
68
+ raise ValueError("Argument `stream_ids` must not be empty.")
69
+
70
+ if len(stream_ids) > 1 and fs_new is None:
71
+ raise ValueError(
72
+ "Argument `fs_new` is required when reading multiple streams."
73
+ )
74
+
75
+ if gap_threshold < 0:
76
+ raise ValueError(
77
+ f"Argument `gap_threshold` must be non-negative, got {gap_threshold}."
78
+ )
79
+
80
+ if gap_threshold > 0 and fs_new is None:
81
+ raise ValueError(
82
+ "Argument `fs_new` is required when `gap_threshold > 0`. "
83
+ "Gap detection requires resampling to a regular time grid."
84
+ )
85
+
86
+ streams, header = load_xdf(fname)
87
+ streams = {stream["info"]["stream_id"]: stream for stream in streams}
88
+
89
+ if all(_is_stringstream(streams[stream_id]) for stream_id in stream_ids):
90
+ raise RuntimeError(
91
+ "Loading only marker streams is not supported, at least one stream must"
92
+ " be a regular stream."
93
+ )
94
+
95
+ labels_all, types_all, units_all = [], [], []
96
+ channel_types = get_channel_type_constants(True)
97
+ for stream_id in stream_ids:
98
+ stream = streams[stream_id]
99
+
100
+ n_chans = int(stream["info"]["channel_count"][0])
101
+ labels, types, units = [], [], []
102
+ try:
103
+ for ch in stream["info"]["desc"][0]["channels"][0]["channel"]:
104
+ labels.append(str(ch["label"][0]))
105
+ if ch["type"] and ch["type"][0].lower() in channel_types:
106
+ types.append(ch["type"][0].lower())
107
+ else:
108
+ types.append("misc")
109
+ units.append(ch["unit"][0] if ch["unit"] else "NA")
110
+ except (TypeError, IndexError): # no channel labels found
111
+ pass
112
+ if not labels:
113
+ labels = [f"{stream['info']['name'][0]}_{n}" for n in range(n_chans)]
114
+ if not units:
115
+ units = ["NA" for _ in range(n_chans)]
116
+ if not types:
117
+ types = ["misc" for _ in range(n_chans)]
118
+ labels_all.extend(labels)
119
+ types_all.extend(types)
120
+ units_all.extend(units)
121
+
122
+ # interpolate if gap detection is requested, otherwise resample
123
+ use_interpolation = gap_threshold > 0
124
+
125
+ if fs_new is not None:
126
+ data, first_time = _resample_streams(
127
+ streams, stream_ids, fs_new, use_interpolation
128
+ )
129
+ fs = fs_new
130
+
131
+ if gap_threshold > 0: # mark gaps if requested
132
+ timestamps = first_time + np.arange(len(data)) / fs
133
+ col_start = 0
134
+ for stream_id in stream_ids:
135
+ n_chans = int(streams[stream_id]["info"]["channel_count"][0])
136
+ _mark_gaps(
137
+ data,
138
+ timestamps,
139
+ streams[stream_id]["time_stamps"],
140
+ gap_threshold,
141
+ slice(col_start, col_start + n_chans),
142
+ )
143
+ col_start += n_chans
144
+ else: # only possible if a single stream was selected
145
+ if len(streams[stream_ids[0]]["time_stamps"]) == 0:
146
+ raise ValueError(f"Stream {stream_ids[0]} contains no samples.")
147
+ data = streams[stream_ids[0]]["time_series"]
148
+ first_time = streams[stream_ids[0]]["time_stamps"][0]
149
+ fs = float(
150
+ np.array(streams[stream_ids[0]]["info"]["effective_srate"]).item()
151
+ )
152
+ if fs == 0: # fall back to nominal rate (e.g. when only one sample exists)
153
+ fs = float(streams[stream_ids[0]]["info"]["nominal_srate"][0])
154
+
155
+ info = mne.create_info(ch_names=labels_all, sfreq=fs, ch_types=types_all)
156
+
157
+ microvolts = ("microvolt", "microvolts", "µV", "μV", "uV")
158
+ scale = np.array([1e-6 if u in microvolts else 1 for u in units_all])
159
+ data = (data * scale).T
160
+ super().__init__(preload=data, info=info, filenames=[fname], *args, **kwargs)
161
+
162
+ # convert string streams to annotations
163
+ for stream_id, stream in streams.items():
164
+ if not _is_stringstream(stream):
165
+ continue
166
+ srate = float(stream["info"]["nominal_srate"][0])
167
+ # classic marker streams (srate=0) respect the user's marker selection;
168
+ # regular-rate string streams are always converted automatically
169
+ if srate == 0 and marker_ids is not None and stream_id not in marker_ids:
170
+ continue
171
+ prefix = f"{stream_id}-" if prefix_markers else ""
172
+ onsets_list, descriptions_list = [], []
173
+ for ts, sub in zip(stream["time_stamps"], stream["time_series"]):
174
+ for item in sub:
175
+ if item: # skip empty strings
176
+ onsets_list.append(ts - first_time)
177
+ descriptions_list.append(f"{prefix}{item}")
178
+ if onsets_list:
179
+ self.annotations.append(
180
+ onsets_list, [0] * len(onsets_list), descriptions_list
181
+ )
182
+
183
+ recording_datetime = header["info"].get("datetime", [None])[0]
184
+ if recording_datetime is not None:
185
+ try:
186
+ meas_date = datetime.fromisoformat(recording_datetime)
187
+ except ValueError:
188
+ # LabRecorder emits timezone offsets as +HHMM without the colon
189
+ recording_datetime = (
190
+ recording_datetime[:-2] + ":" + recording_datetime[-2:]
191
+ )
192
+ meas_date = datetime.fromisoformat(recording_datetime)
193
+ self.set_meas_date(meas_date.astimezone(UTC))
194
+
195
+
196
+ def _mark_gaps(data, timestamps, original_timestamps, gap_threshold, cols):
197
+ """Mark gaps in data with NaN based on gaps in original timestamps.
198
+
199
+ This function modifies the data array in-place.
200
+
201
+ Parameters
202
+ ----------
203
+ data : np.ndarray
204
+ Data array of shape (n_samples, n_channels). Modified in-place.
205
+ timestamps : np.ndarray
206
+ Timestamps corresponding to data (interpolated/resampled uniform grid).
207
+ original_timestamps : np.ndarray
208
+ Original timestamps from the stream.
209
+ gap_threshold : float
210
+ Gap threshold in seconds.
211
+ cols : slice
212
+ Column slice indicating which columns belong to this stream.
213
+ """
214
+ # find gaps in original timestamps
215
+ gaps = np.diff(original_timestamps) > gap_threshold
216
+ gap_indices = np.where(gaps)[0]
217
+
218
+ if len(gap_indices) == 0:
219
+ return
220
+
221
+ # for each gap, find the time range and mark it in the data
222
+ for idx in gap_indices:
223
+ gap_start_time = original_timestamps[idx]
224
+ gap_end_time = original_timestamps[idx + 1]
225
+
226
+ # find corresponding indices in the uniform time grid
227
+ start_idx = np.searchsorted(timestamps, gap_start_time, side="right")
228
+ end_idx = np.searchsorted(timestamps, gap_end_time, side="left")
229
+
230
+ # mark the gap region as NaN (only for this stream's columns)
231
+ if start_idx < len(data) and end_idx <= len(data):
232
+ data[start_idx:end_idx, cols] = np.nan
233
+
234
+
235
+ def _resample_streams(streams, stream_ids, fs_new, use_interpolation=False):
236
+ """Resample XDF stream(s) to a common sampling rate.
237
+
238
+ Parameters
239
+ ----------
240
+ streams : dict
241
+ A dictionary mapping stream IDs to XDF streams.
242
+ stream_ids : list[int]
243
+ The IDs of the desired streams.
244
+ fs_new : float
245
+ Target sampling frequency in Hz.
246
+ use_interpolation : bool
247
+ If True, use linear interpolation. If False, use Fourier-based resampling.
248
+
249
+ Returns
250
+ -------
251
+ all_time_series : np.ndarray
252
+ Array of shape (n_samples, n_channels) containing raw data. Time intervals where
253
+ a stream has no data contain `np.nan`.
254
+ first_time : float
255
+ Time of the very first sample in seconds.
256
+ """
257
+ from scipy.interpolate import interp1d
258
+ from scipy.signal import butter, sosfiltfilt
259
+
260
+ start_times = []
261
+ end_times = []
262
+ n_total_chans = 0
263
+ for stream_id in stream_ids:
264
+ if len(streams[stream_id]["time_stamps"]) == 0:
265
+ raise ValueError(f"Stream {stream_id} contains no samples.")
266
+ start_times.append(streams[stream_id]["time_stamps"][0])
267
+ end_times.append(streams[stream_id]["time_stamps"][-1])
268
+ n_total_chans += int(streams[stream_id]["info"]["channel_count"][0])
269
+ first_time = min(start_times)
270
+ last_time = max(end_times)
271
+
272
+ n_samples = int(np.ceil((last_time - first_time) * fs_new))
273
+ all_time_series = np.full((n_samples, n_total_chans), np.nan)
274
+ time_grid = first_time + np.arange(n_samples) / fs_new
275
+
276
+ col_start = 0
277
+ for stream_id in stream_ids:
278
+ timestamps = streams[stream_id]["time_stamps"]
279
+ sort_indices = np.argsort(timestamps)
280
+ timestamps = timestamps[sort_indices]
281
+ timestamps, unique_idx = np.unique(timestamps, return_index=True)
282
+
283
+ if not sort_indices.shape == unique_idx.shape:
284
+ from warnings import warn
285
+
286
+ warn(
287
+ f"Non-unique timestamps found in stream {stream_id}: "
288
+ f"{sort_indices.shape[0]} timestamps, {unique_idx.shape[0]} unique.",
289
+ RuntimeWarning,
290
+ )
291
+
292
+ start_time = timestamps[0]
293
+ end_time = timestamps[-1]
294
+ x_old = streams[stream_id]["time_series"][sort_indices[unique_idx], :]
295
+
296
+ # apply anti-aliasing filter if downsampling
297
+ fs_original = float(
298
+ np.array(streams[stream_id]["info"]["effective_srate"]).item()
299
+ )
300
+ if fs_new < fs_original:
301
+ nyquist = fs_new / 2
302
+ sos = butter(8, 0.95 * nyquist, btype="low", fs=fs_original, output="sos")
303
+ x_old = sosfiltfilt(sos, x_old, axis=0)
304
+
305
+ # find valid time range in output grid
306
+ row_start = int(np.floor((start_time - first_time) * fs_new))
307
+ row_end = int(np.ceil((end_time - first_time) * fs_new))
308
+ time_new = time_grid[row_start:row_end]
309
+
310
+ if use_interpolation: # linear interpolation
311
+ interpolator = interp1d(
312
+ timestamps,
313
+ x_old,
314
+ axis=0,
315
+ kind="linear",
316
+ bounds_error=False,
317
+ fill_value=np.nan,
318
+ )
319
+ x_new = interpolator(time_new)
320
+ else: # Fourier-based resampling
321
+ len_new = len(time_new)
322
+ x_new = scipy.signal.resample(x_old, len_new, axis=0)
323
+
324
+ col_end = col_start + x_new.shape[1]
325
+ all_time_series[row_start:row_end, col_start:col_end] = x_new
326
+
327
+ col_start += x_new.shape[1]
328
+
329
+ return all_time_series, first_time
330
+
331
+
332
+ def read_raw_xdf(
333
+ fname,
334
+ stream_ids=None,
335
+ marker_ids=None,
336
+ prefix_markers=False,
337
+ fs_new=None,
338
+ gap_threshold=0.0,
339
+ **kwargs,
340
+ ):
341
+ """Read XDF file.
342
+
343
+ Parameters
344
+ ----------
345
+ fname : str
346
+ File name to load.
347
+ stream_ids : int | list[int] | None
348
+ ID(s) of streams to load. If `None`, raises a `ValueError` listing the available
349
+ regular stream IDs. Use `pyxdf.resolve_streams(fname)` to list available
350
+ streams.
351
+ marker_ids : list[int] | None
352
+ IDs of marker streams to load. If `None`, load all marker streams. A marker
353
+ stream is a stream with a nominal sampling frequency of 0 Hz.
354
+ prefix_markers : bool
355
+ Whether to prefix marker streams with their corresponding stream ID.
356
+ fs_new : float | None
357
+ Target sampling frequency in Hz (required when reading multiple streams). If
358
+ only one stream is provided, this can be `None`, in which case the stream's
359
+ original sampling rate is used.
360
+ gap_threshold : float
361
+ Detect gaps in timestamps larger than this value (in seconds) and mark those
362
+ samples as NaN. Set to 0.0 to disable gap detection. If `gap_threshold > 0`,
363
+ linear interpolation is used instead of resampling, and `fs_new` must be
364
+ specified.
365
+
366
+ Returns
367
+ -------
368
+ RawXDF
369
+ The raw data.
370
+ """
371
+ if isinstance(stream_ids, int):
372
+ stream_ids = [stream_ids]
373
+ if stream_ids is None:
374
+ streams = resolve_streams(fname)
375
+ ids = [s["stream_id"] for s in streams if s["channel_format"] != "string"]
376
+ msg = (
377
+ "Argument `stream_ids` is required (available regular stream IDs: "
378
+ f"{', '.join(map(str, ids))})."
379
+ )
380
+ raise ValueError(msg)
381
+ return RawXDF(
382
+ fname,
383
+ stream_ids,
384
+ marker_ids,
385
+ prefix_markers,
386
+ fs_new,
387
+ gap_threshold,
388
+ )
389
+
390
+
391
+ def _is_stringstream(stream):
392
+ return stream["info"]["channel_format"][0] == "string"
393
+
394
+
395
+ def get_xml(fname):
396
+ """Get XML stream headers and footers from all streams.
397
+
398
+ Parameters
399
+ ----------
400
+ fname : str
401
+ Name of the XDF file.
402
+
403
+ Returns
404
+ -------
405
+ xml : dict
406
+ XML stream headers and footers.
407
+ """
408
+ with open_xdf(fname) as f:
409
+ xml = defaultdict(dict)
410
+ while True:
411
+ try:
412
+ nbytes = _read_varlen_int(f)
413
+ except EOFError:
414
+ return xml
415
+ tag = struct.unpack("<H", f.read(2))[0]
416
+ if tag in [2, 3, 4, 6]:
417
+ stream_id = struct.unpack("<I", f.read(4))[0]
418
+ if tag in [2, 6]: # parse StreamHeader/StreamFooter chunk
419
+ string = f.read(nbytes - 6).decode()
420
+ xml[stream_id][tag] = ETree.fromstring(string)
421
+ else: # skip remaining chunk contents
422
+ f.seek(nbytes - 6, 1)
423
+ else:
424
+ f.seek(nbytes - 2, 1) # skip remaining chunk contents
425
+
426
+
427
+ def list_chunks(fname):
428
+ """List all chunks contained in an XDF file.
429
+
430
+ Listing chunks summarizes the content of the XDF file. Because this function does
431
+ not attempt to parse the data, this also works for corrupted files.
432
+
433
+ Parameters
434
+ ----------
435
+ fname : str
436
+ Name of the XDF file.
437
+
438
+ Returns
439
+ -------
440
+ chunks : list
441
+ List of dicts containing a short summary for each chunk.
442
+ """
443
+ with open_xdf(fname) as f:
444
+ chunks = []
445
+ while True:
446
+ try:
447
+ nbytes = _read_varlen_int(f)
448
+ except EOFError:
449
+ return chunks
450
+ chunk = {"nbytes": nbytes}
451
+ tag = struct.unpack("<H", f.read(2))[0]
452
+ chunk["tag"] = tag
453
+ if tag == 1:
454
+ chunk["content"] = f.read(nbytes - 2).decode()
455
+ elif tag == 5:
456
+ chunk["content"] = (
457
+ "0x43 0xA5 0x46 0xDC 0xCB 0xF5 0x41 0x0F "
458
+ "0xB3 0x0E 0xD5 0x46 0x73 0x83 0xCB 0xE4"
459
+ )
460
+ f.seek(chunk["nbytes"] - 2, 1) # skip remaining chunk contents
461
+ elif tag in [2, 6]: # XML
462
+ chunk["stream_id"] = struct.unpack("<I", f.read(4))[0]
463
+ chunk["content"] = (
464
+ f.read(chunk["nbytes"] - 6).decode().replace("\t", " ")
465
+ )
466
+ elif tag == 4:
467
+ chunk["stream_id"] = struct.unpack("<I", f.read(4))[0]
468
+ collection_time = struct.unpack("<d", f.read(8))[0]
469
+ offset_value = struct.unpack("<d", f.read(8))[0]
470
+ chunk["content"] = (
471
+ f"Collection time: {collection_time}\nOffset value: {offset_value}"
472
+ )
473
+ elif tag == 3:
474
+ chunk["stream_id"] = struct.unpack("<I", f.read(4))[0]
475
+ remainder = chunk["nbytes"] - 6
476
+ chunk["content"] = f"<BINARY DATA ({remainder} Bytes)>"
477
+ f.seek(remainder, 1) # skip remaining chunk contents
478
+ else:
479
+ f.seek(chunk["nbytes"] - 2, 1) # skip remaining chunk contents
480
+ chunks.append(chunk)