alice_physics/fatigue.rs
1//! Fatigue Life Prediction (S-N Curve + Miner's Rule)
2//!
3//! Predicts the number of
4//! stress cycles a part will survive under repeated loading, using the
5//! classical **Woehler S-N curve** in Basquin power-law form together with
6//! **Miner's linear damage accumulation rule** for variable-amplitude spectra.
7//!
8//! # Model
9//!
10//! Basquin's power law relates cycle count `N` to the alternating stress `S`:
11//!
12//! `N · Sᵐ = C`
13//!
14//! Equivalently, given a reference point `(N_ref, S_ref)`:
15//!
16//! `N(S) = N_ref · (S_ref / S)ᵐ`
17//!
18//! `m` (dimensionless integer exponent) is the fatigue slope:
19//! - Polymers (PLA / ABS / PETG): m ≈ 5
20//! - Aluminum alloys: m ≈ 6
21//! - Steels: m ≈ 10
22//!
23//! Below the **endurance stress** `S_e` a metal survives an unlimited number
24//! of cycles (typical steel behaviour). Polymers do **not** have a true
25//! endurance limit — for these `S_e` is defined as the stress that survives
26//! `10⁶` cycles, with continued log-linear degradation past that point. This
27// LIMITATION(COV-STRUCT-047): currently applies the "true endurance" cutoff to all materials for simplicity
28//! module currently applies the "true endurance" cutoff to all materials for
29//! simplicity; refine with `creep_longterm` if creep-fatigue interaction matters.
30//!
31//! Miner's rule:
32//!
33//! `D = Σ nᵢ / N(Sᵢ)`, failure at `D ≥ 1`.
34//!
35//! # References
36//!
37//! - Basquin, "The exponential law of endurance tests", Proc. ASTM 10, 1910.
38//! - Miner, "Cumulative damage in fatigue", J. Applied Mech. 12, 1945.
39//! - Suresh, *Fatigue of Materials* 2nd ed. Ch. 5.
40//! - ASME BPVC Section VIII, Division 2 (fatigue design curves).
41//!
42//! # Integration status
43//!
44//! `SnCurve`, `SpectrumEntry`, and `miner_damage` are wired into
45//! `structural_solver.rs`. `StructuralSolver::new` selects the curve with
46//! [`SnCurve::for_material`]: the metal presets ([`SnCurve::steel_sus304`] /
47//! [`SnCurve::aluminum_a5052`]) for the crate's `MaterialProperties::sus304()`
48//! / `a5052()` presets (sheet-metal category and the preset name), and
49//! [`SnCurve::from_fdm_material`] for every other material.
50//! `StructuralSolver::with_sn_curve` overrides the selection. The Basquin
51//! inverse `stress_at_cycles` is reached through
52//! `StructuralSolver::fatigue_strength_mpa` and the `analyze_spectrum`
53//! wrapper through `StructuralSolver::fatigue_spectrum_report`, both
54//! evaluated on the solver's selected curve. `INFINITE_LIFE` and
55//! `cycles_to_failure` are crate-internal and reached through
56//! `miner_damage`.
57
58use crate::filament_db::{MaterialCategory, MaterialProperties};
59use crate::math::Fix128;
60
61/// Name carried by `MaterialProperties::sus304()`.
62const SUS304_PRESET_NAME: &str = "SUS304";
63/// Name carried by `MaterialProperties::a5052()`.
64const A5052_PRESET_NAME: &str = "A5052";
65
66// ============================================================================
67// SnCurve
68// ============================================================================
69
70/// Woehler S-N curve for a material.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72pub struct SnCurve {
73 /// Ultimate tensile strength (MPa). Cycle count = 1 approximate.
74 pub ultimate_tensile_mpa: Fix128,
75 /// Endurance / fatigue-limit stress (MPa). Below this, life is infinite.
76 pub endurance_stress_mpa: Fix128,
77 /// Cycle count at which the endurance stress is defined (typically 1e6
78 /// for polymers, 1e7 for steels, but treated as the practical "infinite
79 /// life" reference).
80 pub endurance_cycles: u64,
81 // LIMITATION(COV-STRUCT-046): Basquin exponent m (integer)
82 /// Basquin exponent m (integer). Larger m ⇒ steeper curve ⇒ more sensitive
83 /// to small stress increases. Polymers 5, aluminum 6, steel 10.
84 pub fatigue_exponent_m: u32,
85}
86
87impl SnCurve {
88 /// Construct a reasonable S-N curve from an isotropic FDM material.
89 ///
90 /// Defaults: `S_e = 0.3 · UTS` (polymer rule of thumb), `N_e = 1e6`,
91 /// `m = 5`.
92 #[must_use]
93 pub fn from_fdm_material(m: &MaterialProperties) -> Self {
94 let uts = m.tensile_strength_mpa;
95 let se = uts * Fix128::from_ratio(3, 10);
96 Self {
97 ultimate_tensile_mpa: uts,
98 endurance_stress_mpa: se,
99 endurance_cycles: 1_000_000,
100 fatigue_exponent_m: 5,
101 }
102 }
103
104 /// Curve for `material`: [`Self::steel_sus304`] for the crate's SUS304
105 /// preset and [`Self::aluminum_a5052`] for its A5052 preset (both
106 /// identified by the sheet-metal category and the preset name, as
107 /// `MaterialProperties::sus304()` / `a5052()` carry them), and
108 /// [`Self::from_fdm_material`] for every other material, sheet metals
109 /// with another name included.
110 #[must_use]
111 pub fn for_material(material: &MaterialProperties) -> Self {
112 if material.category == MaterialCategory::SheetMetal {
113 if material.name == SUS304_PRESET_NAME {
114 return Self::steel_sus304();
115 }
116 if material.name == A5052_PRESET_NAME {
117 return Self::aluminum_a5052();
118 }
119 }
120 Self::from_fdm_material(material)
121 }
122
123 /// Austenitic stainless steel preset (SUS304 grade): `UTS = 505 MPa`,
124 /// `S_e = 240 MPa` (`0.475 · UTS`), `N_e = 10⁷`, `m = 10`.
125 #[must_use]
126 pub fn steel_sus304() -> Self {
127 Self {
128 ultimate_tensile_mpa: Fix128::from_int(505),
129 endurance_stress_mpa: Fix128::from_int(240),
130 endurance_cycles: 10_000_000,
131 fatigue_exponent_m: 10,
132 }
133 }
134
135 /// Aluminum A5052 preset: `UTS = 230 MPa`, `S_e = 92 MPa` (`0.4 · UTS`),
136 /// `N_e = 5·10⁶`, `m = 6`.
137 #[must_use]
138 pub fn aluminum_a5052() -> Self {
139 Self {
140 ultimate_tensile_mpa: Fix128::from_int(230),
141 endurance_stress_mpa: Fix128::from_int(92),
142 endurance_cycles: 5_000_000,
143 fatigue_exponent_m: 6,
144 }
145 }
146}
147
148/// Sentinel indicating infinite fatigue life (stress at or below endurance, crate-internal).
149pub(crate) const INFINITE_LIFE: u64 = u64::MAX;
150
151// ============================================================================
152// Life predictions
153// ============================================================================
154
155/// Cycles to failure at a given alternating stress amplitude.
156///
157/// Returns `INFINITE_LIFE` when `stress_mpa ≤ endurance_stress_mpa`.
158///
159/// Uses Basquin: `N = N_e · (S_e / S)^m`. When `S > UTS` the model is
160/// physically invalid; the function still returns a value (very small
161/// cycle count) but the caller should treat this as "static failure".
162#[must_use]
163pub(crate) fn cycles_to_failure(curve: &SnCurve, stress_mpa: Fix128) -> u64 {
164 if stress_mpa <= curve.endurance_stress_mpa {
165 return INFINITE_LIFE;
166 }
167 if stress_mpa <= Fix128::ZERO {
168 return INFINITE_LIFE;
169 }
170
171 // ratio = S_e / S (dimensionless, < 1 since S > S_e)
172 let ratio = curve.endurance_stress_mpa / stress_mpa;
173
174 // ratio^m by repeated multiplication (m integer)
175 let mut r_m = Fix128::ONE;
176 for _ in 0..curve.fatigue_exponent_m {
177 r_m = r_m * ratio;
178 }
179
180 // n = floor(N_e · ratio^m) in integers: N_e times the integer part plus
181 // N_e times the 64 fraction bits. Converting N_e through i64 wrapped a count
182 // above i64::MAX negative. A negative ratio (negative S_e) or a life below
183 // one cycle answers one cycle, the minimum with physical meaning
184 if r_m.hi < 0 {
185 return 1;
186 }
187 let ne = u128::from(curve.endurance_cycles);
188 let n = ne * (r_m.hi as u128) + ((ne * u128::from(r_m.lo)) >> 64);
189 // INFINITE_LIFE is u64::MAX, so a finite life stops one below it
190 u64::try_from(n)
191 .unwrap_or(INFINITE_LIFE - 1)
192 .clamp(1, INFINITE_LIFE - 1)
193}
194
195/// Smallest cycle count the Basquin inverse answers for.
196///
197/// Basquin's law is a high-cycle fit (about `10³` to `10⁷` cycles); below
198/// it the formula returns stresses above the ultimate strength that are
199/// numerically exact and physically meaningless, so the query is refused
200/// rather than clamped (a clamp would hand back a plausible number and hide
201/// that the law does not apply).
202///
203/// Provisional: the low-cycle boundary depends on the material and the
204/// stress ratio, so this single constant is a placeholder for a per-curve
205/// field of `SnCurve` (open issue `fatigue-low-cycle-bound-per-material`).
206pub(crate) const BASQUIN_LOW_CYCLE_BOUND: u64 = 1_000;
207
208/// Why the Basquin inverse (`StructuralSolver::fatigue_strength_mpa`) could
209/// not answer.
210#[derive(Clone, Copy, Debug, PartialEq, Eq)]
211#[non_exhaustive]
212pub enum FatigueRangeError {
213 /// `cycles == 0` has no stress on the S–N curve.
214 ZeroCycles,
215 /// Below the low-cycle bound (currently 1 000 cycles for every curve),
216 /// outside the law's range.
217 BelowLowCycleBound {
218 /// The cycle count asked for.
219 cycles: u64,
220 /// The bound it fell under.
221 bound: u64,
222 },
223 /// The curve's exponent `m` is zero: `(N_e/N)^(1/m)` is undefined.
224 NonPositiveExponent,
225}
226
227/// Alternating stress that would produce failure at exactly `cycles`.
228///
229/// The inverse of the Basquin life `N = N_e · (S_e / S)^m`, i.e.
230/// `S = S_e · (N_e / N)^(1/m)`, evaluated as the fixed-point power
231/// `Fix128::powf_pos` (24 fractional bits of the exponent) polished by two
232/// Newton steps on `x^m = N_e/N` taken from that seed. The seed sits within
233/// `ln(N_e/N) · 2⁻²⁴` of the root, so `x^m` never leaves the range of
234/// `Fix128` (the pre-2026-10-03 version seeded with `√(N_e/N)` and wrapped
235/// `x^m` for every `N_e/N > 2^(126/m)`, returning e.g. 760 490 MPa for
236/// SUS304 at `N = 1`); the two polishes are a fixed count, not a loop to a
237/// tolerance, so there is no drift at the rounding floor.
238///
239/// For `cycles ≥ endurance_cycles` returns `endurance_stress_mpa` exactly.
240///
241/// # Errors
242///
243/// [`FatigueRangeError::ZeroCycles`] for `cycles == 0`,
244/// [`FatigueRangeError::BelowLowCycleBound`] under
245/// [`BASQUIN_LOW_CYCLE_BOUND`], [`FatigueRangeError::NonPositiveExponent`]
246/// for a curve with `fatigue_exponent_m == 0`.
247pub(crate) fn stress_at_cycles(curve: &SnCurve, cycles: u64) -> Result<Fix128, FatigueRangeError> {
248 if cycles == 0 {
249 return Err(FatigueRangeError::ZeroCycles);
250 }
251 if curve.fatigue_exponent_m == 0 {
252 return Err(FatigueRangeError::NonPositiveExponent);
253 }
254 if cycles < BASQUIN_LOW_CYCLE_BOUND {
255 return Err(FatigueRangeError::BelowLowCycleBound {
256 cycles,
257 bound: BASQUIN_LOW_CYCLE_BOUND,
258 });
259 }
260 if cycles >= curve.endurance_cycles {
261 return Ok(curve.endurance_stress_mpa);
262 }
263 let target = u64_ratio(curve.endurance_cycles, cycles);
264 let m = Fix128::from_int(i64::from(curve.fatigue_exponent_m));
265 let mut x = target.powf_pos(Fix128::ONE / m);
266 for _ in 0..2 {
267 let mut xm1 = Fix128::ONE;
268 for _ in 1..curve.fatigue_exponent_m {
269 xm1 = xm1 * x;
270 }
271 let xm = xm1 * x;
272 let df = m * xm1;
273 if df.is_zero() {
274 break;
275 }
276 x = x - (xm - target) / df;
277 }
278 Ok(curve.endurance_stress_mpa * x)
279}
280
281// ============================================================================
282// Miner's rule
283// ============================================================================
284
285/// One entry in a stress spectrum: `(alternating stress, applied cycles)`.
286pub type SpectrumEntry = (Fix128, u64);
287
288/// Miner's rule cumulative damage `D = Σ nᵢ / Nᵢ`.
289///
290/// A part is expected to fail when `D ≥ 1`. Any spectrum entry at or below
291/// the endurance limit contributes zero damage (infinite `Nᵢ`).
292#[must_use]
293pub fn miner_damage(spectrum: &[SpectrumEntry], curve: &SnCurve) -> Fix128 {
294 let mut d = Fix128::ZERO;
295 for &(stress, n) in spectrum {
296 let n_fail = cycles_to_failure(curve, stress);
297 if n_fail == INFINITE_LIFE {
298 continue;
299 }
300 let ratio = u64_ratio(n, n_fail);
301 // saturate instead of wrapping: Fix128 addition is modulo 2^128
302 d = if ratio >= DAMAGE_SATURATED - d {
303 DAMAGE_SATURATED
304 } else {
305 d + ratio
306 };
307 }
308 d
309}
310
311/// The largest damage `miner_damage` reports; a sum that would exceed it
312/// (a count far beyond the life) saturates here instead of wrapping negative.
313pub(crate) const DAMAGE_SATURATED: Fix128 = Fix128::from_raw(i64::MAX, u64::MAX);
314
315/// `n / d` for any `u64` pair (`d >= 1`). Split into the integer quotient and
316/// the remainder so a count above `i64::MAX` is not converted through `i64`
317/// (which wrapped it negative); a quotient beyond `i64::MAX` saturates at
318/// [`DAMAGE_SATURATED`]. A divisor above `i64::MAX` drops the last bit of the
319/// remainder and the divisor (a relative error below 2^-62 in the fraction)
320fn u64_ratio(n: u64, d: u64) -> Fix128 {
321 let q = n / d;
322 let r = n % d;
323 if q > i64::MAX as u64 {
324 return DAMAGE_SATURATED;
325 }
326 let frac = if d > i64::MAX as u64 {
327 Fix128::from_int((r >> 1) as i64) / Fix128::from_int((d >> 1) as i64)
328 } else {
329 Fix128::from_int(r as i64) / Fix128::from_int(d as i64)
330 };
331 Fix128::from_int(q as i64) + frac
332}
333
334/// Cumulative damage report for one stress spectrum, returned by
335/// `StructuralSolver::fatigue_spectrum_report`.
336#[derive(Clone, Copy, Debug, PartialEq, Eq)]
337#[non_exhaustive]
338pub struct FatigueReport {
339 /// Total damage `D`.
340 pub damage: Fix128,
341 /// True iff `damage < 1` (part is expected to survive the spectrum).
342 pub is_safe: bool,
343 /// Safety factor `1 / D` — the multiplier by which the entire spectrum
344 /// could be repeated before failure. Reported as the sentinel
345 /// `Fix128::from_int(i64::MAX >> 8)` for zero damage.
346 pub safety_factor: Fix128,
347}
348
349/// Miner's rule over the whole spectrum, packaged as a [`FatigueReport`]
350/// (`damage` is exactly [`miner_damage`], crate-internal).
351#[must_use]
352pub(crate) fn analyze_spectrum(spectrum: &[SpectrumEntry], curve: &SnCurve) -> FatigueReport {
353 let damage = miner_damage(spectrum, curve);
354 let sf = if damage.is_zero() {
355 Fix128::from_int(i64::MAX >> 8)
356 } else {
357 Fix128::ONE / damage
358 };
359 FatigueReport {
360 damage,
361 is_safe: damage < Fix128::ONE,
362 safety_factor: sf,
363 }
364}
365
366// ============================================================================
367// Tests
368// ============================================================================
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn from_material_uses_polymer_defaults() {
376 let curve = SnCurve::from_fdm_material(&MaterialProperties::pla());
377 assert_eq!(curve.fatigue_exponent_m, 5);
378 assert_eq!(curve.endurance_cycles, 1_000_000);
379 // S_e = 0.3 × 60 (PLA UTS) = 18 MPa (within Fix128 ULP)
380 let diff = if curve.endurance_stress_mpa > Fix128::from_int(18) {
381 curve.endurance_stress_mpa - Fix128::from_int(18)
382 } else {
383 Fix128::from_int(18) - curve.endurance_stress_mpa
384 };
385 assert!(diff < Fix128::from_ratio(1, 1000));
386 }
387
388 #[test]
389 fn stress_below_endurance_is_infinite_life() {
390 let curve = SnCurve::from_fdm_material(&MaterialProperties::pla());
391 let n = cycles_to_failure(&curve, Fix128::from_int(10));
392 assert_eq!(n, INFINITE_LIFE);
393 }
394
395 #[test]
396 fn stress_at_endurance_is_infinite_life() {
397 let curve = SnCurve::from_fdm_material(&MaterialProperties::pla());
398 let n = cycles_to_failure(&curve, curve.endurance_stress_mpa);
399 assert_eq!(n, INFINITE_LIFE);
400 }
401
402 #[test]
403 fn higher_stress_shorter_life() {
404 let curve = SnCurve::steel_sus304();
405 let n_low = cycles_to_failure(&curve, Fix128::from_int(300));
406 let n_high = cycles_to_failure(&curve, Fix128::from_int(400));
407 assert!(n_high < n_low);
408 }
409
410 #[test]
411 fn stress_at_endurance_cycles_returns_endurance() {
412 let curve = SnCurve::steel_sus304();
413 let s = stress_at_cycles(&curve, curve.endurance_cycles).unwrap();
414 assert_eq!(s, curve.endurance_stress_mpa);
415 }
416
417 /// Zero cycles has no stress on the curve: refused, not UTS (the
418 /// pre-2026-10-03 contract returned UTS, a number outside the law).
419 #[test]
420 fn stress_at_zero_cycles_is_refused() {
421 let curve = SnCurve::steel_sus304();
422 assert_eq!(
423 stress_at_cycles(&curve, 0),
424 Err(FatigueRangeError::ZeroCycles)
425 );
426 }
427
428 /// Oracle: `S` decreases with `N` over the whole high-cycle range, and
429 /// the round trip `cycles_to_failure(stress_at_cycles(N))` lands within
430 /// 0.1 % of `N` (the forward map truncates to an integer).
431 #[test]
432 fn stress_at_cycles_monotonic_decreasing_and_round_trips() {
433 let curve = SnCurve::aluminum_a5052();
434 let mut previous = Fix128::from_int(1_000_000);
435 for n in [
436 1_000u64, 2_000, 5_000, 20_000, 100_000, 1_000_000, 4_000_000,
437 ] {
438 let s = stress_at_cycles(&curve, n).unwrap();
439 assert!(s < previous, "N = {n}: {s:?} not below {previous:?}");
440 previous = s;
441 let back = cycles_to_failure(&curve, s);
442 assert!(
443 back.abs_diff(n) * 1000 <= n,
444 "N = {n}: round trip gave {back}"
445 );
446 }
447 }
448
449 #[test]
450 fn miner_damage_empty_spectrum_is_zero() {
451 let curve = SnCurve::from_fdm_material(&MaterialProperties::pla());
452 let d = miner_damage(&[], &curve);
453 assert_eq!(d, Fix128::ZERO);
454 }
455
456 #[test]
457 fn miner_damage_below_endurance_is_zero() {
458 let curve = SnCurve::steel_sus304();
459 let d = miner_damage(&[(Fix128::from_int(100), 1_000_000)], &curve);
460 // Below 240 MPa → no damage
461 assert_eq!(d, Fix128::ZERO);
462 }
463
464 #[test]
465 fn miner_damage_at_full_life_is_one() {
466 let curve = SnCurve::steel_sus304();
467 let stress = Fix128::from_int(400);
468 let n_fail = cycles_to_failure(&curve, stress);
469 let d = miner_damage(&[(stress, n_fail)], &curve);
470 // Full life consumed → D ≈ 1
471 let diff = if d > Fix128::ONE {
472 d - Fix128::ONE
473 } else {
474 Fix128::ONE - d
475 };
476 assert!(diff < Fix128::from_ratio(1, 100), "D was {}", d.to_f32());
477 }
478
479 #[test]
480 fn miner_damage_additive_over_multiple_stresses() {
481 let curve = SnCurve::steel_sus304();
482 let entry_a = (Fix128::from_int(300), 100u64);
483 let entry_b = (Fix128::from_int(350), 50u64);
484 let d_ab = miner_damage(&[entry_a, entry_b], &curve);
485 let d_a = miner_damage(&[entry_a], &curve);
486 let d_b = miner_damage(&[entry_b], &curve);
487 assert_eq!(d_ab, d_a + d_b);
488 }
489
490 #[test]
491 fn analyze_spectrum_safe_report() {
492 let curve = SnCurve::steel_sus304();
493 // 100 cycles at 300 MPa — tiny damage
494 let report = analyze_spectrum(&[(Fix128::from_int(300), 100)], &curve);
495 assert!(report.is_safe);
496 assert!(report.damage > Fix128::ZERO);
497 assert!(report.safety_factor > Fix128::from_int(1000));
498 }
499
500 #[test]
501 fn analyze_spectrum_unsafe_when_overloaded() {
502 let curve = SnCurve::steel_sus304();
503 // Consume triple the life at endurance-doubling stress
504 let stress = Fix128::from_int(480);
505 let n_fail = cycles_to_failure(&curve, stress);
506 let report = analyze_spectrum(&[(stress, n_fail * 3)], &curve);
507 assert!(!report.is_safe);
508 assert!(report.damage > Fix128::ONE);
509 }
510
511 /// Oracle: Basquin `N = N_e · (S_e / S)^m` with the SUS304 preset at
512 /// `S = 300 MPa`: `10⁷ · 0.8¹⁰ = 10⁷ · 0.1073741824 = 1 073 741.824`,
513 /// truncated toward zero to `1 073 741` cycles. Applying exactly that
514 /// many cycles gives Miner `D = 1` (unsafe, safety factor 1); one cycle
515 /// fewer is still safe.
516 #[test]
517 fn sus304_preset_basquin_life_at_300_mpa_is_1073741_cycles() {
518 let curve = SnCurve::steel_sus304();
519 let s = Fix128::from_int(300);
520 let full = analyze_spectrum(&[(s, 1_073_741)], &curve);
521 assert_eq!(full.damage, Fix128::ONE, "D = {}", full.damage.to_f64());
522 assert!(!full.is_safe);
523 assert_eq!(full.safety_factor, Fix128::ONE);
524 let almost = analyze_spectrum(&[(s, 1_073_740)], &curve);
525 assert!(almost.is_safe);
526 assert!(almost.damage < Fix128::ONE);
527 }
528
529 /// Oracle: Miner's rule by hand for the A5052 preset
530 /// (`S_e = 92`, `N_e = 5·10⁶`, `m = 6`).
531 /// `N(120) = ⌊5·10⁶ · (23/30)⁶⌋ = ⌊5·10⁶ · 148 035 889 / 729 000 000⌋
532 /// = 1 015 335`, `N(150) = ⌊5·10⁶ · 46⁶ / 75⁶⌋ = 266 164`.
533 /// Applying `N(120)/5 = 203 067` and `N(150)/4 = 66 541` cycles gives
534 /// `D = 0.2 + 0.25 = 0.45` and a safety factor of `1/0.45 = 2.2̅`.
535 #[test]
536 fn a5052_preset_miner_sum_matches_hand_calculation() {
537 let n120: u128 = 5_000_000 * 148_035_889 / 729_000_000;
538 let n150: u128 = 5_000_000 * 9_474_296_896 / 177_978_515_625;
539 assert_eq!((n120, n150), (1_015_335, 266_164), "hand calculation");
540 let curve = SnCurve::aluminum_a5052();
541 let report = analyze_spectrum(
542 &[
543 (Fix128::from_int(120), 203_067),
544 (Fix128::from_int(150), 66_541),
545 ],
546 &curve,
547 );
548 let d = report.damage.to_f64();
549 assert!((d - 0.45).abs() < 1e-12, "D = {d}");
550 assert!(report.is_safe);
551 let sf = report.safety_factor.to_f64();
552 assert!((sf - 1.0 / 0.45).abs() < 1e-12, "SF = {sf}");
553 }
554
555 /// Degenerate spectra documented on `analyze_spectrum` /
556 /// `cycles_to_failure`: an empty spectrum and an entry at the endurance
557 /// stress (infinite life, even with `u64::MAX` cycles) both report zero
558 /// damage, `is_safe`, and the zero-damage safety-factor sentinel
559 /// `Fix128::from_int(i64::MAX >> 8)`.
560 #[test]
561 fn analyze_spectrum_empty_and_below_endurance_report_zero_damage_sentinel() {
562 let curve = SnCurve::steel_sus304();
563 let sentinel = Fix128::from_int(i64::MAX >> 8);
564 for spectrum in [&[][..], &[(Fix128::from_int(240), u64::MAX)][..]] {
565 let report = analyze_spectrum(spectrum, &curve);
566 assert_eq!(report.damage, Fix128::ZERO);
567 assert!(report.is_safe);
568 assert_eq!(report.safety_factor, sentinel);
569 }
570 }
571
572 /// Oracle: the same Basquin inverse at small cycle counts, where the
573 /// Newton seed `x₀ = √(N_e/N)` makes `x₀^m = (N_e/N)^(m/2)` exceed the
574 /// Fix128 integer range (`2⁶³ ≈ 9.2e18`): SUS304 (`m = 10`) wraps for
575 /// `N < 1e7 / 6191 ≈ 1615`, A5052 (`m = 6`) for `N < 5e6 / 2.1e6 ≈ 2.4`.
576 /// Hand values: `240 · (10⁷)^0.1 = 240 · 5.0118723 = 1202.849 MPa`,
577 /// `92 · (5·10⁶)^(1/6) = 92 · 13.076605 = 1203.048 MPa`. Measured
578 /// 2026-10-03: `760 490.6` and `206 835.0` MPa (wrapped Newton), and
579 /// SUS304 at `N = 5000` is still `1.3e-4 MPa` off (not converged).
580 /// Oracle: the cycle counts at which the pre-2026-10-03 Newton seed
581 /// wrapped (`SUS304 N ≤ 1500`, `A5052 N ≤ 2`) now give the closed form
582 /// `S_e · (N_e/N)^(1/m)` to 1e-6 MPa where the law applies, and the
583 /// documented refusal below the low-cycle bound.
584 #[test]
585 fn stress_at_cycles_small_cycle_counts_match_basquin() {
586 let steel = SnCurve::steel_sus304();
587 for n in [1_000u64, 1_500, 2_000, 5_000] {
588 let got = stress_at_cycles(&steel, n).unwrap().to_f64();
589 let want = 240.0 * crate::det_math::powf64(1e7 / n as f64, 0.1);
590 assert!(
591 (got - want).abs() < 1e-6,
592 "SUS304 N = {n}: got {got}, want {want}"
593 );
594 }
595 assert_eq!(
596 stress_at_cycles(&steel, 1),
597 Err(FatigueRangeError::BelowLowCycleBound {
598 cycles: 1,
599 bound: 1_000
600 })
601 );
602 let alu = SnCurve::aluminum_a5052();
603 assert_eq!(
604 stress_at_cycles(&alu, 2),
605 Err(FatigueRangeError::BelowLowCycleBound {
606 cycles: 2,
607 bound: 1_000
608 })
609 );
610 let got = stress_at_cycles(&alu, 1_000).unwrap().to_f64();
611 let want = 92.0 * crate::det_math::powf64(5e3, 1.0 / 6.0);
612 assert!(
613 (got - want).abs() < 1e-6,
614 "A5052 N = 1000: got {got}, want {want}"
615 );
616 }
617
618 /// Oracle: `S = S_e · (N_e / N)^(1/m)` by hand.
619 /// SUS304 at `N = 10⁵`: `240 · 100^(0.1) = 240 · 1.5848932 = 380.3744 MPa`.
620 /// A5052 at `N = 5·10⁵`: `92 · 10^(1/6) = 92 · 1.4677993 = 135.0375 MPa`.
621 /// The Newton iteration stops at `|Δx| < 1e-6`, so the result is good
622 /// to far better than the `1e-6 MPa` asked for here.
623 #[test]
624 fn stress_at_cycles_inverts_basquin_for_sus304_and_a5052() {
625 let steel = stress_at_cycles(&SnCurve::steel_sus304(), 100_000)
626 .unwrap()
627 .to_f64();
628 let want_steel = 240.0 * crate::det_math::powf64(100.0, 0.1);
629 assert!(
630 (steel - want_steel).abs() < 1e-6,
631 "got {steel}, want {want_steel}"
632 );
633 let alu = stress_at_cycles(&SnCurve::aluminum_a5052(), 500_000)
634 .unwrap()
635 .to_f64();
636 let want_alu = 92.0 * crate::det_math::powf64(10.0, 1.0 / 6.0);
637 assert!((alu - want_alu).abs() < 1e-6, "got {alu}, want {want_alu}");
638 }
639
640 /// Degenerate cycle counts documented on `stress_at_cycles`: `0` is
641 /// refused, `N_e` and anything above (up to `u64::MAX`, which never
642 /// reaches the `as i64` conversion) return the endurance stress exactly,
643 /// and a curve with `m = 0` is refused.
644 #[test]
645 fn stress_at_cycles_degenerate_cycle_counts() {
646 let curve = SnCurve::aluminum_a5052();
647 assert_eq!(
648 stress_at_cycles(&curve, 0),
649 Err(FatigueRangeError::ZeroCycles)
650 );
651 assert_eq!(
652 stress_at_cycles(&curve, curve.endurance_cycles),
653 Ok(curve.endurance_stress_mpa)
654 );
655 assert_eq!(
656 stress_at_cycles(&curve, u64::MAX),
657 Ok(curve.endurance_stress_mpa)
658 );
659 let mut flat = SnCurve::aluminum_a5052();
660 flat.fatigue_exponent_m = 0;
661 assert_eq!(
662 stress_at_cycles(&flat, 10_000),
663 Err(FatigueRangeError::NonPositiveExponent)
664 );
665 }
666
667 #[test]
668 fn steel_has_higher_endurance_than_polymer() {
669 let curve_steel = SnCurve::steel_sus304();
670 let curve_pla = SnCurve::from_fdm_material(&MaterialProperties::pla());
671 assert!(curve_steel.endurance_stress_mpa > curve_pla.endurance_stress_mpa);
672 }
673}