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.
Potentially problematic release.
This version of misleep might be problematic. Click here for more details.
- 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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Configuration handling for MiSleep.
|
|
3
|
+
|
|
4
|
+
MiSleep is configured through an INI file with two sections:
|
|
5
|
+
|
|
6
|
+
* ``[gui]`` -- GUI behaviour (state map, state colors, markers, ...)
|
|
7
|
+
* ``[spec]`` -- spectral analysis defaults (window length, nfft, smoothing)
|
|
8
|
+
|
|
9
|
+
A default configuration is bundled with the package under
|
|
10
|
+
``misleep/config/default_config.ini``. On first run a *user* configuration
|
|
11
|
+
file is created at ``~/.misleep/misleep_config.ini`` and any user setting
|
|
12
|
+
overrides the bundled default, so package upgrades never wipe personal
|
|
13
|
+
settings.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import configparser
|
|
17
|
+
import shutil
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from misleep.logger import get_data_dir, logger
|
|
21
|
+
|
|
22
|
+
#: Name of the per-user configuration file.
|
|
23
|
+
USER_CONFIG_NAME = "misleep_config.ini"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def default_config_path() -> Path:
|
|
27
|
+
"""Return the path of the bundled default configuration file."""
|
|
28
|
+
import importlib.resources
|
|
29
|
+
|
|
30
|
+
return Path(str(importlib.resources.files(__name__).joinpath("default_config.ini")))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def user_config_path() -> Path:
|
|
34
|
+
"""Return the path of the per-user configuration file."""
|
|
35
|
+
return get_data_dir() / USER_CONFIG_NAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ensure_user_config() -> Path:
|
|
39
|
+
"""Create the per-user configuration file on first use and return it."""
|
|
40
|
+
path = user_config_path()
|
|
41
|
+
if not path.exists():
|
|
42
|
+
try:
|
|
43
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
shutil.copy(default_config_path(), path)
|
|
45
|
+
logger.info("Created user configuration at %s", path)
|
|
46
|
+
except OSError as exc: # pragma: no cover
|
|
47
|
+
logger.warning("Could not create user config at %s: %s", path, exc)
|
|
48
|
+
return path
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config(path: str | Path | None = None) -> configparser.ConfigParser:
|
|
52
|
+
"""Load the effective MiSleep configuration.
|
|
53
|
+
|
|
54
|
+
The bundled defaults are loaded first and then overridden by the
|
|
55
|
+
per-user configuration file, so any key the user did not customize
|
|
56
|
+
keeps its default value.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
path : str | Path | None
|
|
61
|
+
Explicit path to a configuration file. When ``None`` (default),
|
|
62
|
+
the per-user configuration is used on top of the bundled default.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
configparser.ConfigParser
|
|
67
|
+
The merged configuration.
|
|
68
|
+
"""
|
|
69
|
+
config = configparser.ConfigParser()
|
|
70
|
+
config.read(default_config_path(), encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
default_user_path = path is None
|
|
73
|
+
if default_user_path:
|
|
74
|
+
path = _ensure_user_config()
|
|
75
|
+
if Path(path).exists():
|
|
76
|
+
config.read(path, encoding="utf-8")
|
|
77
|
+
elif not default_user_path:
|
|
78
|
+
logger.warning("Configuration file %s does not exist, using defaults", path)
|
|
79
|
+
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def save_config(config: configparser.ConfigParser, path: str | Path | None = None) -> Path:
|
|
84
|
+
"""Persist a configuration back to disk.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
config : configparser.ConfigParser
|
|
89
|
+
The configuration to save.
|
|
90
|
+
path : str | Path | None
|
|
91
|
+
Destination file. Defaults to the per-user configuration file.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
Path
|
|
96
|
+
The file the configuration was written to.
|
|
97
|
+
"""
|
|
98
|
+
if path is None:
|
|
99
|
+
path = _ensure_user_config()
|
|
100
|
+
path = Path(path)
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
103
|
+
config.write(f)
|
|
104
|
+
logger.info("Configuration saved to %s", path)
|
|
105
|
+
return path
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# MiSleep default configuration
|
|
3
|
+
# ============================================================
|
|
4
|
+
# This file ships with the package and is merged with the user
|
|
5
|
+
# configuration stored at:
|
|
6
|
+
# Windows : %USERPROFILE%\.misleep\misleep_config.ini
|
|
7
|
+
# macOS : ~/.misleep/misleep_config.ini
|
|
8
|
+
# Linux : ~/.misleep/misleep_config.ini
|
|
9
|
+
#
|
|
10
|
+
# The GUI offers "Config" under the Help menu to open the USER
|
|
11
|
+
# configuration file with your system editor. Copy any section
|
|
12
|
+
# you want to customize from here and restart MiSleep.
|
|
13
|
+
#
|
|
14
|
+
# DO NOT edit this bundled file directly -- your changes would be
|
|
15
|
+
# overwritten on the next package update.
|
|
16
|
+
|
|
17
|
+
[gui]
|
|
18
|
+
version = v0.3.0
|
|
19
|
+
updatetime = 2026/08/18
|
|
20
|
+
marker = ['half_signal', 'first REM', 'WindEEG', 'W-R', 'maker']
|
|
21
|
+
startend = ['high_theta', 'REM', 'Wake', 'Spindle', 'SWA', 'start end label', 'start end label']
|
|
22
|
+
statemap = {"1": "NREM", "2": "REM", "3": "Wake", "4": "INIT"}
|
|
23
|
+
statecolor = {"1": "orange", "2": "skyblue", "3": "red", "4": "#ececec"}
|
|
24
|
+
startendcolor = {"NREM": "orange", "REM": "skyblue", "Wake": "red"}
|
|
25
|
+
statecolorbgalpha = 0.1
|
|
26
|
+
hypnogramstatealpha = 0.55
|
|
27
|
+
markerlinecolor = "red"
|
|
28
|
+
startendlinecolor = "blue"
|
|
29
|
+
freq_range = [0.5, 30]
|
|
30
|
+
openpath = .
|
|
31
|
+
theme = light
|
|
32
|
+
color_tone = black
|
|
33
|
+
spectrogram_cmap = jet
|
|
34
|
+
|
|
35
|
+
[spec]
|
|
36
|
+
win_length_sec = 10.0
|
|
37
|
+
nfft_sec = 10.0
|
|
38
|
+
gaussian_sigma = 1.0
|
misleep/data/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Data model module.
|
|
3
|
+
|
|
4
|
+
This module contains the in-memory data containers used across MiSleep:
|
|
5
|
+
|
|
6
|
+
* :class:`misleep.data.midata.MiData` -- raw signal recordings
|
|
7
|
+
* :class:`misleep.data.annotation.MiAnnotation` -- sleep scoring/annotation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .midata import MiData
|
|
11
|
+
from .annotation import MiAnnotation
|
|
12
|
+
|
|
13
|
+
__all__ = ["MiData", "MiAnnotation"]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Core annotation container :class:`MiAnnotation`.
|
|
3
|
+
|
|
4
|
+
A :class:`MiAnnotation` stores everything the user scores for one
|
|
5
|
+
recording:
|
|
6
|
+
|
|
7
|
+
* the per-second **sleep state** sequence (1 = NREM, 2 = REM, 3 = Wake,
|
|
8
|
+
4 = Init by default, but fully configurable through ``state_map``),
|
|
9
|
+
* single time-point **markers** (e.g. ``[30.5, 'injection']``),
|
|
10
|
+
* **start-end** events (e.g. ``[1, 20, 'spindle']``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MiAnnotation:
|
|
15
|
+
"""MiSleep annotation class.
|
|
16
|
+
|
|
17
|
+
Default state mapping::
|
|
18
|
+
|
|
19
|
+
1 -- NREM
|
|
20
|
+
2 -- REM
|
|
21
|
+
3 -- Wake
|
|
22
|
+
4 -- Init
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
sleep_state : list
|
|
27
|
+
Per-second sleep state labels. The length of ``sleep_state`` equals
|
|
28
|
+
the total duration (in seconds) of the recording. Every element must
|
|
29
|
+
be a key of ``state_map``.
|
|
30
|
+
marker : list, optional
|
|
31
|
+
Single time-point events, e.g. ``[[1, 'injection'], [30, 'injection']]``.
|
|
32
|
+
start_end : list, optional
|
|
33
|
+
Start-end events, e.g. ``[[1, 20, 'spindle'], [30, 50, 'SWA']]``.
|
|
34
|
+
state_map : dict, optional
|
|
35
|
+
Mapping from state code to its meaning. Defaults to
|
|
36
|
+
``{1: 'NREM', 2: 'REM', 3: 'Wake', 4: 'Init'}``.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, sleep_state, marker=None, start_end=None, state_map=None):
|
|
40
|
+
if not isinstance(sleep_state, list):
|
|
41
|
+
raise TypeError(f"'sleep_state' should be a list, got {type(sleep_state)}")
|
|
42
|
+
|
|
43
|
+
if state_map is None:
|
|
44
|
+
self._state_map = {1: "NREM", 2: "REM", 3: "Wake", 4: "Init"}
|
|
45
|
+
else:
|
|
46
|
+
self._state_map = state_map
|
|
47
|
+
|
|
48
|
+
for each in sleep_state:
|
|
49
|
+
if each not in self._state_map.keys():
|
|
50
|
+
raise ValueError(f"Content {each} in the 'sleep_state' does not exist in {self._state_map}")
|
|
51
|
+
|
|
52
|
+
self._sleep_state = sleep_state
|
|
53
|
+
self._anno_length = len(sleep_state)
|
|
54
|
+
|
|
55
|
+
if marker is not None:
|
|
56
|
+
if not isinstance(marker, list):
|
|
57
|
+
raise TypeError(f"'marker' should be a list, got {type(marker)}")
|
|
58
|
+
self._marker = marker if marker is not None else []
|
|
59
|
+
|
|
60
|
+
if start_end is not None:
|
|
61
|
+
if not isinstance(start_end, list):
|
|
62
|
+
raise TypeError(f"'start_end' should be a list, got {type(start_end)}")
|
|
63
|
+
self._start_end = start_end if start_end is not None else []
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# Properties
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
@property
|
|
69
|
+
def sleep_state(self, time_period=None):
|
|
70
|
+
"""Per-second sleep state labels.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
time_period : list of two ints, optional
|
|
75
|
+
Crop the returned sequence to ``[start, end]`` seconds.
|
|
76
|
+
"""
|
|
77
|
+
if time_period is None:
|
|
78
|
+
return self._sleep_state
|
|
79
|
+
return self._sleep_state[time_period[0]: time_period[1]]
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def marker(self, time_period=None):
|
|
83
|
+
"""Marker events, optionally restricted to a time window."""
|
|
84
|
+
if time_period is None:
|
|
85
|
+
return self._marker
|
|
86
|
+
return [each for each in self._marker if time_period[0] <= each[0] <= time_period[1]]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def start_end(self, time_period=None):
|
|
90
|
+
"""Start-end events, optionally restricted to a time window."""
|
|
91
|
+
if time_period is None:
|
|
92
|
+
return self._start_end
|
|
93
|
+
return [each for each in self._start_end if time_period[0] <= each[0] and each[1] <= time_period[1]]
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def anno_length(self):
|
|
97
|
+
"""Length of the annotation in seconds."""
|
|
98
|
+
return self._anno_length
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def state_map(self):
|
|
102
|
+
"""Mapping from state code to state name."""
|
|
103
|
+
return self._state_map
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def state_names(self):
|
|
107
|
+
"""Sorted list of state names used in this annotation."""
|
|
108
|
+
return [self._state_map[k] for k in sorted(self._state_map)]
|
|
109
|
+
|
|
110
|
+
def __repr__(self):
|
|
111
|
+
return (f"MiAnnotation(length={self._anno_length}s, "
|
|
112
|
+
f"markers={len(self._marker)}, "
|
|
113
|
+
f"start_end={len(self._start_end)}, "
|
|
114
|
+
f"state_map={self._state_map})")
|
misleep/data/midata.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""Core signal data container :class:`MiData`.
|
|
3
|
+
|
|
4
|
+
``MiData`` is the primary in-memory representation of a polysomnographic
|
|
5
|
+
recording inside MiSleep. It stores, for every channel:
|
|
6
|
+
|
|
7
|
+
* the signal samples (1-D numpy arrays),
|
|
8
|
+
* the channel name,
|
|
9
|
+
* the sampling frequency of the channel.
|
|
10
|
+
|
|
11
|
+
It also stores the recording start time and an optional free-text
|
|
12
|
+
description, and exposes a small set of in-place or copy-on-write
|
|
13
|
+
operations (filter, differential, crop, pick, rename, add, delete).
|
|
14
|
+
|
|
15
|
+
All channels of a :class:`MiData` object share the same integer duration
|
|
16
|
+
in seconds (the minimum integer duration across channels); longer
|
|
17
|
+
channels are truncated accordingly.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _unique_name(name, existing):
|
|
26
|
+
"""Return ``name`` or the next available ``name_N`` variant."""
|
|
27
|
+
if name not in existing:
|
|
28
|
+
return name
|
|
29
|
+
index = 1
|
|
30
|
+
while f"{name}_{index}" in existing:
|
|
31
|
+
index += 1
|
|
32
|
+
return f"{name}_{index}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MiData:
|
|
36
|
+
"""MiSleep signal data format.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
signals : list or ndarray
|
|
41
|
+
List of 1-D arrays, one per channel.
|
|
42
|
+
channels : list of str
|
|
43
|
+
Channel name for each signal channel, e.g. ``['EEG_F', 'EEG_P', 'EMG']``.
|
|
44
|
+
sf : list of float
|
|
45
|
+
Sampling frequency of each signal channel, e.g. ``[256., 256., 256.]``.
|
|
46
|
+
time : str
|
|
47
|
+
Recording start time in string format, e.g. ``'20240228-19:45:00'``.
|
|
48
|
+
describe : str, optional
|
|
49
|
+
Free-text description of the data. Defaults to ``''``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, signals, channels, sf, time, describe=None):
|
|
53
|
+
self._validate_inputs(signals, channels, sf, describe)
|
|
54
|
+
|
|
55
|
+
self._describe = "" if describe is None else describe
|
|
56
|
+
self._time = time
|
|
57
|
+
|
|
58
|
+
# Avoid duplicated channel names: append "_1", "_2", ...
|
|
59
|
+
temp_channel = []
|
|
60
|
+
for each in channels:
|
|
61
|
+
temp_channel.append(_unique_name(each, temp_channel))
|
|
62
|
+
channels = temp_channel
|
|
63
|
+
|
|
64
|
+
# All channels are truncated to the same integer duration in seconds
|
|
65
|
+
temp_duration = [math.floor(len(signals[idx]) / each) for idx, each in enumerate(sf)]
|
|
66
|
+
self._duration = min(temp_duration)
|
|
67
|
+
|
|
68
|
+
self._signals = [signals[idx][: int(self._duration * each)] for idx, each in enumerate(sf)]
|
|
69
|
+
self._channels = channels
|
|
70
|
+
self._n_channels = len(self._channels)
|
|
71
|
+
self._sf = sf
|
|
72
|
+
|
|
73
|
+
# ------------------------------------------------------------------
|
|
74
|
+
# Validation helpers
|
|
75
|
+
# ------------------------------------------------------------------
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _validate_inputs(signals, channels, sf, describe):
|
|
78
|
+
if not isinstance(signals, (list, np.ndarray)):
|
|
79
|
+
raise TypeError(f"Signals should be a list of arrays or ndarray, got {type(signals)}")
|
|
80
|
+
if len(signals) == 0:
|
|
81
|
+
raise ValueError("At least one signal channel is required")
|
|
82
|
+
for each in signals:
|
|
83
|
+
if not isinstance(each, np.ndarray):
|
|
84
|
+
raise TypeError(f"Signals should be a list of arrays or ndarray, got {type(each)}")
|
|
85
|
+
if each.ndim != 1:
|
|
86
|
+
raise ValueError(f"Each signal must be a 1-D array, got shape {each.shape}")
|
|
87
|
+
|
|
88
|
+
if not isinstance(channels, list):
|
|
89
|
+
raise TypeError(f"Channels should be a list of strings, got {type(channels)}")
|
|
90
|
+
if len(channels) != len(signals):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Length of channels ({len(channels)}) does not match the length of signals ({len(signals)})"
|
|
93
|
+
)
|
|
94
|
+
for each in channels:
|
|
95
|
+
if not isinstance(each, str):
|
|
96
|
+
raise TypeError(f"Channels should be a list of strings, got {type(each)}")
|
|
97
|
+
|
|
98
|
+
if not isinstance(sf, list):
|
|
99
|
+
raise TypeError(f"Sample frequency should be a list of int or float, got {type(sf)}")
|
|
100
|
+
if len(sf) != len(signals):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Length of sample frequency ({len(sf)}) does not match the length of signals ({len(signals)})"
|
|
103
|
+
)
|
|
104
|
+
for each in sf:
|
|
105
|
+
if not isinstance(each, (int, float)):
|
|
106
|
+
raise TypeError(f"Sample frequency should be a list of int or float, got {type(each)}")
|
|
107
|
+
if not math.isfinite(each) or each <= 0:
|
|
108
|
+
raise ValueError(f"Sample frequencies must be finite and positive, got {each}")
|
|
109
|
+
|
|
110
|
+
if describe is not None and not isinstance(describe, str):
|
|
111
|
+
raise TypeError(f"'describe' should be a string for data description, got {type(describe)}")
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
# In-place operations
|
|
115
|
+
# ------------------------------------------------------------------
|
|
116
|
+
def differential(self, chan1=None, chan2=None):
|
|
117
|
+
"""Compute the differential signal ``chan1 - chan2`` and add it as a new channel.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
chan1 : str
|
|
122
|
+
Name of the first channel.
|
|
123
|
+
chan2 : str
|
|
124
|
+
Name of the second channel.
|
|
125
|
+
"""
|
|
126
|
+
if chan1 is None or chan2 is None:
|
|
127
|
+
raise ValueError("Specify two channel names to do differential")
|
|
128
|
+
|
|
129
|
+
if chan1 not in self._channels:
|
|
130
|
+
raise IndexError(f"{chan1} is not in the channel names ({self._channels})")
|
|
131
|
+
if chan2 not in self._channels:
|
|
132
|
+
raise IndexError(f"{chan2} is not in the channel names ({self._channels})")
|
|
133
|
+
|
|
134
|
+
chan1_idx = self._channels.index(chan1)
|
|
135
|
+
chan2_idx = self._channels.index(chan2)
|
|
136
|
+
if self._signals[chan1_idx].shape[0] != self._signals[chan2_idx].shape[0]:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"Channel {chan1} and channel {chan2} have different lengths to do differential "
|
|
139
|
+
f"({self._signals[chan1_idx].shape} and {self._signals[chan2_idx].shape})"
|
|
140
|
+
)
|
|
141
|
+
self.add(self._signals[chan1_idx] - self._signals[chan2_idx],
|
|
142
|
+
f"{chan1}_{chan2}_DIFF", self._sf[chan1_idx])
|
|
143
|
+
|
|
144
|
+
def rename_channels(self, mapping):
|
|
145
|
+
"""Rename channels in place with a mapping.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
mapping : dict
|
|
150
|
+
Map old channel name to a new channel name.
|
|
151
|
+
"""
|
|
152
|
+
if not isinstance(mapping, dict):
|
|
153
|
+
raise TypeError(f"Mapping should be a dict which maps old channel name to a new one, got {type(mapping)}")
|
|
154
|
+
|
|
155
|
+
for each in mapping.keys():
|
|
156
|
+
if each not in self._channels:
|
|
157
|
+
raise IndexError(f"{each} is not in the signal channel list ({self._channels})")
|
|
158
|
+
new_name = mapping[each]
|
|
159
|
+
if not isinstance(new_name, str) or not new_name.strip():
|
|
160
|
+
raise ValueError("New channel names must be non-empty strings")
|
|
161
|
+
index = self._channels.index(each)
|
|
162
|
+
existing = self._channels[:index] + self._channels[index + 1:]
|
|
163
|
+
self._channels[index] = _unique_name(new_name.strip(), existing)
|
|
164
|
+
|
|
165
|
+
def filter(self, chans=None, btype="bandpass", low=0.5, high=30):
|
|
166
|
+
"""Filter the specified channel(s) and add the result as new channel(s).
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
chans : list of str
|
|
171
|
+
Channels to be filtered.
|
|
172
|
+
btype : {'bandpass', 'lowpass', 'highpass', 'bandstop'}, optional
|
|
173
|
+
The type of filter. Default is ``'bandpass'``.
|
|
174
|
+
low : float
|
|
175
|
+
Lower cutoff frequency in Hz.
|
|
176
|
+
high : float
|
|
177
|
+
Higher cutoff frequency in Hz.
|
|
178
|
+
"""
|
|
179
|
+
from misleep.preprocessing.filtering import signal_filter
|
|
180
|
+
|
|
181
|
+
if chans is None or not isinstance(chans, list):
|
|
182
|
+
raise TypeError(f"'chans' should be a list of channel names, got {type(chans)}")
|
|
183
|
+
|
|
184
|
+
for chan in chans:
|
|
185
|
+
if chan in self._channels:
|
|
186
|
+
chan_idx = self._channels.index(chan)
|
|
187
|
+
filtered_data, fname = signal_filter(
|
|
188
|
+
data=self._signals[chan_idx],
|
|
189
|
+
btype=btype, sf=self._sf[chan_idx], low=low, high=high)
|
|
190
|
+
self.add(filtered_data, f"{chan}_{fname}", self._sf[chan_idx])
|
|
191
|
+
else:
|
|
192
|
+
raise IndexError(f"{chan} channel is not in the signal channels ({self._channels})")
|
|
193
|
+
|
|
194
|
+
def add(self, signal, channel, sf):
|
|
195
|
+
"""Add a new signal channel to the data.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
signal : ndarray
|
|
200
|
+
The signal samples.
|
|
201
|
+
channel : str
|
|
202
|
+
Channel name.
|
|
203
|
+
sf : float
|
|
204
|
+
Sampling frequency of the new channel.
|
|
205
|
+
"""
|
|
206
|
+
if not isinstance(signal, np.ndarray) or signal.ndim != 1 or signal.size == 0:
|
|
207
|
+
raise ValueError("The new signal must be a non-empty 1-D numpy array")
|
|
208
|
+
if not isinstance(channel, str):
|
|
209
|
+
raise TypeError(f"Channel name should be a string, got {type(channel)}")
|
|
210
|
+
if not isinstance(sf, (int, float)) or not math.isfinite(sf) or sf <= 0:
|
|
211
|
+
raise TypeError(f"Sample frequency should be a float, got {type(sf)}")
|
|
212
|
+
|
|
213
|
+
_duration = math.floor(len(signal) / sf)
|
|
214
|
+
if _duration < self._duration:
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"The new signal is shorter ({_duration}s) than the existing data "
|
|
217
|
+
f"({self._duration}s)")
|
|
218
|
+
|
|
219
|
+
channel = _unique_name(channel, self.channels)
|
|
220
|
+
|
|
221
|
+
self._signals.append(signal[:int(self._duration * sf)])
|
|
222
|
+
self._channels.append(channel)
|
|
223
|
+
self._n_channels = len(self._channels)
|
|
224
|
+
self._sf.append(sf)
|
|
225
|
+
|
|
226
|
+
def delete(self, channel=None):
|
|
227
|
+
"""Delete a channel by name.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
channel : str
|
|
232
|
+
Name of the channel to delete.
|
|
233
|
+
"""
|
|
234
|
+
if not isinstance(channel, str):
|
|
235
|
+
raise TypeError(f"Channel name should be a string, got {type(channel)}")
|
|
236
|
+
if channel not in self._channels:
|
|
237
|
+
raise IndexError(f"Channel name {channel} is not in the signal channels ({self._channels})")
|
|
238
|
+
if len(self._channels) == 1:
|
|
239
|
+
raise ValueError(f"Channel {channel} is the last channel of signal data, you can't delete it")
|
|
240
|
+
chan_idx = self._channels.index(channel)
|
|
241
|
+
self._signals.pop(chan_idx)
|
|
242
|
+
self._channels.pop(chan_idx)
|
|
243
|
+
self._sf.pop(chan_idx)
|
|
244
|
+
self._n_channels = len(self._channels)
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Copy-on-write operations
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
def crop(self, time_period):
|
|
250
|
+
"""Return a new :class:`MiData` cropped to ``[start, end]`` seconds.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
time_period : list of two ints
|
|
255
|
+
Start and end second of the crop.
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
MiData
|
|
260
|
+
Cropped data (all channels).
|
|
261
|
+
"""
|
|
262
|
+
if not isinstance(time_period, list) or len(time_period) != 2:
|
|
263
|
+
raise TypeError(f"'time_period' should be a list of two positive integers, got {time_period}")
|
|
264
|
+
|
|
265
|
+
if time_period[0] < 0 or time_period[1] < 0 or \
|
|
266
|
+
not isinstance(time_period[0], int) or not isinstance(time_period[1], int):
|
|
267
|
+
raise TypeError(
|
|
268
|
+
f"'time_period' should be a list of two positive integers, "
|
|
269
|
+
f"got {type(time_period[0])} and {type(time_period[1])}")
|
|
270
|
+
|
|
271
|
+
if time_period[0] >= time_period[1]:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f"End time (got {time_period[1]}) of 'time_period' should be larger than start time (got {time_period[0]})")
|
|
274
|
+
|
|
275
|
+
start, end = time_period
|
|
276
|
+
end = min(end, self._duration)
|
|
277
|
+
|
|
278
|
+
signals = [self.signals[idx][int(start * each): int(end * each)]
|
|
279
|
+
for idx, each in enumerate(self.sf)]
|
|
280
|
+
return MiData(signals=signals, channels=self.channels, sf=self.sf,
|
|
281
|
+
time=self.time, describe=self.describe)
|
|
282
|
+
|
|
283
|
+
def pick_chs(self, ch_names):
|
|
284
|
+
"""Return a new :class:`MiData` with only the requested channels.
|
|
285
|
+
|
|
286
|
+
Parameters
|
|
287
|
+
----------
|
|
288
|
+
ch_names : list of str
|
|
289
|
+
Channels to keep.
|
|
290
|
+
|
|
291
|
+
Returns
|
|
292
|
+
-------
|
|
293
|
+
MiData
|
|
294
|
+
Data restricted to the selected channels.
|
|
295
|
+
"""
|
|
296
|
+
if ch_names is None or ch_names == []:
|
|
297
|
+
ch_names = self.channels
|
|
298
|
+
|
|
299
|
+
if not isinstance(ch_names, list):
|
|
300
|
+
raise TypeError(f"'ch_names' should be a list, got {type(ch_names)}")
|
|
301
|
+
|
|
302
|
+
signals, sf, channels = [], [], []
|
|
303
|
+
for chan in ch_names:
|
|
304
|
+
if chan in self.channels:
|
|
305
|
+
chan_idx = self.channels.index(chan)
|
|
306
|
+
signals.append(self.signals[chan_idx])
|
|
307
|
+
sf.append(self.sf[chan_idx])
|
|
308
|
+
channels.append(chan)
|
|
309
|
+
else:
|
|
310
|
+
raise IndexError(f"{chan} channel is not in the signal channels ({self.channels})")
|
|
311
|
+
|
|
312
|
+
return MiData(signals=signals, channels=channels, sf=sf,
|
|
313
|
+
time=self.time, describe=self.describe)
|
|
314
|
+
|
|
315
|
+
def reorder_channels(self, new_order):
|
|
316
|
+
"""Reorder the channels in place.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
new_order : list of str
|
|
321
|
+
Channel names in the desired order (must be a permutation of
|
|
322
|
+
the current channel names).
|
|
323
|
+
"""
|
|
324
|
+
if sorted(new_order) != sorted(self._channels):
|
|
325
|
+
raise ValueError(
|
|
326
|
+
f"'new_order' must be a permutation of the channel names "
|
|
327
|
+
f"({self._channels}), got {new_order}")
|
|
328
|
+
|
|
329
|
+
order_idx = [self._channels.index(name) for name in new_order]
|
|
330
|
+
self._signals = [self._signals[i] for i in order_idx]
|
|
331
|
+
self._channels = [self._channels[i] for i in order_idx]
|
|
332
|
+
self._sf = [self._sf[i] for i in order_idx]
|
|
333
|
+
self._n_channels = len(self._channels)
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------------
|
|
336
|
+
# Properties
|
|
337
|
+
# ------------------------------------------------------------------
|
|
338
|
+
@property
|
|
339
|
+
def duration(self):
|
|
340
|
+
"""Duration of the recording in integer seconds."""
|
|
341
|
+
return self._duration
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def signals(self):
|
|
345
|
+
"""List of signal arrays (one per channel)."""
|
|
346
|
+
return self._signals
|
|
347
|
+
|
|
348
|
+
@property
|
|
349
|
+
def channels(self, idx=None):
|
|
350
|
+
"""Channel names (all, or the one at index ``idx``)."""
|
|
351
|
+
if idx is None:
|
|
352
|
+
return self._channels
|
|
353
|
+
if idx >= self._n_channels:
|
|
354
|
+
raise IndexError(f"Index {idx} can't be larger than the signal channels number {self._n_channels}")
|
|
355
|
+
return self._channels[idx]
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def sf(self, idx=None):
|
|
359
|
+
"""Sampling frequencies (all, or the one at index ``idx``)."""
|
|
360
|
+
if idx is None:
|
|
361
|
+
return self._sf
|
|
362
|
+
if idx >= self._n_channels:
|
|
363
|
+
raise IndexError(f"Index {idx} can't be larger than the signal channels number {self._n_channels}")
|
|
364
|
+
return self._sf[idx]
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def time(self):
|
|
368
|
+
"""Recording start time as a string."""
|
|
369
|
+
return self._time
|
|
370
|
+
|
|
371
|
+
@property
|
|
372
|
+
def describe(self):
|
|
373
|
+
"""Free-text description of the data."""
|
|
374
|
+
return self._describe
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def n_channels(self):
|
|
378
|
+
"""Number of channels."""
|
|
379
|
+
return self._n_channels
|
|
380
|
+
|
|
381
|
+
def get_channel_index(self, channel):
|
|
382
|
+
"""Return the index of a channel by name."""
|
|
383
|
+
return self._channels.index(channel)
|
|
384
|
+
|
|
385
|
+
def __repr__(self):
|
|
386
|
+
summary = ", ".join(f"{ch}@{sf:.0f}Hz" for ch, sf in zip(self._channels, self._sf))
|
|
387
|
+
return f"MiData(duration={self._duration}s, channels=[{summary}], time='{self._time}')"
|
misleep/gui/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# -*- coding: UTF-8 -*-
|
|
2
|
+
"""PySide6 graphical user interface for MiSleep.
|
|
3
|
+
|
|
4
|
+
Launch the GUI with::
|
|
5
|
+
|
|
6
|
+
python -m misleep
|
|
7
|
+
|
|
8
|
+
or from Python::
|
|
9
|
+
|
|
10
|
+
from misleep.gui import show
|
|
11
|
+
show()
|
|
12
|
+
|
|
13
|
+
The GUI is built on **PySide6** (Qt6), which works on Windows, macOS and
|
|
14
|
+
Linux. Before importing anything from this module, make sure PySide6 is
|
|
15
|
+
installed (``pip install 'misleep[gui]'``) and that the ``QT_API``
|
|
16
|
+
environment variable is set to ``pyside6`` for matplotlib integration
|
|
17
|
+
(handled automatically by :mod:`misleep.gui.app`).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from misleep.gui import resources as _resources # noqa: F401 (register Qt resources)
|
|
21
|
+
from .app import main, show
|
|
22
|
+
|
|
23
|
+
__all__ = ["main", "show"]
|