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
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
'''
|
|
2
|
+
The general utils module
|
|
3
|
+
'''
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tarfile
|
|
7
|
+
import numpy as np
|
|
8
|
+
import warnings
|
|
9
|
+
from typing import Any, Callable, Optional, Type, Generator
|
|
10
|
+
from ecgprocess.errors import (
|
|
11
|
+
is_type,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
15
|
+
def replace_with_tar(old_dir:str, new_tar:str, mode:str='w:gz') -> None:
|
|
16
|
+
'''
|
|
17
|
+
Moves the `old_dir` to a `tar` file, removing the `old_dir`.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
old_dir: `str`
|
|
22
|
+
The path to the old directory.
|
|
23
|
+
new_tar: `str`
|
|
24
|
+
The path to the new tar file.
|
|
25
|
+
mode: `str`, default `w:gz`
|
|
26
|
+
The tarfile.open mode.
|
|
27
|
+
|
|
28
|
+
Notes
|
|
29
|
+
-----
|
|
30
|
+
The function does not return anything
|
|
31
|
+
'''
|
|
32
|
+
# Create the tar.gz archive
|
|
33
|
+
with tarfile.open(new_tar, mode) as tar:
|
|
34
|
+
# Iterate through the files in the old directory
|
|
35
|
+
for root, _, files in os.walk(old_dir):
|
|
36
|
+
for file in files:
|
|
37
|
+
# Create the full path to the file
|
|
38
|
+
file_path = os.path.join(root, file)
|
|
39
|
+
# Add the file to the tar archive with only the filename
|
|
40
|
+
tar.add(file_path, arcname=file)
|
|
41
|
+
# Verify the archive was created successfully
|
|
42
|
+
if not os.path.exists(new_tar):
|
|
43
|
+
raise FileNotFoundError('Failed to create tar.gz archive.')
|
|
44
|
+
# Delete the original directory
|
|
45
|
+
shutil.rmtree(old_dir)
|
|
46
|
+
|
|
47
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
48
|
+
def list_tar(path:str, mode:str='r:gz') -> list[str]:
|
|
49
|
+
'''
|
|
50
|
+
Extract the content of a tar file and return this as a list
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
path : `str`,
|
|
55
|
+
The path to the tar file.
|
|
56
|
+
mode : `str`, default `r:gz`
|
|
57
|
+
The tarfile open mode.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
list
|
|
62
|
+
A list of filenames.
|
|
63
|
+
'''
|
|
64
|
+
# make sure we use a read mode
|
|
65
|
+
if mode.startswith('r:') == False:
|
|
66
|
+
raise ValueError('`mode` should start with `r:`')
|
|
67
|
+
# get list
|
|
68
|
+
with tarfile.open(path, mode) as tar:
|
|
69
|
+
# List all contents in the .tar.gz file
|
|
70
|
+
files = []
|
|
71
|
+
for member in tar.getmembers():
|
|
72
|
+
files.append(member.name)
|
|
73
|
+
# return
|
|
74
|
+
return files
|
|
75
|
+
|
|
76
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
77
|
+
def assign_empty_default(arguments:list[Any], empty_object:Callable[[],Any],
|
|
78
|
+
) -> list[Any]:
|
|
79
|
+
'''
|
|
80
|
+
Takes a list of `arguments`, checks if these are `NoneType` and if so
|
|
81
|
+
assigns them 'empty_object'.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
arguments: list of arguments
|
|
86
|
+
A list of arguments which may be set to `NoneType`.
|
|
87
|
+
empty_object: Callable that returns a mutable object
|
|
88
|
+
Examples include a `list` or a `dict`.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
new_arguments: list
|
|
93
|
+
List with `NoneType` replaced by empty mutable object.
|
|
94
|
+
|
|
95
|
+
Examples
|
|
96
|
+
--------
|
|
97
|
+
>>> assign_empty_default(['hi', None, 'hello'], empty_object=list)
|
|
98
|
+
['hi', [], 'hello']
|
|
99
|
+
|
|
100
|
+
Notes
|
|
101
|
+
-----
|
|
102
|
+
This function helps deal with the pitfall of assigning an empty mutable
|
|
103
|
+
object as a default function argument, which would persist through multiple
|
|
104
|
+
function calls, leading to unexpected/undesired behaviours.
|
|
105
|
+
'''
|
|
106
|
+
# check input
|
|
107
|
+
is_type(arguments, list)
|
|
108
|
+
is_type(empty_object, type)
|
|
109
|
+
# loop over arguments
|
|
110
|
+
new_args = [empty_object() if arg is None else arg for arg in arguments]
|
|
111
|
+
# return
|
|
112
|
+
return new_args
|
|
113
|
+
|
|
114
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
115
|
+
def _update_kwargs(update_dict:dict[Any, Any], **kwargs:Optional[Any],
|
|
116
|
+
) -> dict[Any, Any]:
|
|
117
|
+
'''
|
|
118
|
+
This function will take any number of `kwargs` and add them to an
|
|
119
|
+
`update_dict`. If there are any duplicate values in the `kwargs` and the
|
|
120
|
+
`update_dict`, the entries in the `update_dict` will take precedence.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
update_dict : `dict`
|
|
125
|
+
A dictionary with key - value pairs that should be combined with any
|
|
126
|
+
of the supplied kwargs.
|
|
127
|
+
kwargs : `Any`
|
|
128
|
+
Arbitrary keyword arguments.
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
dict:
|
|
133
|
+
A dictionary with the update_dict and kwargs combined, where duplicate
|
|
134
|
+
entries from update_dict overwrite those in kwargs.
|
|
135
|
+
|
|
136
|
+
Examples
|
|
137
|
+
--------
|
|
138
|
+
The function is particularly useful to overwrite `kwargs` that are
|
|
139
|
+
supplied to a nested function say
|
|
140
|
+
|
|
141
|
+
>>> _update_kwargs(update_dict={'c': 'black'}, c='red',
|
|
142
|
+
alpha = 0.5)
|
|
143
|
+
>>> {'c': 'black', 'alpha': 0.5}
|
|
144
|
+
'''
|
|
145
|
+
new_dict = {**kwargs, **update_dict}
|
|
146
|
+
# returns
|
|
147
|
+
return new_dict
|
|
148
|
+
|
|
149
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
150
|
+
class ManagedProperty(object):
|
|
151
|
+
"""
|
|
152
|
+
A generic property factory defining setters and getters, with optional
|
|
153
|
+
type validation.
|
|
154
|
+
|
|
155
|
+
Properties are read-only by default. Use `set_with_setter` to write a
|
|
156
|
+
value; this temporarily unlocks the property on the specific instance
|
|
157
|
+
using a per-instance lock key stored in the instance's ``__dict__``,
|
|
158
|
+
avoiding the shared-state bug that arises when the lock flag is stored
|
|
159
|
+
on the descriptor object itself (which is shared across all instances).
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
name : `str`
|
|
164
|
+
The name of the setters and getters
|
|
165
|
+
types: `Type`, default `NoneType`
|
|
166
|
+
Either a single type, or a tuple of types to test against.
|
|
167
|
+
|
|
168
|
+
Methods
|
|
169
|
+
-------
|
|
170
|
+
set_with_setter(instance, value)
|
|
171
|
+
Temporarily unlocks the property on `instance`, sets the value,
|
|
172
|
+
then re-locks it.
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
property
|
|
177
|
+
A property object with getter and setter.
|
|
178
|
+
"""
|
|
179
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
180
|
+
def __init__(self, name: str, types: tuple[type] | type | None = None):
|
|
181
|
+
"""
|
|
182
|
+
Initialize the ManagedProperty.
|
|
183
|
+
"""
|
|
184
|
+
self.name = name
|
|
185
|
+
self.types = types
|
|
186
|
+
# per-instance lock key stored in the instance __dict__, not on the
|
|
187
|
+
# descriptor, so concurrent or nested calls on different instances
|
|
188
|
+
# cannot interfere with each other.
|
|
189
|
+
self._lock_key = f"__{name}_locked"
|
|
190
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
191
|
+
def __get__(self, instance, owner):
|
|
192
|
+
"""Getter for the property."""
|
|
193
|
+
if instance is None:
|
|
194
|
+
return self
|
|
195
|
+
return instance.__dict__.get(self.name)
|
|
196
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
197
|
+
def __set__(self, instance, value):
|
|
198
|
+
"""Setter for the property — unlocked until set_with_setter is called."""
|
|
199
|
+
if instance.__dict__.get(self._lock_key, False):
|
|
200
|
+
raise AttributeError(f"The property '{self.name}' is read-only.")
|
|
201
|
+
if self.types and not isinstance(value, self.types):
|
|
202
|
+
raise ValueError(
|
|
203
|
+
f"Expected any of {self.types}, got {type(value)} "
|
|
204
|
+
f"for property '{self.name}'."
|
|
205
|
+
)
|
|
206
|
+
instance.__dict__[self.name] = value
|
|
207
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
208
|
+
def set_with_setter(self, instance, value):
|
|
209
|
+
"""
|
|
210
|
+
Unlock the property on `instance`, set the value, then re-lock it.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
instance : `object`
|
|
215
|
+
The instance on which the property is being set.
|
|
216
|
+
value : `any`
|
|
217
|
+
The value to assign to the property.
|
|
218
|
+
"""
|
|
219
|
+
instance.__dict__[self._lock_key] = False
|
|
220
|
+
try:
|
|
221
|
+
setattr(instance, self.name, value)
|
|
222
|
+
finally:
|
|
223
|
+
instance.__dict__[self._lock_key] = True
|
|
224
|
+
|
|
225
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
226
|
+
def parse_number(string:Any, sep:str=',', dec:str='.',
|
|
227
|
+
) -> list[float|int] | Any:
|
|
228
|
+
"""
|
|
229
|
+
Check if a string is a numbers. Maps the string to a list of floats or
|
|
230
|
+
ints.
|
|
231
|
+
|
|
232
|
+
Parameters
|
|
233
|
+
----------
|
|
234
|
+
string : `any`
|
|
235
|
+
Strings and list with a `single` will be checked if this represent
|
|
236
|
+
numbers, other object will be
|
|
237
|
+
returned as is.
|
|
238
|
+
sep : `str`, default ','
|
|
239
|
+
The character used to separate values in a string.
|
|
240
|
+
dec : `str`, default '.'
|
|
241
|
+
The character used as a decimal point.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
`list` [`int` | `float`] or `any`
|
|
246
|
+
A list of parsed integers or floats or the original input.
|
|
247
|
+
|
|
248
|
+
Examples
|
|
249
|
+
--------
|
|
250
|
+
>>> gen_utils.parse_number("1;2;3,5", sep=";", dec=",")
|
|
251
|
+
[1, 2, 3.5]
|
|
252
|
+
|
|
253
|
+
>>> gen_utils.parse_number(["1,2.5,3"])
|
|
254
|
+
[1, 2.5, 3]
|
|
255
|
+
|
|
256
|
+
>>> gen_utils.parse_number(['1,2.5,3', '2'])
|
|
257
|
+
['1,2.5,3', '2']
|
|
258
|
+
|
|
259
|
+
>>> parse_number(123)
|
|
260
|
+
123
|
|
261
|
+
"""
|
|
262
|
+
def is_valid_number(number):
|
|
263
|
+
"""Checks if a string `number` is a valid float or int"""
|
|
264
|
+
try:
|
|
265
|
+
# Replace decimal indicator with standard '.' for float conversion
|
|
266
|
+
float(number.replace(dec, '.'))
|
|
267
|
+
return True
|
|
268
|
+
except ValueError:
|
|
269
|
+
return False
|
|
270
|
+
# ### Split the string into parts using the separator
|
|
271
|
+
# NOTE wrap in try/except in case input is not a string and does not have
|
|
272
|
+
# string.split
|
|
273
|
+
res = string
|
|
274
|
+
# unlist if the string is nested in a list of length one.
|
|
275
|
+
if isinstance(string, list):
|
|
276
|
+
if len(string) == 1:
|
|
277
|
+
string = string[0]
|
|
278
|
+
try:
|
|
279
|
+
parts = string.split(sep)
|
|
280
|
+
if all(is_valid_number(p) for p in parts):
|
|
281
|
+
# Convert each part to float or int, making sure a float has a '.'
|
|
282
|
+
res= [
|
|
283
|
+
float(p.replace(dec, '.')) if dec in p else int(p) for p in parts
|
|
284
|
+
]
|
|
285
|
+
else:
|
|
286
|
+
res = string
|
|
287
|
+
except AttributeError:
|
|
288
|
+
pass
|
|
289
|
+
# #### return
|
|
290
|
+
return res
|
|
291
|
+
|
|
292
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
293
|
+
def string_concat(old:str , new: str, sep:str=', ') -> list[str]:
|
|
294
|
+
'''
|
|
295
|
+
Concatenates two strings, checking if the `old` string might be `NaN`.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
old : `str` or `np.nan`,
|
|
300
|
+
The original string.
|
|
301
|
+
new : `str`
|
|
302
|
+
A new string.
|
|
303
|
+
sep : `str`, default `, `
|
|
304
|
+
The string separator.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
str :
|
|
309
|
+
A concatenated string
|
|
310
|
+
|
|
311
|
+
Notes
|
|
312
|
+
-----
|
|
313
|
+
In general NaN is considered a float and missing string information is
|
|
314
|
+
better reflected by `NoneType`. Nevertheless one does find strings which
|
|
315
|
+
are set to `NaN` which is what this function deals with.
|
|
316
|
+
'''
|
|
317
|
+
# #### check input.
|
|
318
|
+
# np.nan is a float, so testing for float
|
|
319
|
+
is_type(old, (str, float, type(None)))
|
|
320
|
+
is_type(new, str)
|
|
321
|
+
# #### checking for nan
|
|
322
|
+
try:
|
|
323
|
+
if np.isnan(old):
|
|
324
|
+
res = new
|
|
325
|
+
else:
|
|
326
|
+
# will be run when old is a float
|
|
327
|
+
res = f"{old}{sep}{new}"
|
|
328
|
+
except TypeError:
|
|
329
|
+
res = f"{old}{sep}{new}"
|
|
330
|
+
# #### return
|
|
331
|
+
return res
|
|
332
|
+
|
|
333
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
334
|
+
def chunk_list(lst:list[Any], size:int) -> Generator[list[Any], None, None]:
|
|
335
|
+
"""
|
|
336
|
+
Splits a given list into chunks of a specified size.
|
|
337
|
+
|
|
338
|
+
Parameters
|
|
339
|
+
----------
|
|
340
|
+
lst : `list` [`any`]
|
|
341
|
+
A list of arbitrary length.
|
|
342
|
+
size : `int`
|
|
343
|
+
The size of the chunks, should be larger than 0.
|
|
344
|
+
|
|
345
|
+
Yields
|
|
346
|
+
------
|
|
347
|
+
list
|
|
348
|
+
A chunk of the input list of length ``size``. The final chunk
|
|
349
|
+
may be shorter if there aren't enough elements left.
|
|
350
|
+
|
|
351
|
+
Examples
|
|
352
|
+
--------
|
|
353
|
+
>>> data = list(range(10))
|
|
354
|
+
>>> gen = chunk_list(data, 3)
|
|
355
|
+
>>> next(gen)
|
|
356
|
+
[0, 1, 2]
|
|
357
|
+
"""
|
|
358
|
+
is_type(lst, list)
|
|
359
|
+
is_type(size, int)
|
|
360
|
+
if size < 1:
|
|
361
|
+
raise ValueError('`size` should be larger than 0.')
|
|
362
|
+
# #### The algorithm
|
|
363
|
+
for i in range(0, len(lst), size):
|
|
364
|
+
yield lst[i:i + size]
|
|
365
|
+
|
|
366
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
367
|
+
def update_dict_with_warning(d1:dict, d2:dict, verbose:bool=True) -> dict:
|
|
368
|
+
"""
|
|
369
|
+
Dictionary update while raising a warning for key duplication.
|
|
370
|
+
|
|
371
|
+
Parameters
|
|
372
|
+
----------
|
|
373
|
+
d1 : `dict`
|
|
374
|
+
The dictionary with `old` data.
|
|
375
|
+
d2 : `dict`
|
|
376
|
+
The dictionary with `new` data.
|
|
377
|
+
verbose : `bool`, default `True`
|
|
378
|
+
Whether to print a warning when there are duplicated keys with
|
|
379
|
+
distinct values.
|
|
380
|
+
|
|
381
|
+
Notes
|
|
382
|
+
-----
|
|
383
|
+
The value from d2 overwrites the value from d1 in the final result. The
|
|
384
|
+
warning will only be raised when the values are distinct.
|
|
385
|
+
"""
|
|
386
|
+
# Find keys that appear in both dictionaries
|
|
387
|
+
if verbose == True:
|
|
388
|
+
duplicates = d1.keys() & d2.keys()
|
|
389
|
+
# Only raise a warning if the values are distinct
|
|
390
|
+
for key in duplicates:
|
|
391
|
+
if d1[key] != d2[key]:
|
|
392
|
+
warnings.warn(
|
|
393
|
+
(f"Duplicate key '{key}' found. Old value: {d1[key]}, "
|
|
394
|
+
f"new value: {d2[key]} (retained)."),
|
|
395
|
+
UserWarning
|
|
396
|
+
)
|
|
397
|
+
# Overwrite d1 with d2’s key-value pairs
|
|
398
|
+
d1.update(d2)
|
|
399
|
+
return d1
|