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,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
+