accusleepy 0.10.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {accusleepy-0.10.0 → accusleepy-0.11.0}/PKG-INFO +10 -11
  2. {accusleepy-0.10.0 → accusleepy-0.11.0}/README.md +6 -6
  3. accusleepy-0.11.0/accusleepy/__init__.py +1 -0
  4. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/__main__.py +2 -0
  5. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/bouts.py +2 -0
  6. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/brain_state_set.py +2 -0
  7. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/classification.py +2 -0
  8. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/constants.py +5 -2
  9. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/fileio.py +26 -11
  10. accusleepy-0.11.0/accusleepy/gui/__init__.py +1 -0
  11. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/images/primary_window.png +0 -0
  12. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/main.py +17 -24
  13. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/manual_scoring.py +15 -12
  14. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/mplwidget.py +5 -6
  15. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/primary_window.py +3 -5
  16. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/primary_window.ui +0 -1
  17. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/recording_manager.py +24 -14
  18. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/settings_widget.py +1 -1
  19. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/viewer_window.py +2 -4
  20. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/models.py +2 -0
  21. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/multitaper.py +13 -66
  22. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/services.py +4 -4
  23. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/signal_processing.py +9 -4
  24. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/temperature_scaling.py +5 -3
  25. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/validation.py +19 -19
  26. {accusleepy-0.10.0 → accusleepy-0.11.0}/pyproject.toml +8 -5
  27. accusleepy-0.10.0/accusleepy/__init__.py +0 -0
  28. accusleepy-0.10.0/accusleepy/gui/__init__.py +0 -0
  29. /accusleepy-0.10.0/accusleepy/config.json → /accusleepy-0.11.0/accusleepy/default_config.json +0 -0
  30. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/dialogs.py +0 -0
  31. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/brightness_down.png +0 -0
  32. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/brightness_up.png +0 -0
  33. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/double_down_arrow.png +0 -0
  34. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/double_up_arrow.png +0 -0
  35. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/down_arrow.png +0 -0
  36. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/home.png +0 -0
  37. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/question.png +0 -0
  38. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/save.png +0 -0
  39. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/up_arrow.png +0 -0
  40. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/zoom_in.png +0 -0
  41. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/icons/zoom_out.png +0 -0
  42. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/images/viewer_window.png +0 -0
  43. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/images/viewer_window_annotated.png +0 -0
  44. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/resources.qrc +0 -0
  45. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/resources_rc.py +0 -0
  46. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/text/dev_guide.md +0 -0
  47. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/text/main_guide.md +0 -0
  48. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/text/manual_scoring_guide.md +0 -0
  49. {accusleepy-0.10.0 → accusleepy-0.11.0}/accusleepy/gui/viewer_window.ui +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: accusleepy
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: Python implementation of AccuSleep
5
5
  License: GPL-3.0-only
6
6
  Author: Zeke Barger
@@ -11,14 +11,13 @@ Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
- Requires-Dist: fastparquet (>=2024.11.0,<2025.0.0)
15
- Requires-Dist: joblib (>=1.4.2,<2.0.0)
16
14
  Requires-Dist: matplotlib (>=3.10.1,<4.0.0)
17
15
  Requires-Dist: numpy (>=2.2.4,<3.0.0)
18
16
  Requires-Dist: pandas (>=2.2.3,<3.0.0)
19
17
  Requires-Dist: pillow (>=11.1.0,<12.0.0)
20
- Requires-Dist: pre-commit (>=4.2.0,<5.0.0)
21
- Requires-Dist: pyside6 (>=6.9.0,<6.9.3)
18
+ Requires-Dist: platformdirs (>=4.0.0,<5.0.0)
19
+ Requires-Dist: pyarrow (>=23.0.0,<24.0.0)
20
+ Requires-Dist: pyside6 (>=6.10.1,<7.0.0)
22
21
  Requires-Dist: scipy (>=1.15.2,<2.0.0)
23
22
  Requires-Dist: torch (>=2.8.0,<3.0.0)
24
23
  Requires-Dist: torchvision (>=0.23.0,<1.0.0)
@@ -62,13 +61,14 @@ etc.) with python >=3.11,<3.14
62
61
  - `pip install accusleepy`
63
62
  - (optional) download a classification model from https://osf.io/py5eb/ under /python_format/models/
64
63
 
65
- Note that upgrading or reinstalling the package will overwrite any changes
66
- to the [config file](accusleepy/config.json).
67
64
 
68
65
  ## Usage
69
66
 
70
67
  `python -m accusleepy` will open the primary interface.
71
68
 
69
+ Your settings are saved to a platform-specific location
70
+ (e.g., `~/Library/Application Support/accusleepy/config.json` on macOS)
71
+
72
72
  [Guide to the primary interface](accusleepy/gui/text/main_guide.md)
73
73
 
74
74
  [Guide to the manual scoring interface](accusleepy/gui/text/manual_scoring_guide.md)
@@ -79,10 +79,9 @@ please consult the [developer guide](accusleepy/gui/text/dev_guide.md).
79
79
 
80
80
  ## Changelog
81
81
 
82
- - 0.10.0: Improved zoom behavior
83
- - 0.8.1-0.9.3: Improved error handling and code quality
84
- - 0.8.0: More configurable settings, visual improvements
85
- - 0.7.1-0.7.3: Bugfixes, code cleanup
82
+ - 0.11.0: Store config file in a user directory
83
+ - 0.10.0-0.10.1: Improved zoom behavior, updated dependencies
84
+ - 0.7.1-0.9.3: Bugfixes, code cleanup, additional config settings
86
85
  - 0.7.0: More settings can be configured in the UI
87
86
  - 0.6.0: Confidence scores can now be displayed and saved. Retraining your models is recommended
88
87
  since the new calibration feature will make the confidence scores more accurate.
@@ -35,13 +35,14 @@ etc.) with python >=3.11,<3.14
35
35
  - `pip install accusleepy`
36
36
  - (optional) download a classification model from https://osf.io/py5eb/ under /python_format/models/
37
37
 
38
- Note that upgrading or reinstalling the package will overwrite any changes
39
- to the [config file](accusleepy/config.json).
40
38
 
41
39
  ## Usage
42
40
 
43
41
  `python -m accusleepy` will open the primary interface.
44
42
 
43
+ Your settings are saved to a platform-specific location
44
+ (e.g., `~/Library/Application Support/accusleepy/config.json` on macOS)
45
+
45
46
  [Guide to the primary interface](accusleepy/gui/text/main_guide.md)
46
47
 
47
48
  [Guide to the manual scoring interface](accusleepy/gui/text/manual_scoring_guide.md)
@@ -52,10 +53,9 @@ please consult the [developer guide](accusleepy/gui/text/dev_guide.md).
52
53
 
53
54
  ## Changelog
54
55
 
55
- - 0.10.0: Improved zoom behavior
56
- - 0.8.1-0.9.3: Improved error handling and code quality
57
- - 0.8.0: More configurable settings, visual improvements
58
- - 0.7.1-0.7.3: Bugfixes, code cleanup
56
+ - 0.11.0: Store config file in a user directory
57
+ - 0.10.0-0.10.1: Improved zoom behavior, updated dependencies
58
+ - 0.7.1-0.9.3: Bugfixes, code cleanup, additional config settings
59
59
  - 0.7.0: More settings can be configured in the UI
60
60
  - 0.6.0: Confidence scores can now be displayed and saved. Retraining your models is recommended
61
61
  since the new calibration feature will make the confidence scores more accurate.
@@ -0,0 +1 @@
1
+ """AccuSleePy: Python implementation of AccuSleep for automated sleep scoring."""
@@ -1,3 +1,5 @@
1
+ """Entry point for running AccuSleePy as a module (python -m accusleepy)."""
2
+
1
3
  from accusleepy.gui.main import run_primary_window
2
4
 
3
5
  if __name__ == "__main__":
@@ -1,3 +1,5 @@
1
+ """Brain state bout length enforcement."""
2
+
1
3
  import re
2
4
  from dataclasses import dataclass
3
5
  from operator import attrgetter
@@ -1,3 +1,5 @@
1
+ """Define and organize brain states."""
2
+
1
3
  from dataclasses import dataclass
2
4
 
3
5
  import numpy as np
@@ -1,3 +1,5 @@
1
+ """Model training and brain state classification."""
2
+
1
3
  import os
2
4
 
3
5
  import numpy as np
@@ -1,3 +1,5 @@
1
+ """Application-wide constants and default settings."""
2
+
1
3
  import numpy as np
2
4
 
3
5
  # probably don't change these unless you really need to
@@ -24,8 +26,9 @@ MIN_EPOCHS_PER_STATE = 3
24
26
 
25
27
 
26
28
  # very unlikely you will want to change values from here onwards
27
- # config file location
28
- CONFIG_FILE = "config.json"
29
+ # config file names
30
+ DEFAULT_CONFIG_FILE = "default_config.json"
31
+ USER_CONFIG_FILE = "config.json"
29
32
  # number of times to include the EMG power in a training image
30
33
  EMG_COPIES = 9
31
34
  # minimum spectrogram window length, in seconds
@@ -1,14 +1,18 @@
1
+ """File I/O for recordings, labels, calibration data, and configuration."""
2
+
1
3
  import json
2
4
  import os
5
+ import shutil
3
6
  from dataclasses import dataclass
4
- from importlib.metadata import version, PackageNotFoundError
7
+ from importlib.metadata import PackageNotFoundError, version
8
+ from importlib.resources import files
5
9
 
6
10
  import numpy as np
7
11
  import pandas as pd
8
- from PySide6.QtWidgets import QListWidgetItem
12
+ from platformdirs import user_config_dir
9
13
 
10
- from accusleepy.brain_state_set import BRAIN_STATES_KEY, BrainState, BrainStateSet
11
14
  import accusleepy.constants as c
15
+ from accusleepy.brain_state_set import BRAIN_STATES_KEY, BrainState, BrainStateSet
12
16
 
13
17
 
14
18
  @dataclass
@@ -55,7 +59,6 @@ class Recording:
55
59
  label_file: str = "" # path to label file
56
60
  calibration_file: str = "" # path to calibration file
57
61
  sampling_rate: int | float = 0.0 # sampling rate, in Hz
58
- widget: QListWidgetItem = None # list item widget shown in the GUI
59
62
 
60
63
 
61
64
  def load_calibration_file(filename: str) -> tuple[np.ndarray, np.ndarray]:
@@ -128,6 +131,16 @@ def save_labels(
128
131
  pd.DataFrame({c.BRAIN_STATE_COL: labels}).to_csv(filename, index=False)
129
132
 
130
133
 
134
+ def _get_user_config_path() -> str:
135
+ """Return the path to the user's config file in the platform config directory."""
136
+ return os.path.join(user_config_dir("accusleepy"), c.USER_CONFIG_FILE)
137
+
138
+
139
+ def _get_default_config_path() -> str:
140
+ """Return the path to the bundled default config file."""
141
+ return str(files("accusleepy").joinpath(c.DEFAULT_CONFIG_FILE))
142
+
143
+
131
144
  def load_config() -> AccuSleePyConfig:
132
145
  """Load configuration file with brain state options
133
146
 
@@ -143,9 +156,11 @@ def load_config() -> AccuSleePyConfig:
143
156
  default autoscroll state for manual scoring,
144
157
  setting to delete training images automatically
145
158
  """
146
- with open(
147
- os.path.join(os.path.dirname(os.path.abspath(__file__)), c.CONFIG_FILE), "r"
148
- ) as f:
159
+ user_config = _get_user_config_path()
160
+ if not os.path.exists(user_config):
161
+ os.makedirs(os.path.dirname(user_config), exist_ok=True)
162
+ shutil.copy2(_get_default_config_path(), user_config)
163
+ with open(user_config) as f:
149
164
  data = json.load(f)
150
165
 
151
166
  return AccuSleePyConfig(
@@ -229,9 +244,9 @@ def save_config(
229
244
  output_dict.update({c.EPOCHS_TO_SHOW_KEY: epochs_to_show})
230
245
  output_dict.update({c.AUTOSCROLL_KEY: autoscroll_state})
231
246
  output_dict.update({c.DELETE_TRAINING_IMAGES_KEY: delete_training_images})
232
- with open(
233
- os.path.join(os.path.dirname(os.path.abspath(__file__)), c.CONFIG_FILE), "w"
234
- ) as f:
247
+ user_config = _get_user_config_path()
248
+ os.makedirs(os.path.dirname(user_config), exist_ok=True)
249
+ with open(user_config, "w") as f:
235
250
  json.dump(output_dict, f, indent=4)
236
251
  f.write("\n")
237
252
 
@@ -242,7 +257,7 @@ def load_recording_list(filename: str) -> list[Recording]:
242
257
  :param filename: filename of list of recordings
243
258
  :return: list of recordings
244
259
  """
245
- with open(filename, "r") as f:
260
+ with open(filename) as f:
246
261
  data = json.load(f)
247
262
  recording_list = [Recording(**r) for r in data[c.RECORDING_LIST_NAME]]
248
263
  for i, r in enumerate(recording_list):
@@ -0,0 +1 @@
1
+ """Graphical user interface components for AccuSleePy."""
@@ -1,5 +1,7 @@
1
- # AccuSleePy main window
2
- # Icon source: Arkinasi, https://www.flaticon.com/authors/arkinasi
1
+ """AccuSleePy main window.
2
+
3
+ Icon source: Arkinasi, https://www.flaticon.com/authors/arkinasi
4
+ """
3
5
 
4
6
  import logging
5
7
  import os
@@ -39,10 +41,10 @@ from accusleepy.constants import (
39
41
  UNDEFINED_LABEL,
40
42
  )
41
43
  from accusleepy.fileio import (
44
+ get_version,
42
45
  load_config,
43
46
  load_labels,
44
47
  load_recording,
45
- get_version,
46
48
  )
47
49
  from accusleepy.gui.dialogs import select_existing_file, select_save_location
48
50
  from accusleepy.gui.manual_scoring import ManualScoringWindow
@@ -56,9 +58,8 @@ from accusleepy.services import (
56
58
  create_calibration,
57
59
  score_recording_list,
58
60
  )
59
- from accusleepy.validation import validate_and_correct_labels
60
61
  from accusleepy.signal_processing import resample_and_standardize
61
- from accusleepy.validation import check_config_consistency
62
+ from accusleepy.validation import check_config_consistency, validate_and_correct_labels
62
63
 
63
64
  logger = logging.getLogger(__name__)
64
65
 
@@ -93,7 +94,7 @@ class AccuSleepWindow(QMainWindow):
93
94
  """AccuSleePy primary window"""
94
95
 
95
96
  def __init__(self):
96
- super(AccuSleepWindow, self).__init__()
97
+ super().__init__()
97
98
 
98
99
  # initialize the UI
99
100
  self.ui = Ui_PrimaryWindow()
@@ -361,22 +362,18 @@ class AccuSleepWindow(QMainWindow):
361
362
  except Exception:
362
363
  logger.exception("Failed to load %s", filename)
363
364
  self.show_message(
364
- (
365
- "ERROR: could not load classification model. Check "
366
- "user manual for instructions on creating this file."
367
- )
365
+ "ERROR: could not load classification model. Check "
366
+ "user manual for instructions on creating this file."
368
367
  )
369
368
  return
370
369
 
371
370
  # make sure only "default" model type is loaded
372
371
  if model_type != DEFAULT_MODEL_TYPE:
373
372
  self.show_message(
374
- (
375
- "ERROR: only 'default'-style models can be used. "
376
- "'Real-time' models are not supported. "
377
- "See classification.example_real_time_scoring_function.py "
378
- "for an example of how to classify brain states in real time."
379
- )
373
+ "ERROR: only 'default'-style models can be used. "
374
+ "'Real-time' models are not supported. "
375
+ "See classification.example_real_time_scoring_function.py "
376
+ "for an example of how to classify brain states in real time."
380
377
  )
381
378
  return
382
379
 
@@ -431,10 +428,8 @@ class AccuSleepWindow(QMainWindow):
431
428
  )
432
429
  status_widget.setText("could not load recording")
433
430
  self.show_message(
434
- (
435
- "ERROR: could not load recording. "
436
- "Check user manual for formatting instructions."
437
- )
431
+ "ERROR: could not load recording. "
432
+ "Check user manual for formatting instructions."
438
433
  )
439
434
  return None, None, None, False
440
435
 
@@ -504,10 +499,8 @@ class AccuSleepWindow(QMainWindow):
504
499
  logger.exception("Failed to load %s", label_file)
505
500
  self.ui.manual_scoring_status.setText("could not load labels")
506
501
  self.show_message(
507
- (
508
- "ERROR: could not load labels. "
509
- "Check user manual for formatting instructions."
510
- )
502
+ "ERROR: could not load labels. "
503
+ "Check user manual for formatting instructions."
511
504
  )
512
505
  return
513
506
  else:
@@ -1,8 +1,9 @@
1
- # AccuSleePy manual scoring GUI
2
- # Icon sources:
3
- # Arkinasi, https://www.flaticon.com/authors/arkinasi
4
- # kendis lasman, https://www.flaticon.com/packs/ui-79
1
+ """AccuSleePy manual scoring GUI.
5
2
 
3
+ Icon sources:
4
+ Arkinasi, https://www.flaticon.com/authors/arkinasi
5
+ kendis lasman, https://www.flaticon.com/packs/ui-79
6
+ """
6
7
 
7
8
  import copy
8
9
  import os
@@ -33,7 +34,7 @@ from PySide6.QtWidgets import (
33
34
  )
34
35
 
35
36
  from accusleepy.constants import UNDEFINED_LABEL
36
- from accusleepy.fileio import load_config, save_labels, EMGFilter
37
+ from accusleepy.fileio import EMGFilter, load_config, save_labels
37
38
  from accusleepy.gui.mplwidget import resample_x_ticks
38
39
  from accusleepy.gui.viewer_window import Ui_ViewerWindow
39
40
  from accusleepy.signal_processing import create_spectrogram, get_emg_power
@@ -85,8 +86,8 @@ BRIGHTER_SCALE_FACTOR = 0.96
85
86
  DIMMER_SCALE_FACTOR = 1.07
86
87
  # zoom factor for upper plots - larger values = bigger changes
87
88
  ZOOM_FACTOR = 0.1
88
- # interval in seconds between zoom events triggered by scrolling
89
- ZOOM_DELAY = 0.05
89
+ # rate limit for zoom events triggered by scrolling
90
+ MAX_SCROLL_EVENTS_PER_SEC = 24
90
91
 
91
92
 
92
93
  @dataclass
@@ -123,7 +124,7 @@ class ManualScoringWindow(QDialog):
123
124
  :param epoch_length: epoch length, in seconds
124
125
  :param emg_filter: EMG filter parameters
125
126
  """
126
- super(ManualScoringWindow, self).__init__()
127
+ super().__init__()
127
128
 
128
129
  self.label_file = label_file
129
130
  self.eeg = eeg
@@ -914,9 +915,7 @@ class ManualScoringWindow(QDialog):
914
915
  )
915
916
  self.ui.lowerfigure.canvas.axes[1].set_xticklabels(
916
917
  [
917
- "{:02d}:{:02d}:{:05.2f}".format(
918
- int(x // 3600), int(x // 60) % 60, (x % 60)
919
- )
918
+ f"{int(x // 3600):02d}:{int(x // 60) % 60:02d}:{x % 60:05.2f}"
920
919
  for x in x_ticks * self.epoch_length
921
920
  ]
922
921
  )
@@ -983,11 +982,15 @@ class ManualScoringWindow(QDialog):
983
982
  return
984
983
 
985
984
  self.now_zooming = True
985
+ start_time = time.time()
986
986
  if event.button == "up":
987
987
  self.zoom_x(direction=ZOOM_IN)
988
988
  else:
989
989
  self.zoom_x(direction=ZOOM_OUT)
990
- time.sleep(ZOOM_DELAY)
990
+ end_time = time.time()
991
+ time_elapsed = end_time - start_time
992
+ if time_elapsed < 1 / MAX_SCROLL_EVENTS_PER_SEC:
993
+ time.sleep(1 / MAX_SCROLL_EVENTS_PER_SEC - time_elapsed)
991
994
  self.now_zooming = False
992
995
 
993
996
 
@@ -1,4 +1,5 @@
1
- # Widget with a matplotlib FigureCanvas for manual scoring
1
+ """Matplotlib FigureCanvas widget for manual scoring."""
2
+
2
3
  from collections.abc import Callable
3
4
 
4
5
  import matplotlib.ticker as mticker
@@ -293,7 +294,7 @@ class MplWidget(QtWidgets.QWidget):
293
294
  [1 - marker_dy, 1],
294
295
  ]
295
296
  )
296
- for x, y in zip(marker_x, marker_y):
297
+ for x, y in zip(marker_x, marker_y, strict=True):
297
298
  self.top_marker.append(axes[0].plot(x, y - marker_y_offset_top, "r")[0])
298
299
 
299
300
  # EMG subplot
@@ -317,7 +318,7 @@ class MplWidget(QtWidgets.QWidget):
317
318
  linewidth=0.5,
318
319
  )[0]
319
320
 
320
- for x, y in zip(marker_x, marker_y):
321
+ for x, y in zip(marker_x, marker_y, strict=True):
321
322
  self.bottom_marker.append(
322
323
  axes[1].plot(x, -1 * (y - marker_y_offset_bottom), "r")[0]
323
324
  )
@@ -355,9 +356,7 @@ class MplWidget(QtWidgets.QWidget):
355
356
 
356
357
  def time_formatter(self, x, pos):
357
358
  x = x * self.epoch_length
358
- return "{:02d}:{:02d}:{:05.2f}".format(
359
- int(x // 3600), int(x // 60) % 60, (x % 60)
360
- )
359
+ return f"{int(x // 3600):02d}:{int(x // 60) % 60:02d}:{x % 60:05.2f}"
361
360
 
362
361
 
363
362
  def resample_x_ticks(x_ticks: np.array) -> np.array:
@@ -1,9 +1,7 @@
1
- # -*- coding: utf-8 -*-
2
-
3
1
  ################################################################################
4
2
  ## Form generated from reading UI file 'primary_window.ui'
5
3
  ##
6
- ## Created by: Qt User Interface Compiler version 6.9.2
4
+ ## Created by: Qt User Interface Compiler version 6.10.2
7
5
  ##
8
6
  ## WARNING! All changes made in this file will be lost when recompiling UI file!
9
7
  ################################################################################
@@ -32,10 +30,11 @@ from PySide6.QtWidgets import (
32
30
  QVBoxLayout,
33
31
  QWidget,
34
32
  )
33
+
35
34
  import accusleepy.gui.resources_rc # noqa F401
36
35
 
37
36
 
38
- class Ui_PrimaryWindow(object):
37
+ class Ui_PrimaryWindow:
39
38
  def setupUi(self, PrimaryWindow):
40
39
  if not PrimaryWindow.objectName():
41
40
  PrimaryWindow.setObjectName("PrimaryWindow")
@@ -3148,7 +3147,6 @@ class Ui_PrimaryWindow(object):
3148
3147
  "This is the current set of brain states. Important notes:\n"
3149
3148
  "- You must click 'Save settings' for changes to take effect.\n"
3150
3149
  "- Changing these settings can prevent existing label files, calibration files, and trained models from working properly.\n"
3151
- "- Reinstalling AccuSleePy will overwrite this configuration.\n"
3152
3150
  "\n"
3153
3151
  "Each brain state has several attributes:\n"
3154
3152
  "- Digit: the indicator for this state in label files, and the keyboard shortcut for this state in manual scoring.\n"
@@ -3701,7 +3701,6 @@ color: rgb(244, 195, 68);</string>
3701
3701
  <string>This is the current set of brain states. Important notes:
3702
3702
  - You must click 'Save settings' for changes to take effect.
3703
3703
  - Changing these settings can prevent existing label files, calibration files, and trained models from working properly.
3704
- - Reinstalling AccuSleePy will overwrite this configuration.
3705
3704
 
3706
3705
  Each brain state has several attributes:
3707
3706
  - Digit: the indicator for this state in label files, and the keyboard shortcut for this state in manual scoring.
@@ -1,11 +1,20 @@
1
1
  """Recording list manager"""
2
2
 
3
+ from dataclasses import dataclass
4
+
3
5
  from PySide6.QtCore import QObject
4
6
  from PySide6.QtWidgets import QListWidget, QListWidgetItem
5
7
 
6
8
  from accusleepy.fileio import Recording, load_recording_list, save_recording_list
7
9
 
8
10
 
11
+ @dataclass
12
+ class RecordingListItem(Recording):
13
+ """A Recording with an associated QListWidget item for the GUI"""
14
+
15
+ widget: QListWidgetItem = None
16
+
17
+
9
18
  class RecordingListManager(QObject):
10
19
  """Manages the list of recordings and the associated QListWidget"""
11
20
 
@@ -14,19 +23,19 @@ class RecordingListManager(QObject):
14
23
  self._widget = list_widget
15
24
 
16
25
  # Create initial empty recording (there is always at least one)
17
- first_recording = Recording(
26
+ first_recording = RecordingListItem(
18
27
  widget=QListWidgetItem("Recording 1", self._widget),
19
28
  )
20
- self._recordings: list[Recording] = [first_recording]
29
+ self._recordings: list[RecordingListItem] = [first_recording]
21
30
  self._widget.addItem(first_recording.widget)
22
31
  self._widget.setCurrentRow(0)
23
32
 
24
33
  @property
25
- def current(self) -> Recording:
34
+ def current(self) -> RecordingListItem:
26
35
  """The currently selected recording"""
27
36
  return self._recordings[self._widget.currentRow()]
28
37
 
29
- def add(self, sampling_rate: int | float) -> Recording:
38
+ def add(self, sampling_rate: int | float) -> RecordingListItem:
30
39
  """Add a new recording to the list
31
40
 
32
41
  :param sampling_rate: sampling rate for the new recording
@@ -35,7 +44,7 @@ class RecordingListManager(QObject):
35
44
  new_name = max(r.name for r in self._recordings) + 1
36
45
 
37
46
  # Create recording with widget
38
- recording = Recording(
47
+ recording = RecordingListItem(
39
48
  name=new_name,
40
49
  sampling_rate=sampling_rate,
41
50
  widget=QListWidgetItem(f"Recording {new_name}", self._widget),
@@ -63,7 +72,7 @@ class RecordingListManager(QObject):
63
72
  else:
64
73
  # Reset the single recording to defaults
65
74
  recording_name = self._recordings[0].name
66
- self._recordings[0] = Recording(widget=self._recordings[0].widget)
75
+ self._recordings[0] = RecordingListItem(widget=self._recordings[0].widget)
67
76
  self._recordings[0].widget.setText(f"Recording {self._recordings[0].name}")
68
77
  return f"cleared Recording {recording_name}"
69
78
 
@@ -85,14 +94,15 @@ class RecordingListManager(QObject):
85
94
  try:
86
95
  self._widget.clear()
87
96
 
88
- # Load recordings
89
- self._recordings = load_recording_list(filename)
90
-
91
- # Create widgets for each recording
92
- for recording in self._recordings:
93
- recording.widget = QListWidgetItem(
94
- f"Recording {recording.name}", self._widget
97
+ # Load recordings and create widgets
98
+ self._recordings = [
99
+ RecordingListItem(
100
+ **r.__dict__,
101
+ widget=QListWidgetItem(f"Recording {r.name}", self._widget),
95
102
  )
103
+ for r in load_recording_list(filename)
104
+ ]
105
+ for recording in self._recordings:
96
106
  self._widget.addItem(recording.widget)
97
107
  finally:
98
108
  self._widget.blockSignals(False)
@@ -106,5 +116,5 @@ class RecordingListManager(QObject):
106
116
  def __len__(self):
107
117
  return len(self._recordings)
108
118
 
109
- def __getitem__(self, index: int) -> Recording:
119
+ def __getitem__(self, index: int) -> RecordingListItem:
110
120
  return self._recordings[index]
@@ -197,7 +197,7 @@ class SettingsWidget(QObject):
197
197
  # Brain states
198
198
  states = {b.digit: b for b in self._brain_state_set.brain_states}
199
199
  for digit in range(10):
200
- if digit in states.keys():
200
+ if digit in states:
201
201
  self._settings_widgets[digit].enabled_widget.setChecked(True)
202
202
  self._settings_widgets[digit].name_widget.setText(states[digit].name)
203
203
  self._settings_widgets[digit].is_scored_widget.setChecked(
@@ -1,5 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
-
3
1
  ################################################################################
4
2
  ## Form generated from reading UI file 'viewer_window.ui'
5
3
  ##
@@ -23,11 +21,11 @@ from PySide6.QtWidgets import (
23
21
  QVBoxLayout,
24
22
  )
25
23
 
26
- from accusleepy.gui.mplwidget import MplWidget
27
24
  import accusleepy.gui.resources_rc # noqa F401
25
+ from accusleepy.gui.mplwidget import MplWidget
28
26
 
29
27
 
30
- class Ui_ViewerWindow(object):
28
+ class Ui_ViewerWindow:
31
29
  def setupUi(self, ViewerWindow):
32
30
  if not ViewerWindow.objectName():
33
31
  ViewerWindow.setObjectName("ViewerWindow")
@@ -1,3 +1,5 @@
1
+ """Neural network model definitions for sleep stage classification."""
2
+
1
3
  from torch import device, flatten, nn
2
4
  from torch import load as torch_load
3
5
  from torch import save as torch_save
@@ -14,10 +14,6 @@ import timeit
14
14
  import warnings
15
15
 
16
16
  import numpy as np
17
- from joblib import Parallel, cpu_count, delayed
18
-
19
- # from scipy.signal import detrend # unused by AccuSleePy
20
- # from scipy.signal.windows import dpss # lazily loaded later
21
17
 
22
18
 
23
19
  # MULTITAPER SPECTROGRAM #
@@ -206,19 +202,9 @@ def spectrogram(
206
202
  wt,
207
203
  )
208
204
 
209
- if multiprocess: # use multiprocessing
210
- n_jobs = max(cpu_count() - 1, 1) if n_jobs is None else n_jobs
211
- mt_spectrogram = np.vstack(
212
- Parallel(n_jobs=n_jobs)(
213
- delayed(calc_mts_segment)(data_segments[num_window, :], *mts_params)
214
- for num_window in range(num_windows)
215
- )
216
- )
217
-
218
- else: # if no multiprocessing, compute normally
219
- mt_spectrogram = np.apply_along_axis(
220
- calc_mts_segment, 1, data_segments, *mts_params
221
- )
205
+ mt_spectrogram = np.apply_along_axis(
206
+ calc_mts_segment, 1, data_segments, *mts_params
207
+ )
222
208
 
223
209
  # Compute one-sided PSD spectrum
224
210
  mt_spectrogram = mt_spectrogram.T
@@ -251,37 +237,6 @@ def spectrogram(
251
237
  if np.all(mt_spectrogram.flatten() == 0):
252
238
  print("\n Data was all zeros, no output")
253
239
 
254
- # # Plot multitaper spectrogram
255
- # if plot_on:
256
- # # convert from power to dB
257
- # spect_data = nanpow2db(mt_spectrogram)
258
- #
259
- # # Set x and y axes
260
- # dx = stimes[1] - stimes[0]
261
- # dy = sfreqs[1] - sfreqs[0]
262
- # extent = [stimes[0] - dx, stimes[-1] + dx, sfreqs[-1] + dy, sfreqs[0] - dy]
263
- #
264
- # # Plot spectrogram
265
- # if ax is None:
266
- # fig, ax = plt.subplots()
267
- # else:
268
- # fig = ax.get_figure()
269
- # im = ax.imshow(spect_data, extent=extent, aspect="auto")
270
- # fig.colorbar(im, ax=ax, label="PSD (dB)", shrink=0.8)
271
- # ax.set_xlabel("Time (HH:MM:SS)")
272
- # ax.set_ylabel("Frequency (Hz)")
273
- # im.set_cmap(plt.cm.get_cmap("cet_rainbow4"))
274
- # ax.invert_yaxis()
275
- #
276
- # # Scale colormap
277
- # if clim_scale:
278
- # clim = np.percentile(spect_data, [5, 98]) # from 5th percentile to 98th
279
- # im.set_clim(clim) # actually change colorbar scale
280
- #
281
- # fig.show()
282
- # if return_fig:
283
- # return mt_spectrogram, stimes, sfreqs, (fig, ax)
284
-
285
240
  return mt_spectrogram, stimes, sfreqs
286
241
 
287
242
 
@@ -376,7 +331,8 @@ def process_input(
376
331
  + str(frequency_range[0])
377
332
  + ", "
378
333
  + str(frequency_range[1])
379
- + "]"
334
+ + "]",
335
+ stacklevel=2,
380
336
  )
381
337
 
382
338
  # Set number of tapers if none provided
@@ -387,7 +343,8 @@ def process_input(
387
343
  if num_tapers != math.floor(2 * time_bandwidth) - 1:
388
344
  warnings.warn(
389
345
  "Number of tapers is optimal at floor(2*TW) - 1. consider using "
390
- + str(math.floor(2 * time_bandwidth) - 1)
346
+ + str(math.floor(2 * time_bandwidth) - 1),
347
+ stacklevel=2,
391
348
  )
392
349
 
393
350
  # If no window params provided, set to defaults
@@ -400,7 +357,8 @@ def process_input(
400
357
  warnings.warn(
401
358
  "Window size is not divisible by sampling frequency. Adjusting window size to "
402
359
  + str(winsize_samples / fs)
403
- + " seconds"
360
+ + " seconds",
361
+ stacklevel=2,
404
362
  )
405
363
  else:
406
364
  winsize_samples = window_params[0] * fs
@@ -411,7 +369,8 @@ def process_input(
411
369
  warnings.warn(
412
370
  "Window step size is not divisible by sampling frequency. Adjusting window step size to "
413
371
  + str(winstep_samples / fs)
414
- + " seconds"
372
+ + " seconds",
373
+ stacklevel=2,
415
374
  )
416
375
  else:
417
376
  winstep_samples = window_params[1] * fs
@@ -553,7 +512,7 @@ def nanpow2db(y):
553
512
  ydB (float or np array): inputs converted to dB with 0s and negatives resulting in nans
554
513
  """
555
514
 
556
- if isinstance(y, int) or isinstance(y, float):
515
+ if isinstance(y, int | float):
557
516
  if y == 0:
558
517
  return np.nan
559
518
  else:
@@ -568,18 +527,6 @@ def nanpow2db(y):
568
527
  return ydB
569
528
 
570
529
 
571
- # Helper #
572
- def is_outlier(data):
573
- smad = 1.4826 * np.median(
574
- abs(data - np.median(data))
575
- ) # scaled median absolute deviation
576
- outlier_mask = (
577
- abs(data - np.median(data)) > 3 * smad
578
- ) # outliers are more than 3 smads away from median
579
- outlier_mask = outlier_mask | np.isnan(data) | np.isinf(data)
580
- return outlier_mask
581
-
582
-
583
530
  # CALCULATE MULTITAPER SPECTRUM ON SINGLE SEGMENT
584
531
  def calc_mts_segment(
585
532
  data_segment,
@@ -638,7 +585,7 @@ def calc_mts_segment(
638
585
  spower_iter = np.mean(spower[:, 0:2], 1)
639
586
  spower_iter = spower_iter[:, np.newaxis]
640
587
  a = (1 - dpss_eigen) * tpower
641
- for i in range(3): # 3 iterations only
588
+ for _i in range(3): # 3 iterations only
642
589
  # Calc the MSE weights
643
590
  b = np.dot(spower_iter, np.ones((1, num_tapers))) / (
644
591
  (np.dot(spower_iter, np.transpose(dpss_eigen)))
@@ -8,8 +8,8 @@ import datetime
8
8
  import logging
9
9
  import os
10
10
  import shutil
11
+ from collections.abc import Callable
11
12
  from dataclasses import dataclass, field
12
- from typing import Callable
13
13
 
14
14
  import numpy as np
15
15
  import pandas as pd
@@ -21,9 +21,9 @@ from accusleepy.constants import (
21
21
  CALIBRATION_ANNOTATION_FILENAME,
22
22
  DEFAULT_MODEL_TYPE,
23
23
  MIN_EPOCHS_PER_STATE,
24
- UNDEFINED_LABEL,
25
24
  MIXTURE_MEAN_COL,
26
25
  MIXTURE_SD_COL,
26
+ UNDEFINED_LABEL,
27
27
  )
28
28
  from accusleepy.fileio import (
29
29
  EMGFilter,
@@ -36,10 +36,10 @@ from accusleepy.fileio import (
36
36
  )
37
37
  from accusleepy.models import SSANN
38
38
  from accusleepy.signal_processing import (
39
- create_training_images,
40
- resample_and_standardize,
41
39
  create_eeg_emg_image,
40
+ create_training_images,
42
41
  get_mixture_values,
42
+ resample_and_standardize,
43
43
  )
44
44
  from accusleepy.validation import check_label_validity
45
45
 
@@ -1,3 +1,5 @@
1
+ """EEG/EMG signal processing, mixture z-scoring, and training image generation."""
2
+
1
3
  import logging
2
4
  import os
3
5
  import warnings
@@ -19,10 +21,10 @@ from accusleepy.constants import (
19
21
  LABEL_COL,
20
22
  MIN_EPOCHS_PER_STATE,
21
23
  MIN_WINDOW_LEN,
22
- UPPER_FREQ,
23
24
  SPECTROGRAM_UPPER_FREQ,
25
+ UPPER_FREQ,
24
26
  )
25
- from accusleepy.fileio import Recording, load_labels, load_recording, EMGFilter
27
+ from accusleepy.fileio import EMGFilter, Recording, load_labels, load_recording
26
28
  from accusleepy.multitaper import spectrogram
27
29
 
28
30
  # note: scipy is lazily imported
@@ -149,7 +151,7 @@ def create_spectrogram(
149
151
  # pad the EEG signal so that the first spectrogram window is centered
150
152
  # on the first epoch
151
153
  # it's possible there's some jank here, if this isn't close to an integer
152
- pad_length = round((sampling_rate * (window_length_sec - epoch_length) / 2))
154
+ pad_length = round(sampling_rate * (window_length_sec - epoch_length) / 2)
153
155
  padded_eeg = np.concatenate(
154
156
  [eeg[:pad_length][::-1], eeg, eeg[(len(eeg) - pad_length) :][::-1]]
155
157
  )
@@ -326,7 +328,10 @@ def mixture_z_score_img(
326
328
  if labels is None and (mixture_means is None or mixture_sds is None):
327
329
  raise ValueError("must provide either labels or mixture means+SDs")
328
330
  if labels is not None and ((mixture_means is not None) ^ (mixture_sds is not None)):
329
- warnings.warn("labels were given, mixture means / SDs will be ignored")
331
+ warnings.warn(
332
+ "labels were given, mixture means / SDs will be ignored",
333
+ stacklevel=2,
334
+ )
330
335
 
331
336
  if labels is not None:
332
337
  mixture_means, mixture_sds = get_mixture_values(
@@ -1,3 +1,5 @@
1
+ """Temperature scaling for model confidence calibration."""
2
+
1
3
  import logging
2
4
 
3
5
  import numpy as np
@@ -18,7 +20,7 @@ class ModelWithTemperature(nn.Module):
18
20
  """
19
21
 
20
22
  def __init__(self, model):
21
- super(ModelWithTemperature, self).__init__()
23
+ super().__init__()
22
24
  self.model = model
23
25
  # https://github.com/gpleiss/temperature_scaling/issues/20
24
26
  # for another approach, see https://github.com/gpleiss/temperature_scaling/issues/36
@@ -140,7 +142,7 @@ class _ECELoss(nn.Module):
140
142
  """
141
143
  n_bins (int): number of confidence interval bins
142
144
  """
143
- super(_ECELoss, self).__init__()
145
+ super().__init__()
144
146
  bin_boundaries = torch.linspace(0, 1, n_bins + 1)
145
147
  self.bin_lowers = bin_boundaries[:-1]
146
148
  self.bin_uppers = bin_boundaries[1:]
@@ -151,7 +153,7 @@ class _ECELoss(nn.Module):
151
153
  accuracies = predictions.eq(labels)
152
154
 
153
155
  ece = torch.zeros(1, device=logits.device)
154
- for bin_lower, bin_upper in zip(self.bin_lowers, self.bin_uppers):
156
+ for bin_lower, bin_upper in zip(self.bin_lowers, self.bin_uppers, strict=True):
155
157
  # Calculated |confidence - accuracy| in each bin
156
158
  in_bin = confidences.gt(bin_lower.item()) * confidences.le(bin_upper.item())
157
159
  prop_in_bin = in_bin.float().mean()
@@ -1,3 +1,5 @@
1
+ """Validation utilities for brain state labels and recordings."""
2
+
1
3
  import numpy as np
2
4
 
3
5
  from accusleepy.brain_state_set import BrainStateSet
@@ -39,9 +41,10 @@ def check_label_validity(
39
41
  ):
40
42
  return "label file contains invalid entries"
41
43
 
42
- if confidence_scores is not None:
43
- if np.min(confidence_scores) < 0 or np.max(confidence_scores) > 1:
44
- return "label file contains invalid confidence scores"
44
+ if confidence_scores is not None and (
45
+ np.min(confidence_scores) < 0 or np.max(confidence_scores) > 1
46
+ ):
47
+ return "label file contains invalid confidence scores"
45
48
 
46
49
  return None
47
50
 
@@ -141,7 +144,9 @@ def check_config_consistency(
141
144
  # generate message comparing the brain state configs
142
145
  config_comparisons = list()
143
146
  for config, config_name in zip(
144
- [current_scored_states, model_scored_states], ["current", "model's"]
147
+ [current_scored_states, model_scored_states],
148
+ ["current", "model's"],
149
+ strict=True,
145
150
  ):
146
151
  config_comparisons.append(
147
152
  f"Scored brain states in {config_name} configuration: "
@@ -152,6 +157,7 @@ def check_config_consistency(
152
157
  for x, y in zip(
153
158
  config["digit"],
154
159
  config["name"],
160
+ strict=True,
155
161
  )
156
162
  ]
157
163
  )
@@ -162,32 +168,26 @@ def check_config_consistency(
162
168
  len_diff = len(current_scored_states["name"]) - len(model_scored_states["name"])
163
169
  if len_diff != 0:
164
170
  output.append(
165
- (
166
- "WARNING: current brain state configuration has "
167
- f"{'fewer' if len_diff < 0 else 'more'} "
168
- "scored brain states than the model's configuration."
169
- )
171
+ "WARNING: current brain state configuration has "
172
+ f"{'fewer' if len_diff < 0 else 'more'} "
173
+ "scored brain states than the model's configuration."
170
174
  )
171
175
  output = output + config_comparisons
172
176
  else:
173
177
  # the length is the same, but names might be different
174
178
  if current_scored_states["name"] != model_scored_states["name"]:
175
179
  output.append(
176
- (
177
- "WARNING: current brain state configuration appears "
178
- "to contain different brain states than "
179
- "the model's configuration."
180
- )
180
+ "WARNING: current brain state configuration appears "
181
+ "to contain different brain states than "
182
+ "the model's configuration."
181
183
  )
182
184
  output = output + config_comparisons
183
185
 
184
186
  if current_epoch_length != model_epoch_length:
185
187
  output.append(
186
- (
187
- "Warning: the epoch length used when training this model "
188
- f"({model_epoch_length} seconds) "
189
- "does not match the current epoch length setting."
190
- )
188
+ "Warning: the epoch length used when training this model "
189
+ f"({model_epoch_length} seconds) "
190
+ "does not match the current epoch length setting."
191
191
  )
192
192
 
193
193
  return output
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "accusleepy"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  description = "Python implementation of AccuSleep"
5
5
  authors = [
6
6
  {name = "Zeke Barger",email = "zekebarger@gmail.com"}
@@ -15,15 +15,15 @@ dependencies = [
15
15
  "torchvision (>=0.23.0,<1.0.0)",
16
16
  "scipy (>=1.15.2,<2.0.0)",
17
17
  "matplotlib (>=3.10.1,<4.0.0)",
18
- "joblib (>=1.4.2,<2.0.0)",
19
18
  "pillow (>=11.1.0,<12.0.0)",
20
- "fastparquet (>=2024.11.0,<2025.0.0)",
21
- "pre-commit (>=4.2.0,<5.0.0)",
22
19
  "tqdm (>=4.67.1,<5.0.0)",
23
- "pyside6 (>=6.9.0, <6.9.3)",
20
+ "pyside6 (>=6.10.1,<7.0.0)",
21
+ "pyarrow (>=23.0.0,<24.0.0)",
22
+ "platformdirs (>=4.0.0,<5.0.0)",
24
23
  ]
25
24
 
26
25
  [tool.poetry.group.dev.dependencies]
26
+ pre-commit = ">=4.2.0,<5.0.0"
27
27
  pytest = ">=8.3.5,<9.0.0"
28
28
  pytest-qt = ">=4.4.0,<5.0.0"
29
29
  ruff = ">=0.11.2,<0.12.0"
@@ -35,6 +35,9 @@ build-backend = "poetry.core.masonry.api"
35
35
  [tool.ruff]
36
36
  extend-exclude = ["*.ipynb"]
37
37
 
38
+ [tool.ruff.lint]
39
+ select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"]
40
+
38
41
  [tool.pytest.ini_options]
39
42
  markers = [
40
43
  "gui: marks tests as GUI tests (may require display or xvfb)",
File without changes
File without changes