Source code for pylife.strength.fkm_nonlinear.damage_calculator

# 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.

r"""Calculate FKM nonlinear damage and lifetime for ``P_RAM`` and ``P_RAJ``.

The module provides the two damage calculators used after the HCM algorithm
has produced damage-parameter collectives.  ``DamageCalculatorPRAM`` evaluates
``P_RAM`` collectives by an elementary damage sum, while
``DamageCalculatorPRAJ`` implements the guideline-specific ``P_RAJ``
accumulation with logarithmic classing and crack-growth damage.

Examples
--------
>>> from pylife.strength.fkm_nonlinear.damage_calculator import DamageCalculatorPRAM
>>> DamageCalculatorPRAM.__name__
'DamageCalculatorPRAM'
"""

__author__ = "Benjamin Maier"
__maintainer__ = __author__

import pandas as pd
import numpy as np
import warnings
import scipy.optimize

import pylife.strength.woehler_fkm_nonlinear
import pylife.strength.fkm_nonlinear.parameter_calculations
from pylife.strength.fkm_nonlinear.constants import FKMNLConstants

[docs] class DamageCalculatorPRAM: r"""Calculate lifetime from a ``P_RAM`` damage-parameter collective. Use this calculator for the FKM nonlinear assessment path based on the ``P_RAM`` damage parameter. It expects the first and second HCM runs in one collective and evaluates them against a ``WoehlerCurvePRAM`` component Wöhler curve. Parameters ---------- collective : pandas.DataFrame Damage-parameter collective. Each row represents one hysteresis and must contain ``P_RAM`` as damage parameter value, ``is_closed_hysteresis`` as full-cycle flag, ``run_index`` as HCM run number, and ``S_min`` for the hysteresis count. A missing index is replaced by a two-level ``MultiIndex`` with ``hysteresis_index`` and ``assessment_point_index``. component_woehler_curve_P_RAM : WoehlerCurvePRAM Component Wöhler curve for the ``P_RAM`` damage parameter. Notes ----- Implement the FKM nonlinear guideline assessment for ``P_RAM``, especially the load-sequence repetition rule of clause 2.6, equation (2.6-90). The damage of hysteresis ``i`` is accumulated as .. math:: D_i = \begin{cases} 1 / N_i, & \text{closed hysteresis}, \\ 0.5 / N_i, & \text{memory-3 hysteresis}. \end{cases} The resulting damage sum is dimensionless; a value of ``1.0`` means failure. """ def __init__(self, collective, component_woehler_curve_P_RAM): """Initialize the ``P_RAM`` damage calculation. Parameters ---------- collective : pandas.DataFrame Damage-parameter collective with one row per hysteresis. Required columns are ``P_RAM``, ``is_closed_hysteresis``, ``run_index``, and ``S_min``. component_woehler_curve_P_RAM : WoehlerCurvePRAM Component Wöhler curve used to convert ``P_RAM`` values to bearable cycle numbers. """ self._collective = collective.copy() self._component_woehler_curve_P_RAM = component_woehler_curve_P_RAM self._P_RAM_Z = self._component_woehler_curve_P_RAM.P_RAM_Z self._initialize_collective_index() self._initialize_P_RAM_Z_index() # compute bearable number of cycles self._collective["N"] = np.where(self._collective["P_RAM"] >= self._P_RAM_Z, 1e3 * np.power(self._collective["P_RAM"] / self._P_RAM_Z, 1/self._component_woehler_curve_P_RAM.d_1), 1e3 * np.power(self._collective["P_RAM"] / self._P_RAM_Z, 1/self._component_woehler_curve_P_RAM.d_2)) # compute individual damage per cycle self._collective["D"] = np.where(self._collective["is_closed_hysteresis"], 1/self._collective["N"], 0.5/self._collective["N"]) # compute cumulative damage for every node self._collective["cumulative_damage"] = self._collective["D"].groupby("assessment_point_index").cumsum() # compute number of cycles until damage sum is 1 self._n_cycles_until_damage = self._collective["cumulative_damage"].groupby("assessment_point_index").apply(lambda array: np.searchsorted(array, 1)) @property def collective(self): """Return the evaluated ``P_RAM`` collective. Returns ------- pandas.DataFrame Copy of the input collective enriched with bearable cycle numbers, damage per hysteresis, and cumulative damage per assessment point. """ return self._collective @property def P_RAM_max(self): """Return the maximum ``P_RAM`` value of the second HCM run. Returns ------- float or pandas.Series Maximum ``P_RAM`` damage parameter per assessment point. Values below or equal to the fatigue strength limit indicate infinite life for this criterion. """ # get maximum damage parameter P_RAM_max = self._collective.loc[self._collective["run_index"]==2, "P_RAM"].groupby("assessment_point_index").max() return P_RAM_max.squeeze() @property def is_life_infinite(self): """Return whether the ``P_RAM`` assessment predicts infinite life. Returns ------- bool or pandas.Series ``True`` where ``P_RAM_max`` does not exceed the fatigue strength limit of the component Wöhler curve; otherwise ``False``. """ # y/x = d, 1/d = x/y fatigue_strength_limit = self._component_woehler_curve_P_RAM.fatigue_strength_limit # remove any given index of fatigue_strength_limit which results from the assessment_parameters.G parameter if isinstance(fatigue_strength_limit, pd.Series): fatigue_strength_limit.reset_index(drop=True, inplace=True) result = self.P_RAM_max <= fatigue_strength_limit return result.squeeze() @property def lifetime_n_times_load_sequence(self): """Return load-sequence repetitions until ``P_RAM`` failure. Returns ------- float or numpy.ndarray Number of complete load sequence repetitions until the damage sum reaches ``1.0``. A value of ``0`` means that failure occurs before the end of the second HCM run. """ # compute damage sums of both HCM runs damage_sum_first_run = self._collective.loc[self._collective["run_index"]==1, "D"].groupby("assessment_point_index").sum() damage_sum_second_run = self._collective.loc[self._collective["run_index"]==2, "D"].groupby("assessment_point_index").sum() # fill default values for all assessment point where there is no value damage_sum_first_run = self._fill_with_default_for_missing_assessment_points(damage_sum_first_run, 0) damage_sum_second_run = self._fill_with_default_for_missing_assessment_points(damage_sum_second_run, 0) # how often the second run can be repeated (after the first run) until damage # eq. (2.6-90) x = np.where(damage_sum_first_run == 0, 1 / damage_sum_second_run, (1 - damage_sum_first_run) / damage_sum_second_run) # If damage sum of D=1 is reached before end of second run of HCM algorithm, set lifetime_n_times_load_sequence to 0. # Else the value is x + 1 result = np.where(self._n_cycles_until_damage < self._n_hystereses, 0, x + 1) # store value of x self._x = x return result.squeeze() @property def lifetime_n_cycles(self): """Return load cycles until ``P_RAM`` failure. Returns ------- float or numpy.ndarray Number of cycles in the collective until the accumulated damage sum reaches ``1.0``. The value is expressed in counted load cycles, not in seconds or load-sequence repetitions. """ x_plus_1 = self.lifetime_n_times_load_sequence # if damage sum of D=1 is reached before end of second run of HCM algorithm lifetime = np.where(self._n_cycles_until_damage < self._n_hystereses, self._n_cycles_until_damage, x_plus_1 * self._n_hystereses_run_2) return lifetime.squeeze()
[docs] def get_lifetime_functions(self, assessment_parameters): """Return probabilistic lifetime functions for the ``P_RAM`` assessment. The returned callables scale the deterministic lifetime with the material scatter and safety factor of the FKM nonlinear guideline. Parameters ---------- assessment_parameters : pandas.Series Assessment parameters. Only the material group is required here to select the ``f_2.5%`` scatter constant for ``P_RAM``. Returns ------- N_max_bearable : callable Function ``N_max_bearable(P_A, clip_gamma=False)`` returning the maximum bearable number of cycles for failure probability ``P_A``. failure_probability : callable Function ``failure_probability(N)`` returning the failure probability for ``N`` load cycles. Notes ----- Use FKM nonlinear guideline clause 2.5, equation (2.5-38), for the material safety factor. If ``clip_gamma`` is ``True``, ``gamma_M`` is clipped to at least ``1.1`` for the ``P_RAM`` assessment. """ constants = FKMNLConstants().for_material_group(assessment_parameters) f_25 = constants.f_25percent_material_woehler_RAM def N_max_bearable(P_A, clip_gamma=False): beta = pylife.strength.fkm_nonlinear.parameter_calculations.compute_beta(P_A) log_gamma_M = (0.8*beta - 2)*0.08 # Note that the FKM nonlinear guideline defines a cap at 1.1 for P_RAM. if clip_gamma: log_gamma_M = max(log_gamma_M, np.log10(1.1)) reduction_factor_P = np.log10(f_25) - log_gamma_M # Note: P_A shifts the woehler curve, this may switch from slope d_1 to slope d_2, slope_woehler is not constant, but depends on P_A. # Therefore, we do the woehler curve assessment again: # compute bearable number of cycles P_RAM_reduced = self._P_RAM_Z * 10**(reduction_factor_P) self._collective["N"] = np.where(self._collective["P_RAM"] >= P_RAM_reduced, 1e3 * np.power(self._collective["P_RAM"] / P_RAM_reduced, 1/self._component_woehler_curve_P_RAM.d_1), 1e3 * np.power(self._collective["P_RAM"] / P_RAM_reduced, 1/self._component_woehler_curve_P_RAM.d_2)) # compute individual damage per cycle self._collective["D"] = np.where(self._collective["is_closed_hysteresis"], 1/self._collective["N"], 0.5/self._collective["N"]) return self.lifetime_n_cycles def failure_probability(N): result = scipy.optimize.minimize_scalar( lambda x: (N_max_bearable(x) - N) ** 2, bounds=[1e-9, 1-1e-9], method='bounded', options={'xatol': 1e-10}) if result.success: return result.x else: return 0 return N_max_bearable, failure_probability
def _initialize_collective_index(self): """Validate and, if necessary, create the collective index. The method requires the columns used by the ``P_RAM`` damage calculation and stores the number of hysteresis entries for later lifetime conversion. """ # if assessment is done for multiple points at once, work with a multi-indexed data frame if not isinstance(self._collective.index, pd.MultiIndex): n_hystereses = len(self._collective) self._collective.index = pd.MultiIndex.from_product([range(n_hystereses), [0]], names=["hysteresis_index", "assessment_point_index"]) # assert that the index contains the two columns "hysteresis_index" and "assessment_point_index" assert self._collective.index.names == ["hysteresis_index", "assessment_point_index"] assert "P_RAM" in self._collective assert "is_closed_hysteresis" in self._collective assert "run_index" in self._collective # store some statistics about the DataFrame self._n_hystereses = self._collective.groupby("assessment_point_index")["S_min"].count().values[0] self._n_hystereses_run_2 = self._collective[self._collective["run_index"]==2].groupby("assessment_point_index")["S_min"].count().values[0] def _initialize_P_RAM_Z_index(self): """Align node-dependent ``P_RAM_Z`` values with the collective index. If the Wöhler curve provides one ``P_RAM_Z`` value per assessment point, expand it to the hysteresis-level ``MultiIndex`` used by the collective. """ # if P_RAM_Z is a series without multi-index if isinstance(self._P_RAM_Z, pd.Series): if not isinstance(self._P_RAM_Z.index, pd.MultiIndex): n_hystereses = len(self._collective.index.get_level_values("hysteresis_index").unique()) self._P_RAM_Z = pd.Series( data = (np.ones([n_hystereses,1]) * np.array([self._P_RAM_Z])).flatten(), index = self._collective.index) def _fill_with_default_for_missing_assessment_points(self, df, default_value): """Return a series containing all assessment points. Parameters ---------- df : pandas.Series Series indexed by ``assessment_point_index`` that may miss some assessment points. default_value : float Value inserted for missing assessment points. Returns ------- pandas.Series Series indexed by every assessment point in the collective. """ assessment_point_index = self._collective.index.get_level_values("assessment_point_index").unique() series_with_all_rows = pd.Series(np.nan, index=assessment_point_index, name="a") result = pd.concat([df, series_with_all_rows],axis=1)[df.name] result = result.fillna(default_value) return result
[docs] class DamageCalculatorPRAJ: r"""Calculate lifetime from a ``P_RAJ`` collective by guideline accumulation. Use this calculator for the official FKM nonlinear ``P_RAJ`` assessment. It uses the damage values computed during damage-parameter evaluation, classes the second HCM run logarithmically, and accounts for the guideline's crack-growth based degradation of the endurance limit. Parameters ---------- collective : pandas.DataFrame Damage-parameter collective with one row per hysteresis. Required columns are ``P_RAJ``, ``P_RAJ_D``, ``D``, ``run_index``, and ``S_min``. A missing index is replaced by a two-level ``MultiIndex`` with ``hysteresis_index`` and ``assessment_point_index``. assessment_parameters : pandas.Series Parameters of the nonlinear ``P_RAJ`` assessment, including ``P_RAJ_klass_max``, ``P_RAJ_D_e``, ``d_RAJ``, ``a_0``, ``a_end``, ``l_star``, and optionally ``n_bins``. Default for ``n_bins`` is ``200``. component_woehler_curve_P_RAJ : WoehlerCurvePRAJ Component Wöhler curve for the ``P_RAJ`` damage parameter. See Also -------- DamageCalculatorPRAM : Calculate damage from ``P_RAM`` with elementary accumulation. Notes ----- Implement the FKM nonlinear guideline assessment for ``P_RAJ`` in clause 2.9, including equations (2.9-126), (2.9-135), and (2.9-138). The class evaluates .. math:: \bar{N} = H_0\,(2 + \bar{x}_{-2}) as the bearable number of cycles until crack initiation. Choose ``DamageCalculatorPRAJMinerElementary`` instead only for the modified Miner-type ``P_RAJ`` accumulation, not for a strict guideline assessment. """ def __init__(self, collective, assessment_parameters, component_woehler_curve_P_RAJ): """Initialize the guideline ``P_RAJ`` damage calculation. Parameters ---------- collective : pandas.DataFrame Damage-parameter collective with one row per hysteresis. Required columns are ``P_RAJ``, ``P_RAJ_D``, ``D``, ``run_index``, and ``S_min``. assessment_parameters : pandas.Series Assessment parameters controlling classing, crack growth, and the number of logarithmic bins. Default for missing ``n_bins`` is ``200``. component_woehler_curve_P_RAJ : WoehlerCurvePRAJ Component Wöhler curve used by the ``P_RAJ`` assessment. """ self._collective = collective self._assessment_parameters = assessment_parameters self._component_woehler_curve_P_RAJ = component_woehler_curve_P_RAJ self._P_RAJ_D_0 = self._component_woehler_curve_P_RAJ.fatigue_strength_limit self._initialize_collective_index() # get number of bins for P_RAJ if "n_bins" not in self._assessment_parameters: self._assessment_parameters.n_bins = 200 n_bins = self._assessment_parameters.n_bins # setup the lookup table "self._binned_P_RAJ" and self._binned_h self._initialize_binning() # compute cumulative damage for every node self._collective["cumulative_damage"] = self._collective["D"].groupby("assessment_point_index").cumsum() # compute number of cycles until damage sum is 1, eq. (2.9-136) self._n_cycles_until_damage = self._collective["cumulative_damage"].groupby("assessment_point_index").apply(lambda array: np.searchsorted(array, 1)) # calculate the value of self._xbar_minus_2 self._compute_xbar_minus_2() # eq. (2.8-93) self._N_minus_2 = self._H_0 * self._xbar_minus_2 # compute bearable number of cycles until crack self._N_bar = self._H_0 * (2 + self._xbar_minus_2) self._x_bar = (2 + self._xbar_minus_2) @property def collective(self): """Return the evaluated ``P_RAJ`` collective. Returns ------- pandas.DataFrame Input collective enriched with cumulative damage per assessment point. """ return self._collective @property def P_RAJ_max(self): """Return the maximum ``P_RAJ`` value of the second HCM run. Returns ------- float or pandas.Series Maximum ``P_RAJ`` damage parameter per assessment point. Values below or equal to the fatigue strength limit indicate infinite life for this criterion. """ # get maximum damage parameter if isinstance(self._collective.index, pd.MultiIndex): P_RAJ_max = self._collective.loc[self._collective["run_index"]==2, "P_RAJ"].groupby("assessment_point_index").max() else: P_RAJ_max = self._collective.loc[self._collective["run_index"]==2, "P_RAJ"].max() return P_RAJ_max.squeeze() @property def is_life_infinite(self): """Return whether the ``P_RAJ`` assessment predicts infinite life. Returns ------- bool or pandas.Series ``True`` where ``P_RAJ_max`` does not exceed the fatigue strength limit of the component Wöhler curve; otherwise ``False``. """ result = self.P_RAJ_max <= self._component_woehler_curve_P_RAJ.fatigue_strength_limit return result.squeeze() @property def lifetime_n_times_load_sequence(self): """Return load-sequence repetitions until ``P_RAJ`` failure. Returns ------- float or numpy.ndarray Number of complete load sequence repetitions until the damage sum reaches ``1.0``. A value of ``0`` means that failure occurs before the end of the second HCM run. """ # If damage sum of D=1 is reached before end of second run of HCM algorithm, set lifetime_n_times_load_sequence to 0. # Else the value is x + 1 result = np.where(self._n_cycles_until_damage < self._n_hystereses, 0, self._x_bar) return result.squeeze() @property def lifetime_n_cycles(self): """Return load cycles until ``P_RAJ`` failure. Returns ------- float or numpy.ndarray Number of cycles in the collective until crack initiation according to the guideline ``P_RAJ`` accumulation. """ # if damage sum of D=1 is reached before end of second run of HCM algorithm lifetime = np.where(self._n_cycles_until_damage < self._n_hystereses, self._n_cycles_until_damage, self._N_bar) return lifetime.squeeze()
[docs] def get_lifetime_functions(self): """Return probabilistic lifetime functions for the ``P_RAJ`` assessment. Returns ------- N_max_bearable : callable Function ``N_max_bearable(P_A, clip_gamma=False)`` returning the maximum bearable number of cycles for failure probability ``P_A``. failure_probability : callable Function ``failure_probability(N)`` returning the failure probability for ``N`` load cycles. Notes ----- Use FKM nonlinear guideline clause 2.8, equation (2.8-38), for the material safety factor. If ``clip_gamma`` is ``True``, ``gamma_M`` is clipped to at least ``1.2`` for the ``P_RAJ`` assessment. """ constants = FKMNLConstants().for_material_group(self._assessment_parameters) f_25 = constants.f_25percent_material_woehler_RAJ slope_woehler = abs(1/self._component_woehler_curve_P_RAJ.d) lifetime_n_cycles = self.lifetime_n_cycles def N_max_bearable(P_A, clip_gamma=False): beta = pylife.strength.fkm_nonlinear.parameter_calculations.compute_beta(P_A) log_gamma_M = (0.8*beta - 2)*0.155 # Note that the FKM nonlinear guideline defines a cap at 1.2 for P_RAJ. if clip_gamma: log_gamma_M = max(log_gamma_M, np.log10(1.2)) reduction_factor_P = np.log10(f_25) - log_gamma_M reduction_factor_N = reduction_factor_P * slope_woehler return lifetime_n_cycles * 10**(reduction_factor_N) def failure_probability(N): result = scipy.optimize.minimize_scalar( lambda x: (N_max_bearable(x) - N) ** 2, bounds=[1e-9, 1-1e-9], method='bounded', options={'xatol': 1e-10}) if result.success: return result.x else: return 0 return N_max_bearable, failure_probability
def _initialize_collective_index(self): """Validate and, if necessary, create the collective index. The method requires the columns used by the ``P_RAJ`` guideline damage calculation and stores the number of hysteresis entries for lifetime conversion. """ # if assessment is done for multiple points at once, work with a multi-indexed data frame if not isinstance(self._collective.index, pd.MultiIndex): n_hystereses = len(self._collective) self._collective.index = pd.MultiIndex.from_product([range(n_hystereses), [0]], names=["hysteresis_index", "assessment_point_index"]) # assert that the index contains the two columns "hysteresis_index" and "assessment_point_index" assert self._collective.index.names == ["hysteresis_index", "assessment_point_index"] assert "run_index" in self._collective assert "D" in self._collective assert "P_RAJ" in self._collective # store some statistics about the DataFrame self._n_hystereses = self._collective.groupby("assessment_point_index")["S_min"].count().values[0] def _initialize_binning(self): """Create logarithmic ``P_RAJ`` classes for the second HCM run. The class boundaries and class-middle values implement the guideline's ``Klassierung`` step. The counts are stored per assessment point so that several nodes can be evaluated in one calculator instance. """ # initialize the classes for P_RAJ P_RAJ_klass_max = self._assessment_parameters.P_RAJ_klass_max P_RAJ_D_e = self._assessment_parameters.P_RAJ_D_e n_bins = self._assessment_parameters.n_bins # eq. (2.9-126) delta_P = 1.0/n_bins * np.log(P_RAJ_klass_max / P_RAJ_D_e) # initalize binned P_RAJ values with equal logarithmic class sizes self._binned_P_RAJ = np.logspace(np.log10(P_RAJ_klass_max), np.log10(P_RAJ_D_e), n_bins+1) # 201 (n_bins+1) because entry 0 is P_RAJ_class_max # assert that upper bin size divided by lower bin size is equal for all bins log_bin_sizes = [self._binned_P_RAJ[i-1] / self._binned_P_RAJ[i] for i in range(1,n_bins+1)] #assert np.nanstd(log_bin_sizes) < 1e-10 if np.nanstd(log_bin_sizes) >= 1e-10: warnings.warn(f"std(log_bin_sizes) should be zero, but is {np.nanstd(log_bin_sizes)}.") # at this point, the vectorial assertions should hold, masking out nan values # class middle points, eq. (2.9.131) self._binned_P_RAJ_m = (self._binned_P_RAJ[:n_bins] + self._binned_P_RAJ[1:]) / 2 # Now `Klassieren(P_RAJ_i, h_i, P_RAJ_m_i)`, 1 <= i <= 200 (200 is the standard value for n_bins) # equals (self._binned_P_RAJ[i+1], self._binned_h[i], self._binned_P_RAJ_m[i]), 0 <= i < 200. # The corresponding class `i` for a P_RAJ value is given such that: # self._binned_P_RAJ[i] <= P_RAJ <= self._binned_P_RAJ[i+1] # and self._binned_P_RAJ_m[i] is the corresponding center point # # instead of self._binned_h[class_index], we use self._binned_h[assessment_point_index, class_index] # fill binned P_RAJ values for second run of HCM algorithm n_assessment_points = len(self._collective[self._collective.index.get_level_values("hysteresis_index")==0]) self._binned_h = np.zeros((n_assessment_points,n_bins)) self._n_not_in_bin = np.zeros(n_assessment_points) for index, group in self._collective[self._collective.run_index == 2].groupby("hysteresis_index"): # if we have a different stress gradient for every node, the bin contains different values for each node if len(self._binned_P_RAJ.shape) == 2: # this is the vectorial case where the stress gradient is different for every node, # every bin contains one value for every node def find(row): return n_bins - np.searchsorted(np.flip(self._binned_P_RAJ, axis=0)[:,int(row["index"])], row.P_RAJ) i = group.reset_index().reset_index()[["index","P_RAJ"]].apply(find, axis=1) else: # scalar case, each bin contains one value # find class index of binned P_RAJ value i = group.P_RAJ.apply(lambda P_RAJ: n_bins - np.searchsorted(np.flip(self._binned_P_RAJ, axis=0), P_RAJ)) # here, we have: # self._binned_P_RAJ[i] <= P_RAJ <= self._binned_P_RAJ[i+1] # if P_RAJ > P_RAJ_D_e: # increment binned at the corresponding class, equivalent to self._binned_h[i] += 1 increment = np.zeros((n_assessment_points,n_bins+1)) increment[np.array(range(n_assessment_points)), i] = np.where(group.reset_index(drop=True).P_RAJ > P_RAJ_D_e, 1, 0) self._binned_h = self._binned_h + increment[:,:n_bins] # if P_RAJ <= P_RAJ_D_e: # increment n_not_in_bin self._n_not_in_bin += np.where(group.reset_index().P_RAJ <= P_RAJ_D_e, 1, 0) # eq. (2.9-135) self._H_0 = np.sum(self._binned_h, axis=1) + self._n_not_in_bin def _compute_xbar_minus_2(self): """Compute the additional sequence repetitions after the second run. The value ``xbar_minus_2`` corresponds to FKM nonlinear guideline equation (2.9-138) and is later converted to cycles by multiplication with ``H_0``. """ n_bins = self._assessment_parameters.n_bins last_P_RAJ_D = self._collective["P_RAJ_D"].groupby("assessment_point_index").last() # find corresponding class `q` for value last_P_RAJ_D # if we have a different stress gradient for every node, the bin contains different values for each node if len(self._binned_P_RAJ.shape) == 2: def find(row): return n_bins - np.searchsorted(np.flip(self._binned_P_RAJ, axis=0)[:,int(row["index"])], row.P_RAJ_D) q = last_P_RAJ_D.reset_index().reset_index().apply(find, axis=1) else: # scalar case, each bin contains one value q = last_P_RAJ_D.apply(lambda P_RAJ: n_bins - np.searchsorted(np.flip(self._binned_P_RAJ), P_RAJ)) # here, we have: # self._binned_P_RAJ[q] <= last_P_RAJ_D <= self._binned_P_RAJ[q+1] # and self._binned_P_RAJ_m[q] is the corresponding center point # standard calculation of m according to eq. (2.8-60), (2.9-117) m = -1/self._assessment_parameters.d_RAJ # definition of the function f of eq. (2.9-139) def f(j): denominator = np.power(self._assessment_parameters.a_0, 1-m) - np.power(self._assessment_parameters.a_end, 1-m) bracket = self._P_RAJ_D_0 / self._binned_P_RAJ_m[j] \ * (self._assessment_parameters.a_0 + self._assessment_parameters.l_star \ * (1 - self._binned_P_RAJ_m[j]/self._P_RAJ_D_0)) nominator = np.power(self._assessment_parameters.a_0, 1-m) - np.power(bracket, 1-m) return nominator / denominator # store internal variables self._f = f self._q = q self._last_P_RAJ_D = last_P_RAJ_D self._m = m # eq. (2.9-138) self._xbar_minus_2 = np.zeros_like(q, dtype=float) denominator = np.zeros_like(q, dtype=float) previous_j = 0 # iterate from j = q to 198 for all assessment points at once. # The values where j is smaller than q are masked out at the end. for j in range(min(q), n_bins-1): # compute sum in denominator, only one new summand is added in this iteration, # we can avoid doing the complete inner sum over i for every new j for i in range(previous_j, j+1): P_RAJ_m = self._binned_P_RAJ_m[i] # this corresponds to the range [self._binned_P_RAJ[i], self._binned_P_RAJ[i+1]] # eq. (2.9-140) #N = (P_RAJ_m / self._component_woehler_curve_P_RAJ.P_RAJ_Z) ** (1/self._component_woehler_curve_P_RAJ.d) N = self._component_woehler_curve_P_RAJ.calc_N(P_RAJ_m, P_RAJ_D=last_P_RAJ_D) damage = np.where(P_RAJ_m > last_P_RAJ_D, self._binned_h[:,i] / N, # for N = inf (infinite life), damage is zero 0) denominator += damage previous_j = j # silence warning "divide by zero encountered in true_divide". This happens for denominator=0, but then it will use the second branch with np.inf anyways with np.errstate(divide='ignore'): self._xbar_minus_2 += np.where(j >= q, np.where(abs(denominator) > 1e-13, (f(j+1) - f(j)) / denominator, np.inf), 0)