Skip to main content

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}