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,311 @@
|
|
|
1
|
+
'''
|
|
2
|
+
Collecting established tools for ECG derivation or cleaning.
|
|
3
|
+
'''
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from ecgprocess.constants import (
|
|
7
|
+
CoreData as Core,
|
|
8
|
+
)
|
|
9
|
+
from scipy import signal
|
|
10
|
+
from ecgprocess.errors import (
|
|
11
|
+
is_type,
|
|
12
|
+
)
|
|
13
|
+
from typing import Any
|
|
14
|
+
import warnings
|
|
15
|
+
|
|
16
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
17
|
+
# constants
|
|
18
|
+
CLeads = Core.Leads
|
|
19
|
+
_STANDARD_LEADS: tuple[str, ...] = (
|
|
20
|
+
'I', 'II', 'III', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'aVF', 'aVL', 'aVR'
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
24
|
+
def resampling_500hz(signals:dict[str, np.array],
|
|
25
|
+
duration: int | float | None=None,
|
|
26
|
+
median:bool=False) -> dict[str, np.array]:
|
|
27
|
+
"""
|
|
28
|
+
Re-sample an ECG signal to 500 hz.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
signals : `dict` [`str`, np.array]
|
|
33
|
+
A dictionary with the lead names as string keys and the signals
|
|
34
|
+
as a 1D np.array.
|
|
35
|
+
duration : `int` or `float`
|
|
36
|
+
The represents the duration of the ECG in seconds, which is calculated
|
|
37
|
+
based on the fraction of number of samples by the sampling frequency
|
|
38
|
+
in seconds. For raw wavefomrs duration determines the number of
|
|
39
|
+
samples needed to get a 500hz sample: duration times 500.
|
|
40
|
+
median : `bool`, default `False`
|
|
41
|
+
Set to true to resample a median beat ECG to 500hz. The duration of a
|
|
42
|
+
median beat signal is 1.2 seconds, hence the number of samples is fixed
|
|
43
|
+
at: 1.2 times 500 = 600.
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
is_type(signals, dict)
|
|
47
|
+
is_type(duration, (type(None), int, float))
|
|
48
|
+
is_type(median, bool)
|
|
49
|
+
# #### get number of samples
|
|
50
|
+
num_samples = 600
|
|
51
|
+
if median == False:
|
|
52
|
+
if duration is None:
|
|
53
|
+
raise ValueError('`duration` should not be `NoneType` when `median` '
|
|
54
|
+
'is `False`.')
|
|
55
|
+
num_samples = int(duration * 500)
|
|
56
|
+
# #### resample
|
|
57
|
+
new_dict = {}
|
|
58
|
+
for l, sig in signals.items():
|
|
59
|
+
new_dict[l] = signal.resample(sig, num_samples)
|
|
60
|
+
# return
|
|
61
|
+
return new_dict
|
|
62
|
+
|
|
63
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
64
|
+
def get_limb_leads(signals:dict[str, np.array],
|
|
65
|
+
lead_I:str=CLeads.I,
|
|
66
|
+
lead_II:str=CLeads.II,
|
|
67
|
+
) -> dict[str, np.array]:
|
|
68
|
+
"""
|
|
69
|
+
Calculate the derived limb leads (III, aVR, aVL, aVF) from leads I and II.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
signals : `dict` [`str`, np.array]
|
|
74
|
+
A dictionary with the lead names as string keys and the signals
|
|
75
|
+
as a 1D np.array.
|
|
76
|
+
lead_I : `str`, default 'I'
|
|
77
|
+
The key name for lead I in `signals`
|
|
78
|
+
lead_II : `str`, default 'II'
|
|
79
|
+
The key name for lead II in `signals`
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
dict
|
|
84
|
+
A dictionary including limb lead signals.
|
|
85
|
+
|
|
86
|
+
Notes
|
|
87
|
+
-----
|
|
88
|
+
please see this
|
|
89
|
+
`url <https://ecgwaves.com/topic/ekg-ecg-leads-electrodes-systems-limb-chest-precordial/>`_
|
|
90
|
+
for the relevant explanation about the relationships between leads I and II
|
|
91
|
+
and the limb leads.
|
|
92
|
+
"""
|
|
93
|
+
# #### check input and set constants
|
|
94
|
+
is_type(signals, dict)
|
|
95
|
+
is_type(lead_I, str)
|
|
96
|
+
is_type(lead_II, str)
|
|
97
|
+
missing_src = [l for l in (lead_I, lead_II) if signals.get(l) is None]
|
|
98
|
+
if missing_src:
|
|
99
|
+
raise KeyError(
|
|
100
|
+
f'Source leads {missing_src} are not available in `signals`.'
|
|
101
|
+
)
|
|
102
|
+
# making sure we do not affect the original parsed signals
|
|
103
|
+
signals = dict(signals)
|
|
104
|
+
# #### get limb leads
|
|
105
|
+
if signals.get(CLeads.III) is None:
|
|
106
|
+
signals[CLeads.III] = np.subtract(signals[CLeads.II],
|
|
107
|
+
signals[CLeads.I])
|
|
108
|
+
if signals.get(CLeads.aVR) is None:
|
|
109
|
+
signals[CLeads.aVR] = np.add(signals[CLeads.I],
|
|
110
|
+
signals[CLeads.II]) * (-0.5)
|
|
111
|
+
if signals.get(CLeads.aVL) is None:
|
|
112
|
+
signals[CLeads.aVL] = np.subtract(signals[CLeads.I],
|
|
113
|
+
0.5 * signals[CLeads.II])
|
|
114
|
+
if signals.get(CLeads.aVF) is None:
|
|
115
|
+
signals[CLeads.aVF] = np.subtract(signals[CLeads.II],
|
|
116
|
+
0.5 * signals[CLeads.I])
|
|
117
|
+
# return
|
|
118
|
+
return signals
|
|
119
|
+
|
|
120
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
121
|
+
def signal_dicts_to_numpy_array(
|
|
122
|
+
signals: list[dict[str, np.ndarray]],
|
|
123
|
+
leads: list[str] | tuple[str, ...] | None = _STANDARD_LEADS,
|
|
124
|
+
padding: bool = True,
|
|
125
|
+
) -> np.ndarray:
|
|
126
|
+
"""
|
|
127
|
+
Convert a list of ECG signal dictionaries to a 3D NumPy array suitable
|
|
128
|
+
for deep learning.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
signals : `list` [`dict` [`str`, `np.ndarray`]]
|
|
133
|
+
List where each dictionary represents an ECG sample with lead names
|
|
134
|
+
as keys and numpy arrays as values.
|
|
135
|
+
leads : `list` [`str`] or `tuple` [`str`, ...] or `None`, \
|
|
136
|
+
default ``_STANDARD_LEADS``
|
|
137
|
+
Lead names to include and their order. Defaults to the standard
|
|
138
|
+
12-lead set defined in ``_STANDARD_LEADS`` (an immutable tuple).
|
|
139
|
+
Pass `None` to collect all unique leads found across samples in
|
|
140
|
+
sorted order.
|
|
141
|
+
padding : `bool`, default `True`
|
|
142
|
+
Whether to pad shorter signals to the length of the longest signal.
|
|
143
|
+
If `False`, all signals must have the same length. Default is `True`.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
np.ndarray
|
|
148
|
+
3D NumPy array with shape `(num_samples, num_leads, signal_length)`
|
|
149
|
+
containing the ECG data.
|
|
150
|
+
|
|
151
|
+
Raises
|
|
152
|
+
------
|
|
153
|
+
ValueError
|
|
154
|
+
If `ecg_data` is empty.
|
|
155
|
+
If any sample is missing leads specified in `leads`.
|
|
156
|
+
If `padding` is `False` and signals have varying lengths.
|
|
157
|
+
|
|
158
|
+
Notes
|
|
159
|
+
-----
|
|
160
|
+
The numpy array column matches the order of the supplied leads.
|
|
161
|
+
"""
|
|
162
|
+
# check input
|
|
163
|
+
is_type(signals, list)
|
|
164
|
+
if not signals:
|
|
165
|
+
raise ValueError("The signals list is empty.")
|
|
166
|
+
# Determine the list of leads
|
|
167
|
+
if leads is None:
|
|
168
|
+
# Collect all unique leads across samples
|
|
169
|
+
unique_leads: set[str] = set()
|
|
170
|
+
for sample in signals:
|
|
171
|
+
unique_leads.update(sample.keys())
|
|
172
|
+
leads = sorted(unique_leads)
|
|
173
|
+
else:
|
|
174
|
+
# Ensure leads is a sequence of strings
|
|
175
|
+
if not isinstance(leads, (list, tuple)) or not all(
|
|
176
|
+
isinstance(lead, str) for lead in leads
|
|
177
|
+
):
|
|
178
|
+
raise TypeError("`leads` must be a list or tuple of strings.")
|
|
179
|
+
leads = list(leads)
|
|
180
|
+
# set the samples and lead numbers
|
|
181
|
+
num_samples = len(signals)
|
|
182
|
+
num_leads = len(leads)
|
|
183
|
+
# do we want to pad the signals if needed.
|
|
184
|
+
if padding:
|
|
185
|
+
# Find the maximum length among all leads in all samples
|
|
186
|
+
signal_length = max(
|
|
187
|
+
len(sample.get(lead, [])) for sample in signals for lead in leads
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
# Ensure all signals have the same length
|
|
191
|
+
lengths = [
|
|
192
|
+
len(sample.get(lead, [])) for sample in signals for lead in leads
|
|
193
|
+
]
|
|
194
|
+
unique_lengths = set(lengths)
|
|
195
|
+
if len(unique_lengths) != 1:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
"All signals must have the same length when padding is False."
|
|
198
|
+
)
|
|
199
|
+
signal_length = unique_lengths.pop()
|
|
200
|
+
# Initialize the array with NaNs
|
|
201
|
+
data_array = np.full(
|
|
202
|
+
(num_samples, num_leads, signal_length), np.nan, dtype=np.float32
|
|
203
|
+
)
|
|
204
|
+
for i, sample in enumerate(signals):
|
|
205
|
+
for j, lead in enumerate(leads):
|
|
206
|
+
lead_data = sample.get(lead, np.array([]))
|
|
207
|
+
# Validate that lead_data is a np.array
|
|
208
|
+
if not isinstance(lead_data, np.ndarray):
|
|
209
|
+
raise TypeError(
|
|
210
|
+
f"Lead '{lead}' in sample {i} is not a NumPy array."
|
|
211
|
+
)
|
|
212
|
+
current_length = len(lead_data)
|
|
213
|
+
if current_length == 0:
|
|
214
|
+
# If lead data is missing or empty, leave it as NaN
|
|
215
|
+
continue
|
|
216
|
+
elif padding:
|
|
217
|
+
if current_length > signal_length:
|
|
218
|
+
# Truncate the signal if it's longer than signal_length
|
|
219
|
+
warnings.warn('Signal is longer than the max length '
|
|
220
|
+
'this is unexpected, please check input.')
|
|
221
|
+
data_array[i, j, :] = lead_data[:signal_length]
|
|
222
|
+
else:
|
|
223
|
+
# Pad the signal with NaNs if it's shorter than
|
|
224
|
+
# signal_length
|
|
225
|
+
data_array[i, j, :current_length] = lead_data
|
|
226
|
+
else:
|
|
227
|
+
if current_length != signal_length:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Signal length for lead '{lead}' in sample {i} "
|
|
230
|
+
f"({current_length}) does not match signal_length "
|
|
231
|
+
f"({signal_length})."
|
|
232
|
+
)
|
|
233
|
+
data_array[i, j, :] = lead_data
|
|
234
|
+
# return
|
|
235
|
+
return data_array
|
|
236
|
+
|
|
237
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
238
|
+
def signal_calibration(signal:np.ndarray,
|
|
239
|
+
correctionfactor: float,
|
|
240
|
+
baseline: float,
|
|
241
|
+
) -> np.ndarray:
|
|
242
|
+
"""
|
|
243
|
+
Adjusts the ECG signal by subtracting the channel baseline from the signal,
|
|
244
|
+
followed by multiplying the adjusted signal by the channel correction
|
|
245
|
+
factor.
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
signal : `np.ndarray`
|
|
250
|
+
The lead-specific ECG signal.
|
|
251
|
+
correctionfactor : `float`
|
|
252
|
+
The channel correction factor.
|
|
253
|
+
baseline : `float`
|
|
254
|
+
The channel baseline.
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
np.ndarray
|
|
259
|
+
The recalibrated signal.
|
|
260
|
+
"""
|
|
261
|
+
is_type(signal, np.ndarray)
|
|
262
|
+
is_type(correctionfactor, float)
|
|
263
|
+
is_type(baseline, float)
|
|
264
|
+
# algorithm
|
|
265
|
+
new_signal = (signal - baseline) * correctionfactor
|
|
266
|
+
# return
|
|
267
|
+
return new_signal
|
|
268
|
+
|
|
269
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
270
|
+
def signal_resolution(signal:np.ndarray,
|
|
271
|
+
resolution_current: float,
|
|
272
|
+
resolution_target: float,
|
|
273
|
+
) -> np.ndarray:
|
|
274
|
+
"""
|
|
275
|
+
Adjust the amplitude scale of an ECG signal to match a desired resolution.
|
|
276
|
+
|
|
277
|
+
Parameters
|
|
278
|
+
----------
|
|
279
|
+
signal : `np.ndarray`
|
|
280
|
+
The lead-specific ECG signal.
|
|
281
|
+
resolution_current : `float`
|
|
282
|
+
The current resolution.
|
|
283
|
+
resolution_target : `float`
|
|
284
|
+
The target resolution.
|
|
285
|
+
|
|
286
|
+
Returns
|
|
287
|
+
-------
|
|
288
|
+
np.ndarray
|
|
289
|
+
The rescaled signal.
|
|
290
|
+
|
|
291
|
+
Example
|
|
292
|
+
-------
|
|
293
|
+
>>> import numpy as np
|
|
294
|
+
>>> ecg_signal = np.array([10, 20, 30, 40, 50])
|
|
295
|
+
>>> current_res = 2.0 # each digital unit equals 2 μV
|
|
296
|
+
>>> new_signal = adjust_resolution(
|
|
297
|
+
... ecg_signal,
|
|
298
|
+
... resolution_current=current_res,
|
|
299
|
+
... resolution_target=5
|
|
300
|
+
... )
|
|
301
|
+
>>> print(new_signal)
|
|
302
|
+
[ 25. 50. 75. 100. 125.]
|
|
303
|
+
"""
|
|
304
|
+
is_type(signal, np.ndarray)
|
|
305
|
+
is_type(resolution_current, float)
|
|
306
|
+
is_type(resolution_target, float)
|
|
307
|
+
# algorithm
|
|
308
|
+
new_signal = signal * resolution_target/resolution_current
|
|
309
|
+
# return
|
|
310
|
+
return new_signal
|
|
311
|
+
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A module containing a collection of function or classes which can be used
|
|
3
|
+
as engineering functions in the Tabular module.
|
|
4
|
+
|
|
5
|
+
The listed programs are meant to provide an idea of potentially relevant
|
|
6
|
+
solutions. Users can use these functions out of the box, adapt them, or
|
|
7
|
+
simply write their own custom code.
|
|
8
|
+
|
|
9
|
+
When writing your own engineering solution remember that the first argument
|
|
10
|
+
will take the metadata, waveforms, or metadata. The waveforms and metadata
|
|
11
|
+
functions should have a kwargs argument which will be used internally by
|
|
12
|
+
Tabular to pass meta_dict (the metadata of the file being processed) to the
|
|
13
|
+
function environment making it available to alter the signal data.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
17
|
+
# imports
|
|
18
|
+
import re
|
|
19
|
+
import logging
|
|
20
|
+
import numpy as np
|
|
21
|
+
from typing import (
|
|
22
|
+
Callable, Self, Optional, Any, Literal,
|
|
23
|
+
)
|
|
24
|
+
from ecgprocess.errors import (
|
|
25
|
+
FileValidationError,
|
|
26
|
+
InputValidationError,
|
|
27
|
+
is_type,
|
|
28
|
+
)
|
|
29
|
+
from ecgprocess.utils.ecg_tools import(
|
|
30
|
+
signal_dicts_to_numpy_array,
|
|
31
|
+
signal_calibration,
|
|
32
|
+
signal_resolution,
|
|
33
|
+
)
|
|
34
|
+
from ecgprocess.constants import (
|
|
35
|
+
TabularNames as TabNames,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
39
|
+
_log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
42
|
+
def metadata_checkversion(
|
|
43
|
+
metadata:dict[str, Any],
|
|
44
|
+
expected_version = ['1.02 SP03', 'MUSE_9.0.9.18167'],
|
|
45
|
+
expected_manufacturer = 'GE Healthcare',
|
|
46
|
+
expected_model = 'MV360',
|
|
47
|
+
version_name:str = "Softwave version",
|
|
48
|
+
manufacturer_name:str = "Manufacturer",
|
|
49
|
+
model_name = "Model name",
|
|
50
|
+
**kwargs,
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Validates the DICOM file against the specified software version,
|
|
54
|
+
manufacturer, and model. If any of these do not match, a
|
|
55
|
+
FileValidationError is raised.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
metadata : `dict` [`str`, `any`]
|
|
60
|
+
A dictionary containing the metadata for a DICOM file. It must include:
|
|
61
|
+
- "Softwave version": The software version associated with the file.
|
|
62
|
+
- "Manufacturer": The manufacturer of the device.
|
|
63
|
+
- "Model name": The model name of the device.
|
|
64
|
+
expected_version : `str` or `list` [`str`], default ['1.02 SP03', 'MUSE_9.0.9.18167']
|
|
65
|
+
The software version.
|
|
66
|
+
expected_manufacturer : `str` or `list` [`str`], default 'GE Healthcare'
|
|
67
|
+
The manufacturer.
|
|
68
|
+
expected_model : `str` or `list` [`str`], 'MV360'
|
|
69
|
+
The model.
|
|
70
|
+
version_name : `str`
|
|
71
|
+
The key name for version in meta_dict.
|
|
72
|
+
manufacturer_name : `str`
|
|
73
|
+
The key name for manufacturer in meta_dict.
|
|
74
|
+
moel_name : `str`
|
|
75
|
+
The key name for version in meta_dict.
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
dict [`str`, `any`]
|
|
80
|
+
The input `metadata` dictionary if validation is successful.
|
|
81
|
+
|
|
82
|
+
Raises
|
|
83
|
+
------
|
|
84
|
+
FileValidationError
|
|
85
|
+
If the DICOM metadata's software version, manufacturer, or model
|
|
86
|
+
does not match the respective expected values.
|
|
87
|
+
|
|
88
|
+
Notes
|
|
89
|
+
-----
|
|
90
|
+
Depending on the `ignore_invalid` parameter of `Tabular` the failed
|
|
91
|
+
filenames will be added to the `invalid_list` attribute.
|
|
92
|
+
"""
|
|
93
|
+
is_type(manufacturer_name, str)
|
|
94
|
+
is_type(version_name, str)
|
|
95
|
+
is_type(model_name, str)
|
|
96
|
+
# constant
|
|
97
|
+
version = version_name
|
|
98
|
+
manufact = manufacturer_name
|
|
99
|
+
model_name = model_name
|
|
100
|
+
# the algorithm: check each expected field independently so the error
|
|
101
|
+
# message identifies which field failed. expected_* may be a single
|
|
102
|
+
# string or a list of strings; normalise to a list so membership tests
|
|
103
|
+
# work in both cases.
|
|
104
|
+
def _check(field_label: str, got: Any, expected: Any) -> None:
|
|
105
|
+
expected_list = expected if isinstance(expected, list) else [expected]
|
|
106
|
+
if got not in expected_list:
|
|
107
|
+
raise FileValidationError(
|
|
108
|
+
f"{field_label} `{got}` failed to validate; "
|
|
109
|
+
f"expected one of {expected_list}."
|
|
110
|
+
)
|
|
111
|
+
_check("Version", metadata[version], expected_version)
|
|
112
|
+
_check("Manufacturer", metadata[manufact], expected_manufacturer)
|
|
113
|
+
_check("Model", metadata[model_name], expected_model)
|
|
114
|
+
# if all correct simply return metadata
|
|
115
|
+
return metadata
|
|
116
|
+
|
|
117
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
118
|
+
def signal_correction(
|
|
119
|
+
signals:dict[str, np.ndarray], baseline_name:str='wave_channel_baseline_',
|
|
120
|
+
correctionfactor_name:str='wave_channel_correctionfactor_',
|
|
121
|
+
**kwargs) -> dict[str, np.ndarray]:
|
|
122
|
+
"""
|
|
123
|
+
Adjusts the signals by subtracting the channel baseline multiplied
|
|
124
|
+
by the channel correction factor. These parameters must be provided in
|
|
125
|
+
`kwargs[TabNames.META_DICT]`.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
signals : `dict` [`str`, `np.ndarray`]
|
|
130
|
+
A dictionary mapping channel names (strings) to waveform arrays.
|
|
131
|
+
baseline_name : str
|
|
132
|
+
The dictionary key name for the channel baseline. Will internally add
|
|
133
|
+
a numeric suffix ranging from 0 to 11 (inclusive).
|
|
134
|
+
correctionfactor_name : str
|
|
135
|
+
The dictionary key name for the channel correctionfactor. Will
|
|
136
|
+
internally add a numeric suffix ranging from 0 to 11 (inclusive).
|
|
137
|
+
**kwargs
|
|
138
|
+
Additional keyword arguments, which must include a dictionary under
|
|
139
|
+
the key `TabNames.META_DICT`. This dictionary should contain:
|
|
140
|
+
- wave_channel_correctionfactor_i : float
|
|
141
|
+
Correction factor for channel i.
|
|
142
|
+
- wave_channel_baseline_i : float
|
|
143
|
+
Baseline offset for channel i.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
dict [`str`, `np.ndarray`]
|
|
148
|
+
The input signals dictionary with corrected signals.
|
|
149
|
+
|
|
150
|
+
Raises
|
|
151
|
+
------
|
|
152
|
+
KeyError
|
|
153
|
+
If `TabNames.META_DICT` is not found in **kwargs.
|
|
154
|
+
"""
|
|
155
|
+
# constants - these values should be in meta_dict
|
|
156
|
+
corr = correctionfactor_name
|
|
157
|
+
base = baseline_name
|
|
158
|
+
# TODO
|
|
159
|
+
# add a check/raise error for the presence or absence of these keys in
|
|
160
|
+
# meta_dict.
|
|
161
|
+
# the algorithm
|
|
162
|
+
if not TabNames.META_DICT in kwargs:
|
|
163
|
+
raise KeyError(f"`{TabNames.META_DICT}` should be included as kwargs")
|
|
164
|
+
else:
|
|
165
|
+
meta_dict = kwargs[TabNames.META_DICT]
|
|
166
|
+
signals = dict(signals)
|
|
167
|
+
for i, (k, v) in enumerate(signals.items()):
|
|
168
|
+
# skip of None
|
|
169
|
+
if v is None:
|
|
170
|
+
signals[k] = v
|
|
171
|
+
continue
|
|
172
|
+
# confirming this is a np.array
|
|
173
|
+
is_type(v, np.ndarray)
|
|
174
|
+
_log.debug(
|
|
175
|
+
'Applying baseline: %s and factor: %s corrections to lead: %s.',
|
|
176
|
+
meta_dict[base+str(i)], meta_dict[corr+str(i)], k,
|
|
177
|
+
)
|
|
178
|
+
signals[k] = signal_calibration(
|
|
179
|
+
v, correctionfactor=meta_dict[corr+str(i)],
|
|
180
|
+
baseline=meta_dict[base+str(i)],
|
|
181
|
+
)
|
|
182
|
+
return signals
|
|
183
|
+
|
|
184
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
185
|
+
def signal_standardise_res(
|
|
186
|
+
signals:dict[str, np.ndarray], resolution_name:str = 'wave_channel_sens_',
|
|
187
|
+
target_resolution:float = 5., **kwargs,
|
|
188
|
+
) -> dict[str, np.ndarray]:
|
|
189
|
+
"""
|
|
190
|
+
Standardise the resolution signal by adjusting the amplitude scale by the
|
|
191
|
+
ratio of the source and target solution.
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
signals : `dict` [`str`, `np.ndarray`]
|
|
196
|
+
A dictionary mapping channel names (strings) to waveform arrays.
|
|
197
|
+
resolution_name : str
|
|
198
|
+
The dictionary key name for the channel sensitivity/resolution. Will
|
|
199
|
+
internally add a numeric suffix ranging from 0 to 11 (inclusive).
|
|
200
|
+
target_resolution : `float`, default 5
|
|
201
|
+
The target resolution.
|
|
202
|
+
**kwargs
|
|
203
|
+
Additional keyword arguments, which must include a dictionary under
|
|
204
|
+
the key `TabNames.META_DICT`. This dictionary should contain:
|
|
205
|
+
- wave_channel_sens : float
|
|
206
|
+
The wave channel sensitivity/resolution for channel i.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
dict [`str`, `np.ndarray`]
|
|
211
|
+
The input signals dictionary with corrected signals.
|
|
212
|
+
|
|
213
|
+
Raises
|
|
214
|
+
------
|
|
215
|
+
KeyError
|
|
216
|
+
If `TabNames.META_DICT` is not found in **kwargs.
|
|
217
|
+
If resolution_name+str(i) is not found in `TabNames.META_DICT`.
|
|
218
|
+
|
|
219
|
+
Notes
|
|
220
|
+
-----
|
|
221
|
+
The function will apply a scaling factor of
|
|
222
|
+
source_resolution/target_resolution to ensure the returned signal has the
|
|
223
|
+
desired target uV.
|
|
224
|
+
"""
|
|
225
|
+
# constants - these values should be in meta_dict
|
|
226
|
+
sens = resolution_name
|
|
227
|
+
# the algorithm
|
|
228
|
+
if not TabNames.META_DICT in kwargs:
|
|
229
|
+
raise KeyError(f"`{TabNames.META_DICT}` should be included as kwargs")
|
|
230
|
+
else:
|
|
231
|
+
meta_dict = kwargs[TabNames.META_DICT]
|
|
232
|
+
signals = dict(signals)
|
|
233
|
+
for i, (k, v) in enumerate(signals.items()):
|
|
234
|
+
# skip of None
|
|
235
|
+
if v is None:
|
|
236
|
+
signals[k] = v
|
|
237
|
+
continue
|
|
238
|
+
# confirming this is a np.array
|
|
239
|
+
is_type(v, np.ndarray)
|
|
240
|
+
# check key is in dict
|
|
241
|
+
key_name = sens+str(i)
|
|
242
|
+
if not key_name in meta_dict:
|
|
243
|
+
raise KeyError(f"`{key_name}` not found in `{TabNames.META_DICT}`.")
|
|
244
|
+
if target_resolution/meta_dict[key_name] != 1.0:
|
|
245
|
+
_log.debug(
|
|
246
|
+
'Rescaling lead `%s` by factor `%s`.',
|
|
247
|
+
k, target_resolution/meta_dict[key_name],
|
|
248
|
+
)
|
|
249
|
+
signals[k] = signal_resolution(
|
|
250
|
+
v, resolution_current=meta_dict[key_name],
|
|
251
|
+
resolution_target=target_resolution,
|
|
252
|
+
)
|
|
253
|
+
return signals
|
|
254
|
+
|
|
255
|
+
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|
256
|
+
class LeadMapper(object):
|
|
257
|
+
"""
|
|
258
|
+
Normalise ECG lead ordering when a device writes channels out of sequence.
|
|
259
|
+
|
|
260
|
+
Some DICOM devices store channels in a non-standard order (e.g. Lead II
|
|
261
|
+
data in channel-0 slot). ``LeadMapper`` reads ``signal name X`` entries
|
|
262
|
+
from ``meta_dict``, builds an actual-label-to-key mapping, then reassigns
|
|
263
|
+
each canonical key in ``signals`` to the correct array.
|
|
264
|
+
|
|
265
|
+
Parameters
|
|
266
|
+
----------
|
|
267
|
+
accepted_mappings : `dict` [`str`, `list` [`str`]]
|
|
268
|
+
Maps each canonical lead key (e.g. ``'I'``) to a list of device
|
|
269
|
+
label strings that are acceptable for that lead
|
|
270
|
+
(e.g. ``['I', 'Lead I', 'Lead I (Einthoven)']``).
|
|
271
|
+
|
|
272
|
+
Attributes
|
|
273
|
+
----------
|
|
274
|
+
accepted_mappings : `dict` [`str`, `list` [`str`]]
|
|
275
|
+
The accepted lead label mappings supplied at initialisation.
|
|
276
|
+
|
|
277
|
+
Methods
|
|
278
|
+
-------
|
|
279
|
+
__call__(signals, **kwargs)
|
|
280
|
+
Reassign signal arrays to their canonical lead keys.
|
|
281
|
+
|
|
282
|
+
Raises
|
|
283
|
+
------
|
|
284
|
+
InputValidationError
|
|
285
|
+
If ``accepted_mappings`` is not a ``dict``, or if any value is
|
|
286
|
+
not a ``list``.
|
|
287
|
+
|
|
288
|
+
Notes
|
|
289
|
+
-----
|
|
290
|
+
The callable works for both ``engineer_wave`` and ``engineer_median``
|
|
291
|
+
call sites in ``tabular.py`` since both share the same signature
|
|
292
|
+
``(signals, meta_dict=meta_temp)``.
|
|
293
|
+
"""
|
|
294
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
295
|
+
def __init__(self, accepted_mappings: dict[str, list[str]]) -> None:
|
|
296
|
+
"""Initialise LeadMapper."""
|
|
297
|
+
is_type(accepted_mappings, dict)
|
|
298
|
+
for key, val in accepted_mappings.items():
|
|
299
|
+
if not isinstance(val, list):
|
|
300
|
+
raise InputValidationError(
|
|
301
|
+
f"accepted_mappings values must be lists; "
|
|
302
|
+
f"key '{key}' has type {type(val)}"
|
|
303
|
+
)
|
|
304
|
+
self.accepted_mappings = accepted_mappings
|
|
305
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
306
|
+
def __call__(
|
|
307
|
+
self,
|
|
308
|
+
signals: dict[str, np.ndarray],
|
|
309
|
+
**kwargs,
|
|
310
|
+
) -> dict[str, np.ndarray]:
|
|
311
|
+
"""
|
|
312
|
+
Reassign signal arrays to their canonical lead keys.
|
|
313
|
+
|
|
314
|
+
Parameters
|
|
315
|
+
----------
|
|
316
|
+
signals : `dict` [`str`, `np.ndarray`]
|
|
317
|
+
Dict mapping canonical key names to waveform arrays, as parsed
|
|
318
|
+
by the config parser (keys correct, values potentially in wrong
|
|
319
|
+
channel order).
|
|
320
|
+
**kwargs
|
|
321
|
+
Must include ``meta_dict`` (keyed by ``TabNames.META_DICT``),
|
|
322
|
+
a dict containing ``'signal name N'`` entries that map channel
|
|
323
|
+
indices to device lead labels.
|
|
324
|
+
|
|
325
|
+
Returns
|
|
326
|
+
-------
|
|
327
|
+
mapped_signals : `dict` [`str`, `np.ndarray`]
|
|
328
|
+
Shallow copy of ``signals`` with arrays reassigned to the correct
|
|
329
|
+
canonical keys.
|
|
330
|
+
|
|
331
|
+
Raises
|
|
332
|
+
------
|
|
333
|
+
KeyError
|
|
334
|
+
If ``TabNames.META_DICT`` is absent from ``kwargs``, or if no
|
|
335
|
+
device label matches a required canonical key.
|
|
336
|
+
"""
|
|
337
|
+
# check input
|
|
338
|
+
is_type(signals, dict)
|
|
339
|
+
if TabNames.META_DICT not in kwargs:
|
|
340
|
+
raise KeyError(
|
|
341
|
+
f"`{TabNames.META_DICT}` should be included as kwargs"
|
|
342
|
+
)
|
|
343
|
+
# map data
|
|
344
|
+
meta_dict = kwargs[TabNames.META_DICT]
|
|
345
|
+
mapped_signals = dict(signals)
|
|
346
|
+
signal_keys = list(mapped_signals.keys())
|
|
347
|
+
# Map each canonical slot key to the device label the device wrote
|
|
348
|
+
# there; e.g. {'I': 'Lead II'} means channel 0 holds 'Lead II' data.
|
|
349
|
+
actual_mappings = {
|
|
350
|
+
signal_keys[int(re.sub(r"^signal name\s*", "", k))]: v
|
|
351
|
+
for k, v in meta_dict.items()
|
|
352
|
+
if k.startswith('signal name')
|
|
353
|
+
}
|
|
354
|
+
_log.debug('Accepted lead mappings: %s', self.accepted_mappings)
|
|
355
|
+
_log.debug('Actual lead mappings: %s', actual_mappings)
|
|
356
|
+
# Now apply the mappings
|
|
357
|
+
for accepted_key, accepted_vals in self.accepted_mappings.items():
|
|
358
|
+
match = False
|
|
359
|
+
for actual_key, actual_val in actual_mappings.items():
|
|
360
|
+
if actual_val in accepted_vals:
|
|
361
|
+
_log.debug(
|
|
362
|
+
"Mapping '%s' (%s) <- actual '%s' (%s)",
|
|
363
|
+
accepted_key, accepted_vals, actual_key, actual_val,
|
|
364
|
+
)
|
|
365
|
+
mapped_signals[accepted_key] = signals[actual_key]
|
|
366
|
+
match = True
|
|
367
|
+
break
|
|
368
|
+
if not match:
|
|
369
|
+
raise KeyError(
|
|
370
|
+
f"No device label found for canonical key '{accepted_key}' "
|
|
371
|
+
f"(accepted: {accepted_vals})"
|
|
372
|
+
)
|
|
373
|
+
return mapped_signals
|
|
374
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
375
|
+
def __repr__(self) -> str:
|
|
376
|
+
"""Return unambiguous string representation."""
|
|
377
|
+
CLASS_NAME = type(self).__name__
|
|
378
|
+
return f"{CLASS_NAME}(accepted_mappings={self.accepted_mappings!r})"
|
|
379
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
380
|
+
def __str__(self) -> str:
|
|
381
|
+
"""Return human-readable string representation."""
|
|
382
|
+
CLASS_NAME = type(self).__name__
|
|
383
|
+
leads = list(self.accepted_mappings.keys())
|
|
384
|
+
return f"{CLASS_NAME} with {len(leads)} canonical leads: {leads}"
|
|
385
|
+
|