Skip to content

Commit 92e2545

Browse files
authored
Add support for reading and writing .wfdb archive files (#541)
As discussed in wfdb/wfdb-spec#21 and #540 we would like to allow WFDB Records to be saved to a single file `.wfdb`. This pull request introduces support for reading and writing `.wfdb` archive files: a ZIP file that holds the header, signal and annotation files of a single record. ## Implementation The pull request has three commits: 1. **Reading.** `rdrecord`, `rdsamp`, `rdheader` and `rdann` read from an archive when given its path, including the `.wfdb` extension (for example `wfdb.rdrecord("100.wfdb")`). - The header is the file named after the archive (`100.hea` in `100.wfdb`), or the only header in the archive. Signal and annotation files are read by the names that follow from the header, and a missing file is an error. - `rdrecord` reads the header and signals from a single open archive. Archives are opened and closed within each call, so nothing is cached between calls. - Headers larger than 1 MB are rejected, as a guard against damaged or malicious archives. 2. **Writing.** `wrsamp` and `Record.wrsamp` take a `wfdb_archive` argument: a path, or an open `WFDBArchive`. - A new archive is written to a temporary file and moved into place only when writing succeeds. A failed write leaves no partial archive, and an existing archive at the same path is either fully replaced or left unchanged. - New archives get the permissions of the archive they replace, or the default permissions for new files. - `create_archive(file_paths, archive_path)` puts the files of an existing record into an archive. 3. **Docs.** A section on archives in the I/O docs, and a changelog entry. Format details: - **One record per archive.** `WFDBArchive` refuses to write a second header file, so it's always clear which record an archive holds. Annotation and other files can be added alongside the record. - **Compression.** Header files are compressed. Signal files are stored without compression, so that reading a range of samples doesn't require decompressing the whole file. Not supported yet: multi-segment records, and archives in cloud storage or on PhysioNet. These raise a clear error. Multi-segment support would extend the one-record rule to "the main header plus the segment headers it lists", and is planned as a follow-up. ## Example usage ```python import numpy as np import wfdb from wfdb.io.archive import WFDBArchive, create_archive # Write a record straight into an archive record = wfdb.rdrecord("sample-data/test01_00s", physical=False) record.wrsamp(wfdb_archive="test01_00s.wfdb") # Read it back, in full or in part archive_record = wfdb.rdrecord("test01_00s.wfdb", physical=False) np.testing.assert_array_equal(archive_record.d_signal, record.d_signal) part = wfdb.rdrecord("test01_00s.wfdb", sampfrom=100, sampto=200, channels=[0, 2]) signals, fields = wfdb.rdsamp("test01_00s.wfdb") header = wfdb.rdheader("test01_00s.wfdb") # Or with the wrsamp function wfdb.wrsamp( "ecg", fs=250, units=["mV", "mV"], sig_name=["I", "II"], p_signal=np.random.default_rng(0).standard_normal((1000, 2)), fmt=["16", "16"], wfdb_archive="ecg.wfdb", ) # Put an existing record and its annotations into an archive create_archive( ["sample-data/100.hea", "sample-data/100.dat", "sample-data/100.atr"], "100.wfdb", ) annotation = wfdb.rdann("100.wfdb", "atr") # Add an annotation file while writing a record with WFDBArchive("100_copy.wfdb", mode="w") as archive: wfdb.rdrecord("sample-data/100", physical=False).wrsamp(wfdb_archive=archive) archive.add_file("sample-data/100.atr") ``` Note: `Record.wrsamp` writes the digital signal, so the record is read with `physical=False` above. Calling it on a record read with physical signals fails with an unclear `TypeError`. That's existing behaviour, not specific to archives, and could be improved separately. ## Tests `tests/test_archive.py` compares records read from archives with the same records read from separate files, including FLAC formats, multiple samples per frame and sample ranges. It also covers writing, annotations, the one-record rule, permissions, failed writes, and error cases such as missing files and unsupported multi-segment records.
2 parents 4aa1e5b + c7dba93 commit 92e2545

8 files changed

Lines changed: 1531 additions & 49 deletions

File tree

‎docs/changes.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ Unreleased
7474
**Allow None for annotations without notes in wrann**
7575
``wrann`` now accepts ``None`` in ``aux_note`` for annotations without a note, as the docstring says, and writes it the same way as an empty string. It used to raise a ``TypeError``.
7676

77+
**Read and write .wfdb archive files**
78+
A record can be stored in a single ``.wfdb`` archive file (a ZIP file containing the header and signal files). ``rdrecord``, ``rdsamp``, ``rdheader`` and ``rdann`` read a record or its annotations from an archive when given the path of the archive, and ``wrsamp`` writes a record into an archive with the new ``wfdb_archive`` argument.
79+
7780
Version 4.3.1 (Feb 2026)
7881
-----------------------------
7982

‎docs/io.rst‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,54 @@ WFDB Annotations
3434
:members: wrann
3535

3636

37+
WFDB Archives
38+
-------------
39+
40+
A ``.wfdb`` archive is a ZIP file containing the files of a single
41+
record: its header file, the signal files listed in the header, and
42+
optionally annotation files. The files are at the top level of the
43+
archive, and the header file is named after the archive (for example,
44+
``100.wfdb`` contains ``100.hea``).
45+
Files are stored without compression, so that part of a signal can be
46+
read without decompressing the whole file. Archives made by other tools
47+
may compress their files, and can still be read.
48+
49+
To read a record from an archive, pass the path of the archive,
50+
including the ``.wfdb`` extension, to ``rdrecord``, ``rdsamp`` or
51+
``rdheader``. Annotations are read the same way with ``rdann``. To
52+
write a record into an archive, pass the path of the archive as the
53+
``wfdb_archive`` argument of ``wrsamp``.
54+
55+
.. code-block:: python
56+
57+
import wfdb
58+
59+
# Write a record into an archive
60+
wfdb.wrsamp("100", fs=360, units=["mV"], sig_name=["MLII"],
61+
p_signal=signal, fmt=["212"], wfdb_archive="100.wfdb")
62+
63+
# Read the record back, or part of it
64+
record = wfdb.rdrecord("100.wfdb")
65+
record = wfdb.rdrecord("100.wfdb", sampfrom=1000, sampto=2000)
66+
67+
# Put the files of an existing record, including its annotations,
68+
# into an archive, and read the annotations
69+
from wfdb.io.archive import create_archive
70+
create_archive(["100.hea", "100.dat", "100.atr"], "100.wfdb")
71+
annotation = wfdb.rdann("100.wfdb", "atr")
72+
73+
An archive holds a single record, so only one header file can be
74+
written to it. Annotation files can be added alongside the record.
75+
Multi-segment records, and archives in cloud storage or on PhysioNet,
76+
are not supported yet.
77+
78+
.. autofunction:: wfdb.io.archive.create_archive
79+
80+
.. autoclass:: wfdb.io.archive.WFDBArchive
81+
:members: namelist, exists, file_size, open, header_file_name,
82+
read_header, write, add_file, close, abort
83+
84+
3785
Cloud Storage Access
3886
--------------------
3987

‎src/wfdb/io/_header.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def set_defaults(self):
290290
for f in sfields:
291291
self.set_default(f)
292292

293-
def wrheader(self, write_dir="", expanded=True):
293+
def wrheader(self, write_dir="", expanded=True, wfdb_archive=None):
294294
"""Write a WFDB header file.
295295
296296
The signals are not used. Before writing:
@@ -307,6 +307,9 @@ def wrheader(self, write_dir="", expanded=True):
307307
Whether the header file should include `samps_per_frame` (this
308308
should only be true if the signal files are written using
309309
`expanded=True`).
310+
wfdb_archive : WFDBArchive, optional
311+
An open .wfdb archive to write the header file to, instead of
312+
`write_dir`.
310313
311314
Returns
312315
-------
@@ -338,7 +341,7 @@ def wrheader(self, write_dir="", expanded=True):
338341
self.check_field_cohesion(rec_write_fields, list(sig_write_fields))
339342

340343
# Write the header file using the specified fields
341-
self.wr_header_file(rec_write_fields, sig_write_fields, write_dir)
344+
self.wr_header_file(rec_write_fields, sig_write_fields, write_dir, wfdb_archive)
342345

343346
def get_write_fields(self):
344347
"""Get the header fields to write, split into record and signal fields.
@@ -514,7 +517,9 @@ def check_field_cohesion(self, rec_write_fields, sig_write_fields):
514517
"Each file_name (dat file) specified must have the same byte offset"
515518
)
516519

517-
def wr_header_file(self, rec_write_fields, sig_write_fields, write_dir):
520+
def wr_header_file(
521+
self, rec_write_fields, sig_write_fields, write_dir, wfdb_archive=None
522+
):
518523
"""Write a header file using the specified fields.
519524
520525
Converts Record attributes into appropriate WFDB format strings.
@@ -528,6 +533,9 @@ def wr_header_file(self, rec_write_fields, sig_write_fields, write_dir):
528533
being equal to a list of channels to write for each field.
529534
write_dir : str
530535
The directory in which to write the header file.
536+
wfdb_archive : WFDBArchive, optional
537+
An open .wfdb archive to write the header file to, instead of
538+
`write_dir`.
531539
532540
Returns
533541
-------
@@ -572,7 +580,11 @@ def wr_header_file(self, rec_write_fields, sig_write_fields, write_dir):
572580
comment_lines = ["# " + comment for comment in self.comments]
573581
header_lines += comment_lines
574582

575-
util.lines_to_file(self.record_name + ".hea", write_dir, header_lines)
583+
if wfdb_archive is not None:
584+
content = "".join(f"{line}\n" for line in header_lines)
585+
wfdb_archive.write(self.record_name + ".hea", content.encode("utf-8"))
586+
else:
587+
util.lines_to_file(self.record_name + ".hea", write_dir, header_lines)
576588

577589

578590
class MultiHeaderMixin(BaseHeaderMixin):

0 commit comments

Comments
 (0)