Skip to content

Commit 75c188c

Browse files
committed
Allow writing variable layout multi-segment records.
The layout header of a variable layout multi-segment record lists its signals as null signals (format 0, file name '~'), with no samples. Writing such a record failed: the header validation rejected format 0 and the file name '~', and Record.wrsamp tried to compute checksums and write dat files for signals with no data. Null signals are now accepted (file name '~' together with format 0), and only the header is written for a record whose signals have no files. This applies to records written to files as well as to .wfdb archives.
1 parent 42988e1 commit 75c188c

4 files changed

Lines changed: 61 additions & 10 deletions

File tree

‎docs/changes.rst‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ Unreleased
2626
**Read and write .wfdb archive files**
2727
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.
2828

29+
**Write variable layout multi-segment records**
30+
``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 ``~``).
31+
2932
Version 4.3.1 (Feb 2026)
3033
-----------------------------
3134

‎src/wfdb/io/record.py‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -451,9 +451,13 @@ def check_field(self, field, required_channels="all"):
451451
continue
452452

453453
if field == "file_name":
454-
# Check for file_name characters
454+
# Check for file_name characters. A null signal (format
455+
# 0), as in the layout header of a variable layout
456+
# multi-segment record, has no file and uses '~'.
455457
accepted_string = re.match(r"[-\w]+\.?[\w]+", item[ch])
456-
if not accepted_string or accepted_string.string != item[ch]:
458+
if item[ch] == "~" and self.fmt[ch] == "0":
459+
pass
460+
elif not accepted_string or accepted_string.string != item[ch]:
457461
raise ValueError(
458462
"File names should only contain alphanumerics, hyphens, and an extension. eg. record-100.dat"
459463
)
@@ -463,7 +467,11 @@ def check_field(self, field, required_channels="all"):
463467
"Signals in a record that share a given file must be consecutive."
464468
)
465469
elif field == "fmt":
466-
if item[ch] not in _signal.DAT_FMTS:
470+
# Format 0 is a null signal, which has no samples and
471+
# no file
472+
if item[ch] == "0" and self.file_name[ch] == "~":
473+
pass
474+
elif item[ch] not in _signal.DAT_FMTS:
467475
raise ValueError(
468476
"File formats must be valid WFDB dat formats:",
469477
_signal.DAT_FMTS,
@@ -894,10 +902,17 @@ def wrsamp(self, expanded=False, write_dir="", wfdb_archive=None):
894902
N/A
895903
896904
"""
905+
# A record whose signals have no files, such as the layout header
906+
# of a variable layout multi-segment record, has no samples:
907+
# only its header is written.
908+
has_signal_files = self.n_sig > 0 and (
909+
self.file_name is None or any(f != "~" for f in self.file_name)
910+
)
911+
897912
# Update the checksum field (except for channels that did not have
898913
# a checksum to begin with, or where the checksum was already
899914
# valid.)
900-
if self.checksum is not None:
915+
if self.checksum is not None and has_signal_files:
901916
checksums = self.calc_checksum(expanded=expanded)
902917
for ch, old_val in enumerate(self.checksum):
903918
if old_val is None or (checksums[ch] - old_val) % 65536 == 0:
@@ -908,7 +923,7 @@ def wrsamp(self, expanded=False, write_dir="", wfdb_archive=None):
908923
# Perform field validity and cohesion checks, and write the
909924
# header file.
910925
self.wrheader(write_dir=write_dir, expanded=expanded, wfdb_archive=archive)
911-
if self.n_sig > 0:
926+
if has_signal_files:
912927
# Perform signal validity and cohesion checks, and write the
913928
# associated dat files.
914929
self.wr_dats(

‎tests/test_archive.py‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -807,11 +807,24 @@ def test_archive_write_bytes_like(tmp_path, monkeypatch):
807807
assert zf.getinfo("array.dat").compress_type == zipfile.ZIP_STORED
808808

809809

810-
@pytest.mark.parametrize("record", ["fixed1/v102s", "041s/041s"])
811-
def test_multi_record_wrsamp_archive(record, tmp_path):
810+
@pytest.mark.parametrize(
811+
"record, kwargs",
812+
[
813+
("fixed1/v102s", {}),
814+
("041s/041s", {}),
815+
("s25047/s25047-2704-05-04-10-44", {}),
816+
(
817+
"s00001/s00001-2896-10-10-00-31",
818+
dict(sampfrom=14428364, sampto=14428375),
819+
),
820+
],
821+
)
822+
def test_multi_record_wrsamp_archive(record, kwargs, tmp_path):
812823
"""A multi-segment record written into an archive reads back like its files.
813824
814825
In 'fixed1', all segments share one dat file, which is written to the archive once.
826+
's25047' and 's00001' have a variable layout, with a layout header and empty
827+
segments.
815828
"""
816829
record_name = f"sample-data/multi-segment/{record}"
817830
archive_path = os.fspath(tmp_path / "multi.wfdb")
@@ -824,6 +837,7 @@ def test_multi_record_wrsamp_archive(record, tmp_path):
824837
assert sorted(zf.namelist()) == sorted(
825838
os.path.basename(path) for path in multi_record_files(record_name)
826839
)
827-
record = wfdb.rdrecord(archive_path, physical=False)
828-
expected = wfdb.rdrecord(record_name, physical=False)
829-
np.testing.assert_array_equal(record.d_signal, expected.d_signal)
840+
record = wfdb.rdrecord(archive_path, **kwargs)
841+
expected = wfdb.rdrecord(record_name, **kwargs)
842+
assert record.sig_name == expected.sig_name
843+
np.testing.assert_array_equal(record.p_signal, expected.p_signal)

‎tests/test_record.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,25 @@ def test_multi_variable_d(self, source):
11171117
assert record.__eq__(record_pn)
11181118
assert record.__eq__(record_named)
11191119

1120+
def test_multi_variable_write(self, tmp_path):
1121+
"""Write a variable layout multi-segment record and read it back.
1122+
1123+
This includes its layout header and empty segments.
1124+
"""
1125+
record_name = "sample-data/multi-segment/s25047/s25047-2704-05-04-10-44"
1126+
multi_record = wfdb.rdrecord(record_name, m2s=False, physical=False)
1127+
1128+
multi_record.wrsamp(write_dir=str(tmp_path))
1129+
layout = wfdb.rdheader(str(tmp_path / "3234460_layout"))
1130+
record = wfdb.rdrecord(str(tmp_path / "s25047-2704-05-04-10-44"))
1131+
1132+
expected = wfdb.rdrecord(record_name)
1133+
assert layout.sig_len == 0
1134+
assert layout.file_name == ["~"] * 3
1135+
assert layout.fmt == ["0"] * 3
1136+
assert record.sig_name == expected.sig_name
1137+
np.testing.assert_array_equal(record.p_signal, expected.p_signal)
1138+
11201139

11211140
class TestTimeConversion:
11221141
"""Test cases for time conversion"""

0 commit comments

Comments
 (0)