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
misleep/gui/app.py ADDED
@@ -0,0 +1,183 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Application entry point for the MiSleep GUI.
3
+
4
+ Sets up the Qt/PySide6 environment (including the ``QT_API`` variable
5
+ needed by matplotlib) and starts the main window.
6
+
7
+ Files can be opened directly from the command line::
8
+
9
+ misleep # empty session
10
+ misleep data.mat # load data on startup
11
+ misleep data.mat anno.txt # load data + annotation on startup
12
+ misleep --data data.edf --anno anno.txt
13
+ python -m misleep data.mat anno.txt
14
+
15
+ On Windows, after running ``tools/install_file_associations.py``,
16
+ double-clicking a registered ``.mat`` / ``.edf`` file opens it in MiSleep
17
+ automatically.
18
+ """
19
+
20
+ import argparse
21
+ import os
22
+ import sys
23
+
24
+ # Make matplotlib use the PySide6 Qt bindings before anything imports it.
25
+ os.environ.setdefault("QT_API", "pyside6")
26
+
27
+ from misleep.gui import resources # noqa: F401 (register Qt resources)
28
+ from misleep.gui.qt_utils import app_icon
29
+ from misleep.logger import logger
30
+
31
+
32
+ def _setup_high_dpi():
33
+ """Configure Qt high-DPI behaviour before the QApplication is created.
34
+
35
+ Qt6 enables high-DPI scaling by default; this additionally:
36
+
37
+ * uses *pass-through* scale-factor rounding so fractional display
38
+ scales (e.g. 125 % / 150 % on 2K screens) are applied exactly
39
+ instead of being rounded to 0.5 steps,
40
+ * requests per-monitor DPI awareness on Windows (best effort) so the
41
+ window is crisp when moved between monitors with different scales.
42
+ """
43
+ try:
44
+ from PySide6.QtCore import Qt
45
+ from PySide6.QtGui import QGuiApplication
46
+
47
+ QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
48
+ Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
49
+ except Exception: # pragma: no cover
50
+ pass
51
+
52
+ if sys.platform == "win32":
53
+ try:
54
+ import ctypes
55
+
56
+ ctypes.windll.shcore.SetProcessDpiAwareness(2) # per-monitor v2
57
+ except Exception:
58
+ try:
59
+ import ctypes
60
+
61
+ ctypes.windll.user32.SetProcessDPIAware()
62
+ except Exception:
63
+ pass
64
+
65
+
66
+ def _parse_args(argv=None):
67
+ parser = argparse.ArgumentParser(
68
+ prog="misleep",
69
+ description="MiSleep: mice sleep EEG/EMG visualization, scoring and analysis.",
70
+ )
71
+ parser.add_argument(
72
+ "files", nargs="*", metavar="FILE",
73
+ help="Optional signal file (.mat/.edf) and, optionally, annotation file "
74
+ "(.txt). e.g. `misleep data.mat anno.txt`")
75
+ parser.add_argument(
76
+ "--data", dest="data_path", default=None, metavar="PATH",
77
+ help="Signal file (.mat/.edf) to open on startup.")
78
+ parser.add_argument(
79
+ "--anno", dest="anno_path", default=None, metavar="PATH",
80
+ help="Annotation file (.txt) to open on startup.")
81
+ parser.add_argument(
82
+ "--version", action="version",
83
+ version=f"%(prog)s {_version()}",
84
+ )
85
+ args = parser.parse_args(argv)
86
+
87
+ # positional files: first = data, second = annotation.
88
+ # When --data is already given, a single positional file is the annotation.
89
+ data_path = args.data_path
90
+ anno_path = args.anno_path
91
+ if args.files:
92
+ if data_path is None:
93
+ data_path = args.files[0]
94
+ if len(args.files) > 1:
95
+ anno_path = anno_path or args.files[1]
96
+ else:
97
+ anno_path = anno_path or args.files[0]
98
+ return data_path, anno_path
99
+
100
+
101
+ def _version():
102
+ from misleep import __version__
103
+
104
+ return __version__
105
+
106
+
107
+ def _prewarm():
108
+ """Pay the one-time warm-up costs (font cache, heavy scipy imports)
109
+ at application startup instead of inside the first file load.
110
+
111
+ Without this, the very first plot after loading a file silently pays
112
+ ~1-2 s of matplotlib font-cache building and scipy imports, which the
113
+ user perceives as a slow "initialize/draw" phase.
114
+ """
115
+ try:
116
+ import scipy.integrate # noqa: F401
117
+ import scipy.ndimage # noqa: F401
118
+ import scipy.signal # noqa: F401
119
+
120
+ from matplotlib import font_manager
121
+ font_manager.findfont("DejaVu Sans") # build / load the font cache
122
+
123
+ import matplotlib.pyplot as plt
124
+ plt.get_cmap("jet")
125
+
126
+ from misleep.preprocessing import spectral # noqa: F401
127
+ from misleep.viz import spectral as _viz_spectral # noqa: F401
128
+ except Exception: # pragma: no cover
129
+ pass
130
+
131
+
132
+ def show(data_path=None, anno_path=None):
133
+ """Create and show the MiSleep main window (blocking).
134
+
135
+ Parameters
136
+ ----------
137
+ data_path : str, optional
138
+ Signal file (``.mat`` / ``.edf``) to open on startup.
139
+ anno_path : str, optional
140
+ Annotation file (``.txt``) to open on startup.
141
+ """
142
+ try:
143
+ from PySide6.QtWidgets import QApplication
144
+ except ImportError as e: # pragma: no cover
145
+ raise ImportError(
146
+ "The MiSleep GUI requires PySide6. Install it with: "
147
+ "pip install 'misleep[gui]' (or: pip install PySide6)"
148
+ ) from e
149
+
150
+ _setup_high_dpi()
151
+
152
+ # Imported after the Qt bindings are resolved by matplotlib.
153
+ from misleep.gui.main_window import MainWindow
154
+
155
+ app = QApplication.instance() or QApplication(sys.argv[:1])
156
+ app.setApplicationName("MiSleep")
157
+ app.setApplicationDisplayName("MiSleep")
158
+ app.setWindowIcon(app_icon())
159
+
160
+ # Warm-up one-time costs so the first file load renders immediately
161
+ _prewarm()
162
+
163
+ main_win = MainWindow()
164
+
165
+ if data_path:
166
+ logger.info("Opening data file: %s", data_path)
167
+ main_win.open_data(data_path)
168
+ if anno_path:
169
+ logger.info("Opening annotation file: %s", anno_path)
170
+ main_win.open_annotation(anno_path)
171
+
172
+ main_win.showMaximized() # start maximized: no horizontal scrolling
173
+ sys.exit(app.exec())
174
+
175
+
176
+ def main(argv=None):
177
+ """Console-script entry point (``misleep``)."""
178
+ data_path, anno_path = _parse_args(argv)
179
+ show(data_path=data_path, anno_path=anno_path)
180
+
181
+
182
+ if __name__ == "__main__":
183
+ main()
@@ -0,0 +1,429 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """In-application settings dialog.
3
+
4
+ Lets the user edit the MiSleep configuration from inside the GUI
5
+ (state names & colors, marker color, annotation labels, spectral defaults,
6
+ ...). Changes are saved to the per-user configuration
7
+ file and **applied immediately** to the running application -- no
8
+ restart needed.
9
+ """
10
+
11
+ import json
12
+
13
+ from PySide6.QtCore import Qt
14
+ from PySide6.QtGui import QColor
15
+ from PySide6.QtWidgets import (
16
+ QColorDialog,
17
+ QComboBox,
18
+ QDialog,
19
+ QDoubleSpinBox,
20
+ QFileDialog,
21
+ QFormLayout,
22
+ QGroupBox,
23
+ QHBoxLayout,
24
+ QLabel,
25
+ QLineEdit,
26
+ QListWidget,
27
+ QListWidgetItem,
28
+ QMessageBox,
29
+ QPushButton,
30
+ QScrollArea,
31
+ QTabWidget,
32
+ QVBoxLayout,
33
+ QWidget,
34
+ )
35
+
36
+ from misleep.config import save_config
37
+ from misleep.gui.qt_utils import app_icon
38
+
39
+
40
+ def _contrast_text(color: QColor) -> str:
41
+ """Return black or white text depending on the background luminance."""
42
+ lum = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()
43
+ return "#000000" if lum > 150 else "#ffffff"
44
+
45
+
46
+ class ColorButton(QPushButton):
47
+ """A rounded color swatch that opens a color picker when clicked."""
48
+
49
+ def __init__(self, color="#ffffff", parent=None):
50
+ super().__init__(parent)
51
+ self._color = QColor(color)
52
+ self.setCursor(Qt.PointingHandCursor)
53
+ self.setFixedSize(64, 26)
54
+ self.setToolTip("Click to choose a color")
55
+ self.set_color(self._color)
56
+ self.clicked.connect(self._pick)
57
+
58
+ def _pick(self):
59
+ c = QColorDialog.getColor(self._color, self, "Select color")
60
+ if c.isValid():
61
+ self.set_color(c)
62
+
63
+ def set_color(self, color):
64
+ self._color = QColor(color)
65
+ hex_name = self._color.name().upper()
66
+ self.setText(hex_name)
67
+ self.setFont(self.font())
68
+ self.setStyleSheet(
69
+ f"""
70
+ QPushButton {{
71
+ background-color: {self._color.name()};
72
+ color: {_contrast_text(self._color)};
73
+ border: 2px solid rgba(0, 0, 0, 60%);
74
+ border-radius: 4px;
75
+ font-size: 8pt;
76
+ font-weight: bold;
77
+ padding: 0px;
78
+ }}
79
+ QPushButton:hover {{
80
+ border: 2px solid #000000;
81
+ }}
82
+ QPushButton:pressed {{
83
+ background-color: {self._color.darker(120).name()};
84
+ }}
85
+ """
86
+ )
87
+
88
+ def color(self) -> QColor:
89
+ return self._color
90
+
91
+
92
+ class _ListEditor(QWidget):
93
+ """A small editable string list (add / delete / edit items)."""
94
+
95
+ def __init__(self, values, parent=None):
96
+ super().__init__(parent)
97
+ layout = QHBoxLayout(self)
98
+ layout.setContentsMargins(0, 0, 0, 0)
99
+
100
+ self.list = QListWidget()
101
+ self.list.addItems(values)
102
+ layout.addWidget(self.list, 1)
103
+
104
+ btns = QVBoxLayout()
105
+ add_bt = QPushButton("Add")
106
+ del_bt = QPushButton("Delete")
107
+ add_bt.clicked.connect(self._add)
108
+ del_bt.clicked.connect(self._delete)
109
+ btns.addWidget(add_bt)
110
+ btns.addWidget(del_bt)
111
+ btns.addStretch(1)
112
+ layout.addLayout(btns)
113
+
114
+ def _add(self):
115
+ item = QListWidgetItem("new label")
116
+ self.list.addItem(item)
117
+ self.list.setCurrentItem(item)
118
+ self.list.editItem(item)
119
+
120
+ def _delete(self):
121
+ row = self.list.currentRow()
122
+ if row >= 0:
123
+ self.list.takeItem(row)
124
+
125
+ def values(self):
126
+ return [self.list.item(i).text() for i in range(self.list.count())]
127
+
128
+
129
+ class SettingsDialog(QDialog):
130
+ """Edit and immediately apply the MiSleep configuration."""
131
+
132
+ def __init__(self, parent=None):
133
+ super().__init__(parent)
134
+ self._main = parent
135
+ self.config = parent.config
136
+
137
+ self.setWindowTitle("MiSleep settings")
138
+ self.resize(640, 560)
139
+ self.setWindowIcon(app_icon())
140
+
141
+ tabs = QTabWidget()
142
+ tabs.addTab(self._build_states_tab(), "Sleep states")
143
+ tabs.addTab(self._build_colors_tab(), "Colors")
144
+ tabs.addTab(self._build_labels_tab(), "Labels")
145
+ tabs.addTab(self._build_spectral_tab(), "Spectral")
146
+ tabs.addTab(self._build_general_tab(), "General")
147
+
148
+ scroll = QScrollArea()
149
+ scroll.setWidgetResizable(True)
150
+ scroll.setWidget(tabs)
151
+
152
+ btn_row = QHBoxLayout()
153
+ btn_row.addStretch(1)
154
+ open_bt = QPushButton("Open file…")
155
+ open_bt.clicked.connect(self._open_file)
156
+ apply_bt = QPushButton("Apply")
157
+ apply_bt.clicked.connect(self._apply)
158
+ ok_bt = QPushButton("OK")
159
+ ok_bt.clicked.connect(self._ok)
160
+ cancel_bt = QPushButton("Cancel")
161
+ cancel_bt.clicked.connect(self.reject)
162
+ for b in (open_bt, apply_bt, ok_bt, cancel_bt):
163
+ btn_row.addWidget(b)
164
+
165
+ layout = QVBoxLayout(self)
166
+ layout.addWidget(scroll, 1)
167
+ layout.addLayout(btn_row)
168
+
169
+ self._load_from_config()
170
+
171
+ # ------------------------------------------------------------------
172
+ # Tab builders
173
+ # ------------------------------------------------------------------
174
+ def _build_states_tab(self):
175
+ self._state_rows = {} # code -> (name_edit, color_button)
176
+ box = QGroupBox("Sleep states")
177
+ form = QFormLayout(box)
178
+ hint = QLabel("State codes, names and background colors used in the "
179
+ "signal panel and hypnogram. Up to 10 states "
180
+ "(keys 0-9 label the selected area).")
181
+ hint.setWordWrap(True)
182
+ form.addRow(hint)
183
+ self._state_frame = QVBoxLayout()
184
+ form.addRow(self._state_frame)
185
+
186
+ btn_row = QHBoxLayout()
187
+ add_bt = QPushButton("Add state")
188
+ rem_bt = QPushButton("Remove state")
189
+ add_bt.setToolTip("Add a state with the next free code (1-10)")
190
+ rem_bt.setToolTip("Remove the highest state")
191
+ add_bt.clicked.connect(self._add_state)
192
+ rem_bt.clicked.connect(self._remove_state)
193
+ btn_row.addWidget(add_bt)
194
+ btn_row.addWidget(rem_bt)
195
+ btn_row.addStretch(1)
196
+ form.addRow(btn_row)
197
+ return box
198
+
199
+ def _add_state_row(self, code, name, color):
200
+ """Append one state row (code, name edit, color swatch)."""
201
+ row = QWidget()
202
+ h = QHBoxLayout(row)
203
+ h.setContentsMargins(0, 0, 0, 0)
204
+ h.addWidget(QLabel(f"State {code}:"))
205
+ name_edit = QLineEdit(name)
206
+ name_edit.setMinimumWidth(140)
207
+ name_edit.setMaximumWidth(220)
208
+ h.addWidget(name_edit)
209
+ color_bt = ColorButton(color)
210
+ h.addWidget(color_bt)
211
+ h.addStretch(1)
212
+ self._state_frame.addWidget(row)
213
+ self._state_rows[int(code)] = (name_edit, color_bt)
214
+
215
+ def _add_state(self):
216
+ used = set(self._state_rows.keys())
217
+ free = [c for c in range(1, 11) if c not in used]
218
+ if not free:
219
+ QMessageBox.information(self, "MiSleep", "Maximum of 10 states.")
220
+ return
221
+ self._add_state_row(free[0], f"State {free[0]}", "#808080")
222
+
223
+ def _remove_state(self):
224
+ if len(self._state_rows) <= 1:
225
+ QMessageBox.information(self, "MiSleep", "Keep at least one state.")
226
+ return
227
+ code = max(self._state_rows)
228
+ row_widget = self._state_rows[code][0].parentWidget()
229
+ self._state_frame.removeWidget(row_widget)
230
+ row_widget.deleteLater()
231
+ del self._state_rows[code]
232
+
233
+ def _build_colors_tab(self):
234
+ page = QWidget()
235
+ page_layout = QVBoxLayout(page)
236
+ box = QGroupBox("Marker color")
237
+ line_form = QFormLayout(box)
238
+ self._marker_line_bt = ColorButton()
239
+ self._marker_line_bt.setToolTip(
240
+ "Marker color in both the signal panel and hypnogram")
241
+ line_form.addRow("Marker:", self._marker_line_bt)
242
+ page_layout.addWidget(box)
243
+ page_layout.addStretch(1)
244
+ return page
245
+
246
+ def _build_labels_tab(self):
247
+ box = QGroupBox("Label lists")
248
+ layout = QVBoxLayout(box)
249
+
250
+ layout.addWidget(QLabel("Marker labels (single time-point events):"))
251
+ self._marker_editor = _ListEditor([])
252
+ layout.addWidget(self._marker_editor)
253
+
254
+ layout.addWidget(QLabel("Start-End labels (events with a duration):"))
255
+ self._se_editor = _ListEditor([])
256
+ layout.addWidget(self._se_editor)
257
+ return box
258
+
259
+ def _build_spectral_tab(self):
260
+ box = QGroupBox("Spectral analysis defaults")
261
+ form = QFormLayout(box)
262
+
263
+ self._freq_low = QDoubleSpinBox()
264
+ self._freq_low.setRange(0, 500)
265
+ self._freq_low.setDecimals(2)
266
+ self._freq_high = QDoubleSpinBox()
267
+ self._freq_high.setRange(0, 500)
268
+ self._freq_high.setDecimals(2)
269
+ form.addRow("Frequency range (Hz):", self._freq_low)
270
+ form.addRow("", self._freq_high)
271
+
272
+ self._win_length = QDoubleSpinBox()
273
+ self._win_length.setRange(1, 3600)
274
+ self._win_length.setDecimals(1)
275
+ form.addRow("Window length (s):", self._win_length)
276
+
277
+ self._nfft = QDoubleSpinBox()
278
+ self._nfft.setRange(1, 3600)
279
+ self._nfft.setDecimals(1)
280
+ form.addRow("nfft (s):", self._nfft)
281
+
282
+ self._gaussian = QDoubleSpinBox()
283
+ self._gaussian.setRange(0, 100)
284
+ self._gaussian.setDecimals(2)
285
+ form.addRow("Gaussian smoothing σ:", self._gaussian)
286
+ return box
287
+
288
+ def _build_general_tab(self):
289
+ box = QGroupBox("General")
290
+ form = QFormLayout(box)
291
+
292
+ self._bg_alpha = QDoubleSpinBox()
293
+ self._bg_alpha.setRange(0, 1)
294
+ self._bg_alpha.setSingleStep(0.05)
295
+ self._bg_alpha.setDecimals(2)
296
+ form.addRow("State background alpha:", self._bg_alpha)
297
+
298
+ self._hypno_alpha = QDoubleSpinBox()
299
+ self._hypno_alpha.setRange(0.15, 1.0)
300
+ self._hypno_alpha.setSingleStep(0.05)
301
+ self._hypno_alpha.setDecimals(2)
302
+ self._hypno_alpha.setToolTip(
303
+ "Lower values make state bands lighter so marker lines remain visible.")
304
+ form.addRow("Hypnogram state alpha:", self._hypno_alpha)
305
+
306
+ path_row = QHBoxLayout()
307
+ self._openpath = QLineEdit()
308
+ browse_bt = QPushButton("Browse…")
309
+ browse_bt.clicked.connect(self._browse_path)
310
+ path_row.addWidget(self._openpath, 1)
311
+ path_row.addWidget(browse_bt)
312
+ form.addRow("Default open path:", path_row)
313
+
314
+ self._theme_combo = QComboBox()
315
+ self._theme_combo.addItems(["light", "dark"])
316
+ self._theme_combo.setToolTip(
317
+ "Applies immediately: widgets and plots switch together.")
318
+ form.addRow("Theme:", self._theme_combo)
319
+
320
+ self._tone_combo = QComboBox()
321
+ self._tone_combo.addItems(["black", "pink", "blue", "khaki"])
322
+ self._tone_combo.setToolTip(
323
+ "Chrome / accent color scheme (independent of light/dark mode).")
324
+ form.addRow("Color scheme:", self._tone_combo)
325
+
326
+ return box
327
+
328
+ # ------------------------------------------------------------------
329
+ # Load / collect / save
330
+ # ------------------------------------------------------------------
331
+ def _load_from_config(self):
332
+ gui = self.config["gui"]
333
+ spec = self.config["spec"]
334
+
335
+ # Sleep states (dynamic: one row per state code, up to 10)
336
+ while self._state_frame.count():
337
+ item = self._state_frame.takeAt(0)
338
+ if item.widget():
339
+ item.widget().deleteLater()
340
+ statemap = json.loads(gui["statemap"])
341
+ statecolor = json.loads(gui["statecolor"])
342
+ for code in sorted(statemap, key=int):
343
+ self._add_state_row(int(code), str(statemap[code]),
344
+ str(statecolor.get(code, "#808080")))
345
+
346
+ # Label lists
347
+ self._marker_editor.list.clear()
348
+ self._marker_editor.list.addItems(
349
+ [each[1:-1] for each in gui["marker"][1:-1].split(", ")])
350
+ self._se_editor.list.clear()
351
+ self._se_editor.list.addItems(
352
+ [each[1:-1] for each in gui["startend"][1:-1].split(", ")])
353
+
354
+ # Spectral
355
+ low, high = (float(x) for x in gui["freq_range"].strip("[]").split(","))
356
+ self._freq_low.setValue(low)
357
+ self._freq_high.setValue(high)
358
+ self._win_length.setValue(float(spec["win_length_sec"]))
359
+ self._nfft.setValue(float(spec["nfft_sec"]))
360
+ self._gaussian.setValue(float(spec["gaussian_sigma"]))
361
+
362
+ # General
363
+ self._bg_alpha.setValue(float(gui["statecolorbgalpha"]))
364
+ self._hypno_alpha.setValue(float(gui.get("hypnogramstatealpha", "0.55")))
365
+ self._openpath.setText(gui["openpath"])
366
+ self._theme_combo.setCurrentText(gui.get("theme", "light"))
367
+ self._tone_combo.setCurrentText(gui.get("color_tone", "black"))
368
+ self._marker_line_bt.set_color(
369
+ gui.get("markerlinecolor", "red").strip("\"'"))
370
+
371
+ def _browse_path(self):
372
+ path = QFileDialog.getExistingDirectory(self, "Select default folder",
373
+ self._openpath.text())
374
+ if path:
375
+ self._openpath.setText(path)
376
+
377
+ def _open_file(self):
378
+ if self._main is not None:
379
+ self._main.pupup_config()
380
+
381
+ # ------------------------------------------------------------------
382
+ # Apply / save
383
+ # ------------------------------------------------------------------
384
+ def _collect(self) -> dict:
385
+ """Return {section: {key: value}} with the new configuration."""
386
+ statemap = {str(code): name_edit.text().strip()
387
+ for code, (name_edit, _) in self._state_rows.items()}
388
+ statecolor = {str(code): bt.color().name()
389
+ for code, (_, bt) in self._state_rows.items()}
390
+ return {
391
+ "gui": {
392
+ "statemap": json.dumps(statemap),
393
+ "statecolor": json.dumps(statecolor),
394
+ "marker": str(self._marker_editor.values()),
395
+ "startend": str(self._se_editor.values()),
396
+ "statecolorbgalpha": str(self._bg_alpha.value()),
397
+ "hypnogramstatealpha": str(self._hypno_alpha.value()),
398
+ "freq_range": json.dumps([self._freq_low.value(), self._freq_high.value()]),
399
+ "openpath": self._openpath.text(),
400
+ "theme": self._theme_combo.currentText(),
401
+ "color_tone": self._tone_combo.currentText(),
402
+ "spectrogram_cmap": "jet",
403
+ "markerlinecolor": self._marker_line_bt.color().name(),
404
+ },
405
+ "spec": {
406
+ "win_length_sec": str(self._win_length.value()),
407
+ "nfft_sec": str(self._nfft.value()),
408
+ "gaussian_sigma": str(self._gaussian.value()),
409
+ },
410
+ }
411
+
412
+ def _save(self):
413
+ for section, values in self._collect().items():
414
+ for key, value in values.items():
415
+ self.config.set(section, key, value)
416
+ save_config(self.config)
417
+
418
+ def _apply(self):
419
+ self._save()
420
+ if self._main is not None:
421
+ self._main.apply_settings()
422
+ QMessageBox.information(self, "MiSleep",
423
+ "Settings saved and applied.")
424
+
425
+ def _ok(self):
426
+ self._save()
427
+ if self._main is not None:
428
+ self._main.apply_settings()
429
+ self.accept()