# Copyright (c) 2019-2023 - 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.
import pandas as pd
import numpy as np
import scipy.stats as stats
from pylife.utils.functions import scattering_range_to_std
from pylife import PylifeSignal
from pylife import DataValidator
[docs]
@pd.api.extensions.register_dataframe_accessor('fatigue_data')
class FatigueData(PylifeSignal):
''' class for fatigue data
Mandatory keys are
* ``load`` : float, the load level
* ``cycles`` : float, the cycles of failure or runout
* ``fracture``: bool, ``True`` iff the test is a runout
'''
def _validate(self):
self.fail_if_key_missing(['load', 'cycles', 'fracture'])
self._fatigue_limit = None
@property
def num_tests(self):
'''The number of tests'''
return self._obj.shape[0]
@property
def num_fractures(self):
'''The number of fractures'''
return self.fractures.shape[0]
@property
def num_runouts(self):
'''The number of runouts'''
return self.runouts.shape[0]
@property
def fractures(self):
'''Only the fracture tests'''
return self._obj[self._obj.fracture]
@property
def runouts(self):
'''Only the runout tests'''
return self._obj[~self._obj.fracture]
@property
def load(self):
'''The load levels'''
return self._obj.load
@property
def cycles(self):
'''the cycle numbers'''
return self._obj.cycles
@property
def fatigue_limit(self):
'''The start value of the load endurance limit.
It is determined by searching for the lowest load level before the
appearance of a runout data point, and the first load level where a
runout appears. Then the median of the two load levels is the start
value.
'''
if self._fatigue_limit is None:
self._calc_fatigue_limit()
return self._fatigue_limit
@property
def finite_zone(self):
'''All the tests with load levels above ``fatigue_limit``, i.e. the finite zone'''
if self._fatigue_limit is None:
self._calc_fatigue_limit()
return self._finite_zone
@property
def infinite_zone(self):
'''All the tests with load levels below ``fatigue_limit``, i.e. the infinite zone'''
if self._fatigue_limit is None:
self._calc_fatigue_limit()
return self._infinite_zone
@property
def fractured_loads(self):
return np.unique(self.fractures.load.values)
@property
def runout_loads(self):
return np.unique(self.runouts.load.values)
@property
def non_fractured_loads(self):
return np.setdiff1d(self.runout_loads, self.fractured_loads)
@property
def mixed_loads(self):
return np.intersect1d(self.runout_loads, self.fractured_loads)
[docs]
def conservative_fatigue_limit(self):
"""
Sets a lower fatigue limit that what is expected from the algorithm given by Mustafa Kassem.
For calculating the fatigue limit, all amplitudes where runouts and fractures are present are collected.
To this group, the maximum amplitude with only runouts present is added.
Then, the fatigue limit is the mean of all these amplitudes.
Returns
-------
self
See also
--------
Kassem, Mustafa - "Open Source Software Development for Reliability and Lifetime Calculation" pp. 34
"""
amps_to_consider = self.mixed_loads
if len(self.non_fractured_loads ) > 0:
amps_to_consider = np.concatenate((amps_to_consider, [self.non_fractured_loads.max()]))
if len(amps_to_consider) > 0:
self._fatigue_limit = amps_to_consider.mean()
self._calc_finite_zone()
return self
@property
def max_runout_load(self):
return self.runouts.load.max()
def _calc_fatigue_limit(self):
self._calc_finite_zone()
self._fatigue_limit = 0.0 if len(self.runouts) == 0 else self._half_level_above_highest_runout()
def _half_level_above_highest_runout(self):
if len(self._finite_zone) > 0:
return (self._finite_zone.load.min() + self.max_runout_load) / 2.
return self._guess_from_second_highest_runout()
def _guess_from_second_highest_runout(self):
max_loads = np.sort(self._obj.load.unique())[-2:]
return max_loads[1] + (max_loads[1]-max_loads[0]) / 2.
def _calc_finite_zone(self):
if len(self.runouts) == 0:
self._infinite_zone = self._obj[:0]
self._finite_zone = self._obj
return
self._finite_zone = self.fractures[self.fractures.load > self.max_runout_load]
self._infinite_zone = self._obj[self._obj.load <= self.max_runout_load]
def determine_fractures(df, load_cycle_limit=None):
'''Adds a fracture column according to defined load cycle limit
Parameters
----------
df : DataFrame
A ``DataFrame`` containing ``fatigue_data`` without ``fractures`` column
load_cycle_limit : float, optional
If given, all the tests of ``df`` with ``cycles`` equal od above
``load_cycle_limit`` are considered as runouts. Others as fractures.
If not given the maximum cycle number in ``df`` is used as load cycle
limit.
Returns
-------
df : DataFrame
A ``DataFrame`` with the column ``fracture`` added
'''
DataValidator().fail_if_key_missing(df, ['load', 'cycles'])
if load_cycle_limit is None:
load_cycle_limit = df.cycles.max()
ret = df.copy()
ret['fracture'] = df.cycles < load_cycle_limit
return ret