ecgdatakit 2.0.2__tar.gz → 2.0.3__tar.gz

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 (123) hide show
  1. {ecgdatakit-2.0.2/src/ecgdatakit.egg-info → ecgdatakit-2.0.3}/PKG-INFO +1 -1
  2. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/__init__.py +1 -1
  3. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parser.py +69 -28
  4. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/xle.py +46 -8
  5. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt +1 -1
  6. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3/src/ecgdatakit.egg-info}/PKG-INFO +1 -1
  7. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_parser.py +41 -0
  8. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_xle.py +45 -0
  9. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/LICENSE +0 -0
  10. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/MANIFEST.in +0 -0
  11. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/README.md +0 -0
  12. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/pyproject.toml +0 -0
  13. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/setup.cfg +0 -0
  14. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/__init__.py +0 -0
  15. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_catalog.py +0 -0
  16. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_codes.py +0 -0
  17. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_daemon.py +0 -0
  18. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_engine.py +0 -0
  19. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_filenames.py +0 -0
  20. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_identity.py +0 -0
  21. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_shell.py +0 -0
  22. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_stats.py +0 -0
  23. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/_verify.py +0 -0
  24. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/__init__.py +0 -0
  25. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/_base.py +0 -0
  26. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/_xml.py +0 -0
  27. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/alivecor_kardia.py +0 -0
  28. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/dicom_waveform.py +0 -0
  29. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/edf.py +0 -0
  30. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/ge_muse_xml.py +0 -0
  31. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/hl7_aecg.py +0 -0
  32. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/ishne_holter.py +0 -0
  33. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/mfer.py +0 -0
  34. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/mortara_el250.py +0 -0
  35. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/scp_ecg.py +0 -0
  36. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/sierra_xml.py +0 -0
  37. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/wfdb.py +0 -0
  38. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/anonymize/formats/xle.py +0 -0
  39. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/cli.py +0 -0
  40. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/exceptions.py +0 -0
  41. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/models.py +0 -0
  42. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/__init__.py +0 -0
  43. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/batch.py +0 -0
  44. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/codecs/__init__.py +0 -0
  45. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/codecs/lzw.py +0 -0
  46. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/codecs/xli.py +0 -0
  47. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/helpers/__init__.py +0 -0
  48. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/helpers/labels.py +0 -0
  49. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/helpers/record.py +0 -0
  50. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/helpers/waveform_text.py +0 -0
  51. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/helpers/xml.py +0 -0
  52. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/__init__.py +0 -0
  53. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/alivecor_kardia.py +0 -0
  54. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/dicom_waveform.py +0 -0
  55. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/edan_arc.py +0 -0
  56. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/edf.py +0 -0
  57. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/ge_muse_xml.py +0 -0
  58. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/hl7_aecg.py +0 -0
  59. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/ishne_holter.py +0 -0
  60. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/mfer.py +0 -0
  61. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/mortara_el250.py +0 -0
  62. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/scp_ecg.py +0 -0
  63. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/sierra_xml.py +0 -0
  64. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/parsing/parsers/wfdb.py +0 -0
  65. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/plotting/__init__.py +0 -0
  66. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/plotting/_core.py +0 -0
  67. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/plotting/interactive.py +0 -0
  68. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/plotting/static.py +0 -0
  69. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/__init__.py +0 -0
  70. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/_core.py +0 -0
  71. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/clean.py +0 -0
  72. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/denoise.py +0 -0
  73. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/filters.py +0 -0
  74. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/hrv.py +0 -0
  75. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/leads.py +0 -0
  76. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/nn/__init__.py +0 -0
  77. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/nn/deepfade.py +0 -0
  78. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/nn/dense_net.py +0 -0
  79. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/nn/layers.py +0 -0
  80. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/normalize.py +0 -0
  81. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/peaks.py +0 -0
  82. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/quality.py +0 -0
  83. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/resample.py +0 -0
  84. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/processing/transforms.py +0 -0
  85. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit/py.typed +0 -0
  86. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit.egg-info/SOURCES.txt +0 -0
  87. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit.egg-info/dependency_links.txt +0 -0
  88. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit.egg-info/entry_points.txt +0 -0
  89. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit.egg-info/requires.txt +0 -0
  90. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/src/ecgdatakit.egg-info/top_level.txt +0 -0
  91. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/__init__.py +0 -0
  92. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/conftest.py +0 -0
  93. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_alivecor_kardia.py +0 -0
  94. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_anonymize.py +0 -0
  95. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_clean.py +0 -0
  96. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_codecs.py +0 -0
  97. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_denoise.py +0 -0
  98. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_dicom_waveform.py +0 -0
  99. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_dispatch.py +0 -0
  100. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_edan_arc.py +0 -0
  101. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_edf.py +0 -0
  102. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_filters.py +0 -0
  103. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_ge_muse_xml.py +0 -0
  104. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_hl7_aecg.py +0 -0
  105. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_hrv.py +0 -0
  106. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_ishne_holter.py +0 -0
  107. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_leads.py +0 -0
  108. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_mfer.py +0 -0
  109. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_models.py +0 -0
  110. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_mortara_el250.py +0 -0
  111. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_normalize.py +0 -0
  112. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_numpy_input.py +0 -0
  113. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_peaks.py +0 -0
  114. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_peaks_shannon.py +0 -0
  115. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_plotting_interactive.py +0 -0
  116. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_plotting_static.py +0 -0
  117. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_quality.py +0 -0
  118. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_resample.py +0 -0
  119. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_scp_ecg.py +0 -0
  120. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_sierra_xml.py +0 -0
  121. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_transforms.py +0 -0
  122. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_wfdb.py +0 -0
  123. {ecgdatakit-2.0.2 → ecgdatakit-2.0.3}/tests/test_xml_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecgdatakit
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Multi-format ECG file parsing and signal processing library
5
5
  Author-email: Ahmad Fall <ahmad.fall@ird.fr>
6
6
  Maintainer-email: Ahmad Fall <ahmad.fall@ird.fr>
@@ -1,6 +1,6 @@
1
1
  """ECGDataKit - Multi-format ECG file parsing and processing library."""
2
2
 
3
- __version__ = "2.0.2"
3
+ __version__ = "2.0.3"
4
4
 
5
5
  from ecgdatakit.models import (
6
6
  AcquisitionSetup,
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import dataclasses
6
+ import functools
6
7
  import importlib
7
8
  import pkgutil
8
9
  import warnings
@@ -52,31 +53,35 @@ class Parser(ABC):
52
53
  ...
53
54
 
54
55
 
55
- class FileParser:
56
- """Auto-discovers parsers and dispatches files to the right one."""
56
+ @functools.cache
57
+ def _discover() -> tuple[type[Parser], ...]:
58
+ """Every Parser subclass of ``ecgdatakit.parsing.parsers``, in
59
+ detection order."""
60
+ package = importlib.import_module("ecgdatakit.parsing.parsers")
61
+ found: list[type[Parser]] = []
62
+ for _, name, _ in pkgutil.iter_modules(package.__path__):
63
+ module = importlib.import_module(f"ecgdatakit.parsing.parsers.{name}")
64
+ for attr in vars(module).values():
65
+ if (
66
+ isinstance(attr, type)
67
+ and issubclass(attr, Parser)
68
+ and attr is not Parser
69
+ ):
70
+ found.append(attr)
71
+ return tuple(sorted(found, key=lambda cls: (cls.PRIORITY, cls.__name__)))
57
72
 
58
- def __init__(self) -> None:
59
- self._parsers: list[type[Parser]] = []
60
- self._discover_parsers()
61
73
 
62
- def _discover_parsers(self) -> None:
63
- """Find all Parser subclasses in ecgdatakit.parsers package."""
64
- package = importlib.import_module("ecgdatakit.parsing.parsers")
65
- for _, name, _ in pkgutil.iter_modules(package.__path__):
66
- module = importlib.import_module(f"ecgdatakit.parsing.parsers.{name}")
67
- for attr in vars(module).values():
68
- if (
69
- isinstance(attr, type)
70
- and issubclass(attr, Parser)
71
- and attr is not Parser
72
- ):
73
- self._parsers.append(attr)
74
- self._parsers.sort(key=lambda cls: (cls.PRIORITY, cls.__name__))
74
+ class FileParser:
75
+ """Auto-discovers parsers and dispatches files to the right one.
76
+
77
+ No method needs an instance: ``FileParser.parse(path)`` and
78
+ ``FileParser().parse(path)`` do the same.
79
+ """
75
80
 
76
81
  @property
77
82
  def parsers(self) -> list[type[Parser]]:
78
83
  """List of discovered :class:`Parser` subclasses."""
79
- return list(self._parsers)
84
+ return list(_discover())
80
85
 
81
86
  @staticmethod
82
87
  def supported_formats() -> list[dict[str, str | list[str]]]:
@@ -112,8 +117,49 @@ class FileParser:
112
117
  for p in parsers
113
118
  ]
114
119
 
120
+ @staticmethod
121
+ def detect_format(file_path: str | Path) -> dict | None:
122
+ """Detect the format of an ECG file without parsing it.
123
+
124
+ Returns the format and the parser :meth:`parse` would use, or
125
+ ``None`` when no parser recognises the file. The dict holds
126
+ ``name``, ``description`` and ``extensions`` (as in
127
+ :meth:`supported_formats`), ``parser`` (the class name, e.g.
128
+ ``"SCPECGParser"``) and ``parser_class`` (the class itself)::
129
+
130
+ fmt = FileParser.detect_format("ecg.scp")
131
+ record = fmt["parser_class"]().parse("ecg.scp")
132
+
133
+ Raises
134
+ ------
135
+ FileNotFoundError
136
+ If the file does not exist.
137
+ """
138
+ path = Path(file_path)
139
+ if not path.exists():
140
+ raise FileNotFoundError(f"File not found: {path}")
141
+ parser_cls = FileParser._find_parser(path)
142
+ if parser_cls is None:
143
+ return None
144
+ return {
145
+ "name": parser_cls.FORMAT_NAME or parser_cls.__name__,
146
+ "description": parser_cls.FORMAT_DESCRIPTION or (parser_cls.__doc__ or "").strip(),
147
+ "extensions": list(parser_cls.FILE_EXTENSIONS),
148
+ "parser": parser_cls.__name__,
149
+ "parser_class": parser_cls,
150
+ }
151
+
152
+ @staticmethod
153
+ def _find_parser(path: Path) -> type[Parser] | None:
154
+ """First parser, in detection order, that recognises *path*."""
155
+ header = b""
156
+ if path.is_file():
157
+ with open(path, "rb") as f:
158
+ header = f.read(_SNIFF_SIZE)
159
+ return next((p for p in _discover() if p.can_parse(path, header)), None)
160
+
161
+ @staticmethod
115
162
  def parse(
116
- self,
117
163
  file_path: str | Path,
118
164
  auto_scale: bool = True,
119
165
  units: str = "mV",
@@ -159,12 +205,7 @@ class FileParser:
159
205
  path = Path(file_path)
160
206
  if not path.exists():
161
207
  raise FileNotFoundError(f"File not found: {path}")
162
- header = b""
163
- if path.is_file():
164
- with open(path, "rb") as f:
165
- header = f.read(_SNIFF_SIZE)
166
-
167
- parser_cls = next((p for p in self._parsers if p.can_parse(path, header)), None)
208
+ parser_cls = FileParser._find_parser(path)
168
209
  if parser_cls is None:
169
210
  raise UnsupportedFormatError(f"No parser found for: {path.name}")
170
211
 
@@ -174,7 +215,7 @@ class FileParser:
174
215
  try:
175
216
  with warnings.catch_warnings(record=True) as caught:
176
217
  warnings.simplefilter("always")
177
- record = self._run_parser(parser_cls, path)
218
+ record = FileParser._run_parser(parser_cls, path)
178
219
  if record.leads and all(len(lead.samples) == 0 for lead in record.leads):
179
220
  raise CorruptedFileError(
180
221
  f"{parser_cls.FORMAT_NAME or parser_cls.__name__}: "
@@ -182,7 +223,7 @@ class FileParser:
182
223
  )
183
224
  fill_signal_summary(record)
184
225
  if auto_scale:
185
- record = self._auto_scale(record, target)
226
+ record = FileParser._auto_scale(record, target)
186
227
  finally:
187
228
  for w in caught:
188
229
  warnings.warn(w.message, w.category, stacklevel=2)
@@ -33,8 +33,11 @@ Not stored in the file, and taken from outside it:
33
33
 
34
34
  ``XLEParser(wilson_correction=True)`` refers the chest leads to Wilson's
35
35
  central terminal, ``V = CR - (I + II) / 3``, and names them ``V1`` to
36
- ``V6``, with a warning. Leads III, aVR, aVL and aVF are not stored and not
37
- computed.
36
+ ``V6``, with a warning. Leads III, aVR, aVL and aVF are not stored.
37
+ ``XLEParser(wilson_correction=True, compute_missing_leads=True)`` also
38
+ computes them from I and II (Einthoven and Goldberger), with a warning,
39
+ so the record holds the standard 12 leads. ``compute_missing_leads``
40
+ requires ``wilson_correction``.
38
41
  """
39
42
 
40
43
  from __future__ import annotations
@@ -150,10 +153,27 @@ class XLEParser(Parser):
150
153
  FILE_EXTENSIONS = [_EXT]
151
154
  PRIORITY = 45
152
155
 
153
- def __init__(self, wilson_correction: bool = False) -> None:
156
+ def __init__(self, wilson_correction: bool = False,
157
+ compute_missing_leads: bool = False) -> None:
154
158
  """*wilson_correction*: refer the chest leads, stored against the
155
- right arm, to Wilson's central terminal (experimental)."""
159
+ right arm, to Wilson's central terminal (experimental).
160
+ *compute_missing_leads*: add III, aVR, aVL and aVF, computed from
161
+ I and II. Requires *wilson_correction*, so that the record holds
162
+ the standard 12 leads.
163
+
164
+ Raises
165
+ ------
166
+ ValueError
167
+ If *compute_missing_leads* is set without *wilson_correction*.
168
+ """
169
+ if compute_missing_leads and not wilson_correction:
170
+ raise ValueError(
171
+ "compute_missing_leads=True requires wilson_correction=True: "
172
+ "the 12 standard leads need the chest leads referred to "
173
+ "Wilson's central terminal."
174
+ )
156
175
  self.wilson_correction = wilson_correction
176
+ self.compute_missing_leads = compute_missing_leads
157
177
 
158
178
  @staticmethod
159
179
  def can_parse(file_path: Path, header: bytes) -> bool:
@@ -197,7 +217,24 @@ class XLEParser(Parser):
197
217
  )
198
218
  samples[:, 2:] -= (samples[:, :1] + samples[:, 1:2]) / 3
199
219
  chest = "V"
200
- labels = ["I", "II"] + [f"{chest}{k}" for k in range(1, 7)]
220
+ lead_i, lead_ii = samples[:, 0], samples[:, 1]
221
+ leads = [("I", lead_i), ("II", lead_ii)]
222
+ computed: list[str] = []
223
+ if self.compute_missing_leads:
224
+ warnings.warn(
225
+ "Leads III, aVR, aVL and aVF are not stored in the XLE file: "
226
+ "they were computed from leads I and II.",
227
+ UserWarning,
228
+ stacklevel=2,
229
+ )
230
+ leads += [
231
+ ("III", lead_ii - lead_i),
232
+ ("aVR", -(lead_i + lead_ii) / 2),
233
+ ("aVL", lead_i - lead_ii / 2),
234
+ ("aVF", lead_ii - lead_i / 2),
235
+ ]
236
+ computed = ["III", "aVR", "aVL", "aVF"]
237
+ leads += [(f"{chest}{k}", samples[:, k + 1]) for k in range(1, 7)]
201
238
 
202
239
  fields = _fields(data)
203
240
  patient = PatientInfo(
@@ -215,10 +252,10 @@ class XLEParser(Parser):
215
252
  start, date_text, time_text = _recording_date(data)
216
253
  record.recording.date = start
217
254
  record.recording.duration = timedelta(seconds=frames / _SAMPLING_RATE)
218
- for ch in range(_CHANNELS):
255
+ for label, values in leads:
219
256
  record.leads.append(Lead(
220
- label=labels[ch],
221
- samples=np.ascontiguousarray(samples[:, ch]),
257
+ label=label,
258
+ samples=np.ascontiguousarray(values),
222
259
  sampling_rate=_SAMPLING_RATE,
223
260
  resolution=_RESOLUTION_UV,
224
261
  resolution_unit="uV",
@@ -244,6 +281,7 @@ class XLEParser(Parser):
244
281
  meta["wilson_correction"] = self.wilson_correction
245
282
  meta["chest_lead_reference"] = (
246
283
  "Wilson central terminal" if self.wilson_correction else "right arm")
284
+ meta["computed_leads"] = computed
247
285
  meta["header_size"] = _HEADER_SIZE
248
286
  meta["samples_per_channel"] = frames
249
287
  meta["wraps_undone"] = wraps
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
- name: ecgdatakit-2.0.2/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt
2
+ name: ecgdatakit-2.0.3/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt
3
3
  size: 64288532 bytes
4
4
  sha256: 80be2980936c57c00106dec23c01c9f51089d5dce724d4c99938dad5b2b92f13
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ecgdatakit
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Multi-format ECG file parsing and signal processing library
5
5
  Author-email: Ahmad Fall <ahmad.fall@ird.fr>
6
6
  Maintainer-email: Ahmad Fall <ahmad.fall@ird.fr>
@@ -77,6 +77,47 @@ class TestFileParserDiscovery:
77
77
  with pytest.raises(UnsupportedFormatError):
78
78
  fp.parse(unknown)
79
79
 
80
+ def test_parse_needs_no_instance(self, mortara_file: Path):
81
+ a = FileParser.parse(mortara_file)
82
+ b = FileParser().parse(mortara_file)
83
+ assert a.file_format.parser == b.file_format.parser == "MortaraEL250Parser"
84
+ assert [lead.label for lead in a.leads] == [lead.label for lead in b.leads]
85
+
86
+
87
+ class TestDetectFormat:
88
+ @pytest.mark.parametrize("fixture, parser", [
89
+ ("hl7_aecg_file", "HL7aECGParser"),
90
+ ("mortara_file", "MortaraEL250Parser"),
91
+ ("ishne_file", "ISHNEHolterParser"),
92
+ ("edf_file", "EDFParser"),
93
+ ("scp_ecg_file", "SCPECGParser"),
94
+ ("ge_muse_xml_file", "GEMuseXMLParser"),
95
+ ("wfdb_file", "WFDBParser"),
96
+ ("mfer_file", "MFERParser"),
97
+ ])
98
+ def test_detects_the_parser_parse_uses(self, fixture, parser, request):
99
+ path = request.getfixturevalue(fixture)
100
+ fmt = FileParser.detect_format(path)
101
+ assert fmt["parser"] == parser
102
+ assert fmt["parser_class"].__name__ == parser
103
+ assert fmt["name"] == fmt["parser_class"].FORMAT_NAME
104
+ assert fmt["extensions"] == fmt["parser_class"].FILE_EXTENSIONS
105
+ assert FileParser.parse(path).file_format.parser == parser
106
+
107
+ def test_suggested_class_parses_the_file(self, mortara_file: Path):
108
+ fmt = FileParser.detect_format(mortara_file)
109
+ record = fmt["parser_class"]().parse(mortara_file)
110
+ assert record.leads
111
+
112
+ def test_unknown_format_gives_none(self, tmp_path: Path):
113
+ unknown = tmp_path / "data.xyz"
114
+ unknown.write_text("not an ecg file")
115
+ assert FileParser.detect_format(unknown) is None
116
+
117
+ def test_missing_file(self):
118
+ with pytest.raises(FileNotFoundError):
119
+ FileParser.detect_format("/nonexistent/file.xml")
120
+
80
121
 
81
122
  class TestCanParse:
82
123
  # --- Original 4 parsers ---
@@ -20,6 +20,7 @@ FIELDS = (("Given name", 2673), ("Family name", 2712), ("ID", 2761), ("Birth Dat
20
20
  DATE_OFFSET, TIME_OFFSET = 182972, 182982
21
21
  CR_LEADS = ["I", "II", "CR1", "CR2", "CR3", "CR4", "CR5", "CR6"]
22
22
  V_LEADS = ["I", "II", "V1", "V2", "V3", "V4", "V5", "V6"]
23
+ TWELVE_LEADS = ["I", "II", "III", "aVR", "aVL", "aVF", "V1", "V2", "V3", "V4", "V5", "V6"]
23
24
 
24
25
 
25
26
  def _swap16(data: bytes) -> bytes:
@@ -164,6 +165,50 @@ class TestXLE:
164
165
  assert meta["lead_order_stated"] is False
165
166
  assert meta["wilson_correction"] is False
166
167
  assert meta["chest_lead_reference"] == "right arm"
168
+ assert meta["computed_leads"] == []
169
+
170
+ def test_compute_missing_leads(self, tmp_path):
171
+ signal = ecg_like()
172
+ path = make_xle(tmp_path / "rec.xle", signal)
173
+ record, messages = caught_messages(
174
+ XLEParser(wilson_correction=True, compute_missing_leads=True).parse, path)
175
+ assert has(messages, "computed from leads I and II")
176
+ leads = {lead.label: lead.samples for lead in record.leads}
177
+ assert list(leads) == TWELVE_LEADS
178
+ i, ii = signal[:, 0], signal[:, 1]
179
+ np.testing.assert_allclose(leads["III"], ii - i)
180
+ np.testing.assert_allclose(leads["aVR"], -(i + ii) / 2)
181
+ np.testing.assert_allclose(leads["aVL"], i - ii / 2)
182
+ np.testing.assert_allclose(leads["aVF"], ii - i / 2)
183
+ # stored limb leads unchanged
184
+ np.testing.assert_array_equal(leads["I"], i)
185
+ np.testing.assert_array_equal(leads["II"], ii)
186
+ assert record.raw_metadata["computed_leads"] == ["III", "aVR", "aVL", "aVF"]
187
+ # computed leads are converted with the same scale
188
+ mv = record.to_physical().convert_units("mV")
189
+ np.testing.assert_allclose(mv.leads[2].samples, (ii - i) * 0.9375 / 1000)
190
+
191
+ def test_missing_leads_require_wilson(self):
192
+ with pytest.raises(ValueError, match="requires wilson_correction=True"):
193
+ XLEParser(compute_missing_leads=True)
194
+
195
+ def test_standard_twelve_leads(self, tmp_path):
196
+ signal = ecg_like()
197
+ path = make_xle(tmp_path / "rec.xle", signal)
198
+ record, messages = caught_messages(
199
+ XLEParser(wilson_correction=True, compute_missing_leads=True).parse, path)
200
+ assert [lead.label for lead in record.leads] == TWELVE_LEADS
201
+ assert has(messages, "Wilson correction applied")
202
+ assert has(messages, "computed from leads I and II")
203
+ wct = (signal[:, 0] + signal[:, 1]) / 3
204
+ np.testing.assert_allclose(record.leads[6].samples, signal[:, 2] - wct)
205
+ # Einthoven: I + III = II
206
+ np.testing.assert_allclose(record.leads[0].samples + record.leads[2].samples,
207
+ record.leads[1].samples)
208
+ # Goldberger: aVR + aVL + aVF = 0
209
+ np.testing.assert_allclose(
210
+ record.leads[3].samples + record.leads[4].samples + record.leads[5].samples, 0,
211
+ atol=1e-9)
167
212
 
168
213
  @pytest.mark.parametrize("date, time, expected", [
169
214
  ("27.04.99", "08:15", datetime(1999, 4, 27, 8, 15)), # dots: day first
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes