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.
Files changed (94) hide show
  1. misleep/__init__.py +111 -0
  2. misleep/__main__.py +16 -0
  3. misleep/analysis/__init__.py +23 -0
  4. misleep/analysis/auto_stage.py +158 -0
  5. misleep/analysis/detection.py +198 -0
  6. misleep/analysis/features.py +131 -0
  7. misleep/analysis/models/P30_EEG_F_lightgbm.pkl +0 -0
  8. misleep/analysis/models/P30_EEG_P_lightgbm.pkl +0 -0
  9. misleep/analysis/models/__init__.py +0 -0
  10. misleep/analysis/models/ado_EEG_F_lightgbm.pkl +0 -0
  11. misleep/analysis/models/ado_EEG_P_lightgbm.pkl +0 -0
  12. misleep/analysis/models/adult_EEG_F_lightgbm.pkl +0 -0
  13. misleep/analysis/models/adult_EEG_P_lightgbm.pkl +0 -0
  14. misleep/analysis/transformer/__init__.py +46 -0
  15. misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
  16. misleep/analysis/transformer/checkpoints/__init__.py +0 -0
  17. misleep/analysis/transformer/configs.py +52 -0
  18. misleep/analysis/transformer/data.py +362 -0
  19. misleep/analysis/transformer/inference.py +328 -0
  20. misleep/analysis/transformer/models.py +220 -0
  21. misleep/analysis/transformer/preprocessing.py +105 -0
  22. misleep/config/__init__.py +105 -0
  23. misleep/config/default_config.ini +38 -0
  24. misleep/data/__init__.py +13 -0
  25. misleep/data/annotation.py +114 -0
  26. misleep/data/midata.py +387 -0
  27. misleep/gui/__init__.py +23 -0
  28. misleep/gui/app.py +183 -0
  29. misleep/gui/config_dialog.py +429 -0
  30. misleep/gui/dialogs.py +1107 -0
  31. misleep/gui/event_filters.py +26 -0
  32. misleep/gui/main_window.py +2699 -0
  33. misleep/gui/qt_utils.py +277 -0
  34. misleep/gui/resources/__init__.py +9 -0
  35. misleep/gui/resources/entire_logo.png +0 -0
  36. misleep/gui/resources/logo.png +0 -0
  37. misleep/gui/resources/misleep.ico +0 -0
  38. misleep/gui/resources/misleep.qrc +7 -0
  39. misleep/gui/resources/misleep_rc.py +1324 -0
  40. misleep/gui/spec_window.py +187 -0
  41. misleep/gui/style.py +994 -0
  42. misleep/gui/uis/SWA_detect_dialog.ui +181 -0
  43. misleep/gui/uis/SWA_detect_dialog_ui.py +176 -0
  44. misleep/gui/uis/__init__.py +6 -0
  45. misleep/gui/uis/about.ui +88 -0
  46. misleep/gui/uis/about_ui.py +80 -0
  47. misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +77 -0
  48. misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +90 -0
  49. misleep/gui/uis/auto_stage_lightGBM_dialog.ui +124 -0
  50. misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +124 -0
  51. misleep/gui/uis/horizontal_line_dialog.ui +166 -0
  52. misleep/gui/uis/horizontal_line_dialog_ui.py +157 -0
  53. misleep/gui/uis/label_dialog.ui +65 -0
  54. misleep/gui/uis/label_dialog_ui.py +72 -0
  55. misleep/gui/uis/main_window.ui +871 -0
  56. misleep/gui/uis/main_window_ui.py +616 -0
  57. misleep/gui/uis/save_data_dialog.ui +95 -0
  58. misleep/gui/uis/save_data_dialog_ui.py +98 -0
  59. misleep/gui/uis/spec_window.ui +109 -0
  60. misleep/gui/uis/spec_window_ui.py +103 -0
  61. misleep/gui/uis/spindle_detect_dialog.ui +202 -0
  62. misleep/gui/uis/spindle_detect_dialog_ui.py +194 -0
  63. misleep/gui/uis/state_spectral_dialog.ui +240 -0
  64. misleep/gui/uis/state_spectral_dialog_ui.py +204 -0
  65. misleep/gui/uis/transfer_result_dialog.ui +104 -0
  66. misleep/gui/uis/transfer_result_dialog_ui.py +94 -0
  67. misleep/gui/workers.py +69 -0
  68. misleep/io/__init__.py +68 -0
  69. misleep/io/annotation.py +367 -0
  70. misleep/io/array.py +209 -0
  71. misleep/io/base.py +174 -0
  72. misleep/io/edf.py +109 -0
  73. misleep/io/mat.py +189 -0
  74. misleep/logger.py +79 -0
  75. misleep/preprocessing/__init__.py +23 -0
  76. misleep/preprocessing/artifacts.py +76 -0
  77. misleep/preprocessing/filtering.py +94 -0
  78. misleep/preprocessing/segment.py +59 -0
  79. misleep/preprocessing/spectral.py +169 -0
  80. misleep/utils/__init__.py +45 -0
  81. misleep/utils/annotation.py +130 -0
  82. misleep/utils/entropy.py +135 -0
  83. misleep/utils/misc.py +69 -0
  84. misleep/utils/time_utils.py +61 -0
  85. misleep/viz/__init__.py +12 -0
  86. misleep/viz/hypnogram.py +48 -0
  87. misleep/viz/signals.py +51 -0
  88. misleep/viz/spectral.py +98 -0
  89. misleep-0.3.0.dist-info/METADATA +249 -0
  90. misleep-0.3.0.dist-info/RECORD +94 -0
  91. misleep-0.3.0.dist-info/WHEEL +5 -0
  92. misleep-0.3.0.dist-info/entry_points.txt +15 -0
  93. misleep-0.3.0.dist-info/licenses/LICENSE +29 -0
  94. 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