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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import warnings
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional, Any, Self, Literal
|
|
5
|
+
from ecgprocess.constants import (
|
|
6
|
+
CoreData as Core,
|
|
7
|
+
UtilsConfigData as ConfigNames,
|
|
8
|
+
)
|
|
9
|
+
from ecgprocess.errors import (
|
|
10
|
+
InputValidationError,
|
|
11
|
+
is_type,
|
|
12
|
+
Error_MSG,
|
|
13
|
+
NotCalledError,
|
|
14
|
+
_check_is_file,
|
|
15
|
+
)
|
|
16
|
+
from ecgprocess.utils.general import ManagedProperty
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
Classes to handle and parse configuration files describing how the XML/DICOM
|
|
20
|
+
tags should be assigned to dataclass attributes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|
24
|
+
# constants
|
|
25
|
+
CTypes = Core.DataTypes
|
|
26
|
+
CLeads = Core.Leads
|
|
27
|
+
CMeta = Core.MetaData
|
|
28
|
+
|
|
29
|
+
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|
30
|
+
@dataclass
|
|
31
|
+
class PrivilegedData(object):
|
|
32
|
+
'''
|
|
33
|
+
The core metadata dictionary, where the key values cannot not be changed
|
|
34
|
+
because these are expected used by downstream programs. The values can
|
|
35
|
+
however be user defined.
|
|
36
|
+
|
|
37
|
+
Methods
|
|
38
|
+
-------
|
|
39
|
+
update_values(**kwargs)
|
|
40
|
+
Update the values of existing dictionary keys.
|
|
41
|
+
to_dict()
|
|
42
|
+
Returns the current dictionary.
|
|
43
|
+
keys()
|
|
44
|
+
Returns the dictionary keys.
|
|
45
|
+
|
|
46
|
+
Notes
|
|
47
|
+
-----
|
|
48
|
+
The values are initialised to `NoneType` these should be set to values
|
|
49
|
+
relevant for the relevant XML/DICOM tags/attributes.
|
|
50
|
+
|
|
51
|
+
Examples
|
|
52
|
+
--------
|
|
53
|
+
>>> required = PrivilegedData()
|
|
54
|
+
>>> print(*required.keys(), sep='\n')
|
|
55
|
+
unique identifier
|
|
56
|
+
sampling frequency (original)
|
|
57
|
+
sampling number (waveforms)
|
|
58
|
+
sampling number (medianbeats)
|
|
59
|
+
acquisition date
|
|
60
|
+
study date
|
|
61
|
+
channel number
|
|
62
|
+
units (waveforms)
|
|
63
|
+
units (medianbeats)
|
|
64
|
+
'''
|
|
65
|
+
# Defaults, using default_factory and lambda to make sure default do not
|
|
66
|
+
# persist between instances.
|
|
67
|
+
# NOTE using a single _ instead of __ to prevent name mangling.
|
|
68
|
+
_data: dict[str, str] = field(default_factory=lambda: {
|
|
69
|
+
CMeta.UID : None,
|
|
70
|
+
CMeta.NLEADS : None,
|
|
71
|
+
CMeta.RES_U_W : None,
|
|
72
|
+
CMeta.RES_U_M : None,
|
|
73
|
+
CMeta.RES_W : None,
|
|
74
|
+
CMeta.RES_M : None,
|
|
75
|
+
CMeta.SF : None,
|
|
76
|
+
CMeta.SF_U : None,
|
|
77
|
+
CMeta.SN_W : None,
|
|
78
|
+
CMeta.SN_M : None,
|
|
79
|
+
})
|
|
80
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
81
|
+
def update_values(self, **kwargs:Optional[Any]) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Update the values of existing immutable keys.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
**kwargs : `dict` [`str`, `str`]
|
|
88
|
+
Key-value pairs where the key is a required data field and the
|
|
89
|
+
value is the updated DICOM/XML tag.
|
|
90
|
+
|
|
91
|
+
Raises
|
|
92
|
+
------
|
|
93
|
+
KeyError
|
|
94
|
+
If a key in `kwargs` is not a valid required data field.
|
|
95
|
+
"""
|
|
96
|
+
CLASS_NAME = type(self).__name__
|
|
97
|
+
for key, val in kwargs.items():
|
|
98
|
+
if key not in self._data:
|
|
99
|
+
raise KeyError(f"Key '{key}' is not a valid attribute for "
|
|
100
|
+
f"{CLASS_NAME}.")
|
|
101
|
+
self._data[key] = val
|
|
102
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
103
|
+
def keys(self) -> list[str]:
|
|
104
|
+
"""
|
|
105
|
+
Returns the required keys.
|
|
106
|
+
"""
|
|
107
|
+
return list(self._data.keys())
|
|
108
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
109
|
+
def to_dict(self) -> dict[str, str]:
|
|
110
|
+
"""
|
|
111
|
+
Returns the required data as a dictionary.
|
|
112
|
+
"""
|
|
113
|
+
return self._data
|
|
114
|
+
|
|
115
|
+
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|
116
|
+
@dataclass
|
|
117
|
+
class OtherData(PrivilegedData):
|
|
118
|
+
"""
|
|
119
|
+
A class to map internal attribute names to DICOM/XML tags and collect data
|
|
120
|
+
based on optional attributes.
|
|
121
|
+
|
|
122
|
+
Methods
|
|
123
|
+
-------
|
|
124
|
+
update_values(**kwargs)
|
|
125
|
+
Update the values of existing dictionary keys.
|
|
126
|
+
to_dict()
|
|
127
|
+
Returns the current dictionary.
|
|
128
|
+
keys()
|
|
129
|
+
Returns the dictionary keys.
|
|
130
|
+
"""
|
|
131
|
+
# overwrite _data from PrivilegedData
|
|
132
|
+
_data: Optional[dict[str, str]] = field(default_factory=dict)
|
|
133
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
134
|
+
def update_values(self, **kwargs:Optional[Any]) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Update the dictionary key and values.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
**kwargs : `dict` [`str`, `str`]
|
|
141
|
+
Key-value pairs mapping DICOM/XML tags (values) to attribute names
|
|
142
|
+
(keys).
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
for key, val in kwargs.items():
|
|
146
|
+
self._data[key] = val
|
|
147
|
+
|
|
148
|
+
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|
149
|
+
@dataclass
|
|
150
|
+
class DataMap(object):
|
|
151
|
+
"""
|
|
152
|
+
A class to manage and map metadata, waveforms, and median beats,
|
|
153
|
+
and optionally, other data attributes.
|
|
154
|
+
|
|
155
|
+
Attributes
|
|
156
|
+
----------
|
|
157
|
+
WaveForms : `dict` [`str`, `str`]
|
|
158
|
+
A config dict mapping the twelve ECG leads.
|
|
159
|
+
MedianBeats : `dict` [`str`, `str`]
|
|
160
|
+
A config dict mapping the twelve ECG leads.
|
|
161
|
+
MetaData : `dict` [`str`, `str`]
|
|
162
|
+
A dictionary containing metadata attributes and their values.
|
|
163
|
+
OtherData : `dict` [`str`, `str`], default `NoneType`
|
|
164
|
+
An optional dictionary for additional data mappings.
|
|
165
|
+
|
|
166
|
+
Methods
|
|
167
|
+
-------
|
|
168
|
+
get_attributes(all_)
|
|
169
|
+
Returns the names of attributes with non-None values.
|
|
170
|
+
keys(attr_name)
|
|
171
|
+
Returns the keys of the specified dictionary ('MetaData' or
|
|
172
|
+
'OtherData'), or both if no specific dictionary is specified.
|
|
173
|
+
items(attr_name)
|
|
174
|
+
Returns the key-value pairs of the specified dictionary ('MetaData' or
|
|
175
|
+
'OtherData'), or both if no specific dictionary is specified.
|
|
176
|
+
|
|
177
|
+
Examples
|
|
178
|
+
--------
|
|
179
|
+
>>> data_map = DataMap()
|
|
180
|
+
>>> data_map.get_attributes()
|
|
181
|
+
['WaveForms', 'MedianBeats', 'MetaData']
|
|
182
|
+
|
|
183
|
+
Notes
|
|
184
|
+
-----
|
|
185
|
+
The class is intended to work in conjunction with a config file processed
|
|
186
|
+
using `ConfigParser.map(DataMap)`. DataMap ensures any privileged
|
|
187
|
+
attributes omitted from the config are added and set to None, and prevents
|
|
188
|
+
the config file to be altered after processing.
|
|
189
|
+
"""
|
|
190
|
+
# setting attributes
|
|
191
|
+
WaveForms: dict = field(default_factory=lambda: PrivilegedData(
|
|
192
|
+
_data=DataMap.get_leads()).to_dict())
|
|
193
|
+
MedianBeats: dict = field(default_factory=lambda: PrivilegedData(
|
|
194
|
+
_data=DataMap.get_leads()).to_dict())
|
|
195
|
+
MetaData: dict = field(default_factory=lambda: PrivilegedData().to_dict())
|
|
196
|
+
OtherData: dict | None = None
|
|
197
|
+
_VALIDATE_ATTR = (
|
|
198
|
+
ConfigNames.WaveForms, ConfigNames.MedianBeats,
|
|
199
|
+
ConfigNames.MetaData, ConfigNames.OtherData
|
|
200
|
+
)
|
|
201
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
202
|
+
@staticmethod
|
|
203
|
+
def get_leads():
|
|
204
|
+
"""Initialising the leads names to None"""
|
|
205
|
+
lead_attr = [attr for attr in dir(CLeads) if not attr.startswith('__')]
|
|
206
|
+
return {getattr(CLeads, l): None for l in lead_attr}
|
|
207
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
208
|
+
def __post_init__(self):
|
|
209
|
+
"""Validating inputs."""
|
|
210
|
+
is_type(self.WaveForms, dict, 'WaveForms')
|
|
211
|
+
is_type(self.MedianBeats, dict, 'MedianBeats')
|
|
212
|
+
is_type(self.MetaData, dict, 'MetaData')
|
|
213
|
+
is_type(self.OtherData, (type(None), dict), 'OtherData')
|
|
214
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
215
|
+
@property
|
|
216
|
+
def VALIDATE_ATTR(self) -> tuple:
|
|
217
|
+
"""
|
|
218
|
+
Exposes _VALIDATE_ATTR as a read-only property.
|
|
219
|
+
"""
|
|
220
|
+
return self._VALIDATE_ATTR
|
|
221
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
222
|
+
def get_attributes(self, all_:bool=False) -> list[str]:
|
|
223
|
+
"""
|
|
224
|
+
Returns the names of attributes with non-None values.
|
|
225
|
+
|
|
226
|
+
Attributes
|
|
227
|
+
---------
|
|
228
|
+
all_ : `bool`, default `False`
|
|
229
|
+
Whether to return all keys irrespective of whether their values are
|
|
230
|
+
equal to None.
|
|
231
|
+
Returns
|
|
232
|
+
-------
|
|
233
|
+
list [`str`]
|
|
234
|
+
A list of attribute names where the value is not None.
|
|
235
|
+
"""
|
|
236
|
+
is_type(all_, bool)
|
|
237
|
+
if all_ == True:
|
|
238
|
+
res = list(self.__dict__.keys())
|
|
239
|
+
else:
|
|
240
|
+
res = [at for at, val in self.__dict__.items() if val is not None]
|
|
241
|
+
return res
|
|
242
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
243
|
+
def keys(self, attr_name: str | None = None,) -> list[str]:
|
|
244
|
+
"""
|
|
245
|
+
Return the keys for WaveForms, MedianBeats, MetaData, OtherData, or
|
|
246
|
+
all dictionaries.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
attr_name : `str`, default `NoneType`
|
|
251
|
+
Specify attribute name to limit the result to that dictionary. If
|
|
252
|
+
None, keys for all dictionaries are returned, without attempting
|
|
253
|
+
to unique these.
|
|
254
|
+
|
|
255
|
+
Returns
|
|
256
|
+
-------
|
|
257
|
+
`list` [`str`]
|
|
258
|
+
A list with dictionary keys.
|
|
259
|
+
"""
|
|
260
|
+
# #### check input
|
|
261
|
+
is_type(attr_name, (type(None), str))
|
|
262
|
+
if not attr_name in self._VALIDATE_ATTR and not attr_name is None:
|
|
263
|
+
raise ValueError(Error_MSG.CHOICE_PARM.\
|
|
264
|
+
format('attr_name', ', '.join(self._VALIDATE_ATTR)))
|
|
265
|
+
# find keys
|
|
266
|
+
attributes = self._VALIDATE_ATTR if attr_name is None else [attr_name]
|
|
267
|
+
result = []
|
|
268
|
+
for attr in attributes:
|
|
269
|
+
attribute = getattr(self, attr)
|
|
270
|
+
try:
|
|
271
|
+
result = result + list(attribute.keys())
|
|
272
|
+
except AttributeError:
|
|
273
|
+
pass
|
|
274
|
+
return result
|
|
275
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
276
|
+
def items(self, attr_name: str|None = None) -> dict[str, dict[str, str]]:
|
|
277
|
+
"""
|
|
278
|
+
Return the key-value pairs forWaveForms, MedianBeats, MetaData,
|
|
279
|
+
OtherData, or all dictionaries.
|
|
280
|
+
|
|
281
|
+
Parameters
|
|
282
|
+
----------
|
|
283
|
+
attr_name : `str`, default `NoneType`
|
|
284
|
+
Specify the type of attribute to limit the result to that
|
|
285
|
+
dictionary. If None, items for all dictionaries are returned.
|
|
286
|
+
|
|
287
|
+
Returns
|
|
288
|
+
-------
|
|
289
|
+
`dict` [`str`, `dict` [`str`, `str`]
|
|
290
|
+
A dictionary where keys are attribute names and values are the
|
|
291
|
+
key-value pairs of the dictionary.
|
|
292
|
+
"""
|
|
293
|
+
# #### check input
|
|
294
|
+
is_type(attr_name, (type(None), str))
|
|
295
|
+
if not attr_name in self._VALIDATE_ATTR and not attr_name is None:
|
|
296
|
+
raise ValueError(Error_MSG.CHOICE_PARM.\
|
|
297
|
+
format('attr_name', ', '.join(self._VALIDATE_ATTR)))
|
|
298
|
+
# find keys
|
|
299
|
+
attributes = self._VALIDATE_ATTR if attr_name is None else [attr_name]
|
|
300
|
+
result = {attr:{} for attr in attributes}
|
|
301
|
+
for attr in attributes:
|
|
302
|
+
attr_sub = getattr(self, attr, None)
|
|
303
|
+
try:
|
|
304
|
+
result[attr].update(attr_sub.items())
|
|
305
|
+
except (AttributeError, TypeError):
|
|
306
|
+
pass
|
|
307
|
+
return result
|
|
308
|
+
|
|
309
|
+
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|
310
|
+
class ConfigParser(object):
|
|
311
|
+
"""
|
|
312
|
+
Parses configuration files into structured data and optionally assigns this
|
|
313
|
+
to a user supplied mapper instance.
|
|
314
|
+
|
|
315
|
+
Parameters
|
|
316
|
+
----------
|
|
317
|
+
path : str
|
|
318
|
+
Path to the configuration file to be parsed.
|
|
319
|
+
|
|
320
|
+
Notes
|
|
321
|
+
-----
|
|
322
|
+
Required call sequence::
|
|
323
|
+
|
|
324
|
+
parser = ConfigParser(path)() # parse the file
|
|
325
|
+
parser.map(mapper) # map sections to a DataMap
|
|
326
|
+
parser.get_section(name) # retrieve a section
|
|
327
|
+
|
|
328
|
+
Calling `get_section()` before `map()` raises `NotCalledError`.
|
|
329
|
+
"""
|
|
330
|
+
# properties
|
|
331
|
+
path = ManagedProperty(ConfigNames.path)
|
|
332
|
+
_data = ManagedProperty(ConfigNames.data, dict)
|
|
333
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
334
|
+
def __init__(self, path:str):
|
|
335
|
+
'''
|
|
336
|
+
Initialize the ConfigParser instance.
|
|
337
|
+
'''
|
|
338
|
+
# making sure the setter is only used during _init_
|
|
339
|
+
getattr(type(self),ConfigNames.path).set_with_setter(self, path)
|
|
340
|
+
getattr(type(self),ConfigNames.data).set_with_setter(self, {})
|
|
341
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
342
|
+
def __eq__(self, other):
|
|
343
|
+
"""
|
|
344
|
+
Determine how instances are compared.
|
|
345
|
+
"""
|
|
346
|
+
if len(getattr(self, ConfigNames.data)) == 0:
|
|
347
|
+
if self.path == other.path and self._data == other._data:
|
|
348
|
+
return True
|
|
349
|
+
elif hasattr(self, ConfigNames.MAPPER) and\
|
|
350
|
+
hasattr(other, ConfigNames.MAPPER):
|
|
351
|
+
if getattr(self, ConfigNames.MAPPER) ==\
|
|
352
|
+
getattr(other, ConfigNames.MAPPER):
|
|
353
|
+
return True
|
|
354
|
+
elif self._data == other._data:
|
|
355
|
+
return True
|
|
356
|
+
return False
|
|
357
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
358
|
+
def __call__(self) -> Self:
|
|
359
|
+
"""
|
|
360
|
+
Parse the configuration file.
|
|
361
|
+
|
|
362
|
+
This method reads the configuration file specified during
|
|
363
|
+
initialization, validates its structure, and stores the data in a
|
|
364
|
+
dictionary. It supports sections with key-value pairs.
|
|
365
|
+
|
|
366
|
+
Raises
|
|
367
|
+
------
|
|
368
|
+
FileNotFoundError
|
|
369
|
+
If the configuration file does not exist.
|
|
370
|
+
IsADirectoryError
|
|
371
|
+
If the configuration path exists but is not a regular file.
|
|
372
|
+
ValueError
|
|
373
|
+
If a parsed line does not contain a tab delimiter.
|
|
374
|
+
InputValidationError
|
|
375
|
+
If a value-key pair is duplicated within the same section.
|
|
376
|
+
"""
|
|
377
|
+
current = None
|
|
378
|
+
path = getattr(self, ConfigNames.path)
|
|
379
|
+
_check_is_file(path)
|
|
380
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
381
|
+
for line in file:
|
|
382
|
+
line = line.strip()
|
|
383
|
+
if line.startswith("[") and line.endswith("]"):
|
|
384
|
+
# Start a new section
|
|
385
|
+
current = line.strip("[]")
|
|
386
|
+
# check if the attribute type is allowed
|
|
387
|
+
if hasattr(ConfigNames, current) == False:
|
|
388
|
+
raise AttributeError(
|
|
389
|
+
f"Encountered an unknown data type or malformed "
|
|
390
|
+
f"input: `{current}`. Ensure the file format is "
|
|
391
|
+
"correct and all sections are properly defined."
|
|
392
|
+
)
|
|
393
|
+
# confirm this is not already in data - fine if it is,
|
|
394
|
+
# no need to create a new key and subdict.
|
|
395
|
+
# can just add new entries.
|
|
396
|
+
if current not in getattr(self, ConfigNames.data):
|
|
397
|
+
getattr(self, ConfigNames.data)[current] = {}
|
|
398
|
+
# skip if there is a comment
|
|
399
|
+
elif re.match(r"^\s*#", line):
|
|
400
|
+
continue
|
|
401
|
+
elif current and line:
|
|
402
|
+
# Process the line
|
|
403
|
+
if "\t" in line:
|
|
404
|
+
# Split into key-value pairs
|
|
405
|
+
k, v = re.split(r'\t+', line, maxsplit=1)
|
|
406
|
+
# include a strip to make sure spaces are trimmed
|
|
407
|
+
if not k.strip() in\
|
|
408
|
+
getattr(self, ConfigNames.data)[current]:
|
|
409
|
+
getattr(self, ConfigNames.data)[current][k.strip()]=\
|
|
410
|
+
v.strip()
|
|
411
|
+
else:
|
|
412
|
+
raise InputValidationError(
|
|
413
|
+
f"`{k.strip()}` has already been set: "
|
|
414
|
+
f"{getattr(self, ConfigNames.data)[current]}. "
|
|
415
|
+
"Please ensure a single unique name has been "
|
|
416
|
+
"supplied for each variable."
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
raise ValueError("Missing tab delimiter.")
|
|
420
|
+
return self
|
|
421
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
422
|
+
def map(self, mapper:DataMap) -> None:
|
|
423
|
+
"""
|
|
424
|
+
Maps the parsed data to a supplied `DataMap` instance.
|
|
425
|
+
|
|
426
|
+
Attributes
|
|
427
|
+
----------
|
|
428
|
+
mapper : `DataMap`
|
|
429
|
+
An instance of the `DataMap` class, which is expected to have
|
|
430
|
+
attributes corresponding to the parsed data sections (e.g., headers
|
|
431
|
+
or types). These attributes will be updated with the data parsed by
|
|
432
|
+
this class.
|
|
433
|
+
|
|
434
|
+
Notes
|
|
435
|
+
-----
|
|
436
|
+
The method does not return anything and simply creates the `mapper`
|
|
437
|
+
attribute.
|
|
438
|
+
"""
|
|
439
|
+
WRONG = ('Attribute `{}` is outside the allowed for key set for `{}`.'
|
|
440
|
+
'Please update your configuration file to only list allowed '
|
|
441
|
+
'attribute names. For example, consult the mapper object.')
|
|
442
|
+
# ### make sure the instance was called
|
|
443
|
+
if len(getattr(self, ConfigNames.data)) == 0:
|
|
444
|
+
raise NotCalledError
|
|
445
|
+
# ### check input
|
|
446
|
+
missing = [a for a in getattr(self, ConfigNames.data).keys() if\
|
|
447
|
+
not hasattr(mapper, a)]
|
|
448
|
+
if len(missing) > 0:
|
|
449
|
+
raise AttributeError(
|
|
450
|
+
f"The following parsed data types are not defined as "
|
|
451
|
+
f"attributes in the provided mapper instance: "
|
|
452
|
+
f"{', '.join(missing)}. Please ensure the mapper has "
|
|
453
|
+
f"corresponding attributes for all required data types."
|
|
454
|
+
)
|
|
455
|
+
# ### class instances
|
|
456
|
+
# setting up the signal data
|
|
457
|
+
lead_keys = mapper.get_leads()
|
|
458
|
+
wave_data = PrivilegedData(_data=lead_keys.copy())
|
|
459
|
+
median_data = PrivilegedData(_data=lead_keys.copy())
|
|
460
|
+
lead_keys = lead_keys.keys()
|
|
461
|
+
# setting up the meta/other data
|
|
462
|
+
meta_data_req = PrivilegedData()
|
|
463
|
+
req_keys = meta_data_req.keys()
|
|
464
|
+
meta_data_other = OtherData()
|
|
465
|
+
other_data = OtherData()
|
|
466
|
+
for key, d in getattr(self, ConfigNames.data).items():
|
|
467
|
+
for k, v in d.items():
|
|
468
|
+
if key == ConfigNames.MedianBeats:
|
|
469
|
+
if k in lead_keys:
|
|
470
|
+
median_data.update_values(**{k:v})
|
|
471
|
+
else:
|
|
472
|
+
raise KeyError(WRONG.format(k, key))
|
|
473
|
+
elif key == ConfigNames.WaveForms:
|
|
474
|
+
if k in lead_keys:
|
|
475
|
+
wave_data.update_values(**{k:v})
|
|
476
|
+
else:
|
|
477
|
+
raise KeyError(WRONG.format(k, key))
|
|
478
|
+
elif key == ConfigNames.MetaData:
|
|
479
|
+
# identify the required keys
|
|
480
|
+
if k in req_keys:
|
|
481
|
+
meta_data_req.update_values(**{k:v})
|
|
482
|
+
else:
|
|
483
|
+
meta_data_other.update_values(**{k:v})
|
|
484
|
+
else:
|
|
485
|
+
# the non-meta data
|
|
486
|
+
other_data.update_values(**{k:v})
|
|
487
|
+
# #### set attributes
|
|
488
|
+
# set meta-data and other data
|
|
489
|
+
setattr(mapper, ConfigNames.OtherData, other_data.to_dict())
|
|
490
|
+
setattr(mapper, ConfigNames.WaveForms, wave_data.to_dict())
|
|
491
|
+
setattr(mapper, ConfigNames.MedianBeats, median_data.to_dict())
|
|
492
|
+
setattr(mapper, ConfigNames.MetaData,
|
|
493
|
+
{**meta_data_req.to_dict(),
|
|
494
|
+
**meta_data_other.to_dict()}
|
|
495
|
+
)
|
|
496
|
+
setattr(self, ConfigNames.MAPPER, mapper)
|
|
497
|
+
# # return stuff
|
|
498
|
+
# return mapper
|
|
499
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
500
|
+
def get_section(self,
|
|
501
|
+
section_name:Literal['MetaData', 'WaveForms', 'MedianBeats',
|
|
502
|
+
'OtherData'],
|
|
503
|
+
) -> dict[str, str]:
|
|
504
|
+
"""
|
|
505
|
+
Retrieve the parsed data for a specific section of the configuration
|
|
506
|
+
file.
|
|
507
|
+
|
|
508
|
+
Parameters
|
|
509
|
+
----------
|
|
510
|
+
section_name : {'MetaData', 'WaveForms', 'MedianBeats', 'OtherData'}
|
|
511
|
+
Name of the section to retrieve.
|
|
512
|
+
|
|
513
|
+
Returns
|
|
514
|
+
-------
|
|
515
|
+
section : `dict` [`str`, `str`]
|
|
516
|
+
A dictionary of key-value pairs for the requested section.
|
|
517
|
+
|
|
518
|
+
Raises
|
|
519
|
+
------
|
|
520
|
+
NotCalledError
|
|
521
|
+
If `map()` has not been called before this method.
|
|
522
|
+
ValueError
|
|
523
|
+
If `section_name` is not one of the allowed section names.
|
|
524
|
+
KeyError
|
|
525
|
+
If the requested section has no data.
|
|
526
|
+
"""
|
|
527
|
+
is_type(section_name, str)
|
|
528
|
+
SECTION_NAME = [ConfigNames.OtherData, ConfigNames.WaveForms,
|
|
529
|
+
ConfigNames.MedianBeats, ConfigNames.MetaData]
|
|
530
|
+
# confirm the choice is correct
|
|
531
|
+
if not section_name in SECTION_NAME:
|
|
532
|
+
raise ValueError(Error_MSG.CHOICE_PARM.\
|
|
533
|
+
format('section_name', ', '.join(SECTION_NAME)))
|
|
534
|
+
# confirm mapper has been run
|
|
535
|
+
if not hasattr(self, ConfigNames.MAPPER):
|
|
536
|
+
raise NotCalledError('`map()` must be called before `get_section()`.')
|
|
537
|
+
# get the correct attribute
|
|
538
|
+
res = getattr(getattr(self, ConfigNames.MAPPER), section_name)
|
|
539
|
+
if res is None:
|
|
540
|
+
raise KeyError(f"key {section_name} is not available.")
|
|
541
|
+
return res
|
|
542
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
543
|
+
def __str__(self):
|
|
544
|
+
"""String representation of the parsed data."""
|
|
545
|
+
result = [f"{self.__class__.__name__}"]
|
|
546
|
+
if not getattr(self, ConfigNames.data):
|
|
547
|
+
j = ""
|
|
548
|
+
result.append(f"path={getattr(self,ConfigNames.path)}")
|
|
549
|
+
else:
|
|
550
|
+
j = "\n"
|
|
551
|
+
# Determine the maximum key length for alignment
|
|
552
|
+
max_key_length = max(
|
|
553
|
+
(len(key) for _, attr in getattr(self, ConfigNames.data).items()
|
|
554
|
+
for key in attr.keys()), default=0)
|
|
555
|
+
for section, attributes in getattr(self, ConfigNames.data).items():
|
|
556
|
+
result.append(f"[{section}]")
|
|
557
|
+
for key, value in attributes.items():
|
|
558
|
+
# Align values by padding keys
|
|
559
|
+
result.append(f"\t{key:<{max_key_length}} {value}")
|
|
560
|
+
result.append("")
|
|
561
|
+
return j.join(result)
|
|
562
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
563
|
+
def __repr__(self):
|
|
564
|
+
"""Developer-friendly representation of the parsed data."""
|
|
565
|
+
result = [f"{self.__class__.__name__}("]
|
|
566
|
+
if not getattr(self, ConfigNames.data):
|
|
567
|
+
j = ""
|
|
568
|
+
result.append(f"path={getattr(self, ConfigNames.path)}")
|
|
569
|
+
else:
|
|
570
|
+
j = "\n"
|
|
571
|
+
# Determine the maximum key length for alignment
|
|
572
|
+
max_key_length = max(
|
|
573
|
+
(len(key) for _, attr in getattr(self, ConfigNames.data).items()
|
|
574
|
+
for key in attr.keys()), default=0)
|
|
575
|
+
for section, attributes in getattr(self, ConfigNames.data).items():
|
|
576
|
+
result.append(f" [{section}]")
|
|
577
|
+
for key, value in attributes.items():
|
|
578
|
+
# Align values by padding keys
|
|
579
|
+
result.append(f"\t{key:<{max_key_length}} {value}")
|
|
580
|
+
result.append(")")
|
|
581
|
+
return j.join(result)
|