Skip to main content

oxiphysics_materials/fatigue/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#[allow(unused_imports)]
6use super::functions::*;
7use super::functions::{normal_quantile, standard_normal_cdf};
8
9/// Coffin-Manson plastic strain-life equation.
10///
11/// Δε_p/2 = ε_f' * (2N_f)^c
12///
13/// Relates the plastic strain amplitude to the number of reversals to failure.
14#[allow(dead_code)]
15#[derive(Debug, Clone)]
16pub struct CoffinManson {
17    /// Fatigue ductility coefficient ε_f'
18    pub epsilon_f: f64,
19    /// Fatigue ductility exponent c (negative, typically -0.5 to -0.7)
20    pub c: f64,
21}
22#[allow(dead_code)]
23impl CoffinManson {
24    /// Create a new Coffin-Manson model.
25    pub fn new(epsilon_f: f64, c: f64) -> Self {
26        Self { epsilon_f, c }
27    }
28    /// Plastic strain amplitude at 2N reversals.
29    pub fn plastic_strain_amplitude(&self, two_n: f64) -> f64 {
30        self.epsilon_f * two_n.powf(self.c)
31    }
32    /// Reversals to failure at a given plastic strain amplitude.
33    ///
34    /// 2N_f = (Δε_p/2 / ε_f')^(1/c)
35    pub fn reversals_to_failure(&self, plastic_strain_amp: f64) -> f64 {
36        (plastic_strain_amp / self.epsilon_f).powf(1.0 / self.c)
37    }
38    /// Cycles to failure (N_f = 2N_f / 2).
39    pub fn cycles_to_failure(&self, plastic_strain_amp: f64) -> f64 {
40        self.reversals_to_failure(plastic_strain_amp) / 2.0
41    }
42    /// Transition life: number of reversals where elastic and plastic strains are equal.
43    ///
44    /// At the transition life 2N_t:
45    /// (σ_f'/E) * (2N_t)^b = ε_f' * (2N_t)^c
46    /// → 2N_t = (ε_f' * E / σ_f')^(1/(b-c))
47    pub fn transition_life(&self, sigma_f: f64, b: f64, e_modulus: f64) -> f64 {
48        let ratio = self.epsilon_f * e_modulus / sigma_f;
49        ratio.powf(1.0 / (b - self.c))
50    }
51}
52/// Palmgren-Miner cumulative damage rule with pre-computed damage ratios.
53///
54/// Each element of `damages` is the ratio n_i/N_i for one loading block.
55/// Failure is predicted when the sum reaches 1.
56#[derive(Debug, Clone)]
57#[allow(dead_code)]
58pub struct MinerRule {
59    /// Individual damage contributions D_i = n_i / N_i.
60    pub damages: Vec<f64>,
61}
62#[allow(dead_code)]
63impl MinerRule {
64    /// Create a new Miner rule accumulator.
65    pub fn new(damages: Vec<f64>) -> Self {
66        Self { damages }
67    }
68    /// Total accumulated damage D = Σ D_i.
69    pub fn total_damage(&self) -> f64 {
70        self.damages.iter().sum()
71    }
72    /// Returns `true` when total damage D ≥ 1 (failure predicted).
73    pub fn is_failed(&self) -> bool {
74        self.total_damage() >= 1.0
75    }
76}
77/// Biaxial fatigue failure surface (Sines-type ellipse).
78///
79/// Failure when: (σ_a / σ_e)^2 + a*σ_a*σ_b/σ_e^2 + (σ_b / σ_e)^2 = 1
80/// A simplified form using parameters `a` (interaction) and `b` (biaxiality ratio).
81#[derive(Debug, Clone)]
82#[allow(dead_code)]
83pub struct FatigueSurface {
84    /// Interaction coefficient a.
85    pub a: f64,
86    /// Biaxiality coefficient b.
87    pub b: f64,
88    /// Ultimate tensile strength σ_ult (Pa).
89    pub sigma_ult: f64,
90    /// Endurance limit σ_e (Pa).
91    pub sigma_endurance: f64,
92}
93#[allow(dead_code)]
94impl FatigueSurface {
95    /// Create a new biaxial fatigue surface.
96    pub fn new(a: f64, b: f64, sigma_ult: f64, sigma_endurance: f64) -> Self {
97        Self {
98            a,
99            b,
100            sigma_ult,
101            sigma_endurance,
102        }
103    }
104    /// Evaluate the biaxial damage index for stress amplitudes (σ_a1, σ_a2).
105    ///
106    /// Returns a value < 1 for safe, ≥ 1 for failure.
107    pub fn damage_index(&self, sigma_a1: f64, sigma_a2: f64) -> f64 {
108        let se = self.sigma_endurance;
109        if se <= 0.0 {
110            return f64::INFINITY;
111        }
112        let r1 = sigma_a1 / se;
113        let r2 = sigma_a2 / se;
114        r1 * r1 + self.a * r1 * r2 + self.b * r2 * r2
115    }
116    /// Returns `true` when the stress state is outside the failure surface.
117    pub fn is_failed(&self, sigma_a1: f64, sigma_a2: f64) -> bool {
118        self.damage_index(sigma_a1, sigma_a2) >= 1.0
119    }
120}
121/// Fatigue life predictor combining a Basquin S-N curve with a Goodman
122/// mean-stress correction.
123///
124/// N = (σ_ar / A)^(1/B)  where σ_ar = σ_a / (1 - σ_m / σ_ult)
125#[derive(Debug, Clone)]
126#[allow(dead_code)]
127pub struct FatigueLifePredictor {
128    /// Basquin S-N curve.
129    pub sn: BasquinCurve,
130    /// Ultimate tensile strength for Goodman correction (Pa).
131    pub sigma_ult: f64,
132}
133#[allow(dead_code)]
134impl FatigueLifePredictor {
135    /// Create a new predictor.
136    pub fn new(sn: BasquinCurve, sigma_ult: f64) -> Self {
137        Self { sn, sigma_ult }
138    }
139    /// Predict cycles to failure for a given stress amplitude and mean stress.
140    ///
141    /// Uses Goodman correction: σ_ar = σ_a / (1 - σ_m / σ_ult)
142    /// then applies the Basquin S-N curve.
143    pub fn predict_cycles(&self, sigma_a: f64, sigma_m: f64) -> f64 {
144        let denom = 1.0 - sigma_m / self.sigma_ult;
145        if denom <= 0.0 {
146            return 0.0;
147        }
148        let sigma_ar = sigma_a / denom;
149        self.sn.cycles_to_failure(sigma_ar)
150    }
151}
152/// Morrow mean stress correction for strain-life fatigue.
153///
154/// Modified Basquin equation:
155/// Δε_e/2 = ((σ_f' - σ_m) / E) * (2N_f)^b
156///
157/// The mean stress σ_m reduces the effective fatigue strength coefficient.
158#[allow(dead_code)]
159#[derive(Debug, Clone)]
160pub struct MorrowCorrection {
161    /// Fatigue strength coefficient σ_f' (Pa)
162    pub sigma_f: f64,
163    /// Fatigue strength exponent b
164    pub b: f64,
165    /// Young's modulus E (Pa)
166    pub e_modulus: f64,
167}
168#[allow(dead_code)]
169impl MorrowCorrection {
170    /// Create a new Morrow correction model.
171    pub fn new(sigma_f: f64, b: f64, e_modulus: f64) -> Self {
172        Self {
173            sigma_f,
174            b,
175            e_modulus,
176        }
177    }
178    /// Corrected elastic strain amplitude with mean stress.
179    ///
180    /// Δε_e/2 = ((σ_f' - σ_m) / E) * (2N)^b
181    pub fn corrected_strain_amplitude(&self, mean_stress: f64, two_n: f64) -> f64 {
182        ((self.sigma_f - mean_stress) / self.e_modulus) * two_n.powf(self.b)
183    }
184    /// Effective fatigue strength coefficient with mean stress correction.
185    ///
186    /// σ_f_eff = σ_f' - σ_m
187    pub fn effective_sigma_f(&self, mean_stress: f64) -> f64 {
188        self.sigma_f - mean_stress
189    }
190    /// Cycles to failure with Morrow correction (bisection).
191    ///
192    /// Solves: strain_amp = ((σ_f' - σ_m) / E) * (2N)^b for N.
193    pub fn cycles_to_failure(&self, strain_amplitude: f64, mean_stress: f64) -> f64 {
194        let sigma_eff = self.effective_sigma_f(mean_stress);
195        if sigma_eff <= 0.0 {
196            return 0.0;
197        }
198        let two_n = (strain_amplitude * self.e_modulus / sigma_eff).powf(1.0 / self.b);
199        two_n / 2.0
200    }
201}
202/// Soderberg diagram defined by yield strength and endurance limit.
203///
204/// Allowable amplitude: σ_a = σ_e * (1 - σ_m / σ_yield)
205#[derive(Debug, Clone)]
206#[allow(dead_code)]
207pub struct SoderbergDiagram {
208    /// Yield strength σ_yield (Pa).
209    pub sigma_yield: f64,
210    /// Fully-reversed endurance limit σ_e (Pa).
211    pub sigma_endurance: f64,
212}
213#[allow(dead_code)]
214impl SoderbergDiagram {
215    /// Create a new Soderberg diagram.
216    pub fn new(sigma_yield: f64, sigma_endurance: f64) -> Self {
217        Self {
218            sigma_yield,
219            sigma_endurance,
220        }
221    }
222    /// Allowable stress amplitude for a given mean stress.
223    ///
224    /// σ_a_allow = σ_e * (1 - σ_m / σ_yield)  \[clamped to 0\]
225    pub fn soderberg_allowed_amplitude(&self, mean_stress: f64) -> f64 {
226        (self.sigma_endurance * (1.0 - mean_stress / self.sigma_yield)).max(0.0)
227    }
228}
229/// Miner's rule with a user-specified critical damage threshold D_crit.
230///
231/// The standard Palmgren-Miner rule uses D_crit = 1.0, but experimental
232/// evidence shows that actual failure may occur between D = 0.3 and 3.0.
233/// This struct allows a custom D_crit.
234#[allow(dead_code)]
235#[derive(Debug, Clone)]
236pub struct MinerWithCrit {
237    /// Accumulated damage D.
238    pub damage: f64,
239    /// Critical damage threshold D_crit (failure when D >= D_crit).
240    pub d_crit: f64,
241}
242#[allow(dead_code)]
243impl MinerWithCrit {
244    /// Create a new Miner rule accumulator with a custom critical damage.
245    pub fn new(d_crit: f64) -> Self {
246        Self {
247            damage: 0.0,
248            d_crit,
249        }
250    }
251    /// Add damage from one loading block.
252    pub fn add_block(&mut self, n_applied: f64, n_failure: f64) {
253        if n_failure > 0.0 {
254            self.damage += n_applied / n_failure;
255        }
256    }
257    /// Returns true if accumulated damage >= D_crit.
258    pub fn is_failed(&self) -> bool {
259        self.damage >= self.d_crit
260    }
261    /// Remaining life fraction: (D_crit - D) / D_crit.
262    pub fn remaining_life_fraction(&self) -> f64 {
263        ((self.d_crit - self.damage) / self.d_crit).max(0.0)
264    }
265    /// Number of cycles to failure at a constant stress level N_f.
266    pub fn cycles_to_failure(&self, n_f: f64) -> f64 {
267        if self.damage >= self.d_crit {
268            return 0.0;
269        }
270        (self.d_crit - self.damage) * n_f
271    }
272}
273/// Cyclic Ramberg-Osgood stress-strain model.
274///
275/// ε = σ/E + (σ/K')^(1/n')
276///
277/// Describes the cyclic (stabilised hysteresis loop) stress-strain behaviour.
278#[allow(dead_code)]
279#[derive(Debug, Clone)]
280pub struct RambergOsgood {
281    /// Young's modulus E (Pa).
282    pub e_modulus: f64,
283    /// Cyclic strength coefficient K' (Pa).
284    pub k_prime: f64,
285    /// Cyclic strain hardening exponent n' (dimensionless, 0 < n' < 1).
286    pub n_prime: f64,
287}
288#[allow(dead_code)]
289impl RambergOsgood {
290    /// Create a new Ramberg-Osgood model.
291    pub fn new(e_modulus: f64, k_prime: f64, n_prime: f64) -> Self {
292        Self {
293            e_modulus,
294            k_prime,
295            n_prime,
296        }
297    }
298    /// Total strain ε for a given stress σ.
299    pub fn strain(&self, sigma: f64) -> f64 {
300        sigma / self.e_modulus
301            + (sigma.abs() / self.k_prime).powf(1.0 / self.n_prime) * sigma.signum()
302    }
303    /// Elastic strain σ/E.
304    pub fn elastic_strain(&self, sigma: f64) -> f64 {
305        sigma / self.e_modulus
306    }
307    /// Plastic strain (σ/K')^(1/n').
308    pub fn plastic_strain(&self, sigma: f64) -> f64 {
309        (sigma.abs() / self.k_prime).powf(1.0 / self.n_prime) * sigma.signum()
310    }
311    /// Solve for stress given total strain using bisection.
312    pub fn stress_from_strain(&self, epsilon: f64, max_iter: usize) -> f64 {
313        if epsilon.abs() < 1e-30 {
314            return 0.0;
315        }
316        let sign = epsilon.signum();
317        let eps_abs = epsilon.abs();
318        let sigma_max = eps_abs * self.e_modulus * 10.0;
319        let mut lo = 0.0_f64;
320        let mut hi = sigma_max;
321        for _ in 0..max_iter {
322            let mid = 0.5 * (lo + hi);
323            let eps_mid = self.strain(mid * sign).abs();
324            if eps_mid < eps_abs {
325                lo = mid;
326            } else {
327                hi = mid;
328            }
329            if (hi - lo) / sigma_max < 1e-12 {
330                break;
331            }
332        }
333        sign * 0.5 * (lo + hi)
334    }
335    /// Hysteresis loop width (plastic strain range) for a stress range Δσ.
336    ///
337    /// Δε_p = 2 * (Δσ / (2*K'))^(1/n')  (Masing hypothesis)
338    pub fn plastic_strain_range(&self, delta_sigma: f64) -> f64 {
339        2.0 * (delta_sigma / (2.0 * self.k_prime)).powf(1.0 / self.n_prime)
340    }
341    /// Hysteresis loop area (energy per cycle per unit volume) ≈ Δσ * Δε_p * 4/π.
342    ///
343    /// For a Ramberg-Osgood loop the exact energy per cycle is:
344    /// W = Δσ * Δε_p * 2*n'/(n'+1)
345    pub fn energy_per_cycle(&self, delta_sigma: f64) -> f64 {
346        let dep = self.plastic_strain_range(delta_sigma);
347        delta_sigma * dep * 2.0 * self.n_prime / (self.n_prime + 1.0)
348    }
349}
350/// Palmgren-Miner cumulative damage model.
351///
352/// Linear damage accumulation: D = Σ (n_i / N_i)
353/// Failure is predicted when D ≥ 1.
354#[derive(Debug, Clone)]
355pub struct PalmgrenMinor {
356    /// Cumulative damage D
357    pub damage: f64,
358}
359impl PalmgrenMinor {
360    /// Create a new Palmgren-Miner damage accumulator with D = 0.
361    pub fn new() -> Self {
362        Self { damage: 0.0 }
363    }
364    /// Apply a block of n_applied cycles at a stress level with n_failure cycles to failure.
365    ///
366    /// D += n_applied / n_failure
367    ///
368    /// # Arguments
369    /// * `n_applied` - Number of applied cycles at this stress level
370    /// * `n_failure` - Cycles to failure at this stress level
371    pub fn apply_cycle_block(&mut self, n_applied: f64, n_failure: f64) {
372        self.damage += n_applied / n_failure;
373    }
374    /// Return the total accumulated damage D.
375    pub fn total_damage(&self) -> f64 {
376        self.damage
377    }
378    /// Returns true if D ≥ 1.0 (failure predicted by Miner's rule).
379    pub fn is_failed(&self) -> bool {
380        self.damage >= 1.0
381    }
382    /// Reset the damage accumulator to zero.
383    pub fn reset(&mut self) {
384        self.damage = 0.0;
385    }
386    /// Remaining life fraction: 1.0 - D (clamped to \[0, 1\]).
387    #[allow(dead_code)]
388    pub fn remaining_life(&self) -> f64 {
389        (1.0 - self.damage).clamp(0.0, 1.0)
390    }
391    /// Apply damage from a list of (n_applied, n_failure) pairs.
392    #[allow(dead_code)]
393    pub fn apply_spectrum(&mut self, blocks: &[(f64, f64)]) {
394        for &(n_applied, n_failure) in blocks {
395            self.apply_cycle_block(n_applied, n_failure);
396        }
397    }
398    /// Compute the number of additional cycles at a given stress level
399    /// before failure (D reaches 1.0).
400    #[allow(dead_code)]
401    pub fn remaining_cycles(&self, n_failure_at_stress: f64) -> f64 {
402        if self.damage >= 1.0 {
403            return 0.0;
404        }
405        (1.0 - self.damage) * n_failure_at_stress
406    }
407}
408/// Explicit Basquin stress-life solver.
409///
410/// Stress amplitude: σ_a = σ_f_prime * (2N)^b
411/// Inverted to give N_f = 0.5 * (σ_a / σ_f_prime)^(1/b)
412#[allow(dead_code)]
413pub struct BasquinStressLife {
414    /// Fatigue strength coefficient σ_f' (Pa)
415    pub sigma_f_prime: f64,
416    /// Fatigue strength exponent b (negative)
417    pub b_exp: f64,
418    /// Endurance limit (Pa) — cycles beyond which no damage is counted
419    pub endurance_limit: f64,
420}
421impl BasquinStressLife {
422    /// Create a new Basquin stress-life model.
423    pub fn new(sigma_f_prime: f64, b_exp: f64, endurance_limit: f64) -> Self {
424        Self {
425            sigma_f_prime,
426            b_exp,
427            endurance_limit,
428        }
429    }
430    /// Stress amplitude at a given number of reversals 2N.
431    pub fn stress_amplitude(&self, n_reversals: f64) -> f64 {
432        self.sigma_f_prime * n_reversals.powf(self.b_exp)
433    }
434    /// Cycles to failure for a given stress amplitude.
435    ///
436    /// Returns `f64::INFINITY` when `sigma_a <= endurance_limit`.
437    pub fn cycles_to_failure(&self, sigma_a: f64) -> f64 {
438        if sigma_a <= self.endurance_limit {
439            return f64::INFINITY;
440        }
441        let two_n_f = (sigma_a / self.sigma_f_prime).powf(1.0 / self.b_exp);
442        two_n_f / 2.0
443    }
444    /// Fatigue damage per cycle at given stress amplitude.
445    ///
446    /// D = 1 / N_f.  Returns 0 when below endurance limit.
447    pub fn damage_per_cycle(&self, sigma_a: f64) -> f64 {
448        let n_f = self.cycles_to_failure(sigma_a);
449        if n_f.is_infinite() { 0.0 } else { 1.0 / n_f }
450    }
451    /// Endurance ratio: σ_endurance / σ_f_prime.
452    pub fn endurance_ratio(&self) -> f64 {
453        self.endurance_limit / self.sigma_f_prime
454    }
455    /// Cycles at which the S-N curve crosses the endurance limit.
456    pub fn transition_cycles(&self) -> f64 {
457        self.cycles_to_failure(self.endurance_limit * 1.000_000_001)
458    }
459    /// Apply a safety factor to the stress amplitude before computing life.
460    pub fn cycles_with_safety_factor(&self, sigma_a: f64, safety_factor: f64) -> f64 {
461        self.cycles_to_failure(sigma_a * safety_factor)
462    }
463}
464/// A counted fatigue cycle from rainflow analysis.
465#[derive(Debug, Clone, PartialEq)]
466#[allow(dead_code)]
467pub struct RainflowCycle {
468    /// Cycle range (peak − valley).
469    pub range: f64,
470    /// Cycle mean ((peak + valley) / 2).
471    pub mean: f64,
472    /// Counting weight (1.0 for full cycle, 0.5 for half-cycle).
473    pub count: f64,
474}
475impl RainflowCycle {
476    /// Amplitude = range / 2.
477    pub fn amplitude(&self) -> f64 {
478        self.range / 2.0
479    }
480    /// Stress ratio R = (mean − amplitude) / (mean + amplitude).
481    pub fn stress_ratio(&self) -> f64 {
482        let s_min = self.mean - self.amplitude();
483        let s_max = self.mean + self.amplitude();
484        if s_max.abs() > 1e-30 {
485            s_min / s_max
486        } else {
487            0.0
488        }
489    }
490}
491/// Basquin (stress-life) S-N curve.
492///
493/// σ_a = A * N^B
494///
495/// where A is the fatigue strength coefficient and B is the exponent.
496/// Alternatively: N = (σ_a / A)^(1/B)
497#[allow(dead_code)]
498#[derive(Debug, Clone)]
499pub struct BasquinCurve {
500    /// Fatigue strength coefficient A (Pa)
501    pub a: f64,
502    /// Fatigue strength exponent B (negative, typically -0.05 to -0.15)
503    pub b_exp: f64,
504    /// Endurance limit σ_e (Pa). Below this stress, infinite life is assumed.
505    pub endurance_limit: f64,
506}
507#[allow(dead_code)]
508impl BasquinCurve {
509    /// Create a new Basquin S-N curve.
510    pub fn new(a: f64, b_exp: f64, endurance_limit: f64) -> Self {
511        Self {
512            a,
513            b_exp,
514            endurance_limit,
515        }
516    }
517    /// Create from two known (N, σ) data points.
518    ///
519    /// σ_1 = A * N_1^B and σ_2 = A * N_2^B
520    /// → B = ln(σ_1/σ_2) / ln(N_1/N_2)
521    /// → A = σ_1 / N_1^B
522    pub fn from_two_points(
523        n1: f64,
524        sigma1: f64,
525        n2: f64,
526        sigma2: f64,
527        endurance_limit: f64,
528    ) -> Self {
529        let b_exp = (sigma1 / sigma2).ln() / (n1 / n2).ln();
530        let a = sigma1 / n1.powf(b_exp);
531        Self {
532            a,
533            b_exp,
534            endurance_limit,
535        }
536    }
537    /// Stress amplitude at N cycles: σ_a = A * N^B
538    pub fn stress_at_n(&self, n: f64) -> f64 {
539        self.a * n.powf(self.b_exp)
540    }
541    /// Cycles to failure at stress amplitude σ_a: N = (σ_a / A)^(1/B)
542    ///
543    /// Returns `f64::INFINITY` if σ_a <= endurance_limit.
544    pub fn cycles_to_failure(&self, stress_amplitude: f64) -> f64 {
545        if stress_amplitude <= self.endurance_limit {
546            return f64::INFINITY;
547        }
548        (stress_amplitude / self.a).powf(1.0 / self.b_exp)
549    }
550}
551/// Coffin-Manson-Basquin strain-life fatigue parameters.
552///
553/// The total strain amplitude is decomposed into elastic and plastic parts:
554/// - Δε_e/2 = (σ_f/E) * (2N)^b   (Basquin elastic term)
555/// - Δε_p/2 = ε_f * (2N)^c        (Coffin-Manson plastic term)
556/// - Δε/2   = Δε_e/2 + Δε_p/2    (total)
557#[derive(Debug, Clone)]
558#[allow(dead_code)]
559pub struct SNcurve {
560    /// Fatigue strength coefficient σ_f (Pa)
561    pub sigma_f: f64,
562    /// Fatigue strength exponent b (typically -0.05 to -0.12)
563    pub b: f64,
564    /// Fatigue ductility coefficient ε_f (dimensionless)
565    pub epsilon_f: f64,
566    /// Fatigue ductility exponent c (typically -0.5 to -0.7)
567    pub c: f64,
568    /// Young's modulus E (Pa)
569    pub e_modulus: f64,
570}
571impl SNcurve {
572    /// Create a new Coffin-Manson-Basquin S-N curve.
573    ///
574    /// # Arguments
575    /// * `sigma_f`   - Fatigue strength coefficient (Pa)
576    /// * `b`         - Fatigue strength exponent (dimensionless, typically negative)
577    /// * `epsilon_f` - Fatigue ductility coefficient (dimensionless)
578    /// * `c`         - Fatigue ductility exponent (dimensionless, typically negative)
579    /// * `e_modulus` - Young's modulus (Pa)
580    pub fn new(sigma_f: f64, b: f64, epsilon_f: f64, c: f64, e_modulus: f64) -> Self {
581        Self {
582            sigma_f,
583            b,
584            epsilon_f,
585            c,
586            e_modulus,
587        }
588    }
589    /// Elastic strain amplitude: Δε_e/2 = (σ_f/E) * (2N)^b
590    ///
591    /// # Arguments
592    /// * `n_reversals` - Number of reversals 2N (not cycles)
593    pub fn elastic_strain_amplitude(&self, n_reversals: f64) -> f64 {
594        (self.sigma_f / self.e_modulus) * n_reversals.powf(self.b)
595    }
596    /// Plastic strain amplitude: Δε_p/2 = ε_f * (2N)^c
597    ///
598    /// # Arguments
599    /// * `n_reversals` - Number of reversals 2N (not cycles)
600    pub fn plastic_strain_amplitude(&self, n_reversals: f64) -> f64 {
601        self.epsilon_f * n_reversals.powf(self.c)
602    }
603    /// Total strain amplitude: Δε/2 = elastic + plastic
604    ///
605    /// # Arguments
606    /// * `n_reversals` - Number of reversals 2N (not cycles)
607    pub fn total_strain_amplitude(&self, n_reversals: f64) -> f64 {
608        self.elastic_strain_amplitude(n_reversals) + self.plastic_strain_amplitude(n_reversals)
609    }
610    /// Cycles to failure for a given strain amplitude (bisection method).
611    ///
612    /// Solves Δε/2 = (σ_f/E)*(2N)^b + ε_f*(2N)^c for N using bisection.
613    ///
614    /// # Arguments
615    /// * `strain_amplitude` - Total strain amplitude Δε/2
616    pub fn cycles_to_failure_strain(&self, strain_amplitude: f64) -> f64 {
617        let mut lo = 2.0_f64;
618        let mut hi = 2.0e12_f64;
619        let f_lo = self.total_strain_amplitude(lo) - strain_amplitude;
620        let f_hi = self.total_strain_amplitude(hi) - strain_amplitude;
621        if f_lo <= 0.0 {
622            return lo / 2.0;
623        }
624        if f_hi >= 0.0 {
625            return hi / 2.0;
626        }
627        for _ in 0..100 {
628            let mid = (lo + hi) / 2.0;
629            let f_mid = self.total_strain_amplitude(mid) - strain_amplitude;
630            if f_mid > 0.0 {
631                lo = mid;
632            } else {
633                hi = mid;
634            }
635            if (hi - lo) / hi < 1.0e-12 {
636                break;
637            }
638        }
639        (lo + hi) / 2.0 / 2.0
640    }
641    /// Stress amplitude from Basquin equation: σ_a = σ_f * (2N)^b
642    ///
643    /// # Arguments
644    /// * `n_cycles` - Number of cycles N (not reversals)
645    pub fn stress_amplitude_from_n(&self, n_cycles: f64) -> f64 {
646        let two_n = 2.0 * n_cycles;
647        self.sigma_f * two_n.powf(self.b)
648    }
649}
650/// Neuber's rule for notch analysis.
651///
652/// Neuber's rule relates the theoretical (elastic) stress/strain at a notch
653/// to the actual local elastic-plastic stress and strain:
654///
655/// (Kt * σ_nom)² / E = σ_local * ε_local
656///
657/// i.e., the geometric mean of local stress and strain energy equals the
658/// nominal elastic value scaled by Kt².
659///
660/// Given nominal stress σ_nom, stress concentration factor Kt, and
661/// Young's modulus E, the Neuber hyperbola is:
662/// σ_local * ε_local = (Kt * σ_nom)² / E
663///
664/// When combined with a cyclic stress-strain curve (e.g., Ramberg-Osgood)
665/// the intersection gives the actual local stress and strain.
666#[allow(dead_code)]
667#[derive(Debug, Clone)]
668pub struct NeuberRule {
669    /// Stress concentration factor Kt.
670    pub kt: f64,
671    /// Young's modulus E (Pa).
672    pub e_modulus: f64,
673    /// Ramberg-Osgood cyclic strength coefficient K' (Pa).
674    pub k_prime: f64,
675    /// Ramberg-Osgood cyclic strain hardening exponent n'.
676    pub n_prime: f64,
677}
678#[allow(dead_code)]
679impl NeuberRule {
680    /// Create a new Neuber rule model.
681    pub fn new(kt: f64, e_modulus: f64, k_prime: f64, n_prime: f64) -> Self {
682        Self {
683            kt,
684            e_modulus,
685            k_prime,
686            n_prime,
687        }
688    }
689    /// Neuber hyperbola product: (Kt * σ_nom)² / E
690    pub fn neuber_product(&self, sigma_nom: f64) -> f64 {
691        (self.kt * sigma_nom).powi(2) / self.e_modulus
692    }
693    /// Ramberg-Osgood total strain for a given local stress.
694    ///
695    /// ε = σ/E + (σ/K')^(1/n')
696    pub fn ramberg_osgood_strain(&self, sigma_local: f64) -> f64 {
697        sigma_local / self.e_modulus + (sigma_local / self.k_prime).powf(1.0 / self.n_prime)
698    }
699    /// Solve for local stress using Neuber's rule + Ramberg-Osgood (bisection).
700    ///
701    /// Finds σ_local such that σ_local * ε(σ_local) = (Kt * σ_nom)² / E.
702    pub fn local_stress(&self, sigma_nom: f64, max_iter: usize) -> f64 {
703        let target = self.neuber_product(sigma_nom);
704        if target <= 0.0 {
705            return 0.0;
706        }
707        let sigma_el = self.kt * sigma_nom.abs();
708        let mut lo = 0.0_f64;
709        let mut hi = sigma_el * 3.0;
710        for _ in 0..max_iter {
711            let mid = 0.5 * (lo + hi);
712            let eps_mid = self.ramberg_osgood_strain(mid);
713            let prod = mid * eps_mid;
714            if prod < target {
715                lo = mid;
716            } else {
717                hi = mid;
718            }
719            if (hi - lo) / sigma_el.max(1.0) < 1e-12 {
720                break;
721            }
722        }
723        0.5 * (lo + hi)
724    }
725    /// Solve for local strain from local stress (direct Ramberg-Osgood).
726    pub fn local_strain(&self, sigma_local: f64) -> f64 {
727        self.ramberg_osgood_strain(sigma_local)
728    }
729    /// Cyclic Neuber analysis: compute local stress and strain amplitudes
730    /// for a given nominal stress amplitude using Masing's hypothesis.
731    ///
732    /// For cyclic loading, the cyclic Ramberg-Osgood uses factor 2:
733    /// Δε = Δσ/E + 2*(Δσ/2K')^(1/n')
734    ///
735    /// Returns (sigma_a_local, epsilon_a_local).
736    pub fn cyclic_local_stress_strain(&self, sigma_nom_amp: f64, max_iter: usize) -> (f64, f64) {
737        let target = self.neuber_product(sigma_nom_amp);
738        if target <= 0.0 {
739            return (0.0, 0.0);
740        }
741        let sigma_el = self.kt * sigma_nom_amp;
742        let mut lo = 0.0_f64;
743        let mut hi = sigma_el * 3.0;
744        for _ in 0..max_iter {
745            let mid = 0.5 * (lo + hi);
746            let eps_mid = self.ramberg_osgood_strain(mid);
747            let prod = mid * eps_mid;
748            if prod < target {
749                lo = mid;
750            } else {
751                hi = mid;
752            }
753            if (hi - lo) / sigma_el.max(1.0) < 1e-12 {
754                break;
755            }
756        }
757        let sigma_a = 0.5 * (lo + hi);
758        (sigma_a, self.ramberg_osgood_strain(sigma_a))
759    }
760}
761/// Coffin-Manson low-cycle fatigue model (extended variant with primed coefficients).
762///
763/// Total strain amplitude:
764/// Δε/2 = ε_f' * (2N_f)^c
765///
766/// where c is the ductility exponent (typically −0.5 to −0.7).
767#[allow(dead_code)]
768pub struct CoffinMansonLcf {
769    /// Fatigue ductility coefficient ε_f' (dimensionless)
770    pub epsilon_f_prime: f64,
771    /// Fatigue ductility exponent c (negative)
772    pub c_exp: f64,
773}
774impl CoffinMansonLcf {
775    /// Create a new Coffin-Manson low-cycle fatigue model.
776    pub fn new(epsilon_f_prime: f64, c_exp: f64) -> Self {
777        Self {
778            epsilon_f_prime,
779            c_exp,
780        }
781    }
782    /// Plastic strain amplitude at a given number of reversals 2N.
783    pub fn plastic_strain_amplitude(&self, n_reversals: f64) -> f64 {
784        self.epsilon_f_prime * n_reversals.powf(self.c_exp)
785    }
786    /// Reversals to failure for a given plastic strain amplitude.
787    pub fn reversals_to_failure(&self, delta_epsilon_p_half: f64) -> f64 {
788        (delta_epsilon_p_half / self.epsilon_f_prime).powf(1.0 / self.c_exp)
789    }
790    /// Cycles to failure (= reversals / 2).
791    pub fn cycles_to_failure(&self, delta_epsilon_p_half: f64) -> f64 {
792        self.reversals_to_failure(delta_epsilon_p_half) / 2.0
793    }
794    /// Transition reversals between low- and high-cycle fatigue.
795    ///
796    /// At the transition, plastic strain amplitude = elastic strain amplitude.
797    /// Requires the Basquin parameters (σ_f/E, b) to solve simultaneously.
798    pub fn transition_reversals(&self, sigma_f: f64, e_modulus: f64, b_exp: f64) -> f64 {
799        let ratio = self.epsilon_f_prime * e_modulus / sigma_f;
800        ratio.powf(1.0 / (b_exp - self.c_exp))
801    }
802    /// Damage per cycle for Palmgren-Miner accumulation.
803    pub fn damage_per_cycle(&self, delta_epsilon_p_half: f64) -> f64 {
804        let n_f = self.cycles_to_failure(delta_epsilon_p_half);
805        if n_f > 0.0 && n_f.is_finite() {
806            1.0 / n_f
807        } else {
808            0.0
809        }
810    }
811}
812/// Goodman diagram defined by ultimate strength and endurance limit.
813///
814/// Allowable amplitude: σ_a = σ_e * (1 - σ_m / σ_ult)
815#[derive(Debug, Clone)]
816#[allow(dead_code)]
817pub struct GoodmanDiagramNew {
818    /// Ultimate tensile strength σ_ult (Pa).
819    pub sigma_ult: f64,
820    /// Fully-reversed endurance limit σ_e (Pa).
821    pub sigma_endurance: f64,
822}
823#[allow(dead_code)]
824impl GoodmanDiagramNew {
825    /// Create a new Goodman diagram.
826    pub fn new(sigma_ult: f64, sigma_endurance: f64) -> Self {
827        Self {
828            sigma_ult,
829            sigma_endurance,
830        }
831    }
832    /// Allowable stress amplitude for a given mean stress.
833    ///
834    /// σ_a_allow = σ_e * (1 - σ_m / σ_ult)  \[clamped to 0\]
835    pub fn goodman_allowed_amplitude(&self, mean_stress: f64) -> f64 {
836        (self.sigma_endurance * (1.0 - mean_stress / self.sigma_ult)).max(0.0)
837    }
838}
839/// Smith-Watson-Topper (SWT) damage parameter.
840///
841/// SWT = σ_max * Δε/2
842///
843/// Used as a fatigue damage indicator that accounts for both mean stress
844/// and strain amplitude. Failure occurs when SWT equals the material
845/// SWT capacity.
846///
847/// The SWT parameter for a Coffin-Manson-Basquin material:
848/// SWT = (σ_f')² / E * (2N)^(2b) + σ_f' * ε_f' * (2N)^(b+c)
849#[allow(dead_code)]
850#[derive(Debug, Clone)]
851pub struct SwtParameter {
852    /// Fatigue strength coefficient σ_f' (Pa)
853    pub sigma_f: f64,
854    /// Fatigue strength exponent b
855    pub b: f64,
856    /// Fatigue ductility coefficient ε_f'
857    pub epsilon_f: f64,
858    /// Fatigue ductility exponent c
859    pub c: f64,
860    /// Young's modulus E (Pa)
861    pub e_modulus: f64,
862}
863#[allow(dead_code)]
864impl SwtParameter {
865    /// Create a new SWT parameter model.
866    pub fn new(sigma_f: f64, b: f64, epsilon_f: f64, c: f64, e_modulus: f64) -> Self {
867        Self {
868            sigma_f,
869            b,
870            epsilon_f,
871            c,
872            e_modulus,
873        }
874    }
875    /// Compute the SWT damage parameter from max stress and strain amplitude.
876    ///
877    /// SWT = σ_max * Δε/2
878    pub fn compute(max_stress: f64, strain_amplitude: f64) -> f64 {
879        if max_stress <= 0.0 {
880            return 0.0;
881        }
882        max_stress * strain_amplitude
883    }
884    /// Material SWT capacity at a given number of reversals.
885    ///
886    /// SWT = (σ_f')²/E * (2N)^(2b) + σ_f' * ε_f' * (2N)^(b+c)
887    pub fn material_swt(&self, two_n: f64) -> f64 {
888        let term1 = self.sigma_f.powi(2) / self.e_modulus * two_n.powf(2.0 * self.b);
889        let term2 = self.sigma_f * self.epsilon_f * two_n.powf(self.b + self.c);
890        term1 + term2
891    }
892    /// Cycles to failure from SWT parameter (bisection).
893    pub fn cycles_to_failure(&self, swt_value: f64) -> f64 {
894        if swt_value <= 0.0 {
895            return f64::INFINITY;
896        }
897        let mut lo = 2.0_f64;
898        let mut hi = 2.0e12_f64;
899        for _ in 0..100 {
900            let mid = (lo + hi) / 2.0;
901            let swt_mid = self.material_swt(mid);
902            if swt_mid > swt_value {
903                lo = mid;
904            } else {
905                hi = mid;
906            }
907            if (hi - lo) / hi < 1e-12 {
908                break;
909            }
910        }
911        (lo + hi) / 4.0
912    }
913}
914/// Log-normal S-N scatter band model.
915///
916/// At a given stress amplitude, the fatigue life follows a log-normal
917/// distribution with parameters (log_mean_n, log_std_n) derived from
918/// a reference life N_ref and a scatter factor T_n.
919///
920/// T_n = N_p10 / N_p90 (ratio of 10%ile to 90%ile life).
921#[allow(dead_code)]
922#[derive(Debug, Clone)]
923pub struct SNScatterBand {
924    /// Basquin S-N curve (median/mean life).
925    pub sn: BasquinCurve,
926    /// Scatter factor T_n = N_90% / N_10% (ratio of 90th to 10th percentile lives).
927    pub t_n: f64,
928}
929#[allow(dead_code)]
930impl SNScatterBand {
931    /// Create a new S-N scatter band.
932    ///
933    /// # Arguments
934    /// * `sn`  - Median Basquin S-N curve.
935    /// * `t_n` - Scatter factor (ratio N_10 / N_90, typically 3..30 for metals).
936    pub fn new(sn: BasquinCurve, t_n: f64) -> Self {
937        Self { sn, t_n }
938    }
939    /// Log-normal standard deviation of log10(N).
940    ///
941    /// log10(T_n) = 2 * 1.281 * s_log10_n
942    /// → s_log10_n = log10(T_n) / (2 * 1.281)
943    pub fn log_std(&self) -> f64 {
944        self.t_n.log10() / (2.0 * 1.2816)
945    }
946    /// Life at a given probability of survival p_s (0..1).
947    ///
948    /// log10(N_ps) = log10(N_median) + z_p * s_log10
949    /// where z_p is the normal quantile (< 0 for p_s < 0.5).
950    ///
951    /// Uses the rational approximation for the normal quantile.
952    pub fn life_at_survival(&self, sigma_amp: f64, p_survival: f64) -> f64 {
953        if sigma_amp <= self.sn.endurance_limit {
954            return f64::INFINITY;
955        }
956        let n_median = self.sn.cycles_to_failure(sigma_amp);
957        if !n_median.is_finite() {
958            return f64::INFINITY;
959        }
960        let log_n_med = n_median.log10();
961        let s = self.log_std();
962        let z = normal_quantile(1.0 - p_survival);
963        10.0_f64.powf(log_n_med + z * s)
964    }
965    /// Probability of survival at a given life N for a given stress amplitude.
966    ///
967    /// P_s = Φ((log10(N) - log10(N_median)) / s_log10)
968    pub fn survival_probability(&self, sigma_amp: f64, n_cycles: f64) -> f64 {
969        if sigma_amp <= self.sn.endurance_limit {
970            return 1.0;
971        }
972        let n_median = self.sn.cycles_to_failure(sigma_amp);
973        if !n_median.is_finite() {
974            return 1.0;
975        }
976        let s = self.log_std();
977        if s <= 0.0 {
978            return if n_cycles < n_median { 1.0 } else { 0.0 };
979        }
980        let z = (n_cycles.log10() - n_median.log10()) / s;
981        standard_normal_cdf(z)
982    }
983}
984/// Cycle-by-cycle Palmgren-Miner damage accumulator.
985///
986/// Accumulates `n / N_f` for each loading block.  A block represents one or
987/// more applied cycles at a given stress amplitude.
988#[allow(dead_code)]
989pub struct CycleDamageAccumulator {
990    /// Accumulated damage D = Σ (n_i / N_fi).
991    pub damage: f64,
992    /// Critical damage at failure (Miner's rule: 1.0).
993    pub d_crit: f64,
994    /// Log of (stress_amplitude, N_f, n_applied) for each block.
995    pub history: Vec<(f64, f64, f64)>,
996}
997impl CycleDamageAccumulator {
998    /// Create a new accumulator with given failure criterion.
999    pub fn new(d_crit: f64) -> Self {
1000        Self {
1001            damage: 0.0,
1002            d_crit,
1003            history: Vec::new(),
1004        }
1005    }
1006    /// Apply `n_applied` cycles at `sigma_a` using the given S-N curve.
1007    ///
1008    /// Cycles below the endurance limit contribute zero damage.
1009    pub fn apply_cycles(&mut self, n_applied: f64, sigma_a: f64, sn: &BasquinStressLife) {
1010        let n_f = sn.cycles_to_failure(sigma_a);
1011        let d = if n_f.is_infinite() {
1012            0.0
1013        } else {
1014            n_applied / n_f
1015        };
1016        self.damage += d;
1017        self.history.push((sigma_a, n_f, n_applied));
1018    }
1019    /// Apply a rainflow-counted cycle set using a Basquin S-N curve.
1020    pub fn apply_rainflow(&mut self, cycles: &[RainflowCycle], sn: &BasquinStressLife) {
1021        for cyc in cycles {
1022            self.apply_cycles(cyc.count, cyc.amplitude(), sn);
1023        }
1024    }
1025    /// True when accumulated damage meets or exceeds `d_crit`.
1026    pub fn is_failed(&self) -> bool {
1027        self.damage >= self.d_crit
1028    }
1029    /// Remaining life fraction: (D_crit − D) / D_crit.
1030    pub fn remaining_life_fraction(&self) -> f64 {
1031        ((self.d_crit - self.damage) / self.d_crit).max(0.0)
1032    }
1033    /// Estimate remaining cycles at constant amplitude `sigma_a`.
1034    pub fn remaining_cycles(&self, sigma_a: f64, sn: &BasquinStressLife) -> f64 {
1035        let n_f = sn.cycles_to_failure(sigma_a);
1036        if n_f.is_infinite() {
1037            return f64::INFINITY;
1038        }
1039        let remaining_d = (self.d_crit - self.damage).max(0.0);
1040        remaining_d * n_f
1041    }
1042    /// Reset accumulator state.
1043    pub fn reset(&mut self) {
1044        self.damage = 0.0;
1045        self.history.clear();
1046    }
1047    /// Number of blocks applied.
1048    pub fn block_count(&self) -> usize {
1049        self.history.len()
1050    }
1051}
1052/// Paris law crack growth parameters.
1053///
1054/// da/dN = C * (ΔK)^m  (valid between ΔK_th and K_c)
1055#[allow(dead_code)]
1056#[derive(Debug, Clone)]
1057pub struct ParisLaw {
1058    /// Paris coefficient C.
1059    pub c: f64,
1060    /// Paris exponent m.
1061    pub m: f64,
1062    /// Threshold stress intensity factor range ΔK_th (below this, no growth).
1063    pub delta_k_threshold: f64,
1064    /// Fracture toughness K_c (above this, fast fracture).
1065    pub k_fracture: f64,
1066}
1067#[allow(dead_code)]
1068impl ParisLaw {
1069    /// Create a new Paris law model.
1070    pub fn new(c: f64, m: f64, delta_k_threshold: f64, k_fracture: f64) -> Self {
1071        Self {
1072            c,
1073            m,
1074            delta_k_threshold,
1075            k_fracture,
1076        }
1077    }
1078    /// Crack growth rate da/dN for a given ΔK.
1079    ///
1080    /// Returns 0.0 if ΔK < ΔK_th, returns f64::INFINITY if ΔK ≥ K_c.
1081    pub fn crack_growth_rate(&self, delta_k: f64) -> f64 {
1082        if delta_k < self.delta_k_threshold {
1083            return 0.0;
1084        }
1085        if delta_k >= self.k_fracture {
1086            return f64::INFINITY;
1087        }
1088        self.c * delta_k.powf(self.m)
1089    }
1090    /// Integrate crack growth from initial crack size a_0 to final a_f.
1091    ///
1092    /// Uses simple forward Euler integration:
1093    /// a_new = a + (da/dN) * Δcycles
1094    ///
1095    /// Returns the number of cycles to grow from a_0 to a_f,
1096    /// or `None` if fast fracture occurs.
1097    pub fn cycles_to_grow(
1098        &self,
1099        a_initial: f64,
1100        a_final: f64,
1101        delta_k_fn: &impl Fn(f64) -> f64,
1102        da_step: f64,
1103    ) -> Option<f64> {
1104        let mut a = a_initial;
1105        let mut n_cycles = 0.0_f64;
1106        while a < a_final {
1107            let delta_k = delta_k_fn(a);
1108            let da_dn = self.crack_growth_rate(delta_k);
1109            if da_dn.is_infinite() || da_dn <= 0.0 {
1110                return if da_dn.is_infinite() {
1111                    None
1112                } else {
1113                    Some(f64::INFINITY)
1114                };
1115            }
1116            let dn = da_step / da_dn;
1117            n_cycles += dn;
1118            a += da_step;
1119        }
1120        Some(n_cycles)
1121    }
1122    /// Stress intensity factor range for a center crack in an infinite plate.
1123    ///
1124    /// ΔK = Δσ * √(π * a) * Y
1125    /// where Y is the geometry factor (usually ≈ 1.0 for simple cases).
1126    pub fn delta_k_center_crack(delta_sigma: f64, a: f64, y: f64) -> f64 {
1127        delta_sigma * (std::f64::consts::PI * a).sqrt() * y
1128    }
1129    /// NASGRO modified Paris law including crack closure.
1130    ///
1131    /// da/dN = C * ((1-f)/(1-R))^m * (ΔK)^m
1132    /// where f is the crack opening function and R is the stress ratio.
1133    pub fn nasgro_rate(&self, delta_k: f64, r_ratio: f64, crack_opening_f: f64) -> f64 {
1134        let effective_dk = delta_k * (1.0 - crack_opening_f) / (1.0 - r_ratio).max(0.01);
1135        self.crack_growth_rate(effective_dk)
1136    }
1137}
1138/// Modified Goodman diagram for mean stress correction.
1139///
1140/// Accounts for the effect of non-zero mean stress on fatigue life.
1141#[derive(Debug, Clone)]
1142#[allow(dead_code)]
1143pub struct GoodmanDiagram {
1144    /// Ultimate tensile strength σ_u (Pa)
1145    pub ultimate_strength: f64,
1146    /// Yield strength σ_ys (Pa)
1147    pub yield_strength: f64,
1148}
1149impl GoodmanDiagram {
1150    /// Create a new Goodman diagram.
1151    ///
1152    /// # Arguments
1153    /// * `uts` - Ultimate tensile strength (Pa)
1154    /// * `ys`  - Yield strength (Pa)
1155    pub fn new(uts: f64, ys: f64) -> Self {
1156        Self {
1157            ultimate_strength: uts,
1158            yield_strength: ys,
1159        }
1160    }
1161    /// Allowable stress amplitude via modified Goodman criterion.
1162    ///
1163    /// σ_a = σ_e * (1 - σ_m/σ_u) / FS
1164    ///
1165    /// where σ_e is the fully-reversed endurance limit.
1166    ///
1167    /// # Arguments
1168    /// * `mean_stress`      - Mean stress σ_m (Pa)
1169    /// * `factor_of_safety` - Safety factor FS (> 0)
1170    pub fn allowable_amplitude(
1171        &self,
1172        mean_stress: f64,
1173        endurance: f64,
1174        factor_of_safety: f64,
1175    ) -> f64 {
1176        endurance * (1.0 - mean_stress / self.ultimate_strength) / factor_of_safety
1177    }
1178    /// Allowable stress amplitude via Soderberg criterion (uses yield strength).
1179    ///
1180    /// σ_a = σ_e * (1 - σ_m/σ_ys)
1181    ///
1182    /// # Arguments
1183    /// * `mean_stress` - Mean stress σ_m (Pa)
1184    /// * `endurance`   - Fully-reversed endurance limit σ_e (Pa)
1185    pub fn soderberg_amplitude(&self, mean_stress: f64, endurance: f64) -> f64 {
1186        endurance * (1.0 - mean_stress / self.yield_strength)
1187    }
1188    /// Check whether a (mean_stress, amplitude) point is safe by Goodman criterion.
1189    ///
1190    /// Safe if: σ_a/σ_e + σ_m/σ_u ≤ 1
1191    ///
1192    /// # Arguments
1193    /// * `mean_stress` - Mean stress σ_m (Pa)
1194    /// * `amplitude`   - Stress amplitude σ_a (Pa)
1195    /// * `endurance`   - Fully-reversed endurance limit σ_e (Pa)
1196    pub fn is_safe(&self, mean_stress: f64, amplitude: f64, endurance: f64) -> bool {
1197        amplitude / endurance + mean_stress / self.ultimate_strength <= 1.0
1198    }
1199    /// Compute the Goodman equivalent fully-reversed stress amplitude.
1200    ///
1201    /// σ_ar = σ_a / (1 - σ_m / σ_u)
1202    ///
1203    /// This is the equivalent fully-reversed stress amplitude that produces
1204    /// the same fatigue damage as the given (σ_a, σ_m) combination.
1205    #[allow(dead_code)]
1206    pub fn equivalent_amplitude(&self, mean_stress: f64, amplitude: f64) -> f64 {
1207        let denom = 1.0 - mean_stress / self.ultimate_strength;
1208        if denom <= 0.0 {
1209            return f64::INFINITY;
1210        }
1211        amplitude / denom
1212    }
1213    /// Factor of safety for a given (mean, amplitude) point.
1214    ///
1215    /// FS = 1 / (σ_a/σ_e + σ_m/σ_u)
1216    #[allow(dead_code)]
1217    pub fn factor_of_safety(&self, mean_stress: f64, amplitude: f64, endurance: f64) -> f64 {
1218        let ratio = amplitude / endurance + mean_stress / self.ultimate_strength;
1219        if ratio <= 0.0 {
1220            return f64::INFINITY;
1221        }
1222        1.0 / ratio
1223    }
1224}
1225/// Damage tolerance analysis result.
1226#[allow(dead_code)]
1227#[derive(Debug, Clone)]
1228pub struct DamageToleranceResult {
1229    /// Initial crack size.
1230    pub a_initial: f64,
1231    /// Critical crack size (fracture).
1232    pub a_critical: f64,
1233    /// Inspection interval (cycles).
1234    pub inspection_interval: f64,
1235    /// Cycles to critical crack size.
1236    pub cycles_to_critical: Option<f64>,
1237    /// Safety factor on life.
1238    pub safety_factor: f64,
1239}
1240#[allow(dead_code)]
1241impl DamageToleranceResult {
1242    /// Whether the component is safe (cycles_to_critical > inspection_interval * safety_factor).
1243    pub fn is_safe(&self) -> bool {
1244        if let Some(cycles) = self.cycles_to_critical {
1245            cycles > self.inspection_interval * self.safety_factor
1246        } else {
1247            false
1248        }
1249    }
1250    /// Remaining useful life (cycles) given current cycle count.
1251    pub fn remaining_life(&self, current_cycles: f64) -> f64 {
1252        match self.cycles_to_critical {
1253            Some(total) => (total - current_cycles).max(0.0),
1254            None => 0.0,
1255        }
1256    }
1257}
1258/// Gerber diagram defined by ultimate strength and endurance limit.
1259///
1260/// Allowable amplitude: σ_a = σ_e * (1 - (σ_m / σ_ult)^2)
1261#[derive(Debug, Clone)]
1262#[allow(dead_code)]
1263pub struct GerberDiagram {
1264    /// Ultimate tensile strength σ_ult (Pa).
1265    pub sigma_ult: f64,
1266    /// Fully-reversed endurance limit σ_e (Pa).
1267    pub sigma_endurance: f64,
1268}
1269#[allow(dead_code)]
1270impl GerberDiagram {
1271    /// Create a new Gerber diagram.
1272    pub fn new(sigma_ult: f64, sigma_endurance: f64) -> Self {
1273        Self {
1274            sigma_ult,
1275            sigma_endurance,
1276        }
1277    }
1278    /// Allowable stress amplitude for a given mean stress (Gerber parabola).
1279    ///
1280    /// σ_a_allow = σ_e * (1 - (σ_m / σ_ult)^2)  \[clamped to 0\]
1281    pub fn gerber_allowed_amplitude(&self, mean_stress: f64) -> f64 {
1282        let ratio = mean_stress / self.sigma_ult;
1283        (self.sigma_endurance * (1.0 - ratio * ratio)).max(0.0)
1284    }
1285}