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.
- {ecgdatakit-2.0.2/src/ecgdatakit.egg-info → ecgdatakit-2.0.4}/PKG-INFO +40 -22
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/README.md +34 -18
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/pyproject.toml +11 -5
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/__init__.py +9 -1
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_catalog.py +1 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_engine.py +72 -13
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_identity.py +9 -2
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_stats.py +7 -4
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_verify.py +2 -2
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/__init__.py +2 -1
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/ishne_holter.py +35 -8
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/cli.py +5 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/exceptions.py +5 -0
- ecgdatakit-2.0.4/src/ecgdatakit/exporter/__init__.py +28 -0
- ecgdatakit-2.0.4/src/ecgdatakit/exporter/base.py +32 -0
- ecgdatakit-2.0.4/src/ecgdatakit/exporter/hl7_aecg.py +706 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/models.py +163 -19
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/__init__.py +2 -1
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parser.py +71 -30
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/alivecor_kardia.py +1 -1
- ecgdatakit-2.0.4/src/ecgdatakit/parsing/parsers/cardionics_chain.py +520 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/dicom_waveform.py +11 -11
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/edan_arc.py +2 -2
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/ge_muse_xml.py +7 -7
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/hl7_aecg.py +95 -11
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/ishne_holter.py +106 -15
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/mfer.py +4 -4
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/mortara_el250.py +10 -10
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/scp_ecg.py +27 -21
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/sierra_xml.py +10 -10
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/xle.py +156 -14
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt +1 -1
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/normalize.py +4 -4
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4/src/ecgdatakit.egg-info}/PKG-INFO +40 -22
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/SOURCES.txt +6 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/requires.txt +5 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/conftest.py +31 -7
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_anonymize.py +175 -14
- ecgdatakit-2.0.4/tests/test_cardionics_chain.py +956 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_dicom_waveform.py +7 -7
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_edan_arc.py +2 -2
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_edf.py +2 -2
- ecgdatakit-2.0.4/tests/test_export_hl7_aecg.py +339 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_ge_muse_xml.py +4 -4
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_hl7_aecg.py +60 -8
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_ishne_holter.py +89 -4
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_mfer.py +4 -4
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_models.py +67 -5
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_mortara_el250.py +16 -16
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_normalize.py +5 -5
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_parser.py +50 -2
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_scp_ecg.py +28 -15
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_sierra_xml.py +10 -10
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_wfdb.py +2 -2
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_xle.py +185 -3
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/LICENSE +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/MANIFEST.in +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/setup.cfg +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/__init__.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_codes.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_daemon.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_filenames.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/_shell.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/_base.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/_xml.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/alivecor_kardia.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/dicom_waveform.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/edf.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/ge_muse_xml.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/hl7_aecg.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/mfer.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/mortara_el250.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/scp_ecg.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/sierra_xml.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/wfdb.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/anonymize/formats/xle.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/batch.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/codecs/__init__.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/codecs/lzw.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/codecs/xli.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/__init__.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/labels.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/record.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/waveform_text.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/helpers/xml.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/__init__.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/edf.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/parsing/parsers/wfdb.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/plotting/__init__.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/plotting/_core.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/plotting/interactive.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/plotting/static.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/__init__.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/_core.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/clean.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/denoise.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/filters.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/hrv.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/leads.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/__init__.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/deepfade.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/dense_net.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/nn/layers.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/peaks.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/quality.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/resample.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/processing/transforms.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit/py.typed +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/dependency_links.txt +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/entry_points.txt +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/src/ecgdatakit.egg-info/top_level.txt +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/__init__.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_alivecor_kardia.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_clean.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_codecs.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_denoise.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_dispatch.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_filters.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_hrv.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_leads.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_numpy_input.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_peaks.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_peaks_shannon.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_plotting_interactive.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_plotting_static.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_quality.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_resample.py +0 -0
- {ecgdatakit-2.0.2 → ecgdatakit-2.0.4}/tests/test_transforms.py +0 -0
- {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.
|
|
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.
|
|
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
|
[](https://pypi.org/project/ecgdatakit/)
|
|
80
82
|
[](https://github.com/UMMISCO/ECGDataKit/releases)
|
|
81
83
|
[](https://github.com/UMMISCO/ECGDataKit/actions/workflows/tests.yml)
|
|
82
84
|
[](https://ecgdatakit.ummisco.fr)
|
|
83
|
-
[](https://www.python.org/downloads/)
|
|
84
86
|
[](https://github.com/UMMISCO/ECGDataKit/blob/main/LICENSE)
|
|
85
87
|
[](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 -
|
|
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
|
|
245
|
-
recording
|
|
246
|
-
├─ device
|
|
247
|
-
└─ acquisition
|
|
248
|
-
├─ signal
|
|
249
|
-
└─ filters
|
|
250
|
-
leads
|
|
251
|
-
measurements
|
|
252
|
-
interpretation
|
|
253
|
-
|
|
254
|
-
leads_enhanced
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
[](https://pypi.org/project/ecgdatakit/)
|
|
4
4
|
[](https://github.com/UMMISCO/ECGDataKit/releases)
|
|
5
5
|
[](https://github.com/UMMISCO/ECGDataKit/actions/workflows/tests.yml)
|
|
6
6
|
[](https://ecgdatakit.ummisco.fr)
|
|
7
|
-
[](https://www.python.org/downloads/)
|
|
8
8
|
[](https://github.com/UMMISCO/ECGDataKit/blob/main/LICENSE)
|
|
9
9
|
[](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 -
|
|
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
|
|
169
|
-
recording
|
|
170
|
-
├─ device
|
|
171
|
-
└─ acquisition
|
|
172
|
-
├─ signal
|
|
173
|
-
└─ filters
|
|
174
|
-
leads
|
|
175
|
-
measurements
|
|
176
|
-
interpretation
|
|
177
|
-
|
|
178
|
-
leads_enhanced
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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
|
-
|
|
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 = "
|
|
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.
|
|
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
|
]
|
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
656
|
-
#
|
|
657
|
-
|
|
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
|
|
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(
|
|
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({
|
|
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({
|
|
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,
|
|
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.
|
|
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
|