diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..3dcbd965 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,23 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + docs: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r docs/requirements.txt + - name: Build documentation + run: | + cd docs + make html diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index 164bdd28..00000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,80 +0,0 @@ -# Link repository with GitHub Actions -# https://docs.github.com/en/actions/learn-github-actions/introduction-to-github-actions - -name: run-tests -on: - push: - branches: - - main - pull_request: - branches: - - main - -# Set the language, install dependencies, and run the tests -jobs: - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [windows-latest, ubuntu-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9", "3.10"] - steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip poetry - pip install ".[dev]" - - name: Install libsndfile - if: startsWith(matrix.os, 'ubuntu') - run: | - sudo apt-get install -y libsndfile1 - - name: Run tests - run: pytest - - name: Validate poetry file - run: poetry check - - name: Check source code format - run: black --check --diff . - - test-deb10-i386: - runs-on: ubuntu-latest - container: i386/debian:10 - steps: - - name: Install dependencies - run: | - apt-get update - apt-get install -y --no-install-recommends \ - python3-matplotlib \ - python3-numpy \ - python3-pandas \ - python3-requests \ - python3-scipy \ - python3-soundfile \ - python3-pytest \ - git - - # Note: "actions/checkout@v2" requires libstdc++6:amd64 to be - # installed in the container. To keep things simple, use - # "actions/checkout@v1" instead. - # https://github.com/actions/checkout/issues/334 - - uses: actions/checkout@v1 - - - name: Run tests - run: | - pytest-3 - - build-documentation: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r docs/requirements.txt - - name: Build documentation - run: | - cd docs - make html diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..66cb211b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,65 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + NPY_PROMOTION_STATE: weak_and_warn + +jobs: + test: + name: Python ${{ matrix.python-version }} / ${{ matrix.os }} / ${{ matrix.numpy }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + numpy: ["numpy"] + include: + - os: ubuntu-latest + python-version: "3.9" + numpy: "numpy==1.26.4" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - name: Install Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + - name: Install libsndfile + if: startsWith(matrix.os, 'ubuntu') + run: sudo apt-get install -y libsndfile1 + - name: Run tests + run: uv run --with ${{ matrix.numpy }} --extra dev pytest + - name: Check style + run: uv run --extra dev black --check --diff . + + test-deb10-i386: + name: Python 3.7 on Debian 10 i386 + runs-on: ubuntu-latest + container: i386/debian:10 + steps: + - name: Install dependencies + run: | + apt-get update + apt-get install -y --no-install-recommends \ + python3-matplotlib \ + python3-numpy \ + python3-pandas \ + python3-requests \ + python3-scipy \ + python3-soundfile \ + python3-pytest \ + git + python3 --version + # Note: "actions/checkout@v2" requires libstdc++6:amd64 to be + # installed in the container. To keep things simple, use + # "actions/checkout@v1" instead. + # https://github.com/actions/checkout/issues/334 + - uses: actions/checkout@v1 + - name: Run tests + run: pytest-3 diff --git a/.gitignore b/.gitignore index a9de35ba..ebdd3e46 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,3 @@ target/ # pyenv .python-version - -# Poetry -poetry.lock diff --git a/.readthedocs.yml b/.readthedocs.yml index 9fedd977..7d45d867 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -5,7 +5,7 @@ version: 2 build: os: "ubuntu-20.04" tools: - python: "3.9" + python: "3.10" # Build from the docs/ directory with Sphinx sphinx: diff --git a/DEVELOPING.md b/DEVELOPING.md index f78508b0..cf3ec53b 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -16,20 +16,23 @@ black . ## Package and Dependency Management -This project uses [poetry](https://python-poetry.org/docs/) for package management and distribution. +This project uses [uv](https://docs.astral.sh/uv/) for package management and distribution. -Development dependencies are specified as optional dependencies, and then added to the "dev" extra group in the [pyproject.toml](./pyproject.toml) file. +Development dependencies are specified as optional dependencies, at least for now and until [development dependencies](https://docs.astral.sh/uv/concepts/dependencies/#development-dependencies) become more widely used. ```sh -# Do NOT use: poetry add --dev -poetry add --optional +uv add --optional ``` -The `[tool.poetry.dev-dependencies]` attribute is NOT used because of a [limitation](https://github.com/python-poetry/poetry/issues/3514) that prevents these dependencies from being pip installable. Therefore, dev dependencies are not installed when purely running `poetry install`, and the `--no-dev` flag has no meaning in this project. - ## Creating Distributions -Make sure the versions in [version.py](./wfdb/version.py) and [pyproject.toml](./pyproject.toml) are updated and kept in sync. +1. Bump the version in [version.py](./wfdb/version.py). + +2. Add a summary of the changes to [the changelog](https://github.com/MIT-LCP/wfdb-python/blob/main/docs/changes.rst). You may also need to update [the documentation](https://github.com/MIT-LCP/wfdb-python/tree/main/docs). For example, if function arguments have been updated, this change will need to be captured. Open a pull request to merge these changes to the main branch. + +3. After the pull requests above have been merged, go to https://github.com/MIT-LCP/wfdb-python/releases and click "Draft new release" to create a new tag/release of the package. Set the tag to the new version number and draft the release notes (or click "Generate release notes"!). + +4. Publish the project to PyPI, the [Python Package Index](https://pypi.org/project/wfdb/). It may be useful to publish to testpypi and preview the changes before publishing to PyPi. However, the project dependencies likely will not be available when trying to install from there. @@ -47,10 +50,10 @@ poetry config pypi-token.test-pypi To build and upload a new distribution: ```sh -poetry build +uv build -poetry publish -r test-pypi -poetry publish +uv publish --publish-url https://test.pypi.org/legacy/ +uv publish ``` ## Creating Documentation diff --git a/README.md b/README.md index 564a32d8..d05eb73b 100644 --- a/README.md +++ b/README.md @@ -21,36 +21,34 @@ See the [demo.ipynb](https://github.com/MIT-LCP/wfdb-python/blob/main/demo.ipynb ## Installation -The distribution is hosted on PyPI at: . The package can be directly installed from PyPI using either pip or poetry: +The distribution is hosted on PyPI at: . The package can be directly installed from PyPI using pip: ```sh pip install wfdb -poetry add wfdb ``` -On Linux systems, accessing _compressed_ WFDB signal files requires installing `libsndfile`, by running `sudo apt-get install libsndfile1` or `sudo yum install libsndfile`. Support for Apple M1 systems is a work in progess (see and ). +On some less-common systems, you may need to install `libsndfile` separately. See the [soundfile installation notes](https://pypi.org/project/soundfile/) for more information. The development version is hosted at: . This repository also contains demo scripts and example data. To install the development version, clone or download the repository, navigate to the base directory, and run: ```sh -# Without dev dependencies pip install . -poetry install +``` -# With dev dependencies -pip install ".[dev]" -poetry install -E dev +If you intend to make changes to the repository, you can install additional packages that are useful for development by running: -# Install the dependencies only -poetry install -E dev --no-root +```sh +pip install ".[dev]" ``` -**See the [note](https://github.com/MIT-LCP/wfdb-python/blob/main/DEVELOPING.md#package-and-dependency-management) about dev dependencies.** - ## Developing Please see the [DEVELOPING.md](https://github.com/MIT-LCP/wfdb-python/blob/main/DEVELOPING.md) document for contribution/development instructions. +### Creating a new release + +For guidance on creating a new release, see: https://github.com/MIT-LCP/wfdb-python/blob/main/DEVELOPING.md#creating-distributions + ## Citing When using this resource, please cite the software [publication](https://physionet.org/content/wfdb-python/) on PhysioNet. diff --git a/docs/changes.rst b/docs/changes.rst index 061f4877..13dc7fc9 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,6 +5,40 @@ This page lists recent changes in the `wfdb` package (since version 4.0.0) that .. _development repository: https://github.com/MIT-LCP/wfdb-python +Version 4.2.0 (Jan 2025) +----------------------------- + +**Add support for Numpy 2.0** + Fixes were added to address [changes to type promotion](https://numpy.org/devdocs/numpy_2_0_migration_guide.html#changes-to-numpy-data-type-promotion) that led to overflow errors (e.g. https://github.com/MIT-LCP/wfdb-python/issues/493). + +**Fix UnboundLocalError in GQRS algorithm** + Fixes the GQRS algorithm to address an `UnboundLocalError`. + +**Support write directory in `csv_to_wfdb`** + `write_dir` can now be specified when calling `csv_to_wfdb`. + +**Use uv for for package management** + Moves package management from poetry to uv. + +**Fix misordered arguments in `util.lines_to_file`** + Fixes misordered arguments in `util.lines_to_file`. + +**Allow signals to be written with unique samples per frame** + Adds capability to write signal with unique samps_per_frame to `wfdb.io.wrsamp`. + +**Allow expanded physical signal in `calc_adc_params`** + Updates `calc_adc_params` to allow an expanded physical signal to be passed. Previously only a non-expanded signal was allowed. + +**Allow selection of channels when converting to EDF** + Fixes the `wfdb-to_edf()` function to support an optional channels argument. + +**Migrates Ricker wavelet from SciPy to WFDB after deprecation** + The Ricker wavelet (`scipy.signal.ricker`) was removed in SciPy v1.15, so the original implementation was migrated to the WFDB package. + +**Miscellaneous style and typing fixes** + Various fixes were made to code style and handling of data types. + + Version 4.1.2 (June 2023) ----------------------------- diff --git a/docs/conf.py b/docs/conf.py index 1a236b0b..6108549b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -44,7 +44,10 @@ def __getattr__(cls, name): # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc"] +extensions = [ + "sphinx.ext.autodoc", + "numpydoc", +] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/requirements.txt b/docs/requirements.txt index 1cd79c18..3ff46cd5 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +numpydoc<1.6 sphinx==4.5.0 sphinx_rtd_theme==1.0.0 -readthedocs-sphinx-search==0.1.1 +readthedocs-sphinx-search==0.3.2 diff --git a/pyproject.toml b/pyproject.toml index 406cf72f..f550ebd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,37 +1,49 @@ -[tool.poetry] +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] name = "wfdb" -version = "4.1.2" description = "The WFDB Python package: tools for reading, writing, and processing physiologic signals and annotations." -authors = ["The Laboratory for Computational Physiology "] +authors = [{name = "The Laboratory for Computational Physiology", email = "contact@physionet.org"}] +license = {text = "MIT License"} readme = "README.md" +requires-python = ">= 3.9" +dependencies = [ + "numpy >= 1.26.4", + "scipy >= 1.13.0", + "pandas >= 2.2.3", + "soundfile >= 0.10.0", + "matplotlib >= 3.2.2", + "requests >= 2.8.1", +] +dynamic = ["version"] + +[project.optional-dependencies] +dev = [ + "pytest >= 7.1.1", + "pytest-xdist >= 2.5.0", + "pylint >= 2.13.7", + "black >= 22.3.0", + "sphinx >= 4.5.0", +] + +[project.urls] homepage = "https://github.com/MIT-LCP/wfdb-python/" repository = "https://github.com/MIT-LCP/wfdb-python/" documentation = "https://wfdb.readthedocs.io/" -license = "MIT" - -[tool.poetry.dependencies] -python = ">=3.7" -numpy = ">=1.10.1" -scipy = ">=1.0.0" -pandas = ">=1.3.0" -SoundFile = ">=0.10.0" -matplotlib = ">=3.2.2" -requests = ">=2.8.1" -pytest = {version = ">=7.1.1", optional = true} -pytest-xdist = {version = ">=2.5.0", optional = true} -pylint = {version = ">=2.13.7", optional = true} -black = {version = ">=22.3.0", optional = true} -Sphinx = {version = ">=4.5.0", optional = true} - -[tool.poetry.extras] -dev = ["pytest", "pytest-xdist", "pylint", "black", "Sphinx"] - -# Do NOT use [tool.poetry.dev-dependencies]. See: https://github.com/python-poetry/poetry/issues/3514 [tool.black] line-length = 80 -target-version = ['py37'] +target-version = ["py39"] -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +[tool.hatch.build.targets.sdist] +exclude = [ + "/tests", + "/sample-data", + "/demo-img.png", + "/demo.ipynb", +] + +[tool.hatch.version] +path = "wfdb/version.py" diff --git a/tests/io/test_convert.py b/tests/io/test_convert.py index aa7ba78a..cf97f700 100644 --- a/tests/io/test_convert.py +++ b/tests/io/test_convert.py @@ -1,14 +1,22 @@ +import os +import shutil +import unittest + import numpy as np from wfdb.io.record import rdrecord from wfdb.io.convert.edf import read_edf +from wfdb.io.convert.csv import csv_to_wfdb + +class TestEdfToWfdb: + """ + Tests for the io.convert.edf module. + """ -class TestConvert: def test_edf_uniform(self): """ EDF format conversion to MIT for uniform sample rates. - """ # Uniform sample rates record_MIT = rdrecord("sample-data/n16").__dict__ @@ -60,7 +68,6 @@ def test_edf_uniform(self): def test_edf_non_uniform(self): """ EDF format conversion to MIT for non-uniform sample rates. - """ # Non-uniform sample rates record_MIT = rdrecord("sample-data/wave_4").__dict__ @@ -108,3 +115,65 @@ def test_edf_non_uniform(self): target_results = len(fields) * [True] assert np.array_equal(test_results, target_results) + + +class TestCsvToWfdb(unittest.TestCase): + """ + Tests for the io.convert.csv module. + """ + + def setUp(self): + """ + Create a temporary directory containing data for testing. + + Load 100.dat file for comparison to 100.csv file. + """ + self.test_dir = "test_output" + os.makedirs(self.test_dir, exist_ok=True) + + self.record_100_csv = "sample-data/100.csv" + self.record_100_dat = rdrecord("sample-data/100", physical=True) + + def tearDown(self): + """ + Remove the temporary directory after the test. + """ + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def test_write_dir(self): + """ + Call the function with the write_dir argument. + """ + csv_to_wfdb( + file_name=self.record_100_csv, + fs=360, + units="mV", + write_dir=self.test_dir, + ) + + # Check if the output files are created in the specified directory + base_name = os.path.splitext(os.path.basename(self.record_100_csv))[0] + expected_dat_file = os.path.join(self.test_dir, f"{base_name}.dat") + expected_hea_file = os.path.join(self.test_dir, f"{base_name}.hea") + + self.assertTrue(os.path.exists(expected_dat_file)) + self.assertTrue(os.path.exists(expected_hea_file)) + + # Check that newly written file matches the 100.dat file + record_write = rdrecord(os.path.join(self.test_dir, base_name)) + + self.assertEqual(record_write.fs, 360) + self.assertEqual(record_write.fs, self.record_100_dat.fs) + self.assertEqual(record_write.units, ["mV", "mV"]) + self.assertEqual(record_write.units, self.record_100_dat.units) + self.assertEqual(record_write.sig_name, ["MLII", "V5"]) + self.assertEqual(record_write.sig_name, self.record_100_dat.sig_name) + self.assertEqual(record_write.p_signal.size, 1300000) + self.assertEqual( + record_write.p_signal.size, self.record_100_dat.p_signal.size + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_annotation.py b/tests/test_annotation.py index e7d86b50..db3e71d0 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -33,7 +33,8 @@ def test_1(self): # no null to detect in the output text file of rdann. # Target data from WFDB software package - lines = tuple(open("tests/target-output/ann-1", "r")) + with open("tests/target-output/ann-1", "r") as f: + lines = tuple(f) nannot = len(lines) target_time = [None] * nannot @@ -108,7 +109,8 @@ def test_2(self): annotation = wfdb.rdann("sample-data/12726", "anI") # Target data from WFDB software package - lines = tuple(open("tests/target-output/ann-2", "r")) + with open("tests/target-output/ann-2", "r") as f: + lines = tuple(f) nannot = len(lines) target_time = [None] * nannot @@ -181,7 +183,8 @@ def test_3(self): annotation = wfdb.rdann("sample-data/1003", "atr") # Target data from WFDB software package - lines = tuple(open("tests/target-output/ann-3", "r")) + with open("tests/target-output/ann-3", "r") as f: + lines = tuple(f) nannot = len(lines) target_time = [None] * nannot diff --git a/tests/test_record.py b/tests/test_record.py index 8d09e39d..fff8ef44 100644 --- a/tests/test_record.py +++ b/tests/test_record.py @@ -20,6 +20,25 @@ class TestRecord(unittest.TestCase): """ + wrsamp_params = [ + "record_name", + "fs", + "units", + "sig_name", + "p_signal", + "d_signal", + "e_p_signal", + "e_d_signal", + "samps_per_frame", + "fmt", + "adc_gain", + "baseline", + "comments", + "base_time", + "base_date", + "base_datetime", + ] + # ----------------------- 1. Basic Tests -----------------------# def test_1a(self): @@ -227,6 +246,27 @@ def test_1f(self): "Mismatch in %s" % name, ) + # Test writing all supported formats. (Currently not all signal + # formats are supported for output; keep this list in sync with + # 'wr_dat_file' in wfdb/io/_signal.py.) + OUTPUT_FMTS = ["80", "212", "16", "24", "32"] + channels = [] + for i, fmt in enumerate(record.fmt): + if fmt in OUTPUT_FMTS: + channels.append(i) + + partial_record = wfdb.rdrecord( + "sample-data/binformats", + physical=False, + channels=channels, + ) + partial_record.wrsamp(write_dir=self.temp_path) + converted_record = wfdb.rdrecord( + os.path.join(self.temp_path, "binformats"), + physical=False, + ) + assert partial_record == converted_record + def test_read_write_flac(self): """ All FLAC formats, multiple signal files in one record. @@ -286,6 +326,172 @@ def test_read_write_flac_multifrequency(self): ) assert record == record_write + def test_unique_samps_per_frame_e_p_signal(self): + """ + Test writing an e_p_signal with wfdb.io.wrsamp where the signals have different samples per frame. All other + parameters which overlap between a Record object and wfdb.io.wrsamp are also checked. + """ + # Read in a record with different samples per frame + record = wfdb.rdrecord( + "sample-data/mixedsignals", + smooth_frames=False, + ) + + # Write the signals + wfdb.io.wrsamp( + "mixedsignals", + fs=record.fs, + units=record.units, + sig_name=record.sig_name, + base_date=record.base_date, + base_time=record.base_time, + comments=record.comments, + p_signal=record.p_signal, + d_signal=record.d_signal, + e_p_signal=record.e_p_signal, + e_d_signal=record.e_d_signal, + samps_per_frame=record.samps_per_frame, + baseline=record.baseline, + adc_gain=record.adc_gain, + fmt=record.fmt, + write_dir=self.temp_path, + ) + + # Check that the written record matches the original + # Read in the original and written records + record = wfdb.rdrecord("sample-data/mixedsignals", smooth_frames=False) + record_write = wfdb.rdrecord( + os.path.join(self.temp_path, "mixedsignals"), + smooth_frames=False, + ) + + # Check that the signals match + for n, name in enumerate(record.sig_name): + np.testing.assert_array_equal( + record.e_p_signal[n], + record_write.e_p_signal[n], + f"Mismatch in {name}", + ) + + # Filter out the signal + record_filtered = { + k: getattr(record, k) + for k in self.wrsamp_params + if not ( + isinstance(getattr(record, k), np.ndarray) + or ( + isinstance(getattr(record, k), list) + and all( + isinstance(item, np.ndarray) + for item in getattr(record, k) + ) + ) + ) + } + + record_write_filtered = { + k: getattr(record_write, k) + for k in self.wrsamp_params + if not ( + isinstance(getattr(record_write, k), np.ndarray) + or ( + isinstance(getattr(record_write, k), list) + and all( + isinstance(item, np.ndarray) + for item in getattr(record_write, k) + ) + ) + ) + } + + # Check that the arguments beyond the signals also match + assert record_filtered == record_write_filtered + + def test_unique_samps_per_frame_e_d_signal(self): + """ + Test writing an e_d_signal with wfdb.io.wrsamp where the signals have different samples per frame. All other + parameters which overlap between a Record object and wfdb.io.wrsamp are also checked. + """ + # Read in a record with different samples per frame + record = wfdb.rdrecord( + "sample-data/mixedsignals", + physical=False, + smooth_frames=False, + ) + + # Write the signals + wfdb.io.wrsamp( + "mixedsignals", + fs=record.fs, + units=record.units, + sig_name=record.sig_name, + base_date=record.base_date, + base_time=record.base_time, + comments=record.comments, + p_signal=record.p_signal, + d_signal=record.d_signal, + e_p_signal=record.e_p_signal, + e_d_signal=record.e_d_signal, + samps_per_frame=record.samps_per_frame, + baseline=record.baseline, + adc_gain=record.adc_gain, + fmt=record.fmt, + write_dir=self.temp_path, + ) + + # Check that the written record matches the original + # Read in the original and written records + record = wfdb.rdrecord( + "sample-data/mixedsignals", physical=False, smooth_frames=False + ) + record_write = wfdb.rdrecord( + os.path.join(self.temp_path, "mixedsignals"), + physical=False, + smooth_frames=False, + ) + + # Check that the signals match + for n, name in enumerate(record.sig_name): + np.testing.assert_array_equal( + record.e_d_signal[n], + record_write.e_d_signal[n], + f"Mismatch in {name}", + ) + + # Filter out the signal + record_filtered = { + k: getattr(record, k) + for k in self.wrsamp_params + if not ( + isinstance(getattr(record, k), np.ndarray) + or ( + isinstance(getattr(record, k), list) + and all( + isinstance(item, np.ndarray) + for item in getattr(record, k) + ) + ) + ) + } + + record_write_filtered = { + k: getattr(record_write, k) + for k in self.wrsamp_params + if not ( + isinstance(getattr(record_write, k), np.ndarray) + or ( + isinstance(getattr(record_write, k), list) + and all( + isinstance(item, np.ndarray) + for item in getattr(record_write, k) + ) + ) + ) + } + + # Check that the arguments beyond the signals also match + assert record_filtered == record_write_filtered + def test_read_write_flac_many_channels(self): """ Check we can read and write to format 516 with more than 8 channels. @@ -1053,19 +1259,20 @@ def test_physical_conversion(self): adc_gain = [1.0, 1234.567, 765.4321] baseline = [10, 20, -30] d_signal = np.repeat(np.arange(-100, 100), 3).reshape(-1, 3) + d_signal[5:10, :] = [-32768, -2048, -128] e_d_signal = list(d_signal.transpose()) - fmt = ["16", "16", "16"] + fmt = ["16", "212", "80"] # Test adding or subtracting a small offset (0.01 ADU) to check # that we correctly round to the nearest integer for offset in (0, -0.01, 0.01): p_signal = (d_signal + offset - baseline) / adc_gain + p_signal[5:10, :] = np.nan e_p_signal = list(p_signal.transpose()) # Test converting p_signal to d_signal record = wfdb.Record( - n_sig=n_sig, p_signal=p_signal.copy(), adc_gain=adc_gain, baseline=baseline, @@ -1081,7 +1288,6 @@ def test_physical_conversion(self): # Test converting e_p_signal to e_d_signal record = wfdb.Record( - n_sig=n_sig, e_p_signal=[s.copy() for s in e_p_signal], adc_gain=adc_gain, baseline=baseline, @@ -1108,7 +1314,7 @@ def test_physical_conversion(self): p_signal=p_signal, adc_gain=adc_gain, baseline=baseline, - fmt=["16", "16", "16"], + fmt=fmt, write_dir=self.temp_path, ) record = wfdb.rdrecord( diff --git a/wfdb/io/_header.py b/wfdb/io/_header.py index 419fb1cf..0d420521 100644 --- a/wfdb/io/_header.py +++ b/wfdb/io/_header.py @@ -120,8 +120,8 @@ columns=_SPECIFICATION_COLUMNS, dtype="object", data=[ - [(str), "", None, True, None, None], # seg_name - [int_types, " ", "seg_name", True, None, None], # seg_len + [(str, list), "", None, True, None, None], # seg_name + [(int_types, list), " ", "seg_name", True, None, None], # seg_len ], ) @@ -779,7 +779,7 @@ def wr_header_file(self, write_fields, write_dir): comment_lines = ["# " + comment for comment in self.comments] header_lines += comment_lines - util.lines_to_file(self.record_name + ".hea", header_lines, write_dir) + util.lines_to_file(self.record_name + ".hea", write_dir, header_lines) def get_sig_segments(self, sig_name=None): """ diff --git a/wfdb/io/_signal.py b/wfdb/io/_signal.py index a4ffbced..693c6a19 100644 --- a/wfdb/io/_signal.py +++ b/wfdb/io/_signal.py @@ -433,7 +433,7 @@ def set_d_features(self, do_adc=False, single_fmt=True, expanded=False): self.check_field("baseline", "all") # All required fields are present and valid. Perform ADC - self.d_signal = self.adc(expanded) + self.e_d_signal = self.adc(expanded) # Use e_d_signal to set fields self.check_field("e_d_signal", "all") @@ -532,68 +532,60 @@ def adc(self, expanded=False, inplace=False): # To do: choose the minimum return res needed intdtype = "int64" + # Convert a physical (1D or 2D) signal array to digital. Note that + # the input array is modified! + def adc_inplace(p_signal, adc_gain, baseline, d_nan): + nanlocs = np.isnan(p_signal) + np.multiply(p_signal, adc_gain, p_signal) + np.add(p_signal, baseline, p_signal) + np.round(p_signal, 0, p_signal) + np.copyto(p_signal, d_nan, where=nanlocs) + d_signal = p_signal.astype(intdtype, copy=False) + return d_signal + # Do inplace conversion and set relevant variables. if inplace: if expanded: - for ch in range(self.n_sig): - # NAN locations for the channel - ch_nanlocs = np.isnan(self.e_p_signal[ch]) - np.multiply( - self.e_p_signal[ch], + for ch, ch_p_signal in enumerate(self.e_p_signal): + ch_d_signal = adc_inplace( + ch_p_signal, self.adc_gain[ch], - self.e_p_signal[ch], - ) - np.add( - self.e_p_signal[ch], self.baseline[ch], - self.e_p_signal[ch], - ) - np.round(self.e_p_signal[ch], 0, self.e_p_signal[ch]) - self.e_p_signal[ch] = self.e_p_signal[ch].astype( - intdtype, copy=False + d_nans[ch], ) - self.e_p_signal[ch][ch_nanlocs] = d_nans[ch] + self.e_p_signal[ch] = ch_d_signal self.e_d_signal = self.e_p_signal self.e_p_signal = None else: - nanlocs = np.isnan(self.p_signal) - np.multiply(self.p_signal, self.adc_gain, self.p_signal) - np.add(self.p_signal, self.baseline, self.p_signal) - np.round(self.p_signal, 0, self.p_signal) - self.p_signal = self.p_signal.astype(intdtype, copy=False) - self.d_signal = self.p_signal + self.d_signal = adc_inplace( + self.p_signal, + self.adc_gain, + self.baseline, + d_nans, + ) self.p_signal = None # Return the variable else: if expanded: - d_signal = [] - for ch in range(self.n_sig): - # NAN locations for the channel - ch_nanlocs = np.isnan(self.e_p_signal[ch]) - ch_d_signal = self.e_p_signal[ch].copy() - np.multiply(ch_d_signal, self.adc_gain[ch], ch_d_signal) - np.add(ch_d_signal, self.baseline[ch], ch_d_signal) - np.round(ch_d_signal, 0, ch_d_signal) - ch_d_signal = ch_d_signal.astype(intdtype, copy=False) - ch_d_signal[ch_nanlocs] = d_nans[ch] - d_signal.append(ch_d_signal) + e_d_signal = [] + for ch, ch_p_signal in enumerate(self.e_p_signal): + ch_d_signal = adc_inplace( + ch_p_signal.copy(), + self.adc_gain[ch], + self.baseline[ch], + d_nans[ch], + ) + e_d_signal.append(ch_d_signal) + return e_d_signal else: - nanlocs = np.isnan(self.p_signal) - # Cannot cast dtype to int now because gain is float. - d_signal = self.p_signal.copy() - np.multiply(d_signal, self.adc_gain, d_signal) - np.add(d_signal, self.baseline, d_signal) - np.round(d_signal, 0, d_signal) - d_signal = d_signal.astype(intdtype, copy=False) - - if nanlocs.any(): - for ch in range(d_signal.shape[1]): - if nanlocs[:, ch].any(): - d_signal[nanlocs[:, ch], ch] = d_nans[ch] - - return d_signal + return adc_inplace( + self.p_signal.copy(), + self.adc_gain, + self.baseline, + d_nans, + ) def dac(self, expanded=False, return_res=64, inplace=False): """ @@ -707,21 +699,25 @@ def dac(self, expanded=False, return_res=64, inplace=False): return p_signal - def calc_adc_params(self): + def calc_adc_gain_baseline(self, ch, minvals, maxvals): """ - Compute appropriate adc_gain and baseline parameters for adc - conversion, given the physical signal and the fmts. + Compute adc_gain and baseline parameters for a given channel. Parameters ---------- - N/A + ch: int + The channel that the adc_gain and baseline are being computed for. + minvals: list + The minimum values for each channel. + maxvals: list + The maximum values for each channel. Returns ------- - adc_gains : list - List of calculated `adc_gain` values for each channel. - baselines : list - List of calculated `baseline` values for each channel. + adc_gain : float + Calculated `adc_gain` value for a given channel. + baseline : int + Calculated `baseline` value for a given channel. Notes ----- @@ -737,85 +733,132 @@ def calc_adc_params(self): for calculated float `adc_gain` values. """ - adc_gains = [] - baselines = [] + # Get the minimum and maximum (valid) storage values + dmin, dmax = _digi_bounds(self.fmt[ch]) + # add 1 because the lowest value is used to store nans + dmin = dmin + 1 + + pmin = minvals[ch] + pmax = maxvals[ch] + + # Figure out digital samples used to store physical samples + + # If the entire signal is NAN, gain/baseline won't be used + if pmin == np.nan: + adc_gain = 1 + baseline = 1 + # If the signal is just one value, store one digital value. + elif pmin == pmax: + if pmin == 0: + adc_gain = 1 + baseline = 1 + else: + # All digital values are +1 or -1. Keep adc_gain > 0 + adc_gain = abs(1 / pmin) + baseline = 0 + # Regular varied signal case. + else: + # The equation is: p = (d - b) / g + + # Approximately, pmax maps to dmax, and pmin maps to + # dmin. Gradient will be equal to, or close to + # delta(d) / delta(p), since intercept baseline has + # to be an integer. + + # Constraint: baseline must be between +/- 2**31 + adc_gain = (dmax - dmin) / (pmax - pmin) + baseline = dmin - adc_gain * pmin + + # Make adjustments for baseline to be an integer + # This up/down round logic of baseline is to ensure + # there is no overshoot of dmax. Now pmax will map + # to dmax or dmax-1 which is also fine. + if pmin > 0: + baseline = int(np.ceil(baseline)) + else: + baseline = int(np.floor(baseline)) + + # After baseline is set, adjust gain correspondingly.Set + # the gain to map pmin to dmin, and p==0 to baseline. + # In the case where pmin == 0 and dmin == baseline, + # adc_gain is already correct. Avoid dividing by 0. + if dmin != baseline: + adc_gain = (dmin - baseline) / pmin + + # Remap signal if baseline exceeds boundaries. + # This may happen if pmax < 0 + if baseline > MAX_I32: + # pmin maps to dmin, baseline maps to 2**31 - 1 + # pmax will map to a lower value than before + adc_gain = (MAX_I32) - dmin / abs(pmin) + baseline = MAX_I32 + # This may happen if pmin > 0 + elif baseline < MIN_I32: + # pmax maps to dmax, baseline maps to -2**31 + 1 + adc_gain = (dmax - MIN_I32) / pmax + baseline = MIN_I32 + + return adc_gain, baseline - if np.where(np.isinf(self.p_signal))[0].size: - raise ValueError("Signal contains inf. Cannot perform adc.") + def calc_adc_params(self): + """ + Compute appropriate adc_gain and baseline parameters for adc + conversion, given the physical signal and the fmts. - # min and max ignoring nans, unless whole channel is NAN. - # Should suppress warning message. - minvals = np.nanmin(self.p_signal, axis=0) - maxvals = np.nanmax(self.p_signal, axis=0) + Parameters + ---------- + N/A - for ch in range(np.shape(self.p_signal)[1]): - # Get the minimum and maximum (valid) storage values - dmin, dmax = _digi_bounds(self.fmt[ch]) - # add 1 because the lowest value is used to store nans - dmin = dmin + 1 + Returns + ------- + adc_gains : list + List of calculated `adc_gain` values for each channel. + baselines : list + List of calculated `baseline` values for each channel - pmin = minvals[ch] - pmax = maxvals[ch] + """ + adc_gains = [] + baselines = [] - # Figure out digital samples used to store physical samples + if self.p_signal is not None: + if np.where(np.isinf(self.p_signal))[0].size: + raise ValueError("Signal contains inf. Cannot perform adc.") - # If the entire signal is NAN, gain/baseline won't be used - if pmin == np.nan: - adc_gain = 1 - baseline = 1 - # If the signal is just one value, store one digital value. - elif pmin == pmax: - if pmin == 0: - adc_gain = 1 - baseline = 1 - else: - # All digital values are +1 or -1. Keep adc_gain > 0 - adc_gain = abs(1 / pmin) - baseline = 0 - # Regular varied signal case. - else: - # The equation is: p = (d - b) / g - - # Approximately, pmax maps to dmax, and pmin maps to - # dmin. Gradient will be equal to, or close to - # delta(d) / delta(p), since intercept baseline has - # to be an integer. - - # Constraint: baseline must be between +/- 2**31 - adc_gain = (dmax - dmin) / (pmax - pmin) - baseline = dmin - adc_gain * pmin - - # Make adjustments for baseline to be an integer - # This up/down round logic of baseline is to ensure - # there is no overshoot of dmax. Now pmax will map - # to dmax or dmax-1 which is also fine. - if pmin > 0: - baseline = int(np.ceil(baseline)) - else: - baseline = int(np.floor(baseline)) - - # After baseline is set, adjust gain correspondingly.Set - # the gain to map pmin to dmin, and p==0 to baseline. - # In the case where pmin == 0 and dmin == baseline, - # adc_gain is already correct. Avoid dividing by 0. - if dmin != baseline: - adc_gain = (dmin - baseline) / pmin - - # Remap signal if baseline exceeds boundaries. - # This may happen if pmax < 0 - if baseline > MAX_I32: - # pmin maps to dmin, baseline maps to 2**31 - 1 - # pmax will map to a lower value than before - adc_gain = (MAX_I32) - dmin / abs(pmin) - baseline = MAX_I32 - # This may happen if pmin > 0 - elif baseline < MIN_I32: - # pmax maps to dmax, baseline maps to -2**31 + 1 - adc_gain = (dmax - MIN_I32) / pmax - baseline = MIN_I32 - - adc_gains.append(adc_gain) - baselines.append(baseline) + # min and max ignoring nans, unless whole channel is NAN. + # Should suppress warning message. + minvals = np.nanmin(self.p_signal, axis=0) + maxvals = np.nanmax(self.p_signal, axis=0) + + for ch in range(np.shape(self.p_signal)[1]): + adc_gain, baseline = self.calc_adc_gain_baseline( + ch, minvals, maxvals + ) + adc_gains.append(adc_gain) + baselines.append(baseline) + + elif self.e_p_signal is not None: + minvals = [] + maxvals = [] + for ch in self.e_p_signal: + minvals.append(np.nanmin(ch)) + maxvals.append(np.nanmax(ch)) + + if any(x == math.inf for x in minvals) or any( + x == math.inf for x in maxvals + ): + raise ValueError("Signal contains inf. Cannot perform adc.") + + for ch, _ in enumerate(self.e_p_signal): + adc_gain, baseline = self.calc_adc_gain_baseline( + ch, minvals, maxvals + ) + adc_gains.append(adc_gain) + baselines.append(baseline) + + else: + raise Exception( + "Must supply p_signal or e_p_signal to calc_adc_params" + ) return (adc_gains, baselines) @@ -2327,11 +2370,10 @@ def wr_dat_file( if fmt == "80": # convert to 8 bit offset binary form - d_signal = d_signal + 128 - # Concatenate into 1D - d_signal = d_signal.reshape(-1) - # Convert to un_signed 8 bit dtype to write - b_write = d_signal.astype("uint8") + d_signal += 128 + + # Convert to unsigned 8 bit dtype to write (and flatten if necessary) + b_write = d_signal.astype("uint8").reshape(-1) elif fmt == "212": # Each sample is represented by a 12 bit two's complement @@ -2344,7 +2386,7 @@ def wr_dat_file( # repeated for each successive pair of samples. # convert to 12 bit two's complement - d_signal[d_signal < 0] = d_signal[d_signal < 0] + 4096 + d_signal[d_signal < 0] += 4096 # Concatenate into 1D d_signal = d_signal.reshape(-1) @@ -2379,7 +2421,8 @@ def wr_dat_file( elif fmt == "16": # convert to 16 bit two's complement - d_signal[d_signal < 0] = d_signal[d_signal < 0] + 65536 + d_signal = d_signal.astype(np.uint16) + # Split samples into separate bytes using binary masks b1 = d_signal & [255] * tsamps_per_frame b2 = (d_signal & [65280] * tsamps_per_frame) >> 8 @@ -2391,8 +2434,8 @@ def wr_dat_file( # Convert to un_signed 8 bit dtype to write b_write = b_write.astype("uint8") elif fmt == "24": - # convert to 24 bit two's complement - d_signal[d_signal < 0] = d_signal[d_signal < 0] + 16777216 + # convert to 32 bit two's complement (as int24 not an option) + d_signal = d_signal.astype(np.uint32) # Split samples into separate bytes using binary masks b1 = d_signal & [255] * tsamps_per_frame b2 = (d_signal & [65280] * tsamps_per_frame) >> 8 @@ -2408,7 +2451,8 @@ def wr_dat_file( elif fmt == "32": # convert to 32 bit two's complement - d_signal[d_signal < 0] = d_signal[d_signal < 0] + 4294967296 + d_signal = d_signal.astype(np.uint32) + # Split samples into separate bytes using binary masks b1 = d_signal & [255] * tsamps_per_frame b2 = (d_signal & [65280] * tsamps_per_frame) >> 8 diff --git a/wfdb/io/annotation.py b/wfdb/io/annotation.py index 655d1212..6ceb2680 100644 --- a/wfdb/io/annotation.py +++ b/wfdb/io/annotation.py @@ -940,10 +940,10 @@ def wr_ann_file(self, write_fs, write_dir=""): core_bytes = self.calc_core_bytes() # Mark the end of the special annotation types if needed - if fs_bytes == [] and cl_bytes == []: - end_special_bytes = [] - else: + if len(fs_bytes) or len(cl_bytes): end_special_bytes = [0, 236, 255, 255, 255, 255, 1, 0] + else: + end_special_bytes = [] # Write the file with open( @@ -1352,7 +1352,7 @@ def get_contained_labels(self, inplace=True): else: raise Exception("No annotation labels contained in object") - contained_labels = label_map.loc[index_vals, :] + contained_labels = label_map.loc[list(index_vals), :] # Add the counts for i in range(len(counts[0])): @@ -2162,7 +2162,7 @@ def proc_ann_bytes(filebytes, sampto): update = {"subtype": True, "chan": True, "num": True, "aux_note": True} # Get the next label store value - it may indicate additional # fields for this annotation, or the values of the next annotation. - current_label_store = filebytes[bpi, 1] >> 2 + current_label_store = int(filebytes[bpi, 1]) >> 2 while current_label_store > 59: subtype, chan, num, aux_note, update, bpi = proc_extra_field( @@ -2176,7 +2176,7 @@ def proc_ann_bytes(filebytes, sampto): update, ) - current_label_store = filebytes[bpi, 1] >> 2 + current_label_store = int(filebytes[bpi, 1]) >> 2 # Set defaults or carry over previous values if necessary subtype, chan, num, aux_note = update_extra_fields( @@ -2219,7 +2219,7 @@ def proc_core_fields(filebytes, bpi): # The current byte pair will contain either the actual d_sample + annotation store value, # or 0 + SKIP. - while filebytes[bpi, 1] >> 2 == 59: + while int(filebytes[bpi, 1]) >> 2 == 59: # 4 bytes storing dt skip_diff = ( (int(filebytes[bpi + 1, 0]) << 16) @@ -2236,8 +2236,10 @@ def proc_core_fields(filebytes, bpi): bpi = bpi + 3 # Not a skip - it is the actual sample number + annotation type store value - label_store = filebytes[bpi, 1] >> 2 - sample_diff += int(filebytes[bpi, 0] + 256 * (filebytes[bpi, 1] & 3)) + label_store = int(filebytes[bpi, 1]) >> 2 + sample_diff += np.int64(filebytes[bpi, 0]) + 256 * ( + np.int64(filebytes[bpi, 1]) & 3 + ) bpi = bpi + 1 return sample_diff, label_store, bpi @@ -2322,7 +2324,7 @@ def proc_extra_field( aux_notebytes = filebytes[ bpi + 1 : bpi + 1 + int(np.ceil(aux_notelen / 2.0)), : ].flatten() - if aux_notelen & 1: + if int(aux_notelen) & 1: aux_notebytes = aux_notebytes[:-1] # The aux_note string aux_note.append("".join([chr(char) for char in aux_notebytes])) diff --git a/wfdb/io/convert/csv.py b/wfdb/io/convert/csv.py index 3cfd25a2..4817a0e5 100644 --- a/wfdb/io/convert/csv.py +++ b/wfdb/io/convert/csv.py @@ -33,6 +33,7 @@ def csv_to_wfdb( header=True, delimiter=",", verbose=False, + write_dir="", ): """ Read a WFDB header file and return either a `Record` object with the @@ -235,6 +236,10 @@ def csv_to_wfdb( verbose : bool, optional Whether to print all the information read about the file (True) or not (False). + write_dir : str, optional + The directory where the output files will be saved. If write_dir is not + provided, the output files will be saved in the same directory as the + input file. Returns ------- @@ -291,6 +296,7 @@ def csv_to_wfdb( df_CSV = pd.read_csv(file_name, delimiter=delimiter, header=None) if verbose: print("Successfully read CSV") + # Extract the entire signal from the dataframe p_signal = df_CSV.values # The dataframe should be in (`sig_len`, `n_sig`) dimensions @@ -300,10 +306,11 @@ def csv_to_wfdb( n_sig = p_signal.shape[1] if verbose: print("Number of signals: {}".format(n_sig)) + # Check if signal names are valid and set defaults if not sig_name: if header: - sig_name = df_CSV.columns.to_list() + sig_name = df_CSV.columns.tolist() if any(map(str.isdigit, sig_name)): print( "WARNING: One or more of your signal names are numbers, this " @@ -318,15 +325,12 @@ def csv_to_wfdb( if verbose: print("Signal names: {}".format(sig_name)) - # Set the output header file name to be the same, remove path - if os.sep in file_name: - file_name = file_name.split(os.sep)[-1] - record_name = file_name.replace(".csv", "") + record_name = os.path.splitext(os.path.basename(file_name))[0] if verbose: - print("Output header: {}.hea".format(record_name)) + print("Record name: {}.hea".format(record_name)) # Replace the CSV file tag with DAT - dat_file_name = file_name.replace(".csv", ".dat") + dat_file_name = record_name + ".dat" dat_file_name = [dat_file_name] * n_sig if verbose: print("Output record: {}".format(dat_file_name[0])) @@ -450,7 +454,6 @@ def csv_to_wfdb( if verbose: print("Record generated successfully") return record - else: # Write the information to a record and header file wrsamp( @@ -465,6 +468,7 @@ def csv_to_wfdb( comments=comments, base_time=base_time, base_date=base_date, + write_dir=write_dir, ) if verbose: print("File generated successfully") diff --git a/wfdb/io/convert/edf.py b/wfdb/io/convert/edf.py index e77cda59..c2d0af47 100644 --- a/wfdb/io/convert/edf.py +++ b/wfdb/io/convert/edf.py @@ -402,23 +402,27 @@ def read_edf( temp_sig_data = np.fromfile(edf_file, dtype=np.int16) temp_sig_data = temp_sig_data.reshape((-1, sum(samps_per_block))) temp_all_sigs = np.hsplit(temp_sig_data, np.cumsum(samps_per_block)[:-1]) + for i in range(n_sig): # Check if `samps_per_frame` has all equal values if samps_per_frame.count(samps_per_frame[0]) == len(samps_per_frame): sig_data[:, i] = ( - temp_all_sigs[i].flatten() - baseline[i] + temp_all_sigs[i].flatten().astype(np.int64) - baseline[i] ) / adc_gain_all[i] else: temp_sig_data = temp_all_sigs[i].flatten() + if samps_per_frame[i] == 1: - sig_data[:, i] = (temp_sig_data - baseline[i]) / adc_gain_all[i] + sig_data[:, i] = ( + temp_sig_data.astype(np.int64) - baseline[i] + ) / adc_gain_all[i] else: for j in range(sig_len): start_ind = j * samps_per_frame[i] stop_ind = start_ind + samps_per_frame[i] sig_data[j, i] = np.mean( - (temp_sig_data[start_ind:stop_ind] - baseline[i]) - / adc_gain_all[i] + temp_sig_data[start_ind:stop_ind].astype(np.int64) + - baseline[i] / adc_gain_all[i] ) # This is the closest I can get to the original implementation @@ -438,6 +442,8 @@ def read_edf( int(np.sum(v) % 65536) for v in np.transpose(sig_data) ] # not all values correct? + edf_file.close() + record = Record( record_name=record_name_out, n_sig=n_sig, @@ -579,6 +585,7 @@ def wfdb_to_edf( sampfrom=sampfrom, sampto=sampto, smooth_frames=False, + channels=channels, ) record_name_out = record_name.split(os.sep)[-1].replace("-", "_") diff --git a/wfdb/io/convert/tff.py b/wfdb/io/convert/tff.py index c18c02d9..355a3eaf 100644 --- a/wfdb/io/convert/tff.py +++ b/wfdb/io/convert/tff.py @@ -4,6 +4,7 @@ http://www.biomation.com/kin/me6000.htm """ + import datetime import os import struct diff --git a/wfdb/io/download.py b/wfdb/io/download.py index d494ad0e..338d8b97 100644 --- a/wfdb/io/download.py +++ b/wfdb/io/download.py @@ -143,7 +143,7 @@ def _stream_dat(file_name, pn_dir, byte_count, start_byte, dtype): content = f.read(byte_count) # Convert to numpy array - sig_data = np.fromstring(content, dtype=dtype) + sig_data = np.frombuffer(content, dtype=dtype) return sig_data @@ -173,7 +173,7 @@ def _stream_annotation(file_name, pn_dir): content = f.read() # Convert to numpy array - ann_data = np.fromstring(content, dtype=np.dtype(" 1: + raise Exception( + "The number of samples in a channel divided by the corresponding samples_per_frame entry must be uniform" + ) + + # Create the Record object + record = Record( + record_name=record_name, + p_signal=p_signal, + d_signal=d_signal, + e_p_signal=e_p_signal, + e_d_signal=e_d_signal, + samps_per_frame=samps_per_frame, + fs=fs, + fmt=fmt, + units=units, + sig_name=sig_name, + adc_gain=adc_gain, + baseline=baseline, + comments=comments, + base_time=base_time, + base_date=base_date, + base_datetime=base_datetime, + ) + + # Depending on which signal was used, set other required fields. + if p_signal is not None: # Compute optimal fields to store the digital signal, carry out adc, # and set the fields. record.set_d_features(do_adc=1) - else: - # Create the Record object - record = Record( - record_name=record_name, - d_signal=d_signal, - fs=fs, - fmt=fmt, - units=units, - sig_name=sig_name, - adc_gain=adc_gain, - baseline=baseline, - comments=comments, - base_time=base_time, - base_date=base_date, - base_datetime=base_datetime, - ) + elif d_signal is not None: # Use d_signal to set the fields directly record.set_d_features() + elif e_p_signal is not None: + # Compute optimal fields to store the digital signal, carry out adc, + # and set the fields. + record.set_d_features(do_adc=1, expanded=True) + elif e_d_signal is not None: + # Use e_d_signal to set the fields directly + record.set_d_features(expanded=True) # Set default values of any missing field dependencies record.set_defaults() + + # Determine whether the signal is expanded + if (e_d_signal or e_p_signal) is not None: + expanded = True + else: + expanded = False + # Write the record files - header and associated dat - record.wrsamp(write_dir=write_dir) + record.wrsamp(write_dir=write_dir, expanded=expanded) def dl_database( @@ -3049,7 +3104,7 @@ def dl_database( for rec in record_list: print("Generating record list for: " + rec) # May be pointing to directory - if rec.endswith(os.sep): + if rec.endswith("/"): nested_records += [ posixpath.join(rec, sr) for sr in download.get_record_list(posixpath.join(db_dir, rec)) @@ -3119,8 +3174,6 @@ def dl_database( print("Downloading files...") # Create multiple processes to download files. # Limit to 2 connections to avoid overloading the server - pool = multiprocessing.dummy.Pool(processes=2) - pool.map(download.dl_pn_file, dl_inputs) + with multiprocessing.dummy.Pool(processes=2) as pool: + pool.map(download.dl_pn_file, dl_inputs) print("Finished downloading files") - - return diff --git a/wfdb/io/util.py b/wfdb/io/util.py index 1b3f4ad9..07b06dcc 100644 --- a/wfdb/io/util.py +++ b/wfdb/io/util.py @@ -1,6 +1,7 @@ """ A module for general utility functions """ + import math import os diff --git a/wfdb/plot/__init__.py b/wfdb/plot/__init__.py index 5210346a..bf801834 100644 --- a/wfdb/plot/__init__.py +++ b/wfdb/plot/__init__.py @@ -1,4 +1,5 @@ """ The plot subpackage contains tools for plotting signals and annotations. """ + from wfdb.plot.plot import plot_items, plot_wfdb, plot_all_records diff --git a/wfdb/processing/evaluate.py b/wfdb/processing/evaluate.py index d2c86202..3e960286 100644 --- a/wfdb/processing/evaluate.py +++ b/wfdb/processing/evaluate.py @@ -204,9 +204,9 @@ def compare(self): ) # Assign the reference-test pair if close enough if smallest_samp_diff < self.window_width: - self.matching_sample_nums[ - ref_samp_num - ] = closest_samp_num + self.matching_sample_nums[ref_samp_num] = ( + closest_samp_num + ) # Set the starting test sample number to inspect # for the next reference sample. test_samp_num = closest_samp_num + 1 diff --git a/wfdb/processing/peaks.py b/wfdb/processing/peaks.py index dd276950..47d7ce48 100644 --- a/wfdb/processing/peaks.py +++ b/wfdb/processing/peaks.py @@ -4,7 +4,7 @@ def find_peaks(sig): - """ + r""" Find hard peaks and soft peaks in a signal, defined as follows: - Hard peak: a peak that is either /\ or \/. diff --git a/wfdb/processing/qrs.py b/wfdb/processing/qrs.py index 37c726bc..6f2f4bc4 100644 --- a/wfdb/processing/qrs.py +++ b/wfdb/processing/qrs.py @@ -215,7 +215,7 @@ def _mwi(self): N/A """ - wavelet_filter = signal.ricker(self.qrs_width, 4) + wavelet_filter = ricker(self.qrs_width, 4) self.sig_i = ( signal.filtfilt(wavelet_filter, [1], self.sig_f, axis=0) ** 2 @@ -277,7 +277,7 @@ def _learn_init_params(self, n_calib_beats=8): qrs_amps = [] noise_amps = [] - ricker_wavelet = signal.ricker(self.qrs_radius * 2, 4).reshape(-1, 1) + ricker_wavelet = ricker(self.qrs_radius * 2, 4).reshape(-1, 1) # Find the local peaks of the signal. peak_inds_f = find_local_peaks(self.sig_f, self.qrs_radius) @@ -1230,20 +1230,20 @@ def sm(self, at_t): smtpj = self.at(smt + j) smtlj = self.at(smt - j) v += int(smtpj + smtlj) - self.smv_put( - smt, - (v << 1) - + self.at(smt + j + 1) - + self.at(smt - j - 1) - - self.adc_zero * (smdt << 2), - ) - - self.SIG_SMOOTH.append( - (v << 1) - + self.at(smt + j + 1) - + self.at(smt - j - 1) - - self.adc_zero * (smdt << 2) - ) + self.smv_put( + smt, + (v << 1) + + self.at(smt + j + 1) + + self.at(smt - j - 1) + - self.adc_zero * (smdt << 2), + ) + + self.SIG_SMOOTH.append( + (v << 1) + + self.at(smt + j + 1) + + self.at(smt - j - 1) + - self.adc_zero * (smdt << 2) + ) self.c.smt = smt return self.smv_at(at_t) @@ -1540,10 +1540,12 @@ def find_missing(r, p): tann = GQRS.Annotation( tmp_time, "TWAVE", - 1 - if tmp_time - > self.annot.time + self.c.rtmean - else 0, + ( + 1 + if tmp_time + > self.annot.time + self.c.rtmean + else 0 + ), rtdmin, ) # if self.state == "RUNNING": @@ -1774,3 +1776,89 @@ def gqrs_detect( annotations = gqrs.detect(x=d_sig, conf=conf, adc_zero=adc_zero) return np.array([a.time for a in annotations]) + + +# This function includes code from SciPy, which is licensed under the +# BSD 3-Clause "New" or "Revised" License. +# The original code can be found at: +# https://github.com/scipy/scipy/blob/v1.14.0/scipy/signal/_wavelets.py#L316-L359 + +# Copyright (c) 2001-2002 Enthought, Inc. 2003, SciPy Developers. +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: + +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. + +# 2. Redistributions in binary form must reproduce the above +# copyright notice, this list of conditions and the following +# disclaimer in the documentation and/or other materials provided +# with the distribution. + +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +def ricker(points, a): + """ + Return a Ricker wavelet, also known as the "Mexican hat wavelet". + + It models the function: + + ``A * (1 - (x/a)**2) * exp(-0.5*(x/a)**2)``, + + where ``A = 2/(sqrt(3*a)*(pi**0.25))``. + + This function is copied from the `scipy` library which + removed it from version 1.15.0. + + Parameters + ---------- + points : int + Number of points in `vector`. + Will be centered around 0. + a : scalar + Width parameter of the wavelet. + + Returns + ------- + vector : (N,) ndarray + Array of length `points` in shape of ricker curve. + + Examples + -------- + >>> import matplotlib.pyplot as plt + + >>> points = 100 + >>> a = 4.0 + >>> vec2 = ricker(points, a) + >>> print(len(vec2)) + 100 + >>> plt.plot(vec2) + >>> plt.show() + + """ + A = 2 / (np.sqrt(3 * a) * (np.pi**0.25)) + wsq = a**2 + vec = np.arange(0, points) - (points - 1.0) / 2 + xsq = vec**2 + mod = 1 - xsq / wsq + gauss = np.exp(-xsq / (2 * wsq)) + total = A * mod * gauss + return total diff --git a/wfdb/version.py b/wfdb/version.py index 13ffcf42..0fd7811c 100644 --- a/wfdb/version.py +++ b/wfdb/version.py @@ -1 +1 @@ -__version__ = "4.1.2" +__version__ = "4.2.0"