From 3bcc93b06cb6ac70ce9d428e21d2d471b9a449d0 Mon Sep 17 00:00:00 2001 From: Tom Pollard Date: Mon, 2 Nov 2026 09:53:14 -0500 Subject: [PATCH 1/3] Accept sample numbers stored as floats in wrann wrann failed with "ufunc 'bitwise_and' not supported" when sample was a float array, which is easy to get from a detector or model. Accept float sample numbers if they are all whole numbers, and convert them to integers when writing, without changing the caller's array. Other floats (fractions, NaN, infinity) raise a ValueError. Other dtypes are handled as before. --- changelog.d/wrann-float-samples.rst | 2 ++ src/wfdb/io/annotation.py | 20 +++++++++++++++----- tests/test_annotation.py | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 changelog.d/wrann-float-samples.rst diff --git a/changelog.d/wrann-float-samples.rst b/changelog.d/wrann-float-samples.rst new file mode 100644 index 00000000..109a40dc --- /dev/null +++ b/changelog.d/wrann-float-samples.rst @@ -0,0 +1,2 @@ +**Accept sample numbers stored as floats in wrann** + ``wrann`` now accepts a ``sample`` array of floats if all the values are whole numbers (as from a detector that returns floats), and writes them as integers. Other floats, such as ``20.5`` or NaN, raise a ``ValueError``. Float arrays used to fail with ``TypeError: ufunc 'bitwise_and' not supported for the input types``. diff --git a/src/wfdb/io/annotation.py b/src/wfdb/io/annotation.py index 5914785c..66f140a6 100644 --- a/src/wfdb/io/annotation.py +++ b/src/wfdb/io/annotation.py @@ -565,6 +565,13 @@ def check_field(self, field): ) elif field == "sample": + # Sample numbers may be stored as floats (for example, the + # output of a detector), as long as they are whole numbers. + if np.issubdtype(self.sample.dtype, np.floating): + if not np.all(np.mod(self.sample, 1) == 0): + raise ValueError( + "The 'sample' field must only contain whole numbers" + ) if len(self.sample) == 1: sampdiffs = np.array([self.sample[0]]) elif len(self.sample) > 1: @@ -1156,11 +1163,13 @@ def calc_core_bytes(self): All of the bytes to be written to the annotation file. """ - # The difference sample to write - if len(self.sample) == 1: - sampdiff = np.array([self.sample[0]]) + # The difference sample to write. Sample numbers stored as floats + # have been checked to be whole numbers. + sample = self.sample.astype(np.int64) + if len(sample) == 1: + sampdiff = np.array([sample[0]]) else: - sampdiff = np.concatenate(([self.sample[0]], np.diff(self.sample))) + sampdiff = np.concatenate(([sample[0]], np.diff(sample))) # Create a copy of the annotation object with a # compact version of fields to write @@ -1734,7 +1743,8 @@ def wrann( The string annotation file extension. sample : ndarray A numpy array containing the annotation locations in samples relative to - the beginning of the record. + the beginning of the record. Floats are accepted if they are whole + numbers. symbol : list, numpy array, optional The symbols used to display the annotation labels. List or numpy array. If this field is present, `label_store` must not be present. diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 37a9a8b6..c8afd22a 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -357,3 +357,26 @@ def test_contained_labels_from_symbols(): ] assert labels.loc[1, "symbol"] == "N" assert labels["n_occurrences"].sum() == len(ann.sample) + + +def test_wrann_float_samples(temp_path): + """Sample numbers stored as whole-number floats are written as integers""" + sample = np.array([100, 2500, 70000]) + symbol = ["N", "V", "N"] + wfdb.wrann("int", "atr", sample, symbol=symbol, fs=360, write_dir=temp_path) + + float_sample = sample.astype("float64") + wfdb.wrann("float", "atr", float_sample, symbol=symbol, fs=360, write_dir=temp_path) + assert float_sample.dtype == np.float64 + + with ( + open(os.path.join(temp_path, "int.atr"), "rb") as f1, + open(os.path.join(temp_path, "float.atr"), "rb") as f2, + ): + assert f1.read() == f2.read() + + +@pytest.mark.parametrize("sample", [[10.0, 20.5], [10.0, np.nan], [10.0, np.inf]]) +def test_wrann_non_whole_samples(temp_path, sample): + with pytest.raises(ValueError, match="whole numbers"): + wfdb.wrann("x", "atr", np.array(sample), symbol=["N", "N"], write_dir=temp_path) From db029c90247a6323d655c28b69bb3a5f48444486 Mon Sep 17 00:00:00 2001 From: Tom Pollard Date: Mon, 2 Nov 2026 10:00:56 -0500 Subject: [PATCH 2/3] Check for whole numbers without floating-point exceptions On x86, np.mod(inf, 1) sets the invalid flag, which the tests turn into a FloatingPointError. Reject NaN and infinity first, and compare finite values with their rounded values instead. --- src/wfdb/io/annotation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wfdb/io/annotation.py b/src/wfdb/io/annotation.py index 66f140a6..39e1368e 100644 --- a/src/wfdb/io/annotation.py +++ b/src/wfdb/io/annotation.py @@ -567,8 +567,12 @@ def check_field(self, field): elif field == "sample": # Sample numbers may be stored as floats (for example, the # output of a detector), as long as they are whole numbers. + # NaN and infinity are rejected before rounding, so that no + # floating-point exception is raised for them. if np.issubdtype(self.sample.dtype, np.floating): - if not np.all(np.mod(self.sample, 1) == 0): + if not np.all(np.isfinite(self.sample)) or np.any( + self.sample != np.round(self.sample) + ): raise ValueError( "The 'sample' field must only contain whole numbers" ) From 4713d774197c9e06417ee372849d58fa5240405a Mon Sep 17 00:00:00 2001 From: Tom Pollard Date: Wed, 4 Nov 2026 10:15:02 -0500 Subject: [PATCH 3/3] Reject non-whole numbers in object arrays in wrann Converting an object array of sample numbers to integers truncated values such as 20.5 without an error. Check that the conversion doesn't change any value. --- src/wfdb/io/annotation.py | 13 +++++++++++++ tests/test_annotation.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/wfdb/io/annotation.py b/src/wfdb/io/annotation.py index 39e1368e..8e41ad44 100644 --- a/src/wfdb/io/annotation.py +++ b/src/wfdb/io/annotation.py @@ -576,6 +576,19 @@ def check_field(self, field): raise ValueError( "The 'sample' field must only contain whole numbers" ) + # Object arrays may hold any type of number. Check that + # converting them to integers does not change any value. + elif self.sample.dtype == object: + try: + int_sample = self.sample.astype(np.int64) + except (TypeError, ValueError, OverflowError): + raise ValueError( + "The 'sample' field must only contain whole numbers" + ) from None + if np.any(int_sample != self.sample): + raise ValueError( + "The 'sample' field must only contain whole numbers" + ) if len(self.sample) == 1: sampdiffs = np.array([self.sample[0]]) elif len(self.sample) > 1: diff --git a/tests/test_annotation.py b/tests/test_annotation.py index c8afd22a..29f04030 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -1,5 +1,7 @@ import os import re +from decimal import Decimal +from fractions import Fraction import numpy as np import pandas as pd @@ -380,3 +382,34 @@ def test_wrann_float_samples(temp_path): def test_wrann_non_whole_samples(temp_path, sample): with pytest.raises(ValueError, match="whole numbers"): wfdb.wrann("x", "atr", np.array(sample), symbol=["N", "N"], write_dir=temp_path) + + +@pytest.mark.parametrize( + "sample", [[10, 20], [10.0, 20.0], [np.float32(10), Fraction(20)]] +) +def test_wrann_object_samples(temp_path, sample): + """Whole numbers in an object array are written as integers""" + wfdb.wrann( + "x", + "atr", + np.array(sample, dtype=object), + symbol=["N", "N"], + write_dir=temp_path, + ) + ann = wfdb.rdann(os.path.join(temp_path, "x"), "atr") + assert ann.sample.tolist() == [10, 20] + + +@pytest.mark.parametrize( + "sample", + [[10.0, 20.5], [10, Fraction(41, 2)], [10, Decimal("20.5")], [10.0, np.nan]], +) +def test_wrann_non_whole_object_samples(temp_path, sample): + with pytest.raises(ValueError, match="whole numbers"): + wfdb.wrann( + "x", + "atr", + np.array(sample, dtype=object), + symbol=["N", "N"], + write_dir=temp_path, + )