Skip to content

Commit dda360e

Browse files
authored
Support multi-segment records in .wfdb archive files (#583)
Follow-up to #541, which added `.wfdb` archive files for single-segment records. This adds support for multi-segment records. ## Changes 1. **Multi-segment records in archives.** The archive of a multi-segment record holds the main header together with the header and signal files of each segment. - `rdrecord`, `rdsamp` and `rdheader` (with `rd_segments=True`) read the segments from the same open archive. This covers fixed and variable layouts, empty segments (`~`), sample ranges, `channels`, `channel_names` and `m2s=False`. - `MultiRecord.wrsamp` takes a `wfdb_archive` argument and writes the whole record into one archive. - An archive still holds a single record. It can contain several headers only if one is a multi-segment header that lists all the others as its segments (including the layout header). This is now checked when the archive is closed, so files can be added in any order, and an archive that breaks the rule is not saved. Finding the main header takes linear time in the number of segments, whatever order the headers were added in. - Segments can share a signal file (as in `sample-data/multi-segment/fixed1`), so writing a file that is already in the archive is allowed if the content is the same. - Small fix: reading a fixed-layout record with `channel_names` now takes the signal names from the first non-empty segment, as the code intended, instead of the first segment, which can be empty. 2. **Writing variable layout records.** This fixes an existing bug that also affects writing to normal files. `MultiRecord.wrsamp` failed for variable layout records, such as those read with `rdrecord(..., m2s=False)`. The layout header lists its signals as null signals (format `0`, file name `~`), which the header validation rejected, and `Record.wrsamp` tried to compute checksums and write `.dat` files for signals without samples. Null signals are now accepted, and only the header is written for a record whose signals have no files. - `set_defaults()` no longer fails for null signals: they get the WFDB library's default resolution (12 bits), so a variable layout record created from scratch can be written too. A record that mixes null signals with signals that have samples is rejected with a clear error. ## Example ```python import wfdb from wfdb.io.archive import create_archive # Write a multi-segment record into an archive record = wfdb.rdrecord( "sample-data/multi-segment/s25047/s25047-2704-05-04-10-44", m2s=False, physical=False, ) record.wrsamp(wfdb_archive="s25047.wfdb") # Read it back, as a single record or as segments signal = wfdb.rdrecord("s25047.wfdb", channel_names=["ABP", "II"]) segments = wfdb.rdrecord("s25047.wfdb", m2s=False) header = wfdb.rdheader("s25047.wfdb", rd_segments=True) ``` ## Tests - Records read from archives are compared with the same records read from files, for the fixed and variable layout samples in `sample-data/multi-segment`. This includes sample ranges, channel selection and `m2s=False`. - Multi-segment records written into archives read back the same as the original files, and contain exactly the record's files. - Error cases: a missing segment, and an archive with a header that isn't one of the record's segments. - `test_record.py` has new tests that write a variable layout record to files and read it back (one read from `sample-data`, one created from scratch), and that check the error for a record that mixes null signals with other signals. - An archive whose main header was added last is read correctly. `p000878` isn't used in the archive tests, because one of its segments is not included in `sample-data`.
2 parents b6215cb + 0af27fe commit dda360e

8 files changed

Lines changed: 622 additions & 133 deletions

File tree

‎docs/changes.rst‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,10 @@ Unreleased
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

7777
**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.
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. Multi-segment records are supported, with all their segments stored in the same archive.
79+
80+
**Write variable layout multi-segment records**
81+
``MultiRecord.wrsamp`` can now write variable layout multi-segment records, such as those read with ``rdrecord(..., m2s=False)``. Previously, writing the layout header failed because its signals have no samples (format ``0``, file name ``~``).
7982

8083
**Use only beat annotations in ann2rr**
8184
``ann2rr`` now computes intervals between beat (QRS) annotations only, as the WFDB ``ann2rr`` program does, so rhythm changes, comments and other non-beat annotations no longer split an RR interval in two. The first interval is still measured from the start of the record. To include all annotations as before, pass ``all_annotations=True`` (the equivalent of ``ann2rr -A``).

‎docs/io.rst‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,12 @@ write a record into an archive, pass the path of the archive as the
7070
create_archive(["100.hea", "100.dat", "100.atr"], "100.wfdb")
7171
annotation = wfdb.rdann("100.wfdb", "atr")
7272
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.
73+
An archive holds a single record. For a multi-segment record, the
74+
archive contains the main header together with the header and signal
75+
files of each segment, and ``MultiRecord.wrsamp`` writes them all when
76+
given ``wfdb_archive``. Annotation files can be added alongside the
77+
record. Archives in cloud storage or on PhysioNet are not supported
78+
yet.
7779

7880
.. autofunction:: wfdb.io.archive.create_archive
7981

‎src/wfdb/io/_header.py‎

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,33 @@ def preprocess_header(self, field):
251251

252252
return string_field
253253

254+
def _write_header_lines(self, header_lines, write_dir, wfdb_archive):
255+
"""Write the lines of this record's header file.
256+
257+
The file is written to `write_dir`, or into an open .wfdb archive.
258+
259+
Parameters
260+
----------
261+
header_lines : list
262+
The lines of the header file.
263+
write_dir : str
264+
The directory in which to write the header file.
265+
wfdb_archive : WFDBArchive or None
266+
The archive to write the header file to, instead of
267+
`write_dir`.
268+
269+
Returns
270+
-------
271+
N/A
272+
273+
"""
274+
file_name = self.record_name + ".hea"
275+
if wfdb_archive is not None:
276+
content = "".join(f"{line}\n" for line in header_lines)
277+
wfdb_archive.write(file_name, content.encode("utf-8"))
278+
else:
279+
util.lines_to_file(file_name, write_dir, header_lines)
280+
254281

255282
class HeaderMixin(BaseHeaderMixin):
256283
"""Mixin class with single-segment header methods. Inherited by Record class.
@@ -580,11 +607,7 @@ def wr_header_file(
580607
comment_lines = ["# " + comment for comment in self.comments]
581608
header_lines += comment_lines
582609

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)
610+
self._write_header_lines(header_lines, write_dir, wfdb_archive)
588611

589612

590613
class MultiHeaderMixin(BaseHeaderMixin):
@@ -620,7 +643,7 @@ def set_defaults(self):
620643
for field in self.get_write_fields():
621644
self.set_default(field)
622645

623-
def wrheader(self, write_dir=""):
646+
def wrheader(self, write_dir="", wfdb_archive=None):
624647
"""Write a multi-segment WFDB header file.
625648
626649
The signals or segments are not used. Before writing:
@@ -633,6 +656,9 @@ def wrheader(self, write_dir=""):
633656
----------
634657
write_dir : str, optional
635658
The output directory in which the header is written.
659+
wfdb_archive : WFDBArchive, optional
660+
An open .wfdb archive to write the header file to, instead of
661+
`write_dir`.
636662
637663
Returns
638664
-------
@@ -655,7 +681,7 @@ def wrheader(self, write_dir=""):
655681
self.check_field_cohesion()
656682

657683
# Write the header file using the specified fields
658-
self.wr_header_file(write_fields, write_dir)
684+
self.wr_header_file(write_fields, write_dir, wfdb_archive)
659685

660686
def get_write_fields(self):
661687
"""Get the list of fields used to write the multi-segment header.
@@ -730,7 +756,7 @@ def check_field_cohesion(self):
730756
"The sum of the 'seg_len' fields do not match the 'sig_len' field"
731757
)
732758

733-
def wr_header_file(self, write_fields, write_dir):
759+
def wr_header_file(self, write_fields, write_dir, wfdb_archive=None):
734760
"""Write a header file using the specified fields.
735761
736762
Parameters
@@ -740,6 +766,9 @@ def wr_header_file(self, write_fields, write_dir):
740766
and their dependencies.
741767
write_dir : str
742768
The output directory in which the header is written.
769+
wfdb_archive : WFDBArchive, optional
770+
An open .wfdb archive to write the header file to, instead of
771+
`write_dir`.
743772
744773
Returns
745774
-------
@@ -774,7 +803,7 @@ def wr_header_file(self, write_fields, write_dir):
774803
comment_lines = ["# " + comment for comment in self.comments]
775804
header_lines += comment_lines
776805

777-
util.lines_to_file(self.record_name + ".hea", write_dir, header_lines)
806+
self._write_header_lines(header_lines, write_dir, wfdb_archive)
778807

779808
def get_sig_segments(self, sig_name=None):
780809
"""Get the numbers of the segments that contain a signal.

‎src/wfdb/io/_signal.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444

4545
# The bit resolution of each WFDB dat format
4646
BIT_RES = {
47+
# Null signal (no samples). 12 bits is the WFDB library's default resolution
48+
# (WFDB_DEFRES) for formats without a natural one.
49+
"0": 12,
4750
"8": 8,
4851
"16": 16,
4952
"24": 24,

0 commit comments

Comments
 (0)