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.
Files changed (33) hide show
  1. ecgprocess/__init__.py +1 -0
  2. ecgprocess/_version.py +1 -0
  3. ecgprocess/constants.py +170 -0
  4. ecgprocess/errors.py +380 -0
  5. ecgprocess/example_data/__init__.py +1 -0
  6. ecgprocess/example_data/example_datasets/.example-XML1.xml.un~ +0 -0
  7. ecgprocess/example_data/example_datasets/.example-XML1.xsd.un~ +0 -0
  8. ecgprocess/example_data/example_datasets/.example-XML1_a.xsd.un~ +0 -0
  9. ecgprocess/example_data/example_datasets/.example-XML1_d.xsd.un~ +0 -0
  10. ecgprocess/example_data/example_datasets/.example-XML1_wrong.xsd.un~ +0 -0
  11. ecgprocess/example_data/example_datasets/example-DICOM1.dcm +0 -0
  12. ecgprocess/example_data/example_datasets/example-DICOM2.dcm +0 -0
  13. ecgprocess/example_data/example_datasets/example-XML1.xml +675 -0
  14. ecgprocess/example_data/example_datasets/example-XML1.xsd +518 -0
  15. ecgprocess/example_data/example_datasets/example-XML1_error.xsd +519 -0
  16. ecgprocess/example_data/example_datasets/example-XML1_wrong.xsd +519 -0
  17. ecgprocess/example_data/example_datasets/string_data.txt +1 -0
  18. ecgprocess/example_data/examples.py +501 -0
  19. ecgprocess/plot_ecgs.py +921 -0
  20. ecgprocess/process_dicom.py +243 -0
  21. ecgprocess/process_xml.py +319 -0
  22. ecgprocess/tabular.py +1394 -0
  23. ecgprocess/utils/__init__.py +1 -0
  24. ecgprocess/utils/config_tools.py +581 -0
  25. ecgprocess/utils/ecg_tools.py +311 -0
  26. ecgprocess/utils/engineering_tools.py +385 -0
  27. ecgprocess/utils/general.py +399 -0
  28. ecgprocess/utils/reader_tools.py +508 -0
  29. ecgprocess-1.1.0.dist-info/METADATA +153 -0
  30. ecgprocess-1.1.0.dist-info/RECORD +33 -0
  31. ecgprocess-1.1.0.dist-info/WHEEL +5 -0
  32. ecgprocess-1.1.0.dist-info/licenses/LICENCE.txt +17 -0
  33. 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")