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 +29 -0
- mnextend-0.1.0/PKG-INFO +64 -0
- mnextend-0.1.0/README.md +36 -0
- mnextend-0.1.0/pyproject.toml +47 -0
- mnextend-0.1.0/src/mnextend/__init__.py +9 -0
- mnextend-0.1.0/src/mnextend/io/__init__.py +9 -0
- mnextend-0.1.0/src/mnextend/io/mat.py +93 -0
- mnextend-0.1.0/src/mnextend/io/npy.py +76 -0
- mnextend-0.1.0/src/mnextend/io/readers.py +107 -0
- mnextend-0.1.0/src/mnextend/io/utils.py +16 -0
- mnextend-0.1.0/src/mnextend/io/writers.py +156 -0
- mnextend-0.1.0/src/mnextend/io/xdf.py +480 -0
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.
|
mnextend-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
mnextend-0.1.0/README.md
ADDED
|
@@ -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)
|