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,508 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tools to help read and process ECG files.
|
|
3
|
+
|
|
4
|
+
Functions and classes are predominantly aimed to map ECG files to native
|
|
5
|
+
python objects such as dictionary and systematically process these. Functions
|
|
6
|
+
specifically focussed on ECG signals, such as calculating the limb leads,
|
|
7
|
+
are collected in `ecgprocess.utils.ecg_tools`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import pydicom
|
|
12
|
+
import warnings
|
|
13
|
+
import xmltodict
|
|
14
|
+
import numpy as np
|
|
15
|
+
from lxml import etree
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import (
|
|
18
|
+
Optional, Any,
|
|
19
|
+
)
|
|
20
|
+
from ecgprocess.utils.general import(
|
|
21
|
+
parse_number,
|
|
22
|
+
)
|
|
23
|
+
from ecgprocess.constants import (
|
|
24
|
+
CoreData as Core,
|
|
25
|
+
UtilsReaderNames as RNames,
|
|
26
|
+
)
|
|
27
|
+
from ecgprocess.errors import (
|
|
28
|
+
is_type,
|
|
29
|
+
MissingTagError,
|
|
30
|
+
)
|
|
31
|
+
from ecgprocess.constants import UtilsGeneralNames as UGNames
|
|
32
|
+
from ecgprocess.errors import XMLValidationError
|
|
33
|
+
|
|
34
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
35
|
+
# constants
|
|
36
|
+
WARN1 = "XML file is valid (ignoring XML elements not in the supplied XSD)."
|
|
37
|
+
CTypes = Core.DataTypes
|
|
38
|
+
CProc = Core.ProcessingData
|
|
39
|
+
|
|
40
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
41
|
+
# Base class with slots
|
|
42
|
+
class BaseReader:
|
|
43
|
+
'''
|
|
44
|
+
An ECGDICOMReader base class implementing the more efficient __slots__ for
|
|
45
|
+
the waveform arrays, while still retaining __dict__ dynamic attribute
|
|
46
|
+
creation.
|
|
47
|
+
'''
|
|
48
|
+
__slots__ = (CTypes.WaveForms, CTypes.MedianBeats,
|
|
49
|
+
CTypes.MetaData, CTypes.OtherData, CProc.RAW)
|
|
50
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
51
|
+
def __init__(self, waveforms=None, medianbeats=None, metadata=None,
|
|
52
|
+
otherdata=None, raw=None):
|
|
53
|
+
'''
|
|
54
|
+
Initialises slots entries to `None`.
|
|
55
|
+
'''
|
|
56
|
+
setattr(self, CTypes.WaveForms, waveforms)
|
|
57
|
+
setattr(self, CTypes.MedianBeats,medianbeats)
|
|
58
|
+
setattr(self, CTypes.MetaData,metadata)
|
|
59
|
+
setattr(self, CTypes.OtherData, otherdata)
|
|
60
|
+
setattr(self, CProc.RAW,raw)
|
|
61
|
+
|
|
62
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
63
|
+
def validate_xml(xml_path:str|Path, xsd_path:str|Path, strict:bool=True,
|
|
64
|
+
verbose:bool=True,
|
|
65
|
+
) -> etree._ElementTree:
|
|
66
|
+
"""
|
|
67
|
+
Validates an XML file against an XSD schema.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
xml_path : `str` or `Path`
|
|
72
|
+
Path to the XML file.
|
|
73
|
+
xsd_path : `str` or `Path`
|
|
74
|
+
Path to the XSD file.
|
|
75
|
+
strict : `bool`, default `True`
|
|
76
|
+
If False, ignores elements in the XML that are not in the XSD.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
etree._ElementTree
|
|
81
|
+
The parsed XML document.
|
|
82
|
+
|
|
83
|
+
Raises
|
|
84
|
+
------
|
|
85
|
+
XMLValidationError
|
|
86
|
+
Raised if the XSD and XML are incompatible.
|
|
87
|
+
"""
|
|
88
|
+
# #### Check input
|
|
89
|
+
is_type(strict, bool)
|
|
90
|
+
is_type(verbose, bool)
|
|
91
|
+
is_type(xml_path, (str, Path))
|
|
92
|
+
is_type(xsd_path, (str, Path))
|
|
93
|
+
# #### Load the XSD schema
|
|
94
|
+
with open(xsd_path, 'rb') as xsd:
|
|
95
|
+
schema_root = etree.XML(xsd.read())
|
|
96
|
+
schema = etree.XMLSchema(schema_root)
|
|
97
|
+
# #### Parse the XML file
|
|
98
|
+
try:
|
|
99
|
+
with open(xml_path, 'rb') as xml:
|
|
100
|
+
xml_doc = etree.parse(xml)
|
|
101
|
+
except etree.XMLSyntaxError:
|
|
102
|
+
parser = etree.XMLParser(resolve_entities=False, huge_tree=True)
|
|
103
|
+
with open(xml_path, 'rb') as xml:
|
|
104
|
+
xml_doc = etree.parse(xml, parser=parser,)
|
|
105
|
+
# #### Compare XML against XSD
|
|
106
|
+
is_valid = schema.validate(xml_doc)
|
|
107
|
+
# #### Do we want to raise errors when XML elements are absent in the XSD
|
|
108
|
+
if not is_valid and not strict:
|
|
109
|
+
# if strict is False remove error relating to missing XSD elements
|
|
110
|
+
errors = [err.message for err in schema.error_log]
|
|
111
|
+
filtered_errs = [err for err in schema.error_log if\
|
|
112
|
+
not UGNames.LXML_ERROR in err.message]
|
|
113
|
+
# check if there are any other type of errors
|
|
114
|
+
is_valid = len(filtered_errs) == 0
|
|
115
|
+
if is_valid:
|
|
116
|
+
# Where there any errors to begin with
|
|
117
|
+
if verbose == True and len(schema.error_log) > 0:
|
|
118
|
+
warnings.warn(WARN1)
|
|
119
|
+
else:
|
|
120
|
+
errors = [err.message for err in filtered_errs]
|
|
121
|
+
raise XMLValidationError(errors)
|
|
122
|
+
elif is_valid == False:
|
|
123
|
+
errors = [err.message for err in schema.error_log]
|
|
124
|
+
raise XMLValidationError(errors)
|
|
125
|
+
# return xml_doc
|
|
126
|
+
return xml_doc
|
|
127
|
+
|
|
128
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
129
|
+
def xml_to_dict(xml_doc:etree._ElementTree, encoding:str='utf-8',
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
Converts an lxml ElementTree document to a dictionary.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
xml_doc : `etree._ElementTree`
|
|
137
|
+
A validated XML document.
|
|
138
|
+
encoding : `str`, default `utf-8`
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
dict [`str`, `any`]
|
|
143
|
+
A dictionary representation of the XML data.
|
|
144
|
+
"""
|
|
145
|
+
is_type(encoding,str)
|
|
146
|
+
is_type(xml_doc, etree._ElementTree)
|
|
147
|
+
# convert to string
|
|
148
|
+
xml_str = etree.tostring(xml_doc, encoding=encoding)
|
|
149
|
+
# parste to dict
|
|
150
|
+
xml_dict = xmltodict.parse(xml_str)
|
|
151
|
+
# returning stuf.
|
|
152
|
+
return xml_dict
|
|
153
|
+
|
|
154
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
155
|
+
def dicom_to_dict(ds: pydicom.dataset.Dataset) -> dict[str, Any]:
|
|
156
|
+
"""
|
|
157
|
+
Turn a pydicom Dataset into a dict with keys derived from the Element names.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
ds : `pydicom.dataset.Dataset`
|
|
162
|
+
The DICOM dataset to convert.
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
dict [`str`, `any`]
|
|
167
|
+
A dictionary representation of the dataset.
|
|
168
|
+
"""
|
|
169
|
+
output = {}
|
|
170
|
+
for elem in ds:
|
|
171
|
+
# SQ: sequences should be dealt with through recursion
|
|
172
|
+
# Each sequence element contains a list of sub-elements
|
|
173
|
+
# (nested datasets), which may themselves have multiple elements.
|
|
174
|
+
if elem.VR != "SQ":
|
|
175
|
+
output[elem.name] = elem.value
|
|
176
|
+
else:
|
|
177
|
+
output[elem.name] = [dicom_to_dict(item) for item in elem]
|
|
178
|
+
# Extract waveform data if available
|
|
179
|
+
if hasattr(ds, RNames.DICOM_WAVE_ARRAY):
|
|
180
|
+
# Attempt to extract waveform data if the method exists
|
|
181
|
+
waveform_data = {}
|
|
182
|
+
# Extract the waveform data
|
|
183
|
+
try:
|
|
184
|
+
waveform_data["ECG_Leads"] = ds.waveform_array(0).T.tolist()
|
|
185
|
+
except (IndexError, AttributeError):
|
|
186
|
+
pass
|
|
187
|
+
# Extract median beats
|
|
188
|
+
try:
|
|
189
|
+
waveform_data["Median_Beats"] = ds.waveform_array(1).T.tolist()
|
|
190
|
+
except (IndexError, AttributeError):
|
|
191
|
+
pass
|
|
192
|
+
if waveform_data:
|
|
193
|
+
output["WaveformData"] = waveform_data
|
|
194
|
+
# return
|
|
195
|
+
return output
|
|
196
|
+
|
|
197
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
198
|
+
def flatten_dict(d:dict[str, Any], parent_prefix:str='', sep:str='.',
|
|
199
|
+
skip_root:bool=True,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""
|
|
202
|
+
Recursively flatten a nested dictionary, optionally skipping the root
|
|
203
|
+
element.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
d : dict
|
|
208
|
+
The dictionary to flatten.
|
|
209
|
+
parent_prefix : str, default ''
|
|
210
|
+
The base string added as a prefix to all keys during recursion.
|
|
211
|
+
Useful for maintaining context or indicating a higher-level structure.
|
|
212
|
+
sep : str, default '.'
|
|
213
|
+
The key separator
|
|
214
|
+
skip_root : bool, default True
|
|
215
|
+
If True, skips the first level (root) key.
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
dict
|
|
220
|
+
A flattened dictionary where nested keys are concatenated into a single
|
|
221
|
+
key.
|
|
222
|
+
|
|
223
|
+
Examples
|
|
224
|
+
--------
|
|
225
|
+
>>> nested_dict = {
|
|
226
|
+
... 'a': {
|
|
227
|
+
... 'b': 1,
|
|
228
|
+
... 'c': {
|
|
229
|
+
... 'd': 2
|
|
230
|
+
... }
|
|
231
|
+
... }
|
|
232
|
+
... }
|
|
233
|
+
>>> flatten_dict(nested_dict)
|
|
234
|
+
{'b': 1, 'c.d': 2}
|
|
235
|
+
|
|
236
|
+
>>> flatten_dict(nested_dict, skip_root=False)
|
|
237
|
+
{'a.b': 1, 'a.c.d': 2}
|
|
238
|
+
|
|
239
|
+
>>> flatten_dict(nested_dict, sep='_', skip_root=False)
|
|
240
|
+
{'a_b': 1, 'a_c_d': 2}
|
|
241
|
+
"""
|
|
242
|
+
# check input
|
|
243
|
+
is_type(d, dict)
|
|
244
|
+
is_type(parent_prefix, str)
|
|
245
|
+
is_type(sep, str)
|
|
246
|
+
is_type(skip_root, bool)
|
|
247
|
+
# initiate empty results
|
|
248
|
+
res = []
|
|
249
|
+
# If skipping the root, adjust d to be the first nested dictionary level
|
|
250
|
+
if skip_root and isinstance(d, dict) and len(d) == 1:
|
|
251
|
+
_, d = next(iter(d.items()))
|
|
252
|
+
# loop over the remaining keys and values
|
|
253
|
+
for k, v in d.items():
|
|
254
|
+
new_key = f"{parent_prefix}{sep}{k}" if parent_prefix else k
|
|
255
|
+
# if v is dict, unpack
|
|
256
|
+
if isinstance(v, dict):
|
|
257
|
+
res.extend(flatten_dict(v, new_key, sep=sep, skip_root=False).items())
|
|
258
|
+
# if v is list, unpack
|
|
259
|
+
elif isinstance(v, list):
|
|
260
|
+
for i, item in enumerate(v):
|
|
261
|
+
# flatten lists
|
|
262
|
+
if item is not None and isinstance(item, dict):
|
|
263
|
+
res.extend(flatten_dict(item, f"{new_key}_{i}",
|
|
264
|
+
sep=sep, skip_root=False).\
|
|
265
|
+
items())
|
|
266
|
+
# otherwise add non-dict and None types
|
|
267
|
+
else:
|
|
268
|
+
res.append((f"{new_key}_{i}", item))
|
|
269
|
+
# otherwise simply assign
|
|
270
|
+
else:
|
|
271
|
+
res.append((new_key, v))
|
|
272
|
+
return dict(res)
|
|
273
|
+
|
|
274
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
275
|
+
def get_ecg_data(data_dict:dict[str, Any],
|
|
276
|
+
config:dict[str, str],
|
|
277
|
+
parse_numeric:bool=True,
|
|
278
|
+
as_array:bool=False,
|
|
279
|
+
bits:np.dtype | None =None,
|
|
280
|
+
skip_empty:bool=True,
|
|
281
|
+
**kwargs:Optional[Any],
|
|
282
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
283
|
+
'''
|
|
284
|
+
Extracts metadata or signal data from a `data_dict` based on a supplied
|
|
285
|
+
`config` dictionary.
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
data_dict : `dict` [`str`, `any`]
|
|
290
|
+
A dictionary with keys and values matching the config object
|
|
291
|
+
config : `dict` [`str`, `str`]
|
|
292
|
+
a dictionary where the values match some keys in `data_dict` and the
|
|
293
|
+
keys represent the names these will be stored to.
|
|
294
|
+
parse_numeric : `bool`, default `True`
|
|
295
|
+
Will check if a numbers are accidentally presented as strings and parse
|
|
296
|
+
these to numbers.
|
|
297
|
+
as_array : `bool`, default `False`
|
|
298
|
+
Whether data should be mapped to `np.array` using a direct map:
|
|
299
|
+
`np.array(., dtype=bits)`.
|
|
300
|
+
bits : `np.dtype`, default `None`
|
|
301
|
+
np.array bits passed to numpy.array dtype.
|
|
302
|
+
skip_empty : `bool`, default `True`
|
|
303
|
+
Whether to skip config values not matching data_dict keys.
|
|
304
|
+
**kwargs
|
|
305
|
+
keyword arguments to `parse_number`.
|
|
306
|
+
|
|
307
|
+
Returns
|
|
308
|
+
-------
|
|
309
|
+
dict
|
|
310
|
+
A dictionary with the extracted signal data or metadata.
|
|
311
|
+
list
|
|
312
|
+
A list with `config` values which did not match `data_dict` keys.
|
|
313
|
+
|
|
314
|
+
Notes
|
|
315
|
+
-----
|
|
316
|
+
Whenever a config value starts with "[STARTSWITH]", the algorithm gathers
|
|
317
|
+
all relevant values in data_dict that share the specific prefix remaining
|
|
318
|
+
after stripping the quoted text. These values are concatenated into a
|
|
319
|
+
single string, where the original breaks between the values is indicated
|
|
320
|
+
by `[DELIM]`.
|
|
321
|
+
'''
|
|
322
|
+
# #### check input
|
|
323
|
+
is_type(data_dict, dict)
|
|
324
|
+
is_type(config, dict)
|
|
325
|
+
is_type(skip_empty, bool)
|
|
326
|
+
is_type(as_array, bool)
|
|
327
|
+
is_type(parse_numeric, bool)
|
|
328
|
+
STARTS = '[STARTSWITH]'
|
|
329
|
+
DELIM = '[DELIM]'
|
|
330
|
+
# #### initialise object
|
|
331
|
+
results_dict = {}
|
|
332
|
+
results_missing = []
|
|
333
|
+
# #### extract signal data
|
|
334
|
+
for t, s in config.items():
|
|
335
|
+
# cutting this in half using HIT
|
|
336
|
+
HIT = False
|
|
337
|
+
if not s is None:
|
|
338
|
+
if s.startswith(STARTS):
|
|
339
|
+
HIT = True
|
|
340
|
+
# Strip the [STARTSWITH] part
|
|
341
|
+
prefix = s[len(STARTS):]
|
|
342
|
+
# extracting entries which start with STARTS
|
|
343
|
+
combi = DELIM.join(str(v) for k, v in data_dict.items()\
|
|
344
|
+
if k.startswith(prefix)) or None
|
|
345
|
+
if combi is None:
|
|
346
|
+
results_missing.append(s)
|
|
347
|
+
# setting these to none to ensure key consistency
|
|
348
|
+
results_dict[t] = None
|
|
349
|
+
if skip_empty == False:
|
|
350
|
+
# Should an Error be returned
|
|
351
|
+
raise MissingTagError(s)
|
|
352
|
+
else:
|
|
353
|
+
results_dict[t] = combi
|
|
354
|
+
if HIT == False:
|
|
355
|
+
if s in data_dict:
|
|
356
|
+
# if s in data_dict:
|
|
357
|
+
# Check if numerics are accidentally strings.
|
|
358
|
+
if parse_numeric:
|
|
359
|
+
parsed_string = parse_number(data_dict[s], **kwargs)
|
|
360
|
+
results_dict[t] = parsed_string
|
|
361
|
+
if isinstance(parsed_string, list):
|
|
362
|
+
if len(parsed_string) == 1:
|
|
363
|
+
results_dict[t] = parsed_string[0]
|
|
364
|
+
else:
|
|
365
|
+
results_dict[t] = data_dict[s]
|
|
366
|
+
# Do we want to extract signals and store these in a numpy array..
|
|
367
|
+
if as_array == True:
|
|
368
|
+
try:
|
|
369
|
+
results_dict[t] = np.array(results_dict[t], dtype=bits)
|
|
370
|
+
except ValueError:
|
|
371
|
+
pass
|
|
372
|
+
elif skip_empty == False and not s is None:
|
|
373
|
+
# Should an Error be returned
|
|
374
|
+
raise MissingTagError(s)
|
|
375
|
+
else:
|
|
376
|
+
# assign None and append missing metadata
|
|
377
|
+
results_dict[t] = None
|
|
378
|
+
results_missing.append(s)
|
|
379
|
+
# return
|
|
380
|
+
return results_dict, results_missing
|
|
381
|
+
|
|
382
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
383
|
+
def subset_dict(data:dict[str, Any], pattern:dict[str, str],
|
|
384
|
+
substitute:tuple[str,str]|None=(r'_[0-9]{1,2}\.*', ' '),
|
|
385
|
+
character_trim:int=0,
|
|
386
|
+
verbose:bool=True, skip_empty:bool=True,
|
|
387
|
+
) -> dict[str, Any]:
|
|
388
|
+
"""
|
|
389
|
+
This will identify a subset of `data` items based on a `startswith` call
|
|
390
|
+
using the `pattern` key, and based on the patternl values this function
|
|
391
|
+
will identify the single entry in the subset whose values contains a
|
|
392
|
+
unique name which will be added as a prefix to the subset keys.
|
|
393
|
+
|
|
394
|
+
Parameters
|
|
395
|
+
---------
|
|
396
|
+
data : `dict` [`str`, `any`]
|
|
397
|
+
The dictionary to be subsetted and transformed.
|
|
398
|
+
pattern : `dict` [`str`, `str`]
|
|
399
|
+
A dictionary where each key is a prefix to match against the keys of
|
|
400
|
+
`data`, and each value defines the suffix to search within the matching
|
|
401
|
+
keys in `data`. The value found in `data` corresponding to this suffix
|
|
402
|
+
is used as the prefix for the resulting dictionary keys. The pattern
|
|
403
|
+
keys will be matched to the data keys based on a `startswith`, while
|
|
404
|
+
the pattern values will be matched to the data keys using:
|
|
405
|
+
`value in key`.
|
|
406
|
+
substitute : `tuple` [`str`,`str`] or `None`, default `(r"_[0-9]{1,2}\\.*", " ")`
|
|
407
|
+
A tuple containing a regular expression pattern and replacement string.
|
|
408
|
+
This substitution is applied to the remaining portion of the `data` key
|
|
409
|
+
after removing the matching prefix.
|
|
410
|
+
character_trim : int, default `0`
|
|
411
|
+
The number of characters which should be removed from the right-hand
|
|
412
|
+
side of the `data` key which did not match the `pattern` key.
|
|
413
|
+
verbose : `bool`, default `True`
|
|
414
|
+
Whether warnings should be issued.
|
|
415
|
+
|
|
416
|
+
Returns
|
|
417
|
+
-------
|
|
418
|
+
dict [`str`, `any`]
|
|
419
|
+
A dictionary with keys grouped and transformed based on the `pattern`
|
|
420
|
+
and `substitute`. The keys are prefixed with values derived from `data`.
|
|
421
|
+
|
|
422
|
+
Examples
|
|
423
|
+
--------
|
|
424
|
+
>>> data = {
|
|
425
|
+
... "Sequence_0.Referenced Waveform Channels_0": "Channel 1",
|
|
426
|
+
... "Sequence_0.Referenced Waveform Channels_1": "Channel 2",
|
|
427
|
+
... "Sequence_0.Annotation Group Number": 1,
|
|
428
|
+
... "Sequence_0.Unformatted Text Value": "Event A",
|
|
429
|
+
... "Sequence_8.Measurement Units Code Sequence_0.Code Value": "bpm",
|
|
430
|
+
... "Sequence_8.Measurement Units Code Sequence_0.Code Meaning": "Heart Rate",
|
|
431
|
+
... "Sequence_15.Measurement Units Code Sequence_0.Code Meaning": "Temperature",
|
|
432
|
+
... "Sequence_15.Referenced Waveform Channels_1": "Channel 10",
|
|
433
|
+
... "Sequence_15.Numeric Value": 36.7,
|
|
434
|
+
... "Other Annotation Sequence_1.Some Value": "Other Data",
|
|
435
|
+
... "Other Annotation Sequence_1.Code Meaning": "Other Code Meaning",
|
|
436
|
+
... }
|
|
437
|
+
>>> pattern = {
|
|
438
|
+
... "Sequence_15": "Code Meaning",
|
|
439
|
+
... "Sequence_8": "Code Meaning",
|
|
440
|
+
... "Sequence_16": "Code Meaning",
|
|
441
|
+
... "Sequence_11": "Code Meaning",
|
|
442
|
+
... "Other Annotation Sequence_1": "Code Meaning",
|
|
443
|
+
... }
|
|
444
|
+
>>> subset_dict(data, pattern)
|
|
445
|
+
{'Temperature (Referenced Waveform Channels)': 'Channel 10',
|
|
446
|
+
'Temperature (Numeric Value)': 36.7,
|
|
447
|
+
'Heart Rate (Measurement Units Code SequenceCode Value)': 'bpm',
|
|
448
|
+
'Other Code Meaning (Some Value)': 'Other Data'}
|
|
449
|
+
"""
|
|
450
|
+
# #### confirm input
|
|
451
|
+
is_type(data, dict)
|
|
452
|
+
is_type(pattern, dict)
|
|
453
|
+
is_type(substitute, (type(None), tuple))
|
|
454
|
+
if not substitute is None:
|
|
455
|
+
if len(substitute) != 2:
|
|
456
|
+
raise ValueError("`substitute` should be a `Nonetype` or a tuple "
|
|
457
|
+
"with exactly two entries.")
|
|
458
|
+
# #### empty default
|
|
459
|
+
# regex=True
|
|
460
|
+
grouped = {}
|
|
461
|
+
unmatched_keys = set(pattern.keys())
|
|
462
|
+
# #### loop
|
|
463
|
+
for d_name, g_name in pattern.items():
|
|
464
|
+
# fist get all data entries where the key starts with `d_name`
|
|
465
|
+
prefix = None
|
|
466
|
+
sub_dict = {}
|
|
467
|
+
for k, v in data.items():
|
|
468
|
+
mtch = k.startswith(d_name)
|
|
469
|
+
if mtch:
|
|
470
|
+
# remove from unmatched
|
|
471
|
+
unmatched_keys.discard(d_name)
|
|
472
|
+
# Subset the non-matching string
|
|
473
|
+
d_name_len = len(d_name)
|
|
474
|
+
str_remainder = k[d_name_len + character_trim:]
|
|
475
|
+
# Do we want a cleaner string
|
|
476
|
+
str_remainder_clean = str_remainder
|
|
477
|
+
if not substitute is None:
|
|
478
|
+
m, r = substitute
|
|
479
|
+
str_remainder_clean = re.sub(m, r, str_remainder).strip()
|
|
480
|
+
# Check g_name is in str_remainder
|
|
481
|
+
if g_name in str_remainder:
|
|
482
|
+
if prefix is not None:
|
|
483
|
+
raise ValueError(
|
|
484
|
+
f"For pattern {d_name}, `prefix`: {prefix} is "
|
|
485
|
+
f"already set, cannot assign new value: {v} "
|
|
486
|
+
f"please ensure `{g_name}` is unique.")
|
|
487
|
+
prefix = v
|
|
488
|
+
else:
|
|
489
|
+
sub_dict[str_remainder_clean] = v
|
|
490
|
+
# next confirm prefix is not None, and add the prefix
|
|
491
|
+
if len(sub_dict) > 0:
|
|
492
|
+
if prefix is None:
|
|
493
|
+
if skip_empty:
|
|
494
|
+
continue
|
|
495
|
+
else:
|
|
496
|
+
raise ValueError(f"`{g_name}` was not found in data.")
|
|
497
|
+
grouped.update(
|
|
498
|
+
{f"{prefix} ({key})": val for key, val in sub_dict.items()}
|
|
499
|
+
)
|
|
500
|
+
del sub_dict
|
|
501
|
+
# do we want to return un-matching keys
|
|
502
|
+
if verbose and unmatched_keys:
|
|
503
|
+
warnings.warn("The following keys did not match any pattern: "
|
|
504
|
+
f"{', '.join(unmatched_keys)}")
|
|
505
|
+
# return
|
|
506
|
+
return grouped
|
|
507
|
+
|
|
508
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ECGProcess
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: A python module to process and standardise ECG data
|
|
5
|
+
Author-email: "A. Floriaan Schmidt" <floriaanschmidt@gmail.com>
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://gitlab.com/SchmidtAF/ECGProcess
|
|
8
|
+
Project-URL: Documentation, https://SchmidtAF.gitlab.io/ECGProcess/
|
|
9
|
+
Classifier: Programming Language :: Python
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
15
|
+
Requires-Python: <3.14,>=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENCE.txt
|
|
18
|
+
Requires-Dist: lxml>=5
|
|
19
|
+
Requires-Dist: xmltodict>=0.14
|
|
20
|
+
Requires-Dist: pandas>=2.1.1
|
|
21
|
+
Requires-Dist: numpy>=1.26
|
|
22
|
+
Requires-Dist: scipy>=1.7
|
|
23
|
+
Requires-Dist: tensorflow>=2.16
|
|
24
|
+
Requires-Dist: pydicom>=2
|
|
25
|
+
Requires-Dist: matplotlib>=3.9
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=6; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-dependency>=0.5; extra == "dev"
|
|
30
|
+
Requires-Dist: codespell; extra == "dev"
|
|
31
|
+
Requires-Dist: ruff; extra == "dev"
|
|
32
|
+
Requires-Dist: bump2version>=1; extra == "dev"
|
|
33
|
+
Requires-Dist: build; extra == "dev"
|
|
34
|
+
Requires-Dist: twine; extra == "dev"
|
|
35
|
+
Requires-Dist: sphinx; extra == "dev"
|
|
36
|
+
Requires-Dist: sphinx-rtd-theme; extra == "dev"
|
|
37
|
+
Requires-Dist: sphinx-bootstrap-theme; extra == "dev"
|
|
38
|
+
Requires-Dist: furo; extra == "dev"
|
|
39
|
+
Requires-Dist: myst-parser; extra == "dev"
|
|
40
|
+
Requires-Dist: nbsphinx; extra == "dev"
|
|
41
|
+
Requires-Dist: nbsphinx-link; extra == "dev"
|
|
42
|
+
Requires-Dist: sphinx-argparse; extra == "dev"
|
|
43
|
+
Requires-Dist: numpydoc; extra == "dev"
|
|
44
|
+
Requires-Dist: docutils<0.21; extra == "dev"
|
|
45
|
+
Requires-Dist: notebook; extra == "dev"
|
|
46
|
+
Requires-Dist: jupyter; extra == "dev"
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
|
|
49
|
+

|
|
50
|
+
|
|
51
|
+
# An ECG processing module
|
|
52
|
+
__version__: `1.1.0`
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
This repository host a mixture of function dealing with processing
|
|
56
|
+
electrocardiogram (ECG) data, and preparing these data for analysis.
|
|
57
|
+
The processing functions are intended for user defined parametrisation
|
|
58
|
+
(using user supplied configuration files `cnf`) about the data which
|
|
59
|
+
should be extracted, covering signal data such as waveforms or
|
|
60
|
+
median beats, as well as metadata relevant for downstream QC or as
|
|
61
|
+
tabular information.
|
|
62
|
+
|
|
63
|
+
The module implements a basic application programming interface, mapping
|
|
64
|
+
ECG data from distinct file types (e.g. XML or DICOM) to class attributes
|
|
65
|
+
providing a fixed interface for downstream analysis and interrogation.
|
|
66
|
+
|
|
67
|
+
The documentation for ECGProcess can be found [here](https://SchmidtAF.gitlab.io/ECGProcess/).
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
At present, the repository is undergoing development and no packages exist yet
|
|
72
|
+
on PyPy or in Conda.
|
|
73
|
+
Therefore it is recommended that it is installed in either of the two ways
|
|
74
|
+
listed below.
|
|
75
|
+
First, clone this repository and then `cd` to the root of the repository.
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
git clone git@gitlab.com:SchmidtAF/ECGProcess.git
|
|
79
|
+
cd ECGProcess
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Installation using conda dependencies
|
|
83
|
+
A conda environment is provided in a `yaml` file in the directory
|
|
84
|
+
`./resources/conda/envs/`.
|
|
85
|
+
A new conda environment called `ecgprocess` can be built using the command:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
# From the root of the repository
|
|
89
|
+
conda env create --file ./resources/conda/envs/conda_create.yaml
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
To add to an existing environment use:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
# From the root of the repository
|
|
96
|
+
conda env update --file ./resources/conda/envs/conda_update.yaml
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Next the package can be installed:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
python -m pip install .
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Or for an editable (developer) install run the command below from the root of
|
|
106
|
+
the repository.
|
|
107
|
+
The difference with this is that you can just to a `git pull` to
|
|
108
|
+
update repository, or switch branches without re-installing:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
python -m pip install -e .
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
### Installation not using any conda dependencies
|
|
115
|
+
If you are not using conda in any way then install the dependencies via `pip`
|
|
116
|
+
and install repository as an editable install also via pip:
|
|
117
|
+
|
|
118
|
+
Install dependencies:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
python -m pip install --upgrade -r requirements.txt
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Then to install repository you can either do:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
python -m pip install .
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Or for an editable (developer) install run the command below from the root of
|
|
131
|
+
the repository.
|
|
132
|
+
The difference with this is that you can just to a `git pull` to update
|
|
133
|
+
repository, or switch branches without re-installing:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
python -m pip install -e .
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Next steps...
|
|
140
|
+
After installation you might wish to try the `pytest` to confirm
|
|
141
|
+
everything is in working order.
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
# From the root of the repository
|
|
145
|
+
pytest tests
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Usage
|
|
149
|
+
|
|
150
|
+
Please have a look at the examples in
|
|
151
|
+
[resources](https://gitlab.com/SchmidtAF/ECGProcess/-/tree/master/resources/examples)
|
|
152
|
+
for some possible recipes.
|
|
153
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
ecgprocess/__init__.py,sha256=ZygAIkX6Nbjag1czWdQa-yP-GM1mBE_9ss21Xh__JFc,34
|
|
2
|
+
ecgprocess/_version.py,sha256=b6-WiVk0Li5MaV2rBnHYl104TsouINojSWKqHDas0js,22
|
|
3
|
+
ecgprocess/constants.py,sha256=otryxvYntmB6c_xH_eMMpVXNe0F-7OKqvbh1zTaYA40,5933
|
|
4
|
+
ecgprocess/errors.py,sha256=BDZPl_4cLgGV3AAiiphS3xnx9f57VBcvr0hmRLz_Ks4,12025
|
|
5
|
+
ecgprocess/plot_ecgs.py,sha256=ia6ypRperf5GBNYvizyw_Xv5H8AQQU6oR8hAjb_U4xg,38832
|
|
6
|
+
ecgprocess/process_dicom.py,sha256=tm8IMU0D-3xKNE2MfUB7BtbmSdhlwk_WgiX45HZx_PI,9667
|
|
7
|
+
ecgprocess/process_xml.py,sha256=ep9l69q5WYf3PKUMhguGSLtQsqyX1dbPxw1R5jt7rp0,12750
|
|
8
|
+
ecgprocess/tabular.py,sha256=Yk3UbqV2nEugNT54HNVL2o6_bXEOOfL3nwjW_OW0N4I,63742
|
|
9
|
+
ecgprocess/example_data/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
10
|
+
ecgprocess/example_data/examples.py,sha256=nbdHfEWyruNwYo0vtoKTwfLldGOwx6fFgXPK8t6kXw4,17864
|
|
11
|
+
ecgprocess/example_data/example_datasets/.example-XML1.xml.un~,sha256=8VbZNIN8SRTsrhE-hC_OKtScnKcd2oT2XlR7GMNiOjs,2872
|
|
12
|
+
ecgprocess/example_data/example_datasets/.example-XML1.xsd.un~,sha256=jSMJf_mChOZ-LP6Rk9ikjQmpkQ0e3-AC49ykZ1QHbKU,377534
|
|
13
|
+
ecgprocess/example_data/example_datasets/.example-XML1_a.xsd.un~,sha256=raUIHMAEEB9vtz40UdXQntp-uj4ZW35reqzUw338ceY,22780
|
|
14
|
+
ecgprocess/example_data/example_datasets/.example-XML1_d.xsd.un~,sha256=1zEIOMdzGlbu5OlOiyxzWwncGsP1QtDyQk6PWRFKu2Q,238478
|
|
15
|
+
ecgprocess/example_data/example_datasets/.example-XML1_wrong.xsd.un~,sha256=e_Vkp2EQ-xDHtPU8qP4p6nuCCnlmWAcWhWVZh_RjliU,2558
|
|
16
|
+
ecgprocess/example_data/example_datasets/example-DICOM1.dcm,sha256=dBkuaxVeZU21tpJ9ELwKg60MqT65v4rPcBBj7JHD0oM,142376
|
|
17
|
+
ecgprocess/example_data/example_datasets/example-DICOM2.dcm,sha256=Tg75DfNb8ZyPITWEU0paJqaz30a4QSL0IX1NSxnhu0Q,143650
|
|
18
|
+
ecgprocess/example_data/example_datasets/example-XML1.xml,sha256=sAuj7rTz9rWXcBN5Z0o3uD7UNRSn9aJ1vYsAS1NaycI,517675
|
|
19
|
+
ecgprocess/example_data/example_datasets/example-XML1.xsd,sha256=auIlBkM2Ps8NRLRQE0ZEBqXiSHezZZO3DfFqrbdw5U8,30011
|
|
20
|
+
ecgprocess/example_data/example_datasets/example-XML1_error.xsd,sha256=CCOYDskNP_2xxcgLIu4vyt_rKpE6oVRdi7kvDxfVXks,30178
|
|
21
|
+
ecgprocess/example_data/example_datasets/example-XML1_wrong.xsd,sha256=CCOYDskNP_2xxcgLIu4vyt_rKpE6oVRdi7kvDxfVXks,30178
|
|
22
|
+
ecgprocess/example_data/example_datasets/string_data.txt,sha256=Ss_TTlNXIF_-oI6RNCx9Xc2YXv_6OZhmY1f6iq4GK_E,18
|
|
23
|
+
ecgprocess/utils/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
24
|
+
ecgprocess/utils/config_tools.py,sha256=KznuK0vENK5eyXz2a2WYWlGVzyZH_alTApIG0nsjUEE,23155
|
|
25
|
+
ecgprocess/utils/ecg_tools.py,sha256=chCZ9mP8yEAprRqGFw88R9h1fDteIRXAPecogsWMQYE,11027
|
|
26
|
+
ecgprocess/utils/engineering_tools.py,sha256=lUuj5qajRVOFm1QWQ78iI6B_6I-HcsBKx1P0bF3aVV8,14802
|
|
27
|
+
ecgprocess/utils/general.py,sha256=v3UA70Og_YAw50PePPQ0Vi5grjumabqxINnyqE-bnCU,13057
|
|
28
|
+
ecgprocess/utils/reader_tools.py,sha256=Uwd6QT_crn446B45P43ZOZOi2FdyVQHvoWO2o9FJWlY,19283
|
|
29
|
+
ecgprocess-1.1.0.dist-info/licenses/LICENCE.txt,sha256=IGTG-FiCwz8h8OpA1dOMUEGxlg0US7wOW3ZDmQI-ouo,778
|
|
30
|
+
ecgprocess-1.1.0.dist-info/METADATA,sha256=a9t-XhFP3VZO8Ddu8XQaHfOMzVzCGFZAiTjGuuh85d0,4928
|
|
31
|
+
ecgprocess-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
32
|
+
ecgprocess-1.1.0.dist-info/top_level.txt,sha256=i1wlvtScBPYBll7dyIgwR3qyrg9_u7m-uQ2_z0L6snw,11
|
|
33
|
+
ecgprocess-1.1.0.dist-info/RECORD,,
|