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