Skip to content

Commit cc9f94f

Browse files
authored
Handle all-NaN channels in calc_adc_params (#628)
Fixes #485. Writing a record where one channel is entirely NaN fails: ```python import numpy as np, wfdb wfdb.wrsamp("xxx", fs=500, units=["mV"], sig_name=["I"], p_signal=np.array([[np.nan], [np.nan]]), fmt=["16"]) # RuntimeWarning: All-NaN slice encountered (x2) # ValueError: cannot convert float NaN to integer ``` Both causes are the ones described in the issue: - `calc_adc_gain_baseline` checked `pmin == np.nan`, which is always False, so the all-NaN branch never ran. It now uses `np.isnan(pmin)`. - `calc_adc_params` used `np.nanmin`/`np.nanmax`, which warn on all-NaN channels. It now uses `np.fmin.reduce`/`np.fmax.reduce` (as suggested in the issue), which give the same result without the warning, for both `p_signal` and `e_p_signal`. An all-NaN channel now gets `adc_gain=1`, `baseline=1` (the existing intent of that branch), is written as the NaN sample value, and reads back as NaN. Testing: - New `TestSignal.test_all_nan_channel` (runs with RuntimeWarning as an error): checks `calc_adc_params` for `p_signal` and `e_p_signal`, then a `wrsamp`/`rdrecord` round trip with one all-NaN channel and one normal channel. It fails on `main` and passes with this change. - `uv run pytest -m "not network"`: 335 passed. - `ruff format --check`, `ruff check` and `towncrier build --draft` pass. Changelog entry added in `changelog.d/`.
2 parents 9336f76 + 3340b53 commit cc9f94f

3 files changed

Lines changed: 43 additions & 6 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
**Fix writing channels that are entirely NaN**
2+
``wrsamp`` and ``calc_adc_params`` no longer fail with ``cannot convert float NaN to integer`` when a channel contains only NaN values, and no longer emit an "All-NaN slice encountered" warning for such channels.

‎src/wfdb/io/_signal.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,7 @@ def calc_adc_gain_baseline(self, ch, minvals, maxvals):
747747
# Figure out digital samples used to store physical samples
748748

749749
# If the entire signal is NAN, gain/baseline won't be used
750-
if pmin == np.nan:
750+
if np.isnan(pmin):
751751
adc_gain = 1
752752
baseline = 1
753753
# If the signal is just one value, store one digital value.
@@ -828,9 +828,10 @@ def calc_adc_params(self):
828828
raise ValueError("Signal contains inf. Cannot perform adc.")
829829

830830
# min and max ignoring nans, unless whole channel is NAN.
831-
# copy as 64-bit float to avoid precision loss
832-
minvals = np.nanmin(self.p_signal, axis=0).astype("float64")
833-
maxvals = np.nanmax(self.p_signal, axis=0).astype("float64")
831+
# fmin/fmax behave like nanmin/nanmax without warning about
832+
# all-NaN channels. Copy as 64-bit float to avoid precision loss.
833+
minvals = np.fmin.reduce(self.p_signal, axis=0).astype("float64")
834+
maxvals = np.fmax.reduce(self.p_signal, axis=0).astype("float64")
834835

835836
for ch in range(np.shape(self.p_signal)[1]):
836837
adc_gain, baseline = self.calc_adc_gain_baseline(ch, minvals, maxvals)
@@ -842,8 +843,8 @@ def calc_adc_params(self):
842843
maxvals = []
843844
for ch in self.e_p_signal:
844845
# Copy as float for precise arithmetic
845-
minvals.append(float(np.nanmin(ch)))
846-
maxvals.append(float(np.nanmax(ch)))
846+
minvals.append(float(np.fmin.reduce(ch)))
847+
maxvals.append(float(np.fmax.reduce(ch)))
847848

848849
if any(x == math.inf for x in minvals) or any(
849850
x == math.inf for x in maxvals

‎tests/test_record.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1615,6 +1615,40 @@ def test_adc_gain_min_boundary(self, temp_path):
16151615
expected_gain = (32767 - (-2147483648)) / (base_value + 1.5)
16161616
np.testing.assert_allclose(record.adc_gain[0], expected_gain, rtol=1e-3)
16171617

1618+
@pytest.mark.filterwarnings("error::RuntimeWarning")
1619+
def test_all_nan_channel(self, temp_path):
1620+
"""Write and read back a signal with a channel that is entirely NaN."""
1621+
p_signal = np.array(
1622+
[
1623+
[np.nan, 0.5],
1624+
[np.nan, -0.25],
1625+
[np.nan, np.nan],
1626+
]
1627+
)
1628+
1629+
record = wfdb.Record(p_signal=p_signal, fmt=["16", "16"])
1630+
adc_gain, baseline = record.calc_adc_params()
1631+
assert adc_gain[0] == 1
1632+
assert baseline[0] == 1
1633+
1634+
record = wfdb.Record(e_p_signal=list(p_signal.transpose()), fmt=["16", "16"])
1635+
assert record.calc_adc_params() == (adc_gain, baseline)
1636+
1637+
wfdb.wrsamp(
1638+
"test_all_nan_channel",
1639+
fs=250,
1640+
sig_name=["I", "II"],
1641+
units=["mV", "mV"],
1642+
p_signal=p_signal,
1643+
fmt=["16", "16"],
1644+
write_dir=temp_path,
1645+
)
1646+
record = wfdb.rdrecord(os.path.join(temp_path, "test_all_nan_channel"))
1647+
assert np.isnan(record.p_signal[:, 0]).all()
1648+
np.testing.assert_allclose(
1649+
record.p_signal[:, 1], p_signal[:, 1], atol=1 / record.adc_gain[1]
1650+
)
1651+
16181652

16191653
class TestSigCohesion:
16201654
"""Tests for the digital range check in check_sig_cohesion."""

0 commit comments

Comments
 (0)