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..aa56da5 --- /dev/null +++ b/src/fatpy/core/stress_life/damage_params/uniaxial_stress_eq_amp.py @@ -0,0 +1,667 @@ +"""Uniaxial fatigue criteria methods for the stress-life approach. + +Contains criteria that address uniaxial high-cycle fatigue by incorporating the mean +stress effect through an equivalent stress amplitude approach. By adjusting the stress +amplitude to account for mean stress influences—using models such as Goodman, Gerber, +or Soderberg—they enable more accurate fatigue life predictions where mean stresses +significantly affect material endurance. + +For more information you can refer to the following resource: +https://doi.org/10.1051/matecconf/201816510018 +""" + +import warnings + +import numpy as np +from numpy.typing import ArrayLike, NDArray + + +def calc_stress_eq_amp_ASME( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + yield_strength: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using ASME criterion. + + ??? info "ASME Use-case" + The ASME criterion accounts for mean stress effects in high-cycle fatigue + by modifying the stress amplitude based on the yield strength using a linear + relationship. + + ??? 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: 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. + + Raises: + ValueError: If yield strength is not positive. + ValueError: If mean stress magnitude is equal or greater to yield strength, + resulting in infinite equivalent stress amplitude. + """ + 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 equal or greater than yield strength.") + + return stress_amp_arr / (1 - (mean_stress_arr / yield_strength_arr) ** 2) ** 0.5 + + +def calc_stress_eq_amp_bagci( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + yield_strength: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Bagci criterion. + + ??? info "Bagci Use-case" + The Bagci criterion accounts for mean stress effects in high-cycle fatigue + by modifying the stress amplitude based on the yield strength using a linear + relationship. + + ??? 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. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress magnitude exceeds yield strength. + ValueError: If yield strength is not positive. + ValueError: If mean stress magnitude is equal to yield strength, + resulting in infinite equivalent stress amplitude. + """ + 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 equals yield strength this would result in " + "infinite equivalent stress amplitude." + ) + elif np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds yield strength.", + UserWarning, + stacklevel=2, + ) + + return stress_amp_arr / (1 - (mean_stress_arr / yield_strength_arr) ** 4) + + +def calc_stress_eq_amp_gerber( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_stress: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Gerber criterion. + + ??? info "Gerber Use-case" + The Gerber criterion accounts for mean stress effects in high-cycle fatigue + by modifying the stress amplitude based on the ultimate tensile strength. + + ??? 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_stress: Array-like of ultimate tensile strengths. Must be broadcastable + with stress_amp and mean_stress. Leading dimensions are preserved. + + 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. + ValueError: If ultimate tensile strength is not positive. + ValueError: If mean stress magnitude is equal to ultimate tensile strength, + resulting in infinite equivalent stress amplitude. + + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_stress_arr = np.asarray(ult_stress, dtype=np.float64) + + if np.any(ult_stress_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_stress_arr + + if np.any(ratio == 1.0): + raise ValueError( + "Mean stress equals ultimate tensile strength this would result in " + "infinite equivalent stress amplitude." + ) + elif np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds ultimate tensile strength. ", + UserWarning, + stacklevel=2, + ) + + return stress_amp_arr / (1 - (mean_stress_arr / ult_stress_arr) ** 2) + + +def calc_stress_eq_amp_goodman( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_stress: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Goodman criterion. + + ??? info "Goodman Use-case" + The Goodman criterion accounts for mean stress effects in high-cycle fatigue + by modifying the stress amplitude based on the ultimate tensile strength using + a linear relationship. + + ??? 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_stress: Array-like of ultimate tensile strengths. Must be broadcastable + with stress_amp and mean_stress. Leading dimensions are preserved. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress exceeds ultimate tensile strength. + ValueError: If ultimate tensile strength is not positive. + ValueError: If mean stress is equal to ultimate tensile strength, resulting in + infinite equivalent stress amplitude. + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_stress_arr = np.asarray(ult_stress, dtype=np.float64) + + if np.any(ult_stress_arr <= 0): + raise ValueError("Ultimate tensile strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = mean_stress_arr / ult_stress_arr + + if np.any(ratio == 1.0): + raise ValueError( + "Mean stress equals ultimate tensile strength this would result in " + "infinite equivalent stress amplitude." + ) + elif np.any(ratio > 1.0): + warnings.warn( + "Mean stress magnitude exceeds ultimate tensile strength. ", + UserWarning, + stacklevel=2, + ) + + return stress_amp_arr / (1 - mean_stress_arr / ult_stress_arr) + + +def calc_stress_eq_amp_half_slope( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_stress: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using a half-slope mean stress correction. + + ??? info "Half-slope Use-case" + A half-slope mean stress correction can be applied to account for mean + stress effects in high-cycle fatigue by modifying the stress amplitude based + on the ultimate tensile strength. + + ??? 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_stress: Array-like of ultimate tensile strengths. Must be broadcastable + with stress_amp and mean_stress. Leading dimensions are preserved. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress exceeds half of the ultimate tensile strength. + ValueError: If ultimate tensile strength is not positive. + ValueError: If mean stress is equal to half of the ultimate tensile strength, + resulting in zero equivalent stress amplitude. + + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_stress_arr = np.asarray(ult_stress, dtype=np.float64) + + if np.any(ult_stress_arr <= 0): + raise ValueError("Ultimate tensile strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = mean_stress_arr / (2 * ult_stress_arr) + if np.any(ratio == 1.0): + raise ValueError( + "Mean stress equals half of the ultimate tensile strength this would result" + "in zero equivalent stress amplitude." + ) + elif np.any(ratio > 1.0): + warnings.warn( + "Mean stress exceeds half of the ultimate tensile strength. ", + UserWarning, + stacklevel=2, + ) + return stress_amp_arr / (1 - mean_stress_arr / (2 * ult_stress_arr)) + + +def calc_stress_eq_amp_linear( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + stress_parameter_M: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using a linear mean stress correction. + + ??? info "Linear Use-case" + A simple linear mean stress correction can be applied to account for mean + stress effects in high-cycle fatigue by modifying the stress amplitude based + on the ultimate tensile strength. + + ??? 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_parameter_M: Array-like of material stress parameters M. + Must be broadcastable with stress_amp and mean_stress. + Leading dimensions are preserved. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress exceeds material stress parameter M. + ValueError: If material stress parameter M is not positive. + ValueError: If mean stress is equal to material stress parameter M, resulting in + zero equivalent stress amplitude. + + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + stress_parameter_M_arr = np.asarray(stress_parameter_M, dtype=np.float64) + + if np.any(stress_parameter_M_arr <= 0): + raise ValueError("Material stress parameter M must be positive") + # Check if mean stress approaches or exceeds material parameter + ratio = mean_stress_arr / stress_parameter_M_arr + + if np.any(ratio == 1.0): + raise ValueError( + "Mean stress equals material stress parameter M this would result in " + "zero equivalent stress amplitude." + ) + elif np.any(ratio > 1.0): + warnings.warn( + "Mean stress exceeds material stress parameter M. ", + UserWarning, + stacklevel=2, + ) + + return stress_amp_arr / (1 - mean_stress_arr / stress_parameter_M_arr) + + +def calc_stress_eq_amp_morrow( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + true_fract_stress: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Morrow criterion. + + ??? info "Morrow Use-case" + The Morrow criterion accounts for mean stress effects in high-cycle fatigue + by modifying the stress amplitude based on the true fracture strength. + + ??? 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. + true_fract_stress: Array-like of true tensile fracture stress. Must be + broadcastable with stress_amp and mean_stress. Leading dimensions + are preserved. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress exceeds true fracture stress. + ValueError: If true fracture stress is not positive. + ValueError: If mean stress is equal to true fracture stress, resulting in + infinite equivalent stress amplitude. + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + true_fract_stress_arr = np.asarray(true_fract_stress, dtype=np.float64) + + if np.any(true_fract_stress_arr <= 0): + raise ValueError("True fracture stress must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = mean_stress_arr / true_fract_stress_arr + + if np.any(ratio == 1.0): + raise ValueError( + "Mean stress equals true fracture stress this would result in " + "infinite equivalent stress amplitude." + ) + elif np.any(ratio > 1.0): + warnings.warn( + "Mean stress exceeds true fracture stress. ", + UserWarning, + stacklevel=2, + ) + + return stress_amp_arr / (1 - mean_stress_arr / true_fract_stress_arr) + + +def calc_stress_eq_amp_soderberg( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + yield_strength: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Soderberg criterion. + + ??? info "Soderberg Use-case" + The Soderberg criterion accounts for mean stress effects in high-cycle fatigue + by modifying the stress amplitude based on the yield strength using a linear + relationship. + + ??? 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. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress exceeds yield strength. + ValueError: If yield strength is not positive. + ValueError: If mean stress is equal to yield strength, resulting in + infinite equivalent stress amplitude. + """ + 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 = mean_stress_arr / yield_strength_arr + if np.any(ratio == 1.0): + raise ValueError( + "Mean stress equals yield strength this would result in " + "infinite equivalent stress amplitude." + ) + elif np.any(ratio > 1.0): + warnings.warn( + "Mean stress exceeds yield strength. ", + UserWarning, + stacklevel=2, + ) + + return stress_amp_arr / (1 - mean_stress_arr / yield_strength_arr) + + +def calc_stress_eq_amp_smith( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + ult_stress: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Smith criterion. + + ??? info "Smith Use-case" + The Smith criterion accounts for mean stress effects in high-cycle fatigue + by modifying the stress amplitude based on the ultimate tensile strength. + + ??? 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_stress: Array-like of ultimate tensile strengths. Must be broadcastable + with stress_amp and mean_stress. Leading dimensions are preserved. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress exceeds ultimate tensile strength. + ValueError: If ultimate tensile strength is not positive. + ValueError: If mean stress is equal to ultimate tensile strength, resulting in + infinite equivalent stress amplitude. + + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + ult_stress_arr = np.asarray(ult_stress, dtype=np.float64) + + if np.any(ult_stress_arr <= 0): + raise ValueError("Ultimate tensile strength must be positive") + + # Check if mean stress approaches or exceeds material parameter + ratio = mean_stress_arr / ult_stress_arr + if np.any(ratio == 1.0): + raise ValueError( + "Mean stress equals ultimate tensile strength this would result in " + "infinite equivalent stress amplitude." + ) + elif np.any(ratio > 1.0): + warnings.warn( + "Mean stress exceeds ultimate tensile strength. ", + UserWarning, + stacklevel=2, + ) + return (stress_amp_arr * (1 + mean_stress_arr / ult_stress_arr)) / ( + 1 - mean_stress_arr / ult_stress_arr + ) + + +def calc_stress_eq_amp_swt( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Smith-Watson-Topper parameter. + + ??? info "SWT Use-case" + The Smith-Watson-Topper (SWT) parameter accounts for mean stress effects in + high-cycle fatigue by combining stress amplitude and maximum stress in the cycle + + ??? abstract "Math Equations" + The SWT equivalent stress amplitude is calculated as: + + $$ + \begin{align*} + \sigma_{aeq} = \sqrt{\sigma_{a} \cdot (\sigma_{m} + \sigma_{a})} \\ + \text{where: } \sigma_{m} < 0 \rightarrow \sigma_{aeq} = \sigma_{a} \\ + \end{align*} + $$ + + 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. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + Warning: If mean stress is compressive (σₘ < 0), a warning is issued and + the equivalent stress amplitude is set equal to the stress amplitude (σₐ) + ValueError: If stress amplitude is negative. + ValueError: If the validity condition σₐ > |σₘ| is not satisfied. + + ??? note "Validity Condition" + The SWT parameter is valid when $\sigma_a > |\sigma_m|$, 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: σₐ > |σₘ| + abs_mean_stress = np.abs(mean_stress_arr) + invalid_condition = stress_amp_arr <= abs_mean_stress + + if np.any(invalid_condition): + raise ValueError( + "Smith-Watson-Topper parameter validity condition (σₐ > |σₘ|) not " + "satisfied for some data points. The SWT approach may not be " + "appropriate for compressive-dominated loading conditions." + ) + + if np.any(mean_stress_arr < 0): + warnings.warn( + r"Mean stress is compressive, $\sigma_{aeq} = \sigma_a$ was used!", + UserWarning, + stacklevel=2, + ) + return stress_amp_arr + + return np.sqrt(stress_amp_arr * (mean_stress_arr + stress_amp_arr)) + + +def calc_stress_eq_amp_walker( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + walker_parameter: ArrayLike | np.float64, +) -> NDArray[np.float64]: + r"""Calculate equivalent stress amplitude using Walker criterion. + + ??? info "Walker Use-case" + The Walker criterion accounts for mean stress effects in high-cycle fatigue + by modifying by combining stress amplitude and maximum stress in the cycle and + utilizing a material specific exponent - the Walker parameter (γ'). + + ??? 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_parameter: Array-like of Walker exponents (γ'). Must be broadcastable + with stress_amp and mean_stress. Leading dimensions are preserved. + + Returns: + Array of equivalent stress amplitudes. Shape follows NumPy broadcasting + rules for the input arrays. + + Raises: + ValueError: If input arrays cannot be broadcast together or when the + condition γ' in (0, 1) is not satisfied. + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + walker_parameter_arr = np.asarray(walker_parameter, dtype=np.float64) + + # Check validity of Walker parameter: γ' in range (0, 1) + invalid_condition = (walker_parameter_arr < 0) | (walker_parameter_arr > 1) + if np.any(invalid_condition): + raise ValueError("Walker parameter (γ') must be in the range (0, 1). ") + + return (stress_amp_arr + mean_stress_arr) ** ( + 1 - walker_parameter_arr + ) * stress_amp_arr**walker_parameter_arr diff --git a/tests/core/stress_life/damage_params/test_iniaxial_stress_eq_amp.py b/tests/core/stress_life/damage_params/test_iniaxial_stress_eq_amp.py new file mode 100644 index 0000000..2ffee18 --- /dev/null +++ b/tests/core/stress_life/damage_params/test_iniaxial_stress_eq_amp.py @@ -0,0 +1,267 @@ +"""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 Union + +import numpy as np +import pytest +from numpy.testing import assert_allclose +from numpy.typing import NDArray + +from fatpy.core.stress_life.damage_params.uniaxial_stress_eq_amp import ( + _validate_stress_inputs, + calc_stress_eq_amp_gerber, + calc_stress_eq_amp_goodman, + calc_stress_eq_amp_morrow, + calc_stress_eq_amp_swt, +) + +# Type alias for stress calculation functions +callable = Union[ + type(calc_stress_eq_amp_swt), + type(calc_stress_eq_amp_goodman), + type(calc_stress_eq_amp_gerber), + type(calc_stress_eq_amp_morrow), +] + + +@pytest.fixture +def sample_stress_data() -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Fixture providing sample stress amplitude and mean stress data. + + Returns: + tuple: (stress_amplitudes, mean_stresses) arrays for testing + """ + stress_amplitudes = np.array([150.0, 500.0, 80.0]) + mean_stresses = np.array([100.0, 30.0, 0.0]) + return stress_amplitudes, mean_stresses + + +@pytest.fixture +def material_properties() -> dict[str, float]: + """Fixture providing sample material properties. + + Returns: + dict: Material properties for testing + """ + return { + "ult_stress": 700.0, + "true_fract_stress": 770.0, + } + + +@pytest.fixture +def zero_mean_stress_case() -> tuple[float, float]: + """Fixture providing stress case with zero mean stress (purely alternating). + + Returns: + tuple: (stress_amplitude, mean_stress) with mean_stress = 0 + """ + return 100.0, 0.0 + + +@pytest.fixture +def negative_mean_stress_case() -> tuple[float, float]: + """Fixture providing stress case with negative mean stress. + + Returns: + tuple: (stress_amplitude, mean_stress) with negative mean_stress + """ + return 150.0, -50.0 + + +@pytest.fixture +def validation_test_cases() -> dict[str, tuple[float, float, float, str]]: + """Fixture providing test cases for input validation. + + Returns: + dict: Test cases with (stress_amp, mean_stress, material_param, param_name) + """ + return { + "valid_case": (100.0, 50.0, 400.0, "test parameter"), + "negative_stress_amp": (-50.0, 30.0, 400.0, "test parameter"), + "negative_material_param": (100.0, 50.0, -400.0, "test parameter"), + "zero_material_param": (100.0, 50.0, 0.0, "ultimate tensile strength"), + "mean_exceeds_material": (100.0, 450.0, 400.0, "ultimate tensile strength"), + "mean_equals_material": (100.0, 400.0, 400.0, "ultimate tensile strength"), + } + + +def test_validate_stress_inputs_valid_no_material_param( + sample_stress_data: tuple[NDArray[np.float64], NDArray[np.float64]], +) -> None: + """Test validation with valid inputs and no material parameter.""" + stress_amp, mean_stress = sample_stress_data + + stress_amp_arr, mean_stress_arr = _validate_stress_inputs(stress_amp, mean_stress) + + assert_allclose(stress_amp_arr, stress_amp) + assert_allclose(mean_stress_arr, mean_stress) + assert stress_amp_arr.dtype == np.float64 + assert mean_stress_arr.dtype == np.float64 + + +@pytest.mark.parametrize( + "case_name,should_pass,expected_error", + [ + ("valid_case", True, None), + ("negative_stress_amp", False, "Stress amplitude must be non-negative"), + ("negative_material_param", False, "test parameter must be positive"), + ("zero_material_param", False, "ultimate tensile strength must be positive"), + ("mean_exceeds_material", False, "Mean stress magnitude.*exceeds or equals"), + ("mean_equals_material", False, "Mean stress magnitude.*exceeds or equals"), + ], +) +def test_validate_stress_inputs_parametrized( + validation_test_cases: dict[str, tuple[float, float, float, str]], + case_name: str, + should_pass: bool, + expected_error: str | None, +) -> None: + """Parametrized test for input validation with various cases.""" + test_case = validation_test_cases[case_name] + stress_amp, mean_stress, material_param, param_name = test_case + + if should_pass: + stress_amp_arr, mean_stress_arr = _validate_stress_inputs( + stress_amp, mean_stress, material_param, param_name + ) + assert stress_amp_arr == stress_amp + assert mean_stress_arr == mean_stress + else: + with pytest.raises(ValueError, match=expected_error): + _validate_stress_inputs(stress_amp, mean_stress, material_param, param_name) + + +def test_validate_stress_inputs_array_broadcasting() -> None: + """Test validation with different array shapes.""" + stress_amp = np.array([100.0, 200.0, 150.0]) + mean_stress = 50.0 + material_param = 500.0 + + stress_amp_arr, mean_stress_arr = _validate_stress_inputs( + stress_amp, mean_stress, material_param + ) + + assert stress_amp_arr.shape == (3,) + assert mean_stress_arr.shape == () + assert_allclose(stress_amp_arr, [100.0, 200.0, 150.0]) + assert mean_stress_arr == 50.0 + + +@pytest.mark.parametrize( + "method,stress_amp,mean_stress,material_param,expected_result", + [ + (calc_stress_eq_amp_swt, 290.0, 10.0, None, 294.958), + (calc_stress_eq_amp_goodman, 180.0, 100.0, 700.0, 210.0), + (calc_stress_eq_amp_gerber, 180.0, 100.0, 700.0, 183.8), + (calc_stress_eq_amp_morrow, 180.0, 100.0, 770.0, 206.9), + ], +) +def test_calc_stress_eq_amp_basic_calculations( + method: callable, + stress_amp: float, + mean_stress: float, + material_param: float | None, + expected_result: float, +) -> None: + """Test basic calculations for all equivalent stress amplitude methods.""" + if material_param is None: + result = method(stress_amp, mean_stress) + else: + result = method(stress_amp, mean_stress, material_param) + + assert_allclose(result, expected_result, rtol=1e-2) + + +@pytest.mark.parametrize( + "method,material_param_key", + [ + (calc_stress_eq_amp_swt, None), + (calc_stress_eq_amp_goodman, "ult_stress"), + (calc_stress_eq_amp_gerber, "ult_stress"), + (calc_stress_eq_amp_morrow, "true_fract_stress"), + ], +) +def test_calc_stress_eq_amp_array_inputs( + method: callable, + material_param_key: str | None, + sample_stress_data: tuple[NDArray[np.float64], NDArray[np.float64]], + material_properties: dict[str, float], +) -> None: + """Test all methods with array inputs.""" + stress_amp, mean_stress = sample_stress_data + + if material_param_key is None: + result = method(stress_amp, mean_stress) + expected = np.sqrt(stress_amp * (mean_stress + stress_amp)) + else: + material_param = material_properties[material_param_key] + result = method(stress_amp, mean_stress, material_param) + + if method == calc_stress_eq_amp_gerber: + expected = stress_amp / (1 - (mean_stress / material_param) ** 2) + else: # Goodman or Morrow + expected = stress_amp / (1 - mean_stress / material_param) + + assert_allclose(result, expected) + assert result.shape == (3,) + + +@pytest.mark.parametrize( + "method,material_param", + [ + (calc_stress_eq_amp_swt, None), + (calc_stress_eq_amp_goodman, 500.0), + (calc_stress_eq_amp_gerber, 500.0), + (calc_stress_eq_amp_morrow, 800.0), + ], +) +def test_calc_stress_eq_amp_zero_mean_stress( + method: callable, + material_param: float | None, + zero_mean_stress_case: tuple[float, float], +) -> None: + """Test all methods with zero mean stress (should equal stress amplitude).""" + stress_amp, mean_stress = zero_mean_stress_case + + if material_param is None: + result = method(stress_amp, mean_stress) + else: + result = method(stress_amp, mean_stress, material_param) + + assert_allclose(result, stress_amp) + + +def test_calc_stress_eq_amp_swt_negative_mean_stress( + negative_mean_stress_case: tuple[float, float], +) -> None: + """Test SWT with negative mean stress.""" + stress_amp, mean_stress = negative_mean_stress_case + result = calc_stress_eq_amp_swt(stress_amp, mean_stress) + expected = np.sqrt(stress_amp * (stress_amp + mean_stress)) + assert_allclose(result, expected) + + +def test_calc_stress_eq_amp_swt_validity_condition_violation() -> None: + """Test that SWT validity condition violation raises ValueError.""" + with pytest.raises( + ValueError, match="Smith-Watson-Topper parameter validity condition" + ): + calc_stress_eq_amp_swt(stress_amp=50.0, mean_stress=100.0) + + with pytest.raises( + ValueError, match="Smith-Watson-Topper parameter validity condition" + ): + calc_stress_eq_amp_swt(stress_amp=50.0, mean_stress=-100.0) + + +def test_calc_stress_eq_amp_swt_validity_condition_boundary() -> None: + """Test SWT validity condition at boundary.""" + with pytest.raises( + ValueError, match="Smith-Watson-Topper parameter validity condition" + ): + calc_stress_eq_amp_swt(stress_amp=100.0, mean_stress=100.0)