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,2699 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Main window of the MiSleep GUI.
3
+
4
+ All plotting is based on the annotation: the signal area shows the raw
5
+ channels with sleep-state background colors, the spectrogram strip, the
6
+ hypnogram, and a collection of dock widgets for channel/scoring tools.
7
+ """
8
+
9
+ import copy
10
+ import datetime
11
+ import json
12
+ import os
13
+ import time
14
+ from contextlib import contextmanager
15
+
16
+ import numpy as np
17
+ from matplotlib import pyplot as plt
18
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
19
+ from matplotlib.collections import PolyCollection
20
+ from PySide6.QtCore import QEvent, QTimer, Qt
21
+ from PySide6.QtGui import QColor, QKeySequence, QShortcut
22
+ from PySide6.QtWidgets import (
23
+ QAbstractItemView,
24
+ QAbstractSpinBox,
25
+ QApplication,
26
+ QComboBox,
27
+ QFileDialog,
28
+ QGridLayout,
29
+ QMainWindow,
30
+ QMessageBox,
31
+ QPushButton,
32
+ QScrollArea,
33
+ QSizePolicy,
34
+ QVBoxLayout,
35
+ QWidget,
36
+ )
37
+
38
+ from misleep.config import load_config, save_config, user_config_path
39
+ from misleep.data import MiAnnotation, MiData
40
+ from misleep.gui.config_dialog import SettingsDialog
41
+ from misleep.gui.dialogs import (
42
+ AboutDialog,
43
+ AutoStageCausalTransformerDialog,
44
+ AutoStageLightGBMDialog,
45
+ EventListDialog,
46
+ HorizontalLineDialog,
47
+ LabelDialog,
48
+ SaveDataDialog,
49
+ SpindleDetectionDialog,
50
+ StateSpectralDialog,
51
+ SWADetectionDialog,
52
+ TransferResultDialog,
53
+ UserGuideDialog,
54
+ )
55
+ from misleep.gui.event_filters import WheelInputGuard
56
+ from misleep.gui.qt_utils import (
57
+ ChannelListModel,
58
+ CollapsibleSection,
59
+ app_icon,
60
+ create_new_mianno,
61
+ identify_startend_color,
62
+ )
63
+ from misleep.gui.style import (
64
+ COLOR_TONES,
65
+ THEMES,
66
+ apply_theme,
67
+ resolved_theme,
68
+ retheme_figures,
69
+ )
70
+ from misleep.gui.uis.main_window_ui import Ui_MiSleep
71
+ from misleep.gui.workers import SaveThread
72
+ from misleep.io.annotation import available_annotation_readers, load_annotation
73
+ from misleep.io import available_readers, available_writers, load_signal
74
+ from misleep.logger import logger
75
+ from misleep.utils.annotation import lst2group
76
+ from misleep.viz.spectral import spectrogram_color_limits
77
+
78
+
79
+ def _text_on(color_hex: str) -> str:
80
+ """Return black or white text for a background color (any theme)."""
81
+ c = QColor(color_hex)
82
+ lum = 0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()
83
+ return "#000000" if lum > 150 else "#ffffff"
84
+
85
+
86
+ class MainWindow(QMainWindow, Ui_MiSleep):
87
+ """Main window of MiSleep."""
88
+
89
+ def __init__(self, parent=None):
90
+ self._startup_started = time.perf_counter()
91
+ super().__init__(parent)
92
+ self.setupUi(self)
93
+
94
+ # Load configuration first so the GUI can honor the user's theme.
95
+ self.config = load_config()
96
+
97
+ # Apply the selected theme (light / dark): Qt style + fonts +
98
+ # matplotlib defaults, so the plot area matches the chrome.
99
+ self._theme_name = self.config.get("gui", "theme", fallback="light")
100
+ if self._theme_name not in THEMES:
101
+ self._theme_name = "light"
102
+ self._tone_name = self.config.get("gui", "color_tone", fallback="black")
103
+ if self._tone_name not in COLOR_TONES:
104
+ self._tone_name = "black"
105
+ app = QApplication.instance()
106
+ if app is not None:
107
+ apply_theme(app, self._theme_name, self._tone_name)
108
+ self._set_plot_colors()
109
+
110
+ logger.info(f"Initializing MiSleep {self.config['gui']['version']}")
111
+
112
+ self.midata = None
113
+ self.mianno = None
114
+
115
+ # Original data and label file paths
116
+ self.data_path = ""
117
+ self.anno_path = ""
118
+
119
+ # Initial params
120
+ self.current_sec = 0
121
+ self.show_duration = 30 # Seconds of duration to plot
122
+ self.total_seconds = 0 # Total seconds for plot
123
+ self.y_lims = None # list of y lim for each channel
124
+ self.y_shift = None # list of y shift for each channel
125
+ self.show_idx = None # Channels to show in the plot area
126
+ self.state_map_dict = {int(key): value for key, value
127
+ in json.loads(self.config["gui"]["statemap"]).items()}
128
+ self.state_color_dict = {int(key): value for key, value
129
+ in json.loads(self.config["gui"]["statecolor"]).items()}
130
+ # Migrate the legacy white INIT default without overriding any custom
131
+ # non-white color chosen by the user.
132
+ if self.state_map_dict.get(4, "").strip().upper() == "INIT" and \
133
+ str(self.state_color_dict.get(4, "")).lower() in \
134
+ {"white", "#fff", "#ffffff"}:
135
+ self.state_color_dict[4] = "#ececec"
136
+ self.config["gui"]["statecolor"] = json.dumps(
137
+ {str(k): v for k, v in self.state_color_dict.items()})
138
+ self.start_end_color_dict = dict(json.loads(self.config["gui"]["startendcolor"].replace("'", '"')).items())
139
+
140
+ self.ShowRangeCombo_dict = {0: 30, 1: 60, 2: 300, 3: 1800, 4: 3600}
141
+ self.FilterTypeCombo_dict = {
142
+ 0: "bandpass",
143
+ 1: "highpass",
144
+ 2: "lowpass",
145
+ 3: "bandstop",
146
+ }
147
+ self.current_spectrogram_idx = 0
148
+ self.spectrogram_percentile = 99.7
149
+ self.show_midata = None
150
+ self.epoch_length = 5
151
+ self.ac_time = None
152
+
153
+ # Signal area figure
154
+ self.signal_figure = plt.figure()
155
+ self.signal_ax = self.signal_figure.subplots()
156
+ self.signal_figure.tight_layout(h_pad=0, w_pad=0)
157
+ self.signal_figure.subplots_adjust(hspace=0)
158
+ self.signal_canvas = FigureCanvas(self.signal_figure)
159
+ self.signal_canvas.mpl_connect("button_release_event", self.click_signal)
160
+ # start and end axvline, only two lines
161
+ self.signal_start_end_axvline = []
162
+ self.signal_marker_axvline = []
163
+
164
+ # Start-end for labels
165
+ self.start_end = []
166
+ # Start-end for milliseconds, for start_end label
167
+ self.start_end_ms = []
168
+
169
+ # Hypnogram area figure
170
+ self.hypo_figure = plt.figure(layout="constrained")
171
+ self.hypo_ax = self.hypo_figure.subplots()
172
+ self.hypo_canvas = FigureCanvas(self.hypo_figure)
173
+ self.hypo_canvas.mpl_connect("button_release_event", self.click_hypo)
174
+ self.hypo_axvline = self.hypo_ax.axvline(
175
+ self.current_sec, color="gray", alpha=0.8)
176
+
177
+ # Caches for fast page flips (spectrogram / hypnogram are static
178
+ # between flips, so they are computed once and reused).
179
+ self._spec_full_cache = {} # channel idx -> (f, t, Sxx) of whole file
180
+ self._spec_cache_max_sec = 4 * 3600 # longer files compute per window
181
+ self._hypo_key = None # fingerprint of the drawn hypnogram base
182
+ self._hypo_revision = 0 # increments only when states change
183
+ self._hypo_steps = [] # base step artists of the hypnogram
184
+ self._hypo_transient = [] # per-flip overlay artists
185
+ self._signal_artists = {} # signal-axes idx -> artists we own
186
+ self._spec_artist = None # current spectrogram QuadMesh
187
+
188
+ # Initial params for widgets
189
+ self.channel_slm = ChannelListModel()
190
+
191
+ # Initial dialogs and secondary windows
192
+ self.about_dialog = AboutDialog(version=self.config["gui"]["version"],
193
+ update_time=self.config["gui"]["updatetime"])
194
+ # Secondary analysis windows are created on first use. Constructing
195
+ # every Qt form during startup costs time even when most sessions only
196
+ # annotate signals.
197
+ self.spec_window = None
198
+ self.label_dialog = LabelDialog(config=self.config)
199
+ self.transfer_result_dialog = None
200
+ self.state_spectral_dialog = None
201
+ self.save_data_dialog = None
202
+ self.horizontal_line_dialog = None
203
+ # The horizontal_line dict contains the line value, line color, line comment
204
+ # example: {'ch1': [23.33, '#ff0000', '3 x Standard deviation', horizontalLineObject]}
205
+ self.horizontal_line = {}
206
+ self.axhline_horizontal = []
207
+
208
+ self.swa_detection_dialog = None
209
+ self.spindel_detection_dialog = None
210
+ self.auto_stage_lightGBM_dialog = None
211
+ self.auto_stage_causalTransformer_dialog = None
212
+
213
+ # Check whether operations are saved or not
214
+ self.is_saved = True
215
+
216
+ # Timer to auto-save annotations every 5 minutes
217
+ self.save_timer = QTimer()
218
+ self.save_timer.timeout.connect(self.auto_save)
219
+
220
+ # High-DPI / resolution: canvases track the scroll areas
221
+ self.SignalArea.installEventFilter(self)
222
+ self.HypnoArea.installEventFilter(self)
223
+ self.signal_canvas.installEventFilter(self)
224
+ self.hypo_canvas.installEventFilter(self)
225
+ # The mouse wheel over the signal / hypnogram panels flips pages
226
+ self.SignalArea.viewport().installEventFilter(self)
227
+ self.HypnoArea.viewport().installEventFilter(self)
228
+
229
+ # Unified right-hand sidebar (replaces the four separate docks)
230
+ self._build_sidebar()
231
+ self._rebuild_compact_tool_layouts()
232
+ self._constrain_inputs()
233
+ self.setMinimumSize(960, 600)
234
+ self.setWindowIcon(app_icon())
235
+
236
+ self.init_qt()
237
+
238
+ # ------------------------------------------------------------------
239
+ # Theme control
240
+ # ------------------------------------------------------------------
241
+ def _set_plot_colors(self):
242
+ """Cache the theme colors used by the matplotlib canvases."""
243
+ theme = resolved_theme(self._theme_name, self._tone_name)
244
+ self._plot_trace = theme["plot"]["trace"]
245
+ self._plot_grid = theme["plot"]["grid"]
246
+
247
+ def _update_theme_action(self):
248
+ """Keep the menu-bar theme action text in sync with the theme."""
249
+ if getattr(self, "theme_action", None) is not None:
250
+ self.theme_action.setText(
251
+ "Switch to Light Theme" if self._theme_name == "dark"
252
+ else "Switch to Dark Theme")
253
+
254
+ def _ensure_dialog(self, attribute, factory):
255
+ """Create a secondary window only when the user first opens it."""
256
+ dialog = getattr(self, attribute)
257
+ if dialog is None:
258
+ dialog = factory()
259
+ setattr(self, attribute, dialog)
260
+ return dialog
261
+
262
+ @contextmanager
263
+ def _busy_operation(self):
264
+ """Disable interaction during synchronous work and always restore it.
265
+
266
+ Export routines still contain libraries that must run on the GUI
267
+ thread (Qt dialogs and matplotlib). This guard prevents accidental
268
+ input during that work and, importantly, restores the window after
269
+ success, cancellation, or an exception.
270
+ """
271
+ QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
272
+ self.setEnabled(False)
273
+ QApplication.processEvents()
274
+ try:
275
+ yield
276
+ finally:
277
+ self.setEnabled(True)
278
+ QApplication.restoreOverrideCursor()
279
+ self.signal_canvas.update()
280
+ self.hypo_canvas.update()
281
+ self.update()
282
+ QApplication.processEvents()
283
+
284
+ def toggle_theme(self):
285
+ """Toggle between the light and dark themes and redraw everything."""
286
+ self._theme_name = "dark" if self._theme_name == "light" else "light"
287
+ app = QApplication.instance()
288
+ if app is not None:
289
+ apply_theme(app, self._theme_name, self._tone_name)
290
+ retheme_figures(self._theme_name, self._tone_name)
291
+ self._set_plot_colors()
292
+ self._update_theme_action()
293
+ # Persist the choice so the next launch keeps it
294
+ self.save_config({"theme": self._theme_name})
295
+
296
+ if self.midata is not None and self.mianno is not None:
297
+ self.redraw_all(second=self.current_sec)
298
+ else:
299
+ self.clear_refresh(clf=True)
300
+ logger.info("Theme switched to %s", THEMES[self._theme_name]["name"])
301
+
302
+ def _build_sidebar(self):
303
+ """Replace the four right docks with one tidy, fixed sidebar.
304
+
305
+ The dock contents are re-parented into collapsible sections
306
+ (Data / Channels / Scoring / Display) stacked in a single panel,
307
+ so the right side always looks clean regardless of window size.
308
+ """
309
+ self._sections = []
310
+ sidebar = QWidget()
311
+ sidebar.setObjectName("Sidebar")
312
+ sidebar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
313
+ sidebar.setMinimumWidth(0)
314
+ sidebar.setSizePolicy(QSizePolicy.Policy.Ignored,
315
+ QSizePolicy.Policy.Preferred)
316
+ vbox = QVBoxLayout(sidebar)
317
+ vbox.setContentsMargins(0, 0, 0, 0)
318
+ vbox.setSpacing(8)
319
+
320
+ def add_section(title, content, collapsed=False, stretch=0):
321
+ sec = CollapsibleSection(title, content, collapsed=collapsed)
322
+ vbox.addWidget(sec, stretch)
323
+ self._sections.append(sec)
324
+ return sec
325
+
326
+ # Section titles keep the original dock names (Meta / Channel /
327
+ # Annotation / Time), so the sidebar reads exactly like the old GUI.
328
+ add_section("Meta", self.MetaDock.widget(), collapsed=True)
329
+ add_section("Channel", self.ChannelDock.widget(), stretch=1)
330
+ add_section("Annotation", self.AnnotationDock.widget())
331
+ add_section("Time", self.TimeDock.widget())
332
+
333
+ # The sidebar lives in a scroll area (never clips on small screens)
334
+ # and keeps a fixed, comfortable width next to the plot area.
335
+ self.sidebar = sidebar
336
+ scroll = QScrollArea()
337
+ scroll.setObjectName("SidebarScroll")
338
+ scroll.setWidgetResizable(True)
339
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
340
+ scroll.setWidget(sidebar)
341
+ scroll.setMinimumWidth(320)
342
+ scroll.setMaximumWidth(388)
343
+ scroll.setSizePolicy(QSizePolicy.Policy.Preferred,
344
+ QSizePolicy.Policy.Expanding)
345
+ scroll.setMinimumHeight(120)
346
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
347
+ self.sidebar_scroll = scroll
348
+ self.gridLayout_3.addWidget(scroll, 0, 1, 3, 1)
349
+ self.gridLayout_3.setColumnStretch(0, 1)
350
+ self.gridLayout_3.setColumnStretch(1, 0)
351
+
352
+ # tidy, even spacing inside every dock-content grid
353
+ for grid in (self.gridLayout, self.gridLayout_2, self.gridLayout_4):
354
+ grid.setSpacing(6)
355
+ grid.setContentsMargins(8, 6, 8, 8)
356
+ self.formLayout.setSpacing(6)
357
+ self.formLayout.setContentsMargins(8, 6, 8, 8)
358
+ # the channel list takes the section's extra height
359
+ self.gridLayout.setRowStretch(4, 1)
360
+
361
+ # vertical alignment in the Annotation section: the state buttons
362
+ # share equal columns, and the save/marker/list row stretches evenly
363
+ self.gridLayout_2.setColumnStretch(2, 1)
364
+ self.gridLayout_2.setColumnStretch(3, 1)
365
+ self.gridLayout_2.setColumnMinimumWidth(2, 72)
366
+ self.gridLayout_2.setColumnMinimumWidth(3, 72)
367
+ for bt in (self.SaveLabelBt, self.MarkerListBt, self.StartEndListBt):
368
+ bt.setSizePolicy(QSizePolicy.Policy.Expanding,
369
+ QSizePolicy.Policy.Fixed)
370
+
371
+ # The old docks are hidden - their widgets now live in the sidebar.
372
+ for dock in (self.MetaDock, self.ChannelDock,
373
+ self.AnnotationDock, self.TimeDock):
374
+ self.removeDockWidget(dock)
375
+ dock.hide()
376
+
377
+ def _rebuild_compact_tool_layouts(self):
378
+ """Arrange the frequently used tools in predictable equal columns."""
379
+ # Annotation: three mutually-exclusive modes on one row, followed by
380
+ # one two-column state grid, two list buttons, and a full-width save.
381
+ anno = self.gridLayout_2
382
+ self.SleepStateRadio.setText("State")
383
+ self.LabelBt.hide()
384
+ self.line_4.hide()
385
+ for col, radio in enumerate(
386
+ (self.MarkerRadio, self.StartEndRadio, self.SleepStateRadio)):
387
+ anno.addWidget(radio, 0, col, 1, 1)
388
+ anno.setColumnStretch(col, 1)
389
+ anno.setColumnStretch(3, 0)
390
+ anno.setColumnMinimumWidth(3, 0)
391
+
392
+ anno.addWidget(self.ExtraStatePanel, 1, 0, 1, 3)
393
+ anno.addWidget(self.line_5, 2, 0, 1, 3)
394
+ save_row = self.SaveRowPanel.layout()
395
+ save_row.removeWidget(self.SaveLabelBt)
396
+ save_row.setStretch(0, 1)
397
+ save_row.setStretch(1, 1)
398
+ anno.addWidget(self.SaveRowPanel, 3, 0, 1, 3)
399
+ anno.addWidget(self.SaveLabelBt, 4, 0, 1, 3)
400
+
401
+ # Channel move controls get their own row below Percentile and line up
402
+ # with the three action buttons below the channel list.
403
+ channel = self.gridLayout
404
+ channel.addWidget(self.MoveChPanel, 2, 0, 1, 3)
405
+ move_row = self.MoveChPanel.layout()
406
+ move_row.setContentsMargins(0, 0, 0, 0)
407
+ move_row.setSpacing(6)
408
+ self.MoveLabel.setText("Move channel:")
409
+ for widget in (self.MoveLabel, self.MoveUpBt, self.MoveDownBt):
410
+ widget.setMinimumWidth(0)
411
+ # QWIDGETSIZE_MAX itself produces a warning in recent PySide6.
412
+ # This is still effectively unbounded inside the sidebar.
413
+ widget.setMaximumWidth(10000)
414
+ widget.setSizePolicy(QSizePolicy.Policy.Expanding,
415
+ QSizePolicy.Policy.Fixed)
416
+ move_row.setStretch(0, 1)
417
+ move_row.setStretch(1, 1)
418
+ move_row.setStretch(2, 1)
419
+
420
+ def _constrain_inputs(self):
421
+ """Align sidebar editors without forcing cramped fixed widths."""
422
+ for name in ("FilterLowSpin", "FilterHighSpin", "PercentileSpin",
423
+ "SecondSpin", "SecondNumSpin", "multipleScalerEditor"):
424
+ spin = getattr(self, name, None)
425
+ if spin is not None:
426
+ spin.setMinimumWidth(76)
427
+ spin.setMaximumWidth(104)
428
+ for name in ("FilterTypeCombo",):
429
+ combo = getattr(self, name, None)
430
+ if combo is not None:
431
+ combo.setMinimumWidth(112)
432
+ combo.setMaximumWidth(156)
433
+ self.ShowRangeCombo.setMinimumWidth(0)
434
+ self.ShowRangeCombo.setMaximumWidth(16777215)
435
+ self.ShowRangeCombo.setSizePolicy(QSizePolicy.Policy.Expanding,
436
+ QSizePolicy.Policy.Fixed)
437
+ for edit in (self.DataPathEdit, self.AnnoPathEdit):
438
+ edit.setMinimumWidth(150)
439
+ edit.setToolTip(edit.text())
440
+ edit.textChanged.connect(edit.setToolTip)
441
+
442
+ # ------------------------------------------------------------------
443
+ # Initialization
444
+ # ------------------------------------------------------------------
445
+ def init_qt(self):
446
+ """Wire up all Qt widget signals."""
447
+ # MenuBar triggers
448
+ self.actionAbout.triggered.connect(self.about_dialog.exec)
449
+ self.actionConfig.triggered.connect(self.open_settings_dialog)
450
+ self.actionLoadData.triggered.connect(self.load_data)
451
+ self.actionLoadAnnotation.triggered.connect(self.load_anno)
452
+ self.actionStateSpectral.triggered.connect(self.state_spectral)
453
+ self.actionTransferResult.triggered.connect(self.transfer_result)
454
+ self.actionAddLine.triggered.connect(self.add_horizontal_line)
455
+ self.actionSWA_detection.triggered.connect(self.swa_detection)
456
+ self.actionSpindle_Detection.triggered.connect(self.spindle_detection)
457
+ self.actionLightGBM.triggered.connect(self.auto_stage_LightGBM)
458
+ self.actionCausalTransformer.triggered.connect(self.auto_stage_CausalTransformer)
459
+ self.actionSaveData.triggered.connect(self.save_data)
460
+
461
+ # Spectrogram percentile
462
+ self.PercentileSpin.setValue(self.spectrogram_percentile)
463
+ self.PercentileSpin.setRange(0, 100)
464
+ self.PercentileSpin.valueChanged.connect(self.spec_percentile_change)
465
+ # Default spectrogram channel
466
+ self.DefaultCh4SpecBt.clicked.connect(self.default_ch4Spec)
467
+
468
+ # Scroll bar
469
+ self.ScrollerBar.valueChanged.connect(self.scroller_change)
470
+ self.ScrollerBar.setSingleStep(self.epoch_length)
471
+ self.ScrollerBar.setPageStep(self.show_duration)
472
+
473
+ # Time edits
474
+ self.SecondSpin.valueChanged.connect(self.SecondSpin_change)
475
+ self.DateTimeEdit.dateTimeChanged.connect(self.DateTimeEdit_change)
476
+
477
+ # Channel operations
478
+ self.ShowChBt.clicked.connect(self.show_chs)
479
+ self.HideChBt.clicked.connect(self.hide_chs)
480
+ self.DeleteChBt.clicked.connect(self.delete_chs)
481
+ self.ScalerUpBt.clicked.connect(self.scaler_up)
482
+ self.ScalerDownBt.clicked.connect(self.scaler_down)
483
+ self.ShiftUpBt.clicked.connect(self.shift_up)
484
+ self.ShiftDownBt.clicked.connect(self.shift_down)
485
+ self.channel_slm.dataChanged.connect(self.channel_rename)
486
+ # Channel order is changed with the Up/Down buttons (declared in the
487
+ # .ui); editing only starts on double-click.
488
+ self.ChListView.setEditTriggers(
489
+ QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed)
490
+ self.channel_slm.rowsMoved.connect(self._on_channel_list_changed)
491
+ self.channel_slm.rowsInserted.connect(self._on_channel_list_changed)
492
+ self.channel_slm.rowsRemoved.connect(self._on_channel_list_changed)
493
+ self._updating_ch_list = False
494
+ # Channel move arrows (declared in the .ui beside the percentile box)
495
+ self.MoveUpBt.clicked.connect(lambda: self.move_channel("up"))
496
+ self.MoveDownBt.clicked.connect(lambda: self.move_channel("down"))
497
+ # Make sure the move arrows always show their glyph (and an icon),
498
+ # even if a .ui rebuild ever drops the translated text.
499
+ if not self.MoveUpBt.text():
500
+ self.MoveUpBt.setText("\u25b2")
501
+ if not self.MoveDownBt.text():
502
+ self.MoveDownBt.setText("\u25bc")
503
+
504
+ # Filter
505
+ self.FilterTypeCombo.currentIndexChanged.connect(self.FilterTypeCombo_change)
506
+ self.FilterConfirmBt.clicked.connect(self.filter_confirm)
507
+
508
+ # Spectrum window
509
+ self.PlotSpecBt.clicked.connect(self.show_spec_window)
510
+
511
+ # Custom second spin edit and ShowRangeCombo
512
+ self.ShowRangeCombo.setEnabled(True)
513
+ self.SecondNumSpin.setDisabled(True)
514
+ self.SecondNumSpin.setRange(5, 3600)
515
+ self.SecondNumSpin.setValue(30)
516
+ self.SecondNumSpin.valueChanged.connect(self.SecondNumSpin_changed)
517
+ self.ShowRangeCombo.currentIndexChanged.connect(self.ShowRangeCombo_changed)
518
+ self.CustomSecondsCheck.clicked.connect(self.CustomSecondCheck_clicked)
519
+
520
+ # Label radio: start_end by default
521
+ self.SleepStateRadio.setChecked(True)
522
+ self.MarkerRadio.toggled.connect(lambda: self.radio_recheck(self.MarkerRadio))
523
+ self.SleepStateRadio.toggled.connect(lambda: self.radio_recheck(self.SleepStateRadio))
524
+ self.StartEndRadio.toggled.connect(lambda: self.radio_recheck(self.StartEndRadio))
525
+
526
+ # Label buttons
527
+ self.NREMBt.clicked.connect(self.nrem_label)
528
+ self.REMBt.clicked.connect(self.rem_label)
529
+ self.WakeBt.clicked.connect(self.wake_label)
530
+ self.InitBt.clicked.connect(self.init_label)
531
+
532
+ # Shortcuts
533
+ self.labelSc = QShortcut(QKeySequence("a"), self)
534
+ self.labelSc.activated.connect(self.append_start_end)
535
+ self.specSc = QShortcut(QKeySequence("s"), self)
536
+ self.specSc.activated.connect(self.show_spec_window)
537
+
538
+ self.next_pageSc = QShortcut(QKeySequence("Right"), self)
539
+ self.next_pageSc.activated.connect(self.next_page)
540
+ self.previous_pageSc = QShortcut(QKeySequence("Left"), self)
541
+ self.previous_pageSc.activated.connect(self.previous_page)
542
+ self.next_epochSc = QShortcut(QKeySequence("Down"), self)
543
+ self.next_epochSc.activated.connect(self.next_epoch)
544
+ self.previous_epochSc = QShortcut(QKeySequence("Up"), self)
545
+ self.previous_epochSc.activated.connect(self.previous_epoch)
546
+
547
+ # NOTE: page-flipping with the mouse wheel is handled only by the
548
+ # event filter on the signal / hypnogram areas (see eventFilter),
549
+ # so scrolling over docks/tools never moves the signal window.
550
+
551
+ # Load data and annotation shortcuts
552
+ self.load_dataSc = QShortcut(QKeySequence("Shift+D"), self)
553
+ self.load_dataSc.activated.connect(self.load_data)
554
+ self.load_annoSc = QShortcut(QKeySequence("Shift+A"), self)
555
+ self.load_annoSc.activated.connect(self.load_anno)
556
+
557
+ # Save labels
558
+ self.SaveLabelBt.clicked.connect(self.save_anno)
559
+ self.saveSc = QShortcut(QKeySequence("Ctrl+S"), self)
560
+ self.saveSc.activated.connect(self.save_anno)
561
+
562
+ # Dynamic state-label buttons in the Annotation dock
563
+ self._sync_state_buttons()
564
+
565
+ # Scalar input
566
+ self.MultipleScalerConfirmBt.clicked.connect(self.multiple_scaler)
567
+
568
+ self.change_Bts_status(True)
569
+ self._build_menus()
570
+ # Marker / start-end event list viewers (declared in the .ui beside Save)
571
+ self.MarkerListBt.clicked.connect(self.show_marker_list)
572
+ self.StartEndListBt.clicked.connect(self.show_start_end_list)
573
+ logger.info("MiSleep ready in %.3f s - open a data file to start",
574
+ time.perf_counter() - self._startup_started)
575
+
576
+ # Wheel over spin boxes / combos must not change their values
577
+ app = QApplication.instance()
578
+ if app is not None:
579
+ self._wheel_input_guard = WheelInputGuard(app)
580
+ app.installEventFilter(self._wheel_input_guard)
581
+
582
+ def change_Bts_status(self, status=True):
583
+ """Enable/disable buttons according to whether data is loaded."""
584
+ meta_content = self.MetaDock.widget()
585
+ if meta_content is not None:
586
+ meta_content.setDisabled(status)
587
+ self.AcTimeEdit.setDisabled(status)
588
+ self.DeleteChBt.setDisabled(status)
589
+ self.HideChBt.setDisabled(status)
590
+ self.ScalerDownBt.setDisabled(status)
591
+ self.ShiftDownBt.setDisabled(status)
592
+ self.ShowChBt.setDisabled(status)
593
+ self.ScalerUpBt.setDisabled(status)
594
+ self.ShiftUpBt.setDisabled(status)
595
+ self.FilterConfirmBt.setDisabled(status)
596
+ self.DefaultCh4SpecBt.setDisabled(status)
597
+ self.FilterTypeCombo.setDisabled(status)
598
+ self.PlotSpecBt.setDisabled(status)
599
+ self.LabelBt.setDisabled(status)
600
+ self.StartEndRadio.setDisabled(status)
601
+ self.SleepStateRadio.setDisabled(status)
602
+ self.SaveLabelBt.setDisabled(status)
603
+ self.MarkerRadio.setDisabled(status)
604
+ self.MarkerListBt.setDisabled(status)
605
+ self.StartEndListBt.setDisabled(status)
606
+ self.MoveUpBt.setDisabled(status)
607
+ self.MoveDownBt.setDisabled(status)
608
+ for bt in getattr(self, "_state_btns", {}).values():
609
+ bt.setDisabled(status)
610
+ self.DateTimeEdit.setDisabled(status)
611
+ self.ShowRangeCombo.setDisabled(status)
612
+ self.SecondSpin.setDisabled(status)
613
+ self.labelSc.setEnabled(not status)
614
+ self.specSc.setEnabled(not status)
615
+ self.next_pageSc.setEnabled(not status)
616
+ self.previous_pageSc.setEnabled(not status)
617
+ self.next_epochSc.setEnabled(not status)
618
+ self.previous_epochSc.setEnabled(not status)
619
+ self.multipleScalerEditor.setDisabled(status)
620
+ self.MultipleScalerConfirmBt.setDisabled(status)
621
+
622
+ self.menuTools.setDisabled(status)
623
+ self.menuResult.setDisabled(status)
624
+ self._update_annotation_mode()
625
+ self._update_channel_action_states()
626
+
627
+ def _selected_channel_rows(self):
628
+ """Return unique selected channel rows in view order."""
629
+ return sorted({index.row() for index in self.ChListView.selectedIndexes()})
630
+
631
+ def _update_channel_action_states(self, *_args):
632
+ """Keep all channel actions synchronized with the list selection."""
633
+ loaded = self.midata is not None
634
+ selected = self._selected_channel_rows() if loaded else []
635
+ has_selection = bool(selected)
636
+ one_selected = len(selected) == 1
637
+ for widget in (
638
+ self.ShowChBt, self.HideChBt, self.DeleteChBt,
639
+ self.ScalerUpBt, self.ScalerDownBt, self.ShiftUpBt,
640
+ self.ShiftDownBt, self.multipleScalerEditor,
641
+ self.MultipleScalerConfirmBt, self.FilterTypeCombo,
642
+ self.FilterLowSpin, self.FilterHighSpin):
643
+ widget.setEnabled(loaded and has_selection)
644
+ self.FilterConfirmBt.setEnabled(loaded and one_selected)
645
+ self.DefaultCh4SpecBt.setEnabled(loaded and one_selected)
646
+ self.PlotSpecBt.setEnabled(loaded and one_selected)
647
+ self.MoveUpBt.setEnabled(loaded and one_selected and selected[0] > 0)
648
+ self.MoveDownBt.setEnabled(
649
+ loaded and one_selected and selected[0] < self.midata.n_channels - 1)
650
+ if loaded and has_selection:
651
+ self.FilterTypeCombo_change()
652
+
653
+ def _update_annotation_mode(self, *_args):
654
+ """Enable state choices only while the State mode is active."""
655
+ active = self.midata is not None and self.SleepStateRadio.isChecked()
656
+ for bt in getattr(self, "_state_btns", {}).values():
657
+ bt.setEnabled(active)
658
+
659
+ def _build_menus(self):
660
+ """Polish the menu bar: File extras, Settings, Help with user guide."""
661
+ # File: add "Save Annotation" and "Exit"
662
+ save_anno_action = self.menuFile.addAction(app_icon(), "Save Annotation", self.save_anno)
663
+ save_anno_action.setShortcut(QKeySequence("Ctrl+S"))
664
+ self.menuFile.insertAction(self.actionSaveData, save_anno_action)
665
+ self.menuFile.addSeparator()
666
+ self.menuFile.addAction("Exit", self.close)
667
+
668
+ # Help: Settings lives on its own top-level menu, not in About
669
+ self.menuHelp.removeAction(self.actionConfig)
670
+ # Help: About + User Guide (with a link to the online documentation)
671
+ self.menuHelp.addSeparator()
672
+ self.menuHelp.addAction(app_icon(), "User Guide…", self.show_user_guide)
673
+
674
+ # A dedicated Settings menu, placed between Result and Help
675
+ settings_menu = self.menuBar.addMenu("Settings")
676
+ settings_action = settings_menu.addAction(
677
+ app_icon(), "Settings…", self.open_settings_dialog)
678
+ settings_action.setToolTip("Open the in-application settings dialog")
679
+ settings_menu.addSeparator()
680
+ self.theme_action = settings_menu.addAction(
681
+ "Switch to Dark Theme", self.toggle_theme)
682
+ self.theme_action.setShortcut(QKeySequence("Ctrl+Shift+T"))
683
+ self._update_theme_action()
684
+ self.menuBar.insertMenu(self.menuHelp.menuAction(), settings_menu)
685
+
686
+ def show_user_guide(self):
687
+ """Open the in-app user guide (brief usage notes + online docs)."""
688
+ dialog = UserGuideDialog(self)
689
+ dialog.exec()
690
+
691
+ # ------------------------------------------------------------------
692
+ # Data / annotation loading
693
+ # ------------------------------------------------------------------
694
+ def load_data(self):
695
+ """Triggered by actionLoadData: ask for a signal file and open it."""
696
+ extensions = available_readers()
697
+ patterns = " ".join(f"*{ext}" for ext in extensions)
698
+ format_filters = ";;".join(
699
+ f"{ext[1:].upper()} files (*{ext})" for ext in extensions)
700
+ data_path, _ = QFileDialog.getOpenFileName(
701
+ self, "Select data file",
702
+ f"{self.config['gui']['openpath']}",
703
+ f"All supported signals ({patterns});;{format_filters};;All files (*)")
704
+
705
+ if data_path == "":
706
+ return
707
+ self.open_data(data_path)
708
+
709
+ def open_data(self, data_path):
710
+ """Load a signal file (no file dialog) and show it.
711
+
712
+ Used both by the GUI's file dialog and when MiSleep is launched
713
+ with a data file argument (``misleep data.mat``) or by
714
+ double-clicking a registered file.
715
+
716
+ Parameters
717
+ ----------
718
+ data_path : str
719
+ Path of any registered signal file to load.
720
+ """
721
+ load_started = time.perf_counter()
722
+ if not os.path.exists(data_path):
723
+ QMessageBox.about(
724
+ self, "Error",
725
+ f"Data file not found:\n{data_path}")
726
+ return
727
+
728
+ self.data_path = data_path
729
+ self.midata = None
730
+ try:
731
+ self.midata = load_signal(self.data_path)
732
+ if not isinstance(self.midata, MiData):
733
+ raise ValueError("The reader did not return valid MiData")
734
+ except Exception as exc:
735
+ logger.exception("Could not load signal file %s", self.data_path)
736
+ QMessageBox.critical(
737
+ self, "Could not load data",
738
+ f"MiSleep could not open this signal file.\n\n{exc}")
739
+ self.data_path = ""
740
+ self.change_Bts_status(True)
741
+ self.mianno = None
742
+ return
743
+ read_elapsed = time.perf_counter() - load_started
744
+
745
+ # Save config
746
+ self.save_config({"openpath": self.data_path})
747
+
748
+ # New file: drop the cached whole-file spectrograms
749
+ self._spec_full_cache = {}
750
+
751
+ # Set meta info
752
+ self.DataPathEdit.setText(self.data_path)
753
+ self.ac_time = datetime.datetime.strptime(self.midata.time, "%Y%m%d-%H:%M:%S")
754
+ self.AcTimeEdit.setDateTime(self.ac_time)
755
+
756
+ # Set channel infos
757
+ self.fill_channel_listView()
758
+
759
+ self.start_end = []
760
+ self.current_spectrogram_idx = 0
761
+ self.mianno = None
762
+ self.anno_path = ""
763
+ self.AnnoPathEdit.setText("")
764
+
765
+ self.clear_refresh(clf=True, draw=False)
766
+
767
+ try:
768
+ self.check_show()
769
+ except Exception as e:
770
+ logger.error(f"Check Show ERROR: {e}")
771
+ logger.info("Data timing: read %.3f s, initialize/draw %.3f s",
772
+ read_elapsed, time.perf_counter() - load_started - read_elapsed)
773
+
774
+ def load_anno(self):
775
+ """Triggered by actionLoadAnnotation: ask for an annotation file."""
776
+ extensions = available_annotation_readers()
777
+ patterns = " ".join(f"*{ext}" for ext in extensions)
778
+ anno_path, _ = QFileDialog.getOpenFileName(
779
+ self, "Select annotation file",
780
+ f"{self.config['gui']['openpath']}",
781
+ f"All supported annotations ({patterns});;All files (*)")
782
+
783
+ if anno_path == "":
784
+ return
785
+ self.open_annotation(anno_path)
786
+
787
+ def open_annotation(self, anno_path):
788
+ """Load an annotation file (no file dialog).
789
+
790
+ Used both by the GUI's file dialog and when MiSleep is launched
791
+ with an annotation argument (``misleep data.mat anno.txt``).
792
+
793
+ Parameters
794
+ ----------
795
+ anno_path : str
796
+ Path of the annotation ``.txt`` file.
797
+ """
798
+ load_started = time.perf_counter()
799
+ if not os.path.exists(anno_path):
800
+ QMessageBox.about(self, "Error", f"Annotation file not found:\n{anno_path}")
801
+ return
802
+
803
+ self.anno_path = anno_path
804
+ _mianno = self.mianno
805
+
806
+ try:
807
+ self.mianno = load_annotation(self.anno_path, state_map=self.state_map_dict)
808
+ except AssertionError as e:
809
+ if e.args and e.args[0] == "Empty":
810
+ if isinstance(self.midata, MiData):
811
+ self.mianno = create_new_mianno(self.midata.duration)
812
+ else:
813
+ QMessageBox.about(
814
+ self, "Error",
815
+ "To create a new annotation file, load a data file first.")
816
+ self.anno_path = ""
817
+ self.change_Bts_status(True)
818
+ self.mianno = None
819
+ return
820
+ else:
821
+ QMessageBox.critical(self, "Could not load annotation",
822
+ "The annotation file is invalid.")
823
+ self.anno_path = ""
824
+ self.mianno = _mianno
825
+ return
826
+ except Exception as exc:
827
+ logger.exception("Could not load annotation file %s", self.anno_path)
828
+ QMessageBox.critical(self, "Could not load annotation",
829
+ f"MiSleep could not open this annotation.\n\n{exc}")
830
+ self.anno_path = ""
831
+ self.mianno = _mianno
832
+ return
833
+
834
+ # Save config
835
+ self.save_config({"openpath": self.anno_path})
836
+
837
+ # Set meta info
838
+ self.AnnoPathEdit.setText(self.anno_path)
839
+
840
+ self.clear_refresh(clf=True, draw=False)
841
+
842
+ try:
843
+ self.check_show(_mianno)
844
+ except Exception as e:
845
+ logger.error(f"Check Show ERROR: {e}")
846
+ logger.info("Annotation load and redraw completed in %.3f s",
847
+ time.perf_counter() - load_started)
848
+
849
+ def check_show(self, mianno=None):
850
+ """Validate data/annotation and draw everything.
851
+
852
+ Parameters
853
+ ----------
854
+ mianno : MiAnnotation, optional
855
+ Keep the previous annotation when loading a new one failed.
856
+ """
857
+ if not isinstance(self.midata, MiData):
858
+ QMessageBox.about(self, "Error", "Load data file first.")
859
+ self.midata = None
860
+ self.mianno = None
861
+ return
862
+
863
+ # heal the canvases before (re)attaching them to the scroll areas
864
+ self._repair_canvases()
865
+
866
+ if isinstance(self.midata, MiData) and not isinstance(self.mianno, MiAnnotation):
867
+ self.mianno = create_new_mianno(self.midata.duration)
868
+ self._hypo_revision += 1
869
+
870
+ self.show_idx = list(range(self.midata.n_channels))
871
+ self.y_lims = [float(np.max(np.abs(each[:1000])))
872
+ for each in self.midata.signals]
873
+ self.y_lims = [1e-3 if each == 0.0 else each for each in self.y_lims]
874
+ self.y_shift = [0 for _ in range(self.midata.n_channels)]
875
+
876
+ if abs(self.midata.duration - self.mianno.anno_length) >= 600:
877
+ QMessageBox.about(self, "Error", "Data and annotation do not match!")
878
+ self.anno_path = ""
879
+ self.AnnoPathEdit.setText(self.anno_path)
880
+ if mianno:
881
+ self.mianno = mianno
882
+ if not isinstance(self.mianno, MiAnnotation):
883
+ self.mianno = create_new_mianno(self.midata.duration)
884
+ self.total_seconds = self.midata.duration if \
885
+ self.midata.duration < self.mianno.anno_length else self.mianno.anno_length
886
+ self.reset_sec_limit()
887
+
888
+ self.hypo_ax = self.hypo_figure.subplots(nrows=1, ncols=1)
889
+ # new axes: the cached hypnogram base must be rebuilt
890
+ self._hypo_key = None
891
+ self._hypo_steps = []
892
+ self._hypo_transient = []
893
+
894
+ # Set canvases for the plot areas
895
+ # Keep the canvases hidden until their final axes and DPI-aware size
896
+ # are ready. Otherwise Qt briefly paints the tiny default figure and
897
+ # users see it grow after loading or adding a filtered channel.
898
+ self.signal_canvas.hide()
899
+ self.hypo_canvas.hide()
900
+ self.SignalArea.setWidget(self.signal_canvas)
901
+ self.HypnoArea.setWidget(self.hypo_canvas)
902
+
903
+ self.horizontal_line = {}
904
+ for channel in self.midata.channels:
905
+ self.horizontal_line[channel] = []
906
+
907
+ self.redraw_all(second=0)
908
+ self.change_Bts_status(False)
909
+
910
+ self.setWindowTitle(
911
+ f"MiSleep - {os.path.basename(self.data_path)} - {os.path.basename(self.anno_path)}")
912
+ self.start_end = []
913
+ self.mianno._state_map = self.state_map_dict
914
+
915
+ logger.info(f"Load SUCCEED: data - {self.data_path}, anno - {self.anno_path}")
916
+
917
+ # Load feedback
918
+ logger.info(
919
+ "Loaded: %s - %d channel(s), %d s",
920
+ os.path.basename(self.data_path),
921
+ self.midata.n_channels, self.total_seconds)
922
+
923
+ # Start the auto-save timer (5 min)
924
+ self.save_timer.start(60 * 5 * 1000)
925
+
926
+ # Fit the canvases to the (high-DPI aware) window size
927
+ self._fit_canvases()
928
+ self.signal_canvas.show()
929
+ self.hypo_canvas.show()
930
+
931
+ def reset_sec_limit(self):
932
+ """Update scrollbar / spin / datetime limits when show duration changes."""
933
+ self.ScrollerBar.setRange(0, self.total_seconds - self.show_duration)
934
+ self.SecondSpin.setRange(0, self.total_seconds - self.show_duration)
935
+
936
+ self.DateTimeEdit.blockSignals(True)
937
+ self.DateTimeEdit.setDateTimeRange(
938
+ self.ac_time,
939
+ self.ac_time + datetime.timedelta(seconds=self.total_seconds - self.show_duration))
940
+ self.DateTimeEdit.blockSignals(False)
941
+
942
+ # ------------------------------------------------------------------
943
+ # Plotting
944
+ # ------------------------------------------------------------------
945
+ def clear_refresh(self, clf=False, draw=True):
946
+ """Clear and refresh all plot canvases."""
947
+ if clf:
948
+ self.signal_figure.clf()
949
+ self.hypo_figure.clf()
950
+ # the old axes are gone; force a rebuild on the next plot
951
+ self.signal_ax = None
952
+ self._signal_artists = {}
953
+ self._spec_artist = None
954
+ self._hypo_key = None
955
+ self._hypo_steps = []
956
+ self._hypo_transient = []
957
+
958
+ if not draw:
959
+ return
960
+
961
+ self.hypo_figure.canvas.draw()
962
+ self.hypo_figure.canvas.flush_events()
963
+ self.signal_figure.canvas.draw()
964
+ self.signal_figure.canvas.flush_events()
965
+
966
+ def _repair_canvases(self):
967
+ """Recreate a main canvas whose matplotlib figure was closed.
968
+
969
+ A stray pyplot ``close()`` in some export routine used to kill the
970
+ main figures, freezing the signal/hypnogram panels. With the
971
+ source fixed this should never happen, but the guard keeps the
972
+ window self-healing anyway.
973
+ """
974
+ import matplotlib.pyplot as plt
975
+
976
+ def alive(fig):
977
+ try:
978
+ return (plt.fignum_exists(fig.number)
979
+ and plt.figure(fig.number) is fig)
980
+ except Exception: # pragma: no cover
981
+ return False
982
+
983
+ if not alive(self.signal_figure):
984
+ self.signal_figure = plt.figure()
985
+ self.signal_ax = None
986
+ self.signal_canvas = FigureCanvas(self.signal_figure)
987
+ self.signal_canvas.mpl_connect("button_release_event", self.click_signal)
988
+ self.signal_canvas.installEventFilter(self)
989
+ self.SignalArea.setWidget(self.signal_canvas)
990
+ self._signal_artists = {}
991
+ self._spec_artist = None
992
+ logger.warning("Signal canvas was closed externally - recreated")
993
+
994
+ if not alive(self.hypo_figure):
995
+ self.hypo_figure = plt.figure(layout="constrained")
996
+ self.hypo_ax = self.hypo_figure.subplots()
997
+ self.hypo_canvas = FigureCanvas(self.hypo_figure)
998
+ self.hypo_canvas.mpl_connect("button_release_event", self.click_hypo)
999
+ self.hypo_canvas.installEventFilter(self)
1000
+ self.HypnoArea.setWidget(self.hypo_canvas)
1001
+ self._hypo_key = None
1002
+ self._hypo_steps = []
1003
+ self._hypo_transient = []
1004
+ logger.warning("Hypnogram canvas was closed externally - recreated")
1005
+
1006
+ def _fit_canvases(self):
1007
+ """Resize the matplotlib figures to match their widgets.
1008
+
1009
+ The widget size is converted to **device pixels** (multiplied by
1010
+ ``devicePixelRatio``) before being fed to ``set_size_inches``, so
1011
+ the figure fills the widget exactly on high-DPI / scaled displays
1012
+ (e.g. 2K at 125-150 %). A modest minimum keeps scrollbars instead
1013
+ of a crushed plot.
1014
+ """
1015
+ try:
1016
+ sig_dpr = self.signal_canvas.devicePixelRatioF() or 1.0
1017
+ sig_w = self.signal_canvas.width() * sig_dpr
1018
+ sig_h = self.signal_canvas.height() * sig_dpr
1019
+ if sig_w < 50 or sig_h < 50:
1020
+ sig_w = self.SignalArea.viewport().width() * sig_dpr
1021
+ sig_h = self.SignalArea.viewport().height() * sig_dpr
1022
+ if sig_w > 50 and sig_h > 50:
1023
+ self.signal_figure.set_size_inches(
1024
+ sig_w / self.signal_figure.dpi,
1025
+ sig_h / self.signal_figure.dpi)
1026
+ self.signal_canvas.setMinimumSize(300, 200)
1027
+ self._apply_signal_layout()
1028
+ self.signal_figure.canvas.draw_idle()
1029
+
1030
+ hypo_dpr = self.hypo_canvas.devicePixelRatioF() or 1.0
1031
+ hypo_w = self.hypo_canvas.width() * hypo_dpr
1032
+ hypo_h = self.hypo_canvas.height() * hypo_dpr
1033
+ if hypo_w < 50 or hypo_h < 50:
1034
+ hypo_w = self.HypnoArea.viewport().width() * hypo_dpr
1035
+ hypo_h = self.HypnoArea.viewport().height() * hypo_dpr
1036
+ if hypo_w > 50 and hypo_h > 50:
1037
+ self.hypo_figure.set_size_inches(
1038
+ hypo_w / self.hypo_figure.dpi,
1039
+ max(1.0, hypo_h / self.hypo_figure.dpi))
1040
+ self.hypo_canvas.setMinimumSize(300, 60)
1041
+ self.hypo_figure.canvas.draw_idle()
1042
+ except Exception as e: # pragma: no cover
1043
+ logger.debug(f"fit canvases skipped: {e}")
1044
+
1045
+ def eventFilter(self, obj, event):
1046
+ """Track canvas/scroll-area resizes; flip pages on mouse wheel."""
1047
+ if event.type() == QEvent.Type.Resize:
1048
+ if obj in (self.signal_canvas, self.hypo_canvas,
1049
+ self.SignalArea, self.HypnoArea):
1050
+ self._fit_canvases()
1051
+ elif event.type() == QEvent.Type.Wheel:
1052
+ if obj in (self.SignalArea.viewport(), self.HypnoArea.viewport(),
1053
+ self.signal_canvas, self.hypo_canvas):
1054
+ delta = event.angleDelta().y()
1055
+ if delta < 0:
1056
+ self.next_page()
1057
+ elif delta > 0:
1058
+ self.previous_page()
1059
+ return True
1060
+ return super().eventFilter(obj, event)
1061
+
1062
+ def resizeEvent(self, event):
1063
+ """Keep the matplotlib canvases in sync with the window size."""
1064
+ super().resizeEvent(event)
1065
+ self._fit_canvases()
1066
+
1067
+ def showEvent(self, event):
1068
+ """Fit the canvases once the window is actually shown."""
1069
+ super().showEvent(event)
1070
+ QTimer.singleShot(0, self._fit_canvases)
1071
+
1072
+ def _choose_tick_step(self, duration):
1073
+ """Pick a tick interval (seconds) so ~6-24 x ticks are shown."""
1074
+ for s in (5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600):
1075
+ if duration / s <= 24:
1076
+ return s
1077
+ return 3600
1078
+
1079
+ def plot_signals(self, flush=True, clf=True, replot_axes=None):
1080
+ """Plot the signal area: spectrogram strip + one box per channel.
1081
+
1082
+ Every channel gets its own axes (box) with its own y scale, so
1083
+ the boxes are independent. The figure fills the whole signal
1084
+ widget (see :meth:`_fit_canvases`).
1085
+
1086
+ Parameters
1087
+ ----------
1088
+ flush : bool
1089
+ Whether to redraw the canvas.
1090
+ clf, replot_axes : kept for API compatibility (always full redraw).
1091
+ """
1092
+ n = len(self.show_idx)
1093
+ need = n + 1 # spectrogram strip + one box per channel
1094
+ # Reuse the existing axes when the channel count is unchanged - much
1095
+ # faster than recreating them on every page flip.
1096
+ current = getattr(self, "signal_ax", None)
1097
+ structure_changed = current is None or not isinstance(current, (list, tuple)) \
1098
+ or len(current) != need
1099
+ if structure_changed:
1100
+ # No event-loop turn occurs between hide/show, so the user sees
1101
+ # only the completed layout, never matplotlib's resize stages.
1102
+ self.signal_canvas.hide()
1103
+ self.signal_figure.clf()
1104
+ self.signal_ax = list(self.signal_figure.subplots(
1105
+ nrows=need, ncols=1,
1106
+ gridspec_kw={"height_ratios": [0.8] + [1.0] * n}))
1107
+ self._signal_artists = {}
1108
+ self._spec_artist = None
1109
+ else:
1110
+ # Remove only the artists we own from the previous flip - far
1111
+ # cheaper than clearing the whole axes.
1112
+ for ax_idx, artists in self._signal_artists.items():
1113
+ for art in artists:
1114
+ try:
1115
+ art.remove()
1116
+ except Exception:
1117
+ pass
1118
+ self._signal_artists = {}
1119
+ self.plot_spectrogram()
1120
+ # visible frames around every panel (spectrogram strip + boxes)
1121
+ self._style_signal_boxes()
1122
+
1123
+ # Sleep-state groups inside the current window
1124
+ sleep_state = self.mianno.sleep_state[
1125
+ self.current_sec: self.current_sec + self.show_duration + 1]
1126
+ sleep_state = lst2group([i, each] for i, each in enumerate(sleep_state))
1127
+
1128
+ tick_step = self._choose_tick_step(self.show_duration)
1129
+
1130
+ for i, each in enumerate(self.show_idx):
1131
+ ax = self.signal_ax[i + 1]
1132
+ y_lim = self.y_lims[each]
1133
+ y_shift = self.y_shift[each]
1134
+ sf = self.midata.sf[each]
1135
+
1136
+ seg = self.midata.signals[each][
1137
+ int(self.current_sec * sf): int(
1138
+ (self.current_sec + self.show_duration) * sf)]
1139
+ # Short windows (up to ~3 min at 300 Hz) keep every sample;
1140
+ # long windows use a min/max envelope so the waveform keeps its
1141
+ # shape without the obvious thinning of every-Nth decimation.
1142
+ if len(seg) <= 60000:
1143
+ xs, ys = None, seg
1144
+ else:
1145
+ xs, ys = self._decimate_trace(seg)
1146
+ if xs is None:
1147
+ line, = ax.plot(ys, color=self._plot_trace, linewidth=0.5)
1148
+ else:
1149
+ line, = ax.plot(xs, ys, color=self._plot_trace, linewidth=0.5)
1150
+ self._signal_artists.setdefault(i + 1, []).append(line)
1151
+ ax.set_ylim(ymin=-y_lim + y_shift, ymax=y_lim + y_shift)
1152
+ ax.set_xlim(xmin=0, xmax=self.show_duration * sf)
1153
+ ax.xaxis.set_ticks([])
1154
+ ax.yaxis.set_ticks([])
1155
+ ax.set_ylabel(f"{self.midata.channels[each]}\n{y_lim:.2e}")
1156
+
1157
+ # grid lines: 5 s for short windows, tick step for long ones
1158
+ grid_step = 5 if self.show_duration < 300 else tick_step
1159
+ for pos_ in range(0, self.show_duration, grid_step):
1160
+ grid_line = ax.axvline(pos_ * sf, color=self._plot_grid,
1161
+ linestyle="--", linewidth=1, alpha=0.45)
1162
+ self._signal_artists[i + 1].append(grid_line)
1163
+
1164
+ # Sleep-state background: one rectangle per run (the fill spans
1165
+ # the full height, so two x-points are exact and cheap to draw)
1166
+ for state in sleep_state:
1167
+ fill = ax.fill_between(
1168
+ [int(state[0] * sf), int(state[1] * sf)],
1169
+ -y_lim + y_shift, y_lim + y_shift,
1170
+ facecolor=self.state_color_dict[state[2]],
1171
+ alpha=float(self.config["gui"]["statecolorbgalpha"]))
1172
+ self._signal_artists[i + 1].append(fill)
1173
+
1174
+ # Time ticks only on the last channel box (auto-reduced for long windows)
1175
+ last_sf = self.midata.sf[self.show_idx[-1]]
1176
+ self.signal_ax[-1].xaxis.set_ticks(
1177
+ [int(each * last_sf) for each in range(0, self.show_duration + 1, tick_step)],
1178
+ range(self.current_sec, self.current_sec + self.show_duration + 1, tick_step),
1179
+ rotation=45)
1180
+ if self.show_duration < 300:
1181
+ self.signal_ax[-1].xaxis.set_ticks(
1182
+ [int(each * last_sf) for each in range(0, self.show_duration + 1)],
1183
+ minor=True)
1184
+
1185
+ if self.StartEndRadio.isChecked():
1186
+ self.plot_start_end_line(flush=False, ms=True)
1187
+ if self.SleepStateRadio.isChecked():
1188
+ self.plot_start_end_line(flush=False)
1189
+ self.plot_marker_line(flush=False)
1190
+ self.plot_start_end_label_line(flush=False)
1191
+ self.plot_horizontal_line(flush=False)
1192
+
1193
+ # Keep the panels tightly packed (no gaps between boxes); the full
1194
+ # tight_layout runs on resize only (see _fit_canvases), not on every
1195
+ # page flip - that alone shaves ~20 ms off each flip.
1196
+ self.signal_figure.subplots_adjust(hspace=0)
1197
+
1198
+ if flush:
1199
+ self.signal_figure.canvas.draw()
1200
+ self.signal_figure.canvas.flush_events()
1201
+ if structure_changed:
1202
+ self._fit_canvases()
1203
+ self.signal_canvas.show()
1204
+
1205
+ def _apply_signal_layout(self):
1206
+ """Tighten the signal figure layout so the panels touch each other."""
1207
+ try:
1208
+ self.signal_figure.tight_layout(h_pad=0, w_pad=0)
1209
+ self.signal_figure.subplots_adjust(hspace=0)
1210
+ except Exception as e: # pragma: no cover
1211
+ logger.debug(f"signal layout skipped: {e}")
1212
+
1213
+ def _style_signal_boxes(self):
1214
+ """Give every signal panel a visible frame so the channels don't
1215
+ look like they float together (all four spines on, theme edge)."""
1216
+ edge = resolved_theme(
1217
+ self._theme_name, self._tone_name)["mpl"]["axes.edgecolor"]
1218
+ for ax in self.signal_ax:
1219
+ for spine in ax.spines.values():
1220
+ spine.set_visible(True)
1221
+ spine.set_edgecolor(edge)
1222
+ spine.set_linewidth(0.9)
1223
+
1224
+ @staticmethod
1225
+ def _decimate_trace(sig, bins=12000):
1226
+ """Min/max envelope decimation that preserves the signal shape.
1227
+
1228
+ Naive every-Nth sampling makes dense EEG look visibly thinned and
1229
+ can alias rhythmic features; plotting the min/max of each bin keeps
1230
+ the waveform envelope intact while bounding the draw cost.
1231
+ """
1232
+ n = len(sig)
1233
+ if n <= bins * 2:
1234
+ return np.arange(n), sig
1235
+ step = int(np.ceil(n / bins))
1236
+ n_bins = n // step
1237
+ trimmed = sig[: n_bins * step].reshape(n_bins, step)
1238
+ mn = trimmed.min(axis=1)
1239
+ mx = trimmed.max(axis=1)
1240
+ xs = np.repeat(np.arange(n_bins) * step, 2)
1241
+ ys = np.empty(2 * n_bins)
1242
+ ys[0::2] = mn
1243
+ ys[1::2] = mx
1244
+ return xs, ys
1245
+
1246
+ def replot_sleep_state_bg(self, state):
1247
+ """Replot the sleep-state background of the selected start-end area."""
1248
+ replot_start = 0 if self.current_sec >= self.start_end[0] \
1249
+ else (self.start_end[0] - self.current_sec)
1250
+ replot_end = self.show_duration if (
1251
+ self.current_sec + self.show_duration <= self.start_end[1]
1252
+ ) else (self.start_end[1] - self.current_sec)
1253
+
1254
+ for i, each in enumerate(self.show_idx):
1255
+ y_lim = self.y_lims[each]
1256
+ y_shift = self.y_shift[each]
1257
+ sf = self.midata.sf[each]
1258
+ x = range(int(replot_start * sf), int(replot_end * sf))
1259
+ cover = self.signal_ax[i + 1].fill_between(
1260
+ x, -y_lim + y_shift, y_lim + y_shift,
1261
+ facecolor=resolved_theme(
1262
+ self._theme_name, self._tone_name)["plot"]["bg"], alpha=1)
1263
+ fill = self.signal_ax[i + 1].fill_between(
1264
+ x, -y_lim + y_shift, y_lim + y_shift,
1265
+ facecolor=self.state_color_dict[state],
1266
+ alpha=float(self.config["gui"]["statecolorbgalpha"]))
1267
+ self._signal_artists.setdefault(i + 1, []).extend([cover, fill])
1268
+ self.signal_figure.canvas.draw()
1269
+ self.signal_figure.canvas.flush_events()
1270
+
1271
+ def spec_percentile_change(self):
1272
+ """Triggered by the spectrogram percentile spin box."""
1273
+ self.spectrogram_percentile = self.PercentileSpin.value()
1274
+ self.plot_spectrogram(flush=True)
1275
+
1276
+ def default_ch4Spec(self):
1277
+ """Set the selected channel as the default spectrogram channel."""
1278
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
1279
+ if len(selected_channel) != 1:
1280
+ QMessageBox.about(
1281
+ self, "Error",
1282
+ "Select one channel to be the default channel for spectrogram.")
1283
+ return
1284
+ self.current_spectrogram_idx = selected_channel[0]
1285
+ self.plot_spectrogram(flush=True)
1286
+
1287
+ def plot_spectrogram(self, flush=False):
1288
+ """Redraw the spectrogram strip (cached whole-file STFT when possible)."""
1289
+ if self.midata is None:
1290
+ return
1291
+ from misleep.preprocessing.spectral import spectrogram
1292
+ # remove the previous spectrogram artist (cheaper than clearing axes)
1293
+ if getattr(self, "_spec_artist", None) is not None:
1294
+ try:
1295
+ self._spec_artist.remove()
1296
+ except Exception:
1297
+ pass
1298
+ self._spec_artist = None
1299
+ freq_range = [float(x) for x in self.config["gui"]["freq_range"].strip("[]").split(",")]
1300
+ ch = self.current_spectrogram_idx
1301
+ sf = self.midata.sf[ch]
1302
+
1303
+ # Cache the STFT of the whole file per channel; page flips then only
1304
+ # slice the cached spectrogram instead of recomputing it.
1305
+ if ch not in self._spec_full_cache:
1306
+ if self.midata.duration <= self._spec_cache_max_sec:
1307
+ f, t, Sxx = spectrogram(
1308
+ signal=self.midata.signals[ch], sf=sf,
1309
+ band=freq_range, step=1, win_sec=5, norm=True)
1310
+ self._spec_full_cache[ch] = (f, t, Sxx)
1311
+ else:
1312
+ self._spec_full_cache[ch] = None # too long: per-window
1313
+
1314
+ cached = self._spec_full_cache.get(ch)
1315
+ if cached is not None:
1316
+ f, t, Sxx = cached
1317
+ sel = (t >= self.current_sec) & (t <= self.current_sec + self.show_duration)
1318
+ t = t[sel]
1319
+ Sxx = Sxx[:, sel]
1320
+ else:
1321
+ f, t, Sxx = spectrogram(
1322
+ signal=self.midata.signals[ch][
1323
+ int(self.current_sec * sf): int(
1324
+ (self.current_sec + self.show_duration) * sf)],
1325
+ sf=sf, band=freq_range, step=1, win_sec=5, norm=True)
1326
+
1327
+ # Long windows: cap the number of time columns drawn
1328
+ if t.size > 2000:
1329
+ step_t = max(1, -(-t.size // 2000))
1330
+ t = t[::step_t]
1331
+ Sxx = Sxx[:, ::step_t]
1332
+
1333
+ cmap_name = self.config.get("gui", "spectrogram_cmap", fallback="jet")
1334
+ try:
1335
+ cmap = plt.get_cmap(cmap_name)
1336
+ except ValueError:
1337
+ cmap = plt.get_cmap("jet")
1338
+ vmin, vmax = spectrogram_color_limits(Sxx, self.spectrogram_percentile)
1339
+
1340
+ self.signal_ax[0].set_xticks([])
1341
+ # Pin the horizontal extent explicitly: with reused axes the
1342
+ # auto-limit can stay stuck at a previous (longer) window, which
1343
+ # compresses the strip to a fraction of its width.
1344
+ self.signal_ax[0].set_xlim(t.min(), t.max())
1345
+ self.signal_ax[0].set_ylim(freq_range)
1346
+ self.signal_ax[0].set_ylabel(f"{self.midata.channels[ch]}")
1347
+ # Match v2's dark low-power background even in the light theme and
1348
+ # eliminate axes-color seams around the pcolormesh.
1349
+ self.signal_ax[0].set_facecolor(cmap(0.0))
1350
+ self._spec_artist = self.signal_ax[0].pcolormesh(
1351
+ t, f, Sxx, cmap=cmap, vmin=vmin, vmax=vmax, shading="auto")
1352
+
1353
+ if flush:
1354
+ self.signal_figure.canvas.draw()
1355
+ self.signal_figure.canvas.flush_events()
1356
+
1357
+ def plot_start_end_line(self, flush=True, ms=False):
1358
+ """Plot the interactive start/end selection lines in the signal area."""
1359
+ start_end = self.start_end_ms if ms else self.start_end
1360
+ for axvline in self.signal_start_end_axvline:
1361
+ try:
1362
+ axvline.remove()
1363
+ except Exception:
1364
+ pass
1365
+ self.signal_start_end_axvline = []
1366
+ for i, each in enumerate(start_end):
1367
+ if self.current_sec <= each <= self.current_sec + self.show_duration:
1368
+ for idx, show_ in enumerate(self.show_idx):
1369
+ y_lim = self.y_lims[show_]
1370
+ y_shift = self.y_shift[show_]
1371
+ sf = self.midata.sf[show_]
1372
+ if ms:
1373
+ x = round((each - self.current_sec) * sf, 3)
1374
+ else:
1375
+ x = int((each - self.current_sec) * sf)
1376
+
1377
+ if i in (0, 1):
1378
+ self.signal_start_end_axvline.append(
1379
+ self.signal_ax[idx + 1].axvline(x, color="lime", alpha=1))
1380
+
1381
+ if i == 0:
1382
+ self.signal_start_end_axvline.append(
1383
+ self.signal_ax[idx + 1].text(
1384
+ x=x, y=-y_lim + y_shift, s="S", color="lime"))
1385
+ if i == 1:
1386
+ self.signal_start_end_axvline.append(
1387
+ self.signal_ax[idx + 1].text(
1388
+ x=x, y=-y_lim + y_shift,
1389
+ horizontalalignment="right", s="E", color="lime"))
1390
+
1391
+ if flush:
1392
+ self.signal_figure.canvas.draw()
1393
+ self.signal_figure.canvas.flush_events()
1394
+ self.plot_hypo()
1395
+
1396
+ def plot_marker_line(self, flush=True):
1397
+ """Plot marker lines in the signal area."""
1398
+ marker_color = self.config["gui"].get(
1399
+ "markerlinecolor", "red").strip("\"'")
1400
+ for axvline in self.signal_marker_axvline:
1401
+ try:
1402
+ axvline.remove()
1403
+ except Exception:
1404
+ pass
1405
+ self.signal_marker_axvline = []
1406
+ for each in self.mianno.marker:
1407
+ if self.current_sec <= each[0] <= self.current_sec + self.show_duration:
1408
+ for idx, show_ in enumerate(self.show_idx):
1409
+ self.signal_marker_axvline.append(
1410
+ self.signal_ax[idx + 1].axvline(
1411
+ int((each[0] - self.current_sec) * self.midata.sf[show_]),
1412
+ color=marker_color, alpha=1))
1413
+ self.signal_marker_axvline.append(
1414
+ self.signal_ax[1].text(
1415
+ x=int((each[0] - self.current_sec) * self.midata.sf[self.show_idx[0]]),
1416
+ y=self.y_lims[self.show_idx[0]] + self.y_shift[self.show_idx[0]],
1417
+ s=each[1], verticalalignment="top", color=marker_color))
1418
+
1419
+ if flush:
1420
+ self.signal_figure.canvas.draw()
1421
+ self.signal_figure.canvas.flush_events()
1422
+ self.plot_hypo()
1423
+
1424
+ def plot_start_end_label_line(self, flush=True):
1425
+ """Plot the start/end annotation label lines."""
1426
+ for art in getattr(self, "signal_se_label_axvline", []):
1427
+ try:
1428
+ art.remove()
1429
+ except Exception:
1430
+ pass
1431
+ self.signal_se_label_axvline = []
1432
+ for each in self.mianno.start_end:
1433
+ if self.current_sec <= each[0] <= self.current_sec + self.show_duration:
1434
+ for idx, show_ in enumerate(self.show_idx):
1435
+ self.signal_se_label_axvline.append(
1436
+ self.signal_ax[idx + 1].axvline(
1437
+ int((each[0] - self.current_sec) * self.midata.sf[show_]),
1438
+ color=identify_startend_color(self.start_end_color_dict, each[2]),
1439
+ alpha=1))
1440
+ self.signal_se_label_axvline.append(
1441
+ self.signal_ax[1].text(
1442
+ x=int((each[0] - self.current_sec) * self.midata.sf[self.show_idx[0]]),
1443
+ y=self.y_lims[self.show_idx[0]] + self.y_shift[self.show_idx[0]],
1444
+ s=each[2] + "-S", verticalalignment="top",
1445
+ color=identify_startend_color(self.start_end_color_dict, each[2])))
1446
+
1447
+ if self.current_sec <= each[1] <= self.current_sec + self.show_duration:
1448
+ for idx, show_ in enumerate(self.show_idx):
1449
+ self.signal_se_label_axvline.append(
1450
+ self.signal_ax[idx + 1].axvline(
1451
+ int((each[1] - self.current_sec) * self.midata.sf[show_]),
1452
+ color="orange", alpha=1))
1453
+ self.signal_se_label_axvline.append(
1454
+ self.signal_ax[1].text(
1455
+ x=int((each[1] - self.current_sec) * self.midata.sf[self.show_idx[0]]),
1456
+ y=self.y_lims[self.show_idx[0]] + self.y_shift[self.show_idx[0]],
1457
+ s=each[2] + "-E", verticalalignment="top",
1458
+ horizontalalignment="right", color="orange"))
1459
+
1460
+ if flush:
1461
+ self.signal_figure.canvas.draw()
1462
+ self.signal_figure.canvas.flush_events()
1463
+
1464
+ def plot_horizontal_line(self, flush=True):
1465
+ """Plot the horizontal reference lines."""
1466
+ for axhline in self.axhline_horizontal:
1467
+ try:
1468
+ axhline.remove()
1469
+ except Exception:
1470
+ pass
1471
+ for idx, show_ in enumerate(self.show_idx):
1472
+ ch = self.midata.channels[show_]
1473
+ sf = self.midata.sf[show_]
1474
+ for line_value, color, comment in self.horizontal_line.get(ch, []):
1475
+ self.axhline_horizontal.append(
1476
+ self.signal_ax[idx + 1].axhline(line_value, color=color, alpha=1))
1477
+ self.axhline_horizontal.append(
1478
+ self.signal_ax[idx + 1].text(
1479
+ x=int(self.show_duration * sf), y=line_value,
1480
+ s=f"{line_value:.2e}",
1481
+ horizontalalignment="left", color=color))
1482
+
1483
+ if flush:
1484
+ self.signal_figure.canvas.draw()
1485
+ self.signal_figure.canvas.flush_events()
1486
+
1487
+ def plot_hypo(self):
1488
+ """Redraw the hypnogram area (one colored segment per sleep state)."""
1489
+ # The state-step background only changes when the annotation changes;
1490
+ # rebuild it lazily and reuse it across page flips.
1491
+ key = None
1492
+ if self.mianno is not None:
1493
+ key = (
1494
+ id(self.mianno),
1495
+ self._hypo_revision,
1496
+ tuple(sorted(self.state_map_dict.items())),
1497
+ tuple(sorted(self.state_color_dict.items())),
1498
+ float(self.config["gui"].get("hypnogramstatealpha", "0.55")),
1499
+ )
1500
+ if key != self._hypo_key:
1501
+ self.hypo_ax.clear()
1502
+ self._hypo_transient = []
1503
+ # One thick filled bar per consecutive run of the same state,
1504
+ # spanning the full state band (edges touch between states).
1505
+ # All bars are drawn as a single PolyCollection, so building the
1506
+ # hypnogram stays fast even for multi-hour recordings with tens
1507
+ # of thousands of state runs.
1508
+ band = 0.5 # half height of each bar (state +/- band)
1509
+ runs = lst2group([i, each] for i, each in enumerate(self.mianno.sleep_state))
1510
+ rects = []
1511
+ colors = []
1512
+ for start, end, state in runs:
1513
+ if end <= start:
1514
+ continue
1515
+ y0 = state - band
1516
+ y1 = state + band
1517
+ rects.append([(start, y0), (end, y0), (end, y1), (start, y1)])
1518
+ colors.append(self.state_color_dict.get(state, "#8892a0"))
1519
+ if rects:
1520
+ collection = PolyCollection(
1521
+ rects, facecolors=colors, edgecolors="none",
1522
+ alpha=float(self.config["gui"].get(
1523
+ "hypnogramstatealpha", "0.55")), zorder=1)
1524
+ self._hypo_steps.append(self.hypo_ax.add_collection(collection))
1525
+
1526
+ self.hypo_ax.set_ylim(0, len(list(self.state_map_dict.keys())) + 0.5)
1527
+ self.hypo_ax.set_xlim(0, self.total_seconds)
1528
+ self.hypo_ax.yaxis.set_ticks(
1529
+ list(self.state_map_dict.keys()), list(self.state_map_dict.values()))
1530
+ self.hypo_ax.set_xlabel("Time (s)")
1531
+ # faint horizontal guides at each state level
1532
+ self.hypo_ax.grid(axis="y", alpha=0.25)
1533
+ self._hypo_key = key
1534
+
1535
+ # remove the per-flip overlay artists (current-time / event lines)
1536
+ for art in self._hypo_transient:
1537
+ try:
1538
+ art.remove()
1539
+ except Exception:
1540
+ pass
1541
+ self._hypo_transient = []
1542
+
1543
+ try:
1544
+ self.hypo_axvline.remove()
1545
+ except Exception:
1546
+ pass # axes may have been recreated; a new line is drawn below
1547
+ self.hypo_axvline = self.hypo_ax.axvline(
1548
+ self.current_sec, color="gray", alpha=0.8)
1549
+ self._hypo_transient.append(self.hypo_axvline)
1550
+
1551
+ if self.StartEndRadio.isChecked():
1552
+ for each in self.start_end_ms:
1553
+ self._hypo_transient.append(
1554
+ self.hypo_ax.axvline(each, color="lime", alpha=1))
1555
+ if self.SleepStateRadio.isChecked():
1556
+ for each in self.start_end:
1557
+ self._hypo_transient.append(
1558
+ self.hypo_ax.axvline(each, color="lime", alpha=1))
1559
+ for each in self.mianno.marker:
1560
+ self._hypo_transient.append(
1561
+ self.hypo_ax.axvline(
1562
+ each[0], color=self.config["gui"].get(
1563
+ "markerlinecolor", "red").strip("\"'"), alpha=1,
1564
+ linewidth=1.6, zorder=6))
1565
+
1566
+ self.hypo_figure.canvas.draw()
1567
+ self.hypo_figure.canvas.flush_events()
1568
+
1569
+ def redraw_all(self, second=0):
1570
+ """Validate ``second`` and redraw everything."""
1571
+ # heal the canvases first in case an export closed a figure
1572
+ self._repair_canvases()
1573
+
1574
+ if second + self.show_duration >= self.total_seconds:
1575
+ self.current_sec = self.total_seconds - self.show_duration
1576
+ elif second <= 0:
1577
+ self.current_sec = 0
1578
+ else:
1579
+ self.current_sec = second
1580
+
1581
+ # Block signals to avoid cyclic value-change operations
1582
+ self.ScrollerBar.blockSignals(True)
1583
+ self.SecondSpin.blockSignals(True)
1584
+ self.DateTimeEdit.blockSignals(True)
1585
+ self.ScrollerBar.setValue(self.current_sec)
1586
+ self.SecondSpin.setValue(self.current_sec)
1587
+ self.DateTimeEdit.setDateTime(self.ac_time + datetime.timedelta(seconds=self.current_sec))
1588
+ self.ScrollerBar.blockSignals(False)
1589
+ self.SecondSpin.blockSignals(False)
1590
+ self.DateTimeEdit.blockSignals(False)
1591
+ self.plot_signals()
1592
+ self.plot_hypo()
1593
+
1594
+ def fill_channel_listView(self):
1595
+ """Fill the channel list view with ``self.midata.channels``."""
1596
+ self._updating_ch_list = True
1597
+ try:
1598
+ self.channel_slm.setChannels(self.midata.channels)
1599
+ # only (re)attach the model when needed, otherwise the
1600
+ # selection would be cleared on every refresh
1601
+ if self.ChListView.model() is not self.channel_slm:
1602
+ self.ChListView.setModel(self.channel_slm)
1603
+ selection_model = self.ChListView.selectionModel()
1604
+ if selection_model is not None:
1605
+ selection_model.selectionChanged.connect(
1606
+ self._update_channel_action_states)
1607
+ finally:
1608
+ self._updating_ch_list = False
1609
+ self._update_channel_action_states()
1610
+
1611
+ def _on_channel_list_changed(self, *args):
1612
+ """React to any channel-list model change.
1613
+
1614
+ ``ChannelListModel`` supports ``moveRows``, so a drag & drop emits
1615
+ ``rowsMoved``; this hook detects the resulting pure reorder (a
1616
+ permutation of the channel names) and applies it. Plain text edits
1617
+ are left to :meth:`channel_rename`.
1618
+ """
1619
+ if self._updating_ch_list or self.midata is None:
1620
+ return
1621
+ try:
1622
+ lst = list(self.channel_slm.channels())
1623
+ except Exception:
1624
+ return
1625
+ if len(lst) != self.midata.n_channels:
1626
+ return
1627
+ if lst == self.midata.channels:
1628
+ return
1629
+ if sorted(lst) != sorted(self.midata.channels):
1630
+ return # a text edit (rename), not a move
1631
+ self.channel_moved(None, 0, len(lst) - 1, None, 0)
1632
+
1633
+ def _apply_channel_order(self, new_order):
1634
+ """Reorder the data and view state to match ``new_order``.
1635
+
1636
+ Only the order changes -- channel names and data stay intact.
1637
+ """
1638
+ spec_name = self.midata.channels[self.current_spectrogram_idx]
1639
+ order_idx = [self.midata.channels.index(name) for name in new_order]
1640
+
1641
+ self.midata.reorder_channels(new_order)
1642
+
1643
+ self.y_lims = [self.y_lims[i] for i in order_idx]
1644
+ self.y_shift = [self.y_shift[i] for i in order_idx]
1645
+ self.horizontal_line = {name: self.horizontal_line.get(name, []) for name in new_order}
1646
+ self.current_spectrogram_idx = self.midata.channels.index(spec_name)
1647
+ self.show_idx = list(range(self.midata.n_channels))
1648
+
1649
+ self.fill_channel_listView()
1650
+ self.redraw_all(second=self.current_sec)
1651
+
1652
+ def channel_moved(self, parent, start, end, destination, row):
1653
+ """Reorder the data to match a model move in the channel list."""
1654
+ if self._updating_ch_list or self.midata is None:
1655
+ return
1656
+
1657
+ new_order = list(self.channel_slm.channels())
1658
+ if new_order == self.midata.channels or len(new_order) != self.midata.n_channels:
1659
+ return
1660
+ self._apply_channel_order(new_order)
1661
+
1662
+ def move_channel(self, direction):
1663
+ """Move the selected channel up or down in the list (buttons)."""
1664
+ if self.midata is None:
1665
+ return
1666
+ selected = [r.row() for r in self.ChListView.selectedIndexes()]
1667
+ if len(selected) != 1:
1668
+ return
1669
+ row = selected[0]
1670
+ target = row - 1 if direction == "up" else row + 1
1671
+ if not (0 <= target < self.midata.n_channels):
1672
+ return
1673
+
1674
+ order = list(self.midata.channels)
1675
+ item = order.pop(row)
1676
+ order.insert(target, item)
1677
+ self._apply_channel_order(order)
1678
+
1679
+ # keep the moved item selected (get a fresh selection model --
1680
+ # setModel may have recreated it)
1681
+ sm = self.ChListView.selectionModel()
1682
+ idx = self.channel_slm.index(target)
1683
+ self.ChListView.setCurrentIndex(idx)
1684
+ if sm is not None:
1685
+ sm.select(idx, sm.SelectionFlag.ClearAndSelect)
1686
+ self._update_channel_action_states()
1687
+
1688
+ def show_marker_list(self):
1689
+ """Open the marker list viewer dialog."""
1690
+ if self.mianno is None:
1691
+ QMessageBox.about(self, "Info", "Load data and annotation first.")
1692
+ return
1693
+ dialog = EventListDialog(self)
1694
+ dialog.show_events(kind="marker")
1695
+ dialog.exec()
1696
+
1697
+ def show_start_end_list(self):
1698
+ """Open the start-end event list viewer dialog."""
1699
+ if self.mianno is None:
1700
+ QMessageBox.about(self, "Info", "Load data and annotation first.")
1701
+ return
1702
+ dialog = EventListDialog(self)
1703
+ dialog.show_events(kind="start_end")
1704
+ dialog.exec()
1705
+
1706
+ def _sync_state_buttons(self):
1707
+ """Keep the Annotation dock state buttons in sync with the state map.
1708
+
1709
+ Every configured state lives in the same two-column grid. The four
1710
+ original buttons are reused; additional states are created there too.
1711
+ """
1712
+ orig = {1: self.NREMBt, 2: self.REMBt, 3: self.WakeBt, 4: self.InitBt}
1713
+ self._state_btns = {}
1714
+ for bt in orig.values():
1715
+ bt.hide()
1716
+
1717
+ # clear and (re)fill the shared state grid
1718
+ extra_grid = self.ExtraStatePanel.layout()
1719
+ while extra_grid.count():
1720
+ item = extra_grid.takeAt(0)
1721
+ w = item.widget()
1722
+ if w is not None and w not in orig.values():
1723
+ w.deleteLater()
1724
+
1725
+ for button_idx, code in enumerate(sorted(self.state_map_dict)):
1726
+ name = self.state_map_dict[code]
1727
+ color = self.state_color_dict.get(code, "#808080")
1728
+ if code in orig:
1729
+ bt = orig[code]
1730
+ bt.show()
1731
+ else:
1732
+ bt = QPushButton()
1733
+ bt.clicked.connect(
1734
+ lambda _=False, c=code: self.sleep_state_label(state_code=c))
1735
+ row, col = divmod(button_idx, 2)
1736
+ extra_grid.addWidget(bt, row, col)
1737
+ bt.setSizePolicy(QSizePolicy.Policy.Expanding,
1738
+ QSizePolicy.Policy.Fixed)
1739
+ bt.setText(f"{code}:{name}")
1740
+ # Full local style: readable text on any state color (works in
1741
+ # light and dark themes - e.g. a white 'Init' button needs dark
1742
+ # text, and a dark state color needs white text).
1743
+ fg = _text_on(color)
1744
+ bt.setStyleSheet(
1745
+ f"QPushButton {{ background-color: {color}; color: {fg};"
1746
+ f" border: 1px solid rgba(0,0,0,150); border-radius: 3px;"
1747
+ f" padding: 2px 7px; min-height: 19px; max-height: 22px;"
1748
+ f" font-weight: 600; }}"
1749
+ f" QPushButton:hover {{ border: 1px solid rgba(255,255,255,190); }}"
1750
+ f" QPushButton:pressed {{ background-color: {color}; }}"
1751
+ f" QPushButton:disabled {{ background-color: #9aa3af;"
1752
+ f" color: #dce1e8; border-color: #7d8794; }}")
1753
+ bt.setToolTip(
1754
+ f"Label the selected area as {name} "
1755
+ f"(shortcut key {code if code < 10 else 0})")
1756
+ self._state_btns[code] = bt
1757
+ extra_grid.setColumnStretch(0, 1)
1758
+ extra_grid.setColumnStretch(1, 1)
1759
+ self._update_annotation_mode()
1760
+
1761
+ # ------------------------------------------------------------------
1762
+ # Interaction
1763
+ # ------------------------------------------------------------------
1764
+ def radio_recheck(self, radioBt):
1765
+ """Reset the selection lines when the label mode changes."""
1766
+ if not radioBt.isChecked():
1767
+ return
1768
+ if radioBt is self.SleepStateRadio:
1769
+ self.start_end_ms = []
1770
+ self.plot_start_end_line()
1771
+ if radioBt is self.StartEndRadio:
1772
+ self.start_end = []
1773
+ self.plot_start_end_line(ms=True)
1774
+ self._update_annotation_mode()
1775
+
1776
+ def click_signal(self, event):
1777
+ """Handle clicks in the signal area (marker / start-end selection).
1778
+
1779
+ The clicked axes identifies the channel (row ``i`` of the signal
1780
+ boxes); the x position gives the time.
1781
+ """
1782
+ if event.inaxes == self.signal_ax[0]:
1783
+ return
1784
+
1785
+ # Find the channel row by identity (Axes are not compared by value)
1786
+ row = None
1787
+ for idx, ax in enumerate(self.signal_ax):
1788
+ if ax is event.inaxes:
1789
+ row = idx - 1
1790
+ break
1791
+ if row is None or row < 0 or row >= len(self.show_idx):
1792
+ return
1793
+
1794
+ try:
1795
+ sf = self.midata.sf[self.show_idx[row]]
1796
+ sec = int(event.xdata / sf) + self.current_sec
1797
+ except (TypeError, IndexError):
1798
+ return
1799
+
1800
+ if self.SleepStateRadio.isChecked():
1801
+ if event.button == 3:
1802
+ # Right click: remove the line(s) at this second
1803
+ for each in self.mianno.start_end:
1804
+ if each[0] == sec or each[1] == sec:
1805
+ self.mianno.start_end.remove(each)
1806
+ self.plot_signals()
1807
+ return
1808
+ if len(self.start_end) == 0:
1809
+ return
1810
+ if len(self.start_end) >= 1 and sec == self.start_end[0]:
1811
+ self.start_end = []
1812
+ self.plot_start_end_line()
1813
+ return
1814
+ if len(self.start_end) == 2 and sec + 1 == self.start_end[1]:
1815
+ self.start_end.pop(1)
1816
+ self.plot_start_end_line()
1817
+ return
1818
+ return
1819
+
1820
+ if not self.start_end:
1821
+ self.start_end.append(sec)
1822
+ elif len(self.start_end) == 2:
1823
+ self.start_end = [sec]
1824
+ elif sec < self.start_end[0]:
1825
+ self.start_end = [sec]
1826
+ else:
1827
+ self.start_end.append(sec + 1)
1828
+
1829
+ self.plot_start_end_line()
1830
+
1831
+ if self.MarkerRadio.isChecked():
1832
+ if event.button == 3:
1833
+ for each in self.mianno.marker:
1834
+ if abs(each[0] - sec) <= 1:
1835
+ self.mianno.marker.remove(each)
1836
+ self.plot_signals()
1837
+ self.plot_hypo()
1838
+ return
1839
+
1840
+ x = round(event.xdata / sf, 3) + self.current_sec
1841
+
1842
+ self.label_dialog._type = 0
1843
+ self.label_dialog.show_contents()
1844
+ self.label_dialog.exec()
1845
+ if self.label_dialog.closed:
1846
+ return
1847
+
1848
+ label_name = self.label_dialog.label_name
1849
+ self.mianno.marker.append([x, label_name])
1850
+ self.plot_marker_line()
1851
+
1852
+ self.is_saved = False
1853
+ self.AnnotationPathLabel.setText("*Annotation path:")
1854
+
1855
+ if self.StartEndRadio.isChecked():
1856
+ x = round(event.xdata / sf, 3) + self.current_sec
1857
+ if event.button == 3:
1858
+ for each in self.mianno.start_end:
1859
+ if int(each[0]) == sec or int(each[1]) == sec:
1860
+ self.mianno.start_end.remove(each)
1861
+ self.plot_signals()
1862
+ return
1863
+ if len(self.start_end_ms) == 0:
1864
+ return
1865
+ if len(self.start_end_ms) >= 1 and sec == int(self.start_end_ms[0]):
1866
+ self.start_end_ms = []
1867
+ self.plot_start_end_line(ms=True)
1868
+ return
1869
+ if len(self.start_end_ms) == 2 and sec == int(self.start_end_ms[1]):
1870
+ self.start_end_ms.pop(1)
1871
+ self.plot_start_end_line(ms=True)
1872
+ return
1873
+ return
1874
+
1875
+ if not self.start_end_ms:
1876
+ self.start_end_ms.append(x)
1877
+ elif len(self.start_end_ms) == 2:
1878
+ if abs(x - self.start_end_ms[1]) <= 1:
1879
+ self.start_end_ms[0] = self.start_end_ms[1]
1880
+ self.start_end_ms.pop(1)
1881
+ else:
1882
+ self.start_end_ms = [x]
1883
+ else:
1884
+ if x < self.start_end_ms[0]:
1885
+ QMessageBox.about(self, "Error", "End should be larger than Start!")
1886
+ return
1887
+ self.start_end_ms.append(x)
1888
+
1889
+ self.plot_start_end_line(ms=True)
1890
+ if len(self.start_end_ms) == 2:
1891
+ # Leave the matplotlib callback first, then open the modal
1892
+ # label chooser. This avoids a half-painted selection line.
1893
+ QTimer.singleShot(0, self.append_start_end)
1894
+
1895
+ def click_hypo(self, event):
1896
+ """Jump to the clicked time on the hypnogram."""
1897
+ if event.inaxes is not self.hypo_ax or event.xdata is None:
1898
+ return
1899
+ current_sec = int(event.xdata)
1900
+ self.redraw_all(second=current_sec)
1901
+
1902
+ def scroller_change(self):
1903
+ """Scrollbar value changed."""
1904
+ self.ScrollerBar.setDisabled(True)
1905
+ current_sec = self.ScrollerBar.value()
1906
+ self.redraw_all(second=current_sec)
1907
+ self.ScrollerBar.setEnabled(True)
1908
+
1909
+ def next_epoch(self):
1910
+ self.redraw_all(second=self.current_sec + 5)
1911
+
1912
+ def previous_epoch(self):
1913
+ self.redraw_all(second=self.current_sec - 5)
1914
+
1915
+ def next_page(self):
1916
+ self.redraw_all(second=self.current_sec + self.show_duration)
1917
+
1918
+ def previous_page(self):
1919
+ self.redraw_all(second=self.current_sec - self.show_duration)
1920
+
1921
+ def SecondSpin_change(self):
1922
+ self.redraw_all(second=self.SecondSpin.value())
1923
+
1924
+ def DateTimeEdit_change(self):
1925
+ dateTime = self.DateTimeEdit.dateTime().toPython()
1926
+ current_sec = int((dateTime - self.ac_time).total_seconds())
1927
+ self.redraw_all(second=current_sec)
1928
+
1929
+ # ------------------------------------------------------------------
1930
+ # Channel operations
1931
+ # ------------------------------------------------------------------
1932
+ def channel_rename(self):
1933
+ """Rename channels from the list view.
1934
+
1935
+ A pure reorder (a permutation of the names) is not a rename -- it
1936
+ is handled by :meth:`_on_channel_list_changed` / :meth:`channel_moved`.
1937
+ """
1938
+ new_channels = list(self.channel_slm.channels())
1939
+ if sorted(new_channels) == sorted(self.midata.channels):
1940
+ return # drag & drop reorder, not a rename
1941
+
1942
+ old_channels = list(self.midata.channels)
1943
+ rename_map = {}
1944
+ for old, new in zip(old_channels, new_channels):
1945
+ if old != new:
1946
+ rename_map[old] = new
1947
+ if not rename_map:
1948
+ return
1949
+
1950
+ # rename_channels may add a "_1" suffix when the target name is
1951
+ # already in use, so rebuild horizontal_line by position afterwards.
1952
+ self.midata.rename_channels(rename_map)
1953
+ final_channels = self.midata.channels
1954
+ self.horizontal_line = {
1955
+ final_channels[i]: self.horizontal_line.get(old_channels[i], [])
1956
+ for i in range(len(final_channels))
1957
+ }
1958
+
1959
+ self.fill_channel_listView()
1960
+ self.plot_signals()
1961
+
1962
+ def show_chs(self):
1963
+ """Show the selected channels."""
1964
+ selected_channels = [each.row() for each in self.ChListView.selectedIndexes()]
1965
+ if not selected_channels:
1966
+ return
1967
+ self.show_idx = sorted(selected_channels)
1968
+ self.plot_signals()
1969
+
1970
+ def hide_chs(self):
1971
+ """Hide the selected channels."""
1972
+ selected_channels = [each.row() for each in self.ChListView.selectedIndexes()]
1973
+ if not selected_channels:
1974
+ return
1975
+ all_channels = copy.deepcopy(self.show_idx)
1976
+ for each in selected_channels:
1977
+ if each in all_channels:
1978
+ all_channels.remove(each)
1979
+
1980
+ if not all_channels:
1981
+ QMessageBox.about(self, "Error", "You can't hide all channels!")
1982
+ return
1983
+
1984
+ self.show_idx = all_channels
1985
+ self.plot_signals()
1986
+
1987
+ def delete_chs(self):
1988
+ """Delete the selected channels from the data."""
1989
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
1990
+ if not selected_channel:
1991
+ return
1992
+ box = QMessageBox.question(
1993
+ self, "Warning", "You are deleting data!",
1994
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
1995
+ QMessageBox.StandardButton.No)
1996
+
1997
+ if box == QMessageBox.StandardButton.Yes:
1998
+ if len(selected_channel) == self.midata.n_channels:
1999
+ QMessageBox.about(self, "Error", "You can't delete all channels!")
2000
+ return
2001
+
2002
+ spec_name = self.midata.channels[self.current_spectrogram_idx]
2003
+ for each in sorted(selected_channel, reverse=True):
2004
+ self.y_lims[each] = -1
2005
+ self.y_shift[each] = -1
2006
+ self.horizontal_line.pop(self.midata.channels[each], None)
2007
+ self.midata.delete(self.midata.channels[each])
2008
+
2009
+ self.y_lims = [each for each in self.y_lims if each != -1]
2010
+ self.y_shift = [each for each in self.y_shift if each != -1]
2011
+ if spec_name in self.midata.channels:
2012
+ self.current_spectrogram_idx = self.midata.channels.index(spec_name)
2013
+ else:
2014
+ self.current_spectrogram_idx = 0
2015
+
2016
+ self.show_idx = list(range(self.midata.n_channels))
2017
+ self.fill_channel_listView()
2018
+ self.redraw_all(second=self.current_sec)
2019
+
2020
+ def scaler_up(self):
2021
+ """Zoom in on the selected channels."""
2022
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
2023
+ if not selected_channel:
2024
+ return
2025
+ self.y_lims = [
2026
+ lim * 0.9 if idx in selected_channel else lim
2027
+ for idx, lim in enumerate(self.y_lims)]
2028
+ self.plot_signals(clf=False, replot_axes=selected_channel)
2029
+
2030
+ def scaler_down(self):
2031
+ """Zoom out on the selected channels."""
2032
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
2033
+ if not selected_channel:
2034
+ return
2035
+ self.y_lims = [
2036
+ lim * 1.1 if idx in selected_channel else lim
2037
+ for idx, lim in enumerate(self.y_lims)]
2038
+ self.plot_signals(clf=False, replot_axes=selected_channel)
2039
+
2040
+ def multiple_scaler(self):
2041
+ """Scale the selected channels by a custom factor."""
2042
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
2043
+ scaler_num = self.multipleScalerEditor.value()
2044
+ if not selected_channel:
2045
+ return
2046
+ self.y_lims = [
2047
+ lim / scaler_num if idx in selected_channel else lim
2048
+ for idx, lim in enumerate(self.y_lims)]
2049
+ self.plot_signals(clf=False, replot_axes=selected_channel)
2050
+
2051
+ def shift_up(self):
2052
+ """Shift the selected channels up."""
2053
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
2054
+ if not selected_channel:
2055
+ return
2056
+ self.y_shift = [
2057
+ shift - self.y_lims[idx] * 0.05 if idx in selected_channel else shift
2058
+ for idx, shift in enumerate(self.y_shift)]
2059
+ self.plot_signals(clf=False, replot_axes=selected_channel)
2060
+
2061
+ def shift_down(self):
2062
+ """Shift the selected channels down."""
2063
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
2064
+ if not selected_channel:
2065
+ return
2066
+ self.y_shift = [
2067
+ shift + self.y_lims[idx] * 0.05 if idx in selected_channel else shift
2068
+ for idx, shift in enumerate(self.y_shift)]
2069
+ self.plot_signals(clf=False, replot_axes=selected_channel)
2070
+
2071
+ def filter_confirm(self):
2072
+ """Apply a filter to the selected channel."""
2073
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
2074
+ if not selected_channel:
2075
+ return
2076
+ if len(selected_channel) > 1:
2077
+ QMessageBox.about(
2078
+ self, "Error",
2079
+ "Select one channel to be the default channel for spectrogram.")
2080
+ return
2081
+
2082
+ filter_type = self.FilterTypeCombo_dict[self.FilterTypeCombo.currentIndex()]
2083
+ low = self.FilterLowSpin.value()
2084
+ high = self.FilterHighSpin.value()
2085
+ if filter_type in ("bandpass", "bandstop") and low >= high:
2086
+ return
2087
+ self.midata.filter(
2088
+ chans=[self.midata.channels[selected_channel[0]]],
2089
+ btype=filter_type, low=low, high=high)
2090
+ self.y_lims.append(self.y_lims[selected_channel[0]])
2091
+ self.y_shift.append(self.y_shift[selected_channel[0]])
2092
+
2093
+ self.horizontal_line[self.midata.channels[-1]] = []
2094
+ self.show_idx.append(self.midata.n_channels - 1)
2095
+ self.fill_channel_listView()
2096
+ self.redraw_all(second=self.current_sec)
2097
+
2098
+ def FilterTypeCombo_change(self):
2099
+ """Enable/disable the filter cut-off spins according to the filter type."""
2100
+ if self.FilterTypeCombo.currentIndex() in (0, 3):
2101
+ self.FilterHighSpin.setEnabled(True)
2102
+ self.FilterLowSpin.setEnabled(True)
2103
+ if self.FilterTypeCombo.currentIndex() == 1:
2104
+ self.FilterHighSpin.setDisabled(True)
2105
+ self.FilterLowSpin.setEnabled(True)
2106
+ if self.FilterTypeCombo.currentIndex() == 2:
2107
+ self.FilterHighSpin.setEnabled(True)
2108
+ self.FilterLowSpin.setDisabled(True)
2109
+
2110
+ def show_spec_window(self):
2111
+ """Show the spectrum/spectrogram window for the selected segment."""
2112
+ from misleep.gui.spec_window import SpecWindow
2113
+ from misleep.preprocessing.spectral import band_power, spectrogram, spectrum
2114
+
2115
+ if self.SleepStateRadio.isChecked() and len(self.start_end) == 2 and \
2116
+ self.start_end[1] - self.start_end[0] >= 5:
2117
+ start_ = self.start_end[0]
2118
+ end_ = self.start_end[1]
2119
+ elif self.StartEndRadio.isChecked() and len(self.start_end_ms) == 2 and \
2120
+ self.start_end_ms[1] - self.start_end_ms[0] >= 5:
2121
+ start_ = self.start_end_ms[0]
2122
+ end_ = self.start_end_ms[1]
2123
+ else:
2124
+ QMessageBox.about(
2125
+ self, "Error",
2126
+ "Please select a start-end larger than 5 seconds.")
2127
+ return
2128
+
2129
+ selected_channel = [each.row() for each in self.ChListView.selectedIndexes()]
2130
+ if not selected_channel or len(selected_channel) > 1:
2131
+ QMessageBox.about(
2132
+ self, "Error",
2133
+ "Select one channel to show spectral analysis")
2134
+ return
2135
+
2136
+ channel = selected_channel[0]
2137
+ signal_data = self.midata.signals[channel][
2138
+ int(start_ * self.midata.sf[channel]): int(end_ * self.midata.sf[channel])]
2139
+
2140
+ win_sec = int(float(self.config["spec"]["win_length_sec"]))
2141
+ nfft = int(float(self.config["spec"]["nfft_sec"]) * self.midata.sf[channel])
2142
+ if win_sec > (end_ - start_):
2143
+ win_sec = end_ - start_
2144
+ if nfft < int(win_sec * self.midata.sf[channel]):
2145
+ nfft = int(win_sec * self.midata.sf[channel])
2146
+
2147
+ try:
2148
+ spec_window = self._ensure_dialog("spec_window", SpecWindow)
2149
+ freq_range = [float(x) for x in self.config["gui"]["freq_range"].strip("[]").split(",")]
2150
+ freq, psd = spectrum(
2151
+ signal=signal_data,
2152
+ sf=self.midata.sf[channel],
2153
+ band=freq_range,
2154
+ nfft=nfft,
2155
+ gaussian_sigma=float(self.config["spec"]["gaussian_sigma"]),
2156
+ win_sec=win_sec,
2157
+ relative=False)
2158
+
2159
+ f, t, Sxx = spectrogram(
2160
+ signal=signal_data,
2161
+ sf=self.midata.sf[channel],
2162
+ band=freq_range,
2163
+ step=1, win_sec=win_sec, norm=False, nfft=nfft)
2164
+
2165
+ bandPower = band_power(psd=psd, freq=freq,
2166
+ bands=[[0.5, 4, "delta"], [4, 9, "theta"]])
2167
+ ratio = bandPower["delta"] / bandPower["theta"]
2168
+
2169
+ spec_window.show_(
2170
+ spectrum=[psd, freq],
2171
+ spectrogram=[f, t, Sxx],
2172
+ percentile_=self.spectrogram_percentile,
2173
+ ratio=ratio, start_end=[start_, end_],
2174
+ freq_range=freq_range,
2175
+ data_path=self.data_path)
2176
+
2177
+ spec_window.activateWindow()
2178
+ state = spec_window.windowState()
2179
+ spec_window.setWindowState(
2180
+ (state & ~Qt.WindowState.WindowMinimized)
2181
+ | Qt.WindowState.WindowActive)
2182
+ spec_window.showNormal()
2183
+ except Exception:
2184
+ QMessageBox.about(
2185
+ self, "Error",
2186
+ "An error occurred while generating the spectral analysis.")
2187
+ return
2188
+
2189
+ def set_show_duration(self, type_="Combo"):
2190
+ """Set the show duration from the combo box or the custom spin box."""
2191
+ if type_ == "Combo":
2192
+ selected_idx = self.ShowRangeCombo.currentIndex()
2193
+ if self.ShowRangeCombo_dict[selected_idx] + self.current_sec >= self.total_seconds:
2194
+ self.show_duration = 30
2195
+ self.current_sec = 0
2196
+ self.ShowRangeCombo.setCurrentIndex(0)
2197
+ else:
2198
+ self.show_duration = self.ShowRangeCombo_dict[selected_idx]
2199
+ if type_ == "Spin":
2200
+ show_duration = self.SecondNumSpin.value()
2201
+ if show_duration + self.current_sec >= self.total_seconds:
2202
+ self.show_duration = 30
2203
+ self.current_sec = 0
2204
+ self.SecondNumSpin.setValue(30)
2205
+ else:
2206
+ self.show_duration = show_duration
2207
+
2208
+ self.reset_sec_limit()
2209
+ self.redraw_all(second=self.current_sec)
2210
+ self.ScrollerBar.setPageStep(self.show_duration)
2211
+
2212
+ def CustomSecondCheck_clicked(self):
2213
+ if self.CustomSecondsCheck.isChecked():
2214
+ self.ShowRangeCombo.setDisabled(True)
2215
+ self.SecondNumSpin.setEnabled(True)
2216
+ self.set_show_duration(type_="Spin")
2217
+ else:
2218
+ self.ShowRangeCombo.setEnabled(True)
2219
+ self.SecondNumSpin.setDisabled(True)
2220
+ self.set_show_duration(type_="Combo")
2221
+
2222
+ def ShowRangeCombo_changed(self):
2223
+ self.set_show_duration(type_="Combo")
2224
+
2225
+ def SecondNumSpin_changed(self):
2226
+ self.set_show_duration(type_="Spin")
2227
+
2228
+ # ------------------------------------------------------------------
2229
+ # Labeling
2230
+ # ------------------------------------------------------------------
2231
+ def nrem_label(self):
2232
+ self.append_sleep_state(sleep_type=1)
2233
+
2234
+ def rem_label(self):
2235
+ self.append_sleep_state(sleep_type=2)
2236
+
2237
+ def wake_label(self):
2238
+ self.append_sleep_state(sleep_type=3)
2239
+
2240
+ def init_label(self):
2241
+ self.append_sleep_state(sleep_type=4)
2242
+
2243
+ def sleep_state_label(self, state_code=1):
2244
+ """Label the selected area with a custom state code (from config)."""
2245
+ self.append_sleep_state(sleep_type=state_code)
2246
+
2247
+ def append_sleep_state(self, sleep_type=None):
2248
+ """Assign a sleep state to the selected start-end area."""
2249
+ if len(self.start_end) != 2:
2250
+ QMessageBox.about(
2251
+ self, "Info", "Please select a start end area in Sleep state mode")
2252
+ return
2253
+
2254
+ self.mianno.sleep_state[self.start_end[0]: self.start_end[1]] = \
2255
+ [sleep_type] * (self.start_end[1] - self.start_end[0])
2256
+ self._hypo_revision += 1
2257
+
2258
+ self.is_saved = False
2259
+ self.AnnotationPathLabel.setText("*Annotation path:")
2260
+ self.replot_sleep_state_bg(state=sleep_type)
2261
+ self.plot_hypo()
2262
+
2263
+ def append_start_end(self):
2264
+ """Append a start-end label to the annotation."""
2265
+ if not self.StartEndRadio.isChecked():
2266
+ QMessageBox.about(self, "Info", "Use Start-End mode")
2267
+ return
2268
+ if len(self.start_end_ms) != 2:
2269
+ QMessageBox.about(self, "Info", "Please select a start end area")
2270
+ return
2271
+
2272
+ self.label_dialog._type = 1
2273
+ self.label_dialog.show_contents()
2274
+ self.label_dialog.exec()
2275
+ if self.label_dialog.closed:
2276
+ return
2277
+
2278
+ label_name = self.label_dialog.label_name
2279
+ self.mianno.start_end.append(
2280
+ [self.start_end_ms[0], self.start_end_ms[1], label_name])
2281
+ self.plot_start_end_label_line()
2282
+
2283
+ self.is_saved = False
2284
+ self.AnnotationPathLabel.setText("*Annotation path:")
2285
+
2286
+ # ------------------------------------------------------------------
2287
+ # Tools / analysis
2288
+ # ------------------------------------------------------------------
2289
+ def transfer_result(self):
2290
+ """Transfer the annotation into an Excel result file."""
2291
+ dialog = self._ensure_dialog(
2292
+ "transfer_result_dialog", TransferResultDialog)
2293
+ dialog.ACTimeEditor.setDateTime(self.ac_time)
2294
+ dialog.TransferStartTimeEdit.setDateTime(self.ac_time)
2295
+ dialog.exec()
2296
+ if dialog.closed:
2297
+ return
2298
+ try:
2299
+ with self._busy_operation():
2300
+ saved = dialog.transfer(
2301
+ config=self.config, mianno=self.mianno,
2302
+ ac_time=self.midata.time)
2303
+ except Exception as exc:
2304
+ logger.exception("Transfer result failed")
2305
+ QMessageBox.about(self, "Error", f"Transfer result failed: {exc}")
2306
+ return
2307
+ if saved:
2308
+ QMessageBox.about(self, "Info", "Transferred result saved")
2309
+
2310
+ def state_spectral(self):
2311
+ """Run the per-state spectral analysis."""
2312
+ dialog = self._ensure_dialog(
2313
+ "state_spectral_dialog", lambda: StateSpectralDialog(config=self.config))
2314
+ dialog.StartTimeEditor.setDateTime(self.ac_time)
2315
+ dialog.EndTimeEditor.setDateTime(
2316
+ self.ac_time + datetime.timedelta(seconds=self.total_seconds))
2317
+ dialog.dialog_show(channels=self.midata.channels)
2318
+ dialog.exec()
2319
+ if dialog.closed:
2320
+ return
2321
+ try:
2322
+ with self._busy_operation():
2323
+ saved = dialog.spectral_analysis(
2324
+ midata=self.midata, mianno=self.mianno,
2325
+ config=self.config)
2326
+ except Exception as exc:
2327
+ logger.exception("State spectral export failed")
2328
+ QMessageBox.about(self, "Error", f"Spectral export failed: {exc}")
2329
+ return
2330
+ if saved:
2331
+ QMessageBox.about(self, "Info", "Spectral analysis finished")
2332
+
2333
+ def add_horizontal_line(self):
2334
+ """Add a horizontal reference line."""
2335
+ try:
2336
+ dialog = self._ensure_dialog(
2337
+ "horizontal_line_dialog", HorizontalLineDialog)
2338
+ dialog.horizontal_line = copy.deepcopy(self.horizontal_line)
2339
+ dialog.show_chs()
2340
+ dialog.midata = self.midata
2341
+ dialog.exec()
2342
+ if dialog.closed:
2343
+ return
2344
+ self.horizontal_line = dialog.horizontal_line
2345
+ self.plot_horizontal_line()
2346
+ except Exception as e:
2347
+ logger.error(f"Add horizontal line ERROR: {e}")
2348
+ QMessageBox.about(self, "Error", str(e))
2349
+ return
2350
+
2351
+ def swa_detection(self):
2352
+ """Slow-wave activity detection."""
2353
+ try:
2354
+ dialog = self._ensure_dialog(
2355
+ "swa_detection_dialog", SWADetectionDialog)
2356
+ dialog.show_chs(self.midata.channels)
2357
+ dialog.exec()
2358
+ if dialog.closed:
2359
+ return
2360
+ swa_lst = dialog.swa_detection(
2361
+ self.midata, self.mianno, self.config)
2362
+
2363
+ self.mianno._start_end += [[each[0], each[4], "SWA"] for each in swa_lst]
2364
+ self.plot_start_end_label_line()
2365
+ self.is_saved = False
2366
+ self.AnnotationPathLabel.setText("*Annotation path:")
2367
+ except Exception as e:
2368
+ logger.error(f"SWA_detection ERROR: {e}")
2369
+ QMessageBox.about(self, "Error", f"SWA detection ERROR. {e}")
2370
+ return
2371
+
2372
+ def spindle_detection(self):
2373
+ """Sleep spindle detection."""
2374
+ try:
2375
+ dialog = self._ensure_dialog(
2376
+ "spindel_detection_dialog", SpindleDetectionDialog)
2377
+ dialog.show_chs(self.midata.channels)
2378
+ dialog.exec()
2379
+ if dialog.closed:
2380
+ return
2381
+ spindle_lst = dialog.spindle_detection(
2382
+ self.midata, self.mianno, self.config)
2383
+
2384
+ self.mianno._start_end += [[each[0], each[1], "Spindle"] for each in spindle_lst]
2385
+ self.plot_start_end_label_line()
2386
+ self.is_saved = False
2387
+ self.AnnotationPathLabel.setText("*Annotation path:")
2388
+ except Exception as e:
2389
+ logger.error(f"Spindel_detection ERROR: {e}")
2390
+ QMessageBox.about(self, "Error", "Spindle detection ERROR")
2391
+ return
2392
+
2393
+ def auto_stage_LightGBM(self):
2394
+ """Auto stage with the LightGBM model."""
2395
+ try:
2396
+ dialog = self._ensure_dialog(
2397
+ "auto_stage_lightGBM_dialog", AutoStageLightGBMDialog)
2398
+ dialog.show_chs(self.midata.channels)
2399
+ dialog.exec()
2400
+ if dialog.closed:
2401
+ return
2402
+ auto_stage_lst, save_anno = dialog.auto_stage(
2403
+ self.midata, self.mianno)
2404
+
2405
+ limit = min(len(self.mianno._sleep_state), len(auto_stage_lst))
2406
+ self.mianno._sleep_state[:limit] = auto_stage_lst[:limit]
2407
+ self._hypo_revision += 1
2408
+
2409
+ if save_anno:
2410
+ self.save_anno()
2411
+ self.is_saved = False
2412
+ self.AnnotationPathLabel.setText("*Annotation path:")
2413
+ self.plot_signals()
2414
+ self.plot_hypo()
2415
+ except ImportError as e:
2416
+ QMessageBox.about(self, "Error", str(e))
2417
+ return
2418
+ except Exception as e:
2419
+ logger.error(f"Auto stage ERROR: {e}")
2420
+ QMessageBox.about(self, "Error", "Auto stage ERROR")
2421
+ return
2422
+
2423
+ def auto_stage_CausalTransformer(self):
2424
+ """Auto stage with the causal-transformer model."""
2425
+ try:
2426
+ dialog = self._ensure_dialog(
2427
+ "auto_stage_causalTransformer_dialog",
2428
+ AutoStageCausalTransformerDialog)
2429
+ dialog.show_chs(self.midata.channels)
2430
+ dialog.exec()
2431
+ if dialog.closed:
2432
+ return
2433
+ auto_stage_lst, save_anno = dialog.auto_stage(self.midata, self.mianno)
2434
+
2435
+ limit = min(len(self.mianno._sleep_state), len(auto_stage_lst))
2436
+ self.mianno._sleep_state[:limit] = auto_stage_lst[:limit]
2437
+ self._hypo_revision += 1
2438
+
2439
+ if save_anno:
2440
+ self.save_anno()
2441
+ self.is_saved = False
2442
+ self.AnnotationPathLabel.setText("*Annotation path:")
2443
+ self.plot_signals()
2444
+ self.plot_hypo()
2445
+ except ImportError as e:
2446
+ QMessageBox.about(self, "Error", str(e))
2447
+ return
2448
+ except Exception as e:
2449
+ logger.error(f"Auto stage ERROR: {e}")
2450
+ QMessageBox.about(self, "Error", "Auto stage ERROR")
2451
+ return
2452
+
2453
+ # ------------------------------------------------------------------
2454
+ # Saving
2455
+ # ------------------------------------------------------------------
2456
+ def save_anno(self, just_save=False):
2457
+ """Save the annotation to file.
2458
+
2459
+ Parameters
2460
+ ----------
2461
+ just_save : bool
2462
+ Only save without touching the current annotation path.
2463
+ """
2464
+ if self.anno_path == "":
2465
+ anno_path, _ = QFileDialog.getOpenFileName(
2466
+ self, "Select annotation file",
2467
+ f"{self.config['gui']['openpath']}",
2468
+ "txt Files (*.txt *.TXT)")
2469
+ if anno_path == "":
2470
+ return
2471
+ if not just_save:
2472
+ self.anno_path = anno_path
2473
+ self.AnnoPathEdit.setText(self.anno_path)
2474
+
2475
+ save_thread = SaveThread(file=[self.mianno, self.midata],
2476
+ file_path=self.anno_path)
2477
+ saved = save_thread.save_anno()
2478
+ if saved and not just_save:
2479
+ self.is_saved = True
2480
+ self.AnnotationPathLabel.setText("Annotation path:")
2481
+ save_thread.quit()
2482
+
2483
+ def save_data(self):
2484
+ """Export (cropped, channel-selected) data to ``.mat``/``.edf``."""
2485
+ dialog = self._ensure_dialog("save_data_dialog", SaveDataDialog)
2486
+ dialog.fill_midata_params(midata=self.midata)
2487
+ dialog.exec()
2488
+ if dialog.closed:
2489
+ return
2490
+
2491
+ selected_channels = [each.row() for each in
2492
+ dialog.ChannelListView.selectedIndexes()]
2493
+ if selected_channels == []:
2494
+ selected_channels = list(range(self.midata.n_channels))
2495
+ midata_to_save = self.midata.pick_chs(
2496
+ [self.midata.channels[each] for each in selected_channels])
2497
+
2498
+ start_sec = 0
2499
+ end_sec = self.midata.duration
2500
+
2501
+ if dialog.CropDataStartCheckBox.isChecked():
2502
+ start_time = dialog.CropStartTimeEditor.dateTime().toPython()
2503
+ midata_to_save._time = start_time.strftime("%Y%m%d-%H:%M:%S")
2504
+ start_sec = int(datetime.timedelta.total_seconds(start_time - self.ac_time))
2505
+ if start_sec < 0:
2506
+ start_sec = 0
2507
+
2508
+ if dialog.CropDataEndCheckBox.isChecked():
2509
+ end_time = dialog.CropEndTimeEditor.dateTime().toPython()
2510
+ end_sec = int(datetime.timedelta.total_seconds(end_time - self.ac_time))
2511
+ if end_sec > self.midata.duration:
2512
+ end_sec = self.midata.duration
2513
+
2514
+ if end_sec <= start_sec:
2515
+ start_sec = 0
2516
+ end_sec = self.midata.duration
2517
+
2518
+ midata_to_save = midata_to_save.crop([start_sec, end_sec])
2519
+
2520
+ extensions = available_writers()
2521
+ filters = ";;".join(
2522
+ f"{extension[1:].upper()} files (*{extension})"
2523
+ for extension in extensions)
2524
+ data_path, _ = QFileDialog.getSaveFileName(
2525
+ self, "Select a file to save data",
2526
+ f"{self.config['gui']['openpath']}_misleep_saved",
2527
+ filters)
2528
+ if data_path == "":
2529
+ return
2530
+
2531
+ save_thread = SaveThread(file=midata_to_save, file_path=data_path)
2532
+ try:
2533
+ with self._busy_operation():
2534
+ saved = save_thread.save_data()
2535
+ except Exception as exc:
2536
+ logger.exception("Data export failed")
2537
+ QMessageBox.about(self, "Error", f"Data save failed: {exc}")
2538
+ return
2539
+ if saved:
2540
+ QMessageBox.about(self, "Info", f"Data Saved to {data_path}")
2541
+ else:
2542
+ QMessageBox.about(self, "Error", "Data save ERROR")
2543
+ save_thread.quit()
2544
+
2545
+ # ------------------------------------------------------------------
2546
+ # Configuration
2547
+ # ------------------------------------------------------------------
2548
+ def open_settings_dialog(self):
2549
+ """Open the in-application settings dialog."""
2550
+ dialog = SettingsDialog(self)
2551
+ dialog.exec()
2552
+
2553
+ def apply_settings(self):
2554
+ """Apply the current configuration to the running application.
2555
+
2556
+ Called after the settings dialog saves changes, so that state
2557
+ names/colors, labels and spectral defaults take effect
2558
+ immediately without restarting MiSleep.
2559
+ """
2560
+ gui = self.config["gui"]
2561
+
2562
+ # Apply a theme change (chosen in the settings dialog) immediately
2563
+ new_theme = gui.get("theme", fallback="light")
2564
+ new_tone = gui.get("color_tone", fallback="black")
2565
+ if new_theme not in THEMES:
2566
+ new_theme = "light"
2567
+ if new_tone not in COLOR_TONES:
2568
+ new_tone = "black"
2569
+ if new_theme != self._theme_name or new_tone != self._tone_name:
2570
+ self._theme_name = new_theme
2571
+ self._tone_name = new_tone
2572
+ app = QApplication.instance()
2573
+ if app is not None:
2574
+ apply_theme(app, new_theme, new_tone)
2575
+ retheme_figures(new_theme, new_tone)
2576
+ self._set_plot_colors()
2577
+ self._update_theme_action()
2578
+
2579
+ # Reload the parsed dictionaries
2580
+ self.state_map_dict = {int(key): value for key, value
2581
+ in json.loads(gui["statemap"]).items()}
2582
+ self.state_color_dict = {int(key): value for key, value
2583
+ in json.loads(gui["statecolor"]).items()}
2584
+ self.start_end_color_dict = dict(
2585
+ json.loads(gui["startendcolor"].replace("'", '"')).items())
2586
+
2587
+ # Rebuild the state-label buttons (names/colors/extra states)
2588
+ self._sync_state_buttons()
2589
+
2590
+ # Refresh the label picker lists
2591
+ self.label_dialog.marker_label = [
2592
+ each[1:-1] for each in gui["marker"][1:-1].split(", ")]
2593
+ self.label_dialog.start_end_label = [
2594
+ each[1:-1] for each in gui["startend"][1:-1].split(", ")]
2595
+
2596
+ # Refresh spectral defaults used by the state-spectral dialog
2597
+ if self.state_spectral_dialog is not None:
2598
+ self.state_spectral_dialog.GaussianSpinBox.setValue(
2599
+ float(self.config["spec"]["gaussian_sigma"]))
2600
+ self.state_spectral_dialog.WinLengthSpinBox.setValue(
2601
+ float(self.config["spec"]["win_length_sec"]))
2602
+ self.state_spectral_dialog.nfftSpinBox.setValue(
2603
+ int(float(self.config["spec"]["nfft_sec"])))
2604
+
2605
+ # Update the annotation state map if one is loaded
2606
+ if isinstance(self.mianno, MiAnnotation):
2607
+ self.mianno._state_map = self.state_map_dict
2608
+
2609
+ # State names/colors are part of the hypnogram itself. Force a base
2610
+ # rebuild now instead of waiting for the next annotation edit.
2611
+ self._hypo_key = None
2612
+ self._hypo_revision += 1
2613
+ self._hypo_steps = []
2614
+ self._hypo_transient = []
2615
+ # Frequency range changes also invalidate the cached whole-file STFT.
2616
+ self._spec_full_cache = {}
2617
+
2618
+ # Redraw with the new colors / frequency range (skip before data loads)
2619
+ if self.midata is not None and self.mianno is not None:
2620
+ self.redraw_all(second=self.current_sec)
2621
+ else:
2622
+ logger.info("Settings applied; they will take effect after loading data.")
2623
+
2624
+ def pupup_config(self):
2625
+ """Open the user configuration file with the system editor."""
2626
+ import platform
2627
+ import subprocess
2628
+
2629
+ path = str(user_config_path())
2630
+ try:
2631
+ system = platform.system()
2632
+ if system == "Windows":
2633
+ os.startfile(path)
2634
+ elif system == "Darwin":
2635
+ subprocess.run(["open", path])
2636
+ elif system == "Linux":
2637
+ subprocess.run(["xdg-open", path])
2638
+ except Exception as e:
2639
+ logger.error(f"Open config.ini ERROR: {e}")
2640
+ QMessageBox.about(self, "Error", f"Open config.ini ERROR: {e}")
2641
+
2642
+ def auto_save(self):
2643
+ """Auto-save the annotation every 5 minutes when modified."""
2644
+ if not self.is_saved:
2645
+ self.save_anno()
2646
+ self.save_timer.start(5 * 60 * 1000)
2647
+
2648
+ def save_config(self, config_dict):
2649
+ """Persist GUI configuration changes to the user config file."""
2650
+ for key, value in config_dict.items():
2651
+ self.config.set("gui", key, value)
2652
+ save_config(self.config)
2653
+
2654
+ # ------------------------------------------------------------------
2655
+ # Event handlers
2656
+ # ------------------------------------------------------------------
2657
+ def keyPressEvent(self, event):
2658
+ """Number keys label the selected area with that state.
2659
+
2660
+ Keys 1-9 map to states 1-9; key 0 maps to state 10 (when defined),
2661
+ supporting up to ten states.
2662
+ """
2663
+ if Qt.Key.Key_0 <= event.key() <= Qt.Key.Key_9:
2664
+ num = int(event.text())
2665
+ state = 10 if num == 0 else num
2666
+ if state in self.state_map_dict.keys():
2667
+ self.sleep_state_label(state_code=state)
2668
+ else:
2669
+ QWidget.keyPressEvent(self, event)
2670
+
2671
+ def closeEvent(self, event):
2672
+ """Ask for confirmation when there are unsaved labels."""
2673
+ if not self.is_saved:
2674
+ box = QMessageBox.question(
2675
+ self, "Warning",
2676
+ "Your labels haven't been saved, discard?\n"
2677
+ "Yes: Save and quit\nNo: Discard\nCancel: Nothing",
2678
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
2679
+ | QMessageBox.StandardButton.Cancel,
2680
+ QMessageBox.StandardButton.Yes)
2681
+
2682
+ if box == QMessageBox.StandardButton.Yes:
2683
+ self.save_anno()
2684
+ event.accept()
2685
+ elif box == QMessageBox.StandardButton.No:
2686
+ event.accept()
2687
+ else:
2688
+ event.ignore()
2689
+
2690
+ if event.isAccepted():
2691
+ self.save_timer.stop()
2692
+ if self.spec_window is not None:
2693
+ self.spec_window.close()
2694
+ plt.close(self.signal_figure)
2695
+ plt.close(self.hypo_figure)
2696
+
2697
+
2698
+ # Backward-compatible alias
2699
+ main_window = MainWindow