From b7825abfd76efb1473495d874dda24466ab47942 Mon Sep 17 00:00:00 2001 From: Ivan Tomac Date: Sun, 8 Mar 2026 23:43:08 +0100 Subject: [PATCH 1/3] Implementation Damage Accumulation Rule according to Palmgren-Miner * src/fatpy/core/damage_cumulationdamage_cumulation_palmgren_meiner.py * tests/core/test_src/fatpy/core/damage_cumulation/test_damage_cumulation.py --- .../damage_cumulation_palmgren_meiner.py | 88 +++++++++++++++ .../test_damage_cumulation.py | 105 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/fatpy/core/damage_cumulation/damage_cumulation_palmgren_meiner.py create mode 100644 tests/core/test_src/fatpy/core/damage_cumulation/test_damage_cumulation.py diff --git a/src/fatpy/core/damage_cumulation/damage_cumulation_palmgren_meiner.py b/src/fatpy/core/damage_cumulation/damage_cumulation_palmgren_meiner.py new file mode 100644 index 0000000..c2330d1 --- /dev/null +++ b/src/fatpy/core/damage_cumulation/damage_cumulation_palmgren_meiner.py @@ -0,0 +1,88 @@ +"""Damage Accumulation Rule according to Palmgren-Miner. + +Resources: + [1] Graphical interpretation of the change in the S-N curve based on the + chosen version can be found e.g. in this open-access paper: + http://dx.doi.org/10.5545/sv-jme.2013.1348 + [2] Miner, M. A. (1945). Cumulative damage in fatigue. Journal of Applied + Mechanics, 12(3), 159-164. +""" + +# import numpy as np +# from numpy.typing import NDArray + + +def damage_cumulation_elementary( + slope_k: float, + constant: float, + sig: float, + number_occurrences: int, +) -> float: + r"""Elementary version of Palmgren-Miner linear damage accumulation. + + The same slope k of the S-N curve below and above the fatigue limit. + + ??? abstract "Math Equations" + $$ + D = n/N = n\,\frac{\sigma^k}{C} + $$ + + """ + total_occurrences: float = constant / sig**slope_k + + damage: float = number_occurrences / total_occurrences + + return damage + + +def damage_cumulation_basic( + slope_k: float, + constant: float, + sig_fl: float, + sig: float, + number_occurrences: int, +) -> float: + """Basic version of Palmgren-Miner linear damage accumulation. + + The S-N curve gets horizontal at the fatigue limit, no damage for stresses beneath. + Otherwise elementary damage is calculated. + """ + if sig < sig_fl: + damage = 0.0 + else: + damage = damage_cumulation_elementary( + slope_k, constant, sig, number_occurrences + ) + + return damage + + +def damage_cumulation_haibach( + slope_k: float, + constant: float, + sig_fl: float, + sig: float, + number_occurrences: int, +) -> float: + r"""Haibach version of Palmgren-Miner linear damage accumulation. + + the original slope_k is modified below fatigue limit to 2*slope_k-1. + + ??? abstract "Math Equations" + $$ + D = \frac{n}{C}\,\frac{\sigma^{2k-1}}{\sigma_\mathrm{FL}^{k-1}} + $$ + + """ + if sig < sig_fl: + damage: float = ( + number_occurrences + * sig ** (2 * slope_k - 1) + / (constant * sig_fl ** (slope_k - 1)) + ) + else: + damage = damage_cumulation_elementary( + slope_k, constant, sig, number_occurrences + ) + + return damage diff --git a/tests/core/test_src/fatpy/core/damage_cumulation/test_damage_cumulation.py b/tests/core/test_src/fatpy/core/damage_cumulation/test_damage_cumulation.py new file mode 100644 index 0000000..b40e421 --- /dev/null +++ b/tests/core/test_src/fatpy/core/damage_cumulation/test_damage_cumulation.py @@ -0,0 +1,105 @@ +"""Test functions for damage accumulation rules.""" + +import pytest +import numpy as np +# from numpy.typing import NDArray + +from fatpy.core.damage_cumulation import damage_cumulation_palmgren_meiner as dcpm + + +@pytest.fixture +def damage_cumulation_parameters() -> dict[str, float]: + """Fixture providing parameters for damage cumulation tests. + + Returns: + dict[str, float]: Parameters including slope_k, constant, sig_fl. + """ + params = { + "slope_k": 5.0, + "constant": 1e17, # 1e15 is on the internet + "sig_fl": 137.97, + } + return params + + +@pytest.fixture +def fatigue_load_low() -> tuple[float, int]: + """Fixture providing a sample fatigue load. + + Returns: + tuple[float, int]: Sample stress and number of occurrences. + """ + return 150.0, 5000 + + +@pytest.fixture +def fatigue_load_hi() -> tuple[float, int]: + """Fixture providing a sample fatigue load. + + Returns: + tuple[float, int]: Sample stress and number of occurrences. + """ + return 110.0, 100000 + + +def test_damage_cumulation_elementary( + damage_cumulation_parameters: dict[str, float], + fatigue_load_low: tuple[float, int], + fatigue_load_hi: tuple[float, int], +) -> None: + """Elementary version + the same slope k of the S-N curve below and above the fatigue limit + """ + slope_k = damage_cumulation_parameters["slope_k"] + constant = damage_cumulation_parameters["constant"] + sig_low, n_low = fatigue_load_low + sig_hi, n_hi = fatigue_load_hi + + d_low = dcpm.damage_cumulation_elementary(slope_k, constant, sig_low, n_low) + d_hi = dcpm.damage_cumulation_elementary(slope_k, constant, sig_hi, n_hi) + + assert np.around(d_low, decimals=4) == 0.0038 + assert np.around(d_hi, decimals=4) == 0.0161 + + +def test_damage_cumulation_basic( + damage_cumulation_parameters: dict[str, float], + fatigue_load_low: tuple[float, int], + fatigue_load_hi: tuple[float, int], +) -> None: + """Basic version + the S-N curve gets horizontal at the fatigue limit, + no damage for stresses beneath + """ + slope_k = damage_cumulation_parameters["slope_k"] + constant = damage_cumulation_parameters["constant"] + sig_fl = damage_cumulation_parameters["sig_fl"] + sig_low, n_low = fatigue_load_low + sig_hi, n_hi = fatigue_load_hi + + d_low = dcpm.damage_cumulation_basic(slope_k, constant, sig_fl, sig_low, n_low) + d_hi = dcpm.damage_cumulation_basic(slope_k, constant, sig_fl, sig_hi, n_hi) + + assert np.around(d_low, decimals=4) == 0.0038 + assert d_hi == 0.0 + + +def test_damage_cumulation_haibach( + damage_cumulation_parameters: dict[str, float], + fatigue_load_low: tuple[float, int], + fatigue_load_hi: tuple[float, int], +) -> None: + """Haibach version + the original slope_k is modified below fatigue limit to 2*slope_k-1 + """ + slope_k = damage_cumulation_parameters["slope_k"] + constant = damage_cumulation_parameters["constant"] + sig_fl = damage_cumulation_parameters["sig_fl"] + sig_low, n_low = fatigue_load_low + sig_hi, n_hi = fatigue_load_hi + + d_low = dcpm.damage_cumulation_haibach(slope_k, constant, sig_fl, sig_low, n_low) + d_hi = dcpm.damage_cumulation_haibach(slope_k, constant, sig_fl, sig_hi, n_hi) + + assert np.around(d_low, decimals=4) == 0.0038 + assert np.around(d_hi, decimals=5) == 0.00651 From 5ee1b906620482461c8cf1286ac4fea201201ce1 Mon Sep 17 00:00:00 2001 From: Ivan Tomac Date: Sun, 15 Mar 2026 11:46:55 +0100 Subject: [PATCH 2/3] Minor Changed location of the tests --- src/fatpy/core/energy_life/demage_parameters.py | 1 - .../fatpy/core => }/damage_cumulation/test_damage_cumulation.py | 0 2 files changed, 1 deletion(-) delete mode 100644 src/fatpy/core/energy_life/demage_parameters.py rename tests/{core/test_src/fatpy/core => }/damage_cumulation/test_damage_cumulation.py (100%) diff --git a/src/fatpy/core/energy_life/demage_parameters.py b/src/fatpy/core/energy_life/demage_parameters.py deleted file mode 100644 index 03f895d..0000000 --- a/src/fatpy/core/energy_life/demage_parameters.py +++ /dev/null @@ -1 +0,0 @@ -"""Damage parameters calculation methods for the energy-life.""" diff --git a/tests/core/test_src/fatpy/core/damage_cumulation/test_damage_cumulation.py b/tests/damage_cumulation/test_damage_cumulation.py similarity index 100% rename from tests/core/test_src/fatpy/core/damage_cumulation/test_damage_cumulation.py rename to tests/damage_cumulation/test_damage_cumulation.py From 7933b89a18f0119b3ed36608e04d8f2710f0724f Mon Sep 17 00:00:00 2001 From: Ivan Tomac Date: Sun, 15 Mar 2026 16:08:34 +0100 Subject: [PATCH 3/3] Implementation LCF uniaxial SWT criterion * src/fatpy/core/energy_life/damage_parameters.py * tests/energy_life/test_damage_parameters.py file `demage_parameters.py` renamed to `damage_parameters.py`. --- .../core/energy_life/damage_parameters.py | 53 +++++++++++++++ tests/energy_life/test_damage_parameters.py | 64 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/fatpy/core/energy_life/damage_parameters.py create mode 100644 tests/energy_life/test_damage_parameters.py diff --git a/src/fatpy/core/energy_life/damage_parameters.py b/src/fatpy/core/energy_life/damage_parameters.py new file mode 100644 index 0000000..acb3851 --- /dev/null +++ b/src/fatpy/core/energy_life/damage_parameters.py @@ -0,0 +1,53 @@ +"""Damage parameters calculation methods for the energy-life.""" + +import numpy as np +from scipy.optimize import root_scalar +from scipy.optimize import RootResults + + +def _fun_swt( + n: float, + sig_f: float, + b: float, + eps_f: float, + c: float, + young_modulus: float, + p_swt: float, +) -> float: + """Function for root finding in SWT calculation.""" + sol: float = ( + p_swt**2 + - sig_f**2 * (2 * n) ** (2 * b) + - young_modulus * eps_f * sig_f * (2 * n) ** (b + c) + ) + return sol + + +def swt( + sig_f: float, + b: float, + eps_f: float, + c: float, + young_modulus: float, + eps_a: float, + sig_m: float, + sig_a: float, + n_0: float = 1.0, +) -> int: + """Calculate the number of cycles to failure according to SWT criterion.""" + if sig_a <= np.abs(sig_m): + raise ValueError("SWT is only valid for sig_a > |sig_m|.") + + p_swt: float = np.sqrt(young_modulus * eps_a * (sig_m + sig_a)) + + solution: RootResults = root_scalar( + _fun_swt, + args=(sig_f, b, eps_f, c, young_modulus, p_swt), + x0=n_0, + method="newton", + ) + + if not solution.converged: + raise ValueError("SWT calculation did not converge.") + + return int(solution.root) diff --git a/tests/energy_life/test_damage_parameters.py b/tests/energy_life/test_damage_parameters.py new file mode 100644 index 0000000..a5b8c32 --- /dev/null +++ b/tests/energy_life/test_damage_parameters.py @@ -0,0 +1,64 @@ +"""Test functions for damage parameters.""" + +import pytest +import numpy as np +# from numpy.typing import NDArray + +from fatpy.core.energy_life import damage_parameters as dp + + +@pytest.fixture +def en_curve_parameters() -> dict[str, float]: + """Parameters of the e-N curve in the form of Manson-Coffin and Basquin equation. + + Returns: + dict[str, float]: Parameters including: + fat_strength_coef: Manson-Coffin and Basquin equation fatigue + strength coefficient + fat_ductility_coef: Manson-Coffin and Basquin equation fatigue + ductility coefficient + fat_strength_exp: Manson-Coffin and Basquin equation fatigue + strength exponent + fat_ductility_exp: Manson-Coffin and Basquin equation fatigue + ductility exponent + elastic_modulus: Young's / Elastic modulus + """ + params = { + "fat_strength_coef": 475.4, + "fat_ductility_coef": 0.612, + "fat_strength_exp": -0.078, + "fat_ductility_exp": -0.62, + "elastic_modulus": 162000.0, + } + return params + + +@pytest.fixture +def stress_strain_values() -> dict[str, float]: + """Stress / Strain values. + + Returns: + dict[str, float]: Parameters including: + strain_amp: Strain amplitude + stress_amp: Stress amplitude + mean_stress: Mean stress + """ + params = {"strain_amp": 0.0135, "stress_amp": 290.0, "mean_stress": 10.0} + return params + + +def test_swt( + en_curve_parameters: dict[str, float], stress_strain_values: dict[str, float] +) -> None: + """Tests Smith-Watson-Topper (SWT) damage parameter for mean stress correction + in strain-life. + + """ + sig_f, eps_f, b, c, young_modulus = en_curve_parameters.values() + eps_a, sig_a, sig_m = stress_strain_values.values() + + n = dp.swt(sig_f, b, eps_f, c, young_modulus, eps_a, sig_m, sig_a) + p_swt = np.sqrt(young_modulus * eps_a * (sig_m + sig_a)) + + assert n == 278 + assert p_swt == 810.0