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..097751a --- /dev/null +++ b/src/fatpy/core/stress_life/damage_params/uniaxial_stress_eq_amp.py @@ -0,0 +1,236 @@ +"""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. +""" + +import numpy as np +from numpy.typing import ArrayLike, NDArray + + +def _validate_stress_inputs( + stress_amp: ArrayLike | np.float64, + mean_stress: ArrayLike | np.float64, + material_param: ArrayLike | np.float64 | None = None, + param_name: str = "material parameter", +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Validate stress inputs and material parameters for fatigue calculations. + + Args: + stress_amp: Stress amplitudes (must be non-negative) + mean_stress: Mean stresses (can be positive or negative) + material_param: Material strength parameter (must be positive) + param_name: Name of material parameter for error messages + + Returns: + Tuple of validated arrays (stress_amp, mean_stress) + + Raises: + ValueError: If validation fails + UserWarning: For questionable but not invalid conditions + """ + stress_amp_arr = np.asarray(stress_amp, dtype=np.float64) + mean_stress_arr = np.asarray(mean_stress, dtype=np.float64) + material_param_arr = ( + None if material_param is None else np.asarray(material_param, dtype=np.float64) + ) + + # Check for negative stress amplitudes + if np.any(stress_amp_arr < 0): + raise ValueError("Stress amplitude must be non-negative") + + # Validate material parameter if provided + if material_param_arr is not None: + if np.any(material_param_arr <= 0): + raise ValueError(f"{param_name} must be positive") + + # Check if mean stress approaches or exceeds material parameter + abs_mean = np.abs(mean_stress_arr) + ratio = abs_mean / material_param_arr + + if np.any(ratio >= 1.0): + raise ValueError( + f"Mean stress magnitude ({np.max(abs_mean):.1f}) exceeds or equals " + f"{param_name} ({np.min(material_param_arr):.1f}). This would result in" + " infinite or negative equivalent stress amplitude." + ) + + return stress_amp_arr, mean_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. + + 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: + + $$ + \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. + + 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 σₐ > |σₘ| 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 warning is issued as the SWT approach may not be appropriate + for compressive-dominated loading conditions. + + """ + stress_amp_arr, mean_stress_arr = _validate_stress_inputs(stress_amp, mean_stress) + + # 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." + ) + + return np.sqrt(stress_amp_arr * (mean_stress_arr + stress_amp_arr)) + + +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. + + 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: + ValueError: If input arrays cannot be broadcast together. + """ + stress_amp_arr, mean_stress_arr = _validate_stress_inputs( + stress_amp, mean_stress, ult_stress, "Ultimate tensile strength" + ) + + ult_stress_arr = np.asarray(ult_stress, dtype=np.float64) + + return stress_amp_arr / (1 - mean_stress_arr / ult_stress_arr) + + +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. + + 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: + ValueError: If input arrays cannot be broadcast together. + + """ + stress_amp_arr, mean_stress_arr = _validate_stress_inputs( + stress_amp, mean_stress, ult_stress, "Ultimate tensile strength" + ) + ult_stress_arr = np.asarray(ult_stress, dtype=np.float64) + + return stress_amp_arr / (1 - (mean_stress_arr / ult_stress_arr) ** 2) + + +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. + + 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: + ValueError: If input arrays cannot be broadcast together + """ + stress_amp_arr, mean_stress_arr = _validate_stress_inputs( + stress_amp, mean_stress, true_fract_stress, "True tensile fracture stress" + ) + true_fract_stress_arr = np.asarray(true_fract_stress, dtype=np.float64) + + return stress_amp_arr / (1 - mean_stress_arr / true_fract_stress_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)