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
ecgprocess/plot_ecgs.py
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
'''
|
|
2
|
+
Tools to plot ECG signals.
|
|
3
|
+
|
|
4
|
+
ECGDrawing takes a called reader instance (ECGXMLReader or ECGDICOMReader)
|
|
5
|
+
and renders the lead-specific ECG signals on a GridSpec figure with
|
|
6
|
+
clinical ECG paper scaling.
|
|
7
|
+
'''
|
|
8
|
+
|
|
9
|
+
# %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
|
10
|
+
# from __future__ import annotations
|
|
11
|
+
import logging
|
|
12
|
+
import numpy as np
|
|
13
|
+
import matplotlib.pylab as plt
|
|
14
|
+
from matplotlib.gridspec import GridSpec
|
|
15
|
+
from matplotlib.ticker import MultipleLocator
|
|
16
|
+
from typing import Any, NamedTuple, Self, Literal
|
|
17
|
+
from ecgprocess.errors import InputValidationError, NotCalledError, is_type
|
|
18
|
+
from ecgprocess.constants import PlotNames as PDNames, CoreData
|
|
19
|
+
from ecgprocess.utils.general import _update_kwargs
|
|
20
|
+
|
|
21
|
+
# initiate the logger
|
|
22
|
+
_log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
25
|
+
def _minor_only_ticks(
|
|
26
|
+
lo: float,
|
|
27
|
+
hi: float,
|
|
28
|
+
minor_step: float,
|
|
29
|
+
major_step: float,
|
|
30
|
+
) -> np.ndarray:
|
|
31
|
+
"""
|
|
32
|
+
Return minor tick positions that do not coincide with major ticks.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
lo : `float`
|
|
37
|
+
Lower axis limit.
|
|
38
|
+
hi : `float`
|
|
39
|
+
Upper axis limit.
|
|
40
|
+
minor_step : `float`
|
|
41
|
+
Minor tick spacing.
|
|
42
|
+
major_step : `float`
|
|
43
|
+
Major tick spacing.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
ticks : `np.ndarray`
|
|
48
|
+
Minor positions with major-coincident values removed.
|
|
49
|
+
"""
|
|
50
|
+
# Small epsilon to keep np.arange inclusive at hi and to absorb
|
|
51
|
+
# floating-point noise when testing whether a tick falls on a major line.
|
|
52
|
+
tol = minor_step * 1e-6
|
|
53
|
+
# get minor tick locations
|
|
54
|
+
tick_loc = np.arange(
|
|
55
|
+
np.ceil(lo / minor_step) * minor_step,
|
|
56
|
+
hi + tol,
|
|
57
|
+
minor_step,
|
|
58
|
+
)
|
|
59
|
+
remainder = tick_loc % major_step
|
|
60
|
+
# which tick to remove
|
|
61
|
+
on_major = np.isclose(remainder, 0.0, atol=tol) | np.isclose(
|
|
62
|
+
remainder, major_step, atol=tol,
|
|
63
|
+
)
|
|
64
|
+
# return
|
|
65
|
+
return tick_loc[~on_major]
|
|
66
|
+
|
|
67
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
68
|
+
def _resolve_lead_kwargs(
|
|
69
|
+
kw_dict: dict[str, Any] | None,
|
|
70
|
+
lead: str,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""
|
|
73
|
+
Return the resolved kwargs dict for a single lead.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
kw_dict : `dict[str, Any]` | `None`
|
|
78
|
+
Either a flat dict (uniform across all leads), a dict of dicts
|
|
79
|
+
(per-lead), or None.
|
|
80
|
+
lead : `str`
|
|
81
|
+
Lead name to resolve for.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
resolved : `dict[str, Any]`
|
|
86
|
+
Kwargs applicable to this lead. Empty dict when kw_dict is None or
|
|
87
|
+
the lead is not listed in a per-lead mapping.
|
|
88
|
+
|
|
89
|
+
Raises
|
|
90
|
+
------
|
|
91
|
+
InputValidationError
|
|
92
|
+
If kw_dict contains a mix of dict and non-dict values.
|
|
93
|
+
|
|
94
|
+
Examples
|
|
95
|
+
--------
|
|
96
|
+
Uniform, same kwargs applied to every lead:
|
|
97
|
+
|
|
98
|
+
>>> _resolve_lead_kwargs({'color': 'red', 'linewidth': 1.5}, 'I')
|
|
99
|
+
{'color': 'red', 'linewidth': 1.5}
|
|
100
|
+
|
|
101
|
+
Per-lead, different kwargs per lead:
|
|
102
|
+
|
|
103
|
+
>>> _resolve_lead_kwargs({
|
|
104
|
+
'I': {'color': 'red'},
|
|
105
|
+
'II': {'color': 'blue'}
|
|
106
|
+
}, lead = 'I')
|
|
107
|
+
{'color': 'red'}
|
|
108
|
+
"""
|
|
109
|
+
# return empty dict if None
|
|
110
|
+
if not kw_dict:
|
|
111
|
+
return {}
|
|
112
|
+
vals = list(kw_dict.values())
|
|
113
|
+
all_dicts = all(isinstance(v, dict) for v in vals)
|
|
114
|
+
any_dicts = any(isinstance(v, dict) for v in vals)
|
|
115
|
+
if all_dicts:
|
|
116
|
+
# return the lead specific dict
|
|
117
|
+
return kw_dict.get(lead, {})
|
|
118
|
+
if not any_dicts:
|
|
119
|
+
# if there are no dicts, that means the original dict should be
|
|
120
|
+
# applied to each lead
|
|
121
|
+
return kw_dict
|
|
122
|
+
# if mixed dicts raise an error
|
|
123
|
+
raise InputValidationError(
|
|
124
|
+
"Mixed dict/non-dict values in kwargs, either pass a flat dict "
|
|
125
|
+
"(uniform) or a dict of dicts (per-lead)."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|
129
|
+
class ECGStyle(NamedTuple):
|
|
130
|
+
"""
|
|
131
|
+
Style configuration for ECGDrawing.
|
|
132
|
+
|
|
133
|
+
Attributes
|
|
134
|
+
----------
|
|
135
|
+
figsize : `tuple` [`float`, `float`], default (20.0, 10.0)
|
|
136
|
+
Figure width and height in inches.
|
|
137
|
+
dpi : `int`, default 600
|
|
138
|
+
Figure resolution in dots per inch.
|
|
139
|
+
background_colour : `str`, default '#ffd6d6'
|
|
140
|
+
Axes background fill colour (ECG paper pink).
|
|
141
|
+
major_grid_colour : `str`, default '#ff6666'
|
|
142
|
+
Colour of the major grid lines.
|
|
143
|
+
minor_grid_colour : `str`, default '#ffaaaa'
|
|
144
|
+
Colour of the minor grid lines.
|
|
145
|
+
major_grid_linewidth : `float`, default 0.3
|
|
146
|
+
Line width of the major grid lines in points.
|
|
147
|
+
minor_grid_linewidth : `float`, default 0.2
|
|
148
|
+
Line width of the minor grid lines in points.
|
|
149
|
+
trace_colour : `str`, default 'black'
|
|
150
|
+
ECG trace line colour.
|
|
151
|
+
trace_linewidth : `float`, default 0.4
|
|
152
|
+
ECG trace line width in points.
|
|
153
|
+
paper_speed : `float`, default 25.0
|
|
154
|
+
Paper speed in mm/s (standard clinical: 25 mm/s).
|
|
155
|
+
mm_per_mv : `float`, default 10.0
|
|
156
|
+
Voltage scaling in mm/mV (standard clinical: 10 mm/mV).
|
|
157
|
+
clip_on_trace : `bool`, default False
|
|
158
|
+
Whether each ECG trace line is clipped to its axes boundary.
|
|
159
|
+
False replicates the physical ECG paper look.
|
|
160
|
+
hspace : `float`, default 0.0
|
|
161
|
+
Vertical spacing between GridSpec rows. Zero mimics continuous ECG
|
|
162
|
+
paper; increase to visually separate panels.
|
|
163
|
+
wspace : `float`, default 0.0
|
|
164
|
+
Horizontal spacing between GridSpec columns. Zero mimics continuous
|
|
165
|
+
ECG paper; increase to visually separate panels.
|
|
166
|
+
x_lim : `tuple` [`float`, `float`] | `None`, default None
|
|
167
|
+
X-axis limits in seconds. None auto-computes from n_samples / fs.
|
|
168
|
+
y_lim : `tuple` [`float`, `float`], default (-2.0, 2.0)
|
|
169
|
+
Y-axis limits in applied to every lead panel. The default would be fine
|
|
170
|
+
for mV, for μV simply multiply by 1,000.
|
|
171
|
+
label_coordinates : `tuple` [`float`, `float`], default (0.02, 0.95)
|
|
172
|
+
Lead label position as ``(x, y)`` in axes coordinates (0–1).
|
|
173
|
+
|
|
174
|
+
Notes
|
|
175
|
+
-----
|
|
176
|
+
Paper speed and mm_per_mv drive the minor/major grid step sizes using the
|
|
177
|
+
standard clinical ECG paper mapping:
|
|
178
|
+
|
|
179
|
+
* x minor = 1 / paper_speed (s) e.g. 0.04 s at 25 mm/s
|
|
180
|
+
* x major = 5 / paper_speed (s) e.g. 0.20 s at 25 mm/s
|
|
181
|
+
* y minor = 1 / mm_per_mv (mV) e.g. 0.1 mV at 10 mm/mV
|
|
182
|
+
* y major = 5 / mm_per_mv (mV) e.g. 0.5 mV at 10 mm/mV
|
|
183
|
+
|
|
184
|
+
Settings not covered here can be applied after rendering via
|
|
185
|
+
``drawing.figure`` / ``drawing.axes`` (standard matplotlib), or passed
|
|
186
|
+
as ``**kwargs`` to ``ECGDrawing.__call__`` which forwards them to
|
|
187
|
+
``plt.figure()``.
|
|
188
|
+
"""
|
|
189
|
+
# #### figure output
|
|
190
|
+
figsize: tuple[float, float] = (20.0, 10.0)
|
|
191
|
+
dpi: int = 600
|
|
192
|
+
# #### colours
|
|
193
|
+
background_colour: str = '#ffd6d6'
|
|
194
|
+
major_grid_colour: str = '#ff6666'
|
|
195
|
+
minor_grid_colour: str = '#ffaaaa'
|
|
196
|
+
# #### grid line widths
|
|
197
|
+
major_grid_linewidth: float = 0.3
|
|
198
|
+
minor_grid_linewidth: float = 0.2
|
|
199
|
+
# #### trace
|
|
200
|
+
trace_colour: str = 'black'
|
|
201
|
+
trace_linewidth: float = 0.4
|
|
202
|
+
# #### calibration
|
|
203
|
+
# x-axis grid spacing is derived from paper_speed (mm/s)
|
|
204
|
+
paper_speed: float = 25.0
|
|
205
|
+
# y-axis grid spacing is derived from mm_per_mv (mm/mV)
|
|
206
|
+
mm_per_mv: float = 10.0
|
|
207
|
+
# #### layout
|
|
208
|
+
clip_on_trace: bool = False
|
|
209
|
+
# Zero spacing mimics continuous ECG paper; increase to separate panels
|
|
210
|
+
hspace: float = 0.0
|
|
211
|
+
wspace: float = 0.0
|
|
212
|
+
# #### axis limits
|
|
213
|
+
# None triggers auto-computation from n_samples / fs at render time
|
|
214
|
+
x_lim: tuple[float, float] | None = None
|
|
215
|
+
y_lim: tuple[float, float] = (-2.0, 2.0)
|
|
216
|
+
# #### lead label
|
|
217
|
+
label_coordinates: tuple[float, float] = (0.02, 0.95)
|
|
218
|
+
|
|
219
|
+
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
|
|
220
|
+
class ECGDrawing:
|
|
221
|
+
"""
|
|
222
|
+
Takes a called reader instance and plots lead-specific ECG signals.
|
|
223
|
+
|
|
224
|
+
After calling, the instance exposes the rendered figure and per-lead axes
|
|
225
|
+
so the caller can apply further matplotlib customisation directly.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
leads : `list` [`str`] | `None`, default None
|
|
230
|
+
Lead names to plot by default. Uses the standard 12-lead list when
|
|
231
|
+
None.
|
|
232
|
+
|
|
233
|
+
Attributes
|
|
234
|
+
----------
|
|
235
|
+
figure : plt.Figure
|
|
236
|
+
The matplotlib figure.
|
|
237
|
+
axes : dict[str, plt.Axes]
|
|
238
|
+
Dict mapping lead name to its Axes.
|
|
239
|
+
layout_mapper : dict[str, tuple[int, slice]]
|
|
240
|
+
The resolved mapper dict used for this render.
|
|
241
|
+
style : ECGStyle
|
|
242
|
+
The ECGStyle instance used.
|
|
243
|
+
minor_step_x : float
|
|
244
|
+
Minor x-axis step in seconds (1 / paper_speed).
|
|
245
|
+
major_step_x : float
|
|
246
|
+
Major x-axis step in seconds (5 / paper_speed).
|
|
247
|
+
minor_step_y : float
|
|
248
|
+
Minor y-axis step in mV (1 / mm_per_mv).
|
|
249
|
+
major_step_y : float
|
|
250
|
+
Major y-axis step in mV (5 / mm_per_mv).
|
|
251
|
+
paper_speed : float
|
|
252
|
+
Resolved paper speed in mm/s.
|
|
253
|
+
mm_per_mv : float
|
|
254
|
+
Resolved voltage scaling in mm/mV.
|
|
255
|
+
default_leads : list[str]
|
|
256
|
+
Lead names plotted by default. Set at init; overridable per call.
|
|
257
|
+
|
|
258
|
+
Methods
|
|
259
|
+
-------
|
|
260
|
+
tile_x_axis(xlims)
|
|
261
|
+
Tiles each column's x-axis to an equal slice of the full signal,
|
|
262
|
+
mimicking continuous ECG paper. Per-lead overrides via ``xlims``.
|
|
263
|
+
to_numpy(crop, close)
|
|
264
|
+
Renders the figure to a 3-D numpy array (RGBA).
|
|
265
|
+
|
|
266
|
+
"""
|
|
267
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
268
|
+
# class attributes
|
|
269
|
+
SOURCE_MAP: dict[str, str] = {
|
|
270
|
+
'waveforms': CoreData.DataTypes.WaveForms,
|
|
271
|
+
'median': CoreData.DataTypes.MedianBeats,
|
|
272
|
+
}
|
|
273
|
+
_VALID_SHOW_AXES: frozenset[str | None] = frozenset({'x', 'y', 'b', None})
|
|
274
|
+
_VALID_CAL_PULSE_STYLES: frozenset[str] = frozenset({'square', 'line'})
|
|
275
|
+
# /////////////////////////////////////////////////////////////////////////
|
|
276
|
+
# __slots__
|
|
277
|
+
# reducing memory overhead and making the public attribute surface explicit.
|
|
278
|
+
__slots__ = (
|
|
279
|
+
PDNames.DEFAULT_LEADS,
|
|
280
|
+
PDNames.FIGURE, PDNames.AXES, PDNames.LAYOUT_MAPPER, PDNames.STYLE,
|
|
281
|
+
PDNames.MINOR_STEP_X, PDNames.MAJOR_STEP_X,
|
|
282
|
+
PDNames.MINOR_STEP_Y, PDNames.MAJOR_STEP_Y,
|
|
283
|
+
PDNames.PAPER_SPEED, PDNames.MM_PER_MV,
|
|
284
|
+
)
|
|
285
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
286
|
+
def __init__(
|
|
287
|
+
self,
|
|
288
|
+
leads: list[str] | None = None,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""
|
|
291
|
+
Initialises a new instance of ECGDrawing.
|
|
292
|
+
"""
|
|
293
|
+
lead_default = [
|
|
294
|
+
v for v in vars(CoreData.Leads).values()
|
|
295
|
+
if isinstance(v, str) and not v.startswith("_")
|
|
296
|
+
][2:]
|
|
297
|
+
# set to leads or the CoreData.Leads
|
|
298
|
+
setattr(self, PDNames.DEFAULT_LEADS, leads or lead_default)
|
|
299
|
+
setattr(self, PDNames.FIGURE, None)
|
|
300
|
+
setattr(self, PDNames.AXES, None)
|
|
301
|
+
setattr(self, PDNames.LAYOUT_MAPPER, None)
|
|
302
|
+
setattr(self, PDNames.STYLE, None)
|
|
303
|
+
setattr(self, PDNames.MINOR_STEP_X, None)
|
|
304
|
+
setattr(self, PDNames.MAJOR_STEP_X, None)
|
|
305
|
+
setattr(self, PDNames.MINOR_STEP_Y, None)
|
|
306
|
+
setattr(self, PDNames.MAJOR_STEP_Y, None)
|
|
307
|
+
setattr(self, PDNames.PAPER_SPEED, None)
|
|
308
|
+
setattr(self, PDNames.MM_PER_MV, None)
|
|
309
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
310
|
+
def __str__(self) -> str:
|
|
311
|
+
"""Return human-readable string representation."""
|
|
312
|
+
CLASS_NAME = type(self).__name__
|
|
313
|
+
leads = getattr(self, PDNames.DEFAULT_LEADS)
|
|
314
|
+
return f"{CLASS_NAME} instance with leads={leads}."
|
|
315
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
316
|
+
def __repr__(self) -> str:
|
|
317
|
+
"""Return unambiguous string representation."""
|
|
318
|
+
CLASS_NAME = type(self).__name__
|
|
319
|
+
leads = getattr(self, PDNames.DEFAULT_LEADS)
|
|
320
|
+
return f"{CLASS_NAME}(leads={leads!r})"
|
|
321
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
322
|
+
def _build_figure(
|
|
323
|
+
self,
|
|
324
|
+
signals: dict[str, np.ndarray],
|
|
325
|
+
mapper: dict[str, tuple[int, slice]],
|
|
326
|
+
style: ECGStyle,
|
|
327
|
+
kwargs_fig: dict[str, Any] | None = None,
|
|
328
|
+
kwargs_gridspec: dict[str, Any] | None = None,
|
|
329
|
+
) -> tuple[plt.Figure, dict[str, plt.Axes]]:
|
|
330
|
+
"""
|
|
331
|
+
Build the GridSpec figure and per-lead axes from a mapper dict.
|
|
332
|
+
|
|
333
|
+
Parameters
|
|
334
|
+
----------
|
|
335
|
+
signals : `dict[str, np.ndarray]`
|
|
336
|
+
Signal data extracted from the reader.
|
|
337
|
+
mapper : `dict[str, tuple[int, slice]]`
|
|
338
|
+
Lead name to ``(row_index, col_slice)`` mapping. Dict insertion
|
|
339
|
+
order determines plotting order. Both n_rows and n_cols are
|
|
340
|
+
derived from the mapper — no assumptions about grid size are made.
|
|
341
|
+
style : `ECGStyle`
|
|
342
|
+
Style configuration.
|
|
343
|
+
kwargs_fig : `dict[str, Any]` | `None`, optional
|
|
344
|
+
Extra keyword arguments for ``plt.figure()``. None is treated
|
|
345
|
+
as an empty dict.
|
|
346
|
+
kwargs_gridspec : `dict[str, Any]` | `None`, optional
|
|
347
|
+
Extra keyword arguments for ``GridSpec()``. None is treated
|
|
348
|
+
as an empty dict.
|
|
349
|
+
|
|
350
|
+
Returns
|
|
351
|
+
-------
|
|
352
|
+
figure : `plt.Figure`
|
|
353
|
+
The created figure.
|
|
354
|
+
axes : `dict` [`str`, `plt.Axes`]
|
|
355
|
+
Mapping of lead name to axes.
|
|
356
|
+
"""
|
|
357
|
+
# #### derive grid dimensions from mapper
|
|
358
|
+
n_rows = max(row for row, _ in mapper.values()) + 1
|
|
359
|
+
n_cols = max(col.stop for _, col in mapper.values())
|
|
360
|
+
# #### style values are defaults
|
|
361
|
+
kwargs_fig = kwargs_fig or {}
|
|
362
|
+
kwargs_gridspec = kwargs_gridspec or {}
|
|
363
|
+
# update the kwargs to allow overwriting
|
|
364
|
+
fig_kwargs = _update_kwargs(
|
|
365
|
+
update_dict=kwargs_fig,
|
|
366
|
+
figsize=style.figsize, dpi=style.dpi,
|
|
367
|
+
)
|
|
368
|
+
gs_kwargs = _update_kwargs(
|
|
369
|
+
update_dict=kwargs_gridspec,
|
|
370
|
+
hspace=style.hspace, wspace=style.wspace,
|
|
371
|
+
)
|
|
372
|
+
fig = plt.figure(**fig_kwargs)
|
|
373
|
+
gs = GridSpec(n_rows, n_cols, figure=fig, **gs_kwargs)
|
|
374
|
+
# #### create one axes per mapper entry in insertion order
|
|
375
|
+
# using logger to print warnings
|
|
376
|
+
axes: dict[str, plt.Axes] = {}
|
|
377
|
+
for lead, (row, col) in mapper.items():
|
|
378
|
+
if lead not in signals:
|
|
379
|
+
_log.warning(
|
|
380
|
+
"Lead %r not found in signal data — axes left blank.", lead
|
|
381
|
+
)
|
|
382
|
+
ax = fig.add_subplot(gs[row, col])
|
|
383
|
+
axes[lead] = ax
|
|
384
|
+
# #### warn about signal data keys the mapper did not request
|
|
385
|
+
for lead in signals:
|
|
386
|
+
if lead not in mapper:
|
|
387
|
+
_log.warning(
|
|
388
|
+
"Lead %r is in signal data but not requested in "
|
|
389
|
+
"mapper.", lead,
|
|
390
|
+
)
|
|
391
|
+
# return
|
|
392
|
+
return fig, axes
|
|
393
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
394
|
+
def _render_leads(
|
|
395
|
+
self,
|
|
396
|
+
axes: dict[str, plt.Axes],
|
|
397
|
+
signals: dict[str, np.ndarray],
|
|
398
|
+
style: ECGStyle,
|
|
399
|
+
sampling_rate: float,
|
|
400
|
+
show_axes: dict[str, str | None] | str | None,
|
|
401
|
+
show_lead_labels: bool,
|
|
402
|
+
cal_pulse: str | None,
|
|
403
|
+
cal_leads: list[str] | None,
|
|
404
|
+
kwargs_plot: dict | dict[str, dict] | None,
|
|
405
|
+
kwargs_grid: dict | dict[str, dict] | None,
|
|
406
|
+
kwargs_ticks: dict | dict[str, dict] | None,
|
|
407
|
+
kwargs_background: dict | dict[str, dict] | None,
|
|
408
|
+
kwargs_label: dict | dict[str, dict] | None,
|
|
409
|
+
) -> None:
|
|
410
|
+
"""
|
|
411
|
+
Render each lead: trace, limits, background, grid, and tick visibility.
|
|
412
|
+
|
|
413
|
+
Parameters
|
|
414
|
+
----------
|
|
415
|
+
axes : `dict` [`str`, `plt.Axes`]
|
|
416
|
+
Per-lead axes created by ``_build_figure``.
|
|
417
|
+
signals : `dict[str, np.ndarray]`
|
|
418
|
+
Signal data extracted from the reader.
|
|
419
|
+
style : `ECGStyle`
|
|
420
|
+
Style configuration.
|
|
421
|
+
sampling_rate : `float`
|
|
422
|
+
Sampling frequency in Hz used to build the time axis.
|
|
423
|
+
show_axes : `dict` [`str`, `str` | `None`] | `str` | `None`
|
|
424
|
+
Which axis ticks and labels to show per lead. A string value
|
|
425
|
+
applies uniformly: ``'x'`` x-axis only, ``'y'`` y-axis only,
|
|
426
|
+
``'b'`` both, ``None`` hides all. A dict maps individual lead
|
|
427
|
+
names to these same string values, leaving unlisted leads hidden.
|
|
428
|
+
show_lead_labels : `bool`
|
|
429
|
+
When True, annotate each panel with its lead name.
|
|
430
|
+
cal_pulse : {'square', 'line'} | `None`
|
|
431
|
+
Calibration pulse style to prepend to each lead, or ``None`` to
|
|
432
|
+
disable. The pulse height follows the clinical 10 mm paper
|
|
433
|
+
convention and is computed as ``10.0 / style.mm_per_mv`` in
|
|
434
|
+
the signal's own units. With the default ``mm_per_mv=10`` this
|
|
435
|
+
is the standard 1 mV pulse for mV signals; for μV signals
|
|
436
|
+
using e.g. ``mm_per_mv=10/1000`` it is 1000 (signal units),
|
|
437
|
+
i.e. a 1 mV-equivalent 10 mm pulse. ``'square'`` draws the
|
|
438
|
+
full rectangular pulse. ``'line'`` draws only a single
|
|
439
|
+
vertical spike, matching the convention used by some
|
|
440
|
+
thermal-paper ECG machines.
|
|
441
|
+
kwargs_plot : `dict` | `dict` [`str`, `dict`] | `None`
|
|
442
|
+
Extra kwargs for ``ax.plot()``.
|
|
443
|
+
kwargs_grid : `dict` | `dict` [`str`, `dict`] | `None`
|
|
444
|
+
Extra kwargs for ``ax.grid()``.
|
|
445
|
+
kwargs_ticks : `dict` | `dict` [`str`, `dict`] | `None`
|
|
446
|
+
Extra kwargs for ``ax.tick_params()``.
|
|
447
|
+
kwargs_background : `dict` | `dict` [`str`, `dict`] | `None`
|
|
448
|
+
Extra kwargs for ``ax.patch.set()``.
|
|
449
|
+
kwargs_label : `dict` | `dict` [`str`, `dict`] | `None`
|
|
450
|
+
Extra kwargs for ``ax.text()`` lead annotations.
|
|
451
|
+
"""
|
|
452
|
+
minor_x = getattr(self, PDNames.MINOR_STEP_X)
|
|
453
|
+
major_x = getattr(self, PDNames.MAJOR_STEP_X)
|
|
454
|
+
minor_y = getattr(self, PDNames.MINOR_STEP_Y)
|
|
455
|
+
major_y = getattr(self, PDNames.MAJOR_STEP_Y)
|
|
456
|
+
# #### get cal_leads
|
|
457
|
+
if cal_leads is None:
|
|
458
|
+
# simply print cal for each lead
|
|
459
|
+
cal_leads = list(signals.keys())
|
|
460
|
+
for lead, ax in axes.items():
|
|
461
|
+
if lead not in signals:
|
|
462
|
+
# blank axes already created and warned in _build_figure
|
|
463
|
+
continue
|
|
464
|
+
signal = signals[lead]
|
|
465
|
+
# #### prepend calibration pulse
|
|
466
|
+
if (cal_pulse is not None) and (lead in cal_leads):
|
|
467
|
+
n_pre = round(0.1 * sampling_rate)
|
|
468
|
+
n_post = round(0.1 * sampling_rate)
|
|
469
|
+
# amplitude follows the clinical 10 mm paper convention:
|
|
470
|
+
# mm_per_mv is used as "mm per signal unit" (the y-axis
|
|
471
|
+
# gain), so 10 / mm_per_mv is the pulse height in signal
|
|
472
|
+
# units. With the default mm_per_mv=10 this is 1.0 (the
|
|
473
|
+
# standard 1 mV pulse for mV signals); for μV signals
|
|
474
|
+
# using mm_per_mv=10/1000 it is 1000 (a 1 mV-equivalent
|
|
475
|
+
# 10 mm pulse).
|
|
476
|
+
amp = 10.0 / style.mm_per_mv
|
|
477
|
+
if cal_pulse == 'square':
|
|
478
|
+
body = np.full(round(0.2 * sampling_rate), amp)
|
|
479
|
+
else:
|
|
480
|
+
body = np.array([0.0, amp, 0.0])
|
|
481
|
+
# adds a 0.1s zero line to the front and back
|
|
482
|
+
# the body is either a line or 0.2s square
|
|
483
|
+
pulse = np.concatenate(
|
|
484
|
+
[np.zeros(n_pre), body, np.zeros(n_post)])
|
|
485
|
+
# adds the pulse to the signal (which is now longer)
|
|
486
|
+
signal = np.concatenate([pulse, signal])
|
|
487
|
+
t = np.arange(len(signal)) / sampling_rate
|
|
488
|
+
# #### resolve per-lead kwargs
|
|
489
|
+
p_kw = _resolve_lead_kwargs(kwargs_plot, lead)
|
|
490
|
+
g_kw = _resolve_lead_kwargs(kwargs_grid, lead)
|
|
491
|
+
t_kw = _resolve_lead_kwargs(kwargs_ticks, lead)
|
|
492
|
+
bg_kw = _resolve_lead_kwargs(kwargs_background, lead)
|
|
493
|
+
# #### trace
|
|
494
|
+
plot_kw = _update_kwargs(
|
|
495
|
+
p_kw,
|
|
496
|
+
color=style.trace_colour,
|
|
497
|
+
lw=style.trace_linewidth,
|
|
498
|
+
clip_on=style.clip_on_trace,
|
|
499
|
+
zorder=3,
|
|
500
|
+
)
|
|
501
|
+
ax.plot(
|
|
502
|
+
t, signal,
|
|
503
|
+
**plot_kw,
|
|
504
|
+
)
|
|
505
|
+
# #### axis limits
|
|
506
|
+
if style.x_lim:
|
|
507
|
+
ax.set_xlim(style.x_lim)
|
|
508
|
+
else:
|
|
509
|
+
ax.set_xlim(0.0, len(signal) / sampling_rate)
|
|
510
|
+
ax.set_ylim(style.y_lim)
|
|
511
|
+
# #### background
|
|
512
|
+
ax.patch.set_facecolor(style.background_colour)
|
|
513
|
+
ax.patch.set_visible(True)
|
|
514
|
+
if bg_kw:
|
|
515
|
+
ax.patch.set(**bg_kw)
|
|
516
|
+
# #### grid lines — minor first (behind), major second (in front)
|
|
517
|
+
# ax.grid() does not reliably honour zorder across x/y axes;
|
|
518
|
+
# vlines/hlines produce LineCollections that do.
|
|
519
|
+
ax.xaxis.set_major_locator(MultipleLocator(major_x))
|
|
520
|
+
ax.yaxis.set_major_locator(MultipleLocator(major_y))
|
|
521
|
+
x_lo, x_hi = ax.get_xlim()
|
|
522
|
+
y_lo, y_hi = ax.get_ylim()
|
|
523
|
+
minor_xs = _minor_only_ticks(x_lo, x_hi, minor_x, major_x)
|
|
524
|
+
minor_ys = _minor_only_ticks(y_lo, y_hi, minor_y, major_y)
|
|
525
|
+
major_xs = np.arange(
|
|
526
|
+
np.ceil(x_lo / major_x) * major_x,
|
|
527
|
+
x_hi + major_x * 1e-6,
|
|
528
|
+
major_x,
|
|
529
|
+
)
|
|
530
|
+
major_ys = np.arange(
|
|
531
|
+
np.ceil(y_lo / major_y) * major_y,
|
|
532
|
+
y_hi + major_y * 1e-6,
|
|
533
|
+
major_y,
|
|
534
|
+
)
|
|
535
|
+
minor_kw = _update_kwargs(
|
|
536
|
+
g_kw,
|
|
537
|
+
color=style.minor_grid_colour,
|
|
538
|
+
linewidth=style.minor_grid_linewidth,
|
|
539
|
+
zorder=1,
|
|
540
|
+
)
|
|
541
|
+
major_kw = _update_kwargs(
|
|
542
|
+
g_kw,
|
|
543
|
+
color=style.major_grid_colour,
|
|
544
|
+
linewidth=style.major_grid_linewidth,
|
|
545
|
+
zorder=2,
|
|
546
|
+
)
|
|
547
|
+
ax.vlines(minor_xs, y_lo, y_hi, **minor_kw)
|
|
548
|
+
ax.hlines(minor_ys, x_lo, x_hi, **minor_kw)
|
|
549
|
+
ax.vlines(major_xs, y_lo, y_hi, **major_kw)
|
|
550
|
+
ax.hlines(major_ys, x_lo, x_hi, **major_kw)
|
|
551
|
+
# #### tick visibility
|
|
552
|
+
lead_show = (
|
|
553
|
+
show_axes.get(lead) if isinstance(show_axes, dict)
|
|
554
|
+
else show_axes
|
|
555
|
+
)
|
|
556
|
+
t_params_kwarg = _update_kwargs(
|
|
557
|
+
t_kw,
|
|
558
|
+
axis='both', which='both',
|
|
559
|
+
bottom=False, left=False,
|
|
560
|
+
labelbottom=False, labelleft=False,
|
|
561
|
+
)
|
|
562
|
+
ax.tick_params(
|
|
563
|
+
**t_params_kwarg,
|
|
564
|
+
)
|
|
565
|
+
if lead_show in ('x', 'b'):
|
|
566
|
+
ax.tick_params(axis='x', bottom=True, labelbottom=True)
|
|
567
|
+
if lead_show in ('y', 'b'):
|
|
568
|
+
ax.tick_params(axis='y', left=True, labelleft=True)
|
|
569
|
+
# #### lead label
|
|
570
|
+
if show_lead_labels:
|
|
571
|
+
lbl_kw = _update_kwargs(
|
|
572
|
+
_resolve_lead_kwargs(kwargs_label, lead),
|
|
573
|
+
fontsize=7, fontweight='bold',
|
|
574
|
+
va='top', ha='left',
|
|
575
|
+
color=style.trace_colour,
|
|
576
|
+
clip_on=False,
|
|
577
|
+
)
|
|
578
|
+
ax.text(
|
|
579
|
+
*style.label_coordinates,
|
|
580
|
+
lead,
|
|
581
|
+
transform=ax.transAxes,
|
|
582
|
+
**lbl_kw,
|
|
583
|
+
)
|
|
584
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
585
|
+
def __call__(
|
|
586
|
+
self,
|
|
587
|
+
reader: Any,
|
|
588
|
+
mapper: dict,
|
|
589
|
+
source: str = "waveforms",
|
|
590
|
+
style: ECGStyle | None = None,
|
|
591
|
+
show_axes: dict[str, str | None] | str | None = None,
|
|
592
|
+
sampling_rate: float | None = None,
|
|
593
|
+
show_lead_labels: bool = False,
|
|
594
|
+
cal_pulse: Literal["square", "line"] | None = None,
|
|
595
|
+
cal_leads: list[str] | None = None,
|
|
596
|
+
kwargs_fig: dict | None = None,
|
|
597
|
+
kwargs_gridspec: dict | None = None,
|
|
598
|
+
kwargs_plot: dict | dict[str, dict] | None = None,
|
|
599
|
+
kwargs_grid: dict | dict[str, dict] | None = None,
|
|
600
|
+
kwargs_ticks: dict | dict[str, dict] | None = None,
|
|
601
|
+
kwargs_background: dict | dict[str, dict] | None = None,
|
|
602
|
+
kwargs_label: dict | dict[str, dict] | None = None,
|
|
603
|
+
) -> Self:
|
|
604
|
+
"""
|
|
605
|
+
Creates an ECG figure from a called reader instance.
|
|
606
|
+
|
|
607
|
+
Parameters
|
|
608
|
+
----------
|
|
609
|
+
reader : Any
|
|
610
|
+
A called ECGXMLReader or ECGDICOMReader instance.
|
|
611
|
+
mapper : `dict` [`str`, `tuple` [`int`, `slice`]]
|
|
612
|
+
Layout dict mapping lead names to ``(row_index, col_slice)``.
|
|
613
|
+
Dict insertion order determines plotting order; grid dimensions are
|
|
614
|
+
derived from the mapper values. Mismatch warnings (missing or
|
|
615
|
+
unrequested leads) are emitted at WARNING level via the
|
|
616
|
+
``ecgprocess.plot_ecgs`` logger.
|
|
617
|
+
source : {'waveforms', 'median'}, default 'waveforms'
|
|
618
|
+
Whether to plot WaveForms or MedianBeats signals. Can be modified
|
|
619
|
+
to use a non-standard source name by updating self.SOURCE_MAP.
|
|
620
|
+
style : `ECGStyle` | `None`, optional
|
|
621
|
+
Style configuration. Uses defaults when None.
|
|
622
|
+
show_axes : `dict` [`str`, `str` | `None`] | `str` | `None`, optional
|
|
623
|
+
Which axis ticks and labels to show per lead. A string value
|
|
624
|
+
applies uniformly: ``'x'`` x-axis only, ``'y'`` y-axis only,
|
|
625
|
+
``'b'`` both, ``None`` hides all. A dict maps individual lead
|
|
626
|
+
names to these same string values, leaving unlisted leads hidden.
|
|
627
|
+
sampling_rate : `float` | `None`, optional
|
|
628
|
+
Sampling frequency in Hz. When None, read from
|
|
629
|
+
``reader.MetaData[CoreData.MetaData.SF]``. Raises
|
|
630
|
+
`InputValidationError` if neither source is available.
|
|
631
|
+
show_lead_labels : `bool`, default False
|
|
632
|
+
When True, each panel is annotated with its lead name in the
|
|
633
|
+
top-left corner using the default label style.
|
|
634
|
+
cal_pulse : {'square', 'line'} | `None`, default `None`
|
|
635
|
+
Calibration pulse style to prepend to each lead signal before
|
|
636
|
+
rendering. ``None`` disables the pulse. The pulse height
|
|
637
|
+
follows the clinical 10 mm paper convention and is computed
|
|
638
|
+
as ``10.0 / style.mm_per_mv`` in the signal's own units, so
|
|
639
|
+
it always spans 10 mm of plotted paper regardless of unit
|
|
640
|
+
choice (mV, μV, …). With the default ``mm_per_mv=10`` this
|
|
641
|
+
gives the standard 1 mV pulse for mV signals; for μV signals
|
|
642
|
+
using e.g. ``mm_per_mv=10/1000`` it gives 1000 signal units
|
|
643
|
+
(a 1 mV-equivalent 10 mm pulse). ``'square'`` draws the full
|
|
644
|
+
rectangular pulse: 0.1 s baseline, then 0.2 s at the computed
|
|
645
|
+
amplitude, then 0.1 s baseline. ``'line'`` draws only a
|
|
646
|
+
single vertical spike: 0.1 s baseline, then a 3-sample
|
|
647
|
+
rise/peak/fall, then 0.1 s baseline, matching the convention
|
|
648
|
+
used by some thermal-paper ECG machines. The caller's data is
|
|
649
|
+
not mutated.
|
|
650
|
+
cal_leads : `list` [`str`] | `None`, default `None`
|
|
651
|
+
Restricts which leads receive the calibration pulse. When
|
|
652
|
+
`None`, every lead in the signal data receives the pulse
|
|
653
|
+
(provided ``cal_pulse`` is not `None`). Lead names not
|
|
654
|
+
present in the signal data are ignored. Has no effect when
|
|
655
|
+
``cal_pulse`` is `None`.
|
|
656
|
+
kwargs_fig : `dict` | `None`, optional
|
|
657
|
+
Extra keyword arguments forwarded to ``plt.figure()``. Style
|
|
658
|
+
fields (``figsize``, ``dpi``) are used as defaults and are
|
|
659
|
+
overridden by any matching keys here.
|
|
660
|
+
kwargs_gridspec : `dict` | `None`, optional
|
|
661
|
+
Extra keyword arguments forwarded to ``GridSpec()``. Style
|
|
662
|
+
fields (``hspace``, ``wspace``) are used as defaults and are
|
|
663
|
+
overridden by any matching keys here.
|
|
664
|
+
kwargs_plot : `dict` | `dict` [`str`, `dict`] | `None`, optional
|
|
665
|
+
Extra keyword arguments forwarded to ``ax.plot()``. Pass a flat
|
|
666
|
+
dict to apply uniformly, or a dict of dicts for per-lead control.
|
|
667
|
+
kwargs_grid : `dict` | `dict` [`str`, `dict`] | `None`, optional
|
|
668
|
+
Extra keyword arguments forwarded to ``ax.grid()``. Same
|
|
669
|
+
uniform/per-lead convention as ``kwargs_plot``.
|
|
670
|
+
kwargs_ticks : `dict` | `dict` [`str`, `dict`] | `None`, optional
|
|
671
|
+
Extra keyword arguments forwarded to ``ax.tick_params()``. Same
|
|
672
|
+
uniform/per-lead convention as ``kwargs_plot``.
|
|
673
|
+
kwargs_background : `dict` | `dict` [`str`, `dict`] | `None`, optional
|
|
674
|
+
Extra keyword arguments forwarded to ``ax.patch.set()``. Same
|
|
675
|
+
uniform/per-lead convention as ``kwargs_plot``.
|
|
676
|
+
kwargs_label : `dict` | `dict` [`str`, `dict`] | `None`, optional
|
|
677
|
+
Extra keyword arguments forwarded to ``ax.text()`` for lead
|
|
678
|
+
labels. Only used when ``show_lead_labels=True``. Same
|
|
679
|
+
uniform/per-lead convention as ``kwargs_plot``. Caller-supplied
|
|
680
|
+
values override the defaults (fontsize=7, fontweight='bold',
|
|
681
|
+
va='top', ha='left').
|
|
682
|
+
|
|
683
|
+
Returns
|
|
684
|
+
-------
|
|
685
|
+
self : ECGDrawing
|
|
686
|
+
Returns the instance with populated figure, axes, and
|
|
687
|
+
calibration attributes.
|
|
688
|
+
"""
|
|
689
|
+
# #### type-check every parameter at the public boundary
|
|
690
|
+
is_type(mapper, dict)
|
|
691
|
+
is_type(source, str)
|
|
692
|
+
is_type(style, (ECGStyle, type(None)))
|
|
693
|
+
is_type(show_axes, (str, dict, type(None)))
|
|
694
|
+
is_type(sampling_rate, (float, int, type(None)))
|
|
695
|
+
is_type(show_lead_labels, bool)
|
|
696
|
+
is_type(cal_pulse, (str, type(None)))
|
|
697
|
+
is_type(cal_leads, (list, type(None)))
|
|
698
|
+
is_type(kwargs_fig, (dict, type(None)))
|
|
699
|
+
is_type(kwargs_gridspec, (dict, type(None)))
|
|
700
|
+
is_type(kwargs_plot, (dict, type(None)))
|
|
701
|
+
is_type(kwargs_grid, (dict, type(None)))
|
|
702
|
+
is_type(kwargs_ticks, (dict, type(None)))
|
|
703
|
+
is_type(kwargs_background, (dict, type(None)))
|
|
704
|
+
is_type(kwargs_label, (dict, type(None)))
|
|
705
|
+
# #### resolve style
|
|
706
|
+
style = style or ECGStyle()
|
|
707
|
+
# #### validate source and extract signals
|
|
708
|
+
if source not in self.SOURCE_MAP:
|
|
709
|
+
raise InputValidationError(
|
|
710
|
+
f"source must be 'waveforms' or 'median', got {source!r}."
|
|
711
|
+
)
|
|
712
|
+
signals = getattr(reader, self.SOURCE_MAP[source], None)
|
|
713
|
+
if signals is None or len(signals) == 0:
|
|
714
|
+
raise InputValidationError(
|
|
715
|
+
f"Reader has no data for source={source!r}."
|
|
716
|
+
)
|
|
717
|
+
# #### check for NaN in every lead
|
|
718
|
+
for lead, arr in signals.items():
|
|
719
|
+
if np.any(np.isnan(arr)):
|
|
720
|
+
raise InputValidationError(
|
|
721
|
+
f"Signal for lead {lead!r} contains NaN values."
|
|
722
|
+
)
|
|
723
|
+
# #### store style and calibration attributes
|
|
724
|
+
setattr(self, PDNames.STYLE, style)
|
|
725
|
+
setattr(self, PDNames.PAPER_SPEED, style.paper_speed)
|
|
726
|
+
setattr(self, PDNames.MM_PER_MV, style.mm_per_mv)
|
|
727
|
+
# grid steps: mm / (mm/s) = s and mm / (mm/mV) = mV
|
|
728
|
+
setattr(self, PDNames.MINOR_STEP_X,
|
|
729
|
+
PDNames.MINOR_GRID_MM / style.paper_speed)
|
|
730
|
+
setattr(self, PDNames.MAJOR_STEP_X,
|
|
731
|
+
PDNames.MAJOR_GRID_MM / style.paper_speed)
|
|
732
|
+
setattr(self, PDNames.MINOR_STEP_Y,
|
|
733
|
+
PDNames.MINOR_GRID_MM / style.mm_per_mv)
|
|
734
|
+
setattr(self, PDNames.MAJOR_STEP_Y,
|
|
735
|
+
PDNames.MAJOR_GRID_MM / style.mm_per_mv)
|
|
736
|
+
# #### validate show_axes content (type already checked above)
|
|
737
|
+
if isinstance(show_axes, str):
|
|
738
|
+
if show_axes not in self._VALID_SHOW_AXES:
|
|
739
|
+
raise InputValidationError(
|
|
740
|
+
f"show_axes must be 'x', 'y', 'b', or None, "
|
|
741
|
+
f"got {show_axes!r}."
|
|
742
|
+
)
|
|
743
|
+
elif isinstance(show_axes, dict):
|
|
744
|
+
invalid = {
|
|
745
|
+
v for v in show_axes.values()
|
|
746
|
+
if v not in self._VALID_SHOW_AXES
|
|
747
|
+
}
|
|
748
|
+
if invalid:
|
|
749
|
+
raise InputValidationError(
|
|
750
|
+
f"show_axes dict contains invalid values: {invalid!r}."
|
|
751
|
+
)
|
|
752
|
+
# #### validate cal_pulse
|
|
753
|
+
if cal_pulse is not None \
|
|
754
|
+
and cal_pulse not in self._VALID_CAL_PULSE_STYLES:
|
|
755
|
+
raise InputValidationError(
|
|
756
|
+
f"cal_pulse must be 'square', 'line', or None, "
|
|
757
|
+
f"got {cal_pulse!r}."
|
|
758
|
+
)
|
|
759
|
+
# #### build figure
|
|
760
|
+
fig, axes = self._build_figure(
|
|
761
|
+
signals, mapper, style,
|
|
762
|
+
kwargs_fig=kwargs_fig,
|
|
763
|
+
kwargs_gridspec=kwargs_gridspec,
|
|
764
|
+
)
|
|
765
|
+
setattr(self, PDNames.FIGURE, fig)
|
|
766
|
+
setattr(self, PDNames.AXES, axes)
|
|
767
|
+
setattr(self, PDNames.LAYOUT_MAPPER, mapper)
|
|
768
|
+
# #### resolve sampling rate
|
|
769
|
+
# trying to pull this from the metadata
|
|
770
|
+
if sampling_rate is None:
|
|
771
|
+
meta = getattr(reader, CoreData.DataTypes.MetaData, None) or {}
|
|
772
|
+
sampling_rate = meta.get(CoreData.MetaData.SF)
|
|
773
|
+
# if still None raise an error
|
|
774
|
+
if sampling_rate is None:
|
|
775
|
+
raise InputValidationError(
|
|
776
|
+
"Sampling rate unavailable: pass sampling_rate= or ensure "
|
|
777
|
+
f"MetaData[{CoreData.MetaData.SF!r}] is set."
|
|
778
|
+
)
|
|
779
|
+
# #### render leads
|
|
780
|
+
self._render_leads(
|
|
781
|
+
axes=axes,
|
|
782
|
+
signals=signals,
|
|
783
|
+
style=style,
|
|
784
|
+
sampling_rate=float(sampling_rate),
|
|
785
|
+
show_axes=show_axes,
|
|
786
|
+
show_lead_labels=show_lead_labels,
|
|
787
|
+
cal_pulse=cal_pulse,
|
|
788
|
+
cal_leads=cal_leads,
|
|
789
|
+
kwargs_plot=kwargs_plot,
|
|
790
|
+
kwargs_grid=kwargs_grid,
|
|
791
|
+
kwargs_ticks=kwargs_ticks,
|
|
792
|
+
kwargs_background=kwargs_background,
|
|
793
|
+
kwargs_label=kwargs_label,
|
|
794
|
+
)
|
|
795
|
+
# return
|
|
796
|
+
return self
|
|
797
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
798
|
+
def tile_x_axis(
|
|
799
|
+
self,
|
|
800
|
+
xlims: dict[str, tuple[float, float]] | None = None,
|
|
801
|
+
) -> Self:
|
|
802
|
+
"""
|
|
803
|
+
Tile the x-axis so each column shows an equal slice of the total
|
|
804
|
+
signal duration, mimicking continuous ECG paper.
|
|
805
|
+
|
|
806
|
+
The column count is derived from the mapper used at render time.
|
|
807
|
+
Each column's x window runs from
|
|
808
|
+
``x_min + col * segment`` to ``x_min + (col + 1) * segment``,
|
|
809
|
+
where ``segment = (x_max - x_min) / n_cols``. Multi-column spans
|
|
810
|
+
(e.g. a full-width rhythm strip) receive the proportionally wider
|
|
811
|
+
window. Each row resets to zero independently.
|
|
812
|
+
|
|
813
|
+
Signal data outside a column's x window is removed from the line
|
|
814
|
+
artists rather than clipped. This prevents traces from bleeding
|
|
815
|
+
horizontally into adjacent panels while leaving
|
|
816
|
+
``clip_on_trace=False`` intact so tall deflections can still cross
|
|
817
|
+
row boundaries.
|
|
818
|
+
|
|
819
|
+
Parameters
|
|
820
|
+
----------
|
|
821
|
+
xlims : `dict` [`str`, `tuple` [`float`, `float`]] | `None`, \
|
|
822
|
+
optional
|
|
823
|
+
Per-lead x-axis overrides. Keys are lead names; values are
|
|
824
|
+
``(x_min, x_max)`` in seconds. Leads not listed fall back to
|
|
825
|
+
the auto-tiled column window.
|
|
826
|
+
|
|
827
|
+
Returns
|
|
828
|
+
-------
|
|
829
|
+
self : ECGDrawing
|
|
830
|
+
Returns the instance for method chaining.
|
|
831
|
+
|
|
832
|
+
Raises
|
|
833
|
+
------
|
|
834
|
+
NotCalledError
|
|
835
|
+
If ``__call__`` has not been invoked yet.
|
|
836
|
+
InputValidationError
|
|
837
|
+
If ``xlims`` is not a dict or contains non-tuple values.
|
|
838
|
+
"""
|
|
839
|
+
axes = getattr(self, PDNames.AXES)
|
|
840
|
+
mapper = getattr(self, PDNames.LAYOUT_MAPPER)
|
|
841
|
+
if axes is None or mapper is None:
|
|
842
|
+
raise NotCalledError(
|
|
843
|
+
"ECGDrawing must be called before tile_x_axis()."
|
|
844
|
+
)
|
|
845
|
+
is_type(xlims, (dict, type(None)))
|
|
846
|
+
xlims = xlims or {}
|
|
847
|
+
# #### derive total x range and column count from rendered state
|
|
848
|
+
# Use min xlim across all axes so that every tile window has complete
|
|
849
|
+
# signal coverage — leads with cal pulses have a longer xlim than
|
|
850
|
+
# leads without, and using min prevents an empty trailing gap.
|
|
851
|
+
x_min = min(ax.get_xlim()[0] for ax in axes.values())
|
|
852
|
+
x_max = min(ax.get_xlim()[1] for ax in axes.values())
|
|
853
|
+
n_cols = max(col.stop for _, col in mapper.values())
|
|
854
|
+
segment = (x_max - x_min) / n_cols
|
|
855
|
+
for lead, (_, col_slice) in mapper.items():
|
|
856
|
+
if lead in xlims:
|
|
857
|
+
col_x_min, col_x_max = xlims[lead]
|
|
858
|
+
else:
|
|
859
|
+
col_x_min = x_min + col_slice.start * segment
|
|
860
|
+
col_x_max = x_min + col_slice.stop * segment
|
|
861
|
+
ax = axes[lead]
|
|
862
|
+
for line in ax.lines:
|
|
863
|
+
xd = np.asarray(line.get_xdata())
|
|
864
|
+
yd = np.asarray(line.get_ydata())
|
|
865
|
+
mask = (xd >= col_x_min) & (xd <= col_x_max)
|
|
866
|
+
line.set_xdata(xd[mask])
|
|
867
|
+
line.set_ydata(yd[mask])
|
|
868
|
+
ax.set_xlim(col_x_min, col_x_max)
|
|
869
|
+
return self
|
|
870
|
+
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
871
|
+
def to_numpy(
|
|
872
|
+
self,
|
|
873
|
+
crop: bool = False,
|
|
874
|
+
close: bool = True,
|
|
875
|
+
) -> np.ndarray:
|
|
876
|
+
"""
|
|
877
|
+
Maps the matplotlib figure to a numpy array.
|
|
878
|
+
|
|
879
|
+
Parameters
|
|
880
|
+
----------
|
|
881
|
+
crop : `bool`, default False
|
|
882
|
+
When True, applies tight layout before conversion, removing
|
|
883
|
+
excess whitespace around the plot area.
|
|
884
|
+
close : `bool`, default True
|
|
885
|
+
Whether to close the figure after extraction.
|
|
886
|
+
|
|
887
|
+
Returns
|
|
888
|
+
-------
|
|
889
|
+
array : np.ndarray
|
|
890
|
+
3-dimensional array of shape (height, width, 4) in RGBA format.
|
|
891
|
+
|
|
892
|
+
Raises
|
|
893
|
+
------
|
|
894
|
+
NotCalledError
|
|
895
|
+
If __call__ has not been invoked yet.
|
|
896
|
+
"""
|
|
897
|
+
# input
|
|
898
|
+
is_type(crop, bool)
|
|
899
|
+
is_type(close, bool)
|
|
900
|
+
# get figure
|
|
901
|
+
fig = getattr(self, PDNames.FIGURE)
|
|
902
|
+
if fig is None:
|
|
903
|
+
raise NotCalledError(
|
|
904
|
+
"ECGDrawing must be called before to_numpy()."
|
|
905
|
+
)
|
|
906
|
+
# cropping
|
|
907
|
+
if crop:
|
|
908
|
+
fig.tight_layout(pad=0)
|
|
909
|
+
fig.canvas.draw()
|
|
910
|
+
# get array from fig
|
|
911
|
+
arr = np.frombuffer(fig.canvas.buffer_rgba(), dtype=np.uint8).copy()
|
|
912
|
+
h, w = fig.canvas.get_width_height()[::-1]
|
|
913
|
+
arr = arr.reshape(h, w, 4)
|
|
914
|
+
# close if needed and results attributes
|
|
915
|
+
if close:
|
|
916
|
+
plt.close(fig)
|
|
917
|
+
setattr(self, PDNames.FIGURE, None)
|
|
918
|
+
setattr(self, PDNames.AXES, None)
|
|
919
|
+
# return
|
|
920
|
+
return arr
|
|
921
|
+
|