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}