mcp-server-mcsa 0.1.0__py3-none-any.whl → 0.1.1__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.
- mcp_server_mcsa/__init__.py +37 -37
- mcp_server_mcsa/__main__.py +5 -5
- mcp_server_mcsa/analysis/__init__.py +19 -19
- mcp_server_mcsa/analysis/bearing.py +147 -147
- mcp_server_mcsa/analysis/envelope.py +96 -97
- mcp_server_mcsa/analysis/fault_detection.py +424 -425
- mcp_server_mcsa/analysis/file_io.py +429 -428
- mcp_server_mcsa/analysis/motor.py +147 -145
- mcp_server_mcsa/analysis/preprocessing.py +180 -180
- mcp_server_mcsa/analysis/spectral.py +171 -172
- mcp_server_mcsa/analysis/test_signal.py +232 -232
- mcp_server_mcsa/analysis/timefreq.py +132 -132
- mcp_server_mcsa/server.py +954 -955
- {mcp_server_mcsa-0.1.0.dist-info → mcp_server_mcsa-0.1.1.dist-info}/METADATA +3 -1
- mcp_server_mcsa-0.1.1.dist-info/RECORD +18 -0
- {mcp_server_mcsa-0.1.0.dist-info → mcp_server_mcsa-0.1.1.dist-info}/licenses/LICENSE +21 -21
- mcp_server_mcsa-0.1.0.dist-info/RECORD +0 -18
- {mcp_server_mcsa-0.1.0.dist-info → mcp_server_mcsa-0.1.1.dist-info}/WHEEL +0 -0
- {mcp_server_mcsa-0.1.0.dist-info → mcp_server_mcsa-0.1.1.dist-info}/entry_points.txt +0 -0
|
@@ -1,428 +1,429 @@
|
|
|
1
|
-
"""File I/O for loading real‑world motor‑current signals.
|
|
2
|
-
|
|
3
|
-
Supports CSV, WAV, NumPy binary (.npy), and TDMS formats commonly used
|
|
4
|
-
in industrial data‑acquisition systems. Each loader returns a standardised
|
|
5
|
-
dictionary with ``signal``, ``sampling_freq_hz``, ``n_samples``, ``duration_s``
|
|
6
|
-
plus format‑specific metadata.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import csv
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
the
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
``
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if col
|
|
85
|
-
return
|
|
86
|
-
if
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# ---------------------------------------------------------------------------
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
dtype =
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
"
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
"
|
|
225
|
-
"
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
"
|
|
229
|
-
"
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
# ---------------------------------------------------------------------------
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
arr
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
signal = arr
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
"
|
|
275
|
-
"
|
|
276
|
-
"
|
|
277
|
-
"
|
|
278
|
-
"
|
|
279
|
-
"
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
# ---------------------------------------------------------------------------
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
ext
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
)
|
|
340
|
-
elif ext == ".
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
"
|
|
372
|
-
"
|
|
373
|
-
"
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
info["
|
|
382
|
-
info["
|
|
383
|
-
info["
|
|
384
|
-
info["
|
|
385
|
-
info["
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
info["
|
|
405
|
-
info["
|
|
406
|
-
info["
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
info["
|
|
420
|
-
info["
|
|
421
|
-
info["
|
|
422
|
-
info["
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
1
|
+
"""File I/O for loading real‑world motor‑current signals.
|
|
2
|
+
|
|
3
|
+
Supports CSV, WAV, NumPy binary (.npy), and TDMS formats commonly used
|
|
4
|
+
in industrial data‑acquisition systems. Each loader returns a standardised
|
|
5
|
+
dictionary with ``signal``, ``sampling_freq_hz``, ``n_samples``, ``duration_s``
|
|
6
|
+
plus format‑specific metadata.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
import struct
|
|
13
|
+
import wave
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# CSV loader
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
def load_csv(
|
|
24
|
+
file_path: str,
|
|
25
|
+
signal_column: int | str = 1,
|
|
26
|
+
time_column: int | str | None = 0,
|
|
27
|
+
sampling_freq_hz: float | None = None,
|
|
28
|
+
delimiter: str = ",",
|
|
29
|
+
skip_header: int = 1,
|
|
30
|
+
max_rows: int | None = None,
|
|
31
|
+
) -> dict:
|
|
32
|
+
"""Load a current signal from a CSV / TSV file.
|
|
33
|
+
|
|
34
|
+
The CSV can contain a time column and one or more data columns. The
|
|
35
|
+
loader auto‑detects the sampling frequency from the time column unless
|
|
36
|
+
``sampling_freq_hz`` is provided explicitly.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
file_path: Absolute or relative path to the CSV file.
|
|
40
|
+
signal_column: Column index (0‑based int) or header name containing
|
|
41
|
+
the current signal.
|
|
42
|
+
time_column: Column index or header name for time (seconds).
|
|
43
|
+
Set to ``None`` if the file has no time column (then
|
|
44
|
+
``sampling_freq_hz`` is required).
|
|
45
|
+
sampling_freq_hz: Explicit sampling frequency. If ``None``, it is
|
|
46
|
+
inferred from the time column.
|
|
47
|
+
delimiter: Column delimiter (default ``","``).
|
|
48
|
+
skip_header: Number of header rows to skip (default 1).
|
|
49
|
+
max_rows: Maximum number of data rows to read (``None`` → all).
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
``dict`` with keys: ``signal``, ``time_s`` (or ``None``),
|
|
53
|
+
``sampling_freq_hz``, ``n_samples``, ``duration_s``, ``file_path``,
|
|
54
|
+
``format``.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
FileNotFoundError: If the file does not exist.
|
|
58
|
+
ValueError: If sampling frequency cannot be determined.
|
|
59
|
+
"""
|
|
60
|
+
path = Path(file_path).resolve()
|
|
61
|
+
if not path.exists():
|
|
62
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
63
|
+
|
|
64
|
+
# ---- Read raw rows ----
|
|
65
|
+
rows: list[list[str]] = []
|
|
66
|
+
with open(path, newline="", encoding="utf-8-sig") as fh:
|
|
67
|
+
reader = csv.reader(fh, delimiter=delimiter)
|
|
68
|
+
header_row: list[str] | None = None
|
|
69
|
+
for i, row in enumerate(reader):
|
|
70
|
+
if i < skip_header:
|
|
71
|
+
header_row = row
|
|
72
|
+
continue
|
|
73
|
+
if max_rows is not None and len(rows) >= max_rows:
|
|
74
|
+
break
|
|
75
|
+
rows.append(row)
|
|
76
|
+
|
|
77
|
+
if not rows:
|
|
78
|
+
raise ValueError(f"No data rows found in {path}")
|
|
79
|
+
|
|
80
|
+
# ---- Resolve column indices ----
|
|
81
|
+
def _resolve_col(col: int | str | None, header: list[str] | None) -> int | None:
|
|
82
|
+
if col is None:
|
|
83
|
+
return None
|
|
84
|
+
if isinstance(col, int):
|
|
85
|
+
return col
|
|
86
|
+
if header is not None:
|
|
87
|
+
stripped = [h.strip() for h in header]
|
|
88
|
+
if col in stripped:
|
|
89
|
+
return stripped.index(col)
|
|
90
|
+
raise ValueError(f"Column '{col}' not found in header: {header}")
|
|
91
|
+
|
|
92
|
+
sig_idx = _resolve_col(signal_column, header_row)
|
|
93
|
+
time_idx = _resolve_col(time_column, header_row)
|
|
94
|
+
|
|
95
|
+
# ---- Parse numeric data ----
|
|
96
|
+
signal_vals: list[float] = []
|
|
97
|
+
time_vals: list[float] | None = [] if time_idx is not None else None
|
|
98
|
+
|
|
99
|
+
for row in rows:
|
|
100
|
+
try:
|
|
101
|
+
signal_vals.append(float(row[sig_idx])) # type: ignore[index]
|
|
102
|
+
if time_vals is not None and time_idx is not None:
|
|
103
|
+
time_vals.append(float(row[time_idx]))
|
|
104
|
+
except (IndexError, ValueError):
|
|
105
|
+
continue # skip malformed rows
|
|
106
|
+
|
|
107
|
+
signal = np.array(signal_vals, dtype=np.float64)
|
|
108
|
+
|
|
109
|
+
# ---- Infer sampling frequency ----
|
|
110
|
+
time_arr: NDArray[np.floating] | None = None
|
|
111
|
+
if time_vals is not None and len(time_vals) > 1:
|
|
112
|
+
time_arr = np.array(time_vals, dtype=np.float64)
|
|
113
|
+
if sampling_freq_hz is None:
|
|
114
|
+
dt = np.median(np.diff(time_arr))
|
|
115
|
+
if dt <= 0:
|
|
116
|
+
raise ValueError("Time column is not monotonically increasing")
|
|
117
|
+
sampling_freq_hz = float(1.0 / dt)
|
|
118
|
+
|
|
119
|
+
if sampling_freq_hz is None:
|
|
120
|
+
raise ValueError(
|
|
121
|
+
"Cannot determine sampling frequency — provide sampling_freq_hz "
|
|
122
|
+
"or include a time column in the CSV."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
n = len(signal)
|
|
126
|
+
duration = n / sampling_freq_hz
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
"signal": signal.tolist(),
|
|
130
|
+
"time_s": time_arr.tolist() if time_arr is not None else None,
|
|
131
|
+
"sampling_freq_hz": float(sampling_freq_hz),
|
|
132
|
+
"n_samples": n,
|
|
133
|
+
"duration_s": round(duration, 6),
|
|
134
|
+
"file_path": str(path),
|
|
135
|
+
"format": "csv",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# WAV loader
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def load_wav(
|
|
144
|
+
file_path: str,
|
|
145
|
+
channel: int = 0,
|
|
146
|
+
) -> dict:
|
|
147
|
+
"""Load a current signal from a WAV audio file.
|
|
148
|
+
|
|
149
|
+
Many portable DAQ systems and low‑cost recorders save data as WAV.
|
|
150
|
+
The signal is normalised to the full‑scale range of the bit depth.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
file_path: Path to the WAV file.
|
|
154
|
+
channel: Channel index for multi‑channel files (default 0).
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Standardised signal dict.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
FileNotFoundError: If the file does not exist.
|
|
161
|
+
"""
|
|
162
|
+
path = Path(file_path).resolve()
|
|
163
|
+
if not path.exists():
|
|
164
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
165
|
+
|
|
166
|
+
with wave.open(str(path), "rb") as wf:
|
|
167
|
+
n_channels = wf.getnchannels()
|
|
168
|
+
sampwidth = wf.getsampwidth()
|
|
169
|
+
fs = wf.getframerate()
|
|
170
|
+
n_frames = wf.getnframes()
|
|
171
|
+
raw = wf.readframes(n_frames)
|
|
172
|
+
|
|
173
|
+
# Decode to numpy
|
|
174
|
+
if sampwidth == 1:
|
|
175
|
+
dtype = np.uint8
|
|
176
|
+
max_val = 128.0
|
|
177
|
+
offset = 128
|
|
178
|
+
elif sampwidth == 2:
|
|
179
|
+
dtype = np.int16
|
|
180
|
+
max_val = 32768.0
|
|
181
|
+
offset = 0
|
|
182
|
+
elif sampwidth == 3:
|
|
183
|
+
# 24-bit — unpack manually
|
|
184
|
+
n_samples_total = len(raw) // 3
|
|
185
|
+
unpacked = []
|
|
186
|
+
for i in range(n_samples_total):
|
|
187
|
+
b = raw[3 * i : 3 * i + 3]
|
|
188
|
+
val = struct.unpack("<i", b + (b"\xff" if b[2] & 0x80 else b"\x00"))[0]
|
|
189
|
+
unpacked.append(val)
|
|
190
|
+
data = np.array(unpacked, dtype=np.float64)
|
|
191
|
+
max_val = 8388608.0
|
|
192
|
+
offset = 0
|
|
193
|
+
dtype = None # handled above
|
|
194
|
+
elif sampwidth == 4:
|
|
195
|
+
dtype = np.int32
|
|
196
|
+
max_val = 2147483648.0
|
|
197
|
+
offset = 0
|
|
198
|
+
else:
|
|
199
|
+
raise ValueError(f"Unsupported WAV sample width: {sampwidth}")
|
|
200
|
+
|
|
201
|
+
if dtype is not None:
|
|
202
|
+
data = np.frombuffer(raw, dtype=dtype).astype(np.float64) - offset
|
|
203
|
+
|
|
204
|
+
# De‑interleave channels
|
|
205
|
+
if n_channels > 1:
|
|
206
|
+
data = data.reshape(-1, n_channels)
|
|
207
|
+
if channel >= n_channels:
|
|
208
|
+
raise ValueError(f"Channel {channel} out of range (file has {n_channels})")
|
|
209
|
+
data = data[:, channel]
|
|
210
|
+
|
|
211
|
+
# Normalise to ±1
|
|
212
|
+
signal_arr: NDArray[np.floating] = data / max_val # type: ignore[assignment]
|
|
213
|
+
|
|
214
|
+
n = len(signal_arr)
|
|
215
|
+
duration = n / fs
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"signal": signal_arr.tolist(),
|
|
219
|
+
"time_s": None,
|
|
220
|
+
"sampling_freq_hz": float(fs),
|
|
221
|
+
"n_samples": n,
|
|
222
|
+
"duration_s": round(duration, 6),
|
|
223
|
+
"file_path": str(path),
|
|
224
|
+
"format": "wav",
|
|
225
|
+
"metadata": {
|
|
226
|
+
"channels": n_channels,
|
|
227
|
+
"selected_channel": channel,
|
|
228
|
+
"sample_width_bytes": sampwidth,
|
|
229
|
+
"bit_depth": sampwidth * 8,
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# NumPy binary loader
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
def load_npy(
|
|
239
|
+
file_path: str,
|
|
240
|
+
sampling_freq_hz: float,
|
|
241
|
+
column: int = 0,
|
|
242
|
+
) -> dict:
|
|
243
|
+
"""Load a current signal from a NumPy ``.npy`` binary file.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
file_path: Path to the ``.npy`` file.
|
|
247
|
+
sampling_freq_hz: Sampling frequency (must be provided for raw arrays).
|
|
248
|
+
column: If the array is 2‑D, select this column.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Standardised signal dict.
|
|
252
|
+
"""
|
|
253
|
+
path = Path(file_path).resolve()
|
|
254
|
+
if not path.exists():
|
|
255
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
256
|
+
|
|
257
|
+
arr = np.load(str(path))
|
|
258
|
+
|
|
259
|
+
if arr.ndim == 2:
|
|
260
|
+
if column >= arr.shape[1]:
|
|
261
|
+
raise ValueError(f"Column {column} out of range (array has {arr.shape[1]} cols)")
|
|
262
|
+
signal = arr[:, column].astype(np.float64)
|
|
263
|
+
elif arr.ndim == 1:
|
|
264
|
+
signal = arr.astype(np.float64)
|
|
265
|
+
else:
|
|
266
|
+
raise ValueError(f"Expected 1‑D or 2‑D array, got {arr.ndim}‑D")
|
|
267
|
+
|
|
268
|
+
n = len(signal)
|
|
269
|
+
duration = n / sampling_freq_hz
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"signal": signal.tolist(),
|
|
273
|
+
"time_s": None,
|
|
274
|
+
"sampling_freq_hz": float(sampling_freq_hz),
|
|
275
|
+
"n_samples": n,
|
|
276
|
+
"duration_s": round(duration, 6),
|
|
277
|
+
"file_path": str(path),
|
|
278
|
+
"format": "npy",
|
|
279
|
+
"metadata": {
|
|
280
|
+
"original_shape": list(arr.shape),
|
|
281
|
+
"selected_column": column if arr.ndim == 2 else None,
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Unified loader (auto‑detect by extension)
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
SUPPORTED_EXTENSIONS = {".csv", ".tsv", ".txt", ".wav", ".npy"}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def load_signal(
|
|
294
|
+
file_path: str,
|
|
295
|
+
sampling_freq_hz: float | None = None,
|
|
296
|
+
signal_column: int | str = 1,
|
|
297
|
+
time_column: int | str | None = 0,
|
|
298
|
+
delimiter: str | None = None,
|
|
299
|
+
channel: int = 0,
|
|
300
|
+
skip_header: int = 1,
|
|
301
|
+
max_rows: int | None = None,
|
|
302
|
+
) -> dict:
|
|
303
|
+
"""Auto‑detect file format and load a motor‑current signal.
|
|
304
|
+
|
|
305
|
+
Dispatches to the appropriate specialised loader based on the file
|
|
306
|
+
extension.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
file_path: Path to the signal file.
|
|
310
|
+
sampling_freq_hz: Sampling frequency (required for .npy; optional
|
|
311
|
+
for CSV if a time column is present; auto‑detected for WAV).
|
|
312
|
+
signal_column: CSV column (index or name) containing the signal.
|
|
313
|
+
time_column: CSV column for time. ``None`` → no time column.
|
|
314
|
+
delimiter: CSV delimiter. ``None`` → auto‑detect (``,`` for .csv,
|
|
315
|
+
``\\t`` for .tsv/.txt).
|
|
316
|
+
channel: WAV channel index.
|
|
317
|
+
skip_header: CSV header rows to skip.
|
|
318
|
+
max_rows: Maximum rows to read from CSV.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Standardised signal dictionary.
|
|
322
|
+
"""
|
|
323
|
+
path = Path(file_path).resolve()
|
|
324
|
+
ext = path.suffix.lower()
|
|
325
|
+
|
|
326
|
+
if ext in (".csv", ".tsv", ".txt"):
|
|
327
|
+
if delimiter is None:
|
|
328
|
+
delimiter = "\t" if ext in (".tsv", ".txt") else ","
|
|
329
|
+
return load_csv(
|
|
330
|
+
str(path),
|
|
331
|
+
signal_column=signal_column,
|
|
332
|
+
time_column=time_column,
|
|
333
|
+
sampling_freq_hz=sampling_freq_hz,
|
|
334
|
+
delimiter=delimiter,
|
|
335
|
+
skip_header=skip_header,
|
|
336
|
+
max_rows=max_rows,
|
|
337
|
+
)
|
|
338
|
+
elif ext == ".wav":
|
|
339
|
+
return load_wav(str(path), channel=channel)
|
|
340
|
+
elif ext == ".npy":
|
|
341
|
+
if sampling_freq_hz is None:
|
|
342
|
+
raise ValueError("sampling_freq_hz is required for .npy files")
|
|
343
|
+
return load_npy(str(path), sampling_freq_hz)
|
|
344
|
+
else:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"Unsupported file format: '{ext}'. "
|
|
347
|
+
f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def get_signal_file_info(file_path: str) -> dict:
|
|
352
|
+
"""Return file metadata without fully loading the signal.
|
|
353
|
+
|
|
354
|
+
Useful for inspecting large files before loading.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
file_path: Path to the signal file.
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Dictionary with file size, format, estimated samples, etc.
|
|
361
|
+
"""
|
|
362
|
+
path = Path(file_path).resolve()
|
|
363
|
+
if not path.exists():
|
|
364
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
365
|
+
|
|
366
|
+
ext = path.suffix.lower()
|
|
367
|
+
size_bytes = path.stat().st_size
|
|
368
|
+
info: dict = {
|
|
369
|
+
"file_path": str(path),
|
|
370
|
+
"file_name": path.name,
|
|
371
|
+
"extension": ext,
|
|
372
|
+
"size_bytes": size_bytes,
|
|
373
|
+
"size_mb": round(size_bytes / (1024 * 1024), 2),
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if ext == ".wav":
|
|
377
|
+
try:
|
|
378
|
+
with wave.open(str(path), "rb") as wf:
|
|
379
|
+
info["format"] = "wav"
|
|
380
|
+
info["channels"] = wf.getnchannels()
|
|
381
|
+
info["sampling_freq_hz"] = wf.getframerate()
|
|
382
|
+
info["n_samples"] = wf.getnframes()
|
|
383
|
+
info["duration_s"] = round(wf.getnframes() / wf.getframerate(), 3)
|
|
384
|
+
info["sample_width_bytes"] = wf.getsampwidth()
|
|
385
|
+
info["bit_depth"] = wf.getsampwidth() * 8
|
|
386
|
+
except Exception as e:
|
|
387
|
+
info["error"] = str(e)
|
|
388
|
+
elif ext == ".npy":
|
|
389
|
+
try:
|
|
390
|
+
# Read only the header — try public API first, fall back to private
|
|
391
|
+
with open(path, "rb") as fh:
|
|
392
|
+
version = np.lib.format.read_magic(fh)
|
|
393
|
+
try:
|
|
394
|
+
shape, fortran, dtype = np.lib.format._read_array_header(fh, version) # type: ignore[attr-defined]
|
|
395
|
+
except AttributeError:
|
|
396
|
+
# NumPy >= 2.0 removed the private helper
|
|
397
|
+
fh.seek(0)
|
|
398
|
+
_ = np.lib.format.read_magic(fh)
|
|
399
|
+
if version[0] == 1:
|
|
400
|
+
header = np.lib.format.read_array_header_1_0(fh)
|
|
401
|
+
else:
|
|
402
|
+
header = np.lib.format.read_array_header_2_0(fh)
|
|
403
|
+
shape, fortran, dtype = header
|
|
404
|
+
info["format"] = "npy"
|
|
405
|
+
info["shape"] = list(shape)
|
|
406
|
+
info["dtype"] = str(dtype)
|
|
407
|
+
info["n_samples"] = shape[0] if len(shape) >= 1 else 0
|
|
408
|
+
except Exception as e:
|
|
409
|
+
info["error"] = str(e)
|
|
410
|
+
elif ext in (".csv", ".tsv", ".txt"):
|
|
411
|
+
try:
|
|
412
|
+
# Count lines and peek at header
|
|
413
|
+
with open(path, encoding="utf-8-sig") as fh:
|
|
414
|
+
first_line = fh.readline().strip()
|
|
415
|
+
second_line = fh.readline().strip()
|
|
416
|
+
line_count = 2
|
|
417
|
+
for _ in fh:
|
|
418
|
+
line_count += 1
|
|
419
|
+
info["format"] = "csv"
|
|
420
|
+
info["header_line"] = first_line
|
|
421
|
+
info["sample_data_line"] = second_line
|
|
422
|
+
info["total_lines"] = line_count
|
|
423
|
+
info["estimated_data_rows"] = max(0, line_count - 1)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
info["error"] = str(e)
|
|
426
|
+
else:
|
|
427
|
+
info["format"] = "unknown"
|
|
428
|
+
|
|
429
|
+
return info
|