use crate::filament_db::{MaterialCategory, MaterialProperties};
use crate::math::Fix128;
const SUS304_PRESET_NAME: &str = "SUS304";
const A5052_PRESET_NAME: &str = "A5052";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SnCurve {
pub ultimate_tensile_mpa: Fix128,
pub endurance_stress_mpa: Fix128,
pub endurance_cycles: u64,
pub fatigue_exponent_m: u32,
}
impl SnCurve {
#[must_use]
pub fn from_fdm_material(m: &MaterialProperties) -> Self {
let uts = m.tensile_strength_mpa;
let se = uts * Fix128::from_ratio(3, 10);
Self {
ultimate_tensile_mpa: uts,
endurance_stress_mpa: se,
endurance_cycles: 1_000_000,
fatigue_exponent_m: 5,
}
}
#[must_use]
pub fn for_material(material: &MaterialProperties) -> Self {
if material.category == MaterialCategory::SheetMetal {
if material.name == SUS304_PRESET_NAME {
return Self::steel_sus304();
}
if material.name == A5052_PRESET_NAME {
return Self::aluminum_a5052();
}
}
Self::from_fdm_material(material)
}
#[must_use]
pub fn steel_sus304() -> Self {
Self {
ultimate_tensile_mpa: Fix128::from_int(505),
endurance_stress_mpa: Fix128::from_int(240),
endurance_cycles: 10_000_000,
fatigue_exponent_m: 10,
}
}
#[must_use]
pub fn aluminum_a5052() -> Self {
Self {
ultimate_tensile_mpa: Fix128::from_int(230),
endurance_stress_mpa: Fix128::from_int(92),
endurance_cycles: 5_000_000,
fatigue_exponent_m: 6,
}
}
}
pub(crate) const INFINITE_LIFE: u64 = u64::MAX;
#[must_use]
pub(crate) fn cycles_to_failure(curve: &SnCurve, stress_mpa: Fix128) -> u64 {
if stress_mpa <= curve.endurance_stress_mpa {
return INFINITE_LIFE;
}
if stress_mpa <= Fix128::ZERO {
return INFINITE_LIFE;
}
let ratio = curve.endurance_stress_mpa / stress_mpa;
let mut r_m = Fix128::ONE;
for _ in 0..curve.fatigue_exponent_m {
r_m = r_m * ratio;
}
if r_m.hi < 0 {
return 1;
}
let ne = u128::from(curve.endurance_cycles);
let n = ne * (r_m.hi as u128) + ((ne * u128::from(r_m.lo)) >> 64);
u64::try_from(n)
.unwrap_or(INFINITE_LIFE - 1)
.clamp(1, INFINITE_LIFE - 1)
}
pub(crate) const BASQUIN_LOW_CYCLE_BOUND: u64 = 1_000;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum FatigueRangeError {
ZeroCycles,
BelowLowCycleBound {
cycles: u64,
bound: u64,
},
NonPositiveExponent,
}
pub(crate) fn stress_at_cycles(curve: &SnCurve, cycles: u64) -> Result<Fix128, FatigueRangeError> {
if cycles == 0 {
return Err(FatigueRangeError::ZeroCycles);
}
if curve.fatigue_exponent_m == 0 {
return Err(FatigueRangeError::NonPositiveExponent);
}
if cycles < BASQUIN_LOW_CYCLE_BOUND {
return Err(FatigueRangeError::BelowLowCycleBound {
cycles,
bound: BASQUIN_LOW_CYCLE_BOUND,
});
}
if cycles >= curve.endurance_cycles {
return Ok(curve.endurance_stress_mpa);
}
let target = u64_ratio(curve.endurance_cycles, cycles);
let m = Fix128::from_int(i64::from(curve.fatigue_exponent_m));
let mut x = target.powf_pos(Fix128::ONE / m);
for _ in 0..2 {
let mut xm1 = Fix128::ONE;
for _ in 1..curve.fatigue_exponent_m {
xm1 = xm1 * x;
}
let xm = xm1 * x;
let df = m * xm1;
if df.is_zero() {
break;
}
x = x - (xm - target) / df;
}
Ok(curve.endurance_stress_mpa * x)
}
pub type SpectrumEntry = (Fix128, u64);
#[must_use]
pub fn miner_damage(spectrum: &[SpectrumEntry], curve: &SnCurve) -> Fix128 {
let mut d = Fix128::ZERO;
for &(stress, n) in spectrum {
let n_fail = cycles_to_failure(curve, stress);
if n_fail == INFINITE_LIFE {
continue;
}
let ratio = u64_ratio(n, n_fail);
d = if ratio >= DAMAGE_SATURATED - d {
DAMAGE_SATURATED
} else {
d + ratio
};
}
d
}
pub(crate) const DAMAGE_SATURATED: Fix128 = Fix128::from_raw(i64::MAX, u64::MAX);
fn u64_ratio(n: u64, d: u64) -> Fix128 {
let q = n / d;
let r = n % d;
if q > i64::MAX as u64 {
return DAMAGE_SATURATED;
}
let frac = if d > i64::MAX as u64 {
Fix128::from_int((r >> 1) as i64) / Fix128::from_int((d >> 1) as i64)
} else {
Fix128::from_int(r as i64) / Fix128::from_int(d as i64)
};
Fix128::from_int(q as i64) + frac
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct FatigueReport {
pub damage: Fix128,
pub is_safe: bool,
pub safety_factor: Fix128,
}
#[must_use]
pub(crate) fn analyze_spectrum(spectrum: &[SpectrumEntry], curve: &SnCurve) -> FatigueReport {
let damage = miner_damage(spectrum, curve);
let sf = if damage.is_zero() {
Fix128::from_int(i64::MAX >> 8)
} else {
Fix128::ONE / damage
};
FatigueReport {
damage,
is_safe: damage < Fix128::ONE,
safety_factor: sf,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_material_uses_polymer_defaults() {
let curve = SnCurve::from_fdm_material(&MaterialProperties::pla());
assert_eq!(curve.fatigue_exponent_m, 5);
assert_eq!(curve.endurance_cycles, 1_000_000);
let diff = if curve.endurance_stress_mpa > Fix128::from_int(18) {
curve.endurance_stress_mpa - Fix128::from_int(18)
} else {
Fix128::from_int(18) - curve.endurance_stress_mpa
};
assert!(diff < Fix128::from_ratio(1, 1000));
}
#[test]
fn stress_below_endurance_is_infinite_life() {
let curve = SnCurve::from_fdm_material(&MaterialProperties::pla());
let n = cycles_to_failure(&curve, Fix128::from_int(10));
assert_eq!(n, INFINITE_LIFE);
}
#[test]
fn stress_at_endurance_is_infinite_life() {
let curve = SnCurve::from_fdm_material(&MaterialProperties::pla());
let n = cycles_to_failure(&curve, curve.endurance_stress_mpa);
assert_eq!(n, INFINITE_LIFE);
}
#[test]
fn higher_stress_shorter_life() {
let curve = SnCurve::steel_sus304();
let n_low = cycles_to_failure(&curve, Fix128::from_int(300));
let n_high = cycles_to_failure(&curve, Fix128::from_int(400));
assert!(n_high < n_low);
}
#[test]
fn stress_at_endurance_cycles_returns_endurance() {
let curve = SnCurve::steel_sus304();
let s = stress_at_cycles(&curve, curve.endurance_cycles).unwrap();
assert_eq!(s, curve.endurance_stress_mpa);
}
#[test]
fn stress_at_zero_cycles_is_refused() {
let curve = SnCurve::steel_sus304();
assert_eq!(
stress_at_cycles(&curve, 0),
Err(FatigueRangeError::ZeroCycles)
);
}
#[test]
fn stress_at_cycles_monotonic_decreasing_and_round_trips() {
let curve = SnCurve::aluminum_a5052();
let mut previous = Fix128::from_int(1_000_000);
for n in [
1_000u64, 2_000, 5_000, 20_000, 100_000, 1_000_000, 4_000_000,
] {
let s = stress_at_cycles(&curve, n).unwrap();
assert!(s < previous, "N = {n}: {s:?} not below {previous:?}");
previous = s;
let back = cycles_to_failure(&curve, s);
assert!(
back.abs_diff(n) * 1000 <= n,
"N = {n}: round trip gave {back}"
);
}
}
#[test]
fn miner_damage_empty_spectrum_is_zero() {
let curve = SnCurve::from_fdm_material(&MaterialProperties::pla());
let d = miner_damage(&[], &curve);
assert_eq!(d, Fix128::ZERO);
}
#[test]
fn miner_damage_below_endurance_is_zero() {
let curve = SnCurve::steel_sus304();
let d = miner_damage(&[(Fix128::from_int(100), 1_000_000)], &curve);
assert_eq!(d, Fix128::ZERO);
}
#[test]
fn miner_damage_at_full_life_is_one() {
let curve = SnCurve::steel_sus304();
let stress = Fix128::from_int(400);
let n_fail = cycles_to_failure(&curve, stress);
let d = miner_damage(&[(stress, n_fail)], &curve);
let diff = if d > Fix128::ONE {
d - Fix128::ONE
} else {
Fix128::ONE - d
};
assert!(diff < Fix128::from_ratio(1, 100), "D was {}", d.to_f32());
}
#[test]
fn miner_damage_additive_over_multiple_stresses() {
let curve = SnCurve::steel_sus304();
let entry_a = (Fix128::from_int(300), 100u64);
let entry_b = (Fix128::from_int(350), 50u64);
let d_ab = miner_damage(&[entry_a, entry_b], &curve);
let d_a = miner_damage(&[entry_a], &curve);
let d_b = miner_damage(&[entry_b], &curve);
assert_eq!(d_ab, d_a + d_b);
}
#[test]
fn analyze_spectrum_safe_report() {
let curve = SnCurve::steel_sus304();
let report = analyze_spectrum(&[(Fix128::from_int(300), 100)], &curve);
assert!(report.is_safe);
assert!(report.damage > Fix128::ZERO);
assert!(report.safety_factor > Fix128::from_int(1000));
}
#[test]
fn analyze_spectrum_unsafe_when_overloaded() {
let curve = SnCurve::steel_sus304();
let stress = Fix128::from_int(480);
let n_fail = cycles_to_failure(&curve, stress);
let report = analyze_spectrum(&[(stress, n_fail * 3)], &curve);
assert!(!report.is_safe);
assert!(report.damage > Fix128::ONE);
}
#[test]
fn sus304_preset_basquin_life_at_300_mpa_is_1073741_cycles() {
let curve = SnCurve::steel_sus304();
let s = Fix128::from_int(300);
let full = analyze_spectrum(&[(s, 1_073_741)], &curve);
assert_eq!(full.damage, Fix128::ONE, "D = {}", full.damage.to_f64());
assert!(!full.is_safe);
assert_eq!(full.safety_factor, Fix128::ONE);
let almost = analyze_spectrum(&[(s, 1_073_740)], &curve);
assert!(almost.is_safe);
assert!(almost.damage < Fix128::ONE);
}
#[test]
fn a5052_preset_miner_sum_matches_hand_calculation() {
let n120: u128 = 5_000_000 * 148_035_889 / 729_000_000;
let n150: u128 = 5_000_000 * 9_474_296_896 / 177_978_515_625;
assert_eq!((n120, n150), (1_015_335, 266_164), "hand calculation");
let curve = SnCurve::aluminum_a5052();
let report = analyze_spectrum(
&[
(Fix128::from_int(120), 203_067),
(Fix128::from_int(150), 66_541),
],
&curve,
);
let d = report.damage.to_f64();
assert!((d - 0.45).abs() < 1e-12, "D = {d}");
assert!(report.is_safe);
let sf = report.safety_factor.to_f64();
assert!((sf - 1.0 / 0.45).abs() < 1e-12, "SF = {sf}");
}
#[test]
fn analyze_spectrum_empty_and_below_endurance_report_zero_damage_sentinel() {
let curve = SnCurve::steel_sus304();
let sentinel = Fix128::from_int(i64::MAX >> 8);
for spectrum in [&[][..], &[(Fix128::from_int(240), u64::MAX)][..]] {
let report = analyze_spectrum(spectrum, &curve);
assert_eq!(report.damage, Fix128::ZERO);
assert!(report.is_safe);
assert_eq!(report.safety_factor, sentinel);
}
}
#[test]
fn stress_at_cycles_small_cycle_counts_match_basquin() {
let steel = SnCurve::steel_sus304();
for n in [1_000u64, 1_500, 2_000, 5_000] {
let got = stress_at_cycles(&steel, n).unwrap().to_f64();
let want = 240.0 * crate::det_math::powf64(1e7 / n as f64, 0.1);
assert!(
(got - want).abs() < 1e-6,
"SUS304 N = {n}: got {got}, want {want}"
);
}
assert_eq!(
stress_at_cycles(&steel, 1),
Err(FatigueRangeError::BelowLowCycleBound {
cycles: 1,
bound: 1_000
})
);
let alu = SnCurve::aluminum_a5052();
assert_eq!(
stress_at_cycles(&alu, 2),
Err(FatigueRangeError::BelowLowCycleBound {
cycles: 2,
bound: 1_000
})
);
let got = stress_at_cycles(&alu, 1_000).unwrap().to_f64();
let want = 92.0 * crate::det_math::powf64(5e3, 1.0 / 6.0);
assert!(
(got - want).abs() < 1e-6,
"A5052 N = 1000: got {got}, want {want}"
);
}
#[test]
fn stress_at_cycles_inverts_basquin_for_sus304_and_a5052() {
let steel = stress_at_cycles(&SnCurve::steel_sus304(), 100_000)
.unwrap()
.to_f64();
let want_steel = 240.0 * crate::det_math::powf64(100.0, 0.1);
assert!(
(steel - want_steel).abs() < 1e-6,
"got {steel}, want {want_steel}"
);
let alu = stress_at_cycles(&SnCurve::aluminum_a5052(), 500_000)
.unwrap()
.to_f64();
let want_alu = 92.0 * crate::det_math::powf64(10.0, 1.0 / 6.0);
assert!((alu - want_alu).abs() < 1e-6, "got {alu}, want {want_alu}");
}
#[test]
fn stress_at_cycles_degenerate_cycle_counts() {
let curve = SnCurve::aluminum_a5052();
assert_eq!(
stress_at_cycles(&curve, 0),
Err(FatigueRangeError::ZeroCycles)
);
assert_eq!(
stress_at_cycles(&curve, curve.endurance_cycles),
Ok(curve.endurance_stress_mpa)
);
assert_eq!(
stress_at_cycles(&curve, u64::MAX),
Ok(curve.endurance_stress_mpa)
);
let mut flat = SnCurve::aluminum_a5052();
flat.fatigue_exponent_m = 0;
assert_eq!(
stress_at_cycles(&flat, 10_000),
Err(FatigueRangeError::NonPositiveExponent)
);
}
#[test]
fn steel_has_higher_endurance_than_polymer() {
let curve_steel = SnCurve::steel_sus304();
let curve_pla = SnCurve::from_fdm_material(&MaterialProperties::pla());
assert!(curve_steel.endurance_stress_mpa > curve_pla.endurance_stress_mpa);
}
}