misleep 0.3.0__py3-none-any.whl
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.
- misleep/__init__.py +111 -0
- misleep/__main__.py +16 -0
- misleep/analysis/__init__.py +23 -0
- misleep/analysis/auto_stage.py +158 -0
- misleep/analysis/detection.py +198 -0
- misleep/analysis/features.py +131 -0
- misleep/analysis/models/P30_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/P30_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/models/__init__.py +0 -0
- misleep/analysis/models/ado_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/ado_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/models/adult_EEG_F_lightgbm.pkl +0 -0
- misleep/analysis/models/adult_EEG_P_lightgbm.pkl +0 -0
- misleep/analysis/transformer/__init__.py +46 -0
- misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
- misleep/analysis/transformer/checkpoints/__init__.py +0 -0
- misleep/analysis/transformer/configs.py +52 -0
- misleep/analysis/transformer/data.py +362 -0
- misleep/analysis/transformer/inference.py +328 -0
- misleep/analysis/transformer/models.py +220 -0
- misleep/analysis/transformer/preprocessing.py +105 -0
- misleep/config/__init__.py +105 -0
- misleep/config/default_config.ini +38 -0
- misleep/data/__init__.py +13 -0
- misleep/data/annotation.py +114 -0
- misleep/data/midata.py +387 -0
- misleep/gui/__init__.py +23 -0
- misleep/gui/app.py +183 -0
- misleep/gui/config_dialog.py +429 -0
- misleep/gui/dialogs.py +1107 -0
- misleep/gui/event_filters.py +26 -0
- misleep/gui/main_window.py +2699 -0
- misleep/gui/qt_utils.py +277 -0
- misleep/gui/resources/__init__.py +9 -0
- misleep/gui/resources/entire_logo.png +0 -0
- misleep/gui/resources/logo.png +0 -0
- misleep/gui/resources/misleep.ico +0 -0
- misleep/gui/resources/misleep.qrc +7 -0
- misleep/gui/resources/misleep_rc.py +1324 -0
- misleep/gui/spec_window.py +187 -0
- misleep/gui/style.py +994 -0
- misleep/gui/uis/SWA_detect_dialog.ui +181 -0
- misleep/gui/uis/SWA_detect_dialog_ui.py +176 -0
- misleep/gui/uis/__init__.py +6 -0
- misleep/gui/uis/about.ui +88 -0
- misleep/gui/uis/about_ui.py +80 -0
- misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +77 -0
- misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +90 -0
- misleep/gui/uis/auto_stage_lightGBM_dialog.ui +124 -0
- misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +124 -0
- misleep/gui/uis/horizontal_line_dialog.ui +166 -0
- misleep/gui/uis/horizontal_line_dialog_ui.py +157 -0
- misleep/gui/uis/label_dialog.ui +65 -0
- misleep/gui/uis/label_dialog_ui.py +72 -0
- misleep/gui/uis/main_window.ui +871 -0
- misleep/gui/uis/main_window_ui.py +616 -0
- misleep/gui/uis/save_data_dialog.ui +95 -0
- misleep/gui/uis/save_data_dialog_ui.py +98 -0
- misleep/gui/uis/spec_window.ui +109 -0
- misleep/gui/uis/spec_window_ui.py +103 -0
- misleep/gui/uis/spindle_detect_dialog.ui +202 -0
- misleep/gui/uis/spindle_detect_dialog_ui.py +194 -0
- misleep/gui/uis/state_spectral_dialog.ui +240 -0
- misleep/gui/uis/state_spectral_dialog_ui.py +204 -0
- misleep/gui/uis/transfer_result_dialog.ui +104 -0
- misleep/gui/uis/transfer_result_dialog_ui.py +94 -0
- misleep/gui/workers.py +69 -0
- misleep/io/__init__.py +68 -0
- misleep/io/annotation.py +367 -0
- misleep/io/array.py +209 -0
- misleep/io/base.py +174 -0
- misleep/io/edf.py +109 -0
- misleep/io/mat.py +189 -0
- misleep/logger.py +79 -0
- misleep/preprocessing/__init__.py +23 -0
- misleep/preprocessing/artifacts.py +76 -0
- misleep/preprocessing/filtering.py +94 -0
- misleep/preprocessing/segment.py +59 -0
- misleep/preprocessing/spectral.py +169 -0
- misleep/utils/__init__.py +45 -0
- misleep/utils/annotation.py +130 -0
- misleep/utils/entropy.py +135 -0
- misleep/utils/misc.py +69 -0
- misleep/utils/time_utils.py +61 -0
- misleep/viz/__init__.py +12 -0
- misleep/viz/hypnogram.py +48 -0
- misleep/viz/signals.py +51 -0
- misleep/viz/spectral.py +98 -0
- misleep-0.3.0.dist-info/METADATA +249 -0
- misleep-0.3.0.dist-info/RECORD +94 -0
- misleep-0.3.0.dist-info/WHEEL +5 -0
- misleep-0.3.0.dist-info/entry_points.txt +15 -0
- misleep-0.3.0.dist-info/licenses/LICENSE +29 -0
- misleep-0.3.0.dist-info/top_level.txt +1 -0
misleep/io/base.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Input/output module: loading and saving signals and annotations.
|
|
3
|
+
|
|
4
|
+
The module is organised around two light-weight registries (readers and
|
|
5
|
+
writers) that map file extensions to callables. Third-party code can
|
|
6
|
+
register new formats either programmatically
|
|
7
|
+
|
|
8
|
+
.. code-block:: python
|
|
9
|
+
|
|
10
|
+
from misleep.io.base import register_signal_reader
|
|
11
|
+
register_signal_reader(".xyz", my_xyz_reader)
|
|
12
|
+
|
|
13
|
+
or declaratively through the ``misleep.signal_readers`` / ``misleep.signal_writers``
|
|
14
|
+
entry-point groups (see the project's ``pyproject.toml``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import importlib.metadata
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
from misleep.data import MiData, MiAnnotation # backward-compatible re-export
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"MiData",
|
|
25
|
+
"MiAnnotation",
|
|
26
|
+
"register_signal_reader",
|
|
27
|
+
"register_signal_writer",
|
|
28
|
+
"load_signal",
|
|
29
|
+
"write_signal",
|
|
30
|
+
"available_readers",
|
|
31
|
+
"available_writers",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
#: Built-in readers/writers: extension (lower-case, with dot) -> callable.
|
|
35
|
+
_BUILTIN_READERS: dict[str, Callable] = {}
|
|
36
|
+
_BUILTIN_WRITERS: dict[str, Callable] = {}
|
|
37
|
+
_ENTRYPOINT_READERS: dict[str, Callable] | None = None
|
|
38
|
+
_ENTRYPOINT_WRITERS: dict[str, Callable] | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalise_extension(extension: str) -> str:
|
|
42
|
+
"""Return a lower-case extension with a leading dot.
|
|
43
|
+
|
|
44
|
+
Entry-point names cannot conveniently be written with a leading dot in
|
|
45
|
+
every packaging tool, while programmatic registrations traditionally use
|
|
46
|
+
one. Normalising both forms fixes third-party readers named ``xyz`` not
|
|
47
|
+
being found for a ``.xyz`` file.
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(extension, str) or not extension.strip():
|
|
50
|
+
raise ValueError("A non-empty file extension is required")
|
|
51
|
+
extension = extension.strip().lower()
|
|
52
|
+
return extension if extension.startswith(".") else f".{extension}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def register_signal_reader(extension: str, func: Callable) -> None:
|
|
56
|
+
"""Register a callable that loads a :class:`MiData` from ``extension`` files.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
extension : str
|
|
61
|
+
File extension including the dot, e.g. ``".mat"``. Matching is
|
|
62
|
+
case-insensitive.
|
|
63
|
+
func : callable
|
|
64
|
+
``func(path: str) -> MiData``.
|
|
65
|
+
"""
|
|
66
|
+
if not callable(func):
|
|
67
|
+
raise TypeError("Signal reader must be callable")
|
|
68
|
+
_BUILTIN_READERS[_normalise_extension(extension)] = func
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def register_signal_writer(extension: str, func: Callable) -> None:
|
|
72
|
+
"""Register a callable that saves a :class:`MiData` to ``extension`` files.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
extension : str
|
|
77
|
+
File extension including the dot, e.g. ``".edf"``.
|
|
78
|
+
func : callable
|
|
79
|
+
``func(signals, channels, sf, time, file_path) -> None``.
|
|
80
|
+
"""
|
|
81
|
+
if not callable(func):
|
|
82
|
+
raise TypeError("Signal writer must be callable")
|
|
83
|
+
_BUILTIN_WRITERS[_normalise_extension(extension)] = func
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _load_entry_points(group: str) -> dict[str, Callable]:
|
|
87
|
+
"""Load third-party entry points for a given group (best effort)."""
|
|
88
|
+
registry: dict[str, Callable] = {}
|
|
89
|
+
try:
|
|
90
|
+
eps = importlib.metadata.entry_points()
|
|
91
|
+
if hasattr(eps, "select"):
|
|
92
|
+
matches = eps.select(group=group)
|
|
93
|
+
else: # pragma: no cover - Python < 3.10
|
|
94
|
+
matches = eps.get(group, [])
|
|
95
|
+
for ep in matches:
|
|
96
|
+
try:
|
|
97
|
+
func = ep.load()
|
|
98
|
+
registry[_normalise_extension(ep.name)] = func
|
|
99
|
+
except Exception:
|
|
100
|
+
continue
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return registry
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _all_readers() -> dict[str, callable]:
|
|
107
|
+
global _ENTRYPOINT_READERS
|
|
108
|
+
if _ENTRYPOINT_READERS is None:
|
|
109
|
+
_ENTRYPOINT_READERS = _load_entry_points("misleep.signal_readers")
|
|
110
|
+
return {**_ENTRYPOINT_READERS, **_BUILTIN_READERS}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _all_writers() -> dict[str, callable]:
|
|
114
|
+
global _ENTRYPOINT_WRITERS
|
|
115
|
+
if _ENTRYPOINT_WRITERS is None:
|
|
116
|
+
_ENTRYPOINT_WRITERS = _load_entry_points("misleep.signal_writers")
|
|
117
|
+
return {**_ENTRYPOINT_WRITERS, **_BUILTIN_WRITERS}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def available_readers() -> list[str]:
|
|
121
|
+
"""List the registered file extensions that can be loaded."""
|
|
122
|
+
return sorted(_all_readers().keys())
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def available_writers() -> list[str]:
|
|
126
|
+
"""List the registered file extensions that can be saved."""
|
|
127
|
+
return sorted(_all_writers().keys())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_signal(data_path: str | Path):
|
|
131
|
+
"""Load a signal file by dispatching on its extension.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
data_path : str or Path
|
|
136
|
+
Path of the file to load (``.mat``, ``.edf`` or any registered
|
|
137
|
+
extension).
|
|
138
|
+
|
|
139
|
+
Returns
|
|
140
|
+
-------
|
|
141
|
+
MiData
|
|
142
|
+
The loaded data.
|
|
143
|
+
"""
|
|
144
|
+
path = Path(data_path)
|
|
145
|
+
if not path.is_file():
|
|
146
|
+
raise FileNotFoundError(f"Signal file not found: {path}")
|
|
147
|
+
suffix = path.suffix.lower()
|
|
148
|
+
readers = _all_readers()
|
|
149
|
+
if suffix not in readers:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"Unsupported file extension '{suffix}'. "
|
|
152
|
+
f"Registered readers: {sorted(readers)}"
|
|
153
|
+
)
|
|
154
|
+
return readers[suffix](str(data_path))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def write_signal(midata, file_path: str | Path) -> None:
|
|
158
|
+
"""Save a :class:`MiData` by dispatching on the target extension.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
midata : MiData
|
|
163
|
+
Data to save.
|
|
164
|
+
file_path : str or Path
|
|
165
|
+
Destination file (``.mat``, ``.edf`` or any registered extension).
|
|
166
|
+
"""
|
|
167
|
+
suffix = Path(file_path).suffix.lower()
|
|
168
|
+
writers = _all_writers()
|
|
169
|
+
if suffix not in writers:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"Unsupported file extension '{suffix}'. "
|
|
172
|
+
f"Registered writers: {sorted(writers)}"
|
|
173
|
+
)
|
|
174
|
+
writers[suffix](midata.signals, midata.channels, midata.sf, midata.time, str(file_path))
|
misleep/io/edf.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""EDF (European Data Format) file reader/writer.
|
|
3
|
+
|
|
4
|
+
Uses the ``pyedflib`` package for both reading and writing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime
|
|
8
|
+
|
|
9
|
+
from misleep.data import MiData
|
|
10
|
+
from misleep.io.base import register_signal_reader, register_signal_writer
|
|
11
|
+
from misleep.logger import logger
|
|
12
|
+
|
|
13
|
+
_TIME_FORMAT = "%Y%m%d-%H:%M:%S"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_edf(data_path):
|
|
17
|
+
"""Load an EDF/EDF+ file into a :class:`MiData`.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
data_path : str
|
|
22
|
+
Path of the ``.edf`` file.
|
|
23
|
+
|
|
24
|
+
Returns
|
|
25
|
+
-------
|
|
26
|
+
MiData
|
|
27
|
+
The loaded data.
|
|
28
|
+
"""
|
|
29
|
+
import pyedflib
|
|
30
|
+
|
|
31
|
+
signals, signal_headers, meta = pyedflib.highlevel.read_edf(edf_file=data_path)
|
|
32
|
+
|
|
33
|
+
return MiData(
|
|
34
|
+
signals=signals,
|
|
35
|
+
channels=[each["label"] for each in signal_headers],
|
|
36
|
+
sf=[each["sample_frequency"] for each in signal_headers],
|
|
37
|
+
time=meta["startdate"].strftime(_TIME_FORMAT),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def write_edf(signals, channels, sf, time, edf_file=None):
|
|
42
|
+
"""Write signal data to an EDF file.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
signals : list of ndarray
|
|
47
|
+
Signal data, one array per channel.
|
|
48
|
+
channels : list of str
|
|
49
|
+
Channel names.
|
|
50
|
+
sf : list of float
|
|
51
|
+
Sampling frequencies.
|
|
52
|
+
time : str
|
|
53
|
+
Acquisition time in ``YYYYMMDD-HH:MM:SS`` format.
|
|
54
|
+
edf_file : str, optional
|
|
55
|
+
Destination path. Defaults to a timestamped file in the current
|
|
56
|
+
directory.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
None
|
|
61
|
+
"""
|
|
62
|
+
import pyedflib
|
|
63
|
+
|
|
64
|
+
if edf_file is None:
|
|
65
|
+
edf_file = f"./{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_saved.edf"
|
|
66
|
+
|
|
67
|
+
header = {
|
|
68
|
+
"technician": "",
|
|
69
|
+
"recording_additional": "",
|
|
70
|
+
"patientname": "MiSleep",
|
|
71
|
+
"patient_additional": "",
|
|
72
|
+
"patientcode": "",
|
|
73
|
+
"equipment": "",
|
|
74
|
+
"admincode": "",
|
|
75
|
+
"sex": "",
|
|
76
|
+
"startdate": datetime.datetime.strptime(time, _TIME_FORMAT),
|
|
77
|
+
"birthdate": "",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
signal_headers = [
|
|
81
|
+
{
|
|
82
|
+
"label": each,
|
|
83
|
+
"dimension": "uV",
|
|
84
|
+
"sample_frequency": sf[idx],
|
|
85
|
+
"physical_max": 10417.0,
|
|
86
|
+
"physical_min": -10417.0,
|
|
87
|
+
"digital_max": 32767,
|
|
88
|
+
"digital_min": -32768,
|
|
89
|
+
"prefilter": "",
|
|
90
|
+
"transducer": "",
|
|
91
|
+
}
|
|
92
|
+
for idx, each in enumerate(channels)
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
with pyedflib.EdfWriter(edf_file, len(signals)) as edf_writer:
|
|
97
|
+
edf_writer.setHeader(header)
|
|
98
|
+
for i, signal in enumerate(signals):
|
|
99
|
+
edf_writer.setSignalHeader(i, signal_headers[i])
|
|
100
|
+
edf_writer.writeSamples(signals)
|
|
101
|
+
logger.info("Data written to %s", edf_file)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.error(f"Write data ERROR: {e}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
register_signal_reader(".edf", load_edf)
|
|
107
|
+
# pyedflib's high-level reader handles BioSemi BDF/BDF+ as well.
|
|
108
|
+
register_signal_reader(".bdf", load_edf)
|
|
109
|
+
register_signal_writer(".edf", write_edf)
|
misleep/io/mat.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""MATLAB ``.mat`` file reader/writer.
|
|
3
|
+
|
|
4
|
+
Supports all three common MAT variants:
|
|
5
|
+
|
|
6
|
+
* v5 / v7 -- loaded with :func:`scipy.io.loadmat`
|
|
7
|
+
* v7.3 -- loaded with the pure-python ``mat73`` package
|
|
8
|
+
* python-saved MiSleep files (identified by a ``save = 'python'`` entry)
|
|
9
|
+
|
|
10
|
+
Writing always produces a v5 ``.mat`` file compatible with MATLAB R14+.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import datetime
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from misleep.data import MiData
|
|
19
|
+
from misleep.logger import logger
|
|
20
|
+
from misleep.io.base import register_signal_reader, register_signal_writer
|
|
21
|
+
|
|
22
|
+
#: Regex for the MiSleep acquisition-time format ``YYYYMMDD-HH:MM:SS``
|
|
23
|
+
_TIME_FORMAT = "%Y%m%d-%H:%M:%S"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_scipy(raw_data):
|
|
27
|
+
"""Parse a dict produced by scipy.io.loadmat into a MiData."""
|
|
28
|
+
names = raw_data.dtype.names
|
|
29
|
+
|
|
30
|
+
# Old version misleep data: raw 2-D numeric array without structure
|
|
31
|
+
if names is None:
|
|
32
|
+
if raw_data.shape[0] > raw_data.shape[1]:
|
|
33
|
+
signals = raw_data.T
|
|
34
|
+
else:
|
|
35
|
+
signals = raw_data
|
|
36
|
+
channels = [f"ch{each + 1}" for each in range(signals.shape[0])]
|
|
37
|
+
sf = [305.0 for _ in range(signals.shape[0])]
|
|
38
|
+
time = datetime.datetime.now().strftime(_TIME_FORMAT)
|
|
39
|
+
return MiData(signals=signals, channels=channels, sf=sf, time=time)
|
|
40
|
+
|
|
41
|
+
raw_data = raw_data[0][0]
|
|
42
|
+
|
|
43
|
+
# Saved by python: channels listed first, then metadata fields
|
|
44
|
+
if "save" in names:
|
|
45
|
+
channels = list(names[:-4])
|
|
46
|
+
sf = [float(each) for each in raw_data["sf"][0]]
|
|
47
|
+
signals = [raw_data[each][0] for each in channels]
|
|
48
|
+
time = raw_data["time"][0]
|
|
49
|
+
return MiData(signals=signals, channels=channels, sf=sf, time=time)
|
|
50
|
+
|
|
51
|
+
# Saved by matlab
|
|
52
|
+
channels = [each for item in raw_data["channels"][0] for each in item]
|
|
53
|
+
sf = [float(each[0]) for item in raw_data["sf"][0] for each in item]
|
|
54
|
+
signals = []
|
|
55
|
+
for each in channels:
|
|
56
|
+
signal_ = raw_data[each]
|
|
57
|
+
if signal_.shape[0] > signal_.shape[1]:
|
|
58
|
+
signals.append(signal_.T[0])
|
|
59
|
+
if signal_.shape[0] < signal_.shape[1]:
|
|
60
|
+
signals.append(signal_[0])
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
time = raw_data["time"][0][0][0]
|
|
64
|
+
datetime.datetime.strptime(time, _TIME_FORMAT)
|
|
65
|
+
except (ValueError, TypeError):
|
|
66
|
+
time = raw_data["time"][0]
|
|
67
|
+
|
|
68
|
+
return MiData(signals=signals, channels=channels, sf=sf, time=time)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_mat73(raw_data):
|
|
72
|
+
"""Parse a dict produced by mat73.loadmat into a MiData."""
|
|
73
|
+
try:
|
|
74
|
+
_ = raw_data["channels"]
|
|
75
|
+
except (KeyError, TypeError):
|
|
76
|
+
# Old version misleep data: raw numeric array
|
|
77
|
+
if raw_data.shape[0] > raw_data.shape[1]:
|
|
78
|
+
signals = raw_data.T
|
|
79
|
+
else:
|
|
80
|
+
signals = raw_data
|
|
81
|
+
channels = [f"ch{each + 1}" for each in range(signals.shape[0])]
|
|
82
|
+
sf = [305.0 for _ in range(signals.shape[0])]
|
|
83
|
+
time = datetime.datetime.now().strftime(_TIME_FORMAT)
|
|
84
|
+
return MiData(signals=signals, channels=channels, sf=sf, time=time)
|
|
85
|
+
|
|
86
|
+
channels = list(raw_data["channels"])
|
|
87
|
+
sf = [float(each) for each in raw_data["sf"]]
|
|
88
|
+
time = raw_data["time"][0]
|
|
89
|
+
|
|
90
|
+
signals = []
|
|
91
|
+
for each in channels:
|
|
92
|
+
signal_ = raw_data[each]
|
|
93
|
+
if signal_.shape[0] > 1:
|
|
94
|
+
signals.append(signal_)
|
|
95
|
+
if signal_.shape[0] == 1:
|
|
96
|
+
signals.append(signal_.T)
|
|
97
|
+
|
|
98
|
+
return MiData(signals=signals, channels=channels, sf=sf, time=time)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_mat(data_path):
|
|
102
|
+
"""Load a ``.mat`` file into a :class:`MiData`.
|
|
103
|
+
|
|
104
|
+
The loader automatically detects the MAT version (v5, v7 or v7.3) and
|
|
105
|
+
the origin of the file (MATLAB vs. MiSleep/python saved).
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
data_path : str
|
|
110
|
+
Path of the ``.mat`` file.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
MiData or None
|
|
115
|
+
The loaded data, or ``None`` when the file could not be parsed.
|
|
116
|
+
"""
|
|
117
|
+
from scipy.io import loadmat as scipy_loadmat
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
raw_data = list(scipy_loadmat(data_path).values())[-1]
|
|
121
|
+
return _load_scipy(raw_data)
|
|
122
|
+
except NotImplementedError:
|
|
123
|
+
# v7.3 file -> use mat73
|
|
124
|
+
try:
|
|
125
|
+
from mat73 import loadmat as mat73_loadmat
|
|
126
|
+
|
|
127
|
+
raw_data = list(mat73_loadmat(data_path).values())[-1]
|
|
128
|
+
return _load_mat73(raw_data)
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.error(f"Load data ERROR: {e}")
|
|
131
|
+
return None
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.error(f"Load data ERROR: {e}")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_mat(signals, channels, sf, time, mat_file=None):
|
|
138
|
+
"""Write signal data to a v5 ``.mat`` file.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
signals : list of ndarray
|
|
143
|
+
Signal data, one array per channel.
|
|
144
|
+
channels : list of str
|
|
145
|
+
Channel names.
|
|
146
|
+
sf : list of float
|
|
147
|
+
Sampling frequencies.
|
|
148
|
+
time : str
|
|
149
|
+
Acquisition time in ``YYYYMMDD-HH:MM:SS`` format.
|
|
150
|
+
mat_file : str, optional
|
|
151
|
+
Destination path. Defaults to a timestamped file in the current
|
|
152
|
+
directory.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
None
|
|
157
|
+
"""
|
|
158
|
+
from scipy.io import savemat
|
|
159
|
+
|
|
160
|
+
if mat_file is None:
|
|
161
|
+
mat_file = f"./{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_saved.mat"
|
|
162
|
+
|
|
163
|
+
mdict = {"data": dict(zip(channels, signals))}
|
|
164
|
+
mdict["data"]["save"] = "python"
|
|
165
|
+
mdict["data"]["channels"] = channels
|
|
166
|
+
mdict["data"]["sf"] = sf
|
|
167
|
+
mdict["data"]["time"] = time
|
|
168
|
+
|
|
169
|
+
savemat(file_name=mat_file, mdict=mdict, format="5", oned_as="row")
|
|
170
|
+
logger.info("Data written to %s", mat_file)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
register_signal_reader(".mat", load_mat)
|
|
174
|
+
register_signal_writer(".mat", write_mat)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _legacy_time_to_seconds(time_str: str) -> int:
|
|
178
|
+
"""Convert a MiSleep time string to seconds since the day start."""
|
|
179
|
+
parts = [int(x) for x in time_str.split("-")[-1].split(":")]
|
|
180
|
+
return ((parts[0] * 60) + parts[1]) * 60 + parts[2]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_start_second(acquisition_time_str: str) -> int:
|
|
184
|
+
"""Return the second-of-day of an acquisition time (helper for exports)."""
|
|
185
|
+
m = re.match(r"(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2}):(\d{2})", acquisition_time_str)
|
|
186
|
+
if not m:
|
|
187
|
+
raise ValueError(f"Invalid acquisition time: {acquisition_time_str!r}")
|
|
188
|
+
_, _, _, hh, mm, ss = (int(g) for g in m.groups())
|
|
189
|
+
return hh * 3600 + mm * 60 + ss
|
misleep/logger.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Logging setup for MiSleep.
|
|
3
|
+
|
|
4
|
+
MiSleep uses a single module-level :data:`logger` instance so that every
|
|
5
|
+
sub-module can simply do ``from misleep.logger import logger`` and log to
|
|
6
|
+
the same destination.
|
|
7
|
+
|
|
8
|
+
The log is written both to the console (stdout) and to a rotating log file
|
|
9
|
+
stored under the per-user MiSleep data directory (see :func:`get_data_dir`).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from logging.handlers import RotatingFileHandler
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_data_dir() -> Path:
|
|
20
|
+
"""Return the per-user MiSleep data directory.
|
|
21
|
+
|
|
22
|
+
The directory is created on first use. On all platforms it defaults to
|
|
23
|
+
``~/.misleep``, which keeps things simple and cross-platform friendly.
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
Path
|
|
28
|
+
Absolute path of the data directory.
|
|
29
|
+
"""
|
|
30
|
+
override = os.environ.get("MISLEEP_DATA_DIR")
|
|
31
|
+
data_dir = Path(override).expanduser() if override else Path.home() / ".misleep"
|
|
32
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return data_dir
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _setup_logger() -> logging.Logger:
|
|
37
|
+
"""Create and configure the MiSleep root logger."""
|
|
38
|
+
logger = logging.getLogger("misleep")
|
|
39
|
+
logger.setLevel(logging.INFO)
|
|
40
|
+
logger.propagate = False
|
|
41
|
+
|
|
42
|
+
# Avoid double handlers when the module is reloaded
|
|
43
|
+
if logger.handlers:
|
|
44
|
+
return logger
|
|
45
|
+
|
|
46
|
+
formatter = logging.Formatter(
|
|
47
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Console handler (skipped when launched with pythonw, where both
|
|
51
|
+
# stdout and stderr are None)
|
|
52
|
+
console_stream = sys.stderr if sys.stderr is not None else sys.stdout
|
|
53
|
+
if console_stream is not None:
|
|
54
|
+
stream_handler = logging.StreamHandler(console_stream)
|
|
55
|
+
stream_handler.setLevel(logging.INFO)
|
|
56
|
+
stream_handler.setFormatter(formatter)
|
|
57
|
+
logger.addHandler(stream_handler)
|
|
58
|
+
|
|
59
|
+
# Rotating file handler under the user data directory
|
|
60
|
+
try:
|
|
61
|
+
log_dir = get_data_dir() / "logs"
|
|
62
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
file_handler = RotatingFileHandler(
|
|
64
|
+
log_dir / "misleep.log",
|
|
65
|
+
maxBytes=5 * 1024 * 1024,
|
|
66
|
+
backupCount=3,
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
)
|
|
69
|
+
file_handler.setLevel(logging.INFO)
|
|
70
|
+
file_handler.setFormatter(formatter)
|
|
71
|
+
logger.addHandler(file_handler)
|
|
72
|
+
except OSError: # pragma: no cover - e.g. read-only home directory
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
return logger
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
#: The shared logger instance used across the package.
|
|
79
|
+
logger = _setup_logger()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Signal preprocessing module.
|
|
3
|
+
|
|
4
|
+
* :mod:`misleep.preprocessing.filtering` -- filtering (Butterworth, mains noise)
|
|
5
|
+
* :mod:`misleep.preprocessing.artifacts` -- artifact rejection
|
|
6
|
+
* :mod:`misleep.preprocessing.spectral` -- spectrum / spectrogram / band power
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .filtering import signal_filter, filter_power_line_noise
|
|
10
|
+
from .artifacts import z_score, reject_artifact
|
|
11
|
+
from .spectral import spectrum, spectrogram, band_power
|
|
12
|
+
from .segment import crop_state_data
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"signal_filter",
|
|
16
|
+
"filter_power_line_noise",
|
|
17
|
+
"z_score",
|
|
18
|
+
"reject_artifact",
|
|
19
|
+
"spectrum",
|
|
20
|
+
"spectrogram",
|
|
21
|
+
"band_power",
|
|
22
|
+
"crop_state_data",
|
|
23
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Artifact rejection.
|
|
3
|
+
|
|
4
|
+
The current implementation rejects 5-second epochs whose standard
|
|
5
|
+
deviation deviates strongly from the average epoch standard deviation
|
|
6
|
+
(:func:`reject_artifact`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def z_score(signal):
|
|
13
|
+
"""Standardize a signal: ``(x - mean) / std``.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
signal : ndarray
|
|
18
|
+
Signal data.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
ndarray
|
|
23
|
+
Z-scored signal.
|
|
24
|
+
"""
|
|
25
|
+
lst_mean = np.mean(signal, axis=0)
|
|
26
|
+
lst_std = np.std(signal, axis=0)
|
|
27
|
+
normalized_data = (signal - lst_mean) / lst_std
|
|
28
|
+
return normalized_data
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reject_artifact(signal, sf=None, threshold=2):
|
|
32
|
+
"""Reject artifact epochs based on per-epoch standard deviation.
|
|
33
|
+
|
|
34
|
+
The signal is split into 5-second epochs; every epoch whose standard
|
|
35
|
+
deviation is ``threshold`` times larger than the mean epoch standard
|
|
36
|
+
deviation is removed, and the remaining samples are concatenated.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
signal : ndarray
|
|
41
|
+
Signal data.
|
|
42
|
+
sf : float, optional
|
|
43
|
+
Sampling frequency. When ``None`` the epoch length defaults to
|
|
44
|
+
1/5th of the signal.
|
|
45
|
+
threshold : float
|
|
46
|
+
Relative standard-deviation threshold. Default is 2.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
ndarray
|
|
51
|
+
The cleaned signal (may be shorter than the input).
|
|
52
|
+
"""
|
|
53
|
+
signal_z_score = z_score(signal)
|
|
54
|
+
# get epoch data (5 second windows)
|
|
55
|
+
if sf is None:
|
|
56
|
+
sf = signal_z_score.shape[0] / 5
|
|
57
|
+
signal_z_score = [
|
|
58
|
+
signal_z_score[int(each): int(each + 5 * sf)]
|
|
59
|
+
for each in range(0, signal_z_score.shape[0], int(5 * sf))
|
|
60
|
+
]
|
|
61
|
+
signal_SD_lst = [np.std(each) for each in signal_z_score]
|
|
62
|
+
ave_signal_sd = np.mean(signal_SD_lst)
|
|
63
|
+
artifacts_idx = []
|
|
64
|
+
|
|
65
|
+
for each in signal_SD_lst:
|
|
66
|
+
if each / ave_signal_sd >= threshold:
|
|
67
|
+
artifacts_idx.append(0)
|
|
68
|
+
else:
|
|
69
|
+
artifacts_idx.append(1)
|
|
70
|
+
|
|
71
|
+
artifacts_idx = np.array(artifacts_idx).astype(bool)
|
|
72
|
+
artifacts_idx = np.repeat(artifacts_idx, int(5 * sf))
|
|
73
|
+
slice_length = artifacts_idx.shape[0] if artifacts_idx.shape[0] < len(signal) else len(signal)
|
|
74
|
+
signal = np.array(signal[:slice_length])[artifacts_idx[:slice_length]]
|
|
75
|
+
|
|
76
|
+
return signal
|