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
@@ -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