diff --git a/.github/test_code.sh b/.github/test_code.sh index 7fa95f694..52a452260 100755 --- a/.github/test_code.sh +++ b/.github/test_code.sh @@ -1,10 +1,13 @@ #!/bin/bash # Script to run tests to account for wonkiness of periodic mac failures. -args=" -s --cov dascore --cov-append --cov-report=xml" +args="tests -s --cov dascore --cov-append --cov-report=xml" if [[ "$1" == "doctest" ]]; then args="dascore --doctest-modules" fi +if [[ "$1" == "profile" ]]; then + args="benchmarks --codspeed" +fi exit_code=0 diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml new file mode 100644 index 000000000..85b6132d6 --- /dev/null +++ b/.github/workflows/profile.yml @@ -0,0 +1,36 @@ +# An action for running DASCore's performance benchmarks. +name: CodSpeed Benchmarks + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +env: + python_version: "3.13" + +jobs: + benchmarks: + name: Run benchmarks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-tags: "true" + fetch-depth: '0' + + - uses: actions/setup-python@v6 + with: + python-version: ${{ env.python_version }} + + - name: Install dependencies + run: pip install ".[profile]" + + - name: Run benchmarks + uses: CodSpeedHQ/action@v4 + with: + mode: instrumentation + run: ./.github/test_code.sh profile + token: ${{ secrets.CODSPEED_TOKEN }} # Optional for public repos diff --git a/.gitignore b/.gitignore index 1ae472ecf..f35307add 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ docs/**/*.ipynb # profile stuff from asv .asv +.codspeed # misc scratch/** @@ -95,3 +96,6 @@ docs/index.quarto_ipynb # Claude stuff .claude CLAUDE.md + +# profile stuff +prof/ diff --git a/asv.conf.json b/asv.conf.json deleted file mode 100644 index c9795bde5..000000000 --- a/asv.conf.json +++ /dev/null @@ -1,184 +0,0 @@ -{ - // The version of the config file format. Do not change, unless - // you know what you are doing. - "version": 1, - - // The name of the project being benchmarked - "project": "dascore", - - // The project's homepage - "project_url": "https://github.com/dasdae/dascore", - - // The URL or local path of the source code repository for the - // project being benchmarked - "repo": ".", - - // The Python project's subdirectory in your repo. If missing or - // the empty string, the project is assumed to be located at the root - // of the repository. - // "repo_subdir": "", - - // Customizable commands for building, installing, and - // uninstalling the project. See asv.conf.json documentation. - // - // "install_command": ["in-dir={env_dir} python -mpip install {wheel_file}"], - // "uninstall_command": ["return-code=any python -mpip uninstall -y {project}"], - // "build_command": [ - // "python setup.py build", - // "PIP_NO_BUILD_ISOLATION=false python -mpip wheel --no-deps --no-index -w {build_cache_dir} {build_dir}" - // ], - - // List of branches to benchmark. If not provided, defaults to "master" - // (for git) or "default" (for mercurial). - // "branches": ["master"], // for git - // "branches": ["default"], // for mercurial - - // The DVCS being used. If not set, it will be automatically - // determined from "repo" by looking at the protocol in the URL - // (if remote), or by looking for special directories, such as - // ".git" (if local). - // "dvcs": "git", - - // The tool to use to create environments. May be "conda", - // "virtualenv" or other value depending on the plugins in use. - // If missing or the empty string, the tool will be automatically - // determined by looking for tools on the PATH environment - // variable. - "environment_type": "conda", - - // timeout in seconds for installing any dependencies in environment - // defaults to 10 min - //"install_timeout": 600, - - // the base URL to show a commit for the project. - // "show_commit_url": "http://github.com/owner/project/commit/", - - // The Pythons you'd like to test against. If not provided, defaults - // to the current version of Python used to run `asv`. - // "pythons": ["2.7", "3.6"], - - // The list of conda channel names to be searched for benchmark - // dependency packages in the specified order - // "conda_channels": ["conda-forge", "defaults"], - - // A conda environment file that is used for environment creation. - // "conda_environment_file": "environment.yml", - - // The matrix of dependencies to test. Each key of the "req" - // requirements dictionary is the name of a package (in PyPI) and - // the values are version numbers. An empty list or empty string - // indicates to just test against the default (latest) - // version. null indicates that the package is to not be - // installed. If the package to be tested is only available from - // PyPi, and the 'environment_type' is conda, then you can preface - // the package name by 'pip+', and the package will be installed - // via pip (with all the conda available packages installed first, - // followed by the pip installed packages). - // - // The ``@env`` and ``@env_nobuild`` keys contain the matrix of - // environment variables to pass to build and benchmark commands. - // An environment will be created for every combination of the - // cartesian product of the "@env" variables in this matrix. - // Variables in "@env_nobuild" will be passed to every environment - // during the benchmark phase, but will not trigger creation of - // new environments. A value of ``null`` means that the variable - // will not be set for the current combination. - // - // "matrix": { - // "req": { - // "numpy": ["1.6", "1.7"], - // "six": ["", null], // test with and without six installed - // "pip+emcee": [""] // emcee is only available for install with pip. - // }, - // "env": {"ENV_VAR_1": ["val1", "val2"]}, - // "env_nobuild": {"ENV_VAR_2": ["val3", null]}, - // }, - - // Combinations of libraries/python versions can be excluded/included - // from the set to test. Each entry is a dictionary containing additional - // key-value pairs to include/exclude. - // - // An exclude entry excludes entries where all values match. The - // values are regexps that should match the whole string. - // - // An include entry adds an environment. Only the packages listed - // are installed. The 'python' key is required. The exclude rules - // do not apply to includes. - // - // In addition to package names, the following keys are available: - // - // - python - // Python version, as in the *pythons* variable above. - // - environment_type - // Environment type, as above. - // - sys_platform - // Platform, as in sys.platform. Possible values for the common - // cases: 'linux2', 'win32', 'cygwin', 'darwin'. - // - req - // Required packages - // - env - // Environment variables - // - env_nobuild - // Non-build environment variables - // - // "exclude": [ - // {"python": "3.2", "sys_platform": "win32"}, // skip py3.2 on windows - // {"environment_type": "conda", "req": {"six": null}}, // don't run without six on conda - // {"env": {"ENV_VAR_1": "val2"}}, // skip val2 for ENV_VAR_1 - // ], - // - // "include": [ - // // additional env for python2.7 - // {"python": "2.7", "req": {"numpy": "1.8"}, "env_nobuild": {"FOO": "123"}}, - // // additional env if run on windows+conda - // {"platform": "win32", "environment_type": "conda", "python": "2.7", "req": {"libpython": ""}}, - // ], - - // The directory (relative to the current directory) that benchmarks are - // stored in. If not provided, defaults to "benchmarks" - // "benchmark_dir": "benchmarks", - - // The directory (relative to the current directory) to cache the Python - // environments in. If not provided, defaults to "env" - "env_dir": ".asv/env", - - // The directory (relative to the current directory) that raw benchmark - // results are stored in. If not provided, defaults to "results". - "results_dir": ".asv/results", - - // The directory (relative to the current directory) that the html tree - // should be written to. If not provided, defaults to "html". - "html_dir": ".asv/html", - - // The number of characters to retain in the commit hashes. - // "hash_length": 8, - - // `asv` will cache results of the recent builds in each - // environment, making them faster to install next time. This is - // the number of builds to keep, per environment. - // "build_cache_size": 2, - - // The commits after which the regression search in `asv publish` - // should start looking for regressions. Dictionary whose keys are - // regexps matching to benchmark names, and values corresponding to - // the commit (exclusive) after which to start looking for - // regressions. The default is to start from the first commit - // with results. If the commit is `null`, regression detection is - // skipped for the matching benchmark. - // - // "regressions_first_commits": { - // "some_benchmark": "352cdf", // Consider regressions only after this commit - // "another_benchmark": null, // Skip regression detection altogether - // }, - - // The thresholds for relative change in results, after which `asv - // publish` starts reporting regressions. Dictionary of the same - // form as in ``regressions_first_commits``, with values - // indicating the thresholds. If multiple entries match, the - // maximum is taken. If no entry matches, the default is 5%. - // - // "regressions_thresholds": { - // "some_benchmark": 0.01, // Threshold of 1% - // "another_benchmark": 0.5, // Threshold of 50% - // }, -} diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py deleted file mode 100644 index 9ad605673..000000000 --- a/benchmarks/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -A suite of benchmarks for DASCore. This __init__.py file is apparently -required. -""" -from __future__ import annotations diff --git a/benchmarks/generic_io_benchmarks.py b/benchmarks/generic_io_benchmarks.py deleted file mode 100644 index 54b64eeec..000000000 --- a/benchmarks/generic_io_benchmarks.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Benchmark for generic memory spool operations.""" - -from __future__ import annotations - -from functools import cache - -import dascore as dc -from dascore.utils.downloader import fetch, get_registry_df - - -@cache -def test_file_paths(): - """Get a dict of name: path for all files in data registry.""" - df = get_registry_df().loc[lambda x: ~x["name"].str.endswith(".csv")] - out = {row["name"]: fetch(row["name"]) for _, row in df.iterrows()} - return out - - -class IoSuite: - """Basic io functions.""" - - def setup(self): - """Get paths of test files.""" - self.path_dict = test_file_paths() - - def time_scan(self): - """Time for basic scanning of all datafiles.""" - for path in self.path_dict.values(): - dc.scan(path) - - def time_scan_df(self): - """Time for basic scanning of all datafiles.""" - for path in self.path_dict.values(): - dc.scan_to_df(path) - - def time_get_format(self): - """Time for basic scanning of all datafiles.""" - for path in self.path_dict.values(): - dc.get_format(path) - - def time_read(self): - """Time for basic scanning of all datafiles.""" - for path in self.path_dict.values(): - dc.read(path)[0] - - def time_spool(self): - """Time for basic scanning of all datafiles.""" - for path in self.path_dict.values(): - dc.spool(path)[0] diff --git a/benchmarks/memory_spool_benchmarks.py b/benchmarks/memory_spool_benchmarks.py deleted file mode 100644 index 75db3e875..000000000 --- a/benchmarks/memory_spool_benchmarks.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Benchmark for generic memory spool operations.""" - -from __future__ import annotations - -import dascore as dc - - -class ChunkSuite: - """Benchmark for chunking patches inside spools.""" - - def setup(self): - """Get test spools.""" - self._spool_no_gap = dc.get_example_spool("random_das", length=10) - self._spool_no_overlap = dc.get_example_spool( - "random_das", length=10, time_gap=10 - ) - self._diverse_spool = dc.get_example_spool("diverse_das") - - def _chunk_n_check(self, spool, length: int | None = 1, time=None): - """Helper function to merge and check the spool.""" - new = spool.chunk(time=time) - if length is not None: - assert len(new) == length - # just in case we make spools more lazy, force iteration - for patch in new: - assert isinstance(patch, dc.Patch) - - def time_contiguous_merge(self): - """Time merging contiguous patches from in-memory spool.""" - self._chunk_n_check(self._spool_no_gap) - - def time_no_overlap_merge(self): - """Timing for trying to chunk patches that have no overlap.""" - self._chunk_n_check(self._spool_no_gap) - - def time_diverse_merge(self): - """Time trying to merge the diverse spool.""" - self._chunk_n_check(self._diverse_spool, length=None) - - def time_1second_chunk(self): - """Time chunking for one second along no gap spool.""" - self._chunk_n_check(self._spool_no_gap, time=1, length=None) - - def time_half_second_chunk(self): - """Time chunking for 0.5 along no gap spool.""" - self._chunk_n_check(self._spool_no_gap, time=0.5, length=None) - - -class SelectSuite: - """Suite of selection timing.""" - - def setup(self): - """Get test spools.""" - self._spool_no_gap = dc.get_example_spool("random_das", length=10) - self._spool_no_gap_df = self._spool_no_gap.get_contents() - self._diverse_spool = dc.get_example_spool("diverse_das") - - def time_select_full_range(self): - """Timing selecting the full time range.""" - df, spool = self._spool_no_gap_df, self._spool_no_gap - t1, t2 = df["time_min"].min(), df["time_max"].max() - spool.select(time=(t1, t2)) - spool.select(time=(None, t2)) - spool.select(time=(t1, None)) - - def time_select_half_range(self): - """Time selecting and trimming.""" - df, spool = self._spool_no_gap_df, self._spool_no_gap - t1, t2 = df["time_min"].min(), df["time_max"].max() - duration = (t2 - t1) / 2 - spool.select(time=(t1, t2 - duration)) - spool.select(time=(t1 + duration, t2)) - - def time_select_strings(self): - """Time select non-dimensional selects.""" - spool = self._diverse_spool - spool.select(tag="some_tag") - spool.select(station="wayout") - - def time_select_string_match(self): - """Time select non-dimensional selects.""" - spool = self._diverse_spool - spool.select(tag="some_*") - spool.select(station="wayou?") diff --git a/benchmarks/patch_benchmarks.py b/benchmarks/patch_benchmarks.py deleted file mode 100644 index c1def980d..000000000 --- a/benchmarks/patch_benchmarks.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Benchmarks for patch functions.""" - -from __future__ import annotations - -import numpy as np - -import dascore as dc -from dascore.utils.patch import get_start_stop_step - - -class ProcessingSuite: - """Suite for processing functions.""" - - patch: dc.Patch - - def setup(self): - """Just load the default patch.""" - self.patch = dc.get_example_patch() - - def time_pass_filter(self): - """Time the pass filter.""" - self.patch.pass_filter(distance=(0.1, 0.2)) - self.patch.pass_filter(time=(10.2, None)) - self.patch.pass_filter(time=(None, 100.22)) - self.patch.pass_filter(time=(10, 100)) - - def time_median_filter(self): - """Time the median filter.""" - self.patch.median_filter(distance=5, time=5, samples=True) - self.patch.median_filter(time=5, samples=True) - - def time_resample(self): - """Time resample operations.""" - # upsample time - start, stop, step = get_start_stop_step(self.patch, "time") - self.patch.interpolate(time=np.arange(start, stop, step / 2)) - # up sample distance - start, stop, step = get_start_stop_step(self.patch, "distance") - new_coord = np.arange(start, stop, step / 2.2) - self.patch.interpolate(distance=new_coord) - - def time_decimate(self): - """Timing decimate.""" - self.patch.decimate(time=2) - self.patch.decimate(time=10, filter_type="iir") - self.patch.decimate(time=10, filter_type="fir") - self.patch.decimate(time=10, filter_type=None) - - def time_select(self): - """Timing select.""" - self.patch.select(distance=(100, 200)) - t1 = self.patch.attrs["time_min"] + np.timedelta64(1, "s") - t2 = t1 + np.timedelta64(3, "s") - self.patch.select(time=(None, t1)) - self.patch.select(time=(t1, None)) - self.patch.select(time=(t1, t2)) - - -class TransformSuite: - """Timing for various transformations.""" - - def setup(self): - """Just load the default patch.""" - self.patch = dc.get_example_patch() - self.dft_patch = self.patch.tran.dft("time") - - def time_indefinite_integrate(self): - """Integrate along time axis.""" - self.patch.integrate(dim="time", definite=False) - - def time_definite_integrate(self): - """Integrate along time axis.""" - self.patch.integrate(dim="time", definite=True) - - def time_differentiate(self): - """Differentiate along time axis.""" - self.patch.differentiate(dim="time") - - def time_dft(self): - """The discrete fourier transform.""" - self.patch.dft(dim="time") - - def time_idft(self): - """The inverse of the fourier transform.""" - self.dft_patch.idft() - - -class VizSuite: - """Timing for visualizations.""" - - patch: dc.Patch - - def setup(self): - """Just load the default patch.""" - self.patch = dc.get_example_patch() - - def teardown(self): - """Just load the default patch.""" - import matplotlib.pyplot as plt - - plt.close("all") - - def time_waterfall(self): - """Timing for waterfall patch.""" - patch = self.patch - patch.viz.waterfall() - - def time_str(self): - """Timing for getting str rep.""" - str(self.patch) diff --git a/benchmarks/readme.md b/benchmarks/readme.md index 1a17fe31f..df439b850 100644 --- a/benchmarks/readme.md +++ b/benchmarks/readme.md @@ -1,3 +1,42 @@ # Benchmarks -These are DASCore's benchmark suite. They are run using the python package [Airspeed Velocity](https://github.com/airspeed-velocity/asv), but a simple script has been setup for convenience (scripts/run_benchmarks.py). +DASCore's benchmark suite uses [CodSpeed](https://codspeed.io/) for continuous performance monitoring. + +## Running Benchmarks Locally + +To run benchmarks locally: + +```bash +# Install test dependencies (includes pytest-codspeed) +pip install -e ".[test]" + +# Run all benchmarks +pytest benchmarks/ --codspeed + +# Run specific benchmark files +pytest benchmarks/test_patch_benchmarks.py --codspeed +pytest benchmarks/test_io_benchmarks.py --codspeed +pytest benchmarks/test_spool_benchmarks.py --codspeed +``` + +## Benchmark Structure + +Benchmarks are now organized as pytest tests in the `benchmarks/` directory: + +- `test_patch_benchmarks.py` - Core Patch processing, transform, and visualization benchmarks +- `test_io_benchmarks.py` - File I/O operations benchmarks +- `test_spool_benchmarks.py` - Spool chunking and selection benchmarks + +Each benchmark uses the `@pytest.mark.benchmark` decorator to automatically measure performance. + +## Continuous Performance Monitoring + +Benchmarks automatically run on: +- Push to main/master branch +- Pull requests + +Performance results are tracked by CodSpeed and reported in pull requests, helping identify performance regressions before they're merged. + +## Migration Notes + +The legacy ASV benchmarks in the `benchmarks/` directory have been converted to pytest format. The new benchmarks maintain the same functionality while providing better integration with the existing test suite. diff --git a/benchmarks/test_io_benchmarks.py b/benchmarks/test_io_benchmarks.py new file mode 100644 index 000000000..f9dda83ff --- /dev/null +++ b/benchmarks/test_io_benchmarks.py @@ -0,0 +1,65 @@ +"""Benchmarks for generic IO operations using pytest-codspeed.""" + +from __future__ import annotations + +from contextlib import suppress +from functools import cache + +import pytest + +import dascore as dc +from dascore.exceptions import MissingOptionalDependencyError +from dascore.utils.downloader import fetch, get_registry_df + + +@cache +def get_test_file_paths(): + """Get a dict of name: path for all files in data registry.""" + df = get_registry_df().loc[lambda x: ~x["name"].str.endswith(".csv")] + out = {row["name"]: fetch(row["name"]) for _, row in df.iterrows()} + return out + + +@pytest.fixture(scope="session") +def test_file_paths(): + """Get paths of test files.""" + return get_test_file_paths() + + +class TestIOBenchmarks: + """Benchmarks for IO operations.""" + + @pytest.mark.benchmark + def test_scan_performance(self, test_file_paths): + """Time for basic scanning of all datafiles.""" + for path in test_file_paths.values(): + with suppress(MissingOptionalDependencyError): + dc.scan(path) + + @pytest.mark.benchmark + def test_scan_df_performance(self, test_file_paths): + """Time for basic scanning of all datafiles to DataFrame.""" + for path in test_file_paths.values(): + with suppress(MissingOptionalDependencyError): + dc.scan_to_df(path) + + @pytest.mark.benchmark + def test_get_format_performance(self, test_file_paths): + """Time for format detection of all datafiles.""" + for path in test_file_paths.values(): + with suppress(MissingOptionalDependencyError): + dc.get_format(path) + + @pytest.mark.benchmark + def test_read_performance(self, test_file_paths): + """Time for basic reading of all datafiles.""" + for path in test_file_paths.values(): + with suppress(MissingOptionalDependencyError): + dc.read(path)[0] + + @pytest.mark.benchmark + def test_spool_performance(self, test_file_paths): + """Time for creating spools from all datafiles.""" + for path in test_file_paths.values(): + with suppress(MissingOptionalDependencyError): + dc.spool(path)[0] diff --git a/benchmarks/test_patch_benchmarks.py b/benchmarks/test_patch_benchmarks.py new file mode 100644 index 000000000..16c5ed151 --- /dev/null +++ b/benchmarks/test_patch_benchmarks.py @@ -0,0 +1,303 @@ +"""Benchmarks for patch functions using pytest-codspeed.""" + +from __future__ import annotations + +import matplotlib.pyplot as plt +import numpy as np +import pytest + +import dascore as dc +from dascore.utils.patch import get_start_stop_step + + +@pytest.fixture(scope="module") +def example_patch(): + """Get the example patch for benchmarks.""" + return dc.get_example_patch() + + +@pytest.fixture(scope="module") +def patch_uneven_time(): + """Get a patch with uneven time coord.""" + patch = dc.get_example_patch() + time = patch.get_coord("time") + rand = np.random.RandomState(39) + new_vales = rand.random(len(time)) + return patch.update_coords(time=new_vales) + + +@pytest.fixture() +def cleanup_mpl(): + """Close all open matplotlib figures after test.""" + yield + plt.close("all") + + +class TestProcessingBenchmarks: + """Benchmarks for patch processing operations.""" + + @pytest.fixture(scope="module") + def interp_time(self, example_patch): + """Get an array for interpolation.""" + # This is a fixture as to not affect the timing. + patch = example_patch + # upsample time + start, stop, step = get_start_stop_step(patch, "time") + step = dc.to_timedelta64(dc.to_float(step) / 2) + return np.arange(start, stop, step) + + @pytest.mark.benchmark + def test_pass_filter_performance(self, example_patch): + """Time the pass filter.""" + patch = example_patch + patch.pass_filter(distance=(0.1, 0.2)) + patch.pass_filter(time=(10.2, None)) + patch.pass_filter(time=(None, 100.22)) + patch.pass_filter(time=(10, 100)) + + @pytest.mark.benchmark + def test_median_filter_performance(self, example_patch): + """Time the median filter.""" + patch = example_patch + patch.median_filter(distance=5, time=5, samples=True) + patch.median_filter(time=5, samples=True) + + @pytest.mark.benchmark + def test_interpolate_performance(self, example_patch, interp_time): + """Time interpolate operations.""" + patch = example_patch + patch.interpolate(time=interp_time) + + @pytest.mark.benchmark + def test_decimate_performance(self, example_patch): + """Time decimation.""" + patch = example_patch + patch.decimate(time=2) + patch.decimate(time=10, filter_type="iir") + patch.decimate(time=10, filter_type="fir") + patch.decimate(time=10, filter_type=None) + + @pytest.mark.benchmark + def test_select_performance(self, example_patch): + """Selecting on time/distance dimension""" + patch = example_patch + patch.select(distance=(100, 200)) + t1 = patch.attrs["time_min"] + np.timedelta64(1, "s") + t2 = t1 + np.timedelta64(3, "s") + patch.select(time=(None, t1)) + patch.select(time=(t1, None)) + patch.select(time=(t1, t2)) + + @pytest.mark.benchmark + def test_sobel_filter_performance(self, example_patch): + """Time the Sobel filter.""" + patch = example_patch + patch.sobel_filter(dim="time") + + @pytest.mark.benchmark + def test_standardize_performance(self, example_patch): + """Time standardization operation.""" + patch = example_patch + patch.standardize(dim="time") + + @pytest.mark.benchmark + def test_taper_performance(self, example_patch): + """Time tapering operations.""" + patch = example_patch + patch.taper(time=0.1) + + @pytest.mark.benchmark + def test_transpose_performance(self, example_patch): + """Time transpose operations.""" + patch = example_patch + dims = patch.dims[::-1] + patch.transpose(*dims) + + @pytest.mark.benchmark + def test_roll_performance(self, example_patch): + """Time roll/shift operations.""" + patch = example_patch + patch.roll(time=10, samples=True) + + @pytest.mark.benchmark + def test_snap_coords_performance(self, patch_uneven_time): + """Time coordinate snapping.""" + patch = patch_uneven_time + patch.snap_coords("time") + + @pytest.mark.benchmark + def test_hampel_filter_performance(self, example_patch): + """Time the Hampel filter.""" + patch = example_patch + patch.hampel_filter(threshold=3.0, time=5, samples=True) + patch.hampel_filter( + threshold=2.5, distance=3, time=5, samples=True, separable=True + ) + + @pytest.mark.benchmark + def test_wiener_filter_performance(self, example_patch): + """Time the Wiener filter.""" + patch = example_patch + patch.wiener_filter(time=3, samples=True) + + +class TestTransformBenchmarks: + """Benchmarks for patch transform operations.""" + + @pytest.fixture + def dft_patch(self, example_patch): + """Get DFT patch for benchmarks.""" + return example_patch.dft("time") + + @pytest.mark.benchmark + def test_indefinite_integrate_performance(self, example_patch): + """Integrate along time axis.""" + example_patch.integrate(dim="time", definite=False) + + @pytest.mark.benchmark + def test_definite_integrate_performance(self, example_patch): + """Integrate along time axis.""" + example_patch.integrate(dim="time", definite=True) + + @pytest.mark.benchmark + def test_differentiate_performance(self, example_patch): + """Differentiate along time axis.""" + example_patch.differentiate(dim="time") + + @pytest.mark.benchmark + def test_dft_performance(self, example_patch): + """The discrete fourier transform.""" + example_patch.dft(dim="time") + + @pytest.mark.benchmark + def test_idft_performance(self, dft_patch): + """The inverse of the fourier transform.""" + dft_patch.idft() + + @pytest.mark.benchmark + def test_stft(self, example_patch): + """Time short time fourier transform transform.""" + patch = example_patch + patch.stft(time=1, overlap=0.25) + + @pytest.mark.benchmark + def test_hilbert_performance(self, example_patch): + """Time Hilbert transform.""" + patch = example_patch + patch.hilbert(dim="time") + + @pytest.mark.benchmark + def test_envelope_performance(self, example_patch): + """Time envelope calculation.""" + patch = example_patch + patch.envelope(dim="time") + + +class TestVisualizationBenchmarks: + """Benchmarks for patch visualization operations (or str repr).""" + + @pytest.mark.benchmark + def test_waterfall_performance(self, example_patch, cleanup_mpl): + """Timing for waterfall patch.""" + example_patch.viz.waterfall() + + @pytest.mark.benchmark + def test_str_performance(self, example_patch): + """Timing for getting str rep.""" + str(example_patch) + + @pytest.mark.benchmark + def test_repr_performance(self, example_patch): + """Time representation generation.""" + repr(example_patch) + + @pytest.mark.benchmark + def test_wiggle_performance(self, example_patch, cleanup_mpl): + """Time wiggle plot visualization.""" + patch = example_patch.select(distance=(0, 100)) # Subset for performance + patch.viz.wiggle() + + +class TestAggregationBenchmarks: + """Benchmarks for patch aggregation operations.""" + + @pytest.mark.benchmark + def test_mean_performance(self, example_patch): + """Time mean aggregation.""" + patch = example_patch + patch.mean(dim="time") + patch.mean(dim="distance") + + @pytest.mark.benchmark + def test_max_performance(self, example_patch): + """Time max aggregation.""" + patch = example_patch + patch.max(dim="time") + patch.max(dim="distance") + + @pytest.mark.benchmark + def test_min_performance(self, example_patch): + """Time min aggregation.""" + patch = example_patch + patch.min(dim="time") + patch.min(dim="distance") + + @pytest.mark.benchmark + def test_std_performance(self, example_patch): + """Time standard deviation aggregation.""" + patch = example_patch + patch.std(dim="time") + patch.std(dim="distance") + + @pytest.mark.benchmark + def test_sum_performance(self, example_patch): + """Time sum aggregation.""" + patch = example_patch + patch.sum(dim="time") + patch.sum(dim="distance") + + @pytest.mark.benchmark + def test_median_performance(self, example_patch): + """Time median aggregation.""" + patch = example_patch + patch.median(dim="time") + patch.median(dim="distance") + + @pytest.mark.benchmark + def test_first_performance(self, example_patch): + """Time first aggregation.""" + patch = example_patch + patch.first(dim="time") + + @pytest.mark.benchmark + def test_last_performance(self, example_patch): + """Time last aggregation.""" + patch = example_patch + patch.last(dim="distance") + + +class TestRollingBenchmarks: + """Benchmarks for rolling window operations.""" + + @pytest.fixture(scope="module") + def small_roller(self, example_patch): + """Get a rolling object""" + return example_patch.rolling(time=5, samples=True) + + @pytest.fixture(scope="module") + def big_roller(self, example_patch): + """Get a large rolling object""" + patch = example_patch + time = patch.get_coord("time") + roll_time = dc.to_float(time.coord_range()) / 4 + return example_patch.rolling(time=roll_time) + + @pytest.mark.benchmark + def test_rolling_small_roller_mean_performance(self, small_roller): + """Time rolling mean calculation for small roller.""" + small_roller.mean() + + @pytest.mark.benchmark + def test_rolling_large_roller_mean_performance(self, big_roller): + """Time rolling mean calculation.""" + big_roller.mean() diff --git a/benchmarks/test_spool_benchmarks.py b/benchmarks/test_spool_benchmarks.py new file mode 100644 index 000000000..7b1bfc119 --- /dev/null +++ b/benchmarks/test_spool_benchmarks.py @@ -0,0 +1,107 @@ +"""Benchmarks for spool operations using pytest-codspeed.""" + +from __future__ import annotations + +import pytest + +import dascore as dc + + +@pytest.fixture +def spool_no_gap(): + """Get test spool with no gaps.""" + return dc.get_example_spool("random_das", length=10) + + +@pytest.fixture +def gapped_spool_no_overlap(): + """Get test spool with no overlap.""" + return dc.get_example_spool("random_das", length=10, time_gap=10) + + +@pytest.fixture +def diverse_spool(): + """Get diverse test spool.""" + return dc.get_example_spool("diverse_das") + + +def _chunk_and_check(spool, length: int | None = 1, time=None): + """Helper function to merge and check the spool.""" + new = spool.chunk(time=time) + if length is not None: + assert len(new) == length + # Check contents + for patch in new: + assert isinstance(patch, dc.Patch) + + +class TestChunkBenchmarks: + """Benchmarks for spool chunking operations.""" + + @pytest.mark.benchmark + def test_contiguous_merge_performance(self, spool_no_gap): + """Time merging contiguous patches from in-memory spool.""" + _chunk_and_check(spool_no_gap) + + @pytest.mark.benchmark + def test_no_overlap_merge_performance(self, gapped_spool_no_overlap): + """Timing for trying to chunk patches that have no overlap.""" + chunked = gapped_spool_no_overlap.chunk(time=None) + # In this case the spool should not be merged. + assert len(chunked) == len(gapped_spool_no_overlap) + + @pytest.mark.benchmark + def test_diverse_merge_performance(self, diverse_spool): + """Time trying to merge the diverse spool.""" + _chunk_and_check(diverse_spool, length=None) + + @pytest.mark.benchmark + def test_1second_chunk_performance(self, spool_no_gap): + """Time chunking for one second along no gap spool.""" + _chunk_and_check(spool_no_gap, time=1, length=None) + + @pytest.mark.benchmark + def test_half_second_chunk_performance(self, spool_no_gap): + """Time chunking for 0.5 along no gap spool.""" + _chunk_and_check(spool_no_gap, time=0.5, length=None) + + +class TestSelectionBenchmarks: + """Benchmarks for spool selection operations.""" + + @pytest.fixture + def spool_no_gap_df(self, spool_no_gap): + """Get contents dataframe for no gap spool.""" + return spool_no_gap.get_contents() + + @pytest.mark.benchmark + def test_select_full_range_performance(self, spool_no_gap, spool_no_gap_df): + """Timing selecting the full time range.""" + df, spool = spool_no_gap_df, spool_no_gap + t1, t2 = df["time_min"].min(), df["time_max"].max() + spool.select(time=(t1, t2)) + spool.select(time=(None, t2)) + spool.select(time=(t1, None)) + + @pytest.mark.benchmark + def test_select_half_range_performance(self, spool_no_gap, spool_no_gap_df): + """Time selecting and trimming.""" + df, spool = spool_no_gap_df, spool_no_gap + t1, t2 = df["time_min"].min(), df["time_max"].max() + duration = (t2 - t1) / 2 + spool.select(time=(t1, t2 - duration)) + spool.select(time=(t1 + duration, t2)) + + @pytest.mark.benchmark + def test_select_strings_performance(self, diverse_spool): + """Time select non-dimensional selects.""" + spool = diverse_spool + spool.select(tag="some_tag") + spool.select(station="wayout") + + @pytest.mark.benchmark + def test_select_string_match_performance(self, diverse_spool): + """Time select non-dimensional selects with wildcards.""" + spool = diverse_spool + spool.select(tag="some_*") + spool.select(station="wayou?") diff --git a/docs/contributing/profiling_benchmarks.qmd b/docs/contributing/profiling_benchmarks.qmd index ef6417c90..7cfd8aa14 100644 --- a/docs/contributing/profiling_benchmarks.qmd +++ b/docs/contributing/profiling_benchmarks.qmd @@ -3,30 +3,30 @@ title: Profiling and Benchmarks --- # Benchmarks -DASCore uses [Airspeed Velocity (asv)](https://github.com/airspeed-velocity/) to create and run a simple benchmark suite. The benchmarks are found in the benchmark folder at the top level of the repository. - -The [asv documentation](https://asv.readthedocs.io/en/stable/) is great, and you can do all sorts of things with the benchmarks. For the most common use case, that is to compare changes in performance on a development branch to the main branch, you can simply use two scripts in the script directory. +DASCore uses [codspeed](https://codspeed.io/) to create and run a simple benchmark suite. The benchmarks are found in the benchmarks folder at the top level of the repository. To run the benchmarks: ```bash -python scripts/run_benchmarks.py +python -m pytest benchmarks/ --codspeed ``` -And to get a simple (text-based) visualization of the differences: -```bash -python scripts/visualize_benchmarks.py -``` +However, when you create a pull request, the benchmarks will be run in the CI/CD and a report displayed. If there are significant performance regressions, more discussion is needed before merging the pull request. -If you notice any significant degradations, it is probably worth looking into via profiling. +If you add significant new functionality, you should probably add a benchmark. # Profiling -You can profile the problematic benchmark(s) to see *why* their performance degraded. This is done with the [asv profile](https://asv.readthedocs.io/en/stable/commands.html#id13) command. +If you find a significant issue, you can profile the problematic benchmark(s) to see *why* their performance degraded. This can be done with the [pytest profile plugin](https://pypi.org/project/pytest-profiling/). -For example, suppose you notice a large increase in runtime for the time_pass_filter benchmark in the patch_benchmarks' ProcessingSuite. You can load a nice interactive [snakeviz](https://jiffyclub.github.io/snakeviz/) gui (assuming you installed snakeviz `pip install snakeviz`) like so: +For example, suppose you notice a large increase in runtime for the pass_filter benchmark in the patch_benchmarks' TestProcessingBenchmarks class. Run the benchmark again under profiling: ```bash -asv profile patch_benchmarks.ProcessingSuite.time_pass_filter -E existing --gui snakeviz +pytest benchmarks/test_patch_benchmarks.py::TestProcessingBenchmarks::test_pass_filter_performance --profile +``` +This will create a new `prof` folder with `test_pass_filter_performance.prof` as one of the files. You can view these with a variety of tools, such as [snakeviz](https://jiffyclub.github.io/snakeviz/) (assuming you installed snakeviz with `pip install snakeviz`). + +```bash +snakeviz prof/test_pass_filter_performance.prof ``` You can then click through the call stack and see which functions can be improved. After tweaking them, re-run the profiling and see if the overall time improves. diff --git a/docs/contributors.qmd b/docs/contributors.qmd index c4c1aeff5..9f117d85c 100644 --- a/docs/contributors.qmd +++ b/docs/contributors.qmd @@ -1,5 +1,3 @@ # Contributors A huge thanks to [all the DASCore contributors](https://github.com/DASDAE/dascore/graphs/contributors)! - -[![Contributors](https://contrib.rocks/image?repo=DASDAE/dascore)](https://github.com/DASDAE/dascore/graphs/contributors) diff --git a/pyproject.toml b/pyproject.toml index 361c74ecd..4bc0b38f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,15 +83,20 @@ test = [ "twine", ] -profile = [ - "asv", - "typer", +profile_base = [ + "pytest-codspeed", + "pytest-profiling", ] all = ["dascore[extras]"] +profile = ["dascore[test]", "dascore[profile_base]"] + dev = ["dascore[test]", "dascore[docs]", "dascore[profile]", "dascore[extras]"] + + + # --- URLs for project [project.urls] diff --git a/readme.md b/readme.md index d14228fb2..2791d1bc8 100644 --- a/readme.md +++ b/readme.md @@ -3,6 +3,7 @@ A python library for distributed fiber optic sensing. [![coverage](https://codecov.io/gh/dasdae/dascore/branch/master/graph/badge.svg)](https://codecov.io/gh/dasdae/dascore) +[![CodSpeed Badge](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/DASDAE/dascore) [![PyPI Version](https://img.shields.io/pypi/v/dascore.svg)](https://pypi.python.org/pypi/dascore) [![supported versions](https://img.shields.io/pypi/pyversions/dascore.svg?label=python_versions)](https://pypi.python.org/pypi/dascore) [![PyPI Downloads](https://img.shields.io/pypi/dm/dascore.svg?label=pypi)](https://pypi.org/project/dascore/) @@ -17,3 +18,5 @@ Documentation [[stable](https://dascore.org), [development](https://dascore.netl [Citation](https://seismica.library.mcgill.ca/article/view/1184) > Chambers, D., Jin, G., Tourei, A., Issah, A. H. S., Lellouch, A., Martin, E., Zhu, D., Girard, A., Yuan, S., Cullison, T., Snyder, T., Kim, S., Danes, N., Pnithan, N., Boltz, M. S. & Mendoza, M. M. (2024). DASCore: a Python Library for Distributed Fiber Optic Sensing. Seismica, 3(2). + +[![Contributors](https://contrib.rocks/image?repo=DASDAE/dascore)](https://github.com/DASDAE/dascore/graphs/contributors) diff --git a/scripts/_benchmark_uilts.py b/scripts/_benchmark_uilts.py deleted file mode 100644 index f07537844..000000000 --- a/scripts/_benchmark_uilts.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Utils for running benchmarks.""" - -from __future__ import annotations - -import os -from contextlib import contextmanager -from pathlib import Path -from subprocess import run - -from rich.console import Console - -console = Console() - - -BASE_PATH = Path(__file__).absolute().parent.parent -REFERENCE_BRANCH = "master" - - -@contextmanager -def cd(path): - """Change directory temporarily.""" - current = os.getcwd() - os.chdir(path) - try: - yield - except Exception: - os.chdir(current) - raise - else: - os.chdir(current) - - -def run_asv(name): - """Run asv, rename output.""" - expected_output_file = BASE_PATH / ".asv" / "results" / "benchmarks.json" - if expected_output_file.exists(): - expected_output_file.unlink() - # check for machine file, if it doesnt exist just use defaults. - machine_file_path = Path.home() / ".asv-machine.json" - if not machine_file_path.exists(): - run("asv machine --yes", check=True, shell=True) - assert machine_file_path.exists() - # tell the user what is going on - console.print() - console.rule(f"[bold red]Running benchmarks for {name}") - console.print() - # run benchmarks - hash = git_hash() - cmd = f"asv run -E existing --set-commit-hash {hash}" - run(cmd, check=True, shell=True) - assert expected_output_file.exists() - return - - -def compare_asv(hash1, hash2): - """Compare two commit hash in something vaguely approximating a table.""" - kwargs = dict(check=True, shell=True, capture_output=True, text=True) - out = run(f"asv compare {hash1} {hash2}", **kwargs) - return out.stdout.strip() - - -def git_branch_name(): - """Return the current branch name.""" - kwargs = dict(check=True, shell=True, capture_output=True, text=True) - out = run("git branch --show-current", **kwargs) - return out.stdout.strip() - - -def git_hash(branch_name=None): - """Return the current git hash name.""" - branch = branch_name or git_branch_name() - kwargs = dict(check=True, shell=True, capture_output=True, text=True) - out = run(f"git rev-parse --short --verify {branch}", **kwargs) - return out.stdout.strip() - - -@contextmanager -def git_checkout(new_branch): - """Checkout branch/commit within context manager.""" - - def _checkout(name): - """Checkout something in git.""" - run(f"git checkout {name}", check=True, shell=True) - return - - current = git_branch_name() - _checkout(new_branch) - try: - yield - except Exception: - _checkout(current) - raise - else: - _checkout(current) diff --git a/scripts/run_benchmarks.py b/scripts/run_benchmarks.py deleted file mode 100644 index 91d36280c..000000000 --- a/scripts/run_benchmarks.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Python script to run benchmarks for dascore. - -It works by using airspeed velocity to run code on current commit, -collecting the results, then running code on reference branch (eg master) -the resetting the repo to current commit. - -A few notes: -- Before running this script make sure all your changes are committed. The - script will raise an exception otherwise. -- asv can usually be run in a more intelligent way, but it doesn't yet - support projects without setup.py files, so this is a bit of a hack - until that gets fixed. -""" - -from __future__ import annotations - -from _benchmark_uilts import ( - BASE_PATH, - REFERENCE_BRANCH, - cd, - git_branch_name, - git_checkout, - run_asv, -) - -if __name__ == "__main__": - with cd(BASE_PATH): - current_name = git_branch_name() - # first run benchmarks of reference. - with git_checkout(REFERENCE_BRANCH): - run_asv(REFERENCE_BRANCH) - # now run benchmarks on current branch. - run_asv(current_name) diff --git a/scripts/visualize_benchmarks.py b/scripts/visualize_benchmarks.py deleted file mode 100644 index 2a48c622f..000000000 --- a/scripts/visualize_benchmarks.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Load the benchmark files and create simple table.""" - -from __future__ import annotations - -from _benchmark_uilts import REFERENCE_BRANCH, compare_asv, console, git_hash - -if __name__ == "__main__": - commit1 = git_hash(REFERENCE_BRANCH) - commit2 = git_hash() - out = compare_asv(commit1, commit2) - console.print(out) diff --git a/tests/test_io/test_common_io.py b/tests/test_io/test_common_io.py index 8dd3bf30c..2061ebc95 100644 --- a/tests/test_io/test_common_io.py +++ b/tests/test_io/test_common_io.py @@ -12,9 +12,10 @@ from contextlib import contextmanager, suppress from functools import cache -from io import BytesIO +from io import BytesIO, UnsupportedOperation from operator import eq, ge, le from pathlib import Path +from urllib import error as urllib_error import numpy as np import pandas as pd @@ -67,7 +68,6 @@ "iDAS005_hdf5_example.626.h5", ), H5Simple(): ("h5_simple_2.h5", "h5_simple_1.h5"), - DASDAEV1(): ("example_dasdae_event_1.h5",), APSensingV10(): ("ap_sensing_1.hdf5",), Febus2(): ("febus_1.h5",), OptoDASV8(): ("opto_das_1.hdf5",), @@ -79,7 +79,6 @@ ), Terra15FormatterV5(): ("terra15_v5_test_file.hdf5",), Terra15FormatterV6(): ("terra15_v6_test_file.hdf5",), - Terra15FormatterV6(): ("terra15_v6_test_file.hdf5",), SegyV1_0(): ("conoco_segy_1.sgy",), DASHDF5(): ("PoroTomo_iDAS_1.h5",), SentekV5(): ("DASDMSShot00_20230328155653619.das",), @@ -97,6 +96,26 @@ SKIP_DATA_FILES = {"whale_1.hdf5", "brady_hs_DAS_DTS_coords.csv"} +@contextmanager +def skip_missing(): + """Skip if missing dependencies found.""" + try: + yield + except MissingOptionalDependencyError as exc: + pytest.skip(f"Missing optional dependency required to read file: {exc}") + except TimeoutError as exc: + pytest.skip(f"Unable to fetch data due to timeout: {exc}") + + +@contextmanager +def skip_timeout(): + """Skip if downloading file times out.""" + try: + yield + except (TimeoutError, urllib_error.URLError) as exc: + pytest.skip(f"Unable to fetch data due to timeout: {exc}") + + @cache def _cached_read(path, io=None): """ @@ -107,7 +126,7 @@ def _cached_read(path, io=None): read = dc.read else: read = io.read - with skip_missing_dependency(): + with skip_missing(): out = read(path) return out @@ -121,15 +140,6 @@ def _get_flat_io_test(): return flat_io -@contextmanager -def skip_missing_dependency(): - """Skip if missing dependencies found.""" - try: - yield - except MissingOptionalDependencyError: - pytest.skip("Missing optional dep to read file.") - - @pytest.fixture(scope="session", params=list(COMMON_IO_READ_TESTS)) def io_instance(request): """Fixture for returning fiber io instances.""" @@ -143,7 +153,8 @@ def io_path_tuple(request): This is used for common testing. """ io, fetch_name = request.param - return io, fetch(fetch_name) + with skip_timeout(): + return io, fetch(fetch_name) @pytest.fixture(scope="session", params=get_registry_df()["name"]) @@ -153,13 +164,14 @@ def data_file_path(request): # Some files should be skipped if not DAS or too big. if str(param) in SKIP_DATA_FILES: pytest.skip(f"Skipping {param}") - return fetch(request.param) + with skip_timeout(): + return fetch(request.param) @pytest.fixture(scope="session") def read_spool(data_file_path): """Read each file into a spool.""" - with skip_missing_dependency(): + with skip_missing(): out = dc.read(data_file_path) return out @@ -167,7 +179,7 @@ def read_spool(data_file_path): @pytest.fixture(scope="session") def scanned_attrs(data_file_path): """Read each file into a spool.""" - with skip_missing_dependency(): + with skip_missing(): out = dc.scan(data_file_path) return out @@ -242,7 +254,8 @@ def test_all_other_files_arent_format(self, io_instance): if isinstance(other_io, type(io_instance)): continue for key in data_files: - path = fetch(key) + with skip_timeout(): + path = fetch(key) out = io_instance.get_format(path) if out: format_name, version = out @@ -275,15 +288,21 @@ def test_read_stream(self, io_path_tuple): """If the format supports reading from a stream, test it out.""" io, path = io_path_tuple req_type = getattr(io.read, "_required_type", None) - if not isinstance(req_type, BinaryReader): - msg = f"{io} doesn't require a read type." - pytest.skip(msg) + if req_type is not BinaryReader: + pytest.skip(f"{io} doesn't support BinaryReader streams.") + spool1 = _cached_read(path) # write file contents to bytes io and ensure it can be read. bio = BytesIO() - bio.write(Path(io_path_tuple).read_bytes()) + bio.write(Path(path).read_bytes()) bio.seek(0) - spool2 = io.read(bio) + try: + spool2 = io.read(bio) + except (AttributeError, OSError, UnsupportedOperation) as e: + # Skip if the format doesn't support BytesIO (e.g., missing + # 'name' attribute, fileno() not supported, or other BytesIO + # incompatibilities) + pytest.skip(f"{io} doesn't support BytesIO streams: {e}") for patch1, patch2 in zip(spool1, spool2): assert patch1.equals(patch2) @@ -293,7 +312,7 @@ def test_slice_single_dim_both_ends(self, io_path_tuple): a patch containing the requested data is returned. """ io, path = io_path_tuple - with skip_missing_dependency(): + with skip_missing(): attrs_from_file = dc.scan(path) assert len(attrs_from_file) # skip files that have more than one patch for now @@ -341,7 +360,7 @@ class TestScan: def test_scan_basics(self, data_file_path): """Ensure each file can be scanned.""" - with skip_missing_dependency(): + with skip_missing(): attrs_list = dc.scan(data_file_path) assert len(attrs_list) @@ -352,7 +371,7 @@ def test_scan_basics(self, data_file_path): def test_scan_has_version_and_format(self, io_path_tuple): """Scan output should contain version and format.""" io, path = io_path_tuple - with skip_missing_dependency(): + with skip_missing(): attr_list = io.scan(path) for attrs in attr_list: assert attrs.file_format == io.name @@ -422,7 +441,7 @@ def test_scan_attrs_match_patch_attrs(self, data_file_path): "tag", "network", ) - with skip_missing_dependency(): + with skip_missing(): scan_attrs_list = dc.scan(data_file_path) patch_attrs_list = [x.attrs for x in _cached_read(data_file_path)] assert len(scan_attrs_list) == len(patch_attrs_list) @@ -431,13 +450,19 @@ def test_scan_attrs_match_patch_attrs(self, data_file_path): # first compare dimensions are related attributes for dim in pat_attrs1.dim_tuple: assert getattr(pat_attrs1, f"{dim}_min") == getattr( - pat_attrs1, f"{dim}_min" + scan_attrs2, f"{dim}_min" ) for dim_attr in DIM_RELATED_ATTRS: attr_name = dim_attr.format(dim=dim) attr1 = getattr(pat_attrs1, attr_name) - attr2 = getattr(pat_attrs1, attr_name) - assert attr1 == attr2 + attr2 = getattr(scan_attrs2, attr_name) + # Use close comparison for floating point values + if isinstance(attr1, float | np.floating) and isinstance( + attr2, float | np.floating + ): + np.testing.assert_allclose(attr1, attr2, rtol=1e-12) + else: + assert attr1 == attr2 # then other expected attributes. for attr_name in comp_attrs: patch_attr = getattr(pat_attrs1, attr_name)