misleep 0.3.1__tar.gz → 0.3.3__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 (123) hide show
  1. {misleep-0.3.1 → misleep-0.3.3}/CHANGELOG.md +16 -0
  2. {misleep-0.3.1/src/misleep.egg-info → misleep-0.3.3}/PKG-INFO +1 -1
  3. {misleep-0.3.1 → misleep-0.3.3}/docs/config_file.md +4 -4
  4. {misleep-0.3.1 → misleep-0.3.3}/pyproject.toml +1 -1
  5. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/__init__.py +1 -1
  6. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/models/benchmark_models.pkl +1 -1
  7. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/config/default_config.ini +2 -2
  8. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/data/midata.py +15 -5
  9. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/dialogs.py +50 -20
  10. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/about.ui +2 -2
  11. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/about_ui.py +2 -2
  12. {misleep-0.3.1 → misleep-0.3.3/src/misleep.egg-info}/PKG-INFO +1 -1
  13. {misleep-0.3.1 → misleep-0.3.3}/tests/test_data.py +53 -0
  14. {misleep-0.3.1 → misleep-0.3.3}/tests/test_gui.py +60 -1
  15. {misleep-0.3.1 → misleep-0.3.3}/LICENSE +0 -0
  16. {misleep-0.3.1 → misleep-0.3.3}/MANIFEST.in +0 -0
  17. {misleep-0.3.1 → misleep-0.3.3}/README.md +0 -0
  18. {misleep-0.3.1 → misleep-0.3.3}/docs/api_reference.md +0 -0
  19. {misleep-0.3.1 → misleep-0.3.3}/docs/data_formats.md +0 -0
  20. {misleep-0.3.1 → misleep-0.3.3}/docs/developer_guide.md +0 -0
  21. {misleep-0.3.1 → misleep-0.3.3}/docs/getting_started.md +0 -0
  22. {misleep-0.3.1 → misleep-0.3.3}/docs/imgs/entire_logo.png +0 -0
  23. {misleep-0.3.1 → misleep-0.3.3}/docs/imgs/gui/main_light.png +0 -0
  24. {misleep-0.3.1 → misleep-0.3.3}/docs/imgs/logo.png +0 -0
  25. {misleep-0.3.1 → misleep-0.3.3}/docs/index.md +0 -0
  26. {misleep-0.3.1 → misleep-0.3.3}/docs/user_guide.md +0 -0
  27. {misleep-0.3.1 → misleep-0.3.3}/examples/auto_stage_example.py +0 -0
  28. {misleep-0.3.1 → misleep-0.3.3}/examples/basic_usage.py +0 -0
  29. {misleep-0.3.1 → misleep-0.3.3}/examples/extend_io_example.py +0 -0
  30. {misleep-0.3.1 → misleep-0.3.3}/setup.cfg +0 -0
  31. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/__main__.py +0 -0
  32. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/_compat.py +0 -0
  33. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/__init__.py +0 -0
  34. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/auto_stage.py +0 -0
  35. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/autostage/__init__.py +0 -0
  36. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/autostage/benchmark.py +0 -0
  37. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/autostage/features.py +0 -0
  38. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/autostage/hmm.py +0 -0
  39. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/autostage/model.py +0 -0
  40. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/autostage/postprocess.py +0 -0
  41. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/detection.py +0 -0
  42. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/features.py +0 -0
  43. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/models/__init__.py +0 -0
  44. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/transformer/__init__.py +0 -0
  45. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
  46. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/transformer/checkpoints/__init__.py +0 -0
  47. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/transformer/configs.py +0 -0
  48. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/transformer/data.py +0 -0
  49. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/transformer/inference.py +0 -0
  50. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/transformer/models.py +0 -0
  51. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/analysis/transformer/preprocessing.py +0 -0
  52. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/config/__init__.py +0 -0
  53. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/data/__init__.py +0 -0
  54. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/data/annotation.py +0 -0
  55. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/__init__.py +0 -0
  56. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/app.py +0 -0
  57. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/config_dialog.py +0 -0
  58. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/event_filters.py +0 -0
  59. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/main_window.py +0 -0
  60. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/qt_utils.py +0 -0
  61. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/resources/__init__.py +0 -0
  62. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/resources/entire_logo.png +0 -0
  63. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/resources/logo.png +0 -0
  64. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/resources/misleep.ico +0 -0
  65. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/resources/misleep.qrc +0 -0
  66. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/resources/misleep_rc.py +0 -0
  67. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/spec_window.py +0 -0
  68. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/style.py +0 -0
  69. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/SWA_detect_dialog.ui +0 -0
  70. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/SWA_detect_dialog_ui.py +0 -0
  71. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/__init__.py +0 -0
  72. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +0 -0
  73. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +0 -0
  74. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/auto_stage_lightGBM_dialog.ui +0 -0
  75. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +0 -0
  76. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/horizontal_line_dialog.ui +0 -0
  77. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/horizontal_line_dialog_ui.py +0 -0
  78. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/label_dialog.ui +0 -0
  79. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/label_dialog_ui.py +0 -0
  80. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/main_window.ui +0 -0
  81. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/main_window_ui.py +0 -0
  82. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/save_data_dialog.ui +0 -0
  83. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/save_data_dialog_ui.py +0 -0
  84. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/spec_window.ui +0 -0
  85. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/spec_window_ui.py +0 -0
  86. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/spindle_detect_dialog.ui +0 -0
  87. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/spindle_detect_dialog_ui.py +0 -0
  88. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/state_spectral_dialog.ui +0 -0
  89. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/state_spectral_dialog_ui.py +0 -0
  90. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/transfer_result_dialog.ui +0 -0
  91. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/uis/transfer_result_dialog_ui.py +0 -0
  92. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/gui/workers.py +0 -0
  93. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/io/__init__.py +0 -0
  94. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/io/annotation.py +0 -0
  95. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/io/array.py +0 -0
  96. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/io/base.py +0 -0
  97. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/io/edf.py +0 -0
  98. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/io/mat.py +0 -0
  99. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/logger.py +0 -0
  100. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/preprocessing/__init__.py +0 -0
  101. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/preprocessing/artifacts.py +0 -0
  102. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/preprocessing/filtering.py +0 -0
  103. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/preprocessing/segment.py +0 -0
  104. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/preprocessing/spectral.py +0 -0
  105. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/utils/__init__.py +0 -0
  106. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/utils/annotation.py +0 -0
  107. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/utils/entropy.py +0 -0
  108. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/utils/misc.py +0 -0
  109. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/utils/time_utils.py +0 -0
  110. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/viz/__init__.py +0 -0
  111. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/viz/hypnogram.py +0 -0
  112. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/viz/signals.py +0 -0
  113. {misleep-0.3.1 → misleep-0.3.3}/src/misleep/viz/spectral.py +0 -0
  114. {misleep-0.3.1 → misleep-0.3.3}/src/misleep.egg-info/SOURCES.txt +0 -0
  115. {misleep-0.3.1 → misleep-0.3.3}/src/misleep.egg-info/dependency_links.txt +0 -0
  116. {misleep-0.3.1 → misleep-0.3.3}/src/misleep.egg-info/entry_points.txt +0 -0
  117. {misleep-0.3.1 → misleep-0.3.3}/src/misleep.egg-info/requires.txt +0 -0
  118. {misleep-0.3.1 → misleep-0.3.3}/src/misleep.egg-info/top_level.txt +0 -0
  119. {misleep-0.3.1 → misleep-0.3.3}/tests/test_analysis.py +0 -0
  120. {misleep-0.3.1 → misleep-0.3.3}/tests/test_io.py +0 -0
  121. {misleep-0.3.1 → misleep-0.3.3}/tests/test_preprocessing.py +0 -0
  122. {misleep-0.3.1 → misleep-0.3.3}/tests/test_segment.py +0 -0
  123. {misleep-0.3.1 → misleep-0.3.3}/tests/test_viz_config.py +0 -0
@@ -5,6 +5,22 @@ 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
+ ## [0.3.2] — 2026-08-24
9
+
10
+ ### Fixed
11
+
12
+ - **Filtering broke on fractional sampling frequencies**: `MiData.add()` (used
13
+ by `MiData.filter()`) recomputed the duration with
14
+ `math.floor(len(signal) / sf)` from a signal that had already been
15
+ truncated to `int(duration * sf)` samples. When `sf` is a decimal (e.g.
16
+ `99.9927` Hz), that came out one second short, so filtering either raised a
17
+ spurious "new signal is shorter" error or produced a channel whose length /
18
+ duration did not match the original signal, which then failed the signal /
19
+ label consistency checks in the GUI. The check now compares sample counts
20
+ against `int(duration * sf)` directly, so filtering preserves the sample
21
+ count and duration for any positive `sf` while still rejecting genuinely
22
+ short signals.
23
+
8
24
  ## [0.3.1] — 2026-08-18
9
25
 
10
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: misleep
3
- Version: 0.3.1
3
+ Version: 0.3.3
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>
@@ -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.1` | displayed in the About dialog |
27
- | `updatetime` | `2026/08/18` | displayed in the About dialog |
26
+ | `version` | `v0.3.2` | displayed in the About dialog |
27
+ | `updatetime` | `2026/08/24` | 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.1
52
- updatetime = 2026/08/18
51
+ version = v0.3.2
52
+ updatetime = 2026/08/24
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.1"
7
+ version = "0.3.3"
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.1"
25
+ __version__ = "0.3.2"
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
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
- name: misleep-0.3.1/src/misleep/analysis/models/benchmark_models.pkl
2
+ name: misleep-0.3.3/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.1
19
- updatetime = 2026/08/18
18
+ version = v0.3.2
19
+ updatetime = 2026/08/24
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"}
@@ -210,15 +210,25 @@ class MiData:
210
210
  if not isinstance(sf, (int, float)) or not math.isfinite(sf) or sf <= 0:
211
211
  raise TypeError(f"Sample frequency should be a float, got {type(sf)}")
212
212
 
213
- _duration = math.floor(len(signal) / sf)
214
- if _duration < self._duration:
213
+ # Every channel shares the same integer duration: the sample count a
214
+ # channel of ``sf`` Hz needs to cover ``self._duration`` seconds is
215
+ # ``int(self._duration * sf)`` (the same truncation used in
216
+ # :meth:`__init__`). Compare sample counts directly instead of
217
+ # re-deriving the duration from an already-truncated length -- with a
218
+ # fractional sampling frequency (e.g. 99.9927 Hz) the naive
219
+ # ``math.floor(len(signal) / sf)`` can come out one second short and
220
+ # wrongly reject signals that are exactly as long as the existing
221
+ # channels (this made ``MiData.filter()`` fail on fractional-sf data).
222
+ expected_samples = int(self._duration * sf)
223
+ if len(signal) < expected_samples:
215
224
  raise ValueError(
216
- f"The new signal is shorter ({_duration}s) than the existing data "
217
- f"({self._duration}s)")
225
+ f"The new signal is shorter ({len(signal)} samples) than the "
226
+ f"existing data ({self._duration}s, {expected_samples} samples "
227
+ f"at {sf} Hz)")
218
228
 
219
229
  channel = _unique_name(channel, self.channels)
220
230
 
221
- self._signals.append(signal[:int(self._duration * sf)])
231
+ self._signals.append(signal[:expected_samples])
222
232
  self._channels.append(channel)
223
233
  self._n_channels = len(self._channels)
224
234
  self._sf.append(sf)
@@ -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
@@ -422,7 +452,7 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
422
452
 
423
453
  gaussian_sigma = self.GaussianSpinBox.value() if self.GaussianCheckBox.isChecked() else None
424
454
 
425
- state_codes = sorted(set(mianno.sleep_state[start_sec:end_sec + 1]))
455
+ state_codes = sorted(set(sleep_state_values))
426
456
  state_data = {
427
457
  state: np.concatenate([
428
458
  channel_data[int(each[0] * sf): int(each[1] * sf)]
@@ -451,21 +481,21 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
451
481
  # Optional per-hour spectral segmentation
452
482
  hour_spec = {state: [] for state in spectra}
453
483
  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(
484
+ hourly_data = _split_state_data_by_hour(
485
+ channel_data, sleep_state_values, spectra, sf)
486
+ for state, state_hours in hourly_data.items():
487
+ for data in state_hours:
488
+ if data is not None and self.RejectArtifactCheckBox.isChecked():
489
+ data = reject_artifact(
490
+ data, sf=sf, threshold=threshold)
491
+ if data is not None and len(data) > sf * 10:
492
+ power = cal_draw_spectrum(
466
493
  data=data, sf=sf, nperseg=nperseg,
467
494
  freq_band=freq_band, relative=relative, nfft=nfft,
468
- gaussian_sigma=gaussian_sigma)[0][1])
495
+ gaussian_sigma=gaussian_sigma)[0][1]
496
+ hour_spec[state].append(power)
497
+ else:
498
+ hour_spec[state].append(None)
469
499
 
470
500
  fd = QFileDialog.getExistingDirectory(self, "Select a folder to save states' data",
471
501
  f"{config['gui']['openpath']}")
@@ -485,10 +515,10 @@ class StateSpectralDialog(QDialog, Ui_StateSpectralDialog):
485
515
  figure.savefig(fd + "/" + safe_name + "_spectrum.pdf")
486
516
  _df = pd.DataFrame(
487
517
  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
518
+ for hour_idx, hour_power in enumerate(hour_spec[state]):
519
+ column = f"ZT{hour_idx}"
520
+ _df[column] = np.nan if hour_power is None \
521
+ else pd.Series(hour_power)
492
522
  _df.to_excel(
493
523
  excel_writer=writer, sheet_name=safe_name[:31],
494
524
  index=False)
@@ -58,14 +58,14 @@
58
58
  <item>
59
59
  <widget class="QLabel" name="VersionLabel">
60
60
  <property name="text">
61
- <string>Version: 0.3.1</string>
61
+ <string>Version: 0.3.2</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/18</string>
68
+ <string>Update: 2026/08/24</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.1", None))
77
- self.UpdateLabel.setText(QCoreApplication.translate("AboutDialog", u"Update: 2026/08/18", 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))
78
78
  self.CopyrightLabel.setText(QCoreApplication.translate("AboutDialog", u"Copyright \u00a9 2023\u20132026 Xueqiang Wang.", None))
79
79
  # retranslateUi
80
80
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: misleep
3
- Version: 0.3.1
3
+ Version: 0.3.3
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>
@@ -88,6 +88,59 @@ def test_midata_filter(midata):
88
88
  assert midata.channels[-1].startswith("EEG_bandpass")
89
89
 
90
90
 
91
+ @pytest.mark.parametrize("sf", [99.9927, 100.5, 256.256, 305.005])
92
+ def test_midata_filter_fractional_sf_preserves_length(sf):
93
+ """Filtering must not change the sample count or duration when sf is fractional.
94
+
95
+ Regression: ``MiData.add`` re-derived the duration with
96
+ ``math.floor(len(signal) / sf)`` from a signal already truncated to
97
+ ``int(duration * sf)`` samples; with a fractional sf that could come out
98
+ one second short and made ``filter()`` raise a spurious error (or produce
99
+ a shorter channel).
100
+ """
101
+ rng = np.random.default_rng(42)
102
+ n_raw = int(1000 * sf)
103
+ md = MiData(signals=[rng.standard_normal(n_raw),
104
+ rng.standard_normal(n_raw)],
105
+ channels=["EEG", "EMG"], sf=[sf, sf], time="20240101-00:00:00")
106
+ duration, lengths = md.duration, [len(s) for s in md.signals]
107
+
108
+ md.filter(chans=["EEG"], btype="bandpass", low=0.5, high=30)
109
+
110
+ assert md.n_channels == 3
111
+ assert md.duration == duration
112
+ assert len(md.signals) == 3
113
+ assert all(len(sig) == length for sig, length in zip(md.signals, lengths))
114
+
115
+
116
+ def test_midata_add_fractional_sf():
117
+ """A channel with ``int(duration * sf)`` samples must be accepted."""
118
+ sf = 99.9927
119
+ md = MiData(signals=[np.zeros(int(1000 * sf)), np.zeros(int(1000 * sf))],
120
+ channels=["EEG", "EMG"], sf=[sf, sf], time="20240101-00:00:00")
121
+ n = md.n_channels
122
+ duration, expected = md.duration, int(md.duration * sf)
123
+
124
+ # Exactly as many samples as the existing channels -> accepted as-is
125
+ md.add(np.zeros(expected), "EEG2", sf)
126
+ assert md.n_channels == n + 1
127
+ assert len(md.signals[-1]) == expected
128
+ assert md.duration == duration # duration unchanged by add()
129
+
130
+ # A genuinely shorter signal is still rejected
131
+ with pytest.raises(ValueError):
132
+ md.add(np.zeros(expected - 10), "TOO_SHORT", sf)
133
+
134
+
135
+ def test_midata_add_fractional_sf_rejects_short_signal():
136
+ """Fractional-sf channels that are truly too short must still be rejected."""
137
+ sf = 256.256
138
+ md = MiData(signals=[np.zeros(int(1000 * sf)), np.zeros(int(1000 * sf))],
139
+ channels=["EEG", "EMG"], sf=[sf, sf], time="20240101-00:00:00")
140
+ with pytest.raises(ValueError):
141
+ md.add(np.zeros(500 * 256), "TOO_SHORT", 256.0)
142
+
143
+
91
144
  def test_midata_differential(midata):
92
145
  midata.differential(chan1="EEG", chan2="EMG")
93
146
  assert midata.channels[-1] == "EEG_EMG_DIFF"
@@ -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.1"
24
+ assert misleep.__version__ == "0.3.2"
25
25
  for name in [
26
26
  "MiData",
27
27
  "MiAnnotation",
@@ -766,6 +766,65 @@ 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
+ def test_state_spectral_hour_columns_preserve_empty_zt_slots(
771
+ fresh_config, tmp_path, monkeypatch):
772
+ """Hourly state spectra stay aligned to ZT0..ZTn despite empty hours."""
773
+ import numpy as np
774
+ import pandas as pd
775
+ from PySide6.QtWidgets import QApplication, QFileDialog
776
+
777
+ app = QApplication.instance() or QApplication([])
778
+ from misleep.data import MiAnnotation, MiData
779
+ from misleep.gui.dialogs import StateSpectralDialog
780
+
781
+ class FakeFigure:
782
+ def savefig(self, *args, **kwargs):
783
+ pass
784
+
785
+ def clear(self):
786
+ pass
787
+
788
+ def fake_spectrum(data, **kwargs):
789
+ return np.array([[1.0, 2.0], [len(data), float(np.mean(data))]]), \
790
+ FakeFigure()
791
+
792
+ monkeypatch.setattr(
793
+ "misleep.gui.dialogs.cal_draw_spectrum", fake_spectrum)
794
+ monkeypatch.setattr(
795
+ QFileDialog, "getExistingDirectory",
796
+ staticmethod(lambda *args, **kwargs: str(tmp_path)))
797
+
798
+ # State 1 is absent from ZT1; state 2 is absent from ZT0 and ZT2.
799
+ states = [1] * 3600 + [2] * 3600 + [1] * 3600
800
+ midata = MiData(
801
+ signals=[np.arange(len(states), dtype=float)],
802
+ channels=["EEG"], sf=[1.0], time="20240409-18:00:00")
803
+ mianno = MiAnnotation(states, state_map={1: "NREM", 2: "REM"})
804
+ fresh_config["gui"]["openpath"] = "recording.mat"
805
+
806
+ dialog = StateSpectralDialog(config=fresh_config)
807
+ dialog.dialog_show(midata.channels)
808
+ dialog.BPFilterCheckBox.setChecked(False)
809
+ dialog.HourSegmentCheckBox.setChecked(True)
810
+ assert dialog.spectral_analysis(midata, mianno, fresh_config) is True
811
+
812
+ workbook = tmp_path / "recording_power_results.xlsx"
813
+ nrem = pd.read_excel(workbook, sheet_name="NREM")
814
+ rem = pd.read_excel(workbook, sheet_name="REM")
815
+ expected = ["frequency", "power", "ZT0", "ZT1", "ZT2"]
816
+ assert list(nrem.columns) == expected
817
+ assert list(rem.columns) == expected
818
+ assert nrem["ZT1"].isna().all()
819
+ assert rem["ZT0"].isna().all()
820
+ assert rem["ZT2"].isna().all()
821
+ assert nrem["ZT0"].notna().all() and nrem["ZT2"].notna().all()
822
+ assert rem["ZT1"].notna().all()
823
+
824
+ dialog.close()
825
+ app.processEvents()
826
+
827
+
769
828
  @pytest.mark.skipif(not _pyside6_available(), reason="PySide6 not installed")
770
829
  def test_gui_with_data(tmp_path):
771
830
  from PySide6.QtWidgets import QApplication
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes