diff --git a/src/fatpy/core/stress_life/damage_params/uniaxial_stress_eq_amp.py b/src/fatpy/core/stress_life/damage_params/uniaxial_stress_eq_amp.py new file mode 100644 index 0000000..b8e2e9d --- /dev/null +++ b/src/fatpy/core/stress_life/damage_params/uniaxial_stress_eq_amp.py @@ -0,0 +1,1078 @@ +"""Uniaxial fatigue criteria methods for the stress-life approach. + +This module contains criteria for uniaxial high-cycle fatigue that incorporate +mean-stress effects via equivalent stress amplitudes. It adjusts the stress amplitude +using models such as Goodman, Gerber, and Soderberg to provide more accurate +fatigue-life predictions when mean stresses significantly affect material endurance. + +For more information, you can refer to the following resource: + +[PAPUGA, Jan, et al. Mean stress effect in stress-life fatigue prediction re-evaluated. +In: MATEC web of conferences. EDP Sciences, 2018. p. 10018.](https://doi.org/10.1051/matecconf/201816510018). +""" + +import warnings + +import numpy as np +from numpy.typing import ArrayLike, NDArray + + +def _asme_correction_method( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + yield_strength: ArrayLike | np.float64, +) -> NDArray[np.float64]: + """Calculate equivalent stress amplitude using ASME criterion.""" + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + yield_strength_arr = np.asarray(yield_strength, dtype=np.float64) + + asme_eq_amp = ( + stress_amp_arr / (1 - (mean_stress_arr / yield_strength_arr) ** 2) ** 0.5 + ) + + return asme_eq_amp + + +# Preset tolerances for all np.isclose() functions in mean stress correction methods +_RTOL = 0.001 +_ATOL = 1e-5 + + +def calc_stress_eq_amp_asme( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + yield_strength: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using ASME criterion. + + ??? abstract "Math Equations" + The ASME equivalent stress amplitude is calculated as: + + $$ + \sigma_{aeq}=\frac{\sigma_a}{\left[1-\left(\frac{\sigma_m}{R_e}\right)^2\right]^{1/2}} + $$ + + Args: + stress_amp: The stress amplitude values. + Leading dimensions are preserved. + mean_stress: The mean stress values. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + yield_strength: The yield strength values. Must be broadcastable with + stress_amp and mean_stress. Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + rtol: Relative tolerance for checking if mean stress magnitude is close to + yield strength. + atol: Absolute tolerance for checking if mean stress magnitude is close to + yield strength. + + Raises: + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If yield strength is not positive ($R_e > 0$). + ValueError: If mean stress magnitude exceeds yield strength, + which would produce a negative value under the square root. + ($|\sigma_m| > R_e$) + ValueError: If mean stress magnitude is close to yield strength + (within tolerance), the equivalent stress amplitude tends to infinity. + ($\left|\frac{\sigma_m}{R_e}\right| \approx 1.0$ within tolerance). + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + yield_strength_arr = np.asarray(yield_strength, dtype=np.float64) + + if np.any(yield_strength_arr <= 0): + raise ValueError("Yield strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = np.abs(mean_stress_arr) / yield_strength_arr + + if np.any(ratio > 1.0): + raise ValueError( + "Mean stress magnitude exceeds yield strength, which produces a negative" + " value under the square root." + ) + + if np.any(np.isclose(ratio, 1.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to yield strength, this results in " + "infinite equivalent stress amplitude." + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _asme_correction_method( + stress_amp_arr, mean_stress_arr, yield_strength_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def _bagci_correction_method( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + yield_strength: ArrayLike | np.float64, +) -> NDArray[np.float64]: + """Calculate equivalent stress amplitude using Bagci criterion.""" + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + yield_strength_arr = np.asarray(yield_strength, dtype=np.float64) + + bagci_eq_amp = stress_amp_arr / (1 - (mean_stress_arr / yield_strength_arr) ** 4) + + return bagci_eq_amp + + +def calc_stress_eq_amp_bagci( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + yield_strength: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Bagci criterion. + + ??? abstract "Math Equations" + The Bagci equivalent stress amplitude is calculated as: + + $$ + \sigma_{aeq}=\frac{\sigma_a}{1-\left(\frac{\sigma_m}{R_e}\right)^4} + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + yield_strength: Array-like of yield strengths. Must be broadcastable with + stress_amp and mean_stress. Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + rtol: Relative tolerance for checking if mean stress magnitude is close to + yield strength. + atol: Absolute tolerance for checking if mean stress magnitude is close to + yield strength. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds yield strength ($|\sigma_m| > R_e$). + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If yield strength is not positive ($R_e > 0$). + ValueError: If mean stress magnitude is close to yield strength + (within tolerance), the equivalent stress amplitude tends to infinity. + ($\left|\frac{\sigma_m}{R_e}\right| \approx 1.0$ within tolerance). + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + yield_strength_arr = np.asarray(yield_strength, dtype=np.float64) + + if np.any(yield_strength_arr <= 0): + raise ValueError("Yield strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = np.abs(mean_stress_arr) / yield_strength_arr + + if np.any(np.isclose(ratio, 1.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to yield strength, this results in " + "infinite equivalent stress amplitude." + ) + + if np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds yield strength.", + UserWarning, + stacklevel=2, + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _bagci_correction_method( + stress_amp_arr, mean_stress_arr, yield_strength_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def _gerber_correction_method( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_tensile_strength: ArrayLike | np.float64, +) -> NDArray[np.float64]: + """Calculate equivalent stress amplitude using Gerber criterion.""" + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_tensile_strength_arr = np.asarray(ult_tensile_strength, dtype=np.float64) + + gerber_eq_amp = stress_amp_arr / ( + 1 - (mean_stress_arr / ult_tensile_strength_arr) ** 2 + ) + + return gerber_eq_amp + + +def calc_stress_eq_amp_gerber( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_tensile_strength: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Gerber criterion. + + ??? abstract "Math Equations" + The Gerber equivalent stress amplitude is calculated as: + + $$ + \displaystyle\sigma_{aeq}=\frac{\sigma_a}{1-\left(\frac{\sigma_m}{\sigma_{UTS}} + \right)^2 } + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + ult_tensile_strength: Array-like of ultimate tensile strengths. Must be + broadcastable with stress_amp and mean_stress. + Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + rtol: Relative tolerance for checking if mean stress magnitude is close to + ultimate tensile strength. + atol: Absolute tolerance for checking if mean stress magnitude is close to + ultimate tensile strength. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds ultimate tensile strength + ($|\sigma_m| > \sigma_{UTS}$). + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If ultimate tensile strength is not positive ($\sigma_{UTS} > 0$). + ValueError: If mean stress magnitude is close to ultimate tensile strength + (within tolerance), the equivalent stress amplitude tends to infinity. + ($\left|\frac{\sigma_m}{\sigma_{UTS}}\right| \approx 1.0$ within tolerance). + + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_tensile_strength_arr = np.asarray(ult_tensile_strength, dtype=np.float64) + + if np.any(ult_tensile_strength_arr <= 0): + raise ValueError("Ultimate tensile strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = np.abs(mean_stress_arr) / ult_tensile_strength_arr + + if np.any(np.isclose(ratio, 1.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to ultimate tensile strength, " + "this results in infinite equivalent stress amplitude." + ) + + if np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds ultimate tensile strength. ", + UserWarning, + stacklevel=2, + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _gerber_correction_method( + stress_amp_arr, mean_stress_arr, ult_tensile_strength_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def _linear_correction_method( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + material_parameter: ArrayLike | np.float64, +) -> NDArray[np.float64]: + """Calculate equivalent stress amplitude using linear correction.""" + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + material_parameter_arr = np.asarray(material_parameter, dtype=np.float64) + + linear_eq_amp = stress_amp_arr / (1 - (mean_stress_arr / material_parameter_arr)) + + return linear_eq_amp + + +def calc_stress_eq_amp_goodman( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_tensile_strength: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Goodman criterion. + + ??? abstract "Math Equations" + The Goodman equivalent stress amplitude is calculated as: + + $$ + \displaystyle\sigma_{aeq}=\frac{\sigma_a}{1-\frac{\sigma_m}{\sigma_{UTS}}} + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + ult_tensile_strength: Array-like of ultimate tensile strengths. Must be + broadcastable with stress_amp and mean_stress. + Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + rtol: Relative tolerance for checking if mean stress magnitude is close to + ultimate tensile strength. + atol: Absolute tolerance for checking if mean stress magnitude is close to + ultimate tensile strength. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds ultimate tensile strength + ($|\sigma_m| > \sigma_{UTS}$). + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If ultimate tensile strength is not positive ($\sigma_{UTS} > 0$). + ValueError: If mean stress magnitude is close to ultimate tensile strength + (within tolerance), the equivalent stress amplitude tends to infinity. + ($\left|\frac{\sigma_m}{\sigma_{UTS}}\right| \approx 1.0$ within tolerance). + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_tensile_strength_arr = np.asarray(ult_tensile_strength, dtype=np.float64) + + if np.any(ult_tensile_strength_arr <= 0): + raise ValueError("Ultimate tensile strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = abs(mean_stress_arr) / ult_tensile_strength_arr + + if np.any(np.isclose(ratio, 1.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to ultimate tensile strength, " + "this results in infinite equivalent stress amplitude." + ) + + if np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds ultimate tensile strength. ", + UserWarning, + stacklevel=2, + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _linear_correction_method( + stress_amp_arr, mean_stress_arr, ult_tensile_strength_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def calc_stress_eq_amp_half_slope( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_tensile_strength: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using a half-slope mean stress correction. + + ??? abstract "Math Equations" + The half-slope corrected equivalent stress amplitude is calculated as: + + $$ + \sigma_{aeq}=\frac{\sigma_a}{1 - \frac{\sigma_m}{2 \cdot \sigma_{UTS}}} + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + ult_tensile_strength: Array-like of ultimate tensile strengths. Must be + broadcastable with stress_amp and mean_stress. Leading dimensions are + preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + rtol: Relative tolerance for checking if mean stress magnitude is close to + ultimate tensile strength. + atol: Absolute tolerance for checking if mean stress magnitude is close to + ultimate tensile strength. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds the ultimate tensile strength + ($|\sigma_m| > \sigma_{UTS}$). + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If ultimate tensile strength is not positive ($\sigma_{UTS} > 0$). + ValueError: If mean stress magnitude is close to double of the ultimate tensile + strength, (within tolerance), the equivalent stress amplitude tends to + infinity. ($\left|\frac{\sigma_m}{\sigma_{UTS}}\right| \approx 2.0$ within + tolerance). + + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_tensile_strength_arr = np.asarray(ult_tensile_strength, dtype=np.float64) + + if np.any(ult_tensile_strength_arr <= 0): + raise ValueError("Ultimate tensile strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = np.abs(mean_stress_arr) / ult_tensile_strength_arr + + if np.any(np.isclose(ratio, 2.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to double of the ultimate tensile strength," + " this results in infinite equivalent stress amplitude." + ) + + if np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds the ultimate tensile strength. ", + UserWarning, + stacklevel=2, + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _linear_correction_method( + stress_amp_arr, mean_stress_arr, 2 * ult_tensile_strength_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def calc_stress_eq_amp_linear( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + stress_param_m: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using a linear mean stress correction. + + ??? abstract "Math Equations" + The linearly corrected equivalent stress amplitude is calculated as: + + $$ + \sigma_{aeq}=\frac{\sigma_a}{1 - \frac{\sigma_m}{M}} + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + stress_param_m: Array-like of material stress parameters M. + Must be broadcastable with stress_amp and mean_stress. + Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + rtol: Relative tolerance for checking if mean stress magnitude is close to + stress parameter M. + atol: Absolute tolerance for checking if mean stress magnitude is close to + stress parameter M. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds material stress parameter M + ($|\sigma_m| > M$). + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If material stress parameter M is not positive ($M > 0$). + ValueError: If mean stress magnitude is close to stress parameter M + (within tolerance), the equivalent stress amplitude tends to infinity. + ($\left|\frac{\sigma_m}{M}\right| \approx 1.0$ within tolerance). + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + stress_param_m_arr = np.asarray(stress_param_m, dtype=np.float64) + + if np.any(stress_param_m_arr <= 0): + raise ValueError("Material stress parameter M must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = abs(mean_stress_arr) / stress_param_m_arr + + if np.any(np.isclose(ratio, 1.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to stress parameter M, " + "this results in infinite equivalent stress amplitude." + ) + + if np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds material stress parameter M. ", + UserWarning, + stacklevel=2, + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _linear_correction_method( + stress_amp_arr, mean_stress_arr, stress_param_m_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def calc_stress_eq_amp_morrow( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + fat_strength_coef: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Morrow criterion. + + ??? abstract "Math Equations" + The Morrow equivalent stress amplitude is calculated as: + + $$ + \displaystyle\sigma_{aeq}=\frac{\sigma_a}{1-\frac{\sigma_m}{\sigma_{true}} } + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + fat_strength_coef: Array-like of fatigue strength coefficients. Must be + broadcastable with stress_amp and mean_stress. Leading dimensions + are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction + rtol: Relative tolerance for checking if mean stress magnitude is close to + fatigue strength coefficient. + atol: Absolute tolerance for checking if mean stress magnitude is close to + fatigue strength coefficient. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds fatigue strength coefficient + ($|\sigma_m| > \sigma_{f}'$). + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If fatigue strength coefficient is not positive ($\sigma_{f}' > 0$). + ValueError: If mean stress magnitude is close to fatigue strength coefficient + (within tolerance), the equivalent stress amplitude tends to infinity. + ($\left|\frac{\sigma_m}{\sigma_{f}'}\right| \approx 1.0$ within tolerance). + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + fat_strength_coef_arr = np.asarray(fat_strength_coef, dtype=np.float64) + + if np.any(fat_strength_coef_arr <= 0): + raise ValueError("Fatigue strength coefficient must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = np.abs(mean_stress_arr) / fat_strength_coef_arr + + if np.any(np.isclose(ratio, 1.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to fatigue strength coefficient, " + "this results in infinite equivalent stress amplitude." + ) + + if np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds fatigue strength coefficient. ", + UserWarning, + stacklevel=2, + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _linear_correction_method( + stress_amp_arr, mean_stress_arr, fat_strength_coef_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def calc_stress_eq_amp_soderberg( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + yield_strength: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Soderberg criterion. + + ??? abstract "Math Equations" + The Soderberg equivalent stress amplitude is calculated as: + + $$ + \sigma_{aeq}=\frac{\sigma_a}{1-\frac{\sigma_m}{R_e}} + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + yield_strength: Array-like of yield strengths. Must be broadcastable with + stress_amp and mean_stress. Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + rtol: Relative tolerance for checking if mean stress magnitude is close to + yield strength. + atol: Absolute tolerance for checking if mean stress magnitude is close to + yield strength. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds yield strength ($|\sigma_m| > R_e$). + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If yield strength is not positive ($R_e > 0$). + ValueError: If mean stress magnitude is close to yield strength + (within tolerance), the equivalent stress amplitude tends to infinity. + ($\left|\frac{\sigma_m}{R_e}\right| \approx 1.0$ within tolerance). + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + yield_strength_arr = np.asarray(yield_strength, dtype=np.float64) + + if np.any(yield_strength_arr <= 0): + raise ValueError("Yield strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = np.abs(mean_stress_arr) / yield_strength_arr + + if np.any(np.isclose(ratio, 1.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to yield strength, this results in " + "infinite equivalent stress amplitude." + ) + + if np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds yield strength. ", + UserWarning, + stacklevel=2, + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _linear_correction_method( + stress_amp_arr, mean_stress_arr, yield_strength_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def _smith_correction_method( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_tensile_strength: ArrayLike | np.float64, +) -> NDArray[np.float64]: + """Calculate equivalent stress amplitude using Smith criterion.""" + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_tensile_strength_arr = np.asarray(ult_tensile_strength, dtype=np.float64) + + smith_eq_amp = ( + stress_amp_arr * (1 + mean_stress_arr / ult_tensile_strength_arr) + ) / (1 - mean_stress_arr / ult_tensile_strength_arr) + + return smith_eq_amp + + +def calc_stress_eq_amp_smith( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_tensile_strength: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, + rtol: float = _RTOL, + atol: float = _ATOL, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Smith criterion. + + ??? abstract "Math Equations" + The Smith equivalent stress amplitude is calculated as: + + $$ + \sigma_{aeq}=\frac{\sigma_a \cdot \left(1 + \frac{\sigma_m}{\sigma_{UTS}} + \right)}{1-\left(\frac{\sigma_m}{\sigma_{UTS}}\right)} + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + ult_tensile_strength: Array-like of ultimate tensile strengths. Must be + broadcastable with stress_amp and mean_stress. + Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + rtol: Relative tolerance for checking if mean stress magnitude is close to + ultimate tensile strength. + atol: Absolute tolerance for checking if mean stress magnitude is close to + ultimate tensile strength. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds ultimate tensile strength + ($\sigma_m > \sigma_{UTS}$). + Warning: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If ultimate tensile strength is not positive ($\sigma_{UTS} > 0$). + ValueError: If mean stress magnitude is close to ultimate tensile strength + (within tolerance), the equivalent stress amplitude tends to infinity. + ($\left|\frac{\sigma_m}{\sigma_{UTS}}\right| \approx 1.0$ within tolerance). + + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_tensile_strength_arr = np.asarray(ult_tensile_strength, dtype=np.float64) + + if np.any(ult_tensile_strength_arr <= 0): + raise ValueError("Ultimate tensile strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = np.abs(mean_stress_arr / ult_tensile_strength_arr) + + if np.any(np.isclose(ratio, 1.0, rtol=rtol, atol=atol)): + raise ValueError( + "Mean stress magnitude is close to ultimate tensile strength, " + "this results in infinite equivalent stress amplitude." + ) + + if np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds ultimate tensile strength. ", + UserWarning, + stacklevel=2, + ) + + if np.any(stress_amp_arr < 0): + warnings.warn( + "Stress amplitude is negative.", + UserWarning, + stacklevel=2, + ) + + eq_stress_amp_arr = _smith_correction_method( + stress_amp_arr, mean_stress_arr, ult_tensile_strength_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def _swt_correction_method( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, +) -> NDArray[np.float64]: + """Calculate equivalent stress amplitude using Smith-Watson-Topper criterion.""" + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + + swt_eq_amp = np.sqrt(stress_amp_arr * (mean_stress_arr + stress_amp_arr)) + + return swt_eq_amp + + +def calc_stress_eq_amp_swt( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Smith-Watson-Topper parameter. + + ??? abstract "Math Equations" + The SWT equivalent stress amplitude is calculated as: + + $$ + \sigma_{aeq} = \sqrt{\sigma_{a} \cdot (\sigma_{m} + \sigma_{a})} \\ + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + ValueError: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If the validity condition $\sigma_a + \sigma_m >0$ is not satisfied. + + ??? note "Validity Condition" + The SWT parameter is valid when $\sigma_a + \sigma_m > 0$, ensuring that the + maximum stress in the cycle is positive (tensile). When this condition is + not met, a ValueError is raised. + + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + + # Check for negative stress amplitudes + if np.any(stress_amp_arr < 0): + raise ValueError("Stress amplitude must be non-negative") + + # Check validity condition: σₐ + σₘ > 0 + invalid_condition = stress_amp_arr + mean_stress_arr <= 0 + + if np.any(invalid_condition): + raise ValueError( + r"Smith-Watson-Topper parameter validity condition $\sigma_a + \sigma_m >0$" + " not satisfied for some data points. The SWT approach may not be " + "appropriate for compressive-dominated loading conditions." + ) + + eq_stress_amp_arr = _swt_correction_method(stress_amp_arr, mean_stress_arr) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr + + +def _walker_correction_method( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + walker_param: ArrayLike | np.float64, +) -> NDArray[np.float64]: + """Calculate equivalent stress amplitude using Walker criterion.""" + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + walker_param_arr = np.asarray(walker_param, dtype=np.float64) + + walker_eq_amp = (stress_amp_arr + mean_stress_arr) ** ( + 1 - walker_param_arr + ) * stress_amp_arr**walker_param_arr + + return walker_eq_amp + + +def calc_stress_eq_amp_walker( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + walker_param: ArrayLike | np.float64, + allow_neg_mean_stress: bool = True, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Walker criterion. + + ??? abstract "Math Equations" + The Walker equivalent stress amplitude is calculated as: + + $$ + \displaystyle\sigma_{aeq}=\left(\sigma_a+\sigma_m\right)^{1-\gamma} \cdot + \sigma_a^{\gamma} + $$ + + Args: + stress_amp: Array-like of stress amplitudes. Leading dimensions are preserved. + mean_stress: Array-like of mean stresses. Must be broadcastable with + stress_amp. Leading dimensions are preserved. + walker_param: Array-like of Walker exponents ($\gamma$). Must be broadcastable + with stress_amp and mean_stress. Leading dimensions are preserved. + allow_neg_mean_stress: A flag to control the calculation method. + Defaults to True. If set to False, the equivalent stress amplitude will be + set equal to the original stress amplitude for cases where the mean stress + is negative, ignoring the correction. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + ValueError: If stress amplitude is negative ($\sigma_a < 0$). + ValueError: If the validity condition $\sigma_a + \sigma_m >0$ is not satisfied. + ValueError: When the condition $\gamma$ in [0, 1] is not satisfied. + + ??? note "Validity Condition" + The Walker method is valid when $\sigma_a + \sigma_m > 0$, ensuring that the + maximum stress in the cycle is positive (tensile). When this condition is + not met, a ValueError is raised. + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + walker_param_arr = np.asarray(walker_param, dtype=np.float64) + + # Check for negative stress amplitudes + if np.any(stress_amp_arr < 0): + raise ValueError("Stress amplitude must be non-negative") + + # Check validity condition: σₐ + σₘ > 0 + invalid_condition = stress_amp_arr + mean_stress_arr <= 0 + + if np.any(invalid_condition): + raise ValueError( + r"Walker method validity condition $\sigma_a + \sigma_m >0$ not " + "satisfied for some data points. The Walker approach may not be " + "appropriate for compressive-dominated loading conditions." + ) + + # Check validity of Walker parameter: γ' in range [0, 1] + invalid_condition = (walker_param_arr < 0) | (walker_param_arr > 1) + if np.any(invalid_condition): + raise ValueError(r"Walker parameter ($\gamma$) must be in the range [0, 1]. ") + + eq_stress_amp_arr = _walker_correction_method( + stress_amp_arr, mean_stress_arr, walker_param_arr + ) + + # If allow_neg_mean_stress is False, set equivalent stress amplitude = to original + if not allow_neg_mean_stress: + eq_stress_amp_arr = np.where( + mean_stress_arr < 0, stress_amp_arr, eq_stress_amp_arr + ) + + return eq_stress_amp_arr diff --git a/tests/core/stress_life/damage_params/test_uniaxial_stress_eq_amp.py b/tests/core/stress_life/damage_params/test_uniaxial_stress_eq_amp.py new file mode 100644 index 0000000..ea0b3df --- /dev/null +++ b/tests/core/stress_life/damage_params/test_uniaxial_stress_eq_amp.py @@ -0,0 +1,476 @@ +"""Test functions for uniaxial stress equivalent amplitude calculations. + +Tests cover input validation, mathematical correctness, and edge cases for all +four equivalent stress amplitude calculation methods: SWT, Goodman, Gerber, and Morrow. +""" + +from typing import Any, Callable, ClassVar + +import numpy as np +import pytest +from numpy.testing import assert_allclose +from numpy.typing import ArrayLike + +from fatpy.core.stress_life.damage_params.uniaxial_stress_eq_amp import ( + calc_stress_eq_amp_asme, + calc_stress_eq_amp_bagci, + calc_stress_eq_amp_gerber, + calc_stress_eq_amp_goodman, + calc_stress_eq_amp_half_slope, + calc_stress_eq_amp_linear, + calc_stress_eq_amp_morrow, + calc_stress_eq_amp_smith, + calc_stress_eq_amp_soderberg, + calc_stress_eq_amp_swt, + calc_stress_eq_amp_walker, +) + +# --- Shared test fixtures / constants --- + +# Broadcasting test variables +_STRESS_AMP_1D = np.array([150.0, 500.0, 80.0, 200.0]) +_MEAN_STRESS_1D = np.array([100.0, 150.0, 30.0, 0.0]) +_MAT_PARAM_1D = np.array([600.0, 700.0, 800.0, 900.0]) +_STRESS_AMP_2D = np.array([[100.0, 200.0], [150.0, 250.0]]) +_MEAN_STRESS_2D = np.array([[0.0, 50.0], [100.0, 150.0]]) +_MEAN_STRESS_2D_COMPAT = np.array([[0.0, 50.0, 75.0, 100.0], [10.0, 25.0, 30.0, 0.0]]) + +# Scalar inputs for calculation tests +_STRESS_AMP = 180.0 +_MEAN_STRESS = 100.0 +_MAT_PARAM = 500.0 +_MEAN_STRESS_SWT = _MEAN_STRESS_WALKER = 80.0 +_STRESS_AMP_SWT = _STRESS_AMP_WALKER = 150.0 + +# Walker exponent (dimensionless, ∈ [0, 1]) +_WALKER_PARAM = 0.5 + +# --- Base class --- + + +class _BaseEqAmpTests: + """Shared tests for all calc_stress_eq_amp_* methods that: + - accept (stress_amp, mean_stress, material_param, allow_neg_mean_stress) + - raise ValueError for invalid (≤0) material param + - raise ValueError when |mean_stress| is close to material param (isclose) + - raise ValueError or Warning when |mean_stress| exceeds material param + - raise Warning when stress_amp is negative + """ + + correction_method: ClassVar[Callable[..., Any]] + expected: ClassVar[float] # expected result for (_SA, +_SM, _MAT_PARAM) + neg_sm_expected: ClassVar[float] # expected result for (_SA, -_SM, _MAT_PARAM) + + # --- Correct caLculation tests --- + + # Trivial case: positive mean stress is corrected → expected result + def test_positive_mean_stress(self) -> None: + assert_allclose( + self.correction_method(_STRESS_AMP, _MEAN_STRESS, _MAT_PARAM), self.expected + ) + + # Negative mean stress case: + def test_negative_mean_stress_with_correction(self) -> None: + assert_allclose( + self.correction_method(_STRESS_AMP, -_MEAN_STRESS, _MAT_PARAM), + self.neg_sm_expected, + ) + + # Zero mean stress: no correction + def test_zero_mean_stress(self) -> None: + assert_allclose( + self.correction_method(_STRESS_AMP, 0.0, _MAT_PARAM), _STRESS_AMP + ) + + # allow_neg_mean_stress=False: + def test_negative_mean_stress_no_correction(self) -> None: + assert_allclose( + self.correction_method( + _STRESS_AMP, -_MEAN_STRESS, _MAT_PARAM, allow_neg_mean_stress=False + ), + _STRESS_AMP, + ) + + # Positive sm: always corrected regardless of the flag + def test_positive_mean_stress_allow_neg_false(self) -> None: + + assert_allclose( + self.correction_method( + _STRESS_AMP, _MEAN_STRESS, _MAT_PARAM, allow_neg_mean_stress=False + ), + self.expected, + ) + + # Array with mixed signs: negative entries return sa, positive entries corrected + def test_mixed_sign_array_allow_neg_false(self) -> None: + result = self.correction_method( + _STRESS_AMP, + [-_MEAN_STRESS, 0.0, _MEAN_STRESS], + _MAT_PARAM, + allow_neg_mean_stress=False, + ) + assert_allclose(result, [_STRESS_AMP, _STRESS_AMP, self.expected]) + + # --- Broadcasting --- + + @pytest.mark.parametrize( + "stress_amp, mean_stress, mp", + [ + pytest.param( + _STRESS_AMP_1D, + 100.0, + 700.0, + id="stress_amp_1d-mean_stress_scalar-mp_scalar", + ), + pytest.param( + 150.0, + _MEAN_STRESS_1D, + 700.0, + id="stress_amp_scalar-mean_stress_1d-mp_scalar", + ), + pytest.param( + 150.0, + 100.0, + _MAT_PARAM_1D, + id="stress_amp_scalar-mean_stress_scalar-mp_1d", + ), + pytest.param(_STRESS_AMP_1D, _MEAN_STRESS_1D, _MAT_PARAM_1D, id="all_1d"), + pytest.param( + _STRESS_AMP_2D, + _MEAN_STRESS_2D, + 700.0, + id="stress_amp_2d-mean_stress_2d-mp_scalar", + ), + pytest.param( + 150.0, + _MEAN_STRESS_2D, + 700.0, + id="stress_amp_scalar-mean_stress_2d-mp_scalar", + ), + pytest.param( + _STRESS_AMP_1D, + _MEAN_STRESS_2D_COMPAT, + 700.0, + id="stress_amp_1d-mean_stress_2d-mp_scalar", + ), + ], + ) + def test_broadcasting( + self, + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + mp: ArrayLike | np.float64, + ) -> None: + result = self.correction_method(stress_amp, mean_stress, mp) + expected_shape = np.broadcast_shapes( + np.asarray(stress_amp).shape, + np.asarray(mean_stress).shape, + np.asarray(mp).shape, + ) + assert result.shape == expected_shape + assert result.dtype == np.float64 + + # --- Input validation --- + + @pytest.mark.parametrize("mp", [0.0, -500.0]) + def test_invalid_material_param(self, mp: float) -> None: + with pytest.raises(ValueError): + self.correction_method(100.0, 50.0, mp) + + @pytest.mark.parametrize("ms", [500.0, -500.0, 499.97, -499.97]) + def test_mean_stress_close_to_param_error(self, ms: float) -> None: + with pytest.raises(ValueError): + self.correction_method(100.0, ms, 500.0) + + # Default behaviour: warn when |sm| > param. + @pytest.mark.parametrize("ms", [600.0, -600.0]) + def test_mean_stress_exceeds_param(self, ms: float) -> None: + with pytest.warns(UserWarning): + self.correction_method(100.0, ms, 500.0) + + @pytest.mark.parametrize("sa", [-100.0, -0.001]) + def test_negative_stress_amp_warning(self, sa: float) -> None: + with pytest.warns(UserWarning): + self.correction_method(sa, 50.0, 500.0) + + +# --- Concrete test classes --- + + +class TestCalcStressEqAmpAsme(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_asme) + expected = _STRESS_AMP / np.sqrt(1.0 - (_MEAN_STRESS / _MAT_PARAM) ** 2) + neg_sm_expected = _STRESS_AMP / np.sqrt(1.0 - (-_MEAN_STRESS / _MAT_PARAM) ** 2) + + # Override: ASME method raises ValueError + @pytest.mark.parametrize("ms", [600.0, -600.0]) + def test_mean_stress_exceeds_param(self, ms: float) -> None: + with pytest.raises(ValueError): + self.correction_method(100.0, ms, 500.0) + + +class TestCalcStressEqAmpBagci(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_bagci) + expected = _BAGCI_EXPECTED = _STRESS_AMP / (1.0 - (_MEAN_STRESS / _MAT_PARAM) ** 4) + neg_sm_expected = _BAGCI_EXPECTED = _STRESS_AMP / ( + 1.0 - (-_MEAN_STRESS / _MAT_PARAM) ** 4 + ) + + +class TestCalcStressEqAmpGerber(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_gerber) + expected = _STRESS_AMP / (1.0 - (_MEAN_STRESS / _MAT_PARAM) ** 2) + neg_sm_expected = _STRESS_AMP / (1.0 - (-_MEAN_STRESS / _MAT_PARAM) ** 2) + + +class TestCalcStressEqAmpGoodman(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_goodman) + expected = _STRESS_AMP / (1.0 - _MEAN_STRESS / _MAT_PARAM) + neg_sm_expected = _STRESS_AMP / (1.0 - (-_MEAN_STRESS / _MAT_PARAM)) + + +class TestCalcStressEqAmpHalfSlope(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_half_slope) + expected = _STRESS_AMP / (1.0 - _MEAN_STRESS / (2.0 * _MAT_PARAM)) + neg_sm_expected = _STRESS_AMP / (1.0 - (-_MEAN_STRESS) / (2.0 * _MAT_PARAM)) + + # Half-slope singularity is at |sm| ≈ 2*UTS → override boundary test + @pytest.mark.parametrize("ms", [1000.0, -1000.0, 999.97, -999.97]) + def test_mean_stress_close_to_param_error(self, ms: float) -> None: + with pytest.raises(ValueError): + self.correction_method(100.0, ms, 500.0) + + +class TestCalcStressEqAmpLinear(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_linear) + expected = _STRESS_AMP / (1.0 - _MEAN_STRESS / _MAT_PARAM) + neg_sm_expected = _STRESS_AMP / (1.0 - (-_MEAN_STRESS / _MAT_PARAM)) + + +class TestCalcStressEqAmpMorrow(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_morrow) + expected = _STRESS_AMP / (1.0 - _MEAN_STRESS / _MAT_PARAM) + neg_sm_expected = _STRESS_AMP / (1.0 - (-_MEAN_STRESS / _MAT_PARAM)) + + +class TestCalcStressEqAmpSoderberg(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_soderberg) + expected = _STRESS_AMP / (1.0 - _MEAN_STRESS / _MAT_PARAM) + neg_sm_expected = _STRESS_AMP / (1.0 - (-_MEAN_STRESS / _MAT_PARAM)) + + +class TestCalcStressEqAmpSmith(_BaseEqAmpTests): + correction_method = staticmethod(calc_stress_eq_amp_smith) + expected = ( + _STRESS_AMP + * (1.0 + _MEAN_STRESS / _MAT_PARAM) + / (1.0 - _MEAN_STRESS / _MAT_PARAM) + ) + neg_sm_expected = ( + _STRESS_AMP + * (1.0 + (-_MEAN_STRESS) / _MAT_PARAM) + / (1.0 - (-_MEAN_STRESS) / _MAT_PARAM) + ) + + +# --- SWT and Walker: standalone classes (different signatures and validation) --- +_SWT_EXPECTED = np.sqrt(_STRESS_AMP_SWT * (_MEAN_STRESS_SWT + _STRESS_AMP_SWT)) +_SWT_NEG_SM_EXPECTED = np.sqrt(_STRESS_AMP_SWT * (-_MEAN_STRESS_SWT + _STRESS_AMP_SWT)) + +_WALKER_EXPECTED = (_STRESS_AMP_WALKER + _MEAN_STRESS_WALKER) ** ( + 1 - _WALKER_PARAM +) * _STRESS_AMP_WALKER**_WALKER_PARAM + +_WALKER_NEG_SM_EXPECTED = (_STRESS_AMP_WALKER - _MEAN_STRESS_WALKER) ** ( + 1 - _WALKER_PARAM +) * _STRESS_AMP_WALKER**_WALKER_PARAM + + +class TestCalcStressEqAmpSwt: + """Tests for SWT: sqrt(sa * (sm + sa)). No material param.""" + + # Trivial case + def test_positive_mean_stress(self) -> None: + assert_allclose( + calc_stress_eq_amp_swt(_STRESS_AMP_SWT, _MEAN_STRESS_SWT), _SWT_EXPECTED + ) + + # Negative mean stress: + def test_negative_mean_stress_with_correction(self) -> None: + assert_allclose( + calc_stress_eq_amp_swt(_STRESS_AMP_SWT, -_MEAN_STRESS_SWT), + _SWT_NEG_SM_EXPECTED, + ) + + # Zero mean stress: + def test_zero_mean_stress(self) -> None: + assert_allclose(calc_stress_eq_amp_swt(_STRESS_AMP_SWT, 0.0), _STRESS_AMP_SWT) + + # Negative mean stress with allow_neg_mean_stress=False: + def test_negative_mean_stress_no_correction(self) -> None: + result = calc_stress_eq_amp_swt( + _STRESS_AMP_SWT, -_MEAN_STRESS_SWT, allow_neg_mean_stress=False + ) + assert_allclose(result, _STRESS_AMP_SWT) + + # Positive mean stress with allow_neg_mean_stress=False: + def test_positive_mean_stress_allow_neg_false(self) -> None: + result = calc_stress_eq_amp_swt( + _STRESS_AMP_SWT, _MEAN_STRESS_SWT, allow_neg_mean_stress=False + ) + assert_allclose(result, _SWT_EXPECTED) + + # Array with mixed signs: negative entries return sa, positive entries corrected + def test_mixed_sign_array_allow_neg_false(self) -> None: + result = calc_stress_eq_amp_swt( + _STRESS_AMP_SWT, + [-_MEAN_STRESS_SWT, 0.0, _MEAN_STRESS_SWT], + allow_neg_mean_stress=False, + ) + assert_allclose(result, [_STRESS_AMP_SWT, _STRESS_AMP_SWT, _SWT_EXPECTED]) + + @pytest.mark.parametrize( + "stress_amp, mean_stress", + [ + pytest.param(_STRESS_AMP_1D, 100.0, id="sa_1d-sm_scalar"), + pytest.param(150.0, _MEAN_STRESS_1D, id="sa_scalar-sm_1d"), + pytest.param(_STRESS_AMP_1D, _MEAN_STRESS_1D, id="all_1d"), + pytest.param(_STRESS_AMP_2D, _MEAN_STRESS_2D, id="sa_2d-sm_2d"), + pytest.param(150.0, _MEAN_STRESS_2D, id="sa_scalar-sm_2d"), + pytest.param(_STRESS_AMP_1D, _MEAN_STRESS_2D_COMPAT, id="sa_1d-sm_2d"), + ], + ) + def test_broadcasting( + self, + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ) -> None: + result = calc_stress_eq_amp_swt(stress_amp, mean_stress) + expected_shape = np.broadcast_shapes( + np.asarray(stress_amp).shape, + np.asarray(mean_stress).shape, + ) + assert result.shape == expected_shape + assert result.dtype == np.float64 + + @pytest.mark.parametrize("sa", [-_STRESS_AMP_SWT, -1.0, -0.001]) + def test_negative_stress_amp_raises(self, sa: float) -> None: + with pytest.raises(ValueError): + calc_stress_eq_amp_swt(sa, 100.0) + + @pytest.mark.parametrize( + "sa, sm", + [ + pytest.param(100.0, -100.0, id="sa_plus_sm_equals_zero"), + pytest.param(100.0, -200.0, id="sa_plus_sm_negative"), + ], + ) + def test_validity_condition_violated(self, sa: float, sm: float) -> None: + with pytest.raises(ValueError): + calc_stress_eq_amp_swt(sa, sm) + + +class TestCalcStressEqAmpWalker: + """Tests for Walker: (sa + sm)^(1-γ) * sa^γ. walker_param = γ ∈ [0, 1].""" + + # Trivial case + def test_positive_mean_stress(self) -> None: + assert_allclose( + calc_stress_eq_amp_walker( + _STRESS_AMP_WALKER, _MEAN_STRESS_WALKER, _WALKER_PARAM + ), + _WALKER_EXPECTED, + ) + + # Negative mean stress: + def test_negative_mean_stress_with_correction(self) -> None: + assert_allclose( + calc_stress_eq_amp_walker( + _STRESS_AMP_WALKER, -_MEAN_STRESS_WALKER, _WALKER_PARAM + ), + _WALKER_NEG_SM_EXPECTED, + ) + + # Zero mean stress: + def test_zero_mean_stress(self) -> None: + assert_allclose( + calc_stress_eq_amp_walker(_STRESS_AMP_WALKER, 0.0, _WALKER_PARAM), + _STRESS_AMP_WALKER, + ) + + # Negative mean stress with allow_neg_mean_stress=False: + def test_negative_mean_stress_no_correction(self) -> None: + result = calc_stress_eq_amp_walker( + _STRESS_AMP_WALKER, + -_MEAN_STRESS_WALKER, + _WALKER_PARAM, + allow_neg_mean_stress=False, + ) + assert_allclose(result, _STRESS_AMP_WALKER) + + # Positive mean stress with allow_neg_mean_stress=False: + def test_positive_mean_stress_allow_neg_false(self) -> None: + result = calc_stress_eq_amp_walker( + _STRESS_AMP_WALKER, + _MEAN_STRESS_WALKER, + _WALKER_PARAM, + allow_neg_mean_stress=False, + ) + assert_allclose(result, _WALKER_EXPECTED) + + # Array with mixed signs: negative entries return sa, positive entries corrected + def test_mixed_sign_array_allow_neg_false(self) -> None: + result = calc_stress_eq_amp_walker( + _STRESS_AMP_WALKER, + [-_MEAN_STRESS_WALKER, 0.0, _MEAN_STRESS_WALKER], + _WALKER_PARAM, + allow_neg_mean_stress=False, + ) + assert_allclose( + result, [_STRESS_AMP_WALKER, _STRESS_AMP_WALKER, _WALKER_EXPECTED] + ) + + @pytest.mark.parametrize( + "stress_amp, mean_stress", + [ + pytest.param(_STRESS_AMP_1D, 100.0, id="sa_1d-sm_scalar"), + pytest.param(150.0, _MEAN_STRESS_1D, id="sa_scalar-sm_1d"), + pytest.param(_STRESS_AMP_1D, _MEAN_STRESS_1D, id="all_1d"), + pytest.param(_STRESS_AMP_2D, _MEAN_STRESS_2D, id="sa_2d-sm_2d"), + pytest.param(150.0, _MEAN_STRESS_2D, id="sa_scalar-sm_2d"), + pytest.param(_STRESS_AMP_1D, _MEAN_STRESS_2D_COMPAT, id="sa_1d-sm_2d"), + ], + ) + def test_broadcasting( + self, + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ) -> None: + result = calc_stress_eq_amp_walker(stress_amp, mean_stress, _WALKER_PARAM) + expected_shape = np.broadcast_shapes( + np.asarray(stress_amp).shape, + np.asarray(mean_stress).shape, + ) + assert result.shape == expected_shape + assert result.dtype == np.float64 + + @pytest.mark.parametrize("sa", [-_STRESS_AMP_WALKER, -1.0, -0.001]) + def test_negative_stress_amp_raises(self, sa: float) -> None: + with pytest.raises(ValueError): + calc_stress_eq_amp_walker(sa, 100.0, _WALKER_PARAM) + + @pytest.mark.parametrize( + "sa, sm", + [ + pytest.param(100.0, -100.0, id="sa_plus_sm_equals_zero"), + pytest.param(100.0, -200.0, id="sa_plus_sm_negative"), + ], + ) + def test_validity_condition_violated(self, sa: float, sm: float) -> None: + with pytest.raises(ValueError): + calc_stress_eq_amp_walker(sa, sm, _WALKER_PARAM) + + @pytest.mark.parametrize("gamma", [-0.1, 1.1, -1.0, 2.0]) + def test_invalid_walker_param(self, gamma: float) -> None: + with pytest.raises(ValueError): + calc_stress_eq_amp_walker(_STRESS_AMP_WALKER, _MEAN_STRESS_WALKER, gamma)