ecgdatakit 2.0.0__tar.gz → 2.0.2__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.0/src/ecgdatakit.egg-info → ecgdatakit-2.0.2}/PKG-INFO +3 -2
  2. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/README.md +2 -1
  3. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/__init__.py +1 -1
  4. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/__init__.py +4 -2
  5. ecgdatakit-2.0.2/src/ecgdatakit/anonymize/formats/xle.py +88 -0
  6. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/models.py +8 -0
  7. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parser.py +6 -0
  8. ecgdatakit-2.0.2/src/ecgdatakit/parsing/parsers/xle.py +259 -0
  9. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt +1 -1
  10. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2/src/ecgdatakit.egg-info}/PKG-INFO +3 -2
  11. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/SOURCES.txt +3 -0
  12. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_anonymize.py +26 -3
  13. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_parser.py +3 -2
  14. ecgdatakit-2.0.2/tests/test_xle.py +239 -0
  15. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/LICENSE +0 -0
  16. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/MANIFEST.in +0 -0
  17. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/pyproject.toml +0 -0
  18. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/setup.cfg +0 -0
  19. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/__init__.py +0 -0
  20. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_catalog.py +0 -0
  21. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_codes.py +0 -0
  22. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_daemon.py +0 -0
  23. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_engine.py +0 -0
  24. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_filenames.py +0 -0
  25. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_identity.py +0 -0
  26. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_shell.py +0 -0
  27. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_stats.py +0 -0
  28. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_verify.py +0 -0
  29. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/_base.py +0 -0
  30. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/_xml.py +0 -0
  31. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/alivecor_kardia.py +0 -0
  32. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/dicom_waveform.py +0 -0
  33. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/edf.py +0 -0
  34. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/ge_muse_xml.py +0 -0
  35. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/hl7_aecg.py +0 -0
  36. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/ishne_holter.py +0 -0
  37. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/mfer.py +0 -0
  38. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/mortara_el250.py +0 -0
  39. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/scp_ecg.py +0 -0
  40. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/sierra_xml.py +0 -0
  41. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/wfdb.py +0 -0
  42. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/cli.py +0 -0
  43. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/exceptions.py +0 -0
  44. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/__init__.py +0 -0
  45. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/batch.py +0 -0
  46. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/codecs/__init__.py +0 -0
  47. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/codecs/lzw.py +0 -0
  48. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/codecs/xli.py +0 -0
  49. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/__init__.py +0 -0
  50. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/labels.py +0 -0
  51. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/record.py +0 -0
  52. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/waveform_text.py +0 -0
  53. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/xml.py +0 -0
  54. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/__init__.py +0 -0
  55. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/alivecor_kardia.py +0 -0
  56. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/dicom_waveform.py +0 -0
  57. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/edan_arc.py +0 -0
  58. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/edf.py +0 -0
  59. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/ge_muse_xml.py +0 -0
  60. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/hl7_aecg.py +0 -0
  61. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/ishne_holter.py +0 -0
  62. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/mfer.py +0 -0
  63. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/mortara_el250.py +0 -0
  64. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/scp_ecg.py +0 -0
  65. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/sierra_xml.py +0 -0
  66. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/wfdb.py +0 -0
  67. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/plotting/__init__.py +0 -0
  68. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/plotting/_core.py +0 -0
  69. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/plotting/interactive.py +0 -0
  70. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/plotting/static.py +0 -0
  71. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/__init__.py +0 -0
  72. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/_core.py +0 -0
  73. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/clean.py +0 -0
  74. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/denoise.py +0 -0
  75. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/filters.py +0 -0
  76. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/hrv.py +0 -0
  77. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/leads.py +0 -0
  78. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/__init__.py +0 -0
  79. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/deepfade.py +0 -0
  80. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/dense_net.py +0 -0
  81. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/layers.py +0 -0
  82. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/normalize.py +0 -0
  83. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/peaks.py +0 -0
  84. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/quality.py +0 -0
  85. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/resample.py +0 -0
  86. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/transforms.py +0 -0
  87. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/py.typed +0 -0
  88. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/dependency_links.txt +0 -0
  89. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/entry_points.txt +0 -0
  90. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/requires.txt +0 -0
  91. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/top_level.txt +0 -0
  92. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/__init__.py +0 -0
  93. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/conftest.py +0 -0
  94. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_alivecor_kardia.py +0 -0
  95. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_clean.py +0 -0
  96. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_codecs.py +0 -0
  97. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_denoise.py +0 -0
  98. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_dicom_waveform.py +0 -0
  99. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_dispatch.py +0 -0
  100. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_edan_arc.py +0 -0
  101. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_edf.py +0 -0
  102. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_filters.py +0 -0
  103. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_ge_muse_xml.py +0 -0
  104. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_hl7_aecg.py +0 -0
  105. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_hrv.py +0 -0
  106. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_ishne_holter.py +0 -0
  107. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_leads.py +0 -0
  108. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_mfer.py +0 -0
  109. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_models.py +0 -0
  110. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_mortara_el250.py +0 -0
  111. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_normalize.py +0 -0
  112. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_numpy_input.py +0 -0
  113. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_peaks.py +0 -0
  114. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_peaks_shannon.py +0 -0
  115. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_plotting_interactive.py +0 -0
  116. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_plotting_static.py +0 -0
  117. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_quality.py +0 -0
  118. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_resample.py +0 -0
  119. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_scp_ecg.py +0 -0
  120. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_sierra_xml.py +0 -0
  121. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_transforms.py +0 -0
  122. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_wfdb.py +0 -0
  123. {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/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.0
3
+ Version: 2.0.2
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>
@@ -94,7 +94,7 @@ Developed at [UMMISCO](https://www.ummisco.fr) / [IRD](https://www.ird.fr) by Ah
94
94
 
95
95
  ## Features
96
96
 
97
- ### Parsing - 12 ECG formats, one unified data model
97
+ ### Parsing - 13 ECG formats, one unified data model
98
98
 
99
99
  Formats are detected from the file content, not the extension.
100
100
 
@@ -112,6 +112,7 @@ Formats are detected from the file content, not the extension.
112
112
  | MFER | `.mwf`, `.mfer`, `.mfr` | `MFR ` preamble or a chain of known MFER tags |
113
113
  | EDAN ARC Holter (experimental) | `patient.hea` + `ecgraw.dat`, `.arc` | `patient.hea` with sibling `ecgraw.dat`, or an `.arc` with a known signature |
114
114
  | AliveCor Kardia JSON | `.json` | JSON object with Kardia recording keys (`recordedAt`, `algorithmDetermination`, ...) |
115
+ | XLE ECG (experimental) | `.xle` | `.xle` file with a 256 KiB header holding the patient field labels |
115
116
 
116
117
  Parsers report what the file stores. They never compute clinical values (intervals, heart rate, QTc, axes, pacemaker status) or invent dates. The only derived values are those a format requires (duration from the sample count, age from the birth date when no age is stored), and a default taken from a format specification is flagged with a warning and in `raw_metadata`.
117
118
 
@@ -18,7 +18,7 @@ Developed at [UMMISCO](https://www.ummisco.fr) / [IRD](https://www.ird.fr) by Ah
18
18
 
19
19
  ## Features
20
20
 
21
- ### Parsing - 12 ECG formats, one unified data model
21
+ ### Parsing - 13 ECG formats, one unified data model
22
22
 
23
23
  Formats are detected from the file content, not the extension.
24
24
 
@@ -36,6 +36,7 @@ Formats are detected from the file content, not the extension.
36
36
  | MFER | `.mwf`, `.mfer`, `.mfr` | `MFR ` preamble or a chain of known MFER tags |
37
37
  | EDAN ARC Holter (experimental) | `patient.hea` + `ecgraw.dat`, `.arc` | `patient.hea` with sibling `ecgraw.dat`, or an `.arc` with a known signature |
38
38
  | AliveCor Kardia JSON | `.json` | JSON object with Kardia recording keys (`recordedAt`, `algorithmDetermination`, ...) |
39
+ | XLE ECG (experimental) | `.xle` | `.xle` file with a 256 KiB header holding the patient field labels |
39
40
 
40
41
  Parsers report what the file stores. They never compute clinical values (intervals, heart rate, QTc, axes, pacemaker status) or invent dates. The only derived values are those a format requires (duration from the sample count, age from the birth date when no age is stored), and a default taken from a format specification is flagged with a warning and in `raw_metadata`.
41
42
 
@@ -1,6 +1,6 @@
1
1
  """ECGDataKit - Multi-format ECG file parsing and processing library."""
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "2.0.2"
4
4
 
5
5
  from ecgdatakit.models import (
6
6
  AcquisitionSetup,
@@ -1,6 +1,7 @@
1
1
  """Format handlers, one per ecgdatakit parser.
2
2
 
3
- EDAN Holter is not handled: the format is undocumented.
3
+ EDAN Holter is not handled: the format is undocumented. XLE, also
4
+ undocumented, is handled from its reverse-engineered layout.
4
5
  """
5
6
 
6
7
  from __future__ import annotations
@@ -17,13 +18,14 @@ from ecgdatakit.anonymize.formats.mortara_el250 import MortaraHandler
17
18
  from ecgdatakit.anonymize.formats.scp_ecg import SCPHandler
18
19
  from ecgdatakit.anonymize.formats.sierra_xml import SierraHandler
19
20
  from ecgdatakit.anonymize.formats.wfdb import WFDBHandler
21
+ from ecgdatakit.anonymize.formats.xle import XLEHandler
20
22
 
21
23
  HANDLERS: dict[str, Handler] = {
22
24
  h.parser: h
23
25
  for h in (
24
26
  HL7Handler(), MuseHandler(), SierraHandler(), MortaraHandler(),
25
27
  ISHNEHandler(), EDFHandler(), AliveCorHandler(),
26
- SCPHandler(), MFERHandler(), WFDBHandler(), DICOMHandler(),
28
+ SCPHandler(), MFERHandler(), WFDBHandler(), DICOMHandler(), XLEHandler(),
27
29
  )
28
30
  }
29
31
  """Handler by parser class name."""
@@ -0,0 +1,88 @@
1
+ """XLE anonymization (experimental format).
2
+
3
+ The patient fields are text in the header, with the two bytes of each
4
+ 16-bit word swapped, each value after its label and ended by a NUL (see
5
+ :mod:`ecgdatakit.parsing.parsers.xle`). The text area is swapped back,
6
+ edited in place and swapped again: given name emptied, family name and
7
+ patient ID replaced by the patient code, names and IDs inside the history
8
+ and comment replaced. Birth date and sex are kept. XLE has no ECG ID.
9
+
10
+ Every value keeps to its slot: the bytes up to the alignment spaces of the
11
+ next label, and for the comment, its value and the NULs after it. Only the
12
+ header is held in memory: the samples are copied as a stream.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from ecgdatakit.anonymize._identity import Identity, Replacer
20
+ from ecgdatakit.anonymize.formats._base import Codes, Handler, Rewrite, Spliced
21
+ from ecgdatakit.parsing.parsers.xle import _FIELDS, _TEXT_END, _fields, _swap16
22
+
23
+
24
+ def _slots(text: bytes) -> dict[str, tuple[int, int]]:
25
+ """Start and end of the bytes each value may use, in the swapped text."""
26
+ slots: dict[str, tuple[int, int]] = {}
27
+ for k, (label, off) in enumerate(_FIELDS):
28
+ start = off + len(label) + 1
29
+ if text[start:start + 1] == b" ":
30
+ start += 1
31
+ if k + 1 < len(_FIELDS):
32
+ end = _FIELDS[k + 1][1]
33
+ end -= len(text[start:end]) - len(text[start:end].rstrip(b" "))
34
+ else:
35
+ value = text[start:_TEXT_END].split(b"\x00", 1)[0]
36
+ end = start + len(value)
37
+ while end < _TEXT_END and text[end] == 0:
38
+ end += 1
39
+ slots[label] = (start, max(start, end))
40
+ return slots
41
+
42
+
43
+ class XLEHandler(Handler):
44
+ """XLE ECG: patient fields in a byte-swapped header text area."""
45
+
46
+ parser = "XLEParser"
47
+
48
+ def identity(self, path: Path) -> Identity:
49
+ with open(path, "rb") as f:
50
+ fields = _fields(f.read(_TEXT_END))
51
+ identity = Identity()
52
+ identity.add("first_names", fields["Given name"])
53
+ identity.add("last_names", fields["Family name"])
54
+ identity.add("patient_ids", fields["ID"])
55
+ return identity
56
+
57
+ def rewrite(self, path, out_path, identity: Identity, codes: Codes,
58
+ replacer: Replacer) -> Rewrite:
59
+ with open(path, "rb") as f:
60
+ head = bytearray(f.read(_TEXT_END))
61
+ text = bytearray(_swap16(bytes(head)))
62
+ values = _fields(bytes(head))
63
+ result = Rewrite()
64
+
65
+ def put(label: str, value: str) -> None:
66
+ start, end = _slots(bytes(text))[label]
67
+ raw = value.encode("latin-1", errors="replace")
68
+ if len(raw) > end - start:
69
+ result.notes.append(f"{label} cut to its {end - start}-byte field")
70
+ raw = raw[:end - start]
71
+ text[start:end] = raw + b"\x00" * (end - start - len(raw))
72
+
73
+ if values["Given name"]:
74
+ put("Given name", "")
75
+ if values["Family name"] or identity.has_patient:
76
+ put("Family name", codes.patient)
77
+ if values["ID"] and values["ID"] not in codes.keep:
78
+ put("ID", codes.patient)
79
+ for label in ("History", "Comment"):
80
+ if values[label]:
81
+ put(label, replacer.replace(values[label]))
82
+
83
+ head[:len(text)] = _swap16(bytes(text))
84
+ result.outputs[Path(out_path)] = Spliced(bytes(head), Path(path), len(head))
85
+ return result
86
+
87
+ def free_text(self, data: bytes) -> list[str]:
88
+ return [v for v in _fields(data[:_TEXT_END]).values() if v]
@@ -969,13 +969,18 @@ class ECGRecord:
969
969
  Leads already in physical units keep their sample array (it is
970
970
  shared with this record). Leads whose file gives no physical unit
971
971
  (uncalibrated counts) are left raw and reported in a warning.
972
+ When the scale is not stated in the file but set by the parser
973
+ (``raw_metadata["scale_warning"]``), that warning is issued.
972
974
  """
973
975
  skipped: list[str] = []
976
+ converted: list[str] = []
974
977
 
975
978
  def convert(lead: Lead) -> Lead:
976
979
  if lead.is_raw and lead.resolution != 0.0 and not lead.resolution_unit:
977
980
  skipped.append(lead.label)
978
981
  return lead
982
+ if lead.is_raw:
983
+ converted.append(lead.label)
979
984
  return lead.to_physical()
980
985
 
981
986
  leads = [convert(lead) for lead in self.leads]
@@ -987,6 +992,9 @@ class ECGRecord:
987
992
  "left as raw counts.",
988
993
  stacklevel=2,
989
994
  )
995
+ scale_warning = self.raw_metadata.get("scale_warning")
996
+ if converted and scale_warning:
997
+ warnings.warn(scale_warning, UserWarning, stacklevel=2)
990
998
  return self._with_signals(leads, beats, enhanced)
991
999
 
992
1000
  def convert_units(self, target: str) -> ECGRecord:
@@ -231,6 +231,7 @@ class FileParser:
231
231
  """
232
232
  raw_labels: list[str] = []
233
233
  other_units: list[str] = []
234
+ converted: list[str] = []
234
235
  def root(samples: np.ndarray) -> int:
235
236
  while isinstance(samples.base, np.ndarray):
236
237
  samples = samples.base
@@ -272,6 +273,7 @@ class FileParser:
272
273
  other_units.append(f"{lead.label} ({lead.resolution_unit})")
273
274
  return lead.to_physical()
274
275
  factor = _TO_UV[unit] / _TO_UV[target]
276
+ converted.append(lead.label)
275
277
  return dataclasses.replace(
276
278
  lead,
277
279
  samples=apply(lead.samples, lead.resolution * factor, lead.offset * factor),
@@ -306,4 +308,8 @@ class FileParser:
306
308
  f"converted to {target}.",
307
309
  stacklevel=3,
308
310
  )
311
+ # A scale the file does not state, set by the parser (experimental)
312
+ scale_warning = record.raw_metadata.get("scale_warning")
313
+ if converted and scale_warning:
314
+ warnings.warn(scale_warning, UserWarning, stacklevel=3)
309
315
  return record
@@ -0,0 +1,259 @@
1
+ """XLE ECG parser (experimental).
2
+
3
+ The manufacturer gives no public description of this format. The layout
4
+ below was worked out from sample files and a third-party reader of the
5
+ format, and has not been confirmed by a specification or by the
6
+ recorder's own software.
7
+
8
+ * ``.xle`` files with a 256 KiB (``0x40000`` byte) header followed by the
9
+ signal up to the end of the file.
10
+ * Header: patient fields stored as text with the two bytes of each 16-bit
11
+ word swapped, each value after its label (``Given name:``,
12
+ ``Family name:``, ``ID:``, ``Birth Date:``, ``Sex:``, ``History:``,
13
+ ``Comment:``) at fixed offsets and ended by a NUL. The recording date
14
+ (``DD.MM.YY`` or ``MM/DD/YY``) and time (``HH:MM``) are text swapped the
15
+ same way, at fixed offsets. The other header bytes are not decoded.
16
+ * Signal: 8 channels interleaved, signed 16-bit little-endian, 16 bytes
17
+ per sample instant. Only the low 16 bits of each value are stored, so a
18
+ value that drifts past +32767 continues from -32768: consecutive
19
+ differences are taken modulo 65536 to rebuild a continuous signal, the
20
+ first stored value being the origin. The channels are I, II, then the
21
+ six chest leads measured against the right arm (``CR1`` to ``CR6``).
22
+
23
+ Not stored in the file, and taken from outside it:
24
+
25
+ * the voltage scale: 0.9375 µV per count (15/16), from the third-party
26
+ reader and not from the manufacturer's documentation. Samples stay raw
27
+ counts with this resolution, so ``auto_scale`` converts them. The
28
+ conversion warns that the scale is not validated
29
+ (``raw_metadata["scale_warning"]``);
30
+ * the sampling rate: 1000 Hz, the rate that matches the sample files
31
+ (heart cycle intervals, and mains interference at 50 Hz and its
32
+ harmonics). ``raw_metadata["sampling_rate_stated"]`` is ``False``.
33
+
34
+ ``XLEParser(wilson_correction=True)`` refers the chest leads to Wilson's
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.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import warnings
43
+ from datetime import datetime, timedelta
44
+ from pathlib import Path
45
+
46
+ import numpy as np
47
+
48
+ from ecgdatakit.exceptions import CorruptedFileError
49
+ from ecgdatakit.models import ECGRecord, Lead, PatientInfo, SignalCharacteristics
50
+ from ecgdatakit.parsing.helpers import fill_signal_summary
51
+ from ecgdatakit.parsing.parser import Parser
52
+
53
+ _EXT = ".xle"
54
+ _HEADER_SIZE = 0x40000
55
+ _CHANNELS = 8
56
+ _FRAME = _CHANNELS * 2
57
+ _SAMPLING_RATE = 1000
58
+ _RESOLUTION_UV = 15 / 16 # not validated by the manufacturer's documentation
59
+ _SCALE_WARNING = (
60
+ "XLE: the voltage scale is not stored in the file. The samples were "
61
+ "converted with 0.9375 uV per count, a scale from a third-party reader "
62
+ "that is not validated by the manufacturer's documentation. Converted "
63
+ "amplitudes are experimental: check them against the recorder's own "
64
+ "printout before use."
65
+ )
66
+
67
+ # Offset of each label in the byte-swapped header, and the field it names
68
+ _FIELDS = (
69
+ ("Given name", 2673),
70
+ ("Family name", 2712),
71
+ ("ID", 2761),
72
+ ("Birth Date", 2793),
73
+ ("Sex", 2840),
74
+ ("History", 2876),
75
+ ("Comment", 2916),
76
+ )
77
+ _TEXT_END = 3200
78
+ _SEX = {"M": "M", "F": "F", "W": "F"} # W: weiblich
79
+ # Recording date (8 characters) and time (5 characters), byte-swapped
80
+ _DATE_OFFSET = 182972
81
+ _TIME_OFFSET = 182982
82
+
83
+
84
+ def _swap16(data: bytes) -> bytes:
85
+ """*data* with the two bytes of every 16-bit word exchanged."""
86
+ out = bytearray(data[: len(data) // 2 * 2])
87
+ out[0::2], out[1::2] = data[1:len(out):2], data[0:len(out):2]
88
+ return bytes(out)
89
+
90
+
91
+ def _has_labels(header: bytes) -> bool:
92
+ text = _swap16(header[:_TEXT_END])
93
+ return all(
94
+ text[off:off + len(label) + 1] == label.encode("ascii") + b":"
95
+ for label, off in _FIELDS
96
+ )
97
+
98
+
99
+ def _fields(header: bytes) -> dict[str, str]:
100
+ """Value of every patient field, decoded up to its NUL."""
101
+ text = _swap16(header[:_TEXT_END])
102
+ values: dict[str, str] = {}
103
+ for k, (label, off) in enumerate(_FIELDS):
104
+ start = off + len(label) + 1
105
+ end = _FIELDS[k + 1][1] if k + 1 < len(_FIELDS) else _TEXT_END
106
+ raw = text[start:end].split(b"\x00", 1)[0]
107
+ values[label] = raw.decode("latin-1").strip()
108
+ return values
109
+
110
+
111
+ def _unwrap(stored: np.ndarray) -> tuple[np.ndarray, int]:
112
+ """Continuous signal from 16-bit values stored modulo 65536, and the
113
+ number of wraps undone."""
114
+ values = stored.astype(np.int64)
115
+ diff = np.diff(values, axis=0)
116
+ wrapped = (diff + 32768) % 65536 - 32768
117
+ wraps = int(np.count_nonzero(wrapped != diff))
118
+ out = np.empty_like(values)
119
+ out[:1] = values[:1]
120
+ np.cumsum(wrapped, axis=0, out=out[1:])
121
+ out[1:] += values[:1]
122
+ return out.astype(np.float64), wraps
123
+
124
+
125
+ def _recording_date(data: bytes) -> tuple[datetime | None, str, str]:
126
+ """Recording start, and the date and time text it was read from.
127
+ Dates with dots are day first, dates with slashes month first."""
128
+ date = _swap16(data[_DATE_OFFSET:_DATE_OFFSET + 8]).split(b"\x00", 1)[0]
129
+ time = _swap16(data[_TIME_OFFSET:_TIME_OFFSET + 6])[:5].split(b"\x00", 1)[0]
130
+ date_text = date.decode("latin-1").strip()
131
+ time_text = time.decode("latin-1").strip()
132
+ fmt = "%d.%m.%y" if "." in date_text else "%m/%d/%y"
133
+ try:
134
+ start = datetime.strptime(date_text, fmt)
135
+ except ValueError:
136
+ return None, date_text, time_text
137
+ try:
138
+ clock = datetime.strptime(time_text, "%H:%M")
139
+ start = start.replace(hour=clock.hour, minute=clock.minute)
140
+ except ValueError:
141
+ pass
142
+ return start, date_text, time_text
143
+
144
+
145
+ class XLEParser(Parser):
146
+ """Parser for ``.xle`` ECG files (experimental, reverse-engineered)."""
147
+
148
+ FORMAT_NAME = "XLE ECG"
149
+ FORMAT_DESCRIPTION = "XLE 8-channel ECG (.xle), experimental"
150
+ FILE_EXTENSIONS = [_EXT]
151
+ PRIORITY = 45
152
+
153
+ def __init__(self, wilson_correction: bool = False) -> None:
154
+ """*wilson_correction*: refer the chest leads, stored against the
155
+ right arm, to Wilson's central terminal (experimental)."""
156
+ self.wilson_correction = wilson_correction
157
+
158
+ @staticmethod
159
+ def can_parse(file_path: Path, header: bytes) -> bool:
160
+ path = Path(file_path)
161
+ if path.suffix.lower() != _EXT or not path.is_file():
162
+ return False
163
+ size = path.stat().st_size
164
+ if size <= _HEADER_SIZE or (size - _HEADER_SIZE) % _FRAME:
165
+ return False
166
+ return len(header) >= _TEXT_END and _has_labels(header)
167
+
168
+ def parse(self, file_path: Path) -> ECGRecord:
169
+ path = Path(file_path)
170
+ if not path.exists():
171
+ raise FileNotFoundError(f"File not found: {path}")
172
+ warnings.warn(
173
+ "XLE support is experimental: the format is not documented by its "
174
+ "manufacturer. The layout, the 1000 Hz sampling rate and the lead "
175
+ "order were worked out from sample files and a third-party reader, "
176
+ "and are not validated by the manufacturer's documentation.",
177
+ UserWarning,
178
+ stacklevel=2,
179
+ )
180
+ data = path.read_bytes()
181
+ if len(data) <= _HEADER_SIZE or not _has_labels(data[:_TEXT_END]):
182
+ raise CorruptedFileError(f"Not an XLE ECG file: {path}")
183
+ frames = (len(data) - _HEADER_SIZE) // _FRAME
184
+ leftover = len(data) - _HEADER_SIZE - frames * _FRAME
185
+ stored = np.frombuffer(data, dtype="<i2", count=frames * _CHANNELS,
186
+ offset=_HEADER_SIZE).reshape(frames, _CHANNELS)
187
+ samples, wraps = _unwrap(stored)
188
+ chest = "CR"
189
+ if self.wilson_correction:
190
+ warnings.warn(
191
+ "Wilson correction applied to the XLE chest leads, "
192
+ "V = CR - (I + II) / 3: they are stored against the right arm. "
193
+ "This correction is experimental and not validated by the "
194
+ "manufacturer's documentation.",
195
+ UserWarning,
196
+ stacklevel=2,
197
+ )
198
+ samples[:, 2:] -= (samples[:, :1] + samples[:, 1:2]) / 3
199
+ chest = "V"
200
+ labels = ["I", "II"] + [f"{chest}{k}" for k in range(1, 7)]
201
+
202
+ fields = _fields(data)
203
+ patient = PatientInfo(
204
+ patient_id=fields["ID"],
205
+ first_name=fields["Given name"],
206
+ last_name=fields["Family name"],
207
+ clinical_history=fields["History"],
208
+ )
209
+ sex = fields["Sex"]
210
+ if sex:
211
+ patient.sex = _SEX.get(sex[0].upper(), "U")
212
+
213
+ record = ECGRecord(source_format="xle")
214
+ record.patient = patient
215
+ start, date_text, time_text = _recording_date(data)
216
+ record.recording.date = start
217
+ record.recording.duration = timedelta(seconds=frames / _SAMPLING_RATE)
218
+ for ch in range(_CHANNELS):
219
+ record.leads.append(Lead(
220
+ label=labels[ch],
221
+ samples=np.ascontiguousarray(samples[:, ch]),
222
+ sampling_rate=_SAMPLING_RATE,
223
+ resolution=_RESOLUTION_UV,
224
+ resolution_unit="uV",
225
+ is_raw=True,
226
+ ))
227
+ record.recording.acquisition.signal = SignalCharacteristics(
228
+ sampling_rate=_SAMPLING_RATE,
229
+ bits_per_sample=16,
230
+ signal_signed=True,
231
+ number_channels_allocated=_CHANNELS,
232
+ number_channels_valid=_CHANNELS,
233
+ data_encoding="int16",
234
+ compression="none",
235
+ )
236
+
237
+ meta = record.raw_metadata
238
+ meta["filepath"] = str(path)
239
+ meta["reverse_engineered"] = True
240
+ meta["sampling_rate_stated"] = False
241
+ meta["resolution_stated"] = False
242
+ meta["scale_warning"] = _SCALE_WARNING
243
+ meta["lead_order_stated"] = False
244
+ meta["wilson_correction"] = self.wilson_correction
245
+ meta["chest_lead_reference"] = (
246
+ "Wilson central terminal" if self.wilson_correction else "right arm")
247
+ meta["header_size"] = _HEADER_SIZE
248
+ meta["samples_per_channel"] = frames
249
+ meta["wraps_undone"] = wraps
250
+ meta["recording_date_text"] = date_text
251
+ meta["recording_time_text"] = time_text
252
+ meta["birth_date_text"] = fields["Birth Date"]
253
+ meta["sex_text"] = sex
254
+ meta["comment"] = fields["Comment"]
255
+ if leftover:
256
+ warnings.warn(f"XLE file ends with a partial frame ({leftover} bytes); ignored.",
257
+ UserWarning, stacklevel=2)
258
+ fill_signal_summary(record)
259
+ return record
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
- name: ecgdatakit-2.0.0/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt
2
+ name: ecgdatakit-2.0.2/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.0
3
+ Version: 2.0.2
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>
@@ -94,7 +94,7 @@ Developed at [UMMISCO](https://www.ummisco.fr) / [IRD](https://www.ird.fr) by Ah
94
94
 
95
95
  ## Features
96
96
 
97
- ### Parsing - 12 ECG formats, one unified data model
97
+ ### Parsing - 13 ECG formats, one unified data model
98
98
 
99
99
  Formats are detected from the file content, not the extension.
100
100
 
@@ -112,6 +112,7 @@ Formats are detected from the file content, not the extension.
112
112
  | MFER | `.mwf`, `.mfer`, `.mfr` | `MFR ` preamble or a chain of known MFER tags |
113
113
  | EDAN ARC Holter (experimental) | `patient.hea` + `ecgraw.dat`, `.arc` | `patient.hea` with sibling `ecgraw.dat`, or an `.arc` with a known signature |
114
114
  | AliveCor Kardia JSON | `.json` | JSON object with Kardia recording keys (`recordedAt`, `algorithmDetermination`, ...) |
115
+ | XLE ECG (experimental) | `.xle` | `.xle` file with a 256 KiB header holding the patient field labels |
115
116
 
116
117
  Parsers report what the file stores. They never compute clinical values (intervals, heart rate, QTc, axes, pacemaker status) or invent dates. The only derived values are those a format requires (duration from the sample count, age from the birth date when no age is stored), and a default taken from a format specification is flagged with a warning and in `raw_metadata`.
117
118
 
@@ -37,6 +37,7 @@ src/ecgdatakit/anonymize/formats/mortara_el250.py
37
37
  src/ecgdatakit/anonymize/formats/scp_ecg.py
38
38
  src/ecgdatakit/anonymize/formats/sierra_xml.py
39
39
  src/ecgdatakit/anonymize/formats/wfdb.py
40
+ src/ecgdatakit/anonymize/formats/xle.py
40
41
  src/ecgdatakit/parsing/__init__.py
41
42
  src/ecgdatakit/parsing/batch.py
42
43
  src/ecgdatakit/parsing/parser.py
@@ -61,6 +62,7 @@ src/ecgdatakit/parsing/parsers/mortara_el250.py
61
62
  src/ecgdatakit/parsing/parsers/scp_ecg.py
62
63
  src/ecgdatakit/parsing/parsers/sierra_xml.py
63
64
  src/ecgdatakit/parsing/parsers/wfdb.py
65
+ src/ecgdatakit/parsing/parsers/xle.py
64
66
  src/ecgdatakit/plotting/__init__.py
65
67
  src/ecgdatakit/plotting/_core.py
66
68
  src/ecgdatakit/plotting/interactive.py
@@ -115,4 +117,5 @@ tests/test_scp_ecg.py
115
117
  tests/test_sierra_xml.py
116
118
  tests/test_transforms.py
117
119
  tests/test_wfdb.py
120
+ tests/test_xle.py
118
121
  tests/test_xml_helpers.py
@@ -701,7 +701,12 @@ class TestPatientIdField:
701
701
 
702
702
 
703
703
  ALL_FORMATS = ["hl7_aecg_file", "mortara_file", "ishne_file", "edf_file", "scp_ecg_file",
704
- "ge_muse_xml_file", "dicom_12lead_file", "mfer_file", "sierra", "wfdb", "alivecor"]
704
+ "ge_muse_xml_file", "dicom_12lead_file", "mfer_file", "sierra", "wfdb", "alivecor",
705
+ "xle"]
706
+
707
+ XLE_VALUES = {"Given name": "John", "Family name": "Doe", "ID": "PX-7781",
708
+ "Birth Date": "01.02.03", "Sex": "m", "History": "none",
709
+ "Comment": "Doe follow-up"}
705
710
 
706
711
 
707
712
  def _any_format_file(name: str, request, tmp_path: Path) -> list[Path]:
@@ -723,6 +728,11 @@ def _any_format_file(name: str, request, tmp_path: Path) -> list[Path]:
723
728
  (folder / "rec.hea").write_text("rec 1 100 20\nrec.dat 16 200 16 0 0 0 0 II\n"
724
729
  "# name: John Doe\n# id: PX-4521\n")
725
730
  return [folder / "rec.hea", folder / "rec.dat"]
731
+ if name == "xle":
732
+ from tests.test_xle import ecg_like, make_xle
733
+ path = tmp_path / "make" / "rec.xle"
734
+ path.parent.mkdir(parents=True, exist_ok=True)
735
+ return [make_xle(path, ecg_like(), XLE_VALUES)]
726
736
  if name == "alivecor":
727
737
  doc = {"id": "rec8f3kd92jd83", "patientID": "PAT7d9s8f7d9s8", "duration": 1000,
728
738
  "recordedAt": "2021-04-16T15:19:09+05:30", "algorithmDetermination": "normal",
@@ -739,6 +749,16 @@ def _main_id(files: list[Path]) -> str:
739
749
  return _parse(files[0]).patient.patient_id
740
750
 
741
751
 
752
+ def _readable(path: Path) -> str:
753
+ """File content as text, plus the header with its 16-bit words swapped
754
+ back (XLE stores its text that way)."""
755
+ data = path.read_bytes()
756
+ n = min(4096, len(data)) // 2 * 2
757
+ head = bytearray(n)
758
+ head[0::2], head[1::2] = data[1:n:2], data[0:n:2]
759
+ return data.decode("latin-1") + "\n" + bytes(head).decode("latin-1")
760
+
761
+
742
762
  def _has(text: str, value: str) -> bool:
743
763
  return bool(re.search(rf"(?<![^\W_]){re.escape(value)}(?![^\W_])", text, re.IGNORECASE))
744
764
 
@@ -759,7 +779,7 @@ class TestPatientIdAllFormats:
759
779
  with open(dataset / "anonymization_catalog.csv", newline="") as fh:
760
780
  [row] = list(csv.DictReader(fh))
761
781
  out = dataset / row["anonymized_path"]
762
- return patient_id, row, _parse(out), out.read_bytes().decode("latin-1")
782
+ return patient_id, row, _parse(out), _readable(out)
763
783
 
764
784
  @pytest.mark.parametrize("name", ALL_FORMATS)
765
785
  def test_without_grouping_the_id_is_kept(self, name, request, tmp_path):
@@ -1032,6 +1052,9 @@ def _longer_id(name: str, files: list[Path], pid: str) -> str:
1032
1052
  old = mfer_tlv(0x82, b"MFER001^EXAM9")
1033
1053
  assert old in data
1034
1054
  path.write_bytes(data.replace(old, mfer_tlv(0x82, new_id.encode() + b"^EXAM9")))
1055
+ elif name == "xle":
1056
+ from tests.test_xle import ecg_like, make_xle
1057
+ make_xle(path, ecg_like(), dict(XLE_VALUES, ID=new_id))
1035
1058
  else: # text formats: XML, WFDB header, JSON
1036
1059
  text = data.decode("utf-8")
1037
1060
  assert pid in text
@@ -1060,7 +1083,7 @@ class TestFolderNameInsideIdAllFormats:
1060
1083
  [row] = list(csv.DictReader(fh))
1061
1084
  out = dataset / row["anonymized_path"]
1062
1085
  after = _parse(out)
1063
- text = out.read_bytes().decode("latin-1")
1086
+ text = _readable(out)
1064
1087
  assert after.patient.patient_id == row["anonymized_patient_id"]
1065
1088
  assert not _has(text, pid) and "V1" not in after.patient.patient_id
1066
1089
  assert pid not in row["risk_reason"] and new_id not in row["risk_reason"]
@@ -41,13 +41,14 @@ class TestFileParserDiscovery:
41
41
  assert "DICOMWaveformParser" in names
42
42
  assert "EDANARCHolterParser" in names
43
43
  assert "AliveCorKardiaParser" in names
44
+ assert "XLEParser" in names
44
45
  # Removed from the release: no public spec and no real sample
45
46
  assert "BeneHeartR12Parser" not in names
46
47
  assert "GEMAC2000Parser" not in names
47
48
 
48
49
  def test_discovers_correct_count(self):
49
- # 12 parsers (DICOM is discovered even without pydicom installed)
50
- assert len(FileParser().parsers) == 12
50
+ # 13 parsers (DICOM is discovered even without pydicom installed)
51
+ assert len(FileParser().parsers) == 13
51
52
 
52
53
  def test_discovers_each_parser_once(self):
53
54
  names = [p.__name__ for p in FileParser().parsers]