misleep 0.3.3__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.3 → misleep-0.3.4}/CHANGELOG.md +34 -0
  2. {misleep-0.3.3/src/misleep.egg-info → misleep-0.3.4}/PKG-INFO +3 -2
  3. {misleep-0.3.3 → misleep-0.3.4}/docs/config_file.md +4 -4
  4. {misleep-0.3.3 → misleep-0.3.4}/pyproject.toml +1 -1
  5. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/__init__.py +1 -1
  6. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/autostage/benchmark.py +40 -1
  7. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/models/benchmark_models.pkl +1 -1
  8. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/config/default_config.ini +2 -2
  9. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/dialogs.py +6 -2
  10. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/main_window.py +9 -5
  11. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/qt_utils.py +6 -5
  12. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/spec_window.py +2 -2
  13. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/about.ui +2 -2
  14. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/about_ui.py +2 -2
  15. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/preprocessing/spectral.py +64 -16
  16. {misleep-0.3.3 → misleep-0.3.4/src/misleep.egg-info}/PKG-INFO +3 -2
  17. {misleep-0.3.3 → misleep-0.3.4}/tests/test_analysis.py +14 -0
  18. {misleep-0.3.3 → misleep-0.3.4}/tests/test_gui.py +41 -1
  19. misleep-0.3.4/tests/test_preprocessing.py +170 -0
  20. misleep-0.3.3/tests/test_preprocessing.py +0 -95
  21. {misleep-0.3.3 → misleep-0.3.4}/LICENSE +0 -0
  22. {misleep-0.3.3 → misleep-0.3.4}/MANIFEST.in +0 -0
  23. {misleep-0.3.3 → misleep-0.3.4}/README.md +0 -0
  24. {misleep-0.3.3 → misleep-0.3.4}/docs/api_reference.md +0 -0
  25. {misleep-0.3.3 → misleep-0.3.4}/docs/data_formats.md +0 -0
  26. {misleep-0.3.3 → misleep-0.3.4}/docs/developer_guide.md +0 -0
  27. {misleep-0.3.3 → misleep-0.3.4}/docs/getting_started.md +0 -0
  28. {misleep-0.3.3 → misleep-0.3.4}/docs/imgs/entire_logo.png +0 -0
  29. {misleep-0.3.3 → misleep-0.3.4}/docs/imgs/gui/main_light.png +0 -0
  30. {misleep-0.3.3 → misleep-0.3.4}/docs/imgs/logo.png +0 -0
  31. {misleep-0.3.3 → misleep-0.3.4}/docs/index.md +0 -0
  32. {misleep-0.3.3 → misleep-0.3.4}/docs/user_guide.md +0 -0
  33. {misleep-0.3.3 → misleep-0.3.4}/examples/auto_stage_example.py +0 -0
  34. {misleep-0.3.3 → misleep-0.3.4}/examples/basic_usage.py +0 -0
  35. {misleep-0.3.3 → misleep-0.3.4}/examples/extend_io_example.py +0 -0
  36. {misleep-0.3.3 → misleep-0.3.4}/setup.cfg +0 -0
  37. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/__main__.py +0 -0
  38. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/_compat.py +0 -0
  39. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/__init__.py +0 -0
  40. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/auto_stage.py +0 -0
  41. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/autostage/__init__.py +0 -0
  42. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/autostage/features.py +0 -0
  43. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/autostage/hmm.py +0 -0
  44. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/autostage/model.py +0 -0
  45. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/autostage/postprocess.py +0 -0
  46. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/detection.py +0 -0
  47. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/features.py +0 -0
  48. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/models/__init__.py +0 -0
  49. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/transformer/__init__.py +0 -0
  50. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
  51. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/transformer/checkpoints/__init__.py +0 -0
  52. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/transformer/configs.py +0 -0
  53. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/transformer/data.py +0 -0
  54. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/transformer/inference.py +0 -0
  55. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/transformer/models.py +0 -0
  56. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/analysis/transformer/preprocessing.py +0 -0
  57. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/config/__init__.py +0 -0
  58. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/data/__init__.py +0 -0
  59. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/data/annotation.py +0 -0
  60. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/data/midata.py +0 -0
  61. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/__init__.py +0 -0
  62. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/app.py +0 -0
  63. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/config_dialog.py +0 -0
  64. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/event_filters.py +0 -0
  65. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/resources/__init__.py +0 -0
  66. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/resources/entire_logo.png +0 -0
  67. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/resources/logo.png +0 -0
  68. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/resources/misleep.ico +0 -0
  69. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/resources/misleep.qrc +0 -0
  70. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/resources/misleep_rc.py +0 -0
  71. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/style.py +0 -0
  72. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/SWA_detect_dialog.ui +0 -0
  73. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/SWA_detect_dialog_ui.py +0 -0
  74. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/__init__.py +0 -0
  75. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +0 -0
  76. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +0 -0
  77. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/auto_stage_lightGBM_dialog.ui +0 -0
  78. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +0 -0
  79. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/horizontal_line_dialog.ui +0 -0
  80. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/horizontal_line_dialog_ui.py +0 -0
  81. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/label_dialog.ui +0 -0
  82. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/label_dialog_ui.py +0 -0
  83. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/main_window.ui +0 -0
  84. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/main_window_ui.py +0 -0
  85. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/save_data_dialog.ui +0 -0
  86. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/save_data_dialog_ui.py +0 -0
  87. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/spec_window.ui +0 -0
  88. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/spec_window_ui.py +0 -0
  89. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/spindle_detect_dialog.ui +0 -0
  90. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/spindle_detect_dialog_ui.py +0 -0
  91. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/state_spectral_dialog.ui +0 -0
  92. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/state_spectral_dialog_ui.py +0 -0
  93. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/transfer_result_dialog.ui +0 -0
  94. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/uis/transfer_result_dialog_ui.py +0 -0
  95. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/gui/workers.py +0 -0
  96. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/io/__init__.py +0 -0
  97. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/io/annotation.py +0 -0
  98. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/io/array.py +0 -0
  99. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/io/base.py +0 -0
  100. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/io/edf.py +0 -0
  101. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/io/mat.py +0 -0
  102. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/logger.py +0 -0
  103. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/preprocessing/__init__.py +0 -0
  104. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/preprocessing/artifacts.py +0 -0
  105. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/preprocessing/filtering.py +0 -0
  106. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/preprocessing/segment.py +0 -0
  107. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/utils/__init__.py +0 -0
  108. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/utils/annotation.py +0 -0
  109. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/utils/entropy.py +0 -0
  110. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/utils/misc.py +0 -0
  111. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/utils/time_utils.py +0 -0
  112. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/viz/__init__.py +0 -0
  113. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/viz/hypnogram.py +0 -0
  114. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/viz/signals.py +0 -0
  115. {misleep-0.3.3 → misleep-0.3.4}/src/misleep/viz/spectral.py +0 -0
  116. {misleep-0.3.3 → misleep-0.3.4}/src/misleep.egg-info/SOURCES.txt +0 -0
  117. {misleep-0.3.3 → misleep-0.3.4}/src/misleep.egg-info/dependency_links.txt +0 -0
  118. {misleep-0.3.3 → misleep-0.3.4}/src/misleep.egg-info/entry_points.txt +0 -0
  119. {misleep-0.3.3 → misleep-0.3.4}/src/misleep.egg-info/requires.txt +0 -0
  120. {misleep-0.3.3 → misleep-0.3.4}/src/misleep.egg-info/top_level.txt +0 -0
  121. {misleep-0.3.3 → misleep-0.3.4}/tests/test_data.py +0 -0
  122. {misleep-0.3.3 → misleep-0.3.4}/tests/test_io.py +0 -0
  123. {misleep-0.3.3 → misleep-0.3.4}/tests/test_segment.py +0 -0
  124. {misleep-0.3.3 → 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.3
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.3"
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.3/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"}
@@ -449,6 +449,8 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
449
449
  nfft = int(self.nfftSpinBox.value() * sf) if self.nfftCheckBox.isChecked() else None
450
450
  if nfft is not None and nfft < nperseg:
451
451
  nfft = None
452
+ frequency_step = 1.0 / (
453
+ self.nfftSpinBox.value() if nfft is not None else win_length)
452
454
 
453
455
  gaussian_sigma = self.GaussianSpinBox.value() if self.GaussianCheckBox.isChecked() else None
454
456
 
@@ -472,7 +474,8 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
472
474
  spectra = {
473
475
  state: cal_draw_spectrum(data=data, sf=sf, nperseg=nperseg,
474
476
  freq_band=freq_band, relative=relative,
475
- nfft=nfft, gaussian_sigma=gaussian_sigma)
477
+ nfft=nfft, gaussian_sigma=gaussian_sigma,
478
+ frequency_step=frequency_step)
476
479
  for state, data in state_data.items()
477
480
  }
478
481
 
@@ -492,7 +495,8 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
492
495
  power = cal_draw_spectrum(
493
496
  data=data, sf=sf, nperseg=nperseg,
494
497
  freq_band=freq_band, relative=relative, nfft=nfft,
495
- gaussian_sigma=gaussian_sigma)[0][1]
498
+ gaussian_sigma=gaussian_sigma,
499
+ frequency_step=frequency_step)[0][1]
496
500
  hour_spec[state].append(power)
497
501
  else:
498
502
  hour_spec[state].append(None)
@@ -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.3
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)
@@ -21,7 +21,7 @@ def _pyside6_available():
21
21
  def test_public_api():
22
22
  import misleep
23
23
 
24
- assert misleep.__version__ == "0.3.2"
24
+ assert misleep.__version__ == "0.3.4"
25
25
  for name in [
26
26
  "MiData",
27
27
  "MiAnnotation",
@@ -766,6 +766,46 @@ def test_data_export_always_restores_main_window(tmp_path, monkeypatch):
766
766
  app.processEvents()
767
767
 
768
768
 
769
+ @pytest.mark.skipif(not _pyside6_available(), reason="PySide6 not installed")
770
+ @pytest.mark.parametrize("use_nfft", [False, True])
771
+ def test_state_spectral_export_regular_frequencies(
772
+ fresh_config, tmp_path, monkeypatch, use_nfft):
773
+ """The real Excel export uses the nominal FFT grid at fractional sf."""
774
+ import numpy as np
775
+ import pandas as pd
776
+ from PySide6.QtWidgets import QApplication, QFileDialog
777
+ from misleep.data import MiAnnotation, MiData
778
+ from misleep.gui.dialogs import StateSpectralDialog
779
+
780
+ app = QApplication.instance() or QApplication([])
781
+ sf = 100.23
782
+ data = MiData(
783
+ signals=[np.random.default_rng(21).standard_normal(int(sf * 60))],
784
+ channels=["EEG"], sf=[sf], time="20240409-18:00:00")
785
+ annotation = MiAnnotation([1] * 60, state_map={1: "NREM"})
786
+ fresh_config["gui"]["openpath"] = "fractional.mat"
787
+ monkeypatch.setattr(QFileDialog, "getExistingDirectory",
788
+ staticmethod(lambda *args, **kwargs: str(tmp_path)))
789
+ dialog = StateSpectralDialog(config=fresh_config)
790
+ dialog.dialog_show(data.channels)
791
+ dialog.BPFilterCheckBox.setChecked(False)
792
+ dialog.RejectArtifactCheckBox.setChecked(False)
793
+ dialog.HourSegmentCheckBox.setChecked(False)
794
+ dialog.nfftCheckBox.setChecked(use_nfft)
795
+ dialog.nfftSpinBox.setValue(20)
796
+ dialog.BPLow.setValue(0.5)
797
+ dialog.BPHigh.setValue(30)
798
+ assert dialog.spectral_analysis(data, annotation, fresh_config) is True
799
+ exported = pd.read_excel(tmp_path / "fractional_power_results.xlsx")
800
+ step = 0.05 if use_nfft else 0.1
801
+ expected = np.round(np.arange(round(0.5 / step), round(30 / step) + 1) * step, 12)
802
+ np.testing.assert_allclose(exported.frequency, expected, rtol=0, atol=1e-12)
803
+ assert exported.frequency.iloc[-1] == 30
804
+ assert (exported.power >= 0).all()
805
+ dialog.close()
806
+ app.processEvents()
807
+
808
+
769
809
  @pytest.mark.skipif(not _pyside6_available(), reason="PySide6 not installed")
770
810
  def test_state_spectral_hour_columns_preserve_empty_zt_slots(
771
811
  fresh_config, tmp_path, monkeypatch):