misleep 0.3.2__tar.gz → 0.3.4__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 (124) hide show
  1. {misleep-0.3.2 → misleep-0.3.4}/CHANGELOG.md +34 -0
  2. {misleep-0.3.2/src/misleep.egg-info → misleep-0.3.4}/PKG-INFO +3 -2
  3. {misleep-0.3.2 → misleep-0.3.4}/docs/config_file.md +4 -4
  4. {misleep-0.3.2 → misleep-0.3.4}/pyproject.toml +1 -1
  5. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/__init__.py +1 -1
  6. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/autostage/benchmark.py +40 -1
  7. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/models/benchmark_models.pkl +1 -1
  8. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/config/default_config.ini +2 -2
  9. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/dialogs.py +55 -21
  10. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/main_window.py +9 -5
  11. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/qt_utils.py +6 -5
  12. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/spec_window.py +2 -2
  13. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/about.ui +2 -2
  14. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/about_ui.py +2 -2
  15. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/preprocessing/spectral.py +64 -16
  16. {misleep-0.3.2 → misleep-0.3.4/src/misleep.egg-info}/PKG-INFO +3 -2
  17. {misleep-0.3.2 → misleep-0.3.4}/tests/test_analysis.py +14 -0
  18. {misleep-0.3.2 → misleep-0.3.4}/tests/test_gui.py +100 -1
  19. misleep-0.3.4/tests/test_preprocessing.py +170 -0
  20. misleep-0.3.2/tests/test_preprocessing.py +0 -95
  21. {misleep-0.3.2 → misleep-0.3.4}/LICENSE +0 -0
  22. {misleep-0.3.2 → misleep-0.3.4}/MANIFEST.in +0 -0
  23. {misleep-0.3.2 → misleep-0.3.4}/README.md +0 -0
  24. {misleep-0.3.2 → misleep-0.3.4}/docs/api_reference.md +0 -0
  25. {misleep-0.3.2 → misleep-0.3.4}/docs/data_formats.md +0 -0
  26. {misleep-0.3.2 → misleep-0.3.4}/docs/developer_guide.md +0 -0
  27. {misleep-0.3.2 → misleep-0.3.4}/docs/getting_started.md +0 -0
  28. {misleep-0.3.2 → misleep-0.3.4}/docs/imgs/entire_logo.png +0 -0
  29. {misleep-0.3.2 → misleep-0.3.4}/docs/imgs/gui/main_light.png +0 -0
  30. {misleep-0.3.2 → misleep-0.3.4}/docs/imgs/logo.png +0 -0
  31. {misleep-0.3.2 → misleep-0.3.4}/docs/index.md +0 -0
  32. {misleep-0.3.2 → misleep-0.3.4}/docs/user_guide.md +0 -0
  33. {misleep-0.3.2 → misleep-0.3.4}/examples/auto_stage_example.py +0 -0
  34. {misleep-0.3.2 → misleep-0.3.4}/examples/basic_usage.py +0 -0
  35. {misleep-0.3.2 → misleep-0.3.4}/examples/extend_io_example.py +0 -0
  36. {misleep-0.3.2 → misleep-0.3.4}/setup.cfg +0 -0
  37. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/__main__.py +0 -0
  38. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/_compat.py +0 -0
  39. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/__init__.py +0 -0
  40. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/auto_stage.py +0 -0
  41. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/autostage/__init__.py +0 -0
  42. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/autostage/features.py +0 -0
  43. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/autostage/hmm.py +0 -0
  44. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/autostage/model.py +0 -0
  45. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/autostage/postprocess.py +0 -0
  46. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/detection.py +0 -0
  47. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/features.py +0 -0
  48. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/models/__init__.py +0 -0
  49. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/transformer/__init__.py +0 -0
  50. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
  51. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/transformer/checkpoints/__init__.py +0 -0
  52. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/transformer/configs.py +0 -0
  53. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/transformer/data.py +0 -0
  54. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/transformer/inference.py +0 -0
  55. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/transformer/models.py +0 -0
  56. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/analysis/transformer/preprocessing.py +0 -0
  57. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/config/__init__.py +0 -0
  58. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/data/__init__.py +0 -0
  59. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/data/annotation.py +0 -0
  60. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/data/midata.py +0 -0
  61. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/__init__.py +0 -0
  62. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/app.py +0 -0
  63. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/config_dialog.py +0 -0
  64. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/event_filters.py +0 -0
  65. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/resources/__init__.py +0 -0
  66. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/resources/entire_logo.png +0 -0
  67. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/resources/logo.png +0 -0
  68. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/resources/misleep.ico +0 -0
  69. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/resources/misleep.qrc +0 -0
  70. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/resources/misleep_rc.py +0 -0
  71. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/style.py +0 -0
  72. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/SWA_detect_dialog.ui +0 -0
  73. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/SWA_detect_dialog_ui.py +0 -0
  74. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/__init__.py +0 -0
  75. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +0 -0
  76. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +0 -0
  77. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/auto_stage_lightGBM_dialog.ui +0 -0
  78. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +0 -0
  79. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/horizontal_line_dialog.ui +0 -0
  80. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/horizontal_line_dialog_ui.py +0 -0
  81. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/label_dialog.ui +0 -0
  82. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/label_dialog_ui.py +0 -0
  83. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/main_window.ui +0 -0
  84. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/main_window_ui.py +0 -0
  85. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/save_data_dialog.ui +0 -0
  86. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/save_data_dialog_ui.py +0 -0
  87. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/spec_window.ui +0 -0
  88. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/spec_window_ui.py +0 -0
  89. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/spindle_detect_dialog.ui +0 -0
  90. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/spindle_detect_dialog_ui.py +0 -0
  91. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/state_spectral_dialog.ui +0 -0
  92. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/state_spectral_dialog_ui.py +0 -0
  93. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/transfer_result_dialog.ui +0 -0
  94. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/uis/transfer_result_dialog_ui.py +0 -0
  95. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/gui/workers.py +0 -0
  96. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/io/__init__.py +0 -0
  97. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/io/annotation.py +0 -0
  98. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/io/array.py +0 -0
  99. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/io/base.py +0 -0
  100. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/io/edf.py +0 -0
  101. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/io/mat.py +0 -0
  102. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/logger.py +0 -0
  103. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/preprocessing/__init__.py +0 -0
  104. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/preprocessing/artifacts.py +0 -0
  105. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/preprocessing/filtering.py +0 -0
  106. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/preprocessing/segment.py +0 -0
  107. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/utils/__init__.py +0 -0
  108. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/utils/annotation.py +0 -0
  109. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/utils/entropy.py +0 -0
  110. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/utils/misc.py +0 -0
  111. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/utils/time_utils.py +0 -0
  112. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/viz/__init__.py +0 -0
  113. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/viz/hypnogram.py +0 -0
  114. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/viz/signals.py +0 -0
  115. {misleep-0.3.2 → misleep-0.3.4}/src/misleep/viz/spectral.py +0 -0
  116. {misleep-0.3.2 → misleep-0.3.4}/src/misleep.egg-info/SOURCES.txt +0 -0
  117. {misleep-0.3.2 → misleep-0.3.4}/src/misleep.egg-info/dependency_links.txt +0 -0
  118. {misleep-0.3.2 → misleep-0.3.4}/src/misleep.egg-info/entry_points.txt +0 -0
  119. {misleep-0.3.2 → misleep-0.3.4}/src/misleep.egg-info/requires.txt +0 -0
  120. {misleep-0.3.2 → misleep-0.3.4}/src/misleep.egg-info/top_level.txt +0 -0
  121. {misleep-0.3.2 → misleep-0.3.4}/tests/test_data.py +0 -0
  122. {misleep-0.3.2 → misleep-0.3.4}/tests/test_io.py +0 -0
  123. {misleep-0.3.2 → misleep-0.3.4}/tests/test_segment.py +0 -0
  124. {misleep-0.3.2 → misleep-0.3.4}/tests/test_viz_config.py +0 -0
@@ -5,6 +5,40 @@ All notable changes to MiSleep are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ### Fixed
11
+
12
+ - **Regular spectral export frequency grid**: State Spectral and the spectrum
13
+ window interpolate power onto the nominal frequency spacing selected by the
14
+ FFT duration (e.g. 0.1 Hz for 10 s), including exact band endpoints.
15
+ Fractional sample rates no longer produce drifting frequency labels or
16
+ duplicate near-boundary rows. Excel contains the regular numeric values,
17
+ rather than merely displaying rounded labels.
18
+
19
+ ## [0.3.4] — 2026-09-30
20
+
21
+ ### Fixed
22
+
23
+ - **Spectral export frequency boundaries**: retain the true FFT frequency
24
+ precision and interpolate power at the exact requested band endpoints.
25
+ Fractional sample rates no longer drop the 30 Hz endpoint because a nearby
26
+ FFT bin rounds to 30.01 Hz. Applies to State Spectral, spectrum and
27
+ spectrogram exports. CSV frequency axes retain full precision, and power
28
+ integration uses the actual frequency coordinates.
29
+
30
+ ## [0.3.3] — 2026-09-22
31
+
32
+ ### Fixed
33
+
34
+ - **Auto-staging model compatibility**: benchmark models serialized with
35
+ NumPy 2.x now load under NumPy 1.x as well. This fixes
36
+ `No module named 'numpy._core'` for users installing MiSleep into an
37
+ environment with an older supported NumPy release.
38
+ - **Hourly State Spectral export**: columns stay aligned to `ZT0`, `ZT1`,
39
+ ... from the selected start time. Hours in which a state is absent remain
40
+ empty instead of shifting later spectra into earlier columns.
41
+
8
42
  ## [0.3.2] — 2026-08-24
9
43
 
10
44
  ### Fixed
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: misleep
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: MiSleep: Mice sleep EEG/EMG visualization, scoring, and analysis
5
5
  Author-email: Xueqiang Wang <swang9194@gmail.com>
6
6
  Maintainer-email: Xueqiang Wang <swang9194@gmail.com>
@@ -78,6 +78,7 @@ Requires-Dist: lightgbm>=3.3; extra == "full"
78
78
  Provides-Extra: dev
79
79
  Requires-Dist: pytest>=7.0; extra == "dev"
80
80
  Requires-Dist: pytest-cov; extra == "dev"
81
+ Dynamic: license-file
81
82
 
82
83
  # MiSleep
83
84
 
@@ -23,8 +23,8 @@ wipe your personal settings. Open the user file from the GUI via
23
23
 
24
24
  | key | default | meaning |
25
25
  |-----|---------|---------|
26
- | `version` | `v0.3.2` | displayed in the About dialog |
27
- | `updatetime` | `2026/08/24` | displayed in the About dialog |
26
+ | `version` | `v0.3.4` | displayed in the About dialog |
27
+ | `updatetime` | `2026/09/30` | displayed in the About dialog |
28
28
  | `marker` | `['half_signal', 'first REM', ...]` | marker label list (label picker) |
29
29
  | `startend` | `['high_theta', 'REM', ...]` | start-end label list (label picker) |
30
30
  | `statemap` | `{"1": "NREM", "2": "REM", "3": "Wake", "4": "INIT", ...}` | state code -> name mapping (JSON) |
@@ -48,8 +48,8 @@ wipe your personal settings. Open the user file from the GUI via
48
48
 
49
49
  ```ini
50
50
  [gui]
51
- version = v0.3.2
52
- updatetime = 2026/08/24
51
+ version = v0.3.4
52
+ updatetime = 2026/09/30
53
53
  marker = ['half_signal', 'first REM', 'WindEEG', 'W-R', 'maker']
54
54
  startend = ['high_theta', 'REM', 'Wake', 'Spindle', 'SWA', 'start end label', 'start end label']
55
55
  statemap = {"1": "NREM", "2": "REM", "3": "Wake", "4": "INIT", "5": "IS", "6": "MicroArousal"}
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "misleep"
7
- version = "0.3.2"
7
+ version = "0.3.4"
8
8
  description = "MiSleep: Mice sleep EEG/EMG visualization, scoring, and analysis"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -22,7 +22,7 @@ works without PySide6; import ``misleep.gui`` explicitly when needed.
22
22
  from importlib import import_module
23
23
 
24
24
  __author__ = "Xueqiang Wang <swang9194@gmail.com>"
25
- __version__ = "0.3.2"
25
+ __version__ = "0.3.4"
26
26
 
27
27
  # Importing ``misleep`` used to import scipy, pandas, sklearn and matplotlib
28
28
  # immediately. The GUI necessarily imports some of those later, but making
@@ -22,6 +22,7 @@ hypnogram.
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
+ from contextlib import contextmanager
25
26
  from functools import lru_cache
26
27
  from pathlib import Path
27
28
 
@@ -48,6 +49,43 @@ STRIDE = 1.0
48
49
  COMBO_BASE = {"F": "eegf", "P": "eegp"}
49
50
 
50
51
 
52
+ @contextmanager
53
+ def _numpy_pickle_compat(joblib_module):
54
+ """Make NumPy-2-created model pickles readable with NumPy 1.x.
55
+
56
+ NumPy 2 renamed its private ``numpy.core`` package to ``numpy._core``.
57
+ The bundled benchmark was serialized under NumPy 2 and therefore stores
58
+ the latter module path. NumPy 1.x cannot import it, even though the
59
+ referenced scalar implementation is otherwise compatible. Remap that
60
+ one private module path inside Joblib's unpickler, without changing the
61
+ process-wide NumPy module namespace.
62
+ """
63
+ try:
64
+ numpy_major = int(np.__version__.split(".", 1)[0])
65
+ except (TypeError, ValueError): # pragma: no cover - non-standard build
66
+ numpy_major = 2
67
+
68
+ if numpy_major >= 2:
69
+ yield
70
+ return
71
+
72
+ unpickler = joblib_module.numpy_pickle.NumpyUnpickler
73
+ original_find_class = unpickler.find_class
74
+
75
+ def find_class_compat(self, module, name):
76
+ if module == "numpy._core":
77
+ module = "numpy.core"
78
+ elif module.startswith("numpy._core."):
79
+ module = "numpy.core." + module[len("numpy._core."):]
80
+ return original_find_class(self, module, name)
81
+
82
+ try:
83
+ unpickler.find_class = find_class_compat
84
+ yield
85
+ finally:
86
+ unpickler.find_class = original_find_class
87
+
88
+
51
89
  def models_path() -> Path:
52
90
  """Return the path of the packaged benchmark models file."""
53
91
  return resource_dir("misleep.analysis.models") / "benchmark_models.pkl"
@@ -63,7 +101,8 @@ def load_models(path=None):
63
101
  raise FileNotFoundError(
64
102
  f"Model file not found: {path}. Make sure the package data is "
65
103
  f"installed (pip install misleep).")
66
- return joblib.load(path)
104
+ with _numpy_pickle_compat(joblib):
105
+ return joblib.load(path)
67
106
 
68
107
 
69
108
  def model_combo(site="F", use_emg=False, use_acc=False):
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
- name: misleep-0.3.2/src/misleep/analysis/models/benchmark_models.pkl
2
+ name: misleep-0.3.4/src/misleep/analysis/models/benchmark_models.pkl
3
3
  size: 50435195 bytes
4
4
  sha256: 8390ffa5ef8d45701a09244ec75dea46d2e4bff6a4c8242b0de84da8ea9cd31c
@@ -15,8 +15,8 @@
15
15
  # overwritten on the next package update.
16
16
 
17
17
  [gui]
18
- version = v0.3.2
19
- updatetime = 2026/08/24
18
+ version = v0.3.4
19
+ updatetime = 2026/09/30
20
20
  marker = ['half_signal', 'first REM', 'WindEEG', 'W-R', 'maker']
21
21
  startend = ['high_theta', 'REM', 'Wake', 'Spindle', 'SWA', 'start end label', 'start end label']
22
22
  statemap = {"1": "NREM", "2": "REM", "3": "Wake", "4": "INIT"}
@@ -7,6 +7,7 @@ the two auto-staging dialogs and the save-data dialog, plus the About box.
7
7
  """
8
8
 
9
9
  import datetime
10
+ import math
10
11
  import os
11
12
  from copy import deepcopy
12
13
 
@@ -50,6 +51,33 @@ from misleep.logger import logger
50
51
  from misleep.utils.annotation import lst2group
51
52
 
52
53
 
54
+ def _split_state_data_by_hour(channel_data, sleep_states, states, sf):
55
+ """Return per-state signal segments in fixed, time-aligned hour slots.
56
+
57
+ Every state receives one entry per elapsed hour. An entry is ``None``
58
+ when that state has no usable data in the hour, so later hours never
59
+ shift left to fill an empty ZT column.
60
+ """
61
+ hour_count = int(math.ceil(len(sleep_states) / 3600))
62
+ result = {state: [None] * hour_count for state in states}
63
+
64
+ for hour_idx in range(hour_count):
65
+ hour_start = hour_idx * 3600
66
+ hour_stop = min(hour_start + 3600, len(sleep_states))
67
+ groups = lst2group(enumerate(sleep_states[hour_start:hour_stop]))
68
+ for state in states:
69
+ parts = [
70
+ channel_data[
71
+ int((hour_start + group_start) * sf):
72
+ int((hour_start + group_stop) * sf)]
73
+ for group_start, group_stop, value in groups
74
+ if value == state
75
+ ]
76
+ if parts:
77
+ result[state][hour_idx] = np.concatenate(parts)
78
+ return result
79
+
80
+
53
81
  class AboutDialog(QDialog, Ui_AboutDialog):
54
82
  """The About box of MiSleep."""
55
83
 
@@ -400,11 +428,13 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
400
428
  end_sec = mianno.anno_length
401
429
 
402
430
  midata = midata.crop([start_sec, end_sec])
403
- sleep_state = mianno.sleep_state[start_sec:end_sec + 1]
431
+ # ``end_sec`` is exclusive, matching MiData.crop(). Using end+1 here
432
+ # created a spurious sample at exact hour boundaries.
433
+ sleep_state_values = mianno.sleep_state[start_sec:end_sec]
404
434
 
405
435
  channel_idx = self.ChannelSelector.currentIndex()
406
436
  channel_data = midata.signals[channel_idx]
407
- sleep_state = lst2group([[idx, each] for idx, each in enumerate(sleep_state)])
437
+ sleep_state = lst2group(enumerate(sleep_state_values))
408
438
  sf = midata.sf[channel_idx]
409
439
 
410
440
  # Band-pass filter if checked
@@ -419,10 +449,12 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
419
449
  nfft = int(self.nfftSpinBox.value() * sf) if self.nfftCheckBox.isChecked() else None
420
450
  if nfft is not None and nfft < nperseg:
421
451
  nfft = None
452
+ frequency_step = 1.0 / (
453
+ self.nfftSpinBox.value() if nfft is not None else win_length)
422
454
 
423
455
  gaussian_sigma = self.GaussianSpinBox.value() if self.GaussianCheckBox.isChecked() else None
424
456
 
425
- state_codes = sorted(set(mianno.sleep_state[start_sec:end_sec + 1]))
457
+ state_codes = sorted(set(sleep_state_values))
426
458
  state_data = {
427
459
  state: np.concatenate([
428
460
  channel_data[int(each[0] * sf): int(each[1] * sf)]
@@ -442,7 +474,8 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
442
474
  spectra = {
443
475
  state: cal_draw_spectrum(data=data, sf=sf, nperseg=nperseg,
444
476
  freq_band=freq_band, relative=relative,
445
- nfft=nfft, gaussian_sigma=gaussian_sigma)
477
+ nfft=nfft, gaussian_sigma=gaussian_sigma,
478
+ frequency_step=frequency_step)
446
479
  for state, data in state_data.items()
447
480
  }
448
481
 
@@ -451,21 +484,22 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
451
484
  # Optional per-hour spectral segmentation
452
485
  hour_spec = {state: [] for state in spectra}
453
486
  if self.HourSegmentCheckBox.isChecked():
454
- for sec in range(0, end_sec - start_sec, 3600):
455
- hour_states = mianno.sleep_state[start_sec + sec:start_sec + sec + 3600]
456
- hour_states = lst2group([[idx + sec, each]
457
- for idx, each in enumerate(hour_states)])
458
- for state in spectra:
459
- data_parts = [channel_data[int(each[0] * sf): int(each[1] * sf)]
460
- for each in hour_states if each[2] == state]
461
- data = np.concatenate(data_parts) if data_parts else np.array([])
462
- if self.RejectArtifactCheckBox.isChecked() and len(data):
463
- data = reject_artifact(data, sf=sf, threshold=threshold)
464
- if len(data) > sf * 10:
465
- hour_spec[state].append(cal_draw_spectrum(
487
+ hourly_data = _split_state_data_by_hour(
488
+ channel_data, sleep_state_values, spectra, sf)
489
+ for state, state_hours in hourly_data.items():
490
+ for data in state_hours:
491
+ if data is not None and self.RejectArtifactCheckBox.isChecked():
492
+ data = reject_artifact(
493
+ data, sf=sf, threshold=threshold)
494
+ if data is not None and len(data) > sf * 10:
495
+ power = cal_draw_spectrum(
466
496
  data=data, sf=sf, nperseg=nperseg,
467
497
  freq_band=freq_band, relative=relative, nfft=nfft,
468
- gaussian_sigma=gaussian_sigma)[0][1])
498
+ gaussian_sigma=gaussian_sigma,
499
+ frequency_step=frequency_step)[0][1]
500
+ hour_spec[state].append(power)
501
+ else:
502
+ hour_spec[state].append(None)
469
503
 
470
504
  fd = QFileDialog.getExistingDirectory(self, "Select a folder to save states' data",
471
505
  f"{config['gui']['openpath']}")
@@ -485,10 +519,10 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
485
519
  figure.savefig(fd + "/" + safe_name + "_spectrum.pdf")
486
520
  _df = pd.DataFrame(
487
521
  data=spec.T, columns=["frequency", "power"])
488
- if hour_spec[state]:
489
- _df[[str(each) for each in range(
490
- 1, len(hour_spec[state]) + 1)]] = \
491
- pd.DataFrame(hour_spec[state]).T
522
+ for hour_idx, hour_power in enumerate(hour_spec[state]):
523
+ column = f"ZT{hour_idx}"
524
+ _df[column] = np.nan if hour_power is None \
525
+ else pd.Series(hour_power)
492
526
  _df.to_excel(
493
527
  excel_writer=writer, sheet_name=safe_name[:31],
494
528
  index=False)
@@ -2274,8 +2274,10 @@ class MainWindow(QMainWindow, Ui_MiSleep):
2274
2274
  nfft = int(float(self.config["spec"]["nfft_sec"]) * self.midata.sf[channel])
2275
2275
  if win_sec > (end_ - start_):
2276
2276
  win_sec = end_ - start_
2277
- if nfft < int(win_sec * self.midata.sf[channel]):
2278
- nfft = int(win_sec * self.midata.sf[channel])
2277
+ if nfft < int(win_sec * self.midata.sf[channel]):
2278
+ nfft = int(win_sec * self.midata.sf[channel])
2279
+ frequency_step = 1.0 / max(
2280
+ float(self.config["spec"]["nfft_sec"]), win_sec)
2279
2281
 
2280
2282
  try:
2281
2283
  spec_window = self._ensure_dialog("spec_window", SpecWindow)
@@ -2286,14 +2288,16 @@ class MainWindow(QMainWindow, Ui_MiSleep):
2286
2288
  band=freq_range,
2287
2289
  nfft=nfft,
2288
2290
  gaussian_sigma=float(self.config["spec"]["gaussian_sigma"]),
2289
- win_sec=win_sec,
2290
- relative=False)
2291
+ win_sec=win_sec,
2292
+ frequency_step=frequency_step,
2293
+ relative=False)
2291
2294
 
2292
2295
  f, t, Sxx = spectrogram(
2293
2296
  signal=signal_data,
2294
2297
  sf=self.midata.sf[channel],
2295
2298
  band=freq_range,
2296
- step=1, win_sec=win_sec, norm=False, nfft=nfft)
2299
+ step=1, win_sec=win_sec, norm=False, nfft=nfft,
2300
+ frequency_step=frequency_step)
2297
2301
 
2298
2302
  bandPower = band_power(psd=psd, freq=freq,
2299
2303
  bands=[[0.5, 4, "delta"], [4, 9, "theta"]])
@@ -184,7 +184,8 @@ class ChannelListModel(QAbstractListModel):
184
184
  self.endResetModel()
185
185
 
186
186
 
187
- def cal_draw_spectrum(data, sf, nperseg, freq_band=None, relative=None, nfft=None, gaussian_sigma=None):
187
+ def cal_draw_spectrum(data, sf, nperseg, freq_band=None, relative=None, nfft=None,
188
+ gaussian_sigma=None, frequency_step=None):
188
189
  """Calculate a power spectrum and return it together with a figure.
189
190
 
190
191
  Parameters
@@ -203,6 +204,8 @@ def cal_draw_spectrum(data, sf, nperseg, freq_band=None, relative=None, nfft=Non
203
204
  Number of FFT points.
204
205
  gaussian_sigma : float, optional
205
206
  Gaussian smoothing sigma.
207
+ frequency_step : float, optional
208
+ Regular frequency spacing derived from the selected FFT duration.
206
209
 
207
210
  Returns
208
211
  -------
@@ -218,19 +221,17 @@ def cal_draw_spectrum(data, sf, nperseg, freq_band=None, relative=None, nfft=Non
218
221
  from matplotlib.figure import Figure
219
222
  from scipy.ndimage import gaussian_filter1d
220
223
  from scipy.signal import welch
224
+ from misleep.preprocessing.spectral import _select_frequency_band
221
225
 
222
226
  if freq_band is None:
223
227
  freq_band = [0.5, 30]
224
228
  F, P = welch(data, sf, nperseg=nperseg, nfft=nfft, scaling="density")
225
229
 
226
- F = np.array([round(each, 2) for each in F])
227
230
  if gaussian_sigma is not None:
228
231
  P = gaussian_filter1d(P, sigma=gaussian_sigma)
229
232
 
230
233
  if freq_band is not None:
231
- idx_band = np.logical_and(F >= freq_band[0], F <= freq_band[1])
232
- F = F[idx_band]
233
- P = P[idx_band]
234
+ F, P = _select_frequency_band(F, P, freq_band, frequency_step)
234
235
 
235
236
  if relative:
236
237
  total = sum(P)
@@ -136,7 +136,7 @@ class SpecWindow(QMainWindow, Ui_spec_window):
136
136
  data_path = fd[:-4]
137
137
  fd = data_path + "_data.csv"
138
138
  _df = pd.DataFrame(
139
- data=np.array([[f"{value:.2f}" for value in self.spectrum[1]], self.spectrum[0]]).T,
139
+ data=np.array([self.spectrum[1], self.spectrum[0]]).T,
140
140
  columns=["frequency", "power"])
141
141
  _df.to_csv(fd, index=False)
142
142
  except OSError:
@@ -166,7 +166,7 @@ class SpecWindow(QMainWindow, Ui_spec_window):
166
166
  _df = pd.DataFrame(
167
167
  self.spectrogram[2].T,
168
168
  index=[f"{value:.2f}" for value in self.spectrogram[1]],
169
- columns=[f"{value:.2f}" for value in self.spectrogram[0]])
169
+ columns=self.spectrogram[0])
170
170
  _df.to_csv(fd, index=True)
171
171
  except OSError:
172
172
  QMessageBox.about(
@@ -58,14 +58,14 @@
58
58
  <item>
59
59
  <widget class="QLabel" name="VersionLabel">
60
60
  <property name="text">
61
- <string>Version: 0.3.2</string>
61
+ <string>Version: 0.3.4</string>
62
62
  </property>
63
63
  </widget>
64
64
  </item>
65
65
  <item>
66
66
  <widget class="QLabel" name="UpdateLabel">
67
67
  <property name="text">
68
- <string>Update: 2026/08/24</string>
68
+ <string>Update: 2026/09/30</string>
69
69
  </property>
70
70
  </widget>
71
71
  </item>
@@ -73,8 +73,8 @@ class Ui_AboutDialog(object):
73
73
  AboutDialog.setWindowTitle(QCoreApplication.translate("AboutDialog", u"About", None))
74
74
  self.label_4.setText(QCoreApplication.translate("AboutDialog", u"<html><head/><body><p><img src=\":/logo/entire_logo.png\"/></p></body></html>", None))
75
75
  self.groupBox.setTitle(QCoreApplication.translate("AboutDialog", u"MiSleep", None))
76
- self.VersionLabel.setText(QCoreApplication.translate("AboutDialog", u"Version: 0.3.2", None))
77
- self.UpdateLabel.setText(QCoreApplication.translate("AboutDialog", u"Update: 2026/08/24", None))
76
+ self.VersionLabel.setText(QCoreApplication.translate("AboutDialog", u"Version: 0.3.4", None))
77
+ self.UpdateLabel.setText(QCoreApplication.translate("AboutDialog", u"Update: 2026/09/30", None))
78
78
  self.CopyrightLabel.setText(QCoreApplication.translate("AboutDialog", u"Copyright \u00a9 2023\u20132026 Xueqiang Wang.", None))
79
79
  # retranslateUi
80
80
 
@@ -9,7 +9,58 @@ from scipy.signal import stft, welch
9
9
  from misleep.preprocessing.filtering import signal_filter
10
10
 
11
11
 
12
- def spectrum(signal, sf, band=None, relative=True, win_sec=1, nfft=None, gaussian_sigma=None):
12
+ def _select_frequency_band(freq, power, band, frequency_step=None):
13
+ """Crop on the true FFT axis and interpolate exact band endpoints.
14
+
15
+ Fractional sample rates and integer FFT lengths need not place a bin
16
+ exactly on the requested limits. Never round bins before selecting them
17
+ or relabel an out-of-band bin as the boundary. Interpolate power between
18
+ the two surrounding bins instead, without extrapolating beyond the axis.
19
+ The frequency dimension is the first axis of ``power``.
20
+ With ``frequency_step``, interpolate onto a regular zero-anchored grid
21
+ (e.g. 0.5, 0.6, ..., 30 Hz for a ten-second FFT setting).
22
+ """
23
+ low, high = band
24
+ if frequency_step is not None:
25
+ if not np.isfinite(frequency_step) or frequency_step <= 0:
26
+ raise ValueError("frequency_step must be finite and positive")
27
+ low, high = max(low, freq[0]), min(high, freq[-1])
28
+ if low > high:
29
+ return freq[:0], power[:0]
30
+ first = int(np.ceil(low / frequency_step))
31
+ last = int(np.floor(high / frequency_step))
32
+ grid = np.round(np.arange(first, last + 1) * frequency_step, 12)
33
+ grid = grid[(grid > low) & (grid < high)]
34
+ grid = np.unique(np.concatenate(([low], grid, [high])))
35
+ right = np.clip(np.searchsorted(freq, grid), 1, len(freq) - 1)
36
+ left = right - 1
37
+ weight = (grid - freq[left]) / (freq[right] - freq[left])
38
+ weight = weight.reshape((-1,) + (1,) * (power.ndim - 1))
39
+ values = power[left] + weight * (power[right] - power[left])
40
+ return grid, values
41
+ selected = (freq >= low) & (freq <= high)
42
+ result_freq = freq[selected]
43
+ result_power = power[selected]
44
+ for boundary, prepend in ((low, True), (high, False)):
45
+ if not freq[0] <= boundary <= freq[-1]:
46
+ continue
47
+ if np.any(result_freq == boundary):
48
+ continue
49
+ right = np.searchsorted(freq, boundary)
50
+ left = right - 1
51
+ weight = (boundary - freq[left]) / (freq[right] - freq[left])
52
+ value = power[left] + weight * (power[right] - power[left])
53
+ if prepend:
54
+ result_freq = np.concatenate(([boundary], result_freq))
55
+ result_power = np.concatenate((value[np.newaxis], result_power))
56
+ else:
57
+ result_freq = np.concatenate((result_freq, [boundary]))
58
+ result_power = np.concatenate((result_power, value[np.newaxis]))
59
+ return result_freq, result_power
60
+
61
+
62
+ def spectrum(signal, sf, band=None, relative=True, win_sec=1, nfft=None, gaussian_sigma=None,
63
+ frequency_step=None):
13
64
  """Calculate the (Welch) power spectrum of a signal.
14
65
 
15
66
  The signal is band-pass filtered to ``band`` first, then the power
@@ -31,6 +82,8 @@ def spectrum(signal, sf, band=None, relative=True, win_sec=1, nfft=None, gaussia
31
82
  Number of FFT points.
32
83
  gaussian_sigma : float, optional
33
84
  Sigma for Gaussian smoothing of the PSD.
85
+ frequency_step : float, optional
86
+ Interpolate onto this regular frequency grid for display/export.
34
87
 
35
88
  Returns
36
89
  -------
@@ -54,21 +107,19 @@ def spectrum(signal, sf, band=None, relative=True, win_sec=1, nfft=None, gaussia
54
107
  signal, _ = signal_filter(data=signal, sf=sf, btype="bandpass", low=band[0], high=band[1])
55
108
 
56
109
  freq, psd = welch(signal, sf, nperseg=int(sf * win_sec), nfft=nfft, scaling="density")
57
- freq = np.array([round(each, 2) for each in freq])
58
110
  psd = gaussian_filter1d(psd, sigma=gaussian_sigma) if gaussian_sigma is not None else psd
59
111
 
60
- idx_freq = np.logical_and(freq >= band[0], freq <= band[1])
61
- freq = freq[idx_freq]
62
- psd = psd[idx_freq]
112
+ freq, psd = _select_frequency_band(freq, psd, band, frequency_step)
63
113
 
64
- total_power = simpson(psd, dx=freq[1] - freq[0])
114
+ total_power = simpson(psd, x=freq)
65
115
  if relative and total_power > 0:
66
116
  psd /= total_power
67
117
 
68
118
  return freq, psd
69
119
 
70
120
 
71
- def spectrogram(signal, sf, band=None, step=0.2, win_sec=2, norm=False, nfft=None):
121
+ def spectrogram(signal, sf, band=None, step=0.2, win_sec=2, norm=False, nfft=None,
122
+ frequency_step=None):
72
123
  """Calculate the spectrogram of a signal with the STFT.
73
124
 
74
125
  Parameters
@@ -87,6 +138,8 @@ def spectrogram(signal, sf, band=None, step=0.2, win_sec=2, norm=False, nfft=Non
87
138
  Whether to normalize the power across frequencies at each time point.
88
139
  nfft : int, optional
89
140
  Number of FFT points.
141
+ frequency_step : float, optional
142
+ Interpolate power onto this regular frequency grid for display/export.
90
143
 
91
144
  Returns
92
145
  -------
@@ -120,12 +173,8 @@ def spectrogram(signal, sf, band=None, step=0.2, win_sec=2, norm=False, nfft=Non
120
173
  f, t, Sxx = stft(signal, sf, nperseg=nperseg, nfft=nfft,
121
174
  noverlap=noverlap, padded=False, boundary="zeros")
122
175
 
123
- f = np.array([round(each, 2) for each in f])
124
-
125
- idx_f = np.logical_and(f >= band[0], f <= band[1])
126
- f = f[idx_f]
127
- Sxx = Sxx[idx_f, :]
128
176
  Sxx = np.square(np.abs(Sxx))
177
+ f, Sxx = _select_frequency_band(f, Sxx, band, frequency_step)
129
178
 
130
179
  if norm:
131
180
  sum_power = Sxx.sum(0).reshape(1, -1)
@@ -153,14 +202,13 @@ def band_power(psd, freq, bands=None, relative=False):
153
202
  dict
154
203
  Band name -> band power.
155
204
  """
156
- freq_res = freq[1] - freq[0]
157
205
  band_dict = {}
158
206
  for each in bands:
159
- idx_band = np.logical_and(freq >= each[0], freq <= each[1])
160
- bp = simpson(psd[idx_band], dx=freq_res)
207
+ band_freq, band_psd = _select_frequency_band(freq, psd, each[:2])
208
+ bp = simpson(band_psd, x=band_freq)
161
209
 
162
210
  if relative:
163
- total = simpson(psd, dx=freq_res)
211
+ total = simpson(psd, x=freq)
164
212
  if total > 0:
165
213
  bp /= total
166
214
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: misleep
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: MiSleep: Mice sleep EEG/EMG visualization, scoring, and analysis
5
5
  Author-email: Xueqiang Wang <swang9194@gmail.com>
6
6
  Maintainer-email: Xueqiang Wang <swang9194@gmail.com>
@@ -78,6 +78,7 @@ Requires-Dist: lightgbm>=3.3; extra == "full"
78
78
  Provides-Extra: dev
79
79
  Requires-Dist: pytest>=7.0; extra == "dev"
80
80
  Requires-Dist: pytest-cov; extra == "dev"
81
+ Dynamic: license-file
81
82
 
82
83
  # MiSleep
83
84
 
@@ -96,6 +96,20 @@ def test_model_path():
96
96
  assert p.exists()
97
97
 
98
98
 
99
+ def test_benchmark_model_loads_through_numpy1_compat(monkeypatch):
100
+ """NumPy 1.x users can read the model serialized under NumPy 2.x."""
101
+ from misleep.analysis.autostage import benchmark
102
+
103
+ benchmark.load_models.cache_clear()
104
+ monkeypatch.setattr(benchmark.np, "__version__", "1.26.4")
105
+ models = benchmark.load_models()
106
+ assert set(models) == {
107
+ "eegf", "eegp", "eegf_emg", "eegp_emg",
108
+ "eegf_emg_acc", "eegp_emg_acc",
109
+ }
110
+ benchmark.load_models.cache_clear()
111
+
112
+
99
113
  def test_auto_stage_gbm():
100
114
  sf = 256.0
101
115
  eeg = make_signal(sf=sf, duration=100, seed=2)