From e2752d5dbba53e92fd6f1004fa09b33ad33312b0 Mon Sep 17 00:00:00 2001 From: Tom Pollard Date: Sun, 8 Nov 2026 17:25:18 -0500 Subject: [PATCH] Compute to_dataframe timestamps without drift to_dataframe built its index by repeatedly adding pd.Timedelta(seconds=1/fs), which pandas cuts to whole nanoseconds. For sampling frequencies whose interval is not a whole number of nanoseconds, the error added up over the record: about 0.66 ms by the end of a 30-hour MIMIC-IV record at 62.4725 Hz. Compute each sample's offset directly as round(i * 1e9 / fs) nanoseconds, so every timestamp is within 0.5 ns, and add base_datetime if it is set. --- changelog.d/to-dataframe-drift.rst | 2 ++ src/wfdb/io/record.py | 26 ++++++++++++-------------- tests/test_record.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 changelog.d/to-dataframe-drift.rst diff --git a/changelog.d/to-dataframe-drift.rst b/changelog.d/to-dataframe-drift.rst new file mode 100644 index 00000000..b273c357 --- /dev/null +++ b/changelog.d/to-dataframe-drift.rst @@ -0,0 +1,2 @@ +**Fix drifting timestamps in to_dataframe** + ``Record.to_dataframe`` now computes the timestamp of each sample directly. Previously the sample interval was truncated to whole nanoseconds and added up, so for sampling frequencies such as 360 Hz or 62.4725 Hz the timestamps drifted by up to about a millisecond over a long record. diff --git a/src/wfdb/io/record.py b/src/wfdb/io/record.py index 5b8afe44..6ef0a1cf 100644 --- a/src/wfdb/io/record.py +++ b/src/wfdb/io/record.py @@ -1012,24 +1012,22 @@ def _arrange_fields(self, channels, sampfrom, smooth_frames): def to_dataframe(self) -> pd.DataFrame: """Create a dataframe containing the data from this record. - Returns ------- - A dataframe, with sig_name in the columns. The index is a DatetimeIndex - if both base_date and base_time were set, otherwise a TimedeltaIndex. + pandas.DataFrame + A dataframe, with sig_name in the columns. The index is a + DatetimeIndex if both base_date and base_time were set, + otherwise a TimedeltaIndex. Each timestamp is rounded to the + nearest nanosecond. + """ + # Compute the time of each sample directly, rather than adding up a + # sample interval cut to whole nanoseconds, which drifts over + # long records. + offsets_ns = np.round(np.arange(self.sig_len) * (1e9 / self.fs)) + index = pd.to_timedelta(offsets_ns.astype("int64"), unit="ns") if self.base_datetime is not None: - index = pd.date_range( - start=self.base_datetime, - periods=self.sig_len, - freq=pd.Timedelta(seconds=1 / self.fs), - ) - else: - index = pd.timedelta_range( - start=pd.Timedelta(0), - periods=self.sig_len, - freq=pd.Timedelta(seconds=1 / self.fs), - ) + index = pd.Timestamp(self.base_datetime) + index if self.p_signal is not None: data = self.p_signal diff --git a/tests/test_record.py b/tests/test_record.py index 72b1b043..a9c4eb31 100644 --- a/tests/test_record.py +++ b/tests/test_record.py @@ -940,6 +940,35 @@ def test_to_dataframe(self): ) assert np.array_equal(record.p_signal, df.values) + @pytest.mark.parametrize( + "base_datetime", [None, datetime.datetime(2148, 8, 16, 9, 0, 17, 566000)] + ) + def test_to_dataframe_no_drift(self, base_datetime): + """Timestamps do not drift when 1/fs is not a whole number of ns.""" + fs = 62.4725 + sig_len = 6661120 + record = wfdb.Record( + p_signal=np.zeros((sig_len, 1)), + fs=fs, + sig_len=sig_len, + sig_name=["I"], + base_date=None if base_datetime is None else base_datetime.date(), + base_time=None if base_datetime is None else base_datetime.time(), + ) + + index = record.to_dataframe().index + + start = ( + pd.Timedelta(0) if base_datetime is None else pd.Timestamp(base_datetime) + ) + expected_ns = np.arange(sig_len) * (1e9 / fs) + error_ns = (index - start).to_numpy().astype("int64") - expected_ns + assert np.abs(error_ns).max() <= 0.5 + if base_datetime is None: + assert isinstance(index, pd.TimedeltaIndex) + else: + assert isinstance(index, pd.DatetimeIndex) + def test_header_with_non_utf8(self): """Ignores non-utf8 characters in the header part.""" record = wfdb.rdrecord("sample-data/test_generator_2")