ECGProcess 1.1.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.
- ecgprocess/__init__.py +1 -0
- ecgprocess/_version.py +1 -0
- ecgprocess/constants.py +170 -0
- ecgprocess/errors.py +380 -0
- ecgprocess/example_data/__init__.py +1 -0
- ecgprocess/example_data/example_datasets/.example-XML1.xml.un~ +0 -0
- ecgprocess/example_data/example_datasets/.example-XML1.xsd.un~ +0 -0
- ecgprocess/example_data/example_datasets/.example-XML1_a.xsd.un~ +0 -0
- ecgprocess/example_data/example_datasets/.example-XML1_d.xsd.un~ +0 -0
- ecgprocess/example_data/example_datasets/.example-XML1_wrong.xsd.un~ +0 -0
- ecgprocess/example_data/example_datasets/example-DICOM1.dcm +0 -0
- ecgprocess/example_data/example_datasets/example-DICOM2.dcm +0 -0
- ecgprocess/example_data/example_datasets/example-XML1.xml +675 -0
- ecgprocess/example_data/example_datasets/example-XML1.xsd +518 -0
- ecgprocess/example_data/example_datasets/example-XML1_error.xsd +519 -0
- ecgprocess/example_data/example_datasets/example-XML1_wrong.xsd +519 -0
- ecgprocess/example_data/example_datasets/string_data.txt +1 -0
- ecgprocess/example_data/examples.py +501 -0
- ecgprocess/plot_ecgs.py +921 -0
- ecgprocess/process_dicom.py +243 -0
- ecgprocess/process_xml.py +319 -0
- ecgprocess/tabular.py +1394 -0
- ecgprocess/utils/__init__.py +1 -0
- ecgprocess/utils/config_tools.py +581 -0
- ecgprocess/utils/ecg_tools.py +311 -0
- ecgprocess/utils/engineering_tools.py +385 -0
- ecgprocess/utils/general.py +399 -0
- ecgprocess/utils/reader_tools.py +508 -0
- ecgprocess-1.1.0.dist-info/METADATA +153 -0
- ecgprocess-1.1.0.dist-info/RECORD +33 -0
- ecgprocess-1.1.0.dist-info/WHEEL +5 -0
- ecgprocess-1.1.0.dist-info/licenses/LICENCE.txt +17 -0
- ecgprocess-1.1.0.dist-info/top_level.txt +1 -0
ecgprocess/tabular.py
ADDED
|
@@ -0,0 +1,1394 @@
|
|
|
1
|
+
'''
|
|
2
|
+
A module to process ECG signal data and metadata to tabular form. This also
|
|
3
|
+
includes 2D figures - which are strictly speaking tables of pixels.
|
|
4
|
+
|
|
5
|
+
The module leverages the existing process class instances and based on their
|
|
6
|
+
class attributes extracts the requested data.
|
|
7
|
+
'''
|
|
8
|
+
|
|
9
|
+
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
10
|
+
# imports
|
|
11
|
+
import os
|
|
12
|
+
# import re
|
|
13
|
+
import sys
|
|
14
|
+
import uuid
|
|
15
|
+
import pathlib
|
|
16
|
+
import logging
|
|
17
|
+
import numpy as np
|
|
18
|
+
import pandas as pd
|
|
19
|
+
# filtering out TF warnings
|
|
20
|
+
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2"
|
|
21
|
+
import tensorflow as tf
|
|
22
|
+
# import matplotlib.pylab as plt
|
|
23
|
+
from typing import (
|
|
24
|
+
Callable, Self, Optional, Any, Literal,
|
|
25
|
+
)
|
|
26
|
+
from ecgprocess.errors import (
|
|
27
|
+
FileValidationError,
|
|
28
|
+
XMLValidationError,
|
|
29
|
+
NotCalledError,
|
|
30
|
+
InputValidationError,
|
|
31
|
+
is_type,
|
|
32
|
+
Error_MSG,
|
|
33
|
+
Warn_MSG,
|
|
34
|
+
STDOUT_MSG,
|
|
35
|
+
_check_readable,
|
|
36
|
+
_check_presence,
|
|
37
|
+
)
|
|
38
|
+
from ecgprocess.constants import (
|
|
39
|
+
CoreData as Core,
|
|
40
|
+
TabularNames as TabNames,
|
|
41
|
+
)
|
|
42
|
+
from ecgprocess.utils.general import (
|
|
43
|
+
replace_with_tar,
|
|
44
|
+
_update_kwargs,
|
|
45
|
+
chunk_list,
|
|
46
|
+
update_dict_with_warning,
|
|
47
|
+
)
|
|
48
|
+
# from ecgprocess.plot_ecgs import (
|
|
49
|
+
# ECGDrawing,
|
|
50
|
+
# )
|
|
51
|
+
from ecgprocess.utils.reader_tools import(
|
|
52
|
+
BaseReader,
|
|
53
|
+
)
|
|
54
|
+
from ecgprocess.utils.ecg_tools import(
|
|
55
|
+
signal_dicts_to_numpy_array,
|
|
56
|
+
)
|
|
57
|
+
from ecgprocess.utils.config_tools import(
|
|
58
|
+
ConfigParser,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
62
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
63
|
+
# constants
|
|
64
|
+
CTypes = Core.DataTypes
|
|
65
|
+
CMeta = Core.MetaData
|
|
66
|
+
_log = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
69
|
+
# Example engineering functions
|
|
70
|
+
|
|
71
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
72
|
+
# default
|
|
73
|
+
def metadata_identity(metadata:dict[str, Any], verbose:bool=False,
|
|
74
|
+
**kwargs) -> dict[str, Any]:
|
|
75
|
+
"""
|
|
76
|
+
A place holder identity function, simply returning the same input data
|
|
77
|
+
"""
|
|
78
|
+
is_type(verbose, bool)
|
|
79
|
+
if verbose:
|
|
80
|
+
print(**kwargs)
|
|
81
|
+
return metadata
|
|
82
|
+
|
|
83
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
84
|
+
# default
|
|
85
|
+
def signal_identity(signals:dict[str, np.ndarray], verbose:bool=False,
|
|
86
|
+
**kwargs) -> dict[str, Any]:
|
|
87
|
+
"""
|
|
88
|
+
A place holder identity function, simply returning the same input data
|
|
89
|
+
"""
|
|
90
|
+
is_type(verbose, bool)
|
|
91
|
+
if verbose:
|
|
92
|
+
print(**kwargs)
|
|
93
|
+
if not TabNames.META_DICT in kwargs:
|
|
94
|
+
raise KeyError(f"`{TabNames.META_DICT}` should be included as kwargs")
|
|
95
|
+
return signals
|
|
96
|
+
|
|
97
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
98
|
+
class ECGTable(object):
|
|
99
|
+
'''
|
|
100
|
+
Takes an `BaseReader` instance and loops over a supplied list of files
|
|
101
|
+
containing ECG data and extract relevant information using a `Processing`
|
|
102
|
+
instance and a `Configuration` instance. The extracted information can be
|
|
103
|
+
mapped to a Pandas.DataFrame or saved to disk.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
ecgreader : `BaseReader`
|
|
108
|
+
An instance of the BaseReader data class.
|
|
109
|
+
path_list : `list` [`str`]
|
|
110
|
+
A list of paths to one or more files containing ECG data.
|
|
111
|
+
extract_meta : `bool`, default True
|
|
112
|
+
Whether to extract the metadata data.
|
|
113
|
+
extract_wave : `bool`, default True
|
|
114
|
+
Whether to extract the raw waveforms.
|
|
115
|
+
extract_median : `bool`, default True
|
|
116
|
+
Whether to extract the median beats.
|
|
117
|
+
engineer_meta : `Callable`, default `metadata_identity`
|
|
118
|
+
A function applied to the internal meta_dict object. Please ensure
|
|
119
|
+
the function includes parameter: `meta_dict`.
|
|
120
|
+
engineer_wave : `Callable`, default `signal_identity`
|
|
121
|
+
A function applied to the internal wave_dict object. Please ensure
|
|
122
|
+
the function includes parameters: `wave_dict` and `**kwargs`.
|
|
123
|
+
engineer_median : `Callable`, default `signal_identity`
|
|
124
|
+
A function applied to the internal median_dict object. Please ensure
|
|
125
|
+
the function includes parameters: `median_dict` and `**kwargs`.
|
|
126
|
+
schema : `str` or `NoneType`, default `NoneType`
|
|
127
|
+
The path to an optional XSD schema to validate XML files. Set to
|
|
128
|
+
None to ignore.
|
|
129
|
+
signal_length_w : `int` or `None`, default: `None`
|
|
130
|
+
Target sample count for the per-record `WaveForms` signals. When
|
|
131
|
+
set, each lead is right-padded with `pad_value` or right-truncated
|
|
132
|
+
to this length inside `write_ecg`'s per-record loop, before
|
|
133
|
+
`engineer_wave` runs. `None` disables waveform pad/truncate.
|
|
134
|
+
The output column ``'sampling number padded (waveforms)'``
|
|
135
|
+
records this target value (or ``None``); it does not confirm
|
|
136
|
+
that padding actually changed the array length.
|
|
137
|
+
signal length is equal to duration (second) by sampling rate (Hz).
|
|
138
|
+
For a standard 10 second ECG with 500 Hz the sampling rate is 5,000.
|
|
139
|
+
signal_length_m : `int` or `None`, default: `None`
|
|
140
|
+
Target sample count for the per-record `MedianBeats` signals;
|
|
141
|
+
same semantics as `signal_length_w` for medians. The output
|
|
142
|
+
column ``'sampling number padded (medianbeats)'`` records this
|
|
143
|
+
target value (or ``None``). For a 1.2 second median beat an 500 Hz
|
|
144
|
+
sampling rate the expected sampling rate would be 600.
|
|
145
|
+
pad_value : `int` or `float`, default: `0.0`
|
|
146
|
+
Fill value used when right-padding shorter signals. The date type of
|
|
147
|
+
the original array is preserved.
|
|
148
|
+
|
|
149
|
+
Attributes
|
|
150
|
+
----------
|
|
151
|
+
raw_path_list : `list` [`str`]
|
|
152
|
+
A list of file paths.
|
|
153
|
+
|
|
154
|
+
Methods
|
|
155
|
+
-------
|
|
156
|
+
get_table(unique, **kwargs)
|
|
157
|
+
extract ECG data and maps these to class attributes.
|
|
158
|
+
write_ecg(chunk, target_tar, target_path, tar_mode, file_type, tab_sep,
|
|
159
|
+
tab_compression, tab_append, unique, write_failed, write_chunk_record,
|
|
160
|
+
kwargs_reader, kwargs_tab)
|
|
161
|
+
writes processed ECG data to a single or multiple files.
|
|
162
|
+
|
|
163
|
+
Notes
|
|
164
|
+
-----
|
|
165
|
+
The engineering parameters can be used to supply functions that will be
|
|
166
|
+
applied separately to dictionaries of each processed file before these
|
|
167
|
+
dictionaries are combined and written to disk. The functions are applied in
|
|
168
|
+
the following order to the internal objects: meta_dict (for metadata),
|
|
169
|
+
wave_dict (for waveform signals), median_dict (for medianbeats signals).
|
|
170
|
+
Please ensure the waveform and medianbeats functions includes a **kwargs
|
|
171
|
+
parameter. The kwargs will be used internally to include the metadata. This
|
|
172
|
+
ensures for example, that the engineer_wave function will have access to
|
|
173
|
+
the metadata.
|
|
174
|
+
'''
|
|
175
|
+
# \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
|
|
176
|
+
def __init__(self, ecgreader:BaseReader, path_list:list[str],
|
|
177
|
+
extract_meta:bool=True, extract_wave:bool=True,
|
|
178
|
+
extract_median:bool=True,
|
|
179
|
+
engineer_meta:Callable=metadata_identity,
|
|
180
|
+
engineer_wave:Callable=signal_identity,
|
|
181
|
+
engineer_median:Callable=signal_identity,
|
|
182
|
+
schema:str | None = None,
|
|
183
|
+
signal_length_w:int | None = None,
|
|
184
|
+
signal_length_m:int | None = None,
|
|
185
|
+
pad_value:int | float = 0.0,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Initialises a new instance of `ECGTable`.
|
|
189
|
+
"""
|
|
190
|
+
# #### check input
|
|
191
|
+
is_type(ecgreader, BaseReader)
|
|
192
|
+
is_type(path_list, list)
|
|
193
|
+
is_type(extract_meta, bool)
|
|
194
|
+
is_type(extract_wave, bool)
|
|
195
|
+
is_type(extract_median, bool)
|
|
196
|
+
is_type(schema,
|
|
197
|
+
(str, pathlib.PosixPath, pathlib.WindowsPath, type(None)))
|
|
198
|
+
is_type(signal_length_w, (int, type(None)))
|
|
199
|
+
is_type(signal_length_m, (int, type(None)))
|
|
200
|
+
is_type(pad_value, (int, float))
|
|
201
|
+
# length params, when supplied, must be strictly positive
|
|
202
|
+
if signal_length_w is not None and signal_length_w <= 0:
|
|
203
|
+
raise InputValidationError(
|
|
204
|
+
f"`signal_length_w` must be > 0; got {signal_length_w}.")
|
|
205
|
+
if signal_length_m is not None and signal_length_m <= 0:
|
|
206
|
+
raise InputValidationError(
|
|
207
|
+
f"`signal_length_m` must be > 0; got {signal_length_m}.")
|
|
208
|
+
# #### assign to self
|
|
209
|
+
self.ecgreader = ecgreader
|
|
210
|
+
setattr(self, TabNames.E_META, extract_meta)
|
|
211
|
+
setattr(self, TabNames.E_WAVE, extract_wave)
|
|
212
|
+
setattr(self, TabNames.E_MEDIAN, extract_median)
|
|
213
|
+
setattr(self, TabNames.ENG_META, engineer_meta)
|
|
214
|
+
setattr(self, TabNames.ENG_WAVE, engineer_wave)
|
|
215
|
+
setattr(self, TabNames.ENG_MEDIAN, engineer_median)
|
|
216
|
+
setattr(self, TabNames.RPATH_L, path_list)
|
|
217
|
+
setattr(self, TabNames.SCHEMA, schema)
|
|
218
|
+
setattr(self, TabNames.SIG_LEN_W, signal_length_w)
|
|
219
|
+
setattr(self, TabNames.SIG_LEN_M, signal_length_m)
|
|
220
|
+
setattr(self, TabNames.PAD_VALUE, pad_value)
|
|
221
|
+
# \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
|
|
222
|
+
def __str__(self):
|
|
223
|
+
CLASS_NAME = type(self).__name__
|
|
224
|
+
return (f"{CLASS_NAME} instance with "
|
|
225
|
+
f"ecgreader={self.ecgreader}, "
|
|
226
|
+
f"path_list={getattr(self, TabNames.RPATH_L)}."
|
|
227
|
+
)
|
|
228
|
+
# \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
|
|
229
|
+
def __repr__(self):
|
|
230
|
+
CLASS_NAME = type(self).__name__
|
|
231
|
+
return (f"{CLASS_NAME}(ecgreader={self.ecgreader}, "
|
|
232
|
+
f"path_list={getattr(self, TabNames.RPATH_L)})"
|
|
233
|
+
)
|
|
234
|
+
# \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
|
|
235
|
+
def _pad_or_truncate(self,
|
|
236
|
+
signals:dict[str, np.ndarray] | None,
|
|
237
|
+
target_length:int,
|
|
238
|
+
pad_value:int | float,
|
|
239
|
+
) -> dict[str, np.ndarray] | None:
|
|
240
|
+
"""
|
|
241
|
+
Right-pad or right-truncate each lead in `signals` to a fixed
|
|
242
|
+
sample count. Applies in place mutations.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
signals : `dict` [`str`, `np.ndarray`] or `None`
|
|
247
|
+
Per-lead signal dict. `None` passes through. `None` lead
|
|
248
|
+
values inside the dict pass through unchanged.
|
|
249
|
+
target_length : `int`
|
|
250
|
+
Target sample count per lead. Shorter arrays are right-padded
|
|
251
|
+
with `pad_value`; longer arrays are right-truncated; equal
|
|
252
|
+
length will not be affected.
|
|
253
|
+
pad_value : `int` or `float`
|
|
254
|
+
Fill value used for right-padding. The dtype of the original
|
|
255
|
+
array is preserved by allocating with `dtype=arr.dtype`.
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
signals : `dict` [`str`, `np.ndarray`] or `None`
|
|
260
|
+
The same dict object, with lead values replaced by their
|
|
261
|
+
padded / truncated counterparts. Returned for symmetry with
|
|
262
|
+
the engineer callables (`engineer_wave`, `engineer_median`),
|
|
263
|
+
which take a signal dict and return a signal dict.
|
|
264
|
+
|
|
265
|
+
Notes
|
|
266
|
+
-----
|
|
267
|
+
Mutates `signals` in place. The reader instance is recreated for
|
|
268
|
+
every file by `_loop_table` (`self.ecgreader(p, ...)`), so the
|
|
269
|
+
in-place mutation cannot leak across records. The engineer
|
|
270
|
+
callables run after this method, so they observe the fixed-length
|
|
271
|
+
arrays.
|
|
272
|
+
"""
|
|
273
|
+
# Skip if none
|
|
274
|
+
if signals is None:
|
|
275
|
+
return None
|
|
276
|
+
for lead, arr in signals.items():
|
|
277
|
+
# `None` lead values are legitimate (an engineer callable
|
|
278
|
+
# may null out a lead); preserve them as-is.
|
|
279
|
+
if arr is None:
|
|
280
|
+
continue
|
|
281
|
+
# get signal length and compare to current
|
|
282
|
+
current = arr.shape[0]
|
|
283
|
+
if current == target_length:
|
|
284
|
+
# skip if equal
|
|
285
|
+
continue
|
|
286
|
+
if current > target_length:
|
|
287
|
+
# Right-truncate. `arr[:n]` is a view; `.copy()`
|
|
288
|
+
# decouples the replacement from the original numpy buffer.
|
|
289
|
+
signals[lead] = arr[:target_length].copy()
|
|
290
|
+
continue
|
|
291
|
+
# If all false right-pad
|
|
292
|
+
# Single allocation of the target dtype; the
|
|
293
|
+
# explicit `dtype=arr.dtype` prevents int->float upcast
|
|
294
|
+
# that `np.pad(constant_values=...)` performs silently.
|
|
295
|
+
padded = np.full(target_length, pad_value, dtype=arr.dtype)
|
|
296
|
+
padded[:current] = arr
|
|
297
|
+
signals[lead] = padded
|
|
298
|
+
return signals
|
|
299
|
+
# \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
|
|
300
|
+
def __call__(self, ignore_permission:bool = True, ignore_data:bool = False,
|
|
301
|
+
ignore_invalid:bool = False, confirm_meta:bool=False,
|
|
302
|
+
confirm_wave:bool=False, confirm_median=False,
|
|
303
|
+
verbose:bool=False,
|
|
304
|
+
) -> Self:
|
|
305
|
+
"""
|
|
306
|
+
Will take a BaseReader instance and loops over a list of file paths
|
|
307
|
+
and and confirm the files exist and have appropriate read permission.
|
|
308
|
+
|
|
309
|
+
Parameters
|
|
310
|
+
----------
|
|
311
|
+
ignore_permission : `bool`, default True
|
|
312
|
+
Skips file permission errors. The failed file names will be
|
|
313
|
+
recorded for review.
|
|
314
|
+
ignore_data : `bool`, default False
|
|
315
|
+
Whether files with missing MetaData, WaveForm, or MedianBeat
|
|
316
|
+
attributes should be skipped. The file names for these failures
|
|
317
|
+
will be recorded. Note, to limit I/O calls this step will not
|
|
318
|
+
be conducted during __call__ and instead will be applied when
|
|
319
|
+
processing data using any method with utilises `_loop_table`.
|
|
320
|
+
ignore_invalid : `bool`, default False
|
|
321
|
+
Whether XML files who failed to an XSD validation schema should
|
|
322
|
+
be skipped. Note, to limit I/O calls schema validation will not be
|
|
323
|
+
conducted during __call__ and instead will be applied when
|
|
324
|
+
processing data using any method with utilises `_loop_table`.
|
|
325
|
+
confirm_meta, confirm_wave, confirm_meta : `bool`, default False
|
|
326
|
+
Whether to skip extraction the entire file when the indicated
|
|
327
|
+
attribute is empty.
|
|
328
|
+
verbose : `bool`, default `False`
|
|
329
|
+
Whether to print warnings.
|
|
330
|
+
|
|
331
|
+
Attributes
|
|
332
|
+
----------
|
|
333
|
+
failed_path_list : `list` [`str`]
|
|
334
|
+
File paths which are either absent or without read permission.
|
|
335
|
+
curated_path_list : `list` [`str`]
|
|
336
|
+
File paths which are readable.
|
|
337
|
+
|
|
338
|
+
Returns
|
|
339
|
+
-------
|
|
340
|
+
ECGTable instance
|
|
341
|
+
Returns the class instance with updated attributes.
|
|
342
|
+
"""
|
|
343
|
+
# #### check input
|
|
344
|
+
is_type(ignore_data, bool)
|
|
345
|
+
is_type(ignore_permission, bool)
|
|
346
|
+
is_type(ignore_invalid, bool)
|
|
347
|
+
is_type(verbose, bool)
|
|
348
|
+
# #### assign to self
|
|
349
|
+
self.verbose = verbose
|
|
350
|
+
setattr(self, TabNames.SKIP_PERM, ignore_permission)
|
|
351
|
+
setattr(self, TabNames.SKIP_DATA, ignore_data)
|
|
352
|
+
setattr(self, TabNames.SKIP_INVALID, ignore_invalid)
|
|
353
|
+
setattr(self, TabNames.CONFIRM_META, confirm_meta)
|
|
354
|
+
setattr(self, TabNames.CONFIRM_WAVE, confirm_wave)
|
|
355
|
+
setattr(self, TabNames.CONFIRM_MEDIAN, confirm_median)
|
|
356
|
+
# #### confirm internal logic
|
|
357
|
+
MSG_LOGIC="`{}` is True but `{}` is False, please adjust."
|
|
358
|
+
if getattr(self, TabNames.CONFIRM_META) == True and\
|
|
359
|
+
getattr(self, TabNames.E_META) == False:
|
|
360
|
+
raise ValueError(MSG_LOGIC.format(
|
|
361
|
+
TabNames.CONFIRM_META, TabNames.E_META))
|
|
362
|
+
if getattr(self, TabNames.CONFIRM_WAVE) == True and\
|
|
363
|
+
getattr(self, TabNames.E_WAVE) == False:
|
|
364
|
+
raise ValueError(MSG_LOGIC.format(
|
|
365
|
+
TabNames.CONFIRM_WAVE, TabNames.E_WAVE))
|
|
366
|
+
if getattr(self, TabNames.CONFIRM_MEDIAN) == True and\
|
|
367
|
+
getattr(self, TabNames.E_MEDIAN) == False:
|
|
368
|
+
raise ValueError(MSG_LOGIC.format(
|
|
369
|
+
TabNames.CONFIRM_MEDIAN, TabNames.E_MEDIAN))
|
|
370
|
+
|
|
371
|
+
# #### loop over path
|
|
372
|
+
empty_list = []
|
|
373
|
+
curated_list = []
|
|
374
|
+
# #### loop over individual files and assign to self
|
|
375
|
+
for p in getattr(self, TabNames.RPATH_L):
|
|
376
|
+
try:
|
|
377
|
+
# add p and remove if skipp_missing == True
|
|
378
|
+
curated_list.append(p)
|
|
379
|
+
_check_readable(p)
|
|
380
|
+
except PermissionError as PE:
|
|
381
|
+
empty_list.append(p)
|
|
382
|
+
# try next
|
|
383
|
+
if getattr(self, TabNames.SKIP_PERM):
|
|
384
|
+
curated_list.pop()
|
|
385
|
+
pass
|
|
386
|
+
else:
|
|
387
|
+
raise PE
|
|
388
|
+
setattr(self, TabNames.FPATH_L, empty_list)
|
|
389
|
+
setattr(self, TabNames.CPATH_L, curated_list)
|
|
390
|
+
# #### do we want to print the list of invalid files.
|
|
391
|
+
if len(getattr(self, TabNames.FPATH_L)) > 0:
|
|
392
|
+
_log.warning(
|
|
393
|
+
'The following files could not be accessed or found: %s.',
|
|
394
|
+
getattr(self, TabNames.FPATH_L),
|
|
395
|
+
)
|
|
396
|
+
# #### Return
|
|
397
|
+
return self
|
|
398
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
399
|
+
def _loop_table(self, path_list:list[str],
|
|
400
|
+
parsed_config:ConfigParser,
|
|
401
|
+
unique:bool=True,
|
|
402
|
+
kwargs_reader_call:dict[Any, Any] | None = None,
|
|
403
|
+
kwargs_reader_extract:dict[Any, Any] | None = None,
|
|
404
|
+
) -> tuple[list[dict[str, Any]], list[dict[str, np.ndarray]],
|
|
405
|
+
list[dict[str, np.ndarray]]]:
|
|
406
|
+
"""
|
|
407
|
+
Will loop over the supplied files and extract ECG data.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
path_list : `list` [`str`]
|
|
412
|
+
A list of paths to one or more files containing ECG data.
|
|
413
|
+
parsed_config : `ConfigParser`
|
|
414
|
+
A parsed configuration file which was mapped using
|
|
415
|
+
`ConfigParser.map`.
|
|
416
|
+
unique : `bool`, default `True`
|
|
417
|
+
ensures the `UID` metadata items are unique between files. Please
|
|
418
|
+
ensure the UID key-value pair is appropriately set in
|
|
419
|
+
the config file. If set to false a file-specific integer key will
|
|
420
|
+
be assigned instead.
|
|
421
|
+
kwargs_reader_call: `dict` [`any`, `any`] or None, default `NoneType`
|
|
422
|
+
passed to the kwargs of the `BaseReader` call method.
|
|
423
|
+
kwargs_reader_extract: `dict` [`any`, `any`] or None, default `NoneType`
|
|
424
|
+
passed to the `BaseReader` extract method.
|
|
425
|
+
|
|
426
|
+
Attributes
|
|
427
|
+
----------
|
|
428
|
+
key_list: `list` [`str`]
|
|
429
|
+
A list of unique identifiers.
|
|
430
|
+
no_data_list : `list` [`str`]
|
|
431
|
+
A list of files without the requested attributes: MetaData,
|
|
432
|
+
WaveForms, and/or MedianBeats.
|
|
433
|
+
invalid_list : `list` [`str`]
|
|
434
|
+
A list of files which failed to validate.
|
|
435
|
+
_id_start_number : `int`
|
|
436
|
+
Records the integer start number used as place holder UID when
|
|
437
|
+
unique = `False`. This attributes ensures UIDs are unique across
|
|
438
|
+
multiple calls to the method.
|
|
439
|
+
|
|
440
|
+
Returns
|
|
441
|
+
-------
|
|
442
|
+
tuple of list
|
|
443
|
+
the metadata, waveform signals, and medianbeats signals.
|
|
444
|
+
|
|
445
|
+
Notes
|
|
446
|
+
-----
|
|
447
|
+
To keep track of multiple ECG files the function will try to use the
|
|
448
|
+
the privileged variable UID. If this is not found in the supplied
|
|
449
|
+
`MetaData` it will instead assign an integer value starting from 1 for
|
|
450
|
+
each file processed.
|
|
451
|
+
"""
|
|
452
|
+
# #### check input
|
|
453
|
+
# NOTE path_list has already been test - not doing this again because
|
|
454
|
+
# it is a private method.
|
|
455
|
+
is_type(unique, bool)
|
|
456
|
+
# if kwargs is None assigned an empty dict
|
|
457
|
+
kwargs_reader_call = kwargs_reader_call or {}
|
|
458
|
+
kwargs_reader_extract = kwargs_reader_extract or {}
|
|
459
|
+
kwargs_reader_extract = _update_kwargs(
|
|
460
|
+
update_dict=kwargs_reader_extract, config=parsed_config,
|
|
461
|
+
)
|
|
462
|
+
# #### check if __call__ has been run
|
|
463
|
+
# note that we are not directly using this attribute otherwise and
|
|
464
|
+
# instead using the supplied path_list.
|
|
465
|
+
if not hasattr(self, TabNames.CPATH_L):
|
|
466
|
+
raise NotCalledError()
|
|
467
|
+
# #### extract data
|
|
468
|
+
meta_list, wave_list, median_list = [[] for _ in range(3)]
|
|
469
|
+
# the lists will be appended after each iteration
|
|
470
|
+
if hasattr(self, TabNames.KEY_L) == False:
|
|
471
|
+
setattr(self, TabNames.KEY_L, [])
|
|
472
|
+
if hasattr(self, TabNames.NO_DATA_L) == False:
|
|
473
|
+
setattr(self, TabNames.NO_DATA_L, [])
|
|
474
|
+
if hasattr(self, TabNames.INVALID_L) == False:
|
|
475
|
+
setattr(self, TabNames.INVALID_L, [])
|
|
476
|
+
if hasattr(self, TabNames.ID_START) == False:
|
|
477
|
+
setattr(self, TabNames.ID_START, 1)
|
|
478
|
+
# loop over individual files
|
|
479
|
+
for i, p in enumerate(path_list,
|
|
480
|
+
start=getattr(self, TabNames.ID_START)):
|
|
481
|
+
if self.verbose == True:
|
|
482
|
+
print(STDOUT_MSG.PROCESSING_PATH.format(p), file=sys.stdout)
|
|
483
|
+
# get instance and check how errors should be handled
|
|
484
|
+
if not getattr(self, TabNames.SCHEMA) is None:
|
|
485
|
+
try:
|
|
486
|
+
ecg_inst = self.ecgreader(
|
|
487
|
+
p, schema=getattr(self, TabNames.SCHEMA),
|
|
488
|
+
verbose=self.verbose,
|
|
489
|
+
**kwargs_reader_call)
|
|
490
|
+
ecg_inst = ecg_inst.extract(**kwargs_reader_extract)
|
|
491
|
+
except XMLValidationError as XML_VAL:
|
|
492
|
+
getattr(self, TabNames.INVALID_L).append(p)
|
|
493
|
+
if getattr(self, TabNames.SKIP_INVALID):
|
|
494
|
+
continue
|
|
495
|
+
else:
|
|
496
|
+
raise XML_VAL
|
|
497
|
+
else:
|
|
498
|
+
try:
|
|
499
|
+
ecg_inst = self.ecgreader(p, verbose=self.verbose,
|
|
500
|
+
**kwargs_reader_call)
|
|
501
|
+
ecg_inst = ecg_inst.extract(**kwargs_reader_extract)
|
|
502
|
+
except AttributeError as AE:
|
|
503
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
504
|
+
if getattr(self, TabNames.SKIP_DATA):
|
|
505
|
+
continue
|
|
506
|
+
else:
|
|
507
|
+
raise AE
|
|
508
|
+
# extract unique identifier.
|
|
509
|
+
if unique == True:
|
|
510
|
+
if hasattr(ecg_inst, CTypes.MetaData) == False:
|
|
511
|
+
raise AttributeError('`unique` is `True`, but there is '
|
|
512
|
+
'no MetaData to extract the UID from.'
|
|
513
|
+
)
|
|
514
|
+
else:
|
|
515
|
+
if not getattr(ecg_inst, CTypes.MetaData)[CMeta.UID] is None:
|
|
516
|
+
key = str(
|
|
517
|
+
getattr(ecg_inst, CTypes.MetaData)[CMeta.UID]
|
|
518
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
raise KeyError(f"`{CMeta.UID}` is None.")
|
|
521
|
+
else:
|
|
522
|
+
key = str(i)
|
|
523
|
+
# confirm the key is unique
|
|
524
|
+
if key in getattr(self, TabNames.KEY_L):
|
|
525
|
+
raise IndexError(
|
|
526
|
+
'The current file seems to have been extracted already. '
|
|
527
|
+
'Please ensure the supplied files are unique. '
|
|
528
|
+
f'This is the current file ID {key} and these '
|
|
529
|
+
'are the IDs of the processed files '
|
|
530
|
+
f'{getattr(self, TabNames.KEY_L)}.'
|
|
531
|
+
)
|
|
532
|
+
else:
|
|
533
|
+
getattr(self, TabNames.KEY_L).append(key)
|
|
534
|
+
# empty objects
|
|
535
|
+
meta_temp = []
|
|
536
|
+
wave_temp, median_temp = ({} for _ in range(2))
|
|
537
|
+
# pad/truncate config (None when the user did not opt in).
|
|
538
|
+
sig_len_w = getattr(self, TabNames.SIG_LEN_W)
|
|
539
|
+
sig_len_m = getattr(self, TabNames.SIG_LEN_M)
|
|
540
|
+
pad_value = getattr(self, TabNames.PAD_VALUE)
|
|
541
|
+
# append metadata.
|
|
542
|
+
meta_available = False
|
|
543
|
+
if getattr(self, TabNames.E_META):
|
|
544
|
+
try:
|
|
545
|
+
meta_temp = getattr(ecg_inst, CTypes.MetaData)
|
|
546
|
+
# add key to meta data
|
|
547
|
+
meta_temp[TabNames.KEY] = key
|
|
548
|
+
# add filename
|
|
549
|
+
meta_temp[TabNames.FILENAM] = p
|
|
550
|
+
# Added padded columns - will stay None if not used.
|
|
551
|
+
meta_temp[TabNames.SN_W_PADDED] = sig_len_w
|
|
552
|
+
meta_temp[TabNames.SN_M_PADDED] = sig_len_m
|
|
553
|
+
# DURATION_PADDED is waveforms-only and needs the
|
|
554
|
+
# original sampling frequency; emit a warning when the
|
|
555
|
+
# user opted in but SF disagrees with the assumed
|
|
556
|
+
# 500 Hz that the rest of the pipeline targets.
|
|
557
|
+
# duration pad is len/sampling freq
|
|
558
|
+
sf_val = meta_temp.get(CMeta.SF)
|
|
559
|
+
if sig_len_w is not None and sf_val:
|
|
560
|
+
meta_temp[TabNames.DURATION_PADDED] = (
|
|
561
|
+
sig_len_w / sf_val)
|
|
562
|
+
if sf_val != 500:
|
|
563
|
+
_log.warning(
|
|
564
|
+
"Pad/truncate target assumes 500 Hz but "
|
|
565
|
+
"%s=%s for file %s.",
|
|
566
|
+
CMeta.SF, sf_val, p,
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
meta_temp[TabNames.DURATION_PADDED] = None
|
|
570
|
+
# available.
|
|
571
|
+
meta_available = True
|
|
572
|
+
except AttributeError as AE:
|
|
573
|
+
if getattr(self, TabNames.SKIP_DATA):
|
|
574
|
+
# skip the entire run there if there is metadata
|
|
575
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
576
|
+
if getattr(self, TabNames.CONFIRM_META):
|
|
577
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
578
|
+
continue
|
|
579
|
+
else:
|
|
580
|
+
raise AE
|
|
581
|
+
if meta_available == True:
|
|
582
|
+
try:
|
|
583
|
+
# piping the metadata through the engineer function
|
|
584
|
+
meta_org = meta_temp.copy()
|
|
585
|
+
meta_new =\
|
|
586
|
+
getattr(self, TabNames.ENG_META)(
|
|
587
|
+
meta_temp,
|
|
588
|
+
)
|
|
589
|
+
if getattr(self, TabNames.CONFIRM_META):
|
|
590
|
+
# skip if everything is None
|
|
591
|
+
if all(v is None for v in meta_new.values()):
|
|
592
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
593
|
+
continue
|
|
594
|
+
# `meta_temp` is supplied to the wave/median functions
|
|
595
|
+
meta_temp = update_dict_with_warning(
|
|
596
|
+
meta_org, meta_new, verbose=self.verbose)
|
|
597
|
+
except FileValidationError as FILE_VAL:
|
|
598
|
+
getattr(self, TabNames.INVALID_L).append(p)
|
|
599
|
+
if getattr(self, TabNames.SKIP_INVALID):
|
|
600
|
+
continue
|
|
601
|
+
else:
|
|
602
|
+
raise FILE_VAL
|
|
603
|
+
# Append waveform signals.
|
|
604
|
+
if getattr(self, TabNames.E_WAVE):
|
|
605
|
+
try:
|
|
606
|
+
# piping the waveform signals through the engineer function
|
|
607
|
+
# metadata is supplied through the function kwargs
|
|
608
|
+
wave_temp = getattr(ecg_inst, CTypes.WaveForms)
|
|
609
|
+
# opt-in pad/truncate before the engineer runs so the
|
|
610
|
+
# engineer (and every downstream writer) sees fixed
|
|
611
|
+
# length arrays.
|
|
612
|
+
if sig_len_w is not None:
|
|
613
|
+
wave_temp = self._pad_or_truncate(
|
|
614
|
+
wave_temp, sig_len_w, pad_value)
|
|
615
|
+
wave_temp =\
|
|
616
|
+
getattr(self, TabNames.ENG_WAVE)(
|
|
617
|
+
wave_temp, meta_dict=meta_temp
|
|
618
|
+
)
|
|
619
|
+
if getattr(self, TabNames.CONFIRM_WAVE):
|
|
620
|
+
# skip if everything is None
|
|
621
|
+
if all(v is None for v in wave_temp.values()):
|
|
622
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
623
|
+
continue
|
|
624
|
+
except AttributeError as AE:
|
|
625
|
+
if getattr(self, TabNames.SKIP_DATA):
|
|
626
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
627
|
+
# do we want to skip the entire file?
|
|
628
|
+
if getattr(self, TabNames.CONFIRM_WAVE):
|
|
629
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
630
|
+
continue
|
|
631
|
+
else:
|
|
632
|
+
raise AE
|
|
633
|
+
# Append median beat signals.
|
|
634
|
+
if getattr(self, TabNames.E_MEDIAN):
|
|
635
|
+
try:
|
|
636
|
+
# piping the medianbeats signals through the engineer
|
|
637
|
+
# function metadata is supplied through the function kwargs
|
|
638
|
+
median_temp = getattr(ecg_inst, CTypes.MedianBeats)
|
|
639
|
+
# opt-in pad/truncate (see waveform block above).
|
|
640
|
+
if sig_len_m is not None:
|
|
641
|
+
median_temp = self._pad_or_truncate(
|
|
642
|
+
median_temp, sig_len_m, pad_value)
|
|
643
|
+
median_temp =\
|
|
644
|
+
getattr(self, TabNames.ENG_MEDIAN)(
|
|
645
|
+
median_temp, meta_dict=meta_temp,
|
|
646
|
+
)
|
|
647
|
+
if getattr(self, TabNames.CONFIRM_MEDIAN):
|
|
648
|
+
# skip if everything is None
|
|
649
|
+
if all(v is None for v in median_temp.values()):
|
|
650
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
651
|
+
continue
|
|
652
|
+
except AttributeError as AE:
|
|
653
|
+
if getattr(self, TabNames.SKIP_DATA):
|
|
654
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
655
|
+
# do we want to skip the entire file?
|
|
656
|
+
if getattr(self, TabNames.CONFIRM_MEDIAN):
|
|
657
|
+
getattr(self,TabNames.NO_DATA_L).append(p)
|
|
658
|
+
continue
|
|
659
|
+
else:
|
|
660
|
+
raise AE
|
|
661
|
+
# append to lists
|
|
662
|
+
if getattr(self, TabNames.E_META):
|
|
663
|
+
meta_list.append(meta_new)
|
|
664
|
+
if getattr(self, TabNames.E_WAVE):
|
|
665
|
+
wave_list.append(wave_temp)
|
|
666
|
+
if getattr(self, TabNames.E_MEDIAN):
|
|
667
|
+
median_list.append(median_temp)
|
|
668
|
+
# make sure this object is unique
|
|
669
|
+
setattr(self,TabNames.NO_DATA_L, list(np.unique(
|
|
670
|
+
getattr(self,TabNames.NO_DATA_L))))
|
|
671
|
+
# record and update the last ID_START
|
|
672
|
+
setattr(self, TabNames.ID_START, i+1)
|
|
673
|
+
# to we want to print warnings?
|
|
674
|
+
if len(getattr(self, TabNames.NO_DATA_L)) > 0:
|
|
675
|
+
_log.warning(
|
|
676
|
+
'The following files do not have the requested data: %s.',
|
|
677
|
+
getattr(self, TabNames.NO_DATA_L),
|
|
678
|
+
)
|
|
679
|
+
if len(getattr(self, TabNames.INVALID_L)) > 0:
|
|
680
|
+
_log.warning(
|
|
681
|
+
'The following XML files could not be validated: %s.',
|
|
682
|
+
getattr(self, TabNames.INVALID_L),
|
|
683
|
+
)
|
|
684
|
+
# check data and return
|
|
685
|
+
return meta_list, wave_list, median_list
|
|
686
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
687
|
+
def get_table(self,
|
|
688
|
+
parsed_config:ConfigParser,
|
|
689
|
+
unique:bool=True,
|
|
690
|
+
kwargs_reader_call:dict[Any, Any] | None = None,
|
|
691
|
+
kwargs_reader_extract:dict[Any, Any] | None = None,
|
|
692
|
+
) -> Self:
|
|
693
|
+
"""
|
|
694
|
+
Returns ECG data as pandas.DataFrames.
|
|
695
|
+
|
|
696
|
+
Parameters
|
|
697
|
+
----------
|
|
698
|
+
parsed_config : `ConfigParser`
|
|
699
|
+
A parsed configuration file which was mapped using
|
|
700
|
+
`ConfigParser.map`.
|
|
701
|
+
unique : `bool`, default `True`
|
|
702
|
+
ensures the `UID` metadata items are unique between files. Please
|
|
703
|
+
ensure the UID key-value pair is appropriately set in
|
|
704
|
+
the config file. If set to false a file-specific integer key will
|
|
705
|
+
be assigned instead.
|
|
706
|
+
kwargs_reader_call: `dict` [`any`, `any`] or None, default `NoneType`
|
|
707
|
+
passed to the kwargs of the `BaseReader` call method.
|
|
708
|
+
kwargs_reader_extract: `dict` [`any`, `any`] or None, default `NoneType`
|
|
709
|
+
passed to the `BaseReader` extract method.
|
|
710
|
+
|
|
711
|
+
Attributes
|
|
712
|
+
----------
|
|
713
|
+
MetaDataTable: pandas.DataFrame
|
|
714
|
+
A table of the metadata.
|
|
715
|
+
WaveFormsTable: pandas.DataFrame
|
|
716
|
+
A long-formatted table with waveforms signals.
|
|
717
|
+
MedianBeatsTable: pandas.DataFrame
|
|
718
|
+
A long-formatted table with the median beat signals.
|
|
719
|
+
|
|
720
|
+
Returns
|
|
721
|
+
-------
|
|
722
|
+
`ECGTable` instance
|
|
723
|
+
Returns the class instance with updated attributes.
|
|
724
|
+
|
|
725
|
+
Notes
|
|
726
|
+
-----
|
|
727
|
+
To keep track of multiple ECG files the function will try to use the
|
|
728
|
+
privileged variable UID. If this is not found in the supplied
|
|
729
|
+
`MetaData` the function will instead assign an integer starting from
|
|
730
|
+
1 as a unique key.
|
|
731
|
+
"""
|
|
732
|
+
# #### run loop
|
|
733
|
+
try:
|
|
734
|
+
meta_list, wave_list, median_list = self._loop_table(
|
|
735
|
+
getattr(self, TabNames.CPATH_L),
|
|
736
|
+
parsed_config=parsed_config, unique=unique,
|
|
737
|
+
kwargs_reader_call=kwargs_reader_call,
|
|
738
|
+
kwargs_reader_extract=kwargs_reader_extract,
|
|
739
|
+
)
|
|
740
|
+
# #### make tables and assign these to self
|
|
741
|
+
if getattr(self, TabNames.E_META):
|
|
742
|
+
setattr(self, CTypes.MetaData, pd.DataFrame(
|
|
743
|
+
meta_list, index=getattr(self, TabNames.KEY_L)
|
|
744
|
+
))
|
|
745
|
+
if getattr(self, TabNames.E_WAVE):
|
|
746
|
+
setattr(self, CTypes.WaveForms, self._get_long_table(
|
|
747
|
+
wave_list, signal_type=CTypes.WaveForms,
|
|
748
|
+
purge_header=True,
|
|
749
|
+
))
|
|
750
|
+
if getattr(self, TabNames.E_MEDIAN):
|
|
751
|
+
setattr(self, CTypes.MedianBeats, self._get_long_table(
|
|
752
|
+
median_list, signal_type=CTypes.MedianBeats,
|
|
753
|
+
purge_header=True,
|
|
754
|
+
))
|
|
755
|
+
finally:
|
|
756
|
+
# cleaning-up.
|
|
757
|
+
if hasattr(self, TabNames.KEY_L) == True:
|
|
758
|
+
delattr(self, TabNames.KEY_L)
|
|
759
|
+
if hasattr(self, TabNames.NO_DATA_L) == True:
|
|
760
|
+
delattr(self, TabNames.NO_DATA_L)
|
|
761
|
+
if hasattr(self, TabNames.INVALID_L) == True:
|
|
762
|
+
delattr(self, TabNames.INVALID_L)
|
|
763
|
+
if hasattr(self, TabNames.ID_START) == True:
|
|
764
|
+
delattr(self, TabNames.ID_START)
|
|
765
|
+
# #### Return
|
|
766
|
+
setattr(self, TabNames.TABLE_CALLED, 'True')
|
|
767
|
+
return self
|
|
768
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
769
|
+
def _get_long_table(self, lead_list:list[dict[str, np.ndarray]],
|
|
770
|
+
signal_type:str, purge_header:bool=True,
|
|
771
|
+
**kwargs,
|
|
772
|
+
) -> pd.DataFrame:
|
|
773
|
+
'''
|
|
774
|
+
Mapping lists of dictionaries containing lead specific numpy arrays
|
|
775
|
+
to a long-formatted pandas table.
|
|
776
|
+
|
|
777
|
+
Parameters
|
|
778
|
+
----------
|
|
779
|
+
lead_list : `list` [`dict` [`str`, `np.ndarray`]
|
|
780
|
+
A list of dictionaries with the lead names mapped to the keys and
|
|
781
|
+
the voltage mapped to the values.
|
|
782
|
+
signal_type : `str`,
|
|
783
|
+
Adds a column `Waveform type` containing this string as a constant.
|
|
784
|
+
purge_header : `bool`, default True
|
|
785
|
+
Set to `False` to make sure the file header persists between calls.
|
|
786
|
+
This is used to ensure the headers are the same between files.
|
|
787
|
+
kwargs
|
|
788
|
+
keyword arguments passed to pd.DataFrame.
|
|
789
|
+
|
|
790
|
+
Returns
|
|
791
|
+
-------
|
|
792
|
+
pd.DataFrame
|
|
793
|
+
A long-formatted table with lead, voltage, sampling sequence
|
|
794
|
+
columns grouped by file (including an unique file indicator column)
|
|
795
|
+
'''
|
|
796
|
+
# #### check input and set constants
|
|
797
|
+
is_type(lead_list, list, 'lead_list')
|
|
798
|
+
is_type(signal_type, str, 'signal_type')
|
|
799
|
+
kwargs_pdd = kwargs
|
|
800
|
+
PREV = 'previous'
|
|
801
|
+
SAMPLING_SEQ = 'Sampling sequence'
|
|
802
|
+
# do we need to purge the header information
|
|
803
|
+
# otherwise this persists across repeated calls
|
|
804
|
+
if purge_header == True:
|
|
805
|
+
setattr(self, PREV, None)
|
|
806
|
+
else:
|
|
807
|
+
if not hasattr(self, PREV):
|
|
808
|
+
setattr(self, PREV, None)
|
|
809
|
+
# #### initiate table and map lists
|
|
810
|
+
table = pd.DataFrame()
|
|
811
|
+
# NOTE w contains a dictionary with k: lead names and v: signals.
|
|
812
|
+
for w, k in zip(lead_list, getattr(self, TabNames.KEY_L), strict=True):
|
|
813
|
+
# ##### are there leads with None instead of signals
|
|
814
|
+
# FIRST get the length of the array and make sure this is unique.
|
|
815
|
+
# SECOND replace these by arrays by NaN
|
|
816
|
+
array_length = []
|
|
817
|
+
for value in w.values():
|
|
818
|
+
if isinstance(value, np.ndarray):
|
|
819
|
+
array_length.append(len(value))
|
|
820
|
+
arr_len = np.unique(array_length)
|
|
821
|
+
if len(arr_len) == 0:
|
|
822
|
+
# add a place holder length
|
|
823
|
+
_log.warning(
|
|
824
|
+
'All the signal data is set to None, replacing these '
|
|
825
|
+
'by arrays of length one.',
|
|
826
|
+
)
|
|
827
|
+
arr_len = 1
|
|
828
|
+
elif len(arr_len) > 1:
|
|
829
|
+
raise ValueError('The number of signals differs per lead: '
|
|
830
|
+
f'`{arr_len}`.')
|
|
831
|
+
for key, sig in w.items():
|
|
832
|
+
if sig is None:
|
|
833
|
+
w[key] = np.full(arr_len, np.nan)
|
|
834
|
+
if arr_len != 1:
|
|
835
|
+
_log.warning(
|
|
836
|
+
"Lead '%s' had a None value and has been "
|
|
837
|
+
"replaced by an array of NaN.",
|
|
838
|
+
key,
|
|
839
|
+
)
|
|
840
|
+
# ##### confirm the dictionary keys/lead names are identical
|
|
841
|
+
current_keys = list(w.keys())
|
|
842
|
+
if getattr(self, PREV) is not None:
|
|
843
|
+
if current_keys != getattr(self, PREV):
|
|
844
|
+
raise KeyError('The dictionaries contain distinct keys. '
|
|
845
|
+
'The last set of valid keys was {}, '
|
|
846
|
+
'compared to {}.'.\
|
|
847
|
+
format(getattr(self, PREV), current_keys))
|
|
848
|
+
# ##### if the same map to dataframe
|
|
849
|
+
temp_df = pd.DataFrame(w, **kwargs_pdd)
|
|
850
|
+
temp_df[TabNames.KEY] = k
|
|
851
|
+
# FIXME this will be fairly slow/inefficient
|
|
852
|
+
table = pd.concat([table, temp_df], ignore_index=True)
|
|
853
|
+
# clean
|
|
854
|
+
setattr(self, PREV, current_keys)
|
|
855
|
+
del current_keys
|
|
856
|
+
del temp_df
|
|
857
|
+
# #### formatting to long
|
|
858
|
+
if table.empty == False:
|
|
859
|
+
table[SAMPLING_SEQ] = table.groupby(
|
|
860
|
+
TabNames.KEY).cumcount()
|
|
861
|
+
long_table = pd.melt(table, id_vars=\
|
|
862
|
+
[TabNames.KEY, SAMPLING_SEQ],
|
|
863
|
+
value_vars=getattr(self, PREV),
|
|
864
|
+
var_name='Lead',
|
|
865
|
+
value_name='Voltage',
|
|
866
|
+
)
|
|
867
|
+
long_table['Signal type'] = signal_type
|
|
868
|
+
else:
|
|
869
|
+
long_table = table
|
|
870
|
+
# #### return
|
|
871
|
+
return long_table
|
|
872
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
873
|
+
def write_ecg(self, parsed_config=ConfigParser,
|
|
874
|
+
chunk:int | None = None, target_tar: None | str = None,
|
|
875
|
+
target_path:str='.', tar_mode:str='w:gz',
|
|
876
|
+
file_type:Literal['table', 'numpy', 'tensorflow'] = 'table',
|
|
877
|
+
tab_sep:str='\t', tab_compression:str | None ='gzip',
|
|
878
|
+
tab_append:bool=True, unique:bool=True,
|
|
879
|
+
write_failed:bool=True, write_chunk_record:bool = False,
|
|
880
|
+
kwargs_reader_call:dict[Any, Any] | None = None,
|
|
881
|
+
kwargs_reader_extract:dict[Any, Any] | None = None,
|
|
882
|
+
kwargs_tab: None | dict[str, Any] = None,
|
|
883
|
+
) -> Self:
|
|
884
|
+
'''
|
|
885
|
+
Extracts chunks of ECG data, and writes these to a single or multiple
|
|
886
|
+
target files which can be optionally tar compressed.
|
|
887
|
+
|
|
888
|
+
Parameters
|
|
889
|
+
----------
|
|
890
|
+
parsed_config : `ConfigParser`
|
|
891
|
+
A parsed configuration file which was mapped using
|
|
892
|
+
`ConfigParser.map`.
|
|
893
|
+
chunk : `int`, default `NoneType`
|
|
894
|
+
The number of sources files written to a single processed file. For
|
|
895
|
+
`file_type=table` set this to 1 with `tab_append=True` minimise the
|
|
896
|
+
memory fingerprint. Set to `NoneType` to combine all the ECG data
|
|
897
|
+
into a single target file.
|
|
898
|
+
target_tar : `str`, default `NoneType`
|
|
899
|
+
The `name` of an optional tarfile where the individual files will
|
|
900
|
+
be written to. The target_tar will be concatenated to
|
|
901
|
+
`target_path`. Depending on the `mode` this directory will be
|
|
902
|
+
tar.gz compressed for example. Set `target_tar` to `NoneType` to
|
|
903
|
+
simply add the files directly to `target_path`.
|
|
904
|
+
Note this will overwrite any potential directory or file with the
|
|
905
|
+
provide name.
|
|
906
|
+
target_path : `str`, default '.'
|
|
907
|
+
The full path where the files should be written to. If provided
|
|
908
|
+
`target_tar` will be created underneath this path, otherwise the
|
|
909
|
+
files will be directly written to the `target_path` terminal
|
|
910
|
+
directory (assuming this is writable).
|
|
911
|
+
file_type : {'table', 'numpy', 'tensorflow'}, default `table`
|
|
912
|
+
Whether to write the files to `tsv` using pandas.DataFrame, to `npz`
|
|
913
|
+
using numpy.savez, or to `tfrecord` using
|
|
914
|
+
tensorflow.io.TFRecordWriter.
|
|
915
|
+
tar_mode : `str`, default `w:gz`
|
|
916
|
+
The tarfile.open mode.
|
|
917
|
+
tab_sep : `str`, default `\\t`
|
|
918
|
+
The file separator, which will be passed to
|
|
919
|
+
pandas.DataFrame.to_csv.
|
|
920
|
+
tab_compression : `str`, default `gzip`
|
|
921
|
+
The file compression passed to pandas.DataFrame.to_csv.
|
|
922
|
+
tab_append : `bool`, default True
|
|
923
|
+
Whether individual chunks should be appended to the `tsv` file.
|
|
924
|
+
unique : `bool`, default `True`
|
|
925
|
+
ensures the `UID` metadata items are unique between files. Please
|
|
926
|
+
ensure the UID metadata key value pair is appropriately set in
|
|
927
|
+
the config file. If set to False an file-specific integer key will
|
|
928
|
+
be assigned instead.
|
|
929
|
+
write_failed : `bool`, default `True`
|
|
930
|
+
Whether to write a text file to disk containing the failed file
|
|
931
|
+
names with some information on why these failed.
|
|
932
|
+
write_chunk_record : `bool`, default `True`
|
|
933
|
+
Whether to include a record matching the chunk indicator to the
|
|
934
|
+
files included in each chunk.
|
|
935
|
+
kwargs_reader_call: `dict` [`any`, `any`] or `None`, default `NoneType`
|
|
936
|
+
passed to the kwargs of the `BaseReader` call method.
|
|
937
|
+
kwargs_reader_extract: `dict` [`any`, `any`] or `None`, default `NoneType`
|
|
938
|
+
passed to the `BaseReader` extract method.
|
|
939
|
+
kwargs_tab : `dict` [`str`, `any`] or `None`, default `None`
|
|
940
|
+
Keyword argument for `pd.DataFrame.to_csv`.
|
|
941
|
+
|
|
942
|
+
Attributes
|
|
943
|
+
----------
|
|
944
|
+
target_path : `str`
|
|
945
|
+
The directory or tar file path were the files are written to.
|
|
946
|
+
|
|
947
|
+
Returns
|
|
948
|
+
-------
|
|
949
|
+
`ECGTable` instance
|
|
950
|
+
The class instance with updated attributes.
|
|
951
|
+
|
|
952
|
+
Raises
|
|
953
|
+
------
|
|
954
|
+
NotADirectoryError or PermissionError
|
|
955
|
+
If the target directory does not exist or is not writable.
|
|
956
|
+
|
|
957
|
+
Notes
|
|
958
|
+
-----
|
|
959
|
+
While file_type='table' can store any kind of information, numpy and
|
|
960
|
+
tfrecord are best used to store numerical/float data. Currently
|
|
961
|
+
non-numerical data are therefore drooped from metadata for these
|
|
962
|
+
filetypes.
|
|
963
|
+
|
|
964
|
+
For numpy and tfrecord the signal data will be automatically
|
|
965
|
+
zero-padded to the longest signal. Missing signals will be presented
|
|
966
|
+
as np.nan. The array columns match the canonical ECG lead order, please
|
|
967
|
+
refer to `ecg_tools.signal_dicts_to_numpy_array` for the exact order.
|
|
968
|
+
'''
|
|
969
|
+
# #### set defaults
|
|
970
|
+
self.kwargs_tab = kwargs_tab or {}
|
|
971
|
+
# expected file_types
|
|
972
|
+
EXP_WRITE=[TabNames.WRITE_TAB, TabNames.WRITE_NUMPY,
|
|
973
|
+
TabNames.WRITE_TFLOW]
|
|
974
|
+
# #### check input
|
|
975
|
+
is_type(parsed_config, ConfigParser)
|
|
976
|
+
is_type(chunk, (type(None), int))
|
|
977
|
+
is_type(target_tar, (type(None), str))
|
|
978
|
+
is_type(target_path, (pathlib.PosixPath, pathlib.WindowsPath, str))
|
|
979
|
+
is_type(tar_mode, str)
|
|
980
|
+
is_type(tab_append, bool)
|
|
981
|
+
is_type(tab_sep, str)
|
|
982
|
+
is_type(tab_compression, (type(None), str))
|
|
983
|
+
is_type(write_failed, bool)
|
|
984
|
+
is_type(write_chunk_record, bool)
|
|
985
|
+
is_type(file_type, str)
|
|
986
|
+
# set to self
|
|
987
|
+
setattr(self, TabNames.WRITE_TYPE, file_type)
|
|
988
|
+
# confirm file_type is correct
|
|
989
|
+
if not file_type in EXP_WRITE:
|
|
990
|
+
raise ValueError(
|
|
991
|
+
Error_MSG.CHOICE_PARM.format('file_type', EXP_WRITE))
|
|
992
|
+
# check readability
|
|
993
|
+
_check_presence(target_path)
|
|
994
|
+
_check_readable(target_path)
|
|
995
|
+
# #### create target path
|
|
996
|
+
# get the current wd if requested
|
|
997
|
+
if target_path == '.':
|
|
998
|
+
target_path = os.getcwd()
|
|
999
|
+
# create a temp directory under target_path or otherwise simply use
|
|
1000
|
+
# target_path
|
|
1001
|
+
if target_tar is not None:
|
|
1002
|
+
# adding a temp dir
|
|
1003
|
+
target = os.path.join(target_path, str(uuid.uuid4()))
|
|
1004
|
+
# make the new child dir
|
|
1005
|
+
# NOTE if target_tar has extensions such as `tar.gz` this will be
|
|
1006
|
+
# included but the directly is not really compressed (will be done
|
|
1007
|
+
# below).
|
|
1008
|
+
os.makedirs(target, exist_ok=True)
|
|
1009
|
+
else:
|
|
1010
|
+
target = target_path
|
|
1011
|
+
setattr(self, TabNames.WRITE_ECG_PATH, target)
|
|
1012
|
+
# #### extract data
|
|
1013
|
+
chunk_record = {}
|
|
1014
|
+
# chunk data
|
|
1015
|
+
if not chunk is None:
|
|
1016
|
+
path_list = chunk_list(getattr(self, TabNames.CPATH_L), chunk)
|
|
1017
|
+
|
|
1018
|
+
else:
|
|
1019
|
+
# making this into single chunk [[ ]]
|
|
1020
|
+
path_list = [getattr(self, TabNames.CPATH_L)]
|
|
1021
|
+
# NOTE First is only relevant when writing to a file using append.
|
|
1022
|
+
first=True
|
|
1023
|
+
# #### loop over the path_list
|
|
1024
|
+
try:
|
|
1025
|
+
for n, p in enumerate(path_list, start=1):
|
|
1026
|
+
# record the chunk_number and the files included
|
|
1027
|
+
# NOTE `p` is a list and `n` an integer
|
|
1028
|
+
chunk_record[n] = p
|
|
1029
|
+
# extract data
|
|
1030
|
+
meta_list, wave_list, median_list = self._loop_table(
|
|
1031
|
+
p, parsed_config=parsed_config,
|
|
1032
|
+
unique=unique,
|
|
1033
|
+
kwargs_reader_call=kwargs_reader_call,
|
|
1034
|
+
kwargs_reader_extract=kwargs_reader_extract,
|
|
1035
|
+
)
|
|
1036
|
+
# write based on the supplied file type
|
|
1037
|
+
if getattr(self, TabNames.WRITE_TYPE) == TabNames.WRITE_TAB:
|
|
1038
|
+
if tab_append == False:
|
|
1039
|
+
self._write_tab(
|
|
1040
|
+
meta_list, wave_list, median_list, target,
|
|
1041
|
+
chunk_num=n if chunk is not None else None,
|
|
1042
|
+
header=True, sep=tab_sep,
|
|
1043
|
+
compression=tab_compression,
|
|
1044
|
+
)
|
|
1045
|
+
# Write to a single file
|
|
1046
|
+
elif first==True:
|
|
1047
|
+
first = False
|
|
1048
|
+
# write first file
|
|
1049
|
+
self._write_tab(meta_list, wave_list, median_list,
|
|
1050
|
+
target, chunk_num=None,
|
|
1051
|
+
header=True, sep=tab_sep,
|
|
1052
|
+
compression=tab_compression,
|
|
1053
|
+
)
|
|
1054
|
+
else:
|
|
1055
|
+
# append
|
|
1056
|
+
self._write_tab(meta_list, wave_list, median_list,
|
|
1057
|
+
target, chunk_num=None,
|
|
1058
|
+
header=False, mode='a', sep=tab_sep,
|
|
1059
|
+
compression=tab_compression,
|
|
1060
|
+
)
|
|
1061
|
+
if getattr(self, TabNames.WRITE_TYPE) == TabNames.WRITE_NUMPY:
|
|
1062
|
+
self._write_numpy(
|
|
1063
|
+
meta_list, wave_list, median_list, target,
|
|
1064
|
+
chunk_num=n if chunk is not None else None,
|
|
1065
|
+
)
|
|
1066
|
+
if getattr(self, TabNames.WRITE_TYPE) == TabNames.WRITE_TFLOW:
|
|
1067
|
+
self._write_tfr(
|
|
1068
|
+
meta_list, wave_list, median_list, target,
|
|
1069
|
+
chunk_num=n if chunk is not None else None,
|
|
1070
|
+
)
|
|
1071
|
+
# deleting the current key list
|
|
1072
|
+
if hasattr(self, TabNames.KEY_L) == True:
|
|
1073
|
+
delattr(self, TabNames.KEY_L)
|
|
1074
|
+
# #### write chunk record
|
|
1075
|
+
if write_chunk_record:
|
|
1076
|
+
chnk_lst = []
|
|
1077
|
+
for cnum, fpaths in chunk_record.items():
|
|
1078
|
+
if isinstance(fpaths, str):
|
|
1079
|
+
raise ValueError('`fpaths` should be a list, not '
|
|
1080
|
+
f'type(fpaths).')
|
|
1081
|
+
# if a string check if it is `, ` separated and map to
|
|
1082
|
+
# list
|
|
1083
|
+
# CHUNK_SPLIT = ', '
|
|
1084
|
+
# if CHUNK_SPLIT in chunk_record:
|
|
1085
|
+
# # NOTE using strip to remove redundant white space
|
|
1086
|
+
# fpaths = [s.strip() for s in fpaths.split(CHUNK_SPLIT)]
|
|
1087
|
+
print('fpaths: ')
|
|
1088
|
+
print(fpaths)
|
|
1089
|
+
print(type(fpaths))
|
|
1090
|
+
# Ensure file_paths is a list or iterable of file paths:
|
|
1091
|
+
for fp in fpaths:
|
|
1092
|
+
chnk_lst.append({'Chunk number': cnum, 'Files': fp})
|
|
1093
|
+
chunk_tab = pd.DataFrame(chnk_lst)
|
|
1094
|
+
chunk_tab.to_csv(os.path.join(target, 'chunkrecord.txt'),
|
|
1095
|
+
sep='\t', header=True, index=False)
|
|
1096
|
+
# #### write failed files
|
|
1097
|
+
if write_failed == True:
|
|
1098
|
+
# adding the reason for failing
|
|
1099
|
+
total_failures =\
|
|
1100
|
+
[(p, 'File permission') for p in\
|
|
1101
|
+
getattr(self, TabNames.FPATH_L) ] +\
|
|
1102
|
+
[ (p, 'No data') for p in\
|
|
1103
|
+
getattr(self, TabNames.NO_DATA_L)] +\
|
|
1104
|
+
[(p, 'Invalid data') for p in\
|
|
1105
|
+
getattr(self, TabNames.INVALID_L)]
|
|
1106
|
+
# writing to text file
|
|
1107
|
+
with open(os.path.join(target, 'FailedFiles.txt'), 'w') as file:
|
|
1108
|
+
for p, cause in total_failures:
|
|
1109
|
+
file.write(p + "\t" + cause + "\n")
|
|
1110
|
+
# #### if needed replace directory by a tarball
|
|
1111
|
+
if target_tar is not None:
|
|
1112
|
+
# create the final target path
|
|
1113
|
+
target_final = os.path.join(target_path, target_tar)
|
|
1114
|
+
replace_with_tar(target, target_final, mode=tar_mode)
|
|
1115
|
+
setattr(self, TabNames.WRITE_ECG_PATH, target_final)
|
|
1116
|
+
finally:
|
|
1117
|
+
# cleaning-up.
|
|
1118
|
+
if hasattr(self, TabNames.NO_DATA_L) == True:
|
|
1119
|
+
delattr(self, TabNames.NO_DATA_L)
|
|
1120
|
+
if hasattr(self, TabNames.INVALID_L) == True:
|
|
1121
|
+
delattr(self, TabNames.INVALID_L)
|
|
1122
|
+
if hasattr(self, TabNames.ID_START) == True:
|
|
1123
|
+
delattr(self, TabNames.ID_START)
|
|
1124
|
+
# #### return
|
|
1125
|
+
return self
|
|
1126
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
1127
|
+
def _write_file_names(self, chunk_num:int|None = None,
|
|
1128
|
+
**kwargs:Optional[Any],
|
|
1129
|
+
) -> tuple[str, str, str, str]:
|
|
1130
|
+
"""
|
|
1131
|
+
Creates filenames with relevant extensions, optionally accounting
|
|
1132
|
+
for chunks by adding the chunk number as suffix.
|
|
1133
|
+
|
|
1134
|
+
Parameters
|
|
1135
|
+
----------
|
|
1136
|
+
chunk_num : `int` or `NoneType`, default `NoneType`
|
|
1137
|
+
Appends the chunk number with a '_' separator before the file
|
|
1138
|
+
extension.
|
|
1139
|
+
|
|
1140
|
+
Returns
|
|
1141
|
+
-------
|
|
1142
|
+
tuple of strings
|
|
1143
|
+
The filenames for metadata, waveforms, medianbeats, and ecg_data.
|
|
1144
|
+
"""
|
|
1145
|
+
# #### check input
|
|
1146
|
+
is_type(chunk_num, (int, type(None)))
|
|
1147
|
+
# Set the initial filenames
|
|
1148
|
+
META_FILE = 'metadata'
|
|
1149
|
+
WAVE_FILE = 'waveforms'
|
|
1150
|
+
MEDIAN_FILE = 'medianbeats'
|
|
1151
|
+
ECG_FILE = 'ecg_data'
|
|
1152
|
+
SEP = ''
|
|
1153
|
+
CHUNK = ''
|
|
1154
|
+
# determine file type
|
|
1155
|
+
if getattr(self, TabNames.WRITE_TYPE) == TabNames.WRITE_TAB:
|
|
1156
|
+
EXT = '.tsv'
|
|
1157
|
+
# is the file compressed?
|
|
1158
|
+
try:
|
|
1159
|
+
if kwargs['compression'] == 'gz':
|
|
1160
|
+
EXT = EXT + '.gz'
|
|
1161
|
+
if kwargs['compression'] == 'gzip':
|
|
1162
|
+
EXT = EXT + '.gz'
|
|
1163
|
+
if kwargs['compression'] == 'bz2':
|
|
1164
|
+
EXT = EXT + '.bz2'
|
|
1165
|
+
if kwargs['compression'] == 'zip':
|
|
1166
|
+
EXT = EXT + '.zip'
|
|
1167
|
+
if kwargs['compression'] == 'xz':
|
|
1168
|
+
EXT = EXT + '.xz'
|
|
1169
|
+
if kwargs['compression'] == 'zst':
|
|
1170
|
+
EXT = EXT + '.zst'
|
|
1171
|
+
except KeyError:
|
|
1172
|
+
pass
|
|
1173
|
+
elif getattr(self, TabNames.WRITE_TYPE) == TabNames.WRITE_NUMPY:
|
|
1174
|
+
EXT = '.npz'
|
|
1175
|
+
elif getattr(self, TabNames.WRITE_TYPE) == TabNames.WRITE_TFLOW:
|
|
1176
|
+
EXT = '.tfrecord'
|
|
1177
|
+
# Are we writing in chunks?
|
|
1178
|
+
if not chunk_num is None:
|
|
1179
|
+
SEP = '_'
|
|
1180
|
+
CHUNK = str(chunk_num)
|
|
1181
|
+
# the final file names
|
|
1182
|
+
META_FILE = META_FILE + SEP + CHUNK + EXT
|
|
1183
|
+
WAVE_FILE = WAVE_FILE + SEP + CHUNK + EXT
|
|
1184
|
+
MEDIAN_FILE = MEDIAN_FILE + SEP + CHUNK + EXT
|
|
1185
|
+
ECG_FILE = ECG_FILE + SEP + CHUNK + EXT
|
|
1186
|
+
# return
|
|
1187
|
+
return META_FILE, WAVE_FILE, MEDIAN_FILE, ECG_FILE
|
|
1188
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
1189
|
+
def _write_tab(self, meta_list:list[dict[str, Any]],
|
|
1190
|
+
wave_list:list[dict[str, np.ndarray]],
|
|
1191
|
+
median_list:list[dict[str, np.ndarray]],
|
|
1192
|
+
target:str,
|
|
1193
|
+
chunk_num:int | None,
|
|
1194
|
+
**kwargs,
|
|
1195
|
+
) -> None:
|
|
1196
|
+
"""
|
|
1197
|
+
Writes data to a flat tsv table.
|
|
1198
|
+
|
|
1199
|
+
Parameters
|
|
1200
|
+
----------
|
|
1201
|
+
wave_list : `list` [`dict` [`str`, `any`]]
|
|
1202
|
+
A list of dictionaries containing metadata.
|
|
1203
|
+
wave_list : `list` [`dict` [`str`, `np.ndarray`]]
|
|
1204
|
+
A list of dictionaries containing waveform signals.
|
|
1205
|
+
median_list : `list` [`dict` [`str`, `np.ndarray`]]
|
|
1206
|
+
A list of dictionaries containing medianbeats signals.
|
|
1207
|
+
target: `str`
|
|
1208
|
+
The target directory.
|
|
1209
|
+
chunk_num : `int` or `NoneType`
|
|
1210
|
+
Appends the chunk number with a '_' separator before the file
|
|
1211
|
+
extension.
|
|
1212
|
+
|
|
1213
|
+
Notes
|
|
1214
|
+
-----
|
|
1215
|
+
This function does not return anything.
|
|
1216
|
+
"""
|
|
1217
|
+
# #### check input
|
|
1218
|
+
is_type(chunk_num, (int, type(None)))
|
|
1219
|
+
kwargs_tab_combi = {**self.kwargs_tab, **kwargs}
|
|
1220
|
+
# #### Set the filenames
|
|
1221
|
+
META_FILE, WAVE_FILE, MEDIAN_FILE, _=\
|
|
1222
|
+
self._write_file_names(chunk_num=chunk_num, **kwargs_tab_combi)
|
|
1223
|
+
# #### write individual files
|
|
1224
|
+
if getattr(self, TabNames.E_META):
|
|
1225
|
+
pd.DataFrame(meta_list, index=getattr(self, TabNames.KEY_L)
|
|
1226
|
+
).to_csv(
|
|
1227
|
+
os.path.join(target, META_FILE),
|
|
1228
|
+
**kwargs_tab_combi,
|
|
1229
|
+
)
|
|
1230
|
+
if getattr(self, TabNames.E_WAVE):
|
|
1231
|
+
self._get_long_table(
|
|
1232
|
+
wave_list, signal_type=CTypes.WaveForms,
|
|
1233
|
+
purge_header=True,
|
|
1234
|
+
).to_csv(
|
|
1235
|
+
os.path.join(target, WAVE_FILE),
|
|
1236
|
+
**kwargs_tab_combi,
|
|
1237
|
+
)
|
|
1238
|
+
if getattr(self, TabNames.E_MEDIAN):
|
|
1239
|
+
self._get_long_table(
|
|
1240
|
+
median_list, signal_type=CTypes.MedianBeats,
|
|
1241
|
+
purge_header=True,
|
|
1242
|
+
).to_csv(
|
|
1243
|
+
os.path.join(target, MEDIAN_FILE),
|
|
1244
|
+
**kwargs_tab_combi,
|
|
1245
|
+
)
|
|
1246
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
1247
|
+
def _write_numpy(self, meta_list:list[dict[str, Any]],
|
|
1248
|
+
wave_list:list[dict[str, np.ndarray]],
|
|
1249
|
+
median_list:list[dict[str, np.ndarray]],
|
|
1250
|
+
target:str,
|
|
1251
|
+
chunk_num:int | None,
|
|
1252
|
+
numeric_meta:bool = True,
|
|
1253
|
+
) -> None:
|
|
1254
|
+
"""
|
|
1255
|
+
Writes data to a compressed numpy file `.npz`.
|
|
1256
|
+
|
|
1257
|
+
Parameters
|
|
1258
|
+
----------
|
|
1259
|
+
wave_list : `list` [`dict` [`str`, `any`]]
|
|
1260
|
+
A list of dictionaries containing metadata.
|
|
1261
|
+
wave_list : `list` [`dict` [`str`, `np.ndarray`]]
|
|
1262
|
+
A list of dictionaries containing waveform signals.
|
|
1263
|
+
median_list : `list` [`dict` [`str`, `np.ndarray`]]
|
|
1264
|
+
A list of dictionaries containing medianbeats signals.
|
|
1265
|
+
target: `str`
|
|
1266
|
+
The target directory.
|
|
1267
|
+
chunk_num : `int` or `NoneType`
|
|
1268
|
+
Appends the chunk number with a '_' separator before the file
|
|
1269
|
+
extension.
|
|
1270
|
+
numeric_meta : `bool`, default `True`
|
|
1271
|
+
Whether non-numeric data should be dropped from the metadata.
|
|
1272
|
+
|
|
1273
|
+
Notes
|
|
1274
|
+
-----
|
|
1275
|
+
This function does not return anything.
|
|
1276
|
+
"""
|
|
1277
|
+
# #### check input
|
|
1278
|
+
is_type(chunk_num, (int, type(None)))
|
|
1279
|
+
is_type(numeric_meta, bool)
|
|
1280
|
+
# Set the filenames
|
|
1281
|
+
_, _, _, ECG_FILE=\
|
|
1282
|
+
self._write_file_names(chunk_num=chunk_num)
|
|
1283
|
+
# #### write individual files
|
|
1284
|
+
res_dict = {}
|
|
1285
|
+
if getattr(self, TabNames.E_META):
|
|
1286
|
+
tab1 = pd.DataFrame(meta_list)
|
|
1287
|
+
if numeric_meta:
|
|
1288
|
+
old_cols = set(tab1.columns)
|
|
1289
|
+
tab1 = tab1.select_dtypes(include=[np.number])
|
|
1290
|
+
new_cols = set(tab1.columns)
|
|
1291
|
+
drop_cols = old_cols - new_cols
|
|
1292
|
+
if len(drop_cols) > 0:
|
|
1293
|
+
_log.warning(Warn_MSG.DROP_NONE_NUMERIC, drop_cols)
|
|
1294
|
+
# get the header
|
|
1295
|
+
tab1_header = list(tab1.columns)
|
|
1296
|
+
res_dict[CTypes.MetaData] = tab1.to_numpy()
|
|
1297
|
+
if getattr(self, TabNames.E_WAVE):
|
|
1298
|
+
# `padding=False` will rely on `ECGTable._pad_or_truncate`
|
|
1299
|
+
res_dict[CTypes.WaveForms] = signal_dicts_to_numpy_array(
|
|
1300
|
+
wave_list, padding=False)
|
|
1301
|
+
if getattr(self, TabNames.E_MEDIAN):
|
|
1302
|
+
res_dict[CTypes.MedianBeats] = signal_dicts_to_numpy_array(
|
|
1303
|
+
median_list, padding=False)
|
|
1304
|
+
# finally write to a single file
|
|
1305
|
+
np.savez(os.path.join(target, ECG_FILE), **res_dict)
|
|
1306
|
+
# include the header
|
|
1307
|
+
if getattr(self, TabNames.E_META):
|
|
1308
|
+
type(self)._write_header(target, tab1_header)
|
|
1309
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
1310
|
+
def _write_tfr(self, meta_list:list[dict[str, Any]],
|
|
1311
|
+
wave_list:list[dict[str, np.ndarray]],
|
|
1312
|
+
median_list:list[dict[str, np.ndarray]],
|
|
1313
|
+
target:str,
|
|
1314
|
+
chunk_num:int | None,
|
|
1315
|
+
numeric_meta:bool = True,
|
|
1316
|
+
) -> None:
|
|
1317
|
+
"""
|
|
1318
|
+
Writes data to a compressed tfrecord.
|
|
1319
|
+
|
|
1320
|
+
Parameters
|
|
1321
|
+
----------
|
|
1322
|
+
wave_list : `list` [`dict` [`str`, `any`]]
|
|
1323
|
+
A list of dictionaries containing metadata.
|
|
1324
|
+
wave_list : `list` [`dict` [`str`, `np.ndarray`]]
|
|
1325
|
+
A list of dictionaries containing waveform signals.
|
|
1326
|
+
median_list : `list` [`dict` [`str`, `np.ndarray`]]
|
|
1327
|
+
A list of dictionaries containing medianbeats signals.
|
|
1328
|
+
target: `str`
|
|
1329
|
+
The target directory.
|
|
1330
|
+
chunk_num : `int` or `NoneType`
|
|
1331
|
+
Appends the chunk number with a '_' separator before the file
|
|
1332
|
+
extension.
|
|
1333
|
+
numeric_meta : `bool`, default `True`
|
|
1334
|
+
Whether non-numeric data should be dropped from the metadata.
|
|
1335
|
+
|
|
1336
|
+
Notes
|
|
1337
|
+
-----
|
|
1338
|
+
This function does not return anything.
|
|
1339
|
+
"""
|
|
1340
|
+
# #### check input
|
|
1341
|
+
is_type(chunk_num, (int, type(None)))
|
|
1342
|
+
is_type(numeric_meta, bool)
|
|
1343
|
+
# Set the filenames
|
|
1344
|
+
_, _, _, ECG_FILE=\
|
|
1345
|
+
self._write_file_names(chunk_num=chunk_num)
|
|
1346
|
+
# #### write individual files
|
|
1347
|
+
res_dict = {}
|
|
1348
|
+
if getattr(self, TabNames.E_META):
|
|
1349
|
+
tab1 = pd.DataFrame(meta_list)
|
|
1350
|
+
if numeric_meta:
|
|
1351
|
+
old_cols = set(tab1.columns)
|
|
1352
|
+
tab1 = tab1.select_dtypes(include=[np.number])
|
|
1353
|
+
new_cols = set(tab1.columns)
|
|
1354
|
+
drop_cols = old_cols - new_cols
|
|
1355
|
+
if len(drop_cols) > 0:
|
|
1356
|
+
_log.warning(Warn_MSG.DROP_NONE_NUMERIC, drop_cols)
|
|
1357
|
+
# get the header
|
|
1358
|
+
tab1_header = list(tab1.columns)
|
|
1359
|
+
# write the dict
|
|
1360
|
+
res_dict[CTypes.MetaData] = tf.train.Feature(
|
|
1361
|
+
float_list=tf.train.FloatList(
|
|
1362
|
+
value=tab1.to_numpy().flatten()
|
|
1363
|
+
))
|
|
1364
|
+
if getattr(self, TabNames.E_WAVE):
|
|
1365
|
+
# `padding=False`: see _write_numpy for the rationale.
|
|
1366
|
+
res_dict[CTypes.WaveForms] = tf.train.Feature(
|
|
1367
|
+
float_list=tf.train.FloatList(
|
|
1368
|
+
value=signal_dicts_to_numpy_array(
|
|
1369
|
+
wave_list, padding=False).flatten()
|
|
1370
|
+
))
|
|
1371
|
+
if getattr(self, TabNames.E_MEDIAN):
|
|
1372
|
+
res_dict[CTypes.MedianBeats] = tf.train.Feature(
|
|
1373
|
+
float_list=tf.train.FloatList(
|
|
1374
|
+
value=signal_dicts_to_numpy_array(
|
|
1375
|
+
median_list, padding=False).flatten()
|
|
1376
|
+
))
|
|
1377
|
+
# finally write to a single file
|
|
1378
|
+
with tf.io.TFRecordWriter(os.path.join(target, ECG_FILE)) as writer:
|
|
1379
|
+
writer.write(
|
|
1380
|
+
tf.train.Example(features=tf.train.Features(feature=res_dict)).\
|
|
1381
|
+
SerializeToString()
|
|
1382
|
+
)
|
|
1383
|
+
# include the header
|
|
1384
|
+
if getattr(self, TabNames.E_META):
|
|
1385
|
+
type(self)._write_header(target, tab1_header)
|
|
1386
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
1387
|
+
@staticmethod
|
|
1388
|
+
def _write_header(target:str, header:list[str]):
|
|
1389
|
+
"""Write list entries a file with each entry on a new line"""
|
|
1390
|
+
with open(os.path.join(target, 'header_metadata.txt'), 'w') as file:
|
|
1391
|
+
# Iterate over the list with enumeration starting at 1
|
|
1392
|
+
for line_number, head in enumerate(header, start=1):
|
|
1393
|
+
# Write each element with its line number to the file
|
|
1394
|
+
file.write(f"{line_number}\t{head}\n")
|