From 388010714d90ac35fcd771c419e09fe087b8153f Mon Sep 17 00:00:00 2001 From: Brian Gow Date: Mon, 12 Jan 2026 13:54:15 -0500 Subject: [PATCH 1/6] enable check segment cohesion --- src/wfdb/io/record.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/wfdb/io/record.py b/src/wfdb/io/record.py index 1c1f96fa..3af4d95b 100644 --- a/src/wfdb/io/record.py +++ b/src/wfdb/io/record.py @@ -1192,6 +1192,9 @@ def wrsamp(self, expanded=False, write_dir="", wfdb_archive=None): N/A """ + # Validate segment cohesion before writing anything. + self._check_segment_cohesion() + with archive_for_writing(wfdb_archive) as archive: # Perform field validity and cohesion checks, and write the # header file. From 5c3047294c45b7fb71f58b580f65bc64cc56cced Mon Sep 17 00:00:00 2001 From: Brian Gow Date: Mon, 12 Jan 2026 16:29:39 -0500 Subject: [PATCH 2/6] add samps_per_frame segment validation --- src/wfdb/io/record.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/wfdb/io/record.py b/src/wfdb/io/record.py index 3af4d95b..4d743175 100644 --- a/src/wfdb/io/record.py +++ b/src/wfdb/io/record.py @@ -1247,6 +1247,34 @@ def _check_segment_cohesion(self): f"The signal length of segment {seg_num} does not match the corresponding segment length" ) + # If segment has expanded signals, validate samps_per_frame and signal lengths + if segment.e_d_signal is not None or segment.e_p_signal is not None: + expanded_signal = ( + segment.e_d_signal + if segment.e_d_signal is not None + else segment.e_p_signal + ) + + if segment.samps_per_frame is None: + raise ValueError( + f"Segment {seg_num} has expanded signals but 'samps_per_frame' is not set" + ) + + for ch in range(segment.n_sig): + if segment.samps_per_frame[ch] is None: + raise ValueError( + f"Segment {seg_num}, channel {ch}: 'samps_per_frame' must be set for expanded signals" + ) + + expected_len = segment.samps_per_frame[ch] * segment.sig_len + actual_len = len(expanded_signal[ch]) + + if actual_len != expected_len: + raise ValueError( + f"Segment {seg_num}, channel {ch}: expanded signal length ({actual_len}) " + f"does not match samps_per_frame[{ch}] * sig_len ({expected_len})" + ) + # No need to check the sum of sig_lens from each segment object against sig_len # Already effectively done it when checking sum(seg_len) against sig_len From 3c8a692f477d13368d174176c80252ba1a0d156e Mon Sep 17 00:00:00 2001 From: Brian Gow Date: Thu, 29 Oct 2026 16:20:53 -0400 Subject: [PATCH 3/6] fix style check errors --- src/wfdb/io/record.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/wfdb/io/record.py b/src/wfdb/io/record.py index 4d743175..3ecc7522 100644 --- a/src/wfdb/io/record.py +++ b/src/wfdb/io/record.py @@ -1244,10 +1244,12 @@ def _check_segment_cohesion(self): # field if segment.sig_len != self.seg_len[seg_num]: raise ValueError( - f"The signal length of segment {seg_num} does not match the corresponding segment length" + f"The signal length of segment {seg_num} does not match the " + f"corresponding segment length" ) - # If segment has expanded signals, validate samps_per_frame and signal lengths + # If segment has expanded signals, validate samps_per_frame and signal + # lengths if segment.e_d_signal is not None or segment.e_p_signal is not None: expanded_signal = ( segment.e_d_signal From 06bd1a35b2cdd1a56ef1f09f432acbf6423c3886 Mon Sep 17 00:00:00 2001 From: Brian Gow Date: Tue, 3 Nov 2026 12:15:31 -0500 Subject: [PATCH 4/6] validate multirecord writes --- src/wfdb/io/record.py | 33 +++----------------- tests/test_multi_record.py | 62 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/src/wfdb/io/record.py b/src/wfdb/io/record.py index 3ecc7522..660d6d9f 100644 --- a/src/wfdb/io/record.py +++ b/src/wfdb/io/record.py @@ -1225,6 +1225,10 @@ def _check_segment_cohesion(self): raise ValueError("Length of segments must match the 'n_seg' field") for seg_num, segment in enumerate(self.segments): + # Empty segments ('~') have no Record to check + if segment is None: + continue + # If segment 0 is a layout specification record, check that its file names # are all == '~'' if seg_num == 0 and self.seg_len[0] == 0: @@ -1248,35 +1252,6 @@ def _check_segment_cohesion(self): f"corresponding segment length" ) - # If segment has expanded signals, validate samps_per_frame and signal - # lengths - if segment.e_d_signal is not None or segment.e_p_signal is not None: - expanded_signal = ( - segment.e_d_signal - if segment.e_d_signal is not None - else segment.e_p_signal - ) - - if segment.samps_per_frame is None: - raise ValueError( - f"Segment {seg_num} has expanded signals but 'samps_per_frame' is not set" - ) - - for ch in range(segment.n_sig): - if segment.samps_per_frame[ch] is None: - raise ValueError( - f"Segment {seg_num}, channel {ch}: 'samps_per_frame' must be set for expanded signals" - ) - - expected_len = segment.samps_per_frame[ch] * segment.sig_len - actual_len = len(expanded_signal[ch]) - - if actual_len != expected_len: - raise ValueError( - f"Segment {seg_num}, channel {ch}: expanded signal length ({actual_len}) " - f"does not match samps_per_frame[{ch}] * sig_len ({expected_len})" - ) - # No need to check the sum of sig_lens from each segment object against sig_len # Already effectively done it when checking sum(seg_len) against sig_len diff --git a/tests/test_multi_record.py b/tests/test_multi_record.py index fc86985c..4fadaf69 100644 --- a/tests/test_multi_record.py +++ b/tests/test_multi_record.py @@ -1,3 +1,6 @@ +import numpy as np +import pytest + import wfdb @@ -105,3 +108,62 @@ def test_contained_combined_ranges_all_pairs(self): continue assert record.contained_combined_ranges([sig_1, sig_2]) == [(0, 2000)] + + +class TestMultiRecordWriting: + """Test writing multi-segment records.""" + + record_path = "sample-data/multi-segment/041s/041s" + + def test_round_trip(self, tmp_path): + record = wfdb.rdrecord(self.record_path, m2s=False, physical=False) + expected_signals = [segment.d_signal.copy() for segment in record.segments] + + record.wrsamp(write_dir=str(tmp_path)) + + record_write = wfdb.rdrecord(str(tmp_path / "041s"), m2s=False, physical=False) + assert record_write.seg_len == record.seg_len + assert record_write.sig_name == record.sig_name + for segment, expected_signal in zip(record_write.segments, expected_signals): + np.testing.assert_array_equal(segment.d_signal, expected_signal) + + def test_segment_length_mismatch_writes_no_files(self, tmp_path): + record = wfdb.rdrecord(self.record_path, m2s=False, physical=False) + record.seg_len[1] -= 1 + + with pytest.raises(ValueError, match="signal length of segment 1"): + record.wrsamp(write_dir=str(tmp_path)) + + assert list(tmp_path.iterdir()) == [] + + def test_outdated_expanded_signal_is_ignored_when_not_expanded(self, tmp_path): + record = wfdb.rdrecord(self.record_path, m2s=False, physical=False) + expected_signal = record.segments[0].d_signal.copy() + record.segments[0].e_d_signal = [[] for _ in range(record.segments[0].n_sig)] + + record.wrsamp(write_dir=str(tmp_path), expanded=False) + + record_write = wfdb.rdrecord(str(tmp_path / "041s"), m2s=False, physical=False) + np.testing.assert_array_equal( + record_write.segments[0].d_signal, expected_signal + ) + + def test_unspecified_samps_per_frame_is_accepted_when_expanded(self, tmp_path): + record = wfdb.rdrecord( + self.record_path, + m2s=False, + physical=False, + smooth_frames=False, + channels=[6], + ) + record.segments[0].samps_per_frame[0] = None + + record.wrsamp(write_dir=str(tmp_path), expanded=True) + + record_write = wfdb.rdrecord( + str(tmp_path / "041s"), + m2s=False, + physical=False, + smooth_frames=False, + ) + assert record_write.segments[0].samps_per_frame == [1] From ac36a18bee1ba727078260455d02ed102c9afcd3 Mon Sep 17 00:00:00 2001 From: Brian Gow Date: Wed, 4 Nov 2026 08:31:32 -0500 Subject: [PATCH 5/6] add multiseg validation changelog --- changelog.d/multiseg-write-validation.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/multiseg-write-validation.rst diff --git a/changelog.d/multiseg-write-validation.rst b/changelog.d/multiseg-write-validation.rst new file mode 100644 index 00000000..89ffdac3 --- /dev/null +++ b/changelog.d/multiseg-write-validation.rst @@ -0,0 +1,2 @@ +**Validate multi-segment record writes** + ``MultiRecord.wrsamp`` now checks the segment lengths and sampling frequencies of all segments before writing any files, instead of writing a segment whose length does not match ``seg_len``. From 4b6b8056a270ec484aa5855aaed6adacc8edc457 Mon Sep 17 00:00:00 2001 From: Brian Gow Date: Wed, 4 Nov 2026 09:06:35 -0500 Subject: [PATCH 6/6] check and test for short seg_len --- src/wfdb/io/record.py | 3 +++ tests/test_multi_record.py | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/wfdb/io/record.py b/src/wfdb/io/record.py index 660d6d9f..2d280710 100644 --- a/src/wfdb/io/record.py +++ b/src/wfdb/io/record.py @@ -1224,6 +1224,9 @@ def _check_segment_cohesion(self): if self.n_seg != len(self.segments): raise ValueError("Length of segments must match the 'n_seg' field") + if len(self.seg_len) != self.n_seg: + raise ValueError("Length of seg_len must match the 'n_seg' field") + for seg_num, segment in enumerate(self.segments): # Empty segments ('~') have no Record to check if segment is None: diff --git a/tests/test_multi_record.py b/tests/test_multi_record.py index 4fadaf69..0c8615c3 100644 --- a/tests/test_multi_record.py +++ b/tests/test_multi_record.py @@ -136,6 +136,17 @@ def test_segment_length_mismatch_writes_no_files(self, tmp_path): assert list(tmp_path.iterdir()) == [] + def test_short_seg_len_writes_no_files(self, tmp_path): + record = wfdb.rdrecord(self.record_path, m2s=False, physical=False) + record.seg_len.pop() + + with pytest.raises( + ValueError, match="Length of seg_len must match the 'n_seg' field" + ): + record.wrsamp(write_dir=str(tmp_path)) + + assert list(tmp_path.iterdir()) == [] + def test_outdated_expanded_signal_is_ignored_when_not_expanded(self, tmp_path): record = wfdb.rdrecord(self.record_path, m2s=False, physical=False) expected_signal = record.segments[0].d_signal.copy()