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.
- {ecgdatakit-2.0.0/src/ecgdatakit.egg-info → ecgdatakit-2.0.2}/PKG-INFO +3 -2
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/README.md +2 -1
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/__init__.py +1 -1
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/__init__.py +4 -2
- ecgdatakit-2.0.2/src/ecgdatakit/anonymize/formats/xle.py +88 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/models.py +8 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parser.py +6 -0
- ecgdatakit-2.0.2/src/ecgdatakit/parsing/parsers/xle.py +259 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt +1 -1
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2/src/ecgdatakit.egg-info}/PKG-INFO +3 -2
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/SOURCES.txt +3 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_anonymize.py +26 -3
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_parser.py +3 -2
- ecgdatakit-2.0.2/tests/test_xle.py +239 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/LICENSE +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/MANIFEST.in +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/pyproject.toml +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/setup.cfg +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_catalog.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_codes.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_daemon.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_engine.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_filenames.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_identity.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_shell.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_stats.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/_verify.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/_base.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/_xml.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/alivecor_kardia.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/dicom_waveform.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/edf.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/ge_muse_xml.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/hl7_aecg.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/ishne_holter.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/mfer.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/mortara_el250.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/scp_ecg.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/sierra_xml.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/anonymize/formats/wfdb.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/cli.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/exceptions.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/batch.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/codecs/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/codecs/lzw.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/codecs/xli.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/labels.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/record.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/waveform_text.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/helpers/xml.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/alivecor_kardia.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/dicom_waveform.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/edan_arc.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/edf.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/ge_muse_xml.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/hl7_aecg.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/ishne_holter.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/mfer.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/mortara_el250.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/scp_ecg.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/sierra_xml.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/parsing/parsers/wfdb.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/plotting/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/plotting/_core.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/plotting/interactive.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/plotting/static.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/_core.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/clean.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/denoise.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/filters.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/hrv.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/leads.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/deepfade.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/dense_net.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/layers.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/normalize.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/peaks.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/quality.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/resample.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/transforms.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/py.typed +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/dependency_links.txt +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/entry_points.txt +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/requires.txt +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit.egg-info/top_level.txt +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/__init__.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/conftest.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_alivecor_kardia.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_clean.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_codecs.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_denoise.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_dicom_waveform.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_dispatch.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_edan_arc.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_edf.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_filters.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_ge_muse_xml.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_hl7_aecg.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_hrv.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_ishne_holter.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_leads.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_mfer.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_models.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_mortara_el250.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_normalize.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_numpy_input.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_peaks.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_peaks_shannon.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_plotting_interactive.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_plotting_static.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_quality.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_resample.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_scp_ecg.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_sierra_xml.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_transforms.py +0 -0
- {ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/tests/test_wfdb.py +0 -0
- {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.
|
|
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 -
|
|
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 -
|
|
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,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
|
{ecgdatakit-2.0.0 → ecgdatakit-2.0.2}/src/ecgdatakit/processing/nn/weights/deepfade_exp_1_ddp.pt
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
|
-
name: ecgdatakit-2.0.
|
|
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.
|
|
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 -
|
|
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
|
|
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
|
|
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
|
-
#
|
|
50
|
-
assert len(FileParser().parsers) ==
|
|
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]
|