Source code for pylife.stress.rainflow.general

# Copyright (c) 2019-2026 - for information on the respective copyright owner
# see the NOTICE file and/or the repository
# https://github.com/boschresearch/pylife
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Provide common streaming helpers for rainflow detectors and recorders."""

__author__ = "Johannes Mueller"
__maintainer__ = __author__

from abc import ABCMeta, abstractmethod
import warnings
import numpy as np
import pandas as pd

from .recorders import AbstractRecorder, LoopValueRecorder  # noqa: F401  (re-exported for backward compatibility)

__all__ = ['find_turns', 'AbstractDetector', 'AbstractRecorder']


[docs] def find_turns(samples): """Find turning points in a sample chunk. Parameters ---------- samples : numpy.ndarray One-dimensional sample chunk containing load values, typically in MPa. Returns ------- index : numpy.ndarray Indices where ``samples`` has a turning point. turns : numpy.ndarray Load values at the turning points. Warnings -------- Any ``NaN`` values are dropped from the input signal before processing it and will thus also not appear in the turns. In those cases a warning is issued. The reason for this is, that if the ``NaN`` appears next to an actual turning point the turning point is no longer detected which will lead to an underestimation of the damage sum later in the damage calculation. Generally you should not have ``NaN`` values in your signal. If you do, it would be a good idea to clean them out before the rainflow detection. Notes ----- In case of plateaus, i.e. multiple directly neighboring samples with exactly the same value building a turning point together, the first sample of the plateau is indexed. """ def clean_nans(samples: np.ndarray): nans = np.asarray(pd.isna(samples)) if nans.any(): warnings.warn(UserWarning("At least one NaN like value has been dropped from the input signal.")) return samples[~nans], nans return samples, None def correct_turns_by_nans(index, nans): if nans is None: return nan_positions = np.where(nans)[0] for nan_pos in nan_positions: index[index >= nan_pos] += 1 def plateau_turns(diffs): plateau_turns = np.zeros_like(diffs, dtype=np.bool_)[1:] duplicates = np.array(diffs == 0, dtype=np.int8) if duplicates.any(): edges = np.diff(duplicates) dups_starts = np.where(edges > 0)[0] dups_ends = np.where(edges < 0)[0] if len(dups_starts) and len(dups_ends): cut_ends = dups_ends[0] < dups_starts[0] cut_starts = dups_starts[-1] > dups_ends[-1] if cut_ends: dups_ends = dups_ends[1:] if cut_starts: dups_starts = dups_starts[:-1] plateau_turns[dups_starts[np.where(diffs[dups_starts] * diffs[dups_ends+1] < 0)]] = True return plateau_turns samples, nans = clean_nans(samples) diffs = np.diff(samples) # find indices where /\ or \/ peak_turns = diffs[:-1] * diffs[1:] < 0.0 index = np.where(np.logical_or(peak_turns, plateau_turns(diffs)))[0] + 1 turns_values = samples[index] correct_turns_by_nans(index, nans) return index, turns_values
[docs] class AbstractDetector(metaclass=ABCMeta): """Define the common base class for streaming rainflow detectors. Subclasses implement a concrete rainflow counting rule, such as three-point or four-point counting. A detector receives load samples and reports closed hysteresis loops to its recorder through ``record_values()``. Detectors that know the source sample indices also call ``record_index()`` and ``report_chunk()``. Parameters ---------- recorder : AbstractRecorder, optional Recorder that receives the detected rainflow loops. If ``None`` (the default), a new :class:`pylife.stress.rainflow.LoopValueRecorder` is created for this detector. Notes ----- Implement ``process()`` so processing is independent of chunk size: ``detector.process(signal)`` should be equivalent to ``detector.process(signal[:n]).process(signal[n:])`` for any split point ``0 < n < len(signal)``. This enables streamed rainflow counting for large signals. Rainflow counting in pyLife is based on common three-point and four-point algorithms, including the DIN 45667 / ASTM E1049 family of rainflow counting methods. """
[docs] def __init__(self, recorder=None): """Instantiate a detector base. Parameters ---------- recorder : AbstractRecorder, optional Recorder that receives detected rainflow loops. If ``None`` (the default), a new :class:`pylife.stress.rainflow.LoopValueRecorder` is created for this detector. """ self._sample_tail = np.array([]) self._recorder = recorder if recorder is not None else LoopValueRecorder() self._head_index = 0 self._residual_index = np.array([0], dtype=np.uintp) self._residuals = np.array([]) self._is_flushing_enabled = False
@property def residuals(self): """Return the residual turning points of the signal processed so far. Returns ------- numpy.ndarray Load values of turning points that have not yet formed closed hysteresis loops. """ return self._residuals @property def residual_index(self): """Return the sample indices of residual turning points. Returns ------- numpy.ndarray Global sample indices of residual turning points. """ return np.append(self._residual_index, self._head_index - 1) @property def recorder(self): """Return the recorder that receives detected loops. Returns ------- AbstractRecorder Recorder instance passed at construction time. """ return self._recorder
[docs] @abstractmethod def process(self, samples, flush=False): """Process a sample chunk. Parameters ---------- samples : array_like Load samples to process, typically in MPa. flush : bool, optional Whether to flush cached values at the end. See :meth:`pylife.stress.rainflow.FourPointDetector.process` for the user-facing behavior. Default is ``False``. Returns ------- AbstractDetector The detector itself so processing can be chained. See Also -------- flush : Process samples and force cached values to be emitted. Notes ----- Must be implemented by subclasses. """ return self
[docs] def flush(self, samples=[]): """Flush cached values after processing an optional final sample chunk. If ``process`` is called instead of ``flush``, the last value of a load sequence is cached for a subsequent call to ``process``, because it may or may not be a turning point of the sequence. Using ``flush`` forces processing of the last value. This may not be the desired effect as multiple increasing or decreasing values in a row could occur, instead of processing only turning points. Parameters ---------- samples : array_like, optional Final load samples to process, typically in MPa. Default is ``[]``. Returns ------- AbstractDetector The detector itself so processing can be chained. See Also -------- process : Process samples without forcing cached values to be emitted. Notes ----- This method is equivalent to ``process(samples, flush=True)``. """ return self.process(samples, flush=True)
def _new_turns(self, samples, flush=False, preserve_start=False): """Provide new turning points for the next chunk. This method can handle samples as both one-dimensional arrays and multi-dimensional data frames. Parameters ---------- samples : array_like or pandas.DataFrame Samples of the chunk to process. flush : bool, optional Whether to flush values at the end instead of keeping a tail. Default is ``False``. preserve_start : bool, optional Whether to preserve the beginning of the sequence. If this is ``False``, only turning points are extracted, for example: _new_turns([1, 2, 1]) # -> 2 _new_turns([0, 1]) # -> 1 If ``preserve_start`` is True, the first point is also added, even though it is not a turn point: _new_turns([1, 2, 1], preserve_start=True) # -> 1, 2 _new_turns([0, 1], preserve_start=True) # -> 0, 1 This option has no effect if there are samples left over from a previous call with ``flush=False``. Default is ``False``. Returns ------- turn_index : numpy.ndarray Global indices of turning points in the processed chunk. turn_values : numpy.ndarray Load values of the turning points. Notes ----- This method can be called by the ``process()`` implementation of subclasses. The sample tail i.e. the samples after the last turning point of the chunk are stored and prepended to the samples of the next call. """ if len(samples) == 0: return np.array([]), np.array([]) if len(self._sample_tail) > 0: preserve_start = False samples_with_last_tail = np.concatenate((self._sample_tail, samples)) turn_index, turn_values = find_turns(samples_with_last_tail) sample_tail_index = turn_index[-1] if turn_index.size > 0 else 0 turn_index += self._head_index - len(self._sample_tail) self._sample_tail = samples_with_last_tail[sample_tail_index:] # FIXME: samples_with_last_tail[-1:] also possible? self._head_index += len(samples) if flush and len(self._sample_tail) > 0: turn_index, turn_values = self._flush_new_turns(turn_index, turn_values) if preserve_start: turn_index, turn_values = self._preserve_start(turn_index, turn_values, samples[0]) return turn_index, turn_values def _flush_new_turns(self, turn_index, turn_values): turn_index = np.concatenate((turn_index, [self._head_index-1])) if isinstance(turn_values, np.ndarray): turn_values = np.concatenate((turn_values, [self._sample_tail[-1]])) else: turn_values.append(self._last_sample) self._sample_tail = self._sample_tail[-1:] return turn_index, turn_values def _preserve_start(self, turn_index, turn_values, first_sample): if turn_index.size > 0: if turn_index[0] > 0: # prepend first sample to results turn_index = np.insert(turn_index, 0, 0) if isinstance(turn_values, np.ndarray): turn_values = np.insert(turn_values, 0, first_sample) else: turn_values.insert(0, first_sample) return turn_index, turn_values def _new_turns_multiple_assessment_points(self, samples, flush=False, preserve_start=False): """Provide new turning points for multiple assessment points. This method is used when the assessment considers multiple points at once. It is called from ``_new_turns``. Parameters ---------- samples : pandas DataFrame Samples of the chunk to process. The index must be a :class:`pandas.MultiIndex` with levels ``load_step`` and ``node_id``. flush : bool, optional Whether to flush values at the end instead of keeping a tail. Default is ``False``. preserve_start : bool, optional Whether to preserve the beginning of the sequence. If this is ``False``, only turning points are extracted, for example: _new_turns([1, 2, 1]) # -> 2 _new_turns([0, 1]) # -> 1 If ``preserve_start`` is True, the first point is also added, even though it is not a turn point: _new_turns([1, 2, 1], preserve_start=True) # -> 1, 2 _new_turns([0, 1], preserve_start=True) # -> 0, 1 Returns ------- turn_index : numpy.ndarray Global indices of turning points in the processed chunk. turn_values : list of pandas.DataFrame Values of the turning points for all assessment points. """ assert isinstance(samples[0], pd.DataFrame) assert samples[0].index.names == ["load_step", "node_id"] # extract the representative samples for the first node first_node_id = samples.index.get_level_values("node_id")[0] samples_of_first_node = samples[samples.index.get_level_values("node_id") == first_node_id].to_numpy().flatten() previous_head_index = self._head_index turn_index, _ = self._new_turns(samples_of_first_node, flush, preserve_start) # the selected samples are a list of DataFrames. Each DataFrame contains the values for all nodes selected_samples = [samples[samples.index.get_level_values("load_step") == index-previous_head_index].reset_index(drop=True) \ for index in turn_index] return turn_index, selected_samples