misleep 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of misleep might be problematic. Click here for more details.

Files changed (94) hide show
  1. misleep/__init__.py +111 -0
  2. misleep/__main__.py +16 -0
  3. misleep/analysis/__init__.py +23 -0
  4. misleep/analysis/auto_stage.py +158 -0
  5. misleep/analysis/detection.py +198 -0
  6. misleep/analysis/features.py +131 -0
  7. misleep/analysis/models/P30_EEG_F_lightgbm.pkl +0 -0
  8. misleep/analysis/models/P30_EEG_P_lightgbm.pkl +0 -0
  9. misleep/analysis/models/__init__.py +0 -0
  10. misleep/analysis/models/ado_EEG_F_lightgbm.pkl +0 -0
  11. misleep/analysis/models/ado_EEG_P_lightgbm.pkl +0 -0
  12. misleep/analysis/models/adult_EEG_F_lightgbm.pkl +0 -0
  13. misleep/analysis/models/adult_EEG_P_lightgbm.pkl +0 -0
  14. misleep/analysis/transformer/__init__.py +46 -0
  15. misleep/analysis/transformer/checkpoints/CausalTransformer_best.pt +0 -0
  16. misleep/analysis/transformer/checkpoints/__init__.py +0 -0
  17. misleep/analysis/transformer/configs.py +52 -0
  18. misleep/analysis/transformer/data.py +362 -0
  19. misleep/analysis/transformer/inference.py +328 -0
  20. misleep/analysis/transformer/models.py +220 -0
  21. misleep/analysis/transformer/preprocessing.py +105 -0
  22. misleep/config/__init__.py +105 -0
  23. misleep/config/default_config.ini +38 -0
  24. misleep/data/__init__.py +13 -0
  25. misleep/data/annotation.py +114 -0
  26. misleep/data/midata.py +387 -0
  27. misleep/gui/__init__.py +23 -0
  28. misleep/gui/app.py +183 -0
  29. misleep/gui/config_dialog.py +429 -0
  30. misleep/gui/dialogs.py +1107 -0
  31. misleep/gui/event_filters.py +26 -0
  32. misleep/gui/main_window.py +2699 -0
  33. misleep/gui/qt_utils.py +277 -0
  34. misleep/gui/resources/__init__.py +9 -0
  35. misleep/gui/resources/entire_logo.png +0 -0
  36. misleep/gui/resources/logo.png +0 -0
  37. misleep/gui/resources/misleep.ico +0 -0
  38. misleep/gui/resources/misleep.qrc +7 -0
  39. misleep/gui/resources/misleep_rc.py +1324 -0
  40. misleep/gui/spec_window.py +187 -0
  41. misleep/gui/style.py +994 -0
  42. misleep/gui/uis/SWA_detect_dialog.ui +181 -0
  43. misleep/gui/uis/SWA_detect_dialog_ui.py +176 -0
  44. misleep/gui/uis/__init__.py +6 -0
  45. misleep/gui/uis/about.ui +88 -0
  46. misleep/gui/uis/about_ui.py +80 -0
  47. misleep/gui/uis/auto_stage_causalTransformer_dialog.ui +77 -0
  48. misleep/gui/uis/auto_stage_causalTransformer_dialog_ui.py +90 -0
  49. misleep/gui/uis/auto_stage_lightGBM_dialog.ui +124 -0
  50. misleep/gui/uis/auto_stage_lightGBM_dialog_ui.py +124 -0
  51. misleep/gui/uis/horizontal_line_dialog.ui +166 -0
  52. misleep/gui/uis/horizontal_line_dialog_ui.py +157 -0
  53. misleep/gui/uis/label_dialog.ui +65 -0
  54. misleep/gui/uis/label_dialog_ui.py +72 -0
  55. misleep/gui/uis/main_window.ui +871 -0
  56. misleep/gui/uis/main_window_ui.py +616 -0
  57. misleep/gui/uis/save_data_dialog.ui +95 -0
  58. misleep/gui/uis/save_data_dialog_ui.py +98 -0
  59. misleep/gui/uis/spec_window.ui +109 -0
  60. misleep/gui/uis/spec_window_ui.py +103 -0
  61. misleep/gui/uis/spindle_detect_dialog.ui +202 -0
  62. misleep/gui/uis/spindle_detect_dialog_ui.py +194 -0
  63. misleep/gui/uis/state_spectral_dialog.ui +240 -0
  64. misleep/gui/uis/state_spectral_dialog_ui.py +204 -0
  65. misleep/gui/uis/transfer_result_dialog.ui +104 -0
  66. misleep/gui/uis/transfer_result_dialog_ui.py +94 -0
  67. misleep/gui/workers.py +69 -0
  68. misleep/io/__init__.py +68 -0
  69. misleep/io/annotation.py +367 -0
  70. misleep/io/array.py +209 -0
  71. misleep/io/base.py +174 -0
  72. misleep/io/edf.py +109 -0
  73. misleep/io/mat.py +189 -0
  74. misleep/logger.py +79 -0
  75. misleep/preprocessing/__init__.py +23 -0
  76. misleep/preprocessing/artifacts.py +76 -0
  77. misleep/preprocessing/filtering.py +94 -0
  78. misleep/preprocessing/segment.py +59 -0
  79. misleep/preprocessing/spectral.py +169 -0
  80. misleep/utils/__init__.py +45 -0
  81. misleep/utils/annotation.py +130 -0
  82. misleep/utils/entropy.py +135 -0
  83. misleep/utils/misc.py +69 -0
  84. misleep/utils/time_utils.py +61 -0
  85. misleep/viz/__init__.py +12 -0
  86. misleep/viz/hypnogram.py +48 -0
  87. misleep/viz/signals.py +51 -0
  88. misleep/viz/spectral.py +98 -0
  89. misleep-0.3.0.dist-info/METADATA +249 -0
  90. misleep-0.3.0.dist-info/RECORD +94 -0
  91. misleep-0.3.0.dist-info/WHEEL +5 -0
  92. misleep-0.3.0.dist-info/entry_points.txt +15 -0
  93. misleep-0.3.0.dist-info/licenses/LICENSE +29 -0
  94. misleep-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,46 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Causal-transformer based automatic sleep staging.
3
+
4
+ This sub-module requires PyTorch (``pip install 'misleep[transformer]'``).
5
+ The heavy sub-modules are imported lazily so that importing
6
+ ``misleep.analysis.transformer`` itself never fails when PyTorch is
7
+ missing -- only *using* the auto-staging API does (with a helpful
8
+ ImportError).
9
+ """
10
+
11
+ import importlib
12
+ from importlib.resources import files
13
+ from pathlib import Path
14
+
15
+ __all__ = ["AutoStageConfig", "EEGTransformerConfig", "PreprocessConfig",
16
+ "STFTConfig", "auto_stage_llm", "default_checkpoint_path",
17
+ "build_model", "load_mouse_recording", "parse_label_file"]
18
+
19
+ #: name -> sub-module that provides it
20
+ _LAZY_EXPORTS = {
21
+ "AutoStageConfig": "misleep.analysis.transformer.inference",
22
+ "auto_stage_llm": "misleep.analysis.transformer.inference",
23
+ "EEGTransformerConfig": "misleep.analysis.transformer.configs",
24
+ "PreprocessConfig": "misleep.analysis.transformer.configs",
25
+ "STFTConfig": "misleep.analysis.transformer.configs",
26
+ "build_model": "misleep.analysis.transformer.models",
27
+ "load_mouse_recording": "misleep.analysis.transformer.data",
28
+ "parse_label_file": "misleep.analysis.transformer.data",
29
+ }
30
+
31
+
32
+ def default_checkpoint_path() -> Path:
33
+ """Return the path of the packaged CausalTransformer checkpoint."""
34
+ ref = files("misleep.analysis.transformer.checkpoints")
35
+ return Path(str(ref)) / "CausalTransformer_best.pt"
36
+
37
+
38
+ def __getattr__(name: str):
39
+ if name in _LAZY_EXPORTS:
40
+ module = importlib.import_module(_LAZY_EXPORTS[name])
41
+ return getattr(module, name)
42
+ raise AttributeError(f"module 'misleep.analysis.transformer' has no attribute {name!r}")
43
+
44
+
45
+ def __dir__():
46
+ return sorted(set(globals()) | set(__all__))
File without changes
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Tuple
5
+
6
+
7
+ @dataclass()
8
+ class PreprocessConfig:
9
+ """Signal preprocessing hyperparameters for the transformer models."""
10
+
11
+ sample_rate: float = 305.1758
12
+ epoch_seconds: float = 5.0
13
+ filter_order: int = 4
14
+ eeg_band: Tuple[float, float] = (0.5, 30.0)
15
+ emg_band: Tuple[float, float] = (15.0, 100.0)
16
+
17
+ def samples_per_epoch(self) -> int:
18
+ return int(round(self.sample_rate * self.epoch_seconds))
19
+
20
+ def hop_length_samples(self) -> int:
21
+ return self.samples_per_epoch()
22
+
23
+
24
+ @dataclass()
25
+ class STFTConfig:
26
+ """STFT hyperparameters."""
27
+
28
+ sample_rate: float = 305.1758
29
+ n_fft: int = 256
30
+ hop_length: int = 128
31
+ win_length: int = 256
32
+ f_max: float = 40.0
33
+
34
+
35
+ @dataclass()
36
+ class EEGTransformerConfig:
37
+ """Light transformer architecture hyperparameters."""
38
+
39
+ num_classes: int = 3
40
+ patch_size: int = 32
41
+ d_model: int = 128
42
+ num_heads: int = 4
43
+ num_layers: int = 4
44
+ dim_feedforward: int = 256
45
+ dropout: float = 0.1
46
+ use_time_tokens: bool = True
47
+ use_spec_tokens: bool = True
48
+ stft: STFTConfig = field(default_factory=STFTConfig)
49
+ context_window: int = 11
50
+ conv_base_filters: int = 16
51
+ conv_kernel_size: int = 7
52
+ conv_blocks: int = 2
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional, Sequence, Tuple
6
+
7
+ import numpy as np
8
+ import torch
9
+ from torch.utils.data import Dataset
10
+
11
+ from .configs import PreprocessConfig
12
+ from .preprocessing import (
13
+ apply_bandpass,
14
+ augment_epoch,
15
+ build_channel_filters,
16
+ epoch_signal,
17
+ exponential_moving_standardize,
18
+ )
19
+
20
+
21
+ def _resolve_mat_key(mat_dict: Dict) -> str:
22
+ keys = [k for k in mat_dict.keys() if not k.startswith("__")]
23
+ if not keys:
24
+ raise ValueError("MAT file does not contain any usable keys.")
25
+ if len(keys) == 1:
26
+ return keys[0]
27
+ for candidate in keys:
28
+ if candidate.lower().startswith("mouse"):
29
+ return candidate
30
+ return keys[0]
31
+
32
+
33
+ def _extract_sample_rate(entry: Dict, default: float) -> float:
34
+ for key in ("sf", "fs", "sample_rate"):
35
+ if key not in entry:
36
+ continue
37
+ try:
38
+ values = np.asarray(entry[key], dtype=np.float32).ravel()
39
+ except Exception:
40
+ continue
41
+ for val in values:
42
+ if val > 0:
43
+ return float(val)
44
+ return float(default)
45
+
46
+
47
+ def load_mouse_recording(
48
+ data_path: Path,
49
+ *,
50
+ return_sample_rate: bool = False,
51
+ default_sample_rate: float = 305.1758,
52
+ ) -> np.ndarray | Tuple[np.ndarray, float]:
53
+ """Load a raw ``(2, samples)`` EEG/EMG recording from ``.mat`` or ``.edf``."""
54
+ if str(data_path).endswith(".mat"):
55
+ import scipy.io as sio
56
+
57
+ data_ = sio.loadmat(data_path, simplify_cells=True)
58
+ key = data_path.stem if data_path.stem in data_ else _resolve_mat_key(data_)
59
+ entry = data_[key]
60
+ elif str(data_path).endswith(".edf"):
61
+ import misleep
62
+
63
+ midata = misleep.load_edf(data_path)
64
+ entry = {"EEG": midata.signals[0], "EMG_DIFF": midata.signals[1], "sf": midata.sf[0]}
65
+ else:
66
+ raise ValueError(f"Unsupported file type: {data_path}")
67
+
68
+ sample_rate = _extract_sample_rate(entry, default_sample_rate)
69
+ if "EEG_P" in entry:
70
+ eeg_raw = entry["EEG_P"]
71
+ elif "EEG" in entry:
72
+ eeg_raw = entry["EEG"]
73
+ else:
74
+ raise KeyError(f"Missing EEG_P or EEG in {data_path}")
75
+ eeg = np.asarray(eeg_raw, dtype=np.float32)
76
+ emg = np.asarray(entry["EMG_DIFF"], dtype=np.float32)
77
+ min_len = min(eeg.shape[-1], emg.shape[-1])
78
+ if min_len <= 0:
79
+ raise ValueError(f"Invalid recording length in {data_path}")
80
+ if eeg.shape[-1] != min_len:
81
+ eeg = eeg[..., :min_len]
82
+ if emg.shape[-1] != min_len:
83
+ emg = emg[..., :min_len]
84
+ stacked = np.stack([eeg, emg], axis=0)
85
+ if return_sample_rate:
86
+ return stacked, sample_rate
87
+ return stacked
88
+
89
+
90
+ def parse_label_file(txt_path: Path) -> Tuple[List[Dict], Dict]:
91
+ """Parse a MiSleep annotation ``.txt`` into entries and metadata."""
92
+ entries: List[Dict] = []
93
+ base_seconds = None
94
+ with txt_path.open("r", encoding="utf-8", errors="ignore") as f:
95
+ lines = [line.strip() for line in f if line.strip()]
96
+ parsing = False
97
+ for line in lines:
98
+ if line.startswith("==========Sleep stage"):
99
+ parsing = True
100
+ continue
101
+ if not parsing:
102
+ continue
103
+ parts = [p.strip() for p in line.split(",")]
104
+ if len(parts) < 8:
105
+ continue
106
+ start_code, start_idx, _, end_code, end_idx, _, label_id, label_name = parts[:8]
107
+ entry = {
108
+ "start_code": start_code,
109
+ "end_code": end_code,
110
+ "start_idx": int(start_idx),
111
+ "end_idx": int(end_idx),
112
+ "label_id": int(label_id),
113
+ "label_name": label_name,
114
+ }
115
+ entries.append(entry)
116
+ if base_seconds is None:
117
+ day, hour, minute, second = (int(v) for v in start_code.split(":"))
118
+ base_seconds = ((day * 24 + hour) * 60 + minute) * 60 + second
119
+ meta = {"base_seconds": base_seconds or 0,
120
+ "duration_units": entries[-1]["end_idx"] if entries else 0}
121
+ return entries, meta
122
+
123
+
124
+ def build_label_array(num_samples: int, annotations: Sequence[Dict], samples_per_unit: float) -> np.ndarray:
125
+ """Build a per-sample label array from parsed annotation entries."""
126
+ labels = np.full(num_samples, -1, dtype=np.int16)
127
+ id_map = {1: 0, 2: 1, 3: 2} # NREM, REM, Wake
128
+ for entry in annotations:
129
+ start = max(0, int(round(entry["start_idx"] * samples_per_unit)))
130
+ end = min(num_samples, int(round(entry["end_idx"] * samples_per_unit)))
131
+ if end <= start:
132
+ continue
133
+ mapped = id_map.get(entry["label_id"], -1)
134
+ if mapped >= 0:
135
+ labels[start:end] = mapped
136
+ return labels
137
+
138
+
139
+ def slice_epochs_with_labels(
140
+ signal_arr: np.ndarray,
141
+ labels: np.ndarray,
142
+ cfg: PreprocessConfig,
143
+ min_valid_ratio: float = 0.8,
144
+ ) -> Tuple[np.ndarray, np.ndarray]:
145
+ """Slice a signal into epochs and assign each epoch its majority label."""
146
+ samples_per_epoch = cfg.samples_per_epoch()
147
+ hop = cfg.hop_length_samples()
148
+ total = signal_arr.shape[1]
149
+ if total < samples_per_epoch:
150
+ return (np.zeros((0, signal_arr.shape[0], samples_per_epoch), dtype=np.float32),
151
+ np.zeros((0,), dtype=np.int64))
152
+ usable_epochs: List[np.ndarray] = []
153
+ epoch_labels: List[int] = []
154
+ start = 0
155
+ while start + samples_per_epoch <= total:
156
+ end = start + samples_per_epoch
157
+ epoch_signal_arr = signal_arr[:, start:end]
158
+ label_slice = labels[start:end]
159
+ valid = label_slice[label_slice >= 0]
160
+ if valid.size / samples_per_epoch < min_valid_ratio:
161
+ start += hop
162
+ continue
163
+ epoch_label = int(np.bincount(valid, minlength=3).argmax())
164
+ usable_epochs.append(epoch_signal_arr)
165
+ epoch_labels.append(epoch_label)
166
+ start += hop
167
+ if not usable_epochs:
168
+ channels = signal_arr.shape[0]
169
+ return (np.zeros((0, channels, samples_per_epoch), dtype=np.float32),
170
+ np.zeros((0,), dtype=np.int64))
171
+ return np.stack(usable_epochs), np.asarray(epoch_labels, dtype=np.int64)
172
+
173
+
174
+ @dataclass()
175
+ class SubjectEpochs:
176
+ """Epochs and labels of one subject."""
177
+
178
+ subject_id: str
179
+ epochs: np.ndarray # shape (N, 2, T)
180
+ labels: np.ndarray # shape (N,)
181
+
182
+
183
+ @dataclass()
184
+ class SubjectInfo:
185
+ """Metadata of one subject."""
186
+
187
+ subject_id: str # relative path without extension
188
+ data_path: Path
189
+ label_path: Optional[Path]
190
+ age_group: str
191
+ mouse_id: str
192
+ extension: str
193
+
194
+
195
+ class SleepEpochDataset(Dataset):
196
+ """PyTorch dataset of context-window sequences with labels."""
197
+
198
+ def __init__(self, subjects: Sequence[SubjectEpochs], window_size: int,
199
+ augment: bool = False) -> None:
200
+ self.window = max(1, window_size)
201
+ self.augment = augment
202
+ self.samples: List[np.ndarray] = []
203
+ self.labels: List[int] = []
204
+ for subj in subjects:
205
+ epochs = subj.epochs.astype(np.float32)
206
+ labels = subj.labels.astype(np.int64)
207
+ if epochs.size == 0:
208
+ continue
209
+ seqs, seq_labels = self._build_sequences(epochs, labels)
210
+ self.samples.append(seqs)
211
+ self.labels.append(seq_labels)
212
+
213
+ if self.samples:
214
+ lengths = [arr.shape[0] for arr in self.samples]
215
+ self.offsets = np.cumsum([0] + lengths)
216
+ self.flat_labels = np.concatenate(self.labels)
217
+ else:
218
+ self.offsets = np.array([0], dtype=np.int64)
219
+ self.flat_labels = np.zeros((0,), dtype=np.int64)
220
+
221
+ def _build_sequences(self, epochs: np.ndarray, labels: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
222
+ total = epochs.shape[0]
223
+ seqs = []
224
+ seq_labels = []
225
+ for idx in range(total):
226
+ start = max(0, idx - self.window + 1)
227
+ seq = epochs[start: idx + 1]
228
+ if seq.shape[0] < self.window:
229
+ pad = np.repeat(seq[0:1], self.window - seq.shape[0], axis=0)
230
+ seq = np.concatenate([pad, seq], axis=0)
231
+ seqs.append(seq)
232
+ seq_labels.append(labels[idx])
233
+ return np.stack(seqs, axis=0), np.asarray(seq_labels, dtype=np.int64)
234
+
235
+ def __len__(self) -> int:
236
+ return int(self.offsets[-1])
237
+
238
+ def _locate(self, idx: int) -> Tuple[int, int]:
239
+ lo, hi = 0, len(self.offsets) - 1
240
+ while lo < hi:
241
+ mid = (lo + hi) // 2
242
+ if idx < self.offsets[mid + 1]:
243
+ hi = mid
244
+ else:
245
+ lo = mid + 1
246
+ subj_idx = lo
247
+ offset = idx - self.offsets[subj_idx]
248
+ return subj_idx, offset
249
+
250
+ def __getitem__(self, idx: int) -> Tuple[torch.Tensor, torch.Tensor]:
251
+ subj_idx, offset = self._locate(idx)
252
+ seq = self.samples[subj_idx][offset]
253
+ label = self.labels[subj_idx][offset]
254
+ if self.augment:
255
+ seq = np.stack([augment_epoch(epoch) for epoch in seq], axis=0)
256
+ return torch.from_numpy(seq), torch.tensor(label, dtype=torch.long)
257
+
258
+
259
+ def _sanitize_subject_id(subject_id: str) -> str:
260
+ return subject_id.replace("/", "__").replace("\\", "__")
261
+
262
+
263
+ def resolve_subject_paths(subject_id: str, data_dir: Path, data_format: str = "mat") -> Tuple[Path, Optional[Path]]:
264
+ base = data_dir / subject_id
265
+ if data_format == "mat":
266
+ mat_path = Path(str(base) + ".mat")
267
+ txt_path = Path(str(base) + ".txt")
268
+ if not mat_path.exists():
269
+ raise FileNotFoundError(f"MAT file not found for subject '{subject_id}' at {mat_path}")
270
+ if not txt_path.exists():
271
+ raise FileNotFoundError(f"TXT file not found for subject '{subject_id}' at {txt_path}")
272
+ return mat_path, txt_path
273
+ if data_format == "npy":
274
+ npy_path = Path(str(base) + ".npz")
275
+ if not npy_path.exists():
276
+ raise FileNotFoundError(f"NPZ file not found for subject '{subject_id}' at {npy_path}")
277
+ return npy_path, None
278
+ raise ValueError(f"Unsupported data format '{data_format}'")
279
+
280
+
281
+ def discover_subject_infos(data_dir: Path, data_format: str = "mat") -> List[SubjectInfo]:
282
+ """Discover ``.mat``/``.npz`` subjects (with label files) under a directory."""
283
+ infos: List[SubjectInfo] = []
284
+ if not data_dir.exists():
285
+ return infos
286
+ pattern = "*.npz" if data_format == "npy" else "*.mat"
287
+ for data_file in sorted(data_dir.rglob(pattern)):
288
+ if not data_file.is_file():
289
+ continue
290
+ if data_format == "mat":
291
+ label_path = data_file.with_suffix(".txt")
292
+ if not label_path.exists():
293
+ continue
294
+ else:
295
+ label_path = None
296
+ rel_path = data_file.relative_to(data_dir)
297
+ rel_id = rel_path.with_suffix("").as_posix()
298
+ parts = rel_path.parts
299
+ if parts:
300
+ age_group = parts[0]
301
+ mouse_id = parts[-2] if len(parts) >= 2 else parts[0]
302
+ else:
303
+ age_group = "root"
304
+ mouse_id = "root"
305
+ infos.append(SubjectInfo(
306
+ subject_id=rel_id,
307
+ data_path=data_file,
308
+ label_path=label_path,
309
+ age_group=age_group,
310
+ mouse_id=mouse_id,
311
+ extension=data_file.suffix,
312
+ ))
313
+ return infos
314
+
315
+
316
+ def load_subject_epochs(
317
+ subject_id: str,
318
+ data_dir: Path,
319
+ cfg: PreprocessConfig,
320
+ cache_dir: Optional[Path] = None,
321
+ data_format: str = "mat",
322
+ ) -> SubjectEpochs:
323
+ """Load (and optionally cache) the epochs + labels of one subject."""
324
+ mat_path, txt_path = resolve_subject_paths(subject_id, data_dir, data_format=data_format)
325
+ cache_file = None
326
+ if cache_dir and data_format == "mat":
327
+ cache_dir.mkdir(parents=True, exist_ok=True)
328
+ cache_file = cache_dir / f"{_sanitize_subject_id(subject_id)}_epochs.npz"
329
+ if cache_file.exists():
330
+ data = np.load(cache_file)
331
+ cached = SubjectEpochs(subject_id, data["epochs"], data["labels"])
332
+ expected_len = cfg.samples_per_epoch()
333
+ expected_hop = cfg.hop_length_samples()
334
+ cache_len = int(data["epoch_samples"]) if "epoch_samples" in data else cached.epochs.shape[-1]
335
+ cache_hop = int(data["hop_samples"]) if "hop_samples" in data else expected_hop + 1
336
+ standardization = data["standardization"] if "standardization" in data else None
337
+ if cache_len == expected_len and cache_hop == expected_hop and standardization == "exp_mov":
338
+ return cached
339
+ print(f"[Cache] {cache_file.name} mismatch detected; rebuilding with new preprocessing settings.")
340
+ if data_format == "npy":
341
+ data = np.load(mat_path)
342
+ return SubjectEpochs(subject_id, data["epochs"], data["labels"])
343
+
344
+ raw = load_mouse_recording(mat_path)
345
+ annotations, meta = parse_label_file(txt_path)
346
+ sos = build_channel_filters(cfg)
347
+ filtered = apply_bandpass(raw, sos)
348
+ standardized = exponential_moving_standardize(filtered, init_window=cfg.samples_per_epoch())
349
+ samples_per_unit = raw.shape[1] / max(meta["duration_units"], 1)
350
+ labels = build_label_array(raw.shape[1], annotations, samples_per_unit)
351
+ epochs, epoch_labels = slice_epochs_with_labels(standardized, labels, cfg)
352
+
353
+ if cache_file is not None:
354
+ np.savez_compressed(
355
+ cache_file,
356
+ epochs=epochs,
357
+ labels=epoch_labels,
358
+ epoch_samples=cfg.samples_per_epoch(),
359
+ hop_samples=cfg.hop_length_samples(),
360
+ standardization="exp_mov",
361
+ )
362
+ return SubjectEpochs(subject_id, epochs, epoch_labels)