Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
18 changes: 18 additions & 0 deletions configs/fatigue_crossmember.yaml
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions scripts/run_fatigue_analysis.py
Original file line number Diff line number Diff line change
@@ -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()
146 changes: 146 additions & 0 deletions src/bodysimpy/analysis/fatigue.py
Original file line number Diff line number Diff line change
@@ -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),
)
Loading