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.
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,367 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Annotation file I/O.
3
+
4
+ The default MiSleep annotation format is a human readable ``.txt`` file
5
+ with three sections:
6
+
7
+ * ``==========Marker==========`` -- single time-point events
8
+ * ``==========Start-End==========`` -- start/end events
9
+ * ``==========Sleep state==========`` (or ``Sleep stage``) -- per-second states
10
+
11
+ A bio-signal annotation format (first two lines are a header, then
12
+ tab-separated state rows) is also supported through :func:`load_bio_anno`.
13
+ """
14
+
15
+ import datetime
16
+ import json
17
+ import math
18
+ from pathlib import Path
19
+
20
+ from misleep.data import MiAnnotation
21
+ from misleep.io.base import MiData # noqa: F401 (kept for API symmetry)
22
+ from misleep.logger import logger
23
+ from misleep.utils.annotation import (
24
+ lst2group,
25
+ marker2mianno,
26
+ sleep_state2mianno,
27
+ start_end2mianno,
28
+ )
29
+ from misleep.utils.time_utils import transfer_time
30
+
31
+
32
+ def load_misleep_anno(file_path, state_map=None):
33
+ """Load annotations from a MiSleep annotation file.
34
+
35
+ Parameters
36
+ ----------
37
+ file_path : str
38
+ Path of the annotation file.
39
+ state_map : dict, optional
40
+ Custom state code -> name mapping.
41
+
42
+ Returns
43
+ -------
44
+ MiAnnotation
45
+ The parsed annotation.
46
+ """
47
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
48
+ annotation = f.read().split("\n")
49
+
50
+ if annotation == [""]:
51
+ raise AssertionError("Empty")
52
+
53
+ try:
54
+ marker_idx = annotation.index("==========Marker==========")
55
+ start_end_idx = annotation.index("==========Start-End==========")
56
+ try:
57
+ sleep_state_idx = annotation.index("==========Sleep state==========")
58
+ except ValueError:
59
+ sleep_state_idx = annotation.index("==========Sleep stage==========")
60
+ except Exception:
61
+ raise AssertionError("Invalid")
62
+
63
+ marker = marker2mianno(annotation[marker_idx + 1: start_end_idx])
64
+ start_end = start_end2mianno(annotation[start_end_idx + 1: sleep_state_idx])
65
+ sleep_state = sleep_state2mianno(annotation[sleep_state_idx + 1:])
66
+
67
+ return MiAnnotation(sleep_state=sleep_state, start_end=start_end,
68
+ marker=marker, state_map=state_map)
69
+
70
+
71
+ def load_bio_anno(file_path):
72
+ """Load a bio-signal annotation file (tab-separated, 2-line header)."""
73
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
74
+ file = f.readlines()
75
+ file = file[2:]
76
+ state_list = [each.split("\t")[1] for each in file]
77
+ state_list = [[each] * 4 for each in state_list]
78
+ state_list = [item for each in state_list for item in each]
79
+ state_map = {
80
+ "AW": 3,
81
+ "QW": 3,
82
+ "NREM": 1,
83
+ "REMS": 2,
84
+ }
85
+ state_list = [state_map[each] for each in state_list]
86
+
87
+ return MiAnnotation(sleep_state=state_list, marker=[], start_end=[])
88
+
89
+
90
+ def load_json_anno(file_path, state_map=None):
91
+ """Load a portable JSON annotation object.
92
+
93
+ Required key: ``sleep_state``. Optional keys are ``marker``,
94
+ ``start_end`` and ``state_map`` and mirror :class:`MiAnnotation`.
95
+ """
96
+ try:
97
+ payload = json.loads(Path(file_path).read_text(encoding="utf-8"))
98
+ except (OSError, json.JSONDecodeError) as exc:
99
+ raise ValueError(f"Invalid JSON annotation: {exc}") from exc
100
+ if not isinstance(payload, dict) or "sleep_state" not in payload:
101
+ raise ValueError("JSON annotation must be an object containing 'sleep_state'")
102
+ mapping = payload.get("state_map", state_map)
103
+ if mapping is not None:
104
+ mapping = {int(key): str(value) for key, value in mapping.items()}
105
+ return MiAnnotation(
106
+ sleep_state=[int(value) for value in payload["sleep_state"]],
107
+ marker=payload.get("marker", []),
108
+ start_end=payload.get("start_end", []),
109
+ state_map=mapping,
110
+ )
111
+
112
+
113
+ def load_table_anno(file_path, state_map=None):
114
+ """Load per-second or interval sleep states from CSV/TSV.
115
+
116
+ A table needs a ``state``/``state_code``/``sleep_state`` column. With
117
+ ``start`` and ``end`` columns each row is an interval; otherwise every
118
+ row represents one second. Optional event rows use ``type=marker`` with
119
+ ``time`` and ``label``, or ``type=start_end`` with ``start/end/label``.
120
+ """
121
+ import pandas as pd
122
+
123
+ path = Path(file_path)
124
+ separator = "\t" if path.suffix.lower() == ".tsv" else ","
125
+ frame = pd.read_csv(path, sep=separator)
126
+ frame.columns = [str(column).strip().lower() for column in frame.columns]
127
+ mapping = state_map or {1: "NREM", 2: "REM", 3: "Wake", 4: "Init"}
128
+ mapping = {int(key): str(value) for key, value in mapping.items()}
129
+ reverse = {name.casefold(): code for code, name in mapping.items()}
130
+ state_column = next(
131
+ (name for name in ("state", "state_code", "sleep_state") if name in frame), None)
132
+
133
+ def state_code(value):
134
+ try:
135
+ code = int(value)
136
+ except (TypeError, ValueError):
137
+ code = reverse.get(str(value).strip().casefold())
138
+ if code not in mapping:
139
+ raise ValueError(f"Unknown sleep state {value!r}; expected {mapping}")
140
+ return code
141
+
142
+ kind = frame["type"].astype(str).str.lower() if "type" in frame else None
143
+ state_rows = frame if kind is None else frame[~kind.isin(["marker", "start_end", "event"])]
144
+ if state_column is None or state_rows.empty:
145
+ raise ValueError("Annotation table must contain sleep-state rows and a state column")
146
+ if {"start", "end"}.issubset(frame.columns):
147
+ duration = int(math.ceil(pd.to_numeric(state_rows["end"]).max()))
148
+ sleep_state = [max(mapping)] * duration
149
+ for _, row in state_rows.iterrows():
150
+ start = max(0, int(math.floor(float(row["start"]))))
151
+ end = min(duration, int(math.ceil(float(row["end"]))))
152
+ sleep_state[start:end] = [state_code(row[state_column])] * max(0, end - start)
153
+ else:
154
+ sleep_state = [state_code(value) for value in state_rows[state_column]]
155
+
156
+ marker, start_end = [], []
157
+ if kind is not None:
158
+ for index, row in frame[kind == "marker"].iterrows():
159
+ when = row.get("time", row.get("start"))
160
+ marker.append([float(when), str(row.get("label", "marker"))])
161
+ for index, row in frame[kind.isin(["start_end", "event"])].iterrows():
162
+ start_end.append([float(row["start"]), float(row["end"]),
163
+ str(row.get("label", "event"))])
164
+ return MiAnnotation(sleep_state=sleep_state, marker=marker,
165
+ start_end=start_end, state_map=mapping)
166
+
167
+
168
+ def available_annotation_readers():
169
+ """Return annotation extensions understood by :func:`load_annotation`."""
170
+ return [".txt", ".json", ".csv", ".tsv"]
171
+
172
+
173
+ def load_annotation(file_path, state_map=None):
174
+ """Load an annotation by extension, auto-detecting legacy TXT files."""
175
+ path = Path(file_path)
176
+ if not path.is_file():
177
+ raise FileNotFoundError(f"Annotation file not found: {path}")
178
+ suffix = path.suffix.lower()
179
+ if suffix == ".json":
180
+ return load_json_anno(path, state_map=state_map)
181
+ if suffix in (".csv", ".tsv"):
182
+ return load_table_anno(path, state_map=state_map)
183
+ if suffix == ".txt":
184
+ prefix = path.read_text(encoding="utf-8", errors="ignore")[:5]
185
+ return load_bio_anno(path) if prefix == "Start" else load_misleep_anno(path, state_map)
186
+ raise ValueError(f"Unsupported annotation extension {suffix!r}")
187
+
188
+
189
+ def save_misleep_anno(mianno, midata, file_path):
190
+ """Write a :class:`MiAnnotation` to the MiSleep ``.txt`` format.
191
+
192
+ Parameters
193
+ ----------
194
+ mianno : MiAnnotation
195
+ The annotation to save.
196
+ midata : MiData
197
+ The associated data (used for the acquisition time).
198
+ file_path : str
199
+ Destination file.
200
+
201
+ Returns
202
+ -------
203
+ bool
204
+ True on success.
205
+ """
206
+ from misleep.utils.time_utils import second2time
207
+
208
+ ac_time = datetime.datetime.strptime(midata.time, "%Y%m%d-%H:%M:%S")
209
+
210
+ marker = [", ".join([
211
+ second2time(round(each[0], 3), ac_time=ac_time, ms=True), str(round(each[0], 3)), "1",
212
+ second2time(round(each[0], 3), ac_time=ac_time, ms=True), str(round(each[0], 3)), "0",
213
+ "1", each[1]
214
+ ]) for each in mianno.marker]
215
+
216
+ start_end_label = [", ".join([
217
+ second2time(round(each[0], 3), ac_time=ac_time, ms=True), str(round(each[0], 3)), "1",
218
+ second2time(round(each[1], 3), ac_time=ac_time, ms=True), str(round(each[1], 3)), "0",
219
+ "1", each[2]
220
+ ]) for each in mianno.start_end]
221
+
222
+ sleep_state = lst2group([[idx, each] for idx, each in enumerate(mianno.sleep_state)])
223
+ sleep_state = [", ".join([
224
+ second2time(each[0], ac_time=ac_time), str(each[0]), "1",
225
+ second2time(each[1], ac_time=ac_time), str(each[1]),
226
+ "0", str(each[2]), mianno.state_map[each[2]]
227
+ ]) for each in sleep_state]
228
+
229
+ if len(marker) > 0:
230
+ marker = [""] + marker
231
+ if len(start_end_label) > 0:
232
+ start_end_label = [""] + start_end_label
233
+
234
+ annos = [
235
+ "READ ONLY! DO NOT EDIT!\n4-INIT 3-Wake 2-REM 1-NREM",
236
+ "Save time: " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
237
+ "Acquisition time: " + ac_time.strftime("%Y-%m-%d %H:%M:%S"),
238
+ "==========Marker==========" + "\n".join(marker),
239
+ "==========Start-End==========" + "\n".join(start_end_label),
240
+ "==========Sleep stage==========", "\n".join(sleep_state)
241
+ ]
242
+
243
+ with open(file_path, "w", encoding="utf-8") as f:
244
+ f.write("\n".join(annos))
245
+ return True
246
+
247
+
248
+ def transfer_result(mianno, ac_time):
249
+ """Convert a :class:`MiAnnotation` into analysis dataframes.
250
+
251
+ Produces per-hour sleep statistics (duration, bout count, average bout
252
+ length, percentage for every state), plus a 12h light/dark phase
253
+ summary. Also returns the raw marker/start-end dataframes.
254
+
255
+ Parameters
256
+ ----------
257
+ mianno : MiAnnotation
258
+ Annotation to transfer.
259
+ ac_time : datetime.datetime
260
+ Acquisition time of the recording.
261
+
262
+ Returns
263
+ -------
264
+ (df, analyse_df, start_end_df, marker_df) : tuple of pandas.DataFrame
265
+ """
266
+ import pandas as pd
267
+
268
+ marker = [[
269
+ transfer_time(ac_time, each[0], "%Y-%m-%d %H:%M:%S", ms=True),
270
+ each[0], each[1]] for each in mianno.marker]
271
+
272
+ start_end_label = [[
273
+ transfer_time(ac_time, each[0], "%Y-%m-%d %H:%M:%S", ms=True), each[0], 1,
274
+ transfer_time(ac_time, each[1], "%Y-%m-%d %H:%M:%S", ms=True), each[1], 0,
275
+ each[2], each[1] - each[0]
276
+ ] for each in mianno.start_end]
277
+
278
+ # Split the sleep state into per-hour groups
279
+ sleep_state = []
280
+ for each in range(int(len(mianno.sleep_state) / 3600) + 1):
281
+ temp_hour_label = mianno.sleep_state[each * 3600: (each + 1) * 3600]
282
+ if temp_hour_label != []:
283
+ sleep_state.append(temp_hour_label)
284
+
285
+ marker_sleep_state = []
286
+ for hour, label in enumerate(sleep_state):
287
+ hour_sleep_state = lst2group(
288
+ [idx + hour * 3600, each] for idx, each in enumerate(label))
289
+ marker_sleep_state += [[
290
+ transfer_time(ac_time, each[0], "%Y-%m-%d %H:%M:%S"), each[0], 1,
291
+ transfer_time(ac_time, each[1], "%Y-%m-%d %H:%M:%S"), each[1], 0,
292
+ each[2], mianno.state_map[each[2]], each[1] - each[0], hour
293
+ ] for each in hour_sleep_state]
294
+
295
+ marker_sleep_state += [[
296
+ transfer_time(ac_time, (hour + 1) * 3600, "%Y-%m-%d %H:%M:%S"), (hour + 1) * 3600, 1,
297
+ transfer_time(ac_time, (hour + 1) * 3600, "%Y-%m-%d %H:%M:%S"), (hour + 1) * 3600, 0,
298
+ 5, "MARKER", "", ""]]
299
+
300
+ columns = ["start_time", "start_time_sec", "start_code",
301
+ "end_time", "end_time_sec", "end_code",
302
+ "state_code", "state", "bout_duration", "hour"]
303
+
304
+ df = pd.DataFrame(data=marker_sleep_state, columns=columns)
305
+
306
+ df["hour"] = df.apply(
307
+ lambda x: "" if x["state"] == "MARKER" else int(x["start_time_sec"] / 3600), axis=1)
308
+ analyse_df = pd.DataFrame()
309
+
310
+ temp_hour = list(set(list(df["hour"])))
311
+ temp_hour.remove("")
312
+ temp_hour = sorted(temp_hour)
313
+ analyse_df["date_time"] = [transfer_time(ac_time, each * 3600, "%Y-%m-%d %H:%M:%S")
314
+ for each in temp_hour]
315
+
316
+ features = []
317
+ for each in temp_hour:
318
+ df_ = df[df["hour"] == each]
319
+ temp_lst = []
320
+ for phase in ["NREM", "REM", "Wake", "INIT"]:
321
+ _duration = df_[df_["state"] == phase]["bout_duration"].sum()
322
+ _bout = df_[df_["state"] == phase]["bout_duration"].count()
323
+ temp_lst += [_duration, _bout, round(_duration / _bout, 2) if _bout != 0 else 0,
324
+ round(_duration / 3600, 4) * 100]
325
+ features.append(temp_lst)
326
+
327
+ analyse_df[["NREM_duration", "NREM_bout", "NREM_ave", "NREM_percentage",
328
+ "REM_duration", "REM_bout", "REM_ave", "REM_percentage",
329
+ "WAKE_duration", "WAKE_bout", "WAKE_ave", "WAKE_percentage",
330
+ "INIT_duration", "INIT_bout", "INIT_ave", "INIT_percentage"]] = features
331
+
332
+ analyse_df[["NREM_duration", "NREM_bout", "REM_duration", "REM_bout", "WAKE_duration",
333
+ "WAKE_bout", "INIT_duration", "INIT_bout"]] = analyse_df[
334
+ ["NREM_duration", "NREM_bout", "REM_duration", "REM_bout", "WAKE_duration",
335
+ "WAKE_bout", "INIT_duration", "INIT_bout"]].astype(int)
336
+
337
+ # 12-h light/dark phase summary (first hour treated as ZT0 by default)
338
+ try:
339
+ phase_data = pd.DataFrame()
340
+ phase_data["date_time"] = ["ZT0-ZT12", "ZT12-ZT24"]
341
+ for phase in ["NREM", "REM", "WAKE", "INIT"]:
342
+ duration_key = f"{phase}_duration"
343
+ bout_key = f"{phase}_bout"
344
+ ave_key = f"{phase}_ave"
345
+ pct_key = f"{phase}_percentage"
346
+ phase_data[duration_key] = [analyse_df[duration_key].iloc[:12].sum(),
347
+ analyse_df[duration_key].iloc[12:24].sum()]
348
+ phase_data[bout_key] = [analyse_df[bout_key].iloc[:12].sum(),
349
+ analyse_df[bout_key].iloc[12:24].sum()]
350
+ phase_data[ave_key] = phase_data.apply(
351
+ lambda x: x[duration_key] / x[bout_key] if x[bout_key] != 0 else 0, axis=1)
352
+ phase_data[pct_key] = phase_data.apply(lambda x: x[duration_key] / (3600 * 12), axis=1)
353
+
354
+ analyse_df = pd.concat([analyse_df, phase_data])
355
+ analyse_df.reset_index(inplace=True)
356
+ except Exception as e:
357
+ logger.warning(f"Could not compute light/dark phase summary: {e}")
358
+
359
+ start_end_df = pd.DataFrame(
360
+ start_end_label,
361
+ columns=["start_time", "start_time_sec", "start_code",
362
+ "end_time", "end_time_sec", "end_code",
363
+ "label", "bout_duration"])
364
+
365
+ marker_df = pd.DataFrame(marker, columns=["timestamp", "timestamp_sec", "label"])
366
+
367
+ return df, analyse_df, start_end_df, marker_df
misleep/io/array.py ADDED
@@ -0,0 +1,209 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """Portable NumPy and delimited-text signal formats.
3
+
4
+ Plain arrays do not contain sampling frequency or acquisition-time metadata.
5
+ MiSleep therefore uses a small JSON sidecar next to ``.npy``, ``.csv`` and
6
+ ``.tsv`` files. For ``recording.npy`` either ``recording.npy.json`` (preferred)
7
+ or ``recording.json`` is accepted::
8
+
9
+ {"sf": 256, "channels": ["EEG", "EMG"],
10
+ "time": "20240409-18:00:00", "channel_axis": 0}
11
+
12
+ ``.npz`` is self-contained and is the recommended NumPy interchange format.
13
+ Loading never enables NumPy pickle support, so untrusted files cannot execute
14
+ Python objects.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime as _datetime
20
+ import json
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+
25
+ from misleep.data import MiData
26
+ from misleep.io.base import register_signal_reader, register_signal_writer
27
+
28
+ _TIME_FORMAT = "%Y%m%d-%H:%M:%S"
29
+
30
+
31
+ def _default_time(path: Path) -> str:
32
+ """Use the file modification time when acquisition time is unavailable."""
33
+ return _datetime.datetime.fromtimestamp(path.stat().st_mtime).strftime(_TIME_FORMAT)
34
+
35
+
36
+ def _read_sidecar(path: Path) -> dict:
37
+ candidates = (path.with_suffix(path.suffix + ".json"), path.with_suffix(".json"))
38
+ for candidate in candidates:
39
+ if candidate.is_file():
40
+ try:
41
+ value = json.loads(candidate.read_text(encoding="utf-8"))
42
+ except (OSError, json.JSONDecodeError) as exc:
43
+ raise ValueError(f"Invalid metadata sidecar {candidate}: {exc}") from exc
44
+ if not isinstance(value, dict):
45
+ raise ValueError(f"Metadata sidecar {candidate} must contain a JSON object")
46
+ return value
47
+ return {}
48
+
49
+
50
+ def _metadata_list(value, count: int, name: str, cast):
51
+ if np.isscalar(value):
52
+ values = [cast(value)] * count
53
+ else:
54
+ values = [cast(item) for item in value]
55
+ if len(values) != count:
56
+ raise ValueError(f"'{name}' has {len(values)} values for {count} signal channels")
57
+ return values
58
+
59
+
60
+ def _to_signals(array, metadata: dict, path: Path):
61
+ data = np.asarray(array)
62
+ if data.dtype.kind not in "biufc":
63
+ raise ValueError(f"{path.name} must contain a numeric array, got dtype {data.dtype}")
64
+ data = np.squeeze(data)
65
+ if data.ndim == 1:
66
+ signals = [np.asarray(data)]
67
+ elif data.ndim == 2:
68
+ channel_axis = metadata.get("channel_axis")
69
+ if channel_axis is None:
70
+ channel_axis = 0 if data.shape[0] <= data.shape[1] else 1
71
+ if channel_axis not in (0, 1):
72
+ raise ValueError("'channel_axis' must be 0 or 1")
73
+ if channel_axis == 1:
74
+ data = data.T
75
+ signals = [np.asarray(row) for row in data]
76
+ else:
77
+ raise ValueError(
78
+ f"{path.name} must be a 1-D or 2-D signal array; got shape {data.shape}")
79
+ return signals
80
+
81
+
82
+ def _build_midata(array, metadata: dict, path: Path) -> MiData:
83
+ if isinstance(array, list) and array and all(np.asarray(item).ndim == 1 for item in array):
84
+ signals = [np.asarray(item) for item in array]
85
+ if any(signal.dtype.kind not in "biufc" for signal in signals):
86
+ raise ValueError(f"{path.name} contains a non-numeric signal array")
87
+ else:
88
+ signals = _to_signals(array, metadata, path)
89
+ if any(signal.size == 0 for signal in signals):
90
+ raise ValueError(f"{path.name} contains an empty signal channel")
91
+ count = len(signals)
92
+ if "sf" not in metadata:
93
+ raise ValueError(
94
+ f"Sampling frequency is missing for {path.name}. Add 'sf' to "
95
+ f"{path.name}.json (for example: {{\"sf\": 256}}).")
96
+ sf = _metadata_list(metadata["sf"], count, "sf", float)
97
+ channels = metadata.get("channels", [f"ch{i + 1}" for i in range(count)])
98
+ channels = _metadata_list(channels, count, "channels", str)
99
+ time = str(metadata.get("time") or _default_time(path))
100
+ try:
101
+ _datetime.datetime.strptime(time, _TIME_FORMAT)
102
+ except ValueError as exc:
103
+ raise ValueError(f"'time' must use YYYYMMDD-HH:MM:SS, got {time!r}") from exc
104
+ return MiData(signals=signals, channels=channels, sf=sf, time=time,
105
+ describe=str(metadata.get("describe", "")))
106
+
107
+
108
+ def load_npy(data_path) -> MiData:
109
+ """Load a numeric ``.npy`` array plus its JSON metadata sidecar."""
110
+ path = Path(data_path)
111
+ try:
112
+ array = np.load(path, allow_pickle=False)
113
+ except ValueError as exc:
114
+ raise ValueError(
115
+ "Object/pickled NPY files are intentionally not loaded for security. "
116
+ "Use a numeric array with a JSON sidecar, or the self-contained NPZ format."
117
+ ) from exc
118
+ return _build_midata(array, _read_sidecar(path), path)
119
+
120
+
121
+ def load_npz(data_path) -> MiData:
122
+ """Load MiSleep's safe, self-contained ``.npz`` interchange format."""
123
+ path = Path(data_path)
124
+ try:
125
+ archive = np.load(path, allow_pickle=False)
126
+ with archive:
127
+ keys = set(archive.files)
128
+ metadata = _read_sidecar(path)
129
+ for key in ("channels", "sf", "time", "describe", "channel_axis"):
130
+ if key in keys:
131
+ value = archive[key]
132
+ metadata[key] = value.item() if value.ndim == 0 else value.tolist()
133
+ signal_keys = sorted(
134
+ (key for key in keys if key.startswith("signal_")),
135
+ key=lambda key: int(key.split("_", 1)[1]),
136
+ )
137
+ if signal_keys:
138
+ signals = [np.asarray(archive[key]) for key in signal_keys]
139
+ elif "signals" in keys:
140
+ signals = _to_signals(archive["signals"], metadata, path)
141
+ else:
142
+ numeric = [key for key in archive.files
143
+ if key not in {"sf", "channel_axis"}
144
+ and np.asarray(archive[key]).dtype.kind in "biufc"]
145
+ if len(numeric) != 1:
146
+ raise ValueError(
147
+ "NPZ must contain 'signals', signal_0/signal_1 arrays, "
148
+ "or exactly one numeric array")
149
+ signals = _to_signals(archive[numeric[0]], metadata, path)
150
+ except (OSError, ValueError, TypeError) as exc:
151
+ if isinstance(exc, ValueError) and "NPZ must" in str(exc):
152
+ raise
153
+ raise ValueError(f"Could not read NPZ signal file {path}: {exc}") from exc
154
+ return _build_midata(signals, metadata, path)
155
+
156
+
157
+ def _load_delimited(data_path, separator: str) -> MiData:
158
+ import pandas as pd
159
+
160
+ path = Path(data_path)
161
+ frame = pd.read_csv(path, sep=separator)
162
+ if frame.empty:
163
+ raise ValueError(f"Signal table {path} is empty")
164
+ metadata = _read_sidecar(path)
165
+ time_column = next(
166
+ (name for name in frame.columns
167
+ if str(name).strip().lower() in {"time", "times", "second", "seconds", "timestamp"}),
168
+ None,
169
+ )
170
+ if time_column is not None and "sf" not in metadata:
171
+ time_values = pd.to_numeric(frame.pop(time_column), errors="raise").to_numpy()
172
+ steps = np.diff(time_values)
173
+ steps = steps[np.isfinite(steps) & (steps > 0)]
174
+ if not len(steps):
175
+ raise ValueError("Time column must contain increasing values to infer sampling frequency")
176
+ metadata["sf"] = float(1.0 / np.median(steps))
177
+ numeric = frame.apply(pd.to_numeric, errors="raise")
178
+ metadata.setdefault("channels", [str(name) for name in numeric.columns])
179
+ metadata["channel_axis"] = 1
180
+ return _build_midata(numeric.to_numpy(), metadata, path)
181
+
182
+
183
+ def load_csv(data_path) -> MiData:
184
+ """Load a headered CSV; infer frequency from a time column if present."""
185
+ return _load_delimited(data_path, ",")
186
+
187
+
188
+ def load_tsv(data_path) -> MiData:
189
+ """Load a headered tab-separated signal table."""
190
+ return _load_delimited(data_path, "\t")
191
+
192
+
193
+ def write_npz(signals, channels, sf, time, npz_file) -> None:
194
+ """Write a pickle-free, self-contained NumPy archive."""
195
+ payload = {f"signal_{i}": np.asarray(signal) for i, signal in enumerate(signals)}
196
+ payload.update(channels=np.asarray(channels, dtype=str), sf=np.asarray(sf, dtype=float),
197
+ time=np.asarray(str(time)))
198
+ np.savez_compressed(npz_file, **payload)
199
+
200
+
201
+ for _extension, _reader in {
202
+ ".npy": load_npy,
203
+ ".npz": load_npz,
204
+ ".csv": load_csv,
205
+ ".tsv": load_tsv,
206
+ }.items():
207
+ register_signal_reader(_extension, _reader)
208
+
209
+ register_signal_writer(".npz", write_npz)