From fc72954a866dfcdcdc56e71a50ba3aabe0883ebe Mon Sep 17 00:00:00 2001 From: Savio Cardoso Date: Sat, 15 Aug 2026 05:07:57 +0200 Subject: [PATCH] feat: add S-N fatigue damage analysis --- README.md | 29 +++++- configs/fatigue_crossmember.yaml | 18 ++++ scripts/run_fatigue_analysis.py | 59 +++++++++++ src/bodysimpy/analysis/fatigue.py | 146 ++++++++++++++++++++++++++ tests/unit/test_fatigue.py | 166 ++++++++++++++++++++++++++++++ 5 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 configs/fatigue_crossmember.yaml create mode 100644 scripts/run_fatigue_analysis.py create mode 100644 src/bodysimpy/analysis/fatigue.py create mode 100644 tests/unit/test_fatigue.py diff --git a/README.md b/README.md index 8437f08..b6199a1 100644 --- a/README.md +++ b/README.md @@ -109,4 +109,31 @@ Reported quantities include: - correlation analysis - standardized linear sensitivity coefficients -The stochastic model is intended as an engineering uncertainty study for the simplified structural surrogate and does not represent production vehicle reliability. \ No newline at end of file +The stochastic model is intended as an engineering uncertainty study for the simplified structural surrogate and does not represent production vehicle reliability. + + +## Fatigue Assessment + +BodySimPy includes an initial stress-life fatigue assessment module based on a power-law S-N model and linear Palmgren-Miner cumulative damage. + +The module evaluates constant-amplitude loading blocks and reports: + +- predicted cycles to failure for each stress amplitude +- individual Miner damage contributions +- cumulative damage fraction +- estimated repeated-spectrum life +- critical loading block + +The current fatigue model is intended for workflow development and engineering-method demonstration. Example S-N parameters are generic and are not representative of proprietary vehicle material or joint durability data. + +### Current limitations + +The initial implementation does not yet include: + +- rainflow counting +- mean-stress corrections +- weld-class fatigue curves +- notch effects +- multiaxial fatigue +- low-cycle fatigue +- crack-growth modelling \ No newline at end of file diff --git a/configs/fatigue_crossmember.yaml b/configs/fatigue_crossmember.yaml new file mode 100644 index 0000000..9a27f78 --- /dev/null +++ b/configs/fatigue_crossmember.yaml @@ -0,0 +1,18 @@ +fatigue: + sn_model: + reference_stress_amplitude_pa: 2.00e8 + reference_cycles: 1.00e6 + slope_exponent: 5.0 + + loading_blocks: + - name: normal_operation + stress_amplitude_pa: 1.20e8 + cycles: 500000 + + - name: elevated_load + stress_amplitude_pa: 1.80e8 + cycles: 100000 + + - name: severe_load + stress_amplitude_pa: 2.40e8 + cycles: 10000 \ No newline at end of file diff --git a/scripts/run_fatigue_analysis.py b/scripts/run_fatigue_analysis.py new file mode 100644 index 0000000..c104b17 --- /dev/null +++ b/scripts/run_fatigue_analysis.py @@ -0,0 +1,59 @@ +from bodysimpy.analysis.fatigue import ( + FatigueLoadBlock, + SNModel, + calculate_miner_damage, +) + + +def main() -> None: + sn_model = SNModel( + reference_stress_amplitude_pa=200e6, + reference_cycles=1.0e6, + slope_exponent=5.0, + ) + + loading_blocks = ( + FatigueLoadBlock( + name="normal_operation", + stress_amplitude_pa=120e6, + cycles=500_000, + ), + FatigueLoadBlock( + name="elevated_load", + stress_amplitude_pa=180e6, + cycles=100_000, + ), + FatigueLoadBlock( + name="severe_load", + stress_amplitude_pa=240e6, + cycles=10_000, + ), + ) + + result = calculate_miner_damage( + blocks=loading_blocks, + sn_model=sn_model, + ) + + print() + print("BodySimPy Fatigue Assessment") + print("=" * 65) + + for block in result.block_results: + print() + print(block.name) + print(f" Stress amplitude: {block.stress_amplitude_pa / 1e6:.3f} MPa") + print(f" Applied cycles: {block.applied_cycles:,}") + print(f" Cycles to failure: {block.cycles_to_failure:,.0f}") + print(f" Miner damage: {block.damage_fraction:.6f}") + + print() + print("-" * 65) + print(f"Total Miner damage: {result.total_damage_fraction:.6f}") + print(f"Estimated spectrum repeats to D=1: {result.estimated_spectrum_repeats_to_failure:.3f}") + print(f"Estimated cycles to D=1: {result.estimated_cycles_to_failure:,.0f}") + print(f"Critical loading block: {result.critical_block_name}") + + +if __name__ == "__main__": + main() diff --git a/src/bodysimpy/analysis/fatigue.py b/src/bodysimpy/analysis/fatigue.py new file mode 100644 index 0000000..08870ba --- /dev/null +++ b/src/bodysimpy/analysis/fatigue.py @@ -0,0 +1,146 @@ +from dataclasses import dataclass +from math import pow + + +@dataclass(frozen=True, slots=True) +class SNModel: + """Power-law S-N fatigue model. + + The implemented relation is: + + N = N_ref * (sigma_ref / sigma_a) ** m + + where N is the predicted cycles to failure and sigma_a is the + stress amplitude. + """ + + reference_stress_amplitude_pa: float + reference_cycles: float + slope_exponent: float + + def __post_init__(self) -> None: + if self.reference_stress_amplitude_pa <= 0.0: + raise ValueError("Reference stress amplitude must be positive.") + + if self.reference_cycles <= 0.0: + raise ValueError("Reference cycle count must be positive.") + + if self.slope_exponent <= 0.0: + raise ValueError("S-N slope exponent must be positive.") + + +@dataclass(frozen=True, slots=True) +class FatigueLoadBlock: + """Constant-amplitude fatigue loading block.""" + + name: str + stress_amplitude_pa: float + cycles: int + + def __post_init__(self) -> None: + if not self.name.strip(): + raise ValueError("Fatigue loading block name must not be empty.") + + if self.stress_amplitude_pa <= 0.0: + raise ValueError("Stress amplitude must be positive.") + + if self.cycles < 0: + raise ValueError("Cycle count must not be negative.") + + +@dataclass(frozen=True, slots=True) +class FatigueBlockResult: + """Calculated fatigue contribution from one loading block.""" + + name: str + stress_amplitude_pa: float + applied_cycles: int + cycles_to_failure: float + damage_fraction: float + + +@dataclass(frozen=True, slots=True) +class FatigueResult: + """Palmgren-Miner cumulative fatigue assessment.""" + + total_damage_fraction: float + estimated_spectrum_repeats_to_failure: float + estimated_cycles_to_failure: float + critical_block_name: str + block_results: tuple[FatigueBlockResult, ...] + + +def calculate_cycles_to_failure( + *, + stress_amplitude_pa: float, + sn_model: SNModel, +) -> float: + """Return predicted cycles to failure from the S-N relation.""" + + if stress_amplitude_pa <= 0.0: + raise ValueError("Stress amplitude must be positive.") + + stress_ratio = sn_model.reference_stress_amplitude_pa / stress_amplitude_pa + + cycles_to_failure = sn_model.reference_cycles * pow( + stress_ratio, + sn_model.slope_exponent, + ) + + return cycles_to_failure + + +def calculate_miner_damage( + *, + blocks: tuple[FatigueLoadBlock, ...], + sn_model: SNModel, +) -> FatigueResult: + """Calculate cumulative damage using the Palmgren-Miner rule.""" + + if not blocks: + raise ValueError("At least one fatigue loading block is required.") + + block_results: list[FatigueBlockResult] = [] + + for block in blocks: + cycles_to_failure = calculate_cycles_to_failure( + stress_amplitude_pa=block.stress_amplitude_pa, + sn_model=sn_model, + ) + + damage_fraction = block.cycles / cycles_to_failure + + block_results.append( + FatigueBlockResult( + name=block.name, + stress_amplitude_pa=block.stress_amplitude_pa, + applied_cycles=block.cycles, + cycles_to_failure=cycles_to_failure, + damage_fraction=damage_fraction, + ) + ) + + total_damage = sum(block.damage_fraction for block in block_results) + + critical_block = max( + block_results, + key=lambda block: block.damage_fraction, + ) + + total_cycles_per_spectrum = sum(block.applied_cycles for block in block_results) + + if total_damage == 0.0: + estimated_spectrum_repeats = float("inf") + estimated_cycles = float("inf") + else: + estimated_spectrum_repeats = 1.0 / total_damage + + estimated_cycles = total_cycles_per_spectrum * estimated_spectrum_repeats + + return FatigueResult( + total_damage_fraction=total_damage, + estimated_spectrum_repeats_to_failure=(estimated_spectrum_repeats), + estimated_cycles_to_failure=estimated_cycles, + critical_block_name=critical_block.name, + block_results=tuple(block_results), + ) diff --git a/tests/unit/test_fatigue.py b/tests/unit/test_fatigue.py new file mode 100644 index 0000000..e60c525 --- /dev/null +++ b/tests/unit/test_fatigue.py @@ -0,0 +1,166 @@ +import pytest + +from bodysimpy.analysis.fatigue import ( + FatigueLoadBlock, + SNModel, + calculate_cycles_to_failure, + calculate_miner_damage, +) + + +def test_sn_model_returns_reference_cycles_at_reference_stress() -> None: + model = SNModel( + reference_stress_amplitude_pa=200e6, + reference_cycles=1.0e6, + slope_exponent=5.0, + ) + + cycles = calculate_cycles_to_failure( + stress_amplitude_pa=200e6, + sn_model=model, + ) + + assert cycles == pytest.approx(1.0e6) + + +def test_lower_stress_amplitude_increases_predicted_life() -> None: + model = SNModel( + reference_stress_amplitude_pa=200e6, + reference_cycles=1.0e6, + slope_exponent=5.0, + ) + + reference_life = calculate_cycles_to_failure( + stress_amplitude_pa=200e6, + sn_model=model, + ) + + lower_stress_life = calculate_cycles_to_failure( + stress_amplitude_pa=150e6, + sn_model=model, + ) + + assert lower_stress_life > reference_life + + +def test_miner_damage_accumulates_multiple_loading_blocks() -> None: + model = SNModel( + reference_stress_amplitude_pa=200e6, + reference_cycles=1.0e6, + slope_exponent=5.0, + ) + + blocks = ( + FatigueLoadBlock( + name="normal_operation", + stress_amplitude_pa=160e6, + cycles=100_000, + ), + FatigueLoadBlock( + name="high_load", + stress_amplitude_pa=220e6, + cycles=20_000, + ), + ) + + result = calculate_miner_damage( + blocks=blocks, + sn_model=model, + ) + + assert result.total_damage_fraction > 0.0 + + expected_damage = sum(block.damage_fraction for block in result.block_results) + + assert result.total_damage_fraction == pytest.approx(expected_damage) + + +def test_miner_damage_identifies_critical_loading_block() -> None: + model = SNModel( + reference_stress_amplitude_pa=200e6, + reference_cycles=1.0e6, + slope_exponent=5.0, + ) + + blocks = ( + FatigueLoadBlock( + name="low_stress", + stress_amplitude_pa=120e6, + cycles=50_000, + ), + FatigueLoadBlock( + name="high_stress", + stress_amplitude_pa=240e6, + cycles=50_000, + ), + ) + + result = calculate_miner_damage( + blocks=blocks, + sn_model=model, + ) + + assert result.critical_block_name == "high_stress" + + +def test_miner_damage_estimates_repeated_spectrum_life() -> None: + model = SNModel( + reference_stress_amplitude_pa=200e6, + reference_cycles=1.0e6, + slope_exponent=5.0, + ) + + blocks = ( + FatigueLoadBlock( + name="baseline", + stress_amplitude_pa=200e6, + cycles=100_000, + ), + ) + + result = calculate_miner_damage( + blocks=blocks, + sn_model=model, + ) + + assert result.total_damage_fraction == pytest.approx(0.1) + assert result.estimated_spectrum_repeats_to_failure == pytest.approx(10.0) + assert result.estimated_cycles_to_failure == pytest.approx(1.0e6) + + +def test_fatigue_model_rejects_invalid_inputs() -> None: + with pytest.raises(ValueError): + SNModel( + reference_stress_amplitude_pa=-1.0, + reference_cycles=1.0e6, + slope_exponent=5.0, + ) + + with pytest.raises(ValueError): + FatigueLoadBlock( + name="invalid", + stress_amplitude_pa=100e6, + cycles=-1, + ) + + +def test_zero_cycle_block_contributes_zero_damage() -> None: + model = SNModel( + reference_stress_amplitude_pa=200e6, + reference_cycles=1.0e6, + slope_exponent=5.0, + ) + + result = calculate_miner_damage( + blocks=( + FatigueLoadBlock( + name="unused_load_case", + stress_amplitude_pa=250e6, + cycles=0, + ), + ), + sn_model=model, + ) + + assert result.total_damage_fraction == pytest.approx(0.0) + assert result.estimated_spectrum_repeats_to_failure == float("inf")