Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/run-pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,23 +77,23 @@ jobs:
timeout-minutes: 350
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"]
python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]
fail-fast: false
env:
# `core` = fast lane, deselects `db`-marked tests (no database downloads);
# `full` = the whole suite. PRs and dev pushes only need the fast lane;
# the full suite runs nightly and on manual dispatch, so that slow or
# flaky database downloads cannot eat the whole CI budget.
# The nightly full suite runs on a SUBSET of versions only
# (3.10/3.12/3.14/3.15): db-download-heavy full runs on a version
# (3.11/3.12/3.14/3.15): db-download-heavy full runs on a version
# subset exceeded the 350-minute job timeout on slow nights (2026-10-11:
# 4/5 jobs killed mid-pytest; GitHub's hard per-job cap is 360 min, so
# the timeout cannot be raised further). 3.12 is kept in the subset
# because it is the primary version and the only lane whose coverage
# is uploaded -- dropping it would silence all `db`-marked coverage.
# Manual dispatch with suite=full still runs the full suite on ALL
# versions.
SUITE: ${{ (github.event_name == 'workflow_dispatch') && inputs.suite || (github.event_name == 'schedule') && (contains(fromJSON('["3.10", "3.12", "3.14", "3.15"]'), matrix.python-version) && 'full' || 'core') || 'core' }}
SUITE: ${{ (github.event_name == 'workflow_dispatch') && inputs.suite || (github.event_name == 'schedule') && (contains(fromJSON('["3.11", "3.12", "3.14", "3.15"]'), matrix.python-version) && 'full' || 'core') || 'core' }}
steps:
- uses: actions/checkout@v7
- name: Clear unnecessary system components
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ format). Releases up to and including 0.0.33 are preserved on the

### Changed

- Python 3.10 (EOL October 2026) is dropped from the support matrix:
`requires-python >= 3.11`, the 3.10 classifier removed, the CI
matrix is now `3.11 / 3.12 / 3.13 / 3.14 / 3.15`, and the nightly
full-suite subset becomes `3.11 / 3.12 / 3.14 / 3.15`.
- The pandas upper bound is removed (`pandas >= 2.2.2`): torch_ecg's
own code paths are verified against pandas 3.1 (no
`set_index(<str column>.values)` patterns, and the readers never
pass `summarize_labels=True` to `wfdb.rdann`); the one pandas-3
crash reachable from a torch_ecg workflow lives in wfdb <= 4.3.1
and is shimmed, see the Fixed section.
- Import-time side effects removed (import purity): the AHA aux-data
module no longer leaves `future.no_silent_downcasting` flipped in the
process-global pandas options (saved and restored around the table
Expand All @@ -32,6 +42,16 @@ format). Releases up to and including 0.0.33 are preserved on the

### Fixed

- wfdb <= 4.3.1 `Annotation.get_contained_labels` raises `TypeError`
under pandas >= 3 (`DataFrame.set_index` receives an
`ArrowStringArray` from `<string column>.values`), breaking e.g.
`rdann(..., summarize_labels=True)` on the annotation objects our
readers return. A guarded shim in `torch_ecg.databases` now applies
the same fix as MIT-LCP/wfdb-python#575 (index by column name, with
`drop=False` to preserve the original output schema) whenever
pandas >= 3 is installed and the wfdb in use still contains the
buggy pattern; any wfdb release carrying the upstream fix disables
the shim automatically.
- A second race in the one-time `database_citation.csv` migration (the
first one, the `unlink` race, was fixed in 0.0.33): when several
processes import the package concurrently, one could `read_csv` the
Expand Down
2 changes: 1 addition & 1 deletion docs/source/install.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
Installation instructions
^^^^^^^^^^^^^^^^^^^^^^^^^

:code:`torch_ecg` requires **Python 3.10 – 3.15** and is available through pip:
:code:`torch_ecg` requires **Python 3.11 – 3.15** and is available through pip:

.. code-block:: bash

Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ dynamic = ["version"]
description = "A Deep Learning Framework for ECG Processing Tasks Based on PyTorch"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
requires-python = ">=3.11"
authors = [
{ name = "DeepPSP", email = "wenh06@gmail.com" },
]
Expand All @@ -17,7 +17,6 @@ classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand All @@ -35,7 +34,7 @@ dependencies = [
"einops",
"matplotlib",
"numpy",
"pandas < 3.0.0",
"pandas >= 2.2.2",
"pyarrow",
"PyWavelets",
"requests",
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
numpy
scipy
pandas < 3.0.0
pandas >= 2.2.2
pyarrow
scikit-learn
boto3
Expand Down
100 changes: 100 additions & 0 deletions test/test_databases/test_wfdb_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Test the wfdb pandas-3 compatibility shim."""

import numpy as np
import pandas as pd
import pytest

from torch_ecg.databases._wfdb_compat import apply_wfdb_pandas3_compat


def _make_annotation(**overrides):
"""Build a minimal wfdb Annotation without touching any files."""
wfdb = pytest.importorskip("wfdb")
fields = dict(
record_name="test",
extension="atr",
sample=np.array([0, 100, 200], dtype=int),
symbol=["N", "N", "A"],
aux_note=[None, None, None],
label_store=None,
)
fields.update(overrides)
return wfdb.Annotation(**fields)


class TestWfdbPandas3Compat:
def test_symbol_branch(self):
"""The symbol branch: works under pandas < 3 natively, and under
pandas >= 3 via the shim (or the upstream fix,
MIT-LCP/wfdb-python#575); the symbol column must survive as a
column (drop=False preserves the pandas<3 output schema).
"""
ann = _make_annotation()
labels = ann.get_contained_labels(inplace=False)
assert isinstance(labels, pd.DataFrame)
assert "symbol" in labels.columns
assert len(labels) == 2 # N and A
assert labels["n_occurrences"].sum() == 3

def test_label_store_branch(self):
"""The label_store branch (no temporary index is set)."""
ann = _make_annotation(symbol=None, label_store=[1, 1, 1])
labels = ann.get_contained_labels(inplace=False)
assert len(labels) == 1
assert labels["n_occurrences"].iloc[0] == 3

def test_description_branch(self):
"""The description branch (string column, indexed by name)."""
ann = _make_annotation(symbol=None, description=["Normal beat"] * 3)
labels = ann.get_contained_labels(inplace=False)
assert "description" in labels.columns
assert labels["n_occurrences"].sum() == 3

def test_custom_labels_triplets(self):
"""Custom labels supplied as a list of triplets."""
ann = _make_annotation(
custom_labels=[(40, "X1", "Custom one"), (41, "X2", "Custom two")],
label_store=[40, 40, 41],
)
labels = ann.get_contained_labels(inplace=False)
assert "X1" in labels["symbol"].tolist()
assert labels.loc[40, "n_occurrences"] == 2

def test_custom_labels_dataframe(self):
"""Custom labels supplied as a DataFrame (exercises its
label_store re-indexing)."""
custom = pd.DataFrame(
{
"label_store": [40, 41],
"symbol": ["X1", "X2"],
"description": ["Custom one", "Custom two"],
}
)
ann = _make_annotation(custom_labels=custom, label_store=[40, 40, 41])
labels = ann.get_contained_labels(inplace=False)
assert labels.loc[40, "n_occurrences"] == 2
assert labels.loc[41, "n_occurrences"] == 1

def test_no_labels_raises(self):
"""An annotation without any label field raises."""
ann = _make_annotation(symbol=None, description=None, label_store=None)
with pytest.raises(Exception):
ann.get_contained_labels(inplace=False)

def test_inplace_true(self):
"""inplace=True sets the attribute and returns None."""
ann = _make_annotation()
ret = ann.get_contained_labels(inplace=True)
assert ret is None
assert isinstance(ann.contained_labels, pd.DataFrame)
assert "symbol" in ann.contained_labels.columns

def test_patch_is_idempotent(self):
"""Applying the compat twice is safe, and the guard skips
fixed/irrelevant environments without raising."""
apply_wfdb_pandas3_compat()
apply_wfdb_pandas3_compat()
# re-run a functional assertion after the double application
ann = _make_annotation()
labels = ann.get_contained_labels(inplace=False)
assert "symbol" in labels.columns
141 changes: 141 additions & 0 deletions torch_ecg/databases/_wfdb_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Compatibility shims for third-party dependencies of the database readers.

Currently one shim is provided:

- :func:`apply_wfdb_pandas3_compat` -- repairs
``wfdb.Annotation.get_contained_labels`` under pandas >= 3.
"""

import inspect
import logging

__all__ = [
"apply_wfdb_pandas3_compat",
]


_logger = logging.getLogger(__name__)


def apply_wfdb_pandas3_compat() -> None:
"""Repair ``Annotation.get_contained_labels`` for pandas >= 3.

Under pandas >= 3 (Arrow-backed strings by default), wfdb <= 4.3.1
raises ``TypeError`` inside ``get_contained_labels`` when the
annotation carries ``symbol`` or ``description``::

label_map.set_index(label_map["symbol"].values, inplace=True)
# TypeError: ... Received column of type ArrowStringArray

``torch_ecg`` itself never calls this method (its readers do not
pass ``summarize_labels=True`` to ``rdann``), but the method is a
public API of the ``Annotation`` objects our readers return, so
end users can reach it. Apply the same two-line fix as
`MIT-LCP/wfdb-python#575 <https://github.com/MIT-LCP/wfdb-python/pull/575>`__
(pass the column *name* to ``set_index`` instead of the Arrow
string array) until a wfdb release carries it.

The patch is applied only when both hold:

- pandas >= 3 is installed;
- the installed wfdb still contains the buggy pattern (checked
against the method source, so any upstream release containing
the fix disables this shim automatically, regardless of
version numbering).
"""

try:
import numpy as np
import pandas as pd
import wfdb
from wfdb.io.annotation import Annotation, ann_label_table, label_triplets_to_df
except ImportError:
return

if int(pd.__version__.split(".")[0]) < 3:
return

source = inspect.getsource(Annotation.get_contained_labels)
if 'label_map["symbol"].values' not in source and "'symbol'].values" not in source:
# the installed wfdb is already fixed (or restructured); nothing to do
return

def get_contained_labels(self, inplace: bool = True):
"""Patched ``Annotation.get_contained_labels`` (pandas >= 3).

Identical to the wfdb implementation, except that string
columns are passed to ``DataFrame.set_index`` by name instead
of via ``.values`` (which yields an unindexable
``ArrowStringArray`` under pandas >= 3). See
MIT-LCP/wfdb-python#575.
"""
if self.custom_labels is not None:
self.check_field("custom_labels")

# Create the label map
label_map = ann_label_table.copy()

# Convert the tuple triplets into a pandas dataframe if needed
if isinstance(self.custom_labels, (list, tuple)):
custom_labels = label_triplets_to_df(self.custom_labels)
elif isinstance(self.custom_labels, pd.DataFrame):
# Set the index just in case it doesn't already match the label_store
# (`label_store` is an integer column; `.values` is safe here).
# Reassignment instead of inplace=True: pandas 3.1 deprecates
# the inplace keyword (removed in pandas 4).
self.custom_labels = self.custom_labels.set_index(self.custom_labels["label_store"].values)
custom_labels = self.custom_labels
else:
custom_labels = None

# Merge the standard WFDB labels with the custom labels.
# custom labels values overwrite standard WFDB if overlap.
if custom_labels is not None:
for i in custom_labels.index:
label_map.loc[i] = custom_labels.loc[i]

# Get the labels using one of the features
if self.label_store is not None:
index_vals = set(self.label_store)
reset_index = False
counts = np.unique(self.label_store, return_counts=True)
elif self.symbol is not None:
index_vals = set(self.symbol)
# `drop=False` keeps the column in the output, matching the
# behavior of the original array-index (pandas < 3) path;
# reassignment instead of inplace=True (deprecated in pandas 3.1)
label_map = label_map.set_index("symbol", drop=False)
reset_index = True
counts = np.unique(self.symbol, return_counts=True)
elif self.description is not None:
index_vals = set(self.description)
label_map = label_map.set_index("description", drop=False)
reset_index = True
counts = np.unique(self.description, return_counts=True)
else:
raise Exception("No annotation labels contained in object")

contained_labels = label_map.loc[list(index_vals), :]

# Add the counts
for i in range(len(counts[0])):
contained_labels.loc[counts[0][i], "n_occurrences"] = counts[1][i]
contained_labels["n_occurrences"] = pd.to_numeric(contained_labels["n_occurrences"], downcast="integer")

if reset_index:
# `label_store` is an integer column; `.values` is safe here
contained_labels = contained_labels.set_index(contained_labels["label_store"].values)

if inplace:
self.contained_labels = contained_labels
return
else:
return contained_labels

Annotation.get_contained_labels = get_contained_labels
_logger.debug(
"patched wfdb (%s) Annotation.get_contained_labels for pandas %s " "(upstream fix: MIT-LCP/wfdb-python#575)",
wfdb.__version__,
pd.__version__,
)
return
3 changes: 3 additions & 0 deletions torch_ecg/databases/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@
from ..utils.download import get_latest_version_from_physionet_s3, http_get
from ..utils.misc import CitationMixin, ReprMixin, dict_to_str, get_record_list_recursive, init_logger
from ._edf import _EdfReader
from ._wfdb_compat import apply_wfdb_pandas3_compat
from .aux_data import get_physionet_dbs

apply_wfdb_pandas3_compat()

__all__ = [
"WFDB_Beat_Annotations",
"WFDB_Non_Beat_Annotations",
Expand Down
Loading