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.

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
@@ -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
@@ -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}')"
@@ -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"]