├── .github └── workflows │ ├── publish-to-pypi.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── poetry.lock ├── pyproject.toml ├── src └── rainflow.py └── tests └── test_rainflow.py /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python distributions to PyPI and TestPyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build-n-publish: 11 | name: Build and publish Python distributions to PyPI and TestPyPI 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@master 16 | 17 | - name: Set up Python 3.11 18 | uses: actions/setup-python@v1 19 | with: 20 | python-version: 3.11 21 | 22 | - name: Install pypa/build 23 | run: >- 24 | python -m 25 | pip install 26 | build 27 | --user 28 | 29 | - name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 30 | run: >- 31 | python -m 32 | build 33 | --sdist 34 | --wheel 35 | --outdir dist/ 36 | . 37 | 38 | - name: Publish distribution 📦 to Test PyPI 39 | uses: pypa/gh-action-pypi-publish@master 40 | with: 41 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 42 | repository_url: https://test.pypi.org/legacy/ 43 | 44 | - name: Publish distribution 📦 to PyPI 45 | if: startsWith(github.ref, 'refs/tags') 46 | uses: pypa/gh-action-pypi-publish@master 47 | with: 48 | password: ${{ secrets.PYPI_API_TOKEN }} 49 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test rainflow 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Install Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install Poetry 25 | uses: snok/install-poetry@v1.3 26 | with: 27 | version: 1.3.2 28 | 29 | - name: Setup virtualenv 30 | run: poetry env use ${{ matrix.python-version }} 31 | 32 | - name: Install rainflow 33 | run: poetry install --no-interaction 34 | 35 | - name: Run tests 36 | run: poetry run pytest 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary files 2 | *~ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | .pytest_cache/ 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *,cover 51 | .hypothesis/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | 60 | # Sphinx documentation 61 | docs/_build/ 62 | 63 | # PyBuilder 64 | target/ 65 | 66 | #Ipython Notebook 67 | .ipynb_checkpoints 68 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [3.2.0] - 2023-04-16 10 | 11 | ### Changed 12 | - (#68, #70) Dropped support for Python 2.7 and 3.6. 13 | - (#66) `importlib.metadata` no longer used to evaluate `rainflow.__version__`. 14 | 15 | ## [3.1.1] - 2021-12-28 16 | 17 | ### Fixed 18 | 19 | - (#62) Due to floating point accuracy `count_cycles` sometimes returned 20 | more bins than what was specified by argument `nbins`. Thank you 21 | [Kyle6699](https://github.com/Kyle6699) for reporting the bug and 22 | providing a test case. 23 | 24 | ## [3.1.0] - 2021-11-16 25 | 26 | ### Changed 27 | - (#56) `extract_cycles` now returns no cycles for very short time series 28 | (containing zero or one reversals). Contributed by 29 | [denis-jasselette-jc](https://github.com/denis-jasselette-jc). 30 | - (#57) Dropped tests for Python 3.4 and 3.5 due to the extra work required 31 | to make the CI and tests work for these old versions. 32 | 33 | ## [3.0.1] - 2020-11-18 34 | 35 | ### Fixed 36 | 37 | - (#48) Fixed a bug which caused some bins to appear twice, with counts 38 | distributed randomly between the two. 39 | Contributed by [CWE0](https://github.com/CWE0). 40 | 41 | ## [3.0.0] - 2020-04-20 42 | 43 | ### Changed 44 | - (#35) By default, the first and the last points in the time series 45 | are treated as reversals. 46 | - (#37) Function `reversals` now yields index and value of each reversal, 47 | instead of value only. 48 | - (#38) Function `extract_cycles` now yields range, mean, count, start index and 49 | end index for each cycle instead of low, high and count. 50 | - (#43) Arguments `binsize` and `nbins` to `count_cycles` produce bins which 51 | include the right edge and exclude the left edge. 52 | 53 | ### Removed 54 | - (#35) Removed optional arguments `left` and `right` to functions 55 | `reversals`, `extract_cycles` and `count_cycles`. The new behaviour 56 | correspods to `left=True` and `right=True`. 57 | 58 | ## [2.2.0] - 2019-10-23 59 | 60 | ### Added 61 | - (#22) Function `count_cycles` now accepts optional arguments `binsize` and `nbins` 62 | for binning cycle ranges. Contributed by [gsokoll](https://github.com/gsokoll). 63 | 64 | ## [2.1.2] - 2018-06-14 65 | 66 | ### Changed 67 | - (#18) Function `extract_cycles` yields trailing half-cycles in the same order 68 | as these cycles start. Contributed by [oysteoh](https://github.com/oysteoh). 69 | - Licence changed to MIT (previously GPL v3) 70 | 71 | ## [2.1.1] - 2018-04-17 72 | 73 | ### Changed 74 | - Long description based on `README.md` is now included in the distribution package. 75 | 76 | ## [2.1.0] - 2018-04-17 77 | 78 | ### Added 79 | - (#10) Added optional arguments `left` and `right` to functions 80 | `reversals`, `extract_cycles` and `count_cycles`. The arguments 81 | tell whether the first and the last point in the time series 82 | should be treated as a reversal (both `False` by default). 83 | 84 | ## [2.0.0] - 2018-02-25 85 | 86 | ### Changed 87 | - (#7) Function `extract_cycles` is now a generator and yields low, high and count 88 | for each cycle. Previously the function returned ranges only. 89 | 90 | ## [1.0.2] - 2016-12-06 91 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Piotr Janiszewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Rainflow 2 | ======== 3 | 4 | [![Test rainflow](https://github.com/iamlikeme/rainflow/actions/workflows/tests.yml/badge.svg)](https://github.com/iamlikeme/rainflow/actions/workflows/tests.yml) 5 | 6 | `rainflow` is a Python implementation of the ASTM E1049-85 rainflow cycle counting 7 | algorythm for fatigue analysis. 8 | 9 | Installation 10 | ------------ 11 | 12 | `rainflow` is available [on PyPI](https://pypi.org/project/rainflow/): 13 | 14 | ``` 15 | pip install rainflow 16 | ``` 17 | 18 | and [on conda-forge](https://github.com/conda-forge/rainflow-feedstock): 19 | 20 | ``` 21 | conda install rainflow --channel conda-forge 22 | ``` 23 | 24 | Usage 25 | ----- 26 | 27 | See release notes in [`CHANGELOG.md`](CHANGELOG.md). 28 | 29 | Let's generate a sample time series. 30 | Here we simply generate a list of floats but `rainflow` works 31 | with any sequence of numbers, including numpy arrays and pandas Series. 32 | 33 | ```python 34 | from math import sin, cos 35 | 36 | time = [4.0 * i / 200 for i in range(200 + 1)] 37 | signal = [0.2 + 0.5 * sin(t) + 0.2 * cos(10*t) + 0.2 * sin(4*t) for t in time] 38 | ``` 39 | 40 | Function `count_cycles` returns a sorted list of ranges and the corresponding 41 | number of cycles: 42 | 43 | ```python 44 | import rainflow 45 | 46 | rainflow.count_cycles(signal) 47 | # Output 48 | [(0.04258965150708488, 0.5), 49 | (0.10973439445727551, 1.0), 50 | (0.11294628078612906, 0.5), 51 | (0.2057106991158965, 1.0), 52 | (0.21467990941625242, 1.0), 53 | (0.4388985979776988, 1.0), 54 | (0.48305748051348263, 0.5), 55 | (0.5286423866535466, 0.5), 56 | (0.7809330293159786, 0.5), 57 | (1.4343610172143002, 0.5)] 58 | ``` 59 | 60 | Cycle ranges can be binned or rounded to a specified number of digits 61 | using optional arguments *binsize*, *nbins* or *ndigits*: 62 | 63 | ```python 64 | rainflow.count_cycles(signal, binsize=0.5) 65 | # Output 66 | [(0.5, 5.5), (1.0, 1.0), (1.5, 0.5)] 67 | 68 | rainflow.count_cycles(signal, ndigits=1) 69 | # Output 70 | [(0.0, 0.5), 71 | (0.1, 1.5), 72 | (0.2, 2.0), 73 | (0.4, 1.0), 74 | (0.5, 1.0), 75 | (0.8, 0.5), 76 | (1.4, 0.5)] 77 | ``` 78 | 79 | Full information about each cycle, including mean value, can be obtained 80 | using the `extract_cycles` function: 81 | 82 | ```python 83 | for rng, mean, count, i_start, i_end in rainflow.extract_cycles(signal): 84 | print(rng, mean, count, i_start, i_end) 85 | # Output 86 | 0.04258965150708488 0.4212948257535425 0.5 0 3 87 | 0.11294628078612906 0.38611651111402034 0.5 3 13 88 | ... 89 | 0.4388985979776988 0.18268137509849586 1.0 142 158 90 | 1.4343610172143002 0.3478109852897205 0.5 94 200 91 | ``` 92 | 93 | Running tests 94 | ------------- 95 | 96 | ``` 97 | pip install .[dev] 98 | pytest 99 | ``` 100 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "atomicwrites" 5 | version = "1.4.0" 6 | description = "Atomic file writes." 7 | category = "dev" 8 | optional = false 9 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 10 | files = [ 11 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 12 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 13 | ] 14 | 15 | [[package]] 16 | name = "attrs" 17 | version = "21.2.0" 18 | description = "Classes Without Boilerplate" 19 | category = "dev" 20 | optional = false 21 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 22 | files = [ 23 | {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, 24 | {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, 25 | ] 26 | 27 | [package.extras] 28 | dev = ["coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"] 29 | docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] 30 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"] 31 | tests-no-zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"] 32 | 33 | [[package]] 34 | name = "colorama" 35 | version = "0.4.4" 36 | description = "Cross-platform colored terminal text." 37 | category = "dev" 38 | optional = false 39 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 40 | files = [ 41 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 42 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 43 | ] 44 | 45 | [[package]] 46 | name = "importlib-metadata" 47 | version = "2.1.2" 48 | description = "Read metadata from Python packages" 49 | category = "dev" 50 | optional = false 51 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 52 | files = [ 53 | {file = "importlib_metadata-2.1.2-py2.py3-none-any.whl", hash = "sha256:cd6a92d78385dd145f5f233b3a6919acf5e8e43922aa9b9dbe78573e3540eb56"}, 54 | {file = "importlib_metadata-2.1.2.tar.gz", hash = "sha256:09db40742204610ef6826af16e49f0479d11d0d54687d0169ff7fddf8b3f557f"}, 55 | ] 56 | 57 | [package.dependencies] 58 | zipp = ">=0.5" 59 | 60 | [package.extras] 61 | docs = ["rst.linker", "sphinx"] 62 | testing = ["importlib-resources (>=1.3)", "packaging", "pep517", "unittest2"] 63 | 64 | [[package]] 65 | name = "iniconfig" 66 | version = "1.1.1" 67 | description = "iniconfig: brain-dead simple config-ini parsing" 68 | category = "dev" 69 | optional = false 70 | python-versions = "*" 71 | files = [ 72 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 73 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 74 | ] 75 | 76 | [[package]] 77 | name = "packaging" 78 | version = "21.2" 79 | description = "Core utilities for Python packages" 80 | category = "dev" 81 | optional = false 82 | python-versions = ">=3.6" 83 | files = [ 84 | {file = "packaging-21.2-py3-none-any.whl", hash = "sha256:14317396d1e8cdb122989b916fa2c7e9ca8e2be9e8060a6eff75b6b7b4d8a7e0"}, 85 | {file = "packaging-21.2.tar.gz", hash = "sha256:096d689d78ca690e4cd8a89568ba06d07ca097e3306a4381635073ca91479966"}, 86 | ] 87 | 88 | [package.dependencies] 89 | pyparsing = ">=2.0.2,<3" 90 | 91 | [[package]] 92 | name = "pluggy" 93 | version = "1.0.0" 94 | description = "plugin and hook calling mechanisms for python" 95 | category = "dev" 96 | optional = false 97 | python-versions = ">=3.6" 98 | files = [ 99 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 100 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 101 | ] 102 | 103 | [package.dependencies] 104 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 105 | 106 | [package.extras] 107 | dev = ["pre-commit", "tox"] 108 | testing = ["pytest", "pytest-benchmark"] 109 | 110 | [[package]] 111 | name = "py" 112 | version = "1.11.0" 113 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 114 | category = "dev" 115 | optional = false 116 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 117 | files = [ 118 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 119 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 120 | ] 121 | 122 | [[package]] 123 | name = "pyparsing" 124 | version = "2.4.7" 125 | description = "Python parsing module" 126 | category = "dev" 127 | optional = false 128 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 129 | files = [ 130 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 131 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 132 | ] 133 | 134 | [[package]] 135 | name = "pytest" 136 | version = "6.2.5" 137 | description = "pytest: simple powerful testing with Python" 138 | category = "dev" 139 | optional = false 140 | python-versions = ">=3.6" 141 | files = [ 142 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 143 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 144 | ] 145 | 146 | [package.dependencies] 147 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 148 | attrs = ">=19.2.0" 149 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 150 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 151 | iniconfig = "*" 152 | packaging = "*" 153 | pluggy = ">=0.12,<2.0" 154 | py = ">=1.8.2" 155 | toml = "*" 156 | 157 | [package.extras] 158 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 159 | 160 | [[package]] 161 | name = "toml" 162 | version = "0.10.2" 163 | description = "Python Library for Tom's Obvious, Minimal Language" 164 | category = "dev" 165 | optional = false 166 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 167 | files = [ 168 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 169 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 170 | ] 171 | 172 | [[package]] 173 | name = "zipp" 174 | version = "1.2.0" 175 | description = "Backport of pathlib-compatible object wrapper for zip files" 176 | category = "dev" 177 | optional = false 178 | python-versions = ">=2.7" 179 | files = [ 180 | {file = "zipp-1.2.0-py2.py3-none-any.whl", hash = "sha256:e0d9e63797e483a30d27e09fffd308c59a700d365ec34e93cc100844168bf921"}, 181 | {file = "zipp-1.2.0.tar.gz", hash = "sha256:c70410551488251b0fee67b460fb9a536af8d6f9f008ad10ac51f615b6a521b1"}, 182 | ] 183 | 184 | [package.extras] 185 | docs = ["jaraco.packaging (>=3.2)", "rst.linker (>=1.9)", "sphinx"] 186 | testing = ["func-timeout", "jaraco.itertools", "pathlib2", "unittest2"] 187 | 188 | [metadata] 189 | lock-version = "2.0" 190 | python-versions = ">=3.7" 191 | content-hash = "f060c82905f13d620ba562410bcfd95497dbfbc36f913a6f263d472b4f8269a6" 192 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "rainflow" 3 | version = "3.2.0" 4 | description = "Implementation of ASTM E1049-85 rainflow cycle counting algorithm" 5 | authors = ["Piotr Janiszewski "] 6 | license = "MIT" 7 | readme = "README.md" 8 | repository = "https://github.com/iamlikeme/rainflow" 9 | classifiers = [ 10 | "Development Status :: 5 - Production/Stable", 11 | "Intended Audience :: Science/Research", 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | "Programming Language :: Python", 15 | "Programming Language :: Python :: 3.7", 16 | "Programming Language :: Python :: 3.8", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Topic :: Scientific/Engineering", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | ] 23 | 24 | [tool.poetry.dependencies] 25 | python = ">=3.7" 26 | 27 | [tool.poetry.dev-dependencies] 28 | pytest = [ 29 | { version = "~4", python = "~2.7" }, 30 | { version = "*", python = "~3" }, 31 | { version = "^6.2.5", python = "~3.10" }, 32 | ] 33 | importlib_metadata = { version = "*", python = "<3.8" } 34 | more-itertools = { version = "<6", python = "~2.7" } 35 | 36 | [build-system] 37 | requires = ["poetry-core>=1.0.0"] 38 | build-backend = "poetry.core.masonry.api" 39 | -------------------------------------------------------------------------------- /src/rainflow.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Implements rainflow cycle counting algorythm for fatigue analysis 4 | according to section 5.4.4 in ASTM E1049-85 (2011). 5 | """ 6 | from __future__ import division 7 | from collections import deque, defaultdict 8 | import math 9 | 10 | __version__ = "3.2.0" 11 | 12 | 13 | def _get_round_function(ndigits=None): 14 | if ndigits is None: 15 | def func(x): 16 | return x 17 | else: 18 | def func(x): 19 | return round(x, ndigits) 20 | return func 21 | 22 | 23 | def reversals(series): 24 | """Iterate reversal points in the series. 25 | 26 | A reversal point is a point in the series at which the first derivative 27 | changes sign. Reversal is undefined at the first (last) point because the 28 | derivative before (after) this point is undefined. The first and the last 29 | points are treated as reversals. 30 | 31 | Parameters 32 | ---------- 33 | series : iterable sequence of numbers 34 | 35 | Yields 36 | ------ 37 | Reversal points as tuples (index, value). 38 | """ 39 | series = iter(series) 40 | 41 | x_last, x = next(series, None), next(series, None) 42 | if x_last is None or x is None: 43 | return 44 | 45 | d_last = (x - x_last) 46 | 47 | yield 0, x_last 48 | index = None 49 | for index, x_next in enumerate(series, start=1): 50 | if x_next == x: 51 | continue 52 | d_next = x_next - x 53 | if d_last * d_next < 0: 54 | yield index, x 55 | x_last, x = x, x_next 56 | d_last = d_next 57 | 58 | if index is not None: 59 | yield index + 1, x_next 60 | 61 | 62 | def extract_cycles(series): 63 | """Iterate cycles in the series. 64 | 65 | Parameters 66 | ---------- 67 | series : iterable sequence of numbers 68 | 69 | Yields 70 | ------ 71 | cycle : tuple 72 | Each tuple contains (range, mean, count, start index, end index). 73 | Count equals to 1.0 for full cycles and 0.5 for half cycles. 74 | """ 75 | points = deque() 76 | 77 | def format_output(point1, point2, count): 78 | i1, x1 = point1 79 | i2, x2 = point2 80 | rng = abs(x1 - x2) 81 | mean = 0.5 * (x1 + x2) 82 | return rng, mean, count, i1, i2 83 | 84 | for point in reversals(series): 85 | points.append(point) 86 | 87 | while len(points) >= 3: 88 | # Form ranges X and Y from the three most recent points 89 | x1, x2, x3 = points[-3][1], points[-2][1], points[-1][1] 90 | X = abs(x3 - x2) 91 | Y = abs(x2 - x1) 92 | 93 | if X < Y: 94 | # Read the next point 95 | break 96 | elif len(points) == 3: 97 | # Y contains the starting point 98 | # Count Y as one-half cycle and discard the first point 99 | yield format_output(points[0], points[1], 0.5) 100 | points.popleft() 101 | else: 102 | # Count Y as one cycle and discard the peak and the valley of Y 103 | yield format_output(points[-3], points[-2], 1.0) 104 | last = points.pop() 105 | points.pop() 106 | points.pop() 107 | points.append(last) 108 | else: 109 | # Count the remaining ranges as one-half cycles 110 | while len(points) > 1: 111 | yield format_output(points[0], points[1], 0.5) 112 | points.popleft() 113 | 114 | 115 | def count_cycles(series, ndigits=None, nbins=None, binsize=None): 116 | """Count cycles in the series. 117 | 118 | Parameters 119 | ---------- 120 | series : iterable sequence of numbers 121 | ndigits : int, optional 122 | Round cycle magnitudes to the given number of digits before counting. 123 | Use a negative value to round to tens, hundreds, etc. 124 | nbins : int, optional 125 | Specifies the number of cycle-counting bins. 126 | binsize : int, optional 127 | Specifies the width of each cycle-counting bin 128 | 129 | Arguments ndigits, nbins and binsize are mutually exclusive. 130 | 131 | Returns 132 | ------- 133 | A sorted list containing pairs of range and cycle count. 134 | The counts may not be whole numbers because the rainflow counting 135 | algorithm may produce half-cycles. If binning is used then ranges 136 | correspond to the right (high) edge of a bin. 137 | """ 138 | if sum(value is not None for value in (ndigits, nbins, binsize)) > 1: 139 | raise ValueError( 140 | "Arguments ndigits, nbins and binsize are mutually exclusive" 141 | ) 142 | 143 | counts = defaultdict(float) 144 | cycles = ( 145 | (rng, count) 146 | for rng, mean, count, i_start, i_end in extract_cycles(series) 147 | ) 148 | 149 | if nbins is not None: 150 | binsize = (max(series) - min(series)) / nbins 151 | 152 | if binsize is not None: 153 | nmax = 0 154 | for rng, count in cycles: 155 | quotient = rng / binsize 156 | n = int(math.ceil(quotient)) # using int for Python 2 compatibility 157 | 158 | if nbins and n > nbins: 159 | # Due to floating point accuracy we may get n > nbins, 160 | # in which case we move rng to the preceeding bin. 161 | if (quotient % 1) > 1e-6: 162 | raise Exception("Unexpected error") 163 | n = n - 1 164 | 165 | counts[n * binsize] += count 166 | nmax = max(n, nmax) 167 | 168 | for i in range(1, nmax): 169 | counts.setdefault(i * binsize, 0.0) 170 | 171 | elif ndigits is not None: 172 | round_ = _get_round_function(ndigits) 173 | for rng, count in cycles: 174 | counts[round_(rng)] += count 175 | 176 | else: 177 | for rng, count in cycles: 178 | counts[rng] += count 179 | 180 | return sorted(counts.items()) 181 | -------------------------------------------------------------------------------- /tests/test_rainflow.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import pytest 3 | import rainflow 4 | import random 5 | import math 6 | 7 | try: 8 | from importlib import metadata as importlib_metadata 9 | except ImportError: 10 | import importlib_metadata as importlib_metadata 11 | 12 | # A test case is a tuple containing the following items: 13 | # - a list representing a time series 14 | # - a list of tuples, each containing: 15 | # cycle range, cycle mean, count (0.5 or 1.0), start index, end index 16 | # - a list of tuples, each containing: cycle range, cycles 17 | # - a boolean that indicates whether range and mean values 18 | # are approximate (True) or exact (False) 19 | TEST_CASE_1 = ( 20 | [-2, 1, -3, 5, -1, 3, -4, 4, -2], 21 | [ 22 | (3, -0.5, 0.5, 0, 1), 23 | (4, -1.0, 0.5, 1, 2), 24 | (4, 1.0, 1.0, 4, 5), 25 | (8, 1.0, 0.5, 2, 3), 26 | (9, 0.5, 0.5, 3, 6), 27 | (8, 0.0, 0.5, 6, 7), 28 | (6, 1.0, 0.5, 7, 8), 29 | ], 30 | [ 31 | (3, 0.5), 32 | (4, 1.5), 33 | (6, 0.5), 34 | (8, 1.0), 35 | (9, 0.5), 36 | ], 37 | False, 38 | ) 39 | TEST_CASE_2 = ( 40 | [ 41 | -1.5, 1.0, -3.0, 10.0, -1.0, 3.0, -8.0, 4.0, -2.0, 6.0, 42 | -1.0, -4.0, -8.0, 2.0, 1.0, -5.0, 0.0, 2.5, -4.0, 1.0, 43 | 0.0, 2.0, -0.5, 44 | ], 45 | [ 46 | (2.5, -0.25, 0.5, 0, 1), 47 | (4.0, -1.00, 0.5, 1, 2), 48 | (4.0, 1.00, 1.0, 4, 5), 49 | (13.0, 3.50, 0.5, 2, 3), 50 | (6.0, 1.00, 1.0, 7, 8), 51 | (14.0, -1.00, 1.0, 6, 9), 52 | (7.0, -1.50, 1.0, 13, 15), 53 | (1.0, 0.50, 1.0, 19, 20), 54 | (18.0, 1.00, 0.5, 3, 12), 55 | (10.5, -2.75, 0.5, 12, 17), 56 | (6.5, -0.75, 0.5, 17, 18), 57 | (6.0, -1.00, 0.5, 18, 21), 58 | (2.5, 0.75, 0.5, 21, 22), 59 | ], 60 | [ 61 | (1.0, 1.0), 62 | (2.5, 1.0), 63 | (4.0, 1.5), 64 | (6.0, 1.5), 65 | (6.5, 0.5), 66 | (7.0, 1.0), 67 | (10.5, 0.5), 68 | (13.0, 0.5), 69 | (14.0, 1.0), 70 | (18.0, 0.5), 71 | ], 72 | False, 73 | ) 74 | TEST_CASE_3 = ( 75 | [ 76 | 0.8 * math.sin(0.01 * math.pi * i) + 0.2 * math.sin(0.032 * math.pi * i) 77 | for i in range(1001) 78 | ], 79 | [ 80 | (0.09020631993390904, 0.638796382297327, 1.0, 26, 45), 81 | (0.7841230166856958, 0.3920615083428479, 0.5, 0, 70), 82 | (0.02555985050659182, -0.556567582861063, 1.0, 122, 134), 83 | (1.6512875405599494, -0.04152075359427887, 0.5, 70, 166), 84 | (1.7986374238678868, 0.03215418805968978, 0.5, 166, 261), 85 | (1.906532656127566, -0.02179342807014989, 0.5, 261, 357), 86 | (1.9722034009805518, 0.011041944356343036, 0.5, 357, 452), 87 | (0.025559850506592485, 0.5565675828610637, 1.0, 866, 878), 88 | (0.09020631993390937, -0.6387963822973273, 1.0, 955, 974), 89 | (1.9942872896932382, -5.551115123125783e-17, 0.5, 452, 548), 90 | (1.9722034009805514, -0.01104194435634337, 0.5, 548, 643), 91 | (1.906532656127565, 0.021793428070149834, 0.5, 643, 739), 92 | (1.7986374238678864, -0.032154188059689504, 0.5, 739, 834), 93 | (1.6512875405599488, 0.04152075359427937, 0.5, 834, 930), 94 | (0.7841230166856932, -0.39206150834284836, 0.5, 930, 1000), 95 | ], 96 | [ 97 | (0.025559850506591708, 1.0), 98 | (0.025559850506592263, 1.0), 99 | (0.09020631993390904, 1.0), 100 | (0.09020631993390937, 1.0), 101 | (0.7841230166856958, 0.5), 102 | (0.7841230166856961, 0.5), 103 | (1.6512875405599488, 0.5), 104 | (1.651287540559949, 0.5), 105 | (1.798637423867886, 0.5), 106 | (1.7986374238678877, 0.5), 107 | (1.9065326561275655, 0.5), 108 | (1.9065326561275668, 0.5), 109 | (1.9722034009805516, 0.5), 110 | (1.9722034009805522, 0.5), 111 | (1.9942872896932382, 0.5) 112 | ], 113 | True, 114 | ) 115 | 116 | 117 | def test_version(): 118 | # Ensure that rainflow.__version__ is the same as version set in pyproject.toml 119 | assert rainflow.__version__ == importlib_metadata.version("rainflow") 120 | 121 | 122 | @pytest.mark.parametrize( 123 | ("series", "cycles", "counts", "approx"), 124 | [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3], 125 | ) 126 | def test_count_cycles(series, cycles, counts, approx): 127 | result = rainflow.count_cycles(series) 128 | if approx: 129 | expected = [(pytest.approx(rng), count) for rng, count in counts] 130 | else: 131 | expected = counts 132 | assert result == expected 133 | 134 | 135 | @pytest.mark.parametrize( 136 | ("series", "cycles", "counts", "approx"), 137 | [TEST_CASE_1, TEST_CASE_2], 138 | ) 139 | def test_count_cycles_ndigits(series, cycles, counts, approx): 140 | series = [x + 0.01 * random.random() for x in series] 141 | assert rainflow.count_cycles(series) != counts 142 | assert rainflow.count_cycles(series, ndigits=1) == counts 143 | 144 | 145 | def test_count_cycles_nbins(): 146 | series = TEST_CASE_1[0] 147 | assert rainflow.count_cycles(series, nbins=1) == [(9, 4.0)] 148 | assert rainflow.count_cycles(series, nbins=2) == [ 149 | (4.5, 2.0), 150 | (9.0, 2.0), 151 | ] 152 | assert rainflow.count_cycles(series, nbins=5) == [ 153 | (1.8, 0.0), 154 | (3.6, 0.5), 155 | (5.4, 1.5), 156 | (7.2, 0.5), 157 | (9.0, 1.5), 158 | ] 159 | assert rainflow.count_cycles(series, nbins=9) == [ 160 | (1.0, 0.0), 161 | (2.0, 0.0), 162 | (3.0, 0.5), 163 | (4.0, 1.5), 164 | (5.0, 0.0), 165 | (6.0, 0.5), 166 | (7.0, 0.0), 167 | (8.0, 1.0), 168 | (9.0, 0.5), 169 | ] 170 | assert rainflow.count_cycles(series, nbins=10) == [ 171 | (0.9, 0.0), 172 | (1.8, 0.0), 173 | (2.7, 0.0), 174 | (3.6, 0.5), 175 | (4.5, 1.5), 176 | (5.4, 0.0), 177 | (6.3, 0.5), 178 | (7.2, 0.0), 179 | (8.1, 1.0), 180 | (9.0, 0.5), 181 | ] 182 | 183 | 184 | def test_count_cycles_binsize_case_1(): 185 | series = TEST_CASE_1[0] 186 | assert rainflow.count_cycles(series, binsize=10) == [(10, 4.0)] 187 | assert rainflow.count_cycles(series, binsize=9) == [(9, 4.0)] 188 | assert rainflow.count_cycles(series, binsize=5) == [ 189 | (5, 2.0), 190 | (10, 2.0), 191 | ] 192 | assert rainflow.count_cycles(series, binsize=3) == [ 193 | (3, 0.5), 194 | (6, 2.0), 195 | (9, 1.5), 196 | ] 197 | assert rainflow.count_cycles(series, binsize=2) == [ 198 | (2, 0.0), 199 | (4, 2.0), 200 | (6, 0.5), 201 | (8, 1.0), 202 | (10, 0.5), 203 | ] 204 | assert rainflow.count_cycles(series, binsize=1) == [ 205 | (1, 0.0), 206 | (2, 0.0), 207 | (3, 0.5), 208 | (4, 1.5), 209 | (5, 0.0), 210 | (6, 0.5), 211 | (7, 0.0), 212 | (8, 1.0), 213 | (9, 0.5), 214 | ] 215 | 216 | 217 | def test_count_cycles_binsize_case_3(): 218 | series = TEST_CASE_3[0] 219 | result = rainflow.count_cycles(series, binsize=0.2) 220 | expected = [ 221 | (pytest.approx(rng), count) 222 | for rng, count in [ 223 | (0.2, 4.0), 224 | (0.4, 0.0), 225 | (0.6, 0.0), 226 | (0.8, 1.0), 227 | (1.0, 0.0), 228 | (1.2, 0.0), 229 | (1.4, 0.0), 230 | (1.6, 0.0), 231 | (1.8, 2.0), 232 | (2.0, 2.5), 233 | ] 234 | ] 235 | assert result == expected 236 | 237 | 238 | @pytest.mark.parametrize( 239 | ("series", "cycles", "counts", "approx"), 240 | [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3], 241 | ) 242 | def test_count_cycles_series_with_zero_derivatives(series, cycles, counts, approx): 243 | series = list(itertools.chain.from_iterable([x, x] for x in series)) 244 | result = rainflow.count_cycles(series) 245 | if approx: 246 | expected = [(pytest.approx(rng), count) for rng, count in counts] 247 | else: 248 | expected = counts 249 | assert result == expected 250 | 251 | 252 | def test_count_cycles_exclusive_arguments(): 253 | series = TEST_CASE_1[0] 254 | 255 | with pytest.raises(ValueError): 256 | rainflow.count_cycles(series, nbins=1, binsize=1) 257 | 258 | with pytest.raises(ValueError): 259 | rainflow.count_cycles(series, nbins=1, ndigits=1) 260 | 261 | with pytest.raises(ValueError): 262 | rainflow.count_cycles(series, binsize=1, ndigits=1) 263 | 264 | 265 | @pytest.mark.parametrize( 266 | ("series", "cycles", "counts", "approx"), 267 | [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3], 268 | ) 269 | def test_extract_cycles(series, cycles, counts, approx): 270 | result = list(rainflow.extract_cycles(series)) 271 | if approx: 272 | expected = [ 273 | (pytest.approx(rng), pytest.approx(mean), count, i, j) 274 | for (rng, mean, count, i, j) in cycles 275 | ] 276 | else: 277 | expected = cycles 278 | assert result == expected 279 | 280 | 281 | @pytest.mark.parametrize( 282 | ("series", "cycles"), 283 | [ 284 | ([], []), 285 | ([1], []), 286 | ([1, 2], []), 287 | ([1, 2, 3], [(2, 2.0, 0.5, 0, 2)]), 288 | ] 289 | ) 290 | def test_extract_cycles_small_series(series, cycles): 291 | assert list(rainflow.extract_cycles(series)) == cycles 292 | 293 | 294 | @pytest.mark.parametrize( 295 | ("series", "cycles", "counts", "approx"), 296 | [TEST_CASE_1, TEST_CASE_2, TEST_CASE_3], 297 | ) 298 | def test_reversals_yield_value(series, cycles, counts, approx): 299 | for index, value in rainflow.reversals(series): 300 | assert value == series[index] 301 | 302 | 303 | @pytest.mark.parametrize( 304 | ("series", "reversals"), 305 | [ 306 | ([], []), 307 | ([1], []), 308 | ([1, 2], [(0, 1)]), 309 | ([1, 2, 3], [(0, 1), (2, 3)]), 310 | ] 311 | ) 312 | def test_reversals_small_series(series, reversals): 313 | assert list(rainflow.reversals(series)) == reversals 314 | 315 | 316 | def test_num_bins(): 317 | # This test checks for a bug reported in issue #60 where the 318 | # returned number of bins was different than the nbins argument 319 | # due to floating point accuracy. 320 | series = [ 321 | 0, 322 | 3517.860166127188, 323 | -3093.4966492094213, 324 | 0, 325 | ] 326 | nbins = 100 327 | result = rainflow.count_cycles(series, nbins=nbins) 328 | assert len(result) == nbins 329 | --------------------------------------------------------------------------------