ecgdatakit 2.0.2__tar.gz → 2.0.4__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 (129) hide show
  1. {ecgdatakit-2.0.2/src/ecgdatakit.egg-info → ecgdatakit-2.0.4}/PKG-INFO +40 -22
  2. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/README.md +34 -18
  3. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/pyproject.toml +11 -5
  4. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/__init__.py +9 -1
  5. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_catalog.py +1 -0
  6. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_engine.py +72 -13
  7. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_identity.py +9 -2
  8. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_stats.py +7 -4
  9. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_verify.py +2 -2
  10. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/__init__.py +2 -1
  11. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/ishne_holter.py +35 -8
  12. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/cli.py +5 -0
  13. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/exceptions.py +5 -0
  14. ecgdatakit-2.0.4/src/ecgdatakit/exporter/__init__.py +28 -0
  15. ecgdatakit-2.0.4/src/ecgdatakit/exporter/base.py +32 -0
  16. ecgdatakit-2.0.4/src/ecgdatakit/exporter/hl7_aecg.py +706 -0
  17. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/models.py +163 -19
  18. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/__init__.py +2 -1
  19. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parser.py +71 -30
  20. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/alivecor_kardia.py +1 -1
  21. ecgdatakit-2.0.4/src/ecgdatakit/parsing/parsers/cardionics_chain.py +520 -0
  22. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/dicom_waveform.py +11 -11
  23. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/edan_arc.py +2 -2
  24. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/ge_muse_xml.py +7 -7
  25. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/hl7_aecg.py +95 -11
  26. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/ishne_holter.py +106 -15
  27. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/mfer.py +4 -4
  28. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/mortara_el250.py +10 -10
  29. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/scp_ecg.py +27 -21
  30. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/sierra_xml.py +10 -10
  31. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/xle.py +156 -14
  32. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt +1 -1
  33. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/normalize.py +4 -4
  34. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4/src/ecgdatakit.egg-info}/PKG-INFO +40 -22
  35. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/SOURCES.txt +6 -0
  36. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/requires.txt +5 -0
  37. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/conftest.py +31 -7
  38. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_anonymize.py +175 -14
  39. ecgdatakit-2.0.4/tests/test_cardionics_chain.py +956 -0
  40. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_dicom_waveform.py +7 -7
  41. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_edan_arc.py +2 -2
  42. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_edf.py +2 -2
  43. ecgdatakit-2.0.4/tests/test_export_hl7_aecg.py +339 -0
  44. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_ge_muse_xml.py +4 -4
  45. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_hl7_aecg.py +60 -8
  46. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_ishne_holter.py +89 -4
  47. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_mfer.py +4 -4
  48. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_models.py +67 -5
  49. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_mortara_el250.py +16 -16
  50. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_normalize.py +5 -5
  51. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_parser.py +50 -2
  52. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_scp_ecg.py +28 -15
  53. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_sierra_xml.py +10 -10
  54. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_wfdb.py +2 -2
  55. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_xle.py +185 -3
  56. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/LICENSE +0 -0
  57. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/MANIFEST.in +0 -0
  58. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/setup.cfg +0 -0
  59. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/__init__.py +0 -0
  60. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_codes.py +0 -0
  61. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_daemon.py +0 -0
  62. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_filenames.py +0 -0
  63. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_shell.py +0 -0
  64. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/_base.py +0 -0
  65. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/_xml.py +0 -0
  66. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/alivecor_kardia.py +0 -0
  67. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/dicom_waveform.py +0 -0
  68. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/edf.py +0 -0
  69. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/ge_muse_xml.py +0 -0
  70. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/hl7_aecg.py +0 -0
  71. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/mfer.py +0 -0
  72. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/mortara_el250.py +0 -0
  73. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/scp_ecg.py +0 -0
  74. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/sierra_xml.py +0 -0
  75. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/wfdb.py +0 -0
  76. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/xle.py +0 -0
  77. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/batch.py +0 -0
  78. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/codecs/__init__.py +0 -0
  79. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/codecs/lzw.py +0 -0
  80. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/codecs/xli.py +0 -0
  81. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/__init__.py +0 -0
  82. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/labels.py +0 -0
  83. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/record.py +0 -0
  84. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/waveform_text.py +0 -0
  85. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/xml.py +0 -0
  86. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/__init__.py +0 -0
  87. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/edf.py +0 -0
  88. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/wfdb.py +0 -0
  89. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/plotting/__init__.py +0 -0
  90. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/plotting/_core.py +0 -0
  91. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/plotting/interactive.py +0 -0
  92. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/plotting/static.py +0 -0
  93. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/__init__.py +0 -0
  94. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/_core.py +0 -0
  95. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/clean.py +0 -0
  96. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/denoise.py +0 -0
  97. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/filters.py +0 -0
  98. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/hrv.py +0 -0
  99. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/leads.py +0 -0
  100. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/__init__.py +0 -0
  101. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/deepfade.py +0 -0
  102. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/dense_net.py +0 -0
  103. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/layers.py +0 -0
  104. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/peaks.py +0 -0
  105. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/quality.py +0 -0
  106. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/resample.py +0 -0
  107. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/transforms.py +0 -0
  108. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/py.typed +0 -0
  109. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/dependency_links.txt +0 -0
  110. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/entry_points.txt +0 -0
  111. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/top_level.txt +0 -0
  112. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/__init__.py +0 -0
  113. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_alivecor_kardia.py +0 -0
  114. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_clean.py +0 -0
  115. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_codecs.py +0 -0
  116. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_denoise.py +0 -0
  117. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_dispatch.py +0 -0
  118. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_filters.py +0 -0
  119. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_hrv.py +0 -0
  120. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_leads.py +0 -0
  121. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_numpy_input.py +0 -0
  122. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_peaks.py +0 -0
  123. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_peaks_shannon.py +0 -0
  124. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_plotting_interactive.py +0 -0
  125. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_plotting_static.py +0 -0
  126. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_quality.py +0 -0
  127. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_resample.py +0 -0
  128. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_transforms.py +0 -0
  129. {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/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.4
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>
@@ -20,12 +20,10 @@ Classifier: Intended Audience :: Healthcare Industry
20
20
  Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
21
21
  Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
22
22
  Classifier: Programming Language :: Python :: 3
23
- Classifier: Programming Language :: Python :: 3.10
24
- Classifier: Programming Language :: Python :: 3.11
25
23
  Classifier: Programming Language :: Python :: 3.12
26
24
  Classifier: Programming Language :: Python :: 3.13
27
25
  Classifier: Operating System :: OS Independent
28
- Requires-Python: >=3.10
26
+ Requires-Python: >=3.12
29
27
  Description-Content-Type: text/markdown
30
28
  License-File: LICENSE
31
29
  Requires-Dist: numpy>=1.24
@@ -50,6 +48,8 @@ Requires-Dist: scipy>=1.10; extra == "plotting-interactive"
50
48
  Provides-Extra: anonymize
51
49
  Requires-Dist: shortuuid>=1.0; extra == "anonymize"
52
50
  Requires-Dist: watchdog>=3.0; extra == "anonymize"
51
+ Provides-Extra: export
52
+ Requires-Dist: lxml>=4.9; extra == "export"
53
53
  Provides-Extra: all
54
54
  Requires-Dist: pydicom>=2.4; extra == "all"
55
55
  Requires-Dist: scipy>=1.10; extra == "all"
@@ -60,10 +60,12 @@ Requires-Dist: peakutils; extra == "all"
60
60
  Requires-Dist: neurokit2; extra == "all"
61
61
  Requires-Dist: shortuuid>=1.0; extra == "all"
62
62
  Requires-Dist: watchdog>=3.0; extra == "all"
63
+ Requires-Dist: lxml>=4.9; extra == "all"
63
64
  Provides-Extra: dev
64
65
  Requires-Dist: pytest>=9.0; extra == "dev"
65
66
  Requires-Dist: pytest-cov>=7.0; extra == "dev"
66
67
  Requires-Dist: ruff>=0.15; extra == "dev"
68
+ Requires-Dist: lxml>=4.9; extra == "dev"
67
69
  Provides-Extra: docs
68
70
  Requires-Dist: sphinx>=8.0; extra == "docs"
69
71
  Requires-Dist: furo>=2024.8; extra == "docs"
@@ -74,13 +76,13 @@ Requires-Dist: sphinx-sitemap>=2.6; extra == "docs"
74
76
  Requires-Dist: sphinxext-opengraph>=0.9; extra == "docs"
75
77
  Dynamic: license-file
76
78
 
77
- # ECGDataKit
79
+ # ECGDataKit 2.0.4
78
80
 
79
81
  [![PyPI](https://img.shields.io/pypi/v/ecgdatakit)](https://pypi.org/project/ecgdatakit/)
80
82
  [![Release](https://img.shields.io/github/v/release/UMMISCO/ECGDataKit)](https://github.com/UMMISCO/ECGDataKit/releases)
81
83
  [![Tests](https://github.com/UMMISCO/ECGDataKit/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/UMMISCO/ECGDataKit/actions/workflows/tests.yml)
82
84
  [![Docs](https://github.com/UMMISCO/ECGDataKit/actions/workflows/docs.yml/badge.svg?branch=main)](https://ecgdatakit.ummisco.fr)
83
- [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
85
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/)
84
86
  [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://github.com/UMMISCO/ECGDataKit/blob/main/LICENSE)
85
87
  [![GitLab](https://img.shields.io/badge/GitLab-mirror-orange?logo=gitlab)](https://git.ummisco.fr/open/ecgdatakit)
86
88
 
@@ -94,7 +96,7 @@ Developed at [UMMISCO](https://www.ummisco.fr) / [IRD](https://www.ird.fr) by Ah
94
96
 
95
97
  ## Features
96
98
 
97
- ### Parsing - 13 ECG formats, one unified data model
99
+ ### Parsing - 14 ECG formats, one unified data model
98
100
 
99
101
  Formats are detected from the file content, not the extension.
100
102
 
@@ -113,6 +115,7 @@ Formats are detected from the file content, not the extension.
113
115
  | EDAN ARC Holter (experimental) | `patient.hea` + `ecgraw.dat`, `.arc` | `patient.hea` with sibling `ecgraw.dat`, or an `.arc` with a known signature |
114
116
  | AliveCor Kardia JSON | `.json` | JSON object with Kardia recording keys (`recordedAt`, `algorithmDetermination`, ...) |
115
117
  | XLE ECG (experimental) | `.xle` | `.xle` file with a 256 KiB header holding the patient field labels |
118
+ | Cardionics Holter chain | `.chn` + `.ecg` | `.chn` file whose first record holds a valid date and an `.ecg` file name or `End` |
116
119
 
117
120
  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`.
118
121
 
@@ -150,6 +153,17 @@ Parsers report what the file stores. They never compute clinical values (interva
150
153
  ecgdatakit anonymize run /path/to/source
151
154
  ```
152
155
 
156
+ ### Export
157
+
158
+ `HL7aECGExporter` writes a record read from any supported format as an HL7 Annotated ECG XML file that conforms to the official HL7 schema. EDAN ARC Holter files are the exception, since they do not state a voltage scale. See [Export](https://ecgdatakit.ummisco.fr/api/export.html) in the docs.
159
+
160
+ ```python
161
+ from ecgdatakit import FileParser
162
+ from ecgdatakit.exporter import HL7aECGExporter
163
+
164
+ HL7aECGExporter().export(FileParser.parse("recording.dcm"), "recording.xml")
165
+ ```
166
+
153
167
  ## Installation
154
168
 
155
169
  ```bash
@@ -174,6 +188,9 @@ pip install "ecgdatakit[dicom]"
174
188
  # With anonymization (ecgdatakit anonymize)
175
189
  pip install "ecgdatakit[anonymize]"
176
190
 
191
+ # With HL7 aECG schema checking of exported files (lxml)
192
+ pip install "ecgdatakit[export]"
193
+
177
194
  # With the experimental DeepFADE denoising autoencoder (requires torch)
178
195
  pip install "ecgdatakit[denoising]"
179
196
 
@@ -241,21 +258,22 @@ Every parser returns the same `ECGRecord`, so downstream code stays identical no
241
258
 
242
259
  ```
243
260
  ECGRecord
244
- patient PatientInfo ID, name, birth date, sex, race, age, weight, height, medications, history, pacemaker
245
- recording RecordingInfo date, end date, duration, technician, physician, room, location
246
- ├─ device DeviceInfo manufacturer, model, serial number, software version, institution
247
- └─ acquisition AcquisitionSetup
248
- ├─ signal SignalCharacteristics sampling rate, resolution, bits/sample, encoding, compression, channels
249
- └─ filters FilterSettings highpass, lowpass, notch frequencies
250
- leads list[Lead] label, samples (float64), sampling rate, resolution, units, is_raw
251
- measurements GlobalMeasurements HR, PR, QRS, QT, QTc (Bazett/Fridericia), P/QRS/T axes, RR interval
252
- interpretation Interpretation statements, severity, source, interpreter
253
- median_beats list[Lead] median/template beats, when available
254
- leads_enhanced list[Lead] the same leads filtered by the device, when the file stores them (AliveCor)
255
- annotations dict[str, str] additional key-value annotations
256
- source_format str parser identifier (e.g. "sierra_xml")
257
- file_format FileFormatInfo file format version and creation date
258
- raw_metadata dict original format-specific metadata
261
+ patient PatientInfo ID, name, birth date, sex, race, age, weight, height, medications, history, pacemaker
262
+ recording RecordingInfo date, end date, duration, technician, physician, room, location
263
+ ├─ device DeviceInfo manufacturer, model, serial number, software version, institution
264
+ └─ acquisition AcquisitionSetup
265
+ ├─ signal SignalCharacteristics sampling rate, resolution, bits/sample, encoding, compression, channels
266
+ └─ filters FilterSettings highpass, lowpass, notch frequencies
267
+ leads list[Lead] label, samples (float64), sampling rate, resolution, units, is_raw
268
+ measurements GlobalMeasurements HR, PR, QRS, QT, QTc (Bazett/Fridericia), P/QRS/T axes, RR interval
269
+ interpretation Interpretation statements, severity, source, interpreter
270
+ representative_beats list[Lead] representative beats (median, average or template), when available
271
+ leads_enhanced list[Lead] the same leads filtered by the device, when the file stores them (AliveCor)
272
+ ecg_files list[ECGFile] name, start, end, samples and read error of each file, when the record spans several files
273
+ annotations dict[str, str] additional key-value annotations
274
+ source_format str parser identifier (e.g. "sierra_xml")
275
+ file_format FileFormatInfo file format version and creation date
276
+ raw_metadata dict original format-specific metadata
259
277
  ```
260
278
 
261
279
  ## Author
@@ -1,10 +1,10 @@
1
- # ECGDataKit
1
+ # ECGDataKit 2.0.4
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/ecgdatakit)](https://pypi.org/project/ecgdatakit/)
4
4
  [![Release](https://img.shields.io/github/v/release/UMMISCO/ECGDataKit)](https://github.com/UMMISCO/ECGDataKit/releases)
5
5
  [![Tests](https://github.com/UMMISCO/ECGDataKit/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/UMMISCO/ECGDataKit/actions/workflows/tests.yml)
6
6
  [![Docs](https://github.com/UMMISCO/ECGDataKit/actions/workflows/docs.yml/badge.svg?branch=main)](https://ecgdatakit.ummisco.fr)
7
- [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
7
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/)
8
8
  [![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://github.com/UMMISCO/ECGDataKit/blob/main/LICENSE)
9
9
  [![GitLab](https://img.shields.io/badge/GitLab-mirror-orange?logo=gitlab)](https://git.ummisco.fr/open/ecgdatakit)
10
10
 
@@ -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 - 13 ECG formats, one unified data model
21
+ ### Parsing - 14 ECG formats, one unified data model
22
22
 
23
23
  Formats are detected from the file content, not the extension.
24
24
 
@@ -37,6 +37,7 @@ Formats are detected from the file content, not the extension.
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
39
  | XLE ECG (experimental) | `.xle` | `.xle` file with a 256 KiB header holding the patient field labels |
40
+ | Cardionics Holter chain | `.chn` + `.ecg` | `.chn` file whose first record holds a valid date and an `.ecg` file name or `End` |
40
41
 
41
42
  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`.
42
43
 
@@ -74,6 +75,17 @@ Parsers report what the file stores. They never compute clinical values (interva
74
75
  ecgdatakit anonymize run /path/to/source
75
76
  ```
76
77
 
78
+ ### Export
79
+
80
+ `HL7aECGExporter` writes a record read from any supported format as an HL7 Annotated ECG XML file that conforms to the official HL7 schema. EDAN ARC Holter files are the exception, since they do not state a voltage scale. See [Export](https://ecgdatakit.ummisco.fr/api/export.html) in the docs.
81
+
82
+ ```python
83
+ from ecgdatakit import FileParser
84
+ from ecgdatakit.exporter import HL7aECGExporter
85
+
86
+ HL7aECGExporter().export(FileParser.parse("recording.dcm"), "recording.xml")
87
+ ```
88
+
77
89
  ## Installation
78
90
 
79
91
  ```bash
@@ -98,6 +110,9 @@ pip install "ecgdatakit[dicom]"
98
110
  # With anonymization (ecgdatakit anonymize)
99
111
  pip install "ecgdatakit[anonymize]"
100
112
 
113
+ # With HL7 aECG schema checking of exported files (lxml)
114
+ pip install "ecgdatakit[export]"
115
+
101
116
  # With the experimental DeepFADE denoising autoencoder (requires torch)
102
117
  pip install "ecgdatakit[denoising]"
103
118
 
@@ -165,21 +180,22 @@ Every parser returns the same `ECGRecord`, so downstream code stays identical no
165
180
 
166
181
  ```
167
182
  ECGRecord
168
- patient PatientInfo ID, name, birth date, sex, race, age, weight, height, medications, history, pacemaker
169
- recording RecordingInfo date, end date, duration, technician, physician, room, location
170
- ├─ device DeviceInfo manufacturer, model, serial number, software version, institution
171
- └─ acquisition AcquisitionSetup
172
- ├─ signal SignalCharacteristics sampling rate, resolution, bits/sample, encoding, compression, channels
173
- └─ filters FilterSettings highpass, lowpass, notch frequencies
174
- leads list[Lead] label, samples (float64), sampling rate, resolution, units, is_raw
175
- measurements GlobalMeasurements HR, PR, QRS, QT, QTc (Bazett/Fridericia), P/QRS/T axes, RR interval
176
- interpretation Interpretation statements, severity, source, interpreter
177
- median_beats list[Lead] median/template beats, when available
178
- leads_enhanced list[Lead] the same leads filtered by the device, when the file stores them (AliveCor)
179
- annotations dict[str, str] additional key-value annotations
180
- source_format str parser identifier (e.g. "sierra_xml")
181
- file_format FileFormatInfo file format version and creation date
182
- raw_metadata dict original format-specific metadata
183
+ patient PatientInfo ID, name, birth date, sex, race, age, weight, height, medications, history, pacemaker
184
+ recording RecordingInfo date, end date, duration, technician, physician, room, location
185
+ ├─ device DeviceInfo manufacturer, model, serial number, software version, institution
186
+ └─ acquisition AcquisitionSetup
187
+ ├─ signal SignalCharacteristics sampling rate, resolution, bits/sample, encoding, compression, channels
188
+ └─ filters FilterSettings highpass, lowpass, notch frequencies
189
+ leads list[Lead] label, samples (float64), sampling rate, resolution, units, is_raw
190
+ measurements GlobalMeasurements HR, PR, QRS, QT, QTc (Bazett/Fridericia), P/QRS/T axes, RR interval
191
+ interpretation Interpretation statements, severity, source, interpreter
192
+ representative_beats list[Lead] representative beats (median, average or template), when available
193
+ leads_enhanced list[Lead] the same leads filtered by the device, when the file stores them (AliveCor)
194
+ ecg_files list[ECGFile] name, start, end, samples and read error of each file, when the record spans several files
195
+ annotations dict[str, str] additional key-value annotations
196
+ source_format str parser identifier (e.g. "sierra_xml")
197
+ file_format FileFormatInfo file format version and creation date
198
+ raw_metadata dict original format-specific metadata
183
199
  ```
184
200
 
185
201
  ## Author
@@ -11,7 +11,7 @@ authors = [{name = "Ahmad Fall", email = "ahmad.fall@ird.fr"}]
11
11
  maintainers = [{name = "Ahmad Fall", email = "ahmad.fall@ird.fr"}]
12
12
  license = "Apache-2.0"
13
13
  license-files = ["LICENSE"]
14
- requires-python = ">=3.10"
14
+ requires-python = ">=3.12"
15
15
  dependencies = [
16
16
  "numpy>=1.24",
17
17
  "defusedxml>=0.7.1",
@@ -26,8 +26,6 @@ classifiers = [
26
26
  "Topic :: Scientific/Engineering :: Medical Science Apps.",
27
27
  "Topic :: Scientific/Engineering :: Bio-Informatics",
28
28
  "Programming Language :: Python :: 3",
29
- "Programming Language :: Python :: 3.10",
30
- "Programming Language :: Python :: 3.11",
31
29
  "Programming Language :: Python :: 3.12",
32
30
  "Programming Language :: Python :: 3.13",
33
31
  "Operating System :: OS Independent",
@@ -54,6 +52,8 @@ plotting = ["matplotlib>=3.7", "scipy>=1.10"]
54
52
  plotting-interactive = ["plotly>=5.15", "scipy>=1.10"]
55
53
  # watchdog: file-system events for the anonymization daemon
56
54
  anonymize = ["shortuuid>=1.0", "watchdog>=3.0"]
55
+ # lxml: checking exported HL7 aECG files against the HL7 schema
56
+ export = ["lxml>=4.9"]
57
57
  all = [
58
58
  "pydicom>=2.4",
59
59
  "scipy>=1.10",
@@ -64,8 +64,10 @@ all = [
64
64
  "neurokit2",
65
65
  "shortuuid>=1.0",
66
66
  "watchdog>=3.0",
67
+ "lxml>=4.9",
67
68
  ]
68
- dev = ["pytest>=9.0", "pytest-cov>=7.0", "ruff>=0.15"]
69
+ # lxml: the tests check every exported HL7 aECG document against the schema
70
+ dev = ["pytest>=9.0", "pytest-cov>=7.0", "ruff>=0.15", "lxml>=4.9"]
69
71
  docs = [
70
72
  "sphinx>=8.0",
71
73
  "furo>=2024.8",
@@ -89,8 +91,12 @@ where = ["src"]
89
91
  [tool.setuptools.dynamic]
90
92
  version = {attr = "ecgdatakit.__version__"}
91
93
 
94
+ [tool.uv]
95
+ # neurokit2 caps setuptools below 82 but does not import it
96
+ override-dependencies = ["setuptools>=84.1.0"]
97
+
92
98
  [tool.ruff]
93
- target-version = "py310"
99
+ target-version = "py312"
94
100
 
95
101
  [tool.pytest.ini_options]
96
102
  testpaths = ["tests"]
@@ -1,10 +1,12 @@
1
1
  """ECGDataKit - Multi-format ECG file parsing and processing library."""
2
2
 
3
- __version__ = "2.0.2"
3
+ __version__ = "2.0.4"
4
4
 
5
5
  from ecgdatakit.models import (
6
6
  AcquisitionSetup,
7
+ BeatAnnotations,
7
8
  DeviceInfo,
9
+ ECGFile,
8
10
  ECGRecord,
9
11
  FileFormatInfo,
10
12
  FilterSettings,
@@ -26,12 +28,16 @@ from ecgdatakit.exceptions import (
26
28
  ChecksumError,
27
29
  ChecksumWarning,
28
30
  RawSamplesError,
31
+ ExportError,
29
32
  )
30
33
  from ecgdatakit.parsing.batch import BatchParseWarning, parse_batch
34
+ from ecgdatakit.parsing.parsers.ishne_holter import read_ishne_annotations
31
35
 
32
36
  __all__ = [
33
37
  "AcquisitionSetup",
38
+ "BeatAnnotations",
34
39
  "DeviceInfo",
40
+ "ECGFile",
35
41
  "ECGRecord",
36
42
  "FileFormatInfo",
37
43
  "FilterSettings",
@@ -52,6 +58,8 @@ __all__ = [
52
58
  "ChecksumError",
53
59
  "ChecksumWarning",
54
60
  "RawSamplesError",
61
+ "ExportError",
55
62
  "BatchParseWarning",
56
63
  "parse_batch",
64
+ "read_ishne_annotations",
57
65
  ]
@@ -29,6 +29,7 @@ COLUMNS = [
29
29
  "original_file_name",
30
30
  "anonymized_file_name",
31
31
  "folder_anonymized",
32
+ "file_name_anonymized",
32
33
  "companions",
33
34
  "duplicate",
34
35
  "raw_size",
@@ -22,7 +22,7 @@ import time
22
22
  import uuid
23
23
  from concurrent.futures import ThreadPoolExecutor, as_completed
24
24
  from dataclasses import dataclass, field
25
- from datetime import datetime, timezone
25
+ from datetime import UTC, datetime
26
26
  from pathlib import Path
27
27
 
28
28
  from ecgdatakit import __version__
@@ -40,6 +40,7 @@ from ecgdatakit.anonymize._stats import (
40
40
  FileRecord,
41
41
  Stat,
42
42
  compare,
43
+ name_pseudonym,
43
44
  write_stats,
44
45
  )
45
46
  from ecgdatakit.anonymize._verify import VerificationError, verify
@@ -58,7 +59,7 @@ _SAVE_EVERY = 200
58
59
 
59
60
 
60
61
  def _now() -> str:
61
- return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
62
+ return datetime.now(UTC).astimezone().isoformat(timespec="seconds")
62
63
 
63
64
 
64
65
  def _hidden(name: str) -> bool:
@@ -179,6 +180,12 @@ class Anonymizer:
179
180
  after the patient pseudonym in the output, and its name is replaced
180
181
  wherever it appears in the files. Every other folder, sub-folders of
181
182
  the patient folder included, keeps its name.
183
+ anonymize_file_names : bool
184
+ ``False`` (default): each anonymized file keeps its raw name. A name
185
+ holding a known identity value (name, ID, anonymized patient folder
186
+ name) is reported as risk. ``True``: each anonymized file is named
187
+ ``<patient pseudonym>_<ECG pseudonym>.<extension>``, nothing of the
188
+ raw name is kept.
182
189
  out_dir : str
183
190
  Output folder created in each dataset, with the same sub-folders
184
191
  as the dataset.
@@ -197,7 +204,7 @@ class Anonymizer:
197
204
 
198
205
  def __init__(self, source: str | Path, datasets: bool = False,
199
206
  patients_dir_name: str | None = None, group_by_patient_folders: bool = False,
200
- anonymize_patient_folders: bool = False,
207
+ anonymize_patient_folders: bool = False, anonymize_file_names: bool = False,
201
208
  out_dir: str = "ANONYMIZED", catalog_name: str = "anonymization_catalog.csv",
202
209
  threads: int = 8, settle_seconds: float = 0.0,
203
210
  lock_stale_seconds: float = 12 * 3600, dry_run: bool = False) -> None:
@@ -214,6 +221,7 @@ class Anonymizer:
214
221
  self.patients_dir_name = patients_dir_name or None
215
222
  self.by_folder = bool(group_by_patient_folders)
216
223
  self.folders = bool(anonymize_patient_folders)
224
+ self.file_names = bool(anonymize_file_names)
217
225
  if self.folders and not self.by_folder:
218
226
  raise ValueError("Renaming patient folders needs group_by_patient_folders "
219
227
  "(--group-by-patient-folders): without it there are no "
@@ -424,6 +432,9 @@ class Anonymizer:
424
432
  continue
425
433
  handler = self._detect(path)
426
434
  if handler is None:
435
+ if path.suffix.lower() == ".ann" and any(
436
+ n.lower() == path.stem.lower() + ".ecg" for n in names):
437
+ continue # ISHNE beat annotations, handled with their recording
427
438
  report.skipped_unsupported += 1
428
439
  continue
429
440
  if isinstance(handler, WFDBHandler) and path.suffix.lower() != ".hea":
@@ -470,7 +481,7 @@ class Anonymizer:
470
481
  if anon_root.is_dir() and all(r != anon_root for _, _, r in anon_roots):
471
482
  anon_roots.append((anon_root, True, anon_root))
472
483
  raw = self._records(dataset, raw_roots, skip=out)
473
- anon = self._records(out, anon_roots, skip=None)
484
+ anon = self._records(out, anon_roots, skip=None, pseudonyms=self._ecg_pseudonyms(dataset))
474
485
  raw_dirs = self._patient_dirs(dataset, raw_roots, out)
475
486
  anon_dirs = self._patient_dirs(out, anon_roots, None)
476
487
  stats = compare(raw, anon, grouped=self.by_folder, renamed=self.folders,
@@ -478,6 +489,22 @@ class Anonymizer:
478
489
  empty = sorted(raw_dirs - {r.patient_folder for r in raw})
479
490
  return stats, empty
480
491
 
492
+ def _ecg_pseudonyms(self, dataset: Path) -> dict[str, str] | None:
493
+ """ECG pseudonym of each anonymized file, by its path relative to
494
+ the output folder, from the catalog. ``None`` when the files are
495
+ named after their pseudonyms: the names are read instead."""
496
+ if self.file_names:
497
+ return None
498
+ catalog = Catalog(self.catalog_path(dataset))
499
+ out = Path(self.out_dir)
500
+ result: dict[str, str] = {}
501
+ for r in catalog.active():
502
+ for p in _outputs(r):
503
+ path = Path(p)
504
+ if path.parts[:len(out.parts)] == out.parts:
505
+ result[path.relative_to(out).as_posix()] = r["anonymized_ecg_id"]
506
+ return result
507
+
481
508
  def _patient_dirs(self, base: Path, roots, skip: Path | None) -> set[str]:
482
509
  """Every patient folder on disk (the folders directly under the
483
510
  roots), with or without ECG files, relative to *base*."""
@@ -493,9 +520,12 @@ class Anonymizer:
493
520
  dirs.add(Path(e.path).relative_to(base).as_posix())
494
521
  return dirs
495
522
 
496
- def _records(self, base: Path, roots, skip: Path | None) -> list[FileRecord]:
523
+ def _records(self, base: Path, roots, skip: Path | None,
524
+ pseudonyms: dict[str, str] | None = None) -> list[FileRecord]:
497
525
  """ECG files under *roots*, with their patient folder and the
498
- identity values read in them."""
526
+ identity values read in them. For anonymized files, *pseudonyms*
527
+ gives the ECG pseudonym of each file (``None``: read from the file
528
+ names)."""
499
529
  found: list[tuple[Path, str]] = []
500
530
  for folder, rec, root in roots:
501
531
  for _, files in self._list(folder, rec, skip=skip):
@@ -517,9 +547,15 @@ class Anonymizer:
517
547
  identity = handler.identity(path)
518
548
  except Exception: # noqa: BLE001 - an unreadable file is counted without values
519
549
  identity = Identity()
520
- return FileRecord(path.relative_to(base).as_posix(), patient_folder,
550
+ rel = path.relative_to(base).as_posix()
551
+ if pseudonyms is None:
552
+ pseudonym = name_pseudonym(rel)
553
+ else:
554
+ # A file missing from the catalog counts as its own ECG
555
+ pseudonym = pseudonyms.get(rel, "") or rel
556
+ return FileRecord(rel, patient_folder,
521
557
  tuple(identity.patient_ids), tuple(identity.ecg_ids),
522
- _sha256([path]))
558
+ _sha256([path]), pseudonym.replace("-", ""))
523
559
 
524
560
  with ThreadPoolExecutor(self.threads) as pool:
525
561
  return [r for r in pool.map(read, found) if r is not None]
@@ -539,7 +575,8 @@ class Anonymizer:
539
575
  # Unchanged on disk; re-done only when its output disappeared
540
576
  # or the folder mode changed
541
577
  if (all((dataset / p).exists() for p in _outputs(old))
542
- and old["folder_anonymized"] == self._folder_flag()):
578
+ and old["folder_anonymized"] == self._folder_flag()
579
+ and _names_flag(old) == self._names_flag()):
543
580
  report.unchanged += 1
544
581
  continue
545
582
  entry.handler = self._detect(entry.path)
@@ -600,6 +637,7 @@ class Anonymizer:
600
637
  now = _now()
601
638
  if (old and old["status"] in ACTIVE and old["raw_sha256"] == sha
602
639
  and old["folder_anonymized"] == self._folder_flag()
640
+ and _names_flag(old) == self._names_flag()
603
641
  and all((dataset / p).exists() for p in _outputs(old))):
604
642
  row = dict(old, raw_size=str(entry.size), raw_mtime_ns=str(entry.mtime_ns))
605
643
  row["status"] = "unchanged"
@@ -652,9 +690,17 @@ class Anonymizer:
652
690
  for v in unmatched:
653
691
  risks.append(f"patient ID field ({v}) does not match the patient folder "
654
692
  f"name, replaced")
655
- # The anonymized file gets a new name: nothing of the raw name is
656
- # kept, since a name may hold identity in any form
657
- if isinstance(handler, WFDBHandler) and handler.is_multi_segment(entry.path):
693
+ replacer = Replacer(identity, patient_code, ecg_code, extra_ids=folder_ids)
694
+ # With anonymize_file_names the file gets a new name: nothing of the
695
+ # raw name is kept, since a name may hold identity in any form.
696
+ # Otherwise the raw name is kept, and reported when it holds a known
697
+ # identity value.
698
+ if not self.file_names:
699
+ new_name = entry.path.name
700
+ if replacer.replace(new_name) != new_name:
701
+ risks.append("file name kept as is and holds identity text "
702
+ "(see --anonymize-file-names)")
703
+ elif isinstance(handler, WFDBHandler) and handler.is_multi_segment(entry.path):
658
704
  new_name = entry.path.name
659
705
  risks.append("multi-segment WFDB record name kept as is (it may hold identity)")
660
706
  else:
@@ -668,7 +714,6 @@ class Anonymizer:
668
714
  new_name, ecg_code)
669
715
  out_path = out_dir / new_name
670
716
 
671
- replacer = Replacer(identity, patient_code, ecg_code, extra_ids=folder_ids)
672
717
  codes = Codes(patient_code, ecg_code, frozenset(kept_ids))
673
718
  rewrite = handler.rewrite(entry.path, out_path, identity, codes, replacer)
674
719
  risks += rewrite.notes
@@ -690,6 +735,7 @@ class Anonymizer:
690
735
  "original_file_name": entry.path.name,
691
736
  "anonymized_file_name": new_name,
692
737
  "folder_anonymized": self._folder_flag(),
738
+ "file_name_anonymized": self._names_flag(),
693
739
  "companions": SEPARATOR.join(
694
740
  (Path(self.out_dir) / rel_dir / p.name).as_posix()
695
741
  for p in rewrite.outputs if p != out_path),
@@ -711,6 +757,10 @@ class Anonymizer:
711
757
  staged = self._stage(rewrite.outputs, staging)
712
758
  verify(entry.path, staged[out_path], replacer, patient_code, kept=kept_ids)
713
759
  values = handler.free_text(_head(rewrite.outputs[out_path]))
760
+ # Companions with a rewritten header of their own (ISHNE .ann) are checked too
761
+ for p, output in rewrite.outputs.items():
762
+ if p != out_path and isinstance(output, Spliced) and output.head:
763
+ values += handler.free_text(output.head)
714
764
  left = sum(1 for v in values if replacer.replace_value(v, is_data(v)) != v)
715
765
  if left:
716
766
  risks.append(f"{left} field value(s) of the anonymized file still hold identity text")
@@ -746,6 +796,9 @@ class Anonymizer:
746
796
  def _folder_flag(self) -> str:
747
797
  return "TRUE" if self.folders else "FALSE"
748
798
 
799
+ def _names_flag(self) -> str:
800
+ return "TRUE" if self.file_names else "FALSE"
801
+
749
802
  def _output_dir(self, entry: Entry, patient_code: str) -> Path:
750
803
  """Output sub-folder of *entry*: the raw one, with only the patient
751
804
  folder renamed to the pseudonym when patient folders are anonymized.
@@ -939,6 +992,12 @@ def _outputs(row: dict) -> list[str]:
939
992
  return [p for p in paths if p]
940
993
 
941
994
 
995
+ def _names_flag(row: dict) -> str:
996
+ """``file_name_anonymized`` of a catalog row. Catalogs written before
997
+ the option existed always anonymized file names."""
998
+ return row.get("file_name_anonymized") or "TRUE"
999
+
1000
+
942
1001
  def _in_scope(path: Path, scanned: list[tuple[Path, bool, bool]]) -> bool:
943
1002
  """True when *path* was in a folder (or is the file) listed by this run."""
944
1003
  for target, recursive, is_file in scanned:
@@ -57,12 +57,19 @@ def name_tokens(*names: str, min_len: int = MIN_NAME_TOKEN) -> list[str]:
57
57
  return sorted(tokens, key=lambda t: (-len(t), t))
58
58
 
59
59
 
60
+ #: Values a writer puts in a field it has nothing for (THEW Holters say "none"
61
+ #: for both names). They are no one's identity: replacing them would also
62
+ #: change every other field that holds the same word (a compression "none")
63
+ _PLACEHOLDERS = {"none", "null", "nil", "unknown", "n/a", "-", "?", "anonymous"}
64
+
65
+
60
66
  @dataclass
61
67
  class Identity:
62
68
  """Identity values stored in one file.
63
69
 
64
70
  Every value is kept as written in the file, so the CSV catalog holds the
65
- exact original text.
71
+ exact original text. Placeholders such as ``none`` or ``unknown`` are not
72
+ identity values and are left out.
66
73
  """
67
74
 
68
75
  patient_ids: list[str] = field(default_factory=list)
@@ -73,7 +80,7 @@ class Identity:
73
80
  def add(self, kind: str, value: str | None) -> None:
74
81
  value = (value or "").strip()
75
82
  values = getattr(self, kind)
76
- if value and value not in values:
83
+ if value and value.lower() not in _PLACEHOLDERS and value not in values:
77
84
  values.append(value)
78
85
 
79
86
  def merge(self, other: Identity) -> None:
@@ -30,6 +30,9 @@ class FileRecord(NamedTuple):
30
30
  patient_ids: tuple[str, ...]
31
31
  ecg_ids: tuple[str, ...]
32
32
  sha256: str
33
+ ecg_pseudonym: str = ""
34
+ """ECG pseudonym of an anonymized file, without hyphens: read from its
35
+ name when file names are anonymized, otherwise from the catalog."""
33
36
 
34
37
 
35
38
  class Stat(NamedTuple):
@@ -50,10 +53,10 @@ def _duplicates(records: list[FileRecord]) -> int:
50
53
  return sum(n - 1 for n in Counter(r.sha256 for r in records).values())
51
54
 
52
55
 
53
- def _name_pseudonym(r: FileRecord) -> str:
56
+ def name_pseudonym(path: str) -> str:
54
57
  """ECG pseudonym written in an anonymized file name
55
58
  (``<patient>_<ecg>[_<n>].<ext>``), without hyphens (WFDB form)."""
56
- parts = PurePosixPath(r.path).name.split(".")[0].split("_")
59
+ parts = PurePosixPath(path).name.split(".")[0].split("_")
57
60
  return parts[1].replace("-", "") if len(parts) >= 2 else ""
58
61
 
59
62
 
@@ -110,9 +113,9 @@ def compare(raw: list[FileRecord], anon: list[FileRecord], grouped: bool,
110
113
  raw_not = sum(1 for r in raw_in if _not_folder_raw(r)) if grouped else 0
111
114
  anon_not = _not_folder_anonymized(anon, renamed) if grouped else 0
112
115
  raw_ecgs = len({r.sha256 for r in raw})
113
- anon_ecgs = len({_name_pseudonym(r) for r in anon})
116
+ anon_ecgs = len({r.ecg_pseudonym for r in anon})
114
117
  anon_ecg_ids = len({v for r in anon for v in r.ecg_ids})
115
- anon_with_ecg_id = len({_name_pseudonym(r) for r in anon if r.ecg_ids})
118
+ anon_with_ecg_id = len({r.ecg_pseudonym for r in anon if r.ecg_ids})
116
119
  if grouped:
117
120
  # One ID per patient folder (folders whose files only hold dates
118
121
  # have none), and no ID shared by two folders
@@ -1,7 +1,7 @@
1
1
  """Check of each anonymized file before it is published.
2
2
 
3
3
  Both files are read with ecgdatakit. The anonymized file must be read by
4
- the same parser, hold the same samples bit for bit (leads, median beats,
4
+ the same parser, hold the same samples bit for bit (leads, representative beats,
5
5
  enhanced leads), have only the pseudonym in the patient name and ID fields,
6
6
  and give every other field the raw value with the identity text replaced.
7
7
  """
@@ -76,7 +76,7 @@ def verify(raw: Path, anonymized: Path, replacer: Replacer, patient_code: str,
76
76
  f"read by {after.file_format.parser} after anonymization, "
77
77
  f"{before.file_format.parser} before")
78
78
  _same_signals(before.leads, after.leads, "leads")
79
- _same_signals(before.median_beats, after.median_beats, "median beats")
79
+ _same_signals(before.representative_beats, after.representative_beats, "representative beats")
80
80
  _same_signals(before.leads_enhanced, after.leads_enhanced, "enhanced leads")
81
81
 
82
82
  old = before.to_dict(include_samples=False)
@@ -1,7 +1,8 @@
1
1
  """Format handlers, one per ecgdatakit parser.
2
2
 
3
3
  EDAN Holter is not handled: the format is undocumented. XLE, also
4
- undocumented, is handled from its reverse-engineered layout.
4
+ undocumented, is handled from its reverse-engineered layout. Cardionics
5
+ chains are not handled, the ISHNE files they list are.
5
6
  """
6
7
 
7
8
  from __future__ import annotations