Skip to main content

oxiphysics_materials/
composite_failure.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Composite material failure criteria and progressive damage models.
5//!
6//! Provides:
7//! - [`MaxStressCriterion`] — longitudinal/transverse/shear failure indices for UD plies
8//! - [`TsaiHillCriterion`] — Tsai-Hill failure surface, load factor, safety margin
9//! - [`TsaiWuCriterion`] — Tsai-Wu tensor criterion, interaction coefficient, biaxial loading
10//! - [`PuckCriterion`] — fiber fracture (FF) and inter-fiber fracture (IFF) modes
11//! - [`ProgressiveDamage`] — stiffness degradation, ply-by-ply failure, last-ply failure
12//! - [`DelamCriterion`] — quadratic delamination, ERR (G_I/G_II/G_III), mixed-mode B-K law
13
14#![allow(dead_code)]
15#![allow(clippy::too_many_arguments)]
16
17use std::f64::consts::PI;
18
19// ---------------------------------------------------------------------------
20// Stress state
21// ---------------------------------------------------------------------------
22
23/// In-plane stress state of a unidirectional ply in principal material axes.
24///
25/// Axes: 1 = fiber direction, 2 = transverse in-plane, 6 = in-plane shear.
26#[derive(Debug, Clone, Copy)]
27pub struct PlaneStress {
28    /// Normal stress in fiber direction σ₁ (Pa).
29    pub sigma1: f64,
30    /// Normal stress transverse to fiber σ₂ (Pa).
31    pub sigma2: f64,
32    /// In-plane shear stress τ₁₂ (Pa).
33    pub tau12: f64,
34}
35
36impl PlaneStress {
37    /// Creates a new plane stress state.
38    pub fn new(sigma1: f64, sigma2: f64, tau12: f64) -> Self {
39        Self {
40            sigma1,
41            sigma2,
42            tau12,
43        }
44    }
45
46    /// Returns a zero stress state.
47    pub fn zero() -> Self {
48        Self {
49            sigma1: 0.0,
50            sigma2: 0.0,
51            tau12: 0.0,
52        }
53    }
54
55    /// Scales all stress components by a factor.
56    pub fn scaled(self, factor: f64) -> Self {
57        Self {
58            sigma1: self.sigma1 * factor,
59            sigma2: self.sigma2 * factor,
60            tau12: self.tau12 * factor,
61        }
62    }
63}
64
65/// Interlaminar stress components at a ply interface.
66#[derive(Debug, Clone, Copy)]
67pub struct InterlaminaStress {
68    /// Through-thickness normal stress σ_z (Pa).
69    pub sigma_z: f64,
70    /// Interlaminar shear stress τ_xz (Pa).
71    pub tau_xz: f64,
72    /// Interlaminar shear stress τ_yz (Pa).
73    pub tau_yz: f64,
74}
75
76impl InterlaminaStress {
77    /// Creates a new interlaminar stress state.
78    pub fn new(sigma_z: f64, tau_xz: f64, tau_yz: f64) -> Self {
79        Self {
80            sigma_z,
81            tau_xz,
82            tau_yz,
83        }
84    }
85}
86
87// ---------------------------------------------------------------------------
88// Ply strength properties
89// ---------------------------------------------------------------------------
90
91/// Strength properties of a unidirectional composite ply.
92#[derive(Debug, Clone)]
93pub struct PlyStrength {
94    /// Longitudinal tensile strength F₁ᵗ (Pa).
95    pub f1t: f64,
96    /// Longitudinal compressive strength F₁c (Pa, positive value).
97    pub f1c: f64,
98    /// Transverse tensile strength F₂ᵗ (Pa).
99    pub f2t: f64,
100    /// Transverse compressive strength F₂c (Pa, positive value).
101    pub f2c: f64,
102    /// In-plane shear strength F₁₂ (Pa).
103    pub f12: f64,
104    /// Through-thickness tensile strength F_zt (Pa, for delamination).
105    pub fzt: f64,
106    /// Through-thickness shear strength F_zs (Pa, for delamination).
107    pub fzs: f64,
108}
109
110impl PlyStrength {
111    /// Returns typical strength values for a carbon/epoxy UD ply (T300/914).
112    pub fn carbon_epoxy_t300() -> Self {
113        Self {
114            f1t: 1500.0e6,
115            f1c: 900.0e6,
116            f2t: 50.0e6,
117            f2c: 200.0e6,
118            f12: 70.0e6,
119            fzt: 50.0e6,
120            fzs: 100.0e6,
121        }
122    }
123
124    /// Returns typical strength values for glass/epoxy UD ply (E-glass/epoxy).
125    pub fn glass_epoxy() -> Self {
126        Self {
127            f1t: 780.0e6,
128            f1c: 500.0e6,
129            f2t: 28.0e6,
130            f2c: 130.0e6,
131            f12: 50.0e6,
132            fzt: 28.0e6,
133            fzs: 80.0e6,
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// MaxStressCriterion
140// ---------------------------------------------------------------------------
141
142/// Maximum stress failure criterion for a unidirectional ply.
143///
144/// Failure occurs when any stress component exceeds its corresponding
145/// allowable strength.  The failure index for each mode is stress/strength;
146/// the ply fails when any index ≥ 1.
147#[derive(Debug, Clone)]
148pub struct MaxStressCriterion {
149    /// Ply strength properties.
150    pub strength: PlyStrength,
151}
152
153impl MaxStressCriterion {
154    /// Constructs a new MaxStressCriterion with the given strength.
155    pub fn new(strength: PlyStrength) -> Self {
156        Self { strength }
157    }
158
159    /// Computes the longitudinal failure index (tension or compression).
160    pub fn fi_longitudinal(&self, stress: PlaneStress) -> f64 {
161        if stress.sigma1 >= 0.0 {
162            stress.sigma1 / self.strength.f1t
163        } else {
164            stress.sigma1.abs() / self.strength.f1c
165        }
166    }
167
168    /// Computes the transverse failure index (tension or compression).
169    pub fn fi_transverse(&self, stress: PlaneStress) -> f64 {
170        if stress.sigma2 >= 0.0 {
171            stress.sigma2 / self.strength.f2t
172        } else {
173            stress.sigma2.abs() / self.strength.f2c
174        }
175    }
176
177    /// Computes the shear failure index.
178    pub fn fi_shear(&self, stress: PlaneStress) -> f64 {
179        stress.tau12.abs() / self.strength.f12
180    }
181
182    /// Returns all three failure indices as \[FI_1, FI_2, FI_12\].
183    pub fn failure_indices(&self, stress: PlaneStress) -> [f64; 3] {
184        [
185            self.fi_longitudinal(stress),
186            self.fi_transverse(stress),
187            self.fi_shear(stress),
188        ]
189    }
190
191    /// Returns the maximum failure index (governing mode).
192    pub fn max_fi(&self, stress: PlaneStress) -> f64 {
193        let fis = self.failure_indices(stress);
194        fis.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
195    }
196
197    /// Returns `true` if any failure index ≥ 1 (first-ply failure).
198    pub fn has_failed(&self, stress: PlaneStress) -> bool {
199        self.max_fi(stress) >= 1.0
200    }
201
202    /// Computes the load factor — the factor by which the stress must be
203    /// multiplied to reach first failure (inverse of max FI).
204    pub fn load_factor(&self, stress: PlaneStress) -> f64 {
205        let max = self.max_fi(stress);
206        if max <= 0.0 { f64::INFINITY } else { 1.0 / max }
207    }
208}
209
210// ---------------------------------------------------------------------------
211// TsaiHillCriterion
212// ---------------------------------------------------------------------------
213
214/// Tsai-Hill failure criterion for a unidirectional ply.
215///
216/// The quadratic failure surface:
217/// (σ₁/F₁)² − σ₁σ₂/F₁² + (σ₂/F₂)² + (τ₁₂/F₁₂)² = 1
218///
219/// where F₁ and F₂ depend on the sign of σ₁ and σ₂.
220#[derive(Debug, Clone)]
221pub struct TsaiHillCriterion {
222    /// Ply strength properties.
223    pub strength: PlyStrength,
224}
225
226impl TsaiHillCriterion {
227    /// Constructs a TsaiHillCriterion.
228    pub fn new(strength: PlyStrength) -> Self {
229        Self { strength }
230    }
231
232    /// Computes the Tsai-Hill failure index H² (dimensionless).
233    ///
234    /// Failure occurs when H² ≥ 1.
235    pub fn failure_index_sq(&self, stress: PlaneStress) -> f64 {
236        let f1 = if stress.sigma1 >= 0.0 {
237            self.strength.f1t
238        } else {
239            self.strength.f1c
240        };
241        let f2 = if stress.sigma2 >= 0.0 {
242            self.strength.f2t
243        } else {
244            self.strength.f2c
245        };
246        let s1 = stress.sigma1 / f1;
247        let s2 = stress.sigma2 / f2;
248        let s12 = stress.tau12 / self.strength.f12;
249        s1 * s1 - s1 * s2 + s2 * s2 + s12 * s12
250    }
251
252    /// Returns the Tsai-Hill failure index H (= √H²).
253    pub fn failure_index(&self, stress: PlaneStress) -> f64 {
254        self.failure_index_sq(stress).sqrt()
255    }
256
257    /// Returns `true` if Tsai-Hill criterion is satisfied (failure H² ≥ 1).
258    pub fn has_failed(&self, stress: PlaneStress) -> bool {
259        self.failure_index_sq(stress) >= 1.0
260    }
261
262    /// Computes the safety margin R = 1/H (R > 1 means no failure).
263    pub fn safety_margin(&self, stress: PlaneStress) -> f64 {
264        let h = self.failure_index(stress);
265        if h <= 0.0 { f64::INFINITY } else { 1.0 / h }
266    }
267
268    /// Computes the load factor RF such that stress × RF is at the failure surface.
269    ///
270    /// From H²(RF·σ) = 1:  RF = 1 / √H²(σ)
271    pub fn load_factor(&self, stress: PlaneStress) -> f64 {
272        let h2 = self.failure_index_sq(stress);
273        if h2 <= 0.0 {
274            f64::INFINITY
275        } else {
276            1.0 / h2.sqrt()
277        }
278    }
279
280    /// Finds the biaxial strength envelope at a given stress ratio σ₂/σ₁ = k.
281    ///
282    /// Returns σ₁ at failure.
283    pub fn biaxial_strength(&self, k: f64) -> f64 {
284        // H²(σ₁, k·σ₁, 0) = 1
285        // A σ₁² = 1  →  σ₁ = 1/√A
286        let f1 = self.strength.f1t.max(self.strength.f1c);
287        let f2 = self.strength.f2t.max(self.strength.f2c);
288        let a = 1.0 / (f1 * f1) - k / (f1 * f1) + k * k / (f2 * f2);
289        if a <= 0.0 {
290            f64::INFINITY
291        } else {
292            1.0 / a.sqrt()
293        }
294    }
295}
296
297// ---------------------------------------------------------------------------
298// TsaiWuCriterion
299// ---------------------------------------------------------------------------
300
301/// Tsai-Wu tensor failure criterion for unidirectional composites.
302///
303/// F_i σ_i + F_ij σ_i σ_j = 1  (Einstein summation, i,j = 1,2,6)
304///
305/// where the strength tensors are determined from uniaxial and shear tests.
306#[derive(Debug, Clone)]
307pub struct TsaiWuCriterion {
308    /// Ply strength properties.
309    pub strength: PlyStrength,
310    /// Biaxial interaction coefficient F₁₂* (default −0.5 based on Tsai-Wu normalization).
311    pub f12_star: f64,
312}
313
314impl TsaiWuCriterion {
315    /// Constructs a TsaiWuCriterion with the given strength and interaction coefficient.
316    pub fn new(strength: PlyStrength, f12_star: f64) -> Self {
317        Self { strength, f12_star }
318    }
319
320    /// Returns the first-order tensor components \[F₁, F₂\].
321    pub fn linear_terms(&self) -> [f64; 2] {
322        let f1 = 1.0 / self.strength.f1t - 1.0 / self.strength.f1c;
323        let f2 = 1.0 / self.strength.f2t - 1.0 / self.strength.f2c;
324        [f1, f2]
325    }
326
327    /// Returns the second-order tensor components \[F₁₁, F₂₂, F₆₆, F₁₂\].
328    pub fn quadratic_terms(&self) -> [f64; 4] {
329        let f11 = 1.0 / (self.strength.f1t * self.strength.f1c);
330        let f22 = 1.0 / (self.strength.f2t * self.strength.f2c);
331        let f66 = 1.0 / (self.strength.f12 * self.strength.f12);
332        let f12 = self.f12_star
333            / (self.strength.f1t * self.strength.f1c * self.strength.f2t * self.strength.f2c)
334                .sqrt();
335        [f11, f22, f66, f12]
336    }
337
338    /// Computes the Tsai-Wu failure index (scalar value; failure when ≥ 1).
339    pub fn failure_index(&self, stress: PlaneStress) -> f64 {
340        let [f1, f2] = self.linear_terms();
341        let [f11, f22, f66, f12] = self.quadratic_terms();
342        let s1 = stress.sigma1;
343        let s2 = stress.sigma2;
344        let s12 = stress.tau12;
345        f1 * s1 + f2 * s2 + f11 * s1 * s1 + f22 * s2 * s2 + f66 * s12 * s12 + 2.0 * f12 * s1 * s2
346    }
347
348    /// Returns `true` if the Tsai-Wu criterion is violated.
349    pub fn has_failed(&self, stress: PlaneStress) -> bool {
350        self.failure_index(stress) >= 1.0
351    }
352
353    /// Computes the load factor RF using the quadratic formula.
354    ///
355    /// RF is the positive root of: H(RF·σ) = 1.
356    pub fn load_factor(&self, stress: PlaneStress) -> f64 {
357        let [f1, f2] = self.linear_terms();
358        let [f11, f22, f66, f12] = self.quadratic_terms();
359        let s1 = stress.sigma1;
360        let s2 = stress.sigma2;
361        let s12 = stress.tau12;
362        // A RF² + B RF − 1 = 0
363        let a = f11 * s1 * s1 + f22 * s2 * s2 + f66 * s12 * s12 + 2.0 * f12 * s1 * s2;
364        let b = f1 * s1 + f2 * s2;
365        if a.abs() < 1e-40 {
366            if b.abs() < 1e-40 {
367                return f64::INFINITY;
368            }
369            return 1.0 / b;
370        }
371        let disc = b * b + 4.0 * a;
372        if disc < 0.0 {
373            return f64::INFINITY;
374        }
375        (-b + disc.sqrt()) / (2.0 * a)
376    }
377
378    /// Checks the stability condition |F₁₂| < √(F₁₁ F₂₂).
379    pub fn is_stable(&self) -> bool {
380        let [f11, f22, _, f12] = self.quadratic_terms();
381        f12.abs() < (f11 * f22).sqrt()
382    }
383}
384
385// ---------------------------------------------------------------------------
386// PuckCriterion
387// ---------------------------------------------------------------------------
388
389/// Puck fiber fracture (FF) mode indicator.
390#[derive(Debug, Clone, Copy, PartialEq)]
391pub enum PuckFfMode {
392    /// No fiber fracture.
393    None,
394    /// Fiber fracture in tension (σ₁ > 0).
395    Tension,
396    /// Fiber fracture in compression (σ₁ < 0) — kinking / splitting.
397    Compression,
398}
399
400/// Puck inter-fiber fracture (IFF) mode.
401#[derive(Debug, Clone, Copy, PartialEq)]
402pub enum PuckIffMode {
403    /// No IFF.
404    None,
405    /// Mode A — transverse tension fracture plane ψ = 0°.
406    ModeA,
407    /// Mode B — in-plane shear dominated, fracture angle ψ ≈ 0°.
408    ModeB,
409    /// Mode C — transverse compression + shear, oblique fracture plane.
410    ModeC,
411}
412
413/// Puck action-plane failure criterion for unidirectional composites.
414///
415/// Separates fiber fracture (FF) from inter-fiber fracture (IFF) and
416/// identifies the fracture plane orientation.
417#[derive(Debug, Clone)]
418pub struct PuckCriterion {
419    /// Ply strength properties.
420    pub strength: PlyStrength,
421    /// Inclination parameter p₊₁₂ for IFF Mode A (≈ 0.30 for C/E).
422    pub p_plus_12: f64,
423    /// Inclination parameter p₋₁₂ for IFF Modes B/C (≈ 0.25 for C/E).
424    pub p_minus_12: f64,
425    /// Fiber modulus parallel E₁ (Pa).
426    pub e1: f64,
427    /// Fiber Poisson's ratio ν₁₂.
428    pub nu12: f64,
429}
430
431impl PuckCriterion {
432    /// Constructs a PuckCriterion with given material parameters.
433    pub fn new(strength: PlyStrength, p_plus_12: f64, p_minus_12: f64, e1: f64, nu12: f64) -> Self {
434        Self {
435            strength,
436            p_plus_12,
437            p_minus_12,
438            e1,
439            nu12,
440        }
441    }
442
443    /// Returns default Puck parameters for carbon/epoxy T300/914.
444    pub fn carbon_epoxy_default() -> Self {
445        Self::new(PlyStrength::carbon_epoxy_t300(), 0.30, 0.25, 130.0e9, 0.28)
446    }
447
448    /// Computes the fiber fracture (FF) exposure f_E,FF.
449    ///
450    /// f_E,FF < 1 → no FF;  f_E,FF ≥ 1 → fiber fracture.
451    pub fn ff_exposure(&self, stress: PlaneStress) -> f64 {
452        if stress.sigma1 >= 0.0 {
453            // tension
454            (stress.sigma1 / self.strength.f1t).abs()
455        } else {
456            // compression — kink band criterion
457            (stress.sigma1.abs() / self.strength.f1c).abs()
458        }
459    }
460
461    /// Returns the FF mode.
462    pub fn ff_mode(&self, stress: PlaneStress) -> PuckFfMode {
463        let fe = self.ff_exposure(stress);
464        if fe < 1.0 {
465            PuckFfMode::None
466        } else if stress.sigma1 >= 0.0 {
467            PuckFfMode::Tension
468        } else {
469            PuckFfMode::Compression
470        }
471    }
472
473    /// Computes the IFF exposure on the action plane at ψ = 0° (simplified).
474    ///
475    /// For Mode A (σ_n > 0):
476    /// f_E,IFF = √\[(τ_nt/F_2A)² + (σ_n/F_2t)²\] + p₊₁₂ σ_n / F_2A
477    ///
478    /// Uses simplified 2-D (in-plane) version.
479    pub fn iff_exposure_mode_a(&self, stress: PlaneStress) -> f64 {
480        if stress.sigma2 < 0.0 {
481            return 0.0;
482        }
483        let s2 = stress.sigma2;
484        let s12 = stress.tau12;
485        let f2a = self.strength.f12; // approximate action-plane shear strength
486        let f2t = self.strength.f2t;
487        let sq = ((s12 / f2a).powi(2) + (s2 / f2t).powi(2)).sqrt();
488        sq + self.p_plus_12 * s2 / f2a
489    }
490
491    /// Computes the IFF exposure for Mode B/C (σ_n ≤ 0).
492    ///
493    /// f_E,IFF = (1/F_2A) √\[τ_nt² + (p₋₁₂ σ_n)²\] + p₋₁₂ σ_n / F_2A
494    pub fn iff_exposure_mode_bc(&self, stress: PlaneStress) -> f64 {
495        if stress.sigma2 >= 0.0 {
496            return 0.0;
497        }
498        let s2 = stress.sigma2; // negative
499        let s12 = stress.tau12;
500        let f2a = self.strength.f12;
501        let sq = (s12 * s12 + (self.p_minus_12 * s2).powi(2)).sqrt();
502        (sq + self.p_minus_12 * s2) / f2a
503    }
504
505    /// Returns the maximum IFF exposure considering all modes.
506    pub fn iff_exposure(&self, stress: PlaneStress) -> f64 {
507        let fa = self.iff_exposure_mode_a(stress);
508        let fbc = self.iff_exposure_mode_bc(stress);
509        fa.max(fbc)
510    }
511
512    /// Identifies the IFF mode at the given stress state.
513    pub fn iff_mode(&self, stress: PlaneStress) -> PuckIffMode {
514        if self.iff_exposure(stress) < 1.0 {
515            return PuckIffMode::None;
516        }
517        if stress.sigma2 >= 0.0 {
518            PuckIffMode::ModeA
519        } else {
520            // Distinguish B vs C by shear dominance
521            if stress.tau12.abs() > self.strength.f12 * 0.5 {
522                PuckIffMode::ModeB
523            } else {
524                PuckIffMode::ModeC
525            }
526        }
527    }
528
529    /// Returns `true` if either FF or IFF criterion is violated.
530    pub fn has_failed(&self, stress: PlaneStress) -> bool {
531        self.ff_exposure(stress) >= 1.0 || self.iff_exposure(stress) >= 1.0
532    }
533
534    /// Estimates the fracture plane angle θ_fp (degrees) for IFF Mode C.
535    ///
536    /// θ_fp ≈ arccos(F₂c / (2·F₂c + p₋₁₂·|σ₂|))  (approximate Puck formula)
537    pub fn fracture_plane_angle(&self, stress: PlaneStress) -> f64 {
538        if stress.sigma2 >= 0.0 {
539            return 0.0; // Mode A: fracture perpendicular to load
540        }
541        let denom = 2.0 * self.strength.f2c + self.p_minus_12 * stress.sigma2.abs();
542        if denom < 1e-20 {
543            return 0.0;
544        }
545        (self.strength.f2c / denom).acos().to_degrees()
546    }
547}
548
549// ---------------------------------------------------------------------------
550// ProgressiveDamage
551// ---------------------------------------------------------------------------
552
553/// Stiffness degradation rule applied after ply failure.
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub enum DegradationRule {
556    /// Total degradation — set failed stiffness components to near zero.
557    Total,
558    /// Partial degradation — reduce by a factor (e.g., 10%).
559    Partial,
560    /// No degradation — track failure but keep original stiffness.
561    None,
562}
563
564/// State of a single ply in a laminate.
565#[derive(Debug, Clone)]
566pub struct PlyState {
567    /// Ply angle (degrees from laminate x-axis).
568    pub angle_deg: f64,
569    /// Ply thickness (m).
570    pub thickness: f64,
571    /// In-plane stiffness matrix Q \[Q11, Q22, Q12, Q66\] (Pa).
572    pub q_matrix: [f64; 4],
573    /// Whether fiber fracture has occurred.
574    pub ff_failed: bool,
575    /// Whether inter-fiber fracture has occurred.
576    pub iff_failed: bool,
577    /// Degradation factor applied (0..1, 1 = no degradation).
578    pub degradation: f64,
579}
580
581impl PlyState {
582    /// Constructs a PlyState from elastic constants.
583    ///
584    /// E₁, E₂ (Pa), ν₁₂, G₁₂ (Pa).
585    pub fn new(angle_deg: f64, thickness: f64, e1: f64, e2: f64, nu12: f64, g12: f64) -> Self {
586        let nu21 = nu12 * e2 / e1;
587        let denom = 1.0 - nu12 * nu21;
588        let q11 = e1 / denom;
589        let q22 = e2 / denom;
590        let q12 = nu12 * e2 / denom;
591        let q66 = g12;
592        Self {
593            angle_deg,
594            thickness,
595            q_matrix: [q11, q22, q12, q66],
596            ff_failed: false,
597            iff_failed: false,
598            degradation: 1.0,
599        }
600    }
601
602    /// Applies stiffness degradation according to rule and failure mode.
603    pub fn apply_degradation(&mut self, rule: DegradationRule, ff: bool, iff: bool) {
604        self.ff_failed |= ff;
605        self.iff_failed |= iff;
606        self.degradation = match rule {
607            DegradationRule::Total => {
608                if ff {
609                    0.01
610                } else if iff {
611                    0.1
612                } else {
613                    1.0
614                }
615            }
616            DegradationRule::Partial => {
617                if ff {
618                    0.5
619                } else if iff {
620                    0.7
621                } else {
622                    1.0
623                }
624            }
625            DegradationRule::None => 1.0,
626        };
627        for q in &mut self.q_matrix {
628            *q *= self.degradation;
629        }
630    }
631
632    /// Returns the effective Q₁₁ after degradation.
633    pub fn q11(&self) -> f64 {
634        self.q_matrix[0]
635    }
636
637    /// Returns the effective Q₂₂ after degradation.
638    pub fn q22(&self) -> f64 {
639        self.q_matrix[1]
640    }
641
642    /// Returns the effective Q₁₂ after degradation.
643    pub fn q12(&self) -> f64 {
644        self.q_matrix[2]
645    }
646
647    /// Returns the effective Q₆₆ after degradation.
648    pub fn q66(&self) -> f64 {
649        self.q_matrix[3]
650    }
651}
652
653/// Progressive damage analysis of a composite laminate.
654///
655/// Performs ply-by-ply failure analysis using the chosen criterion,
656/// applies stiffness degradation, and tracks first-ply failure (FPF)
657/// and last-ply failure (LPF) loads.
658#[derive(Debug, Clone)]
659pub struct ProgressiveDamage {
660    /// Ordered list of ply states.
661    pub plies: Vec<PlyState>,
662    /// Degradation rule applied when a ply fails.
663    pub rule: DegradationRule,
664    /// Strength per ply.
665    pub strengths: Vec<PlyStrength>,
666    /// Applied load multiplier at FPF (0 = not yet reached).
667    pub fpf_load: f64,
668    /// Applied load multiplier at LPF (0 = not yet reached).
669    pub lpf_load: f64,
670}
671
672impl ProgressiveDamage {
673    /// Constructs a ProgressiveDamage model from ply states, strengths, and rule.
674    pub fn new(plies: Vec<PlyState>, strengths: Vec<PlyStrength>, rule: DegradationRule) -> Self {
675        Self {
676            plies,
677            rule,
678            strengths,
679            fpf_load: 0.0,
680            lpf_load: 0.0,
681        }
682    }
683
684    /// Returns the number of intact (unfailed) plies.
685    pub fn n_intact(&self) -> usize {
686        self.plies
687            .iter()
688            .filter(|p| !p.ff_failed && !p.iff_failed)
689            .count()
690    }
691
692    /// Returns `true` if all plies have failed (last-ply failure).
693    pub fn all_failed(&self) -> bool {
694        self.plies.iter().all(|p| p.ff_failed || p.iff_failed)
695    }
696
697    /// Computes the laminate ABD A-matrix (membrane stiffness, 3×3) using CLT.
698    ///
699    /// Returns \[A11, A22, A12, A16, A26, A66\] (Pa·m).
700    pub fn a_matrix(&self) -> [f64; 6] {
701        let mut a = [0.0_f64; 6];
702        for ply in &self.plies {
703            let t = ply.thickness * ply.degradation;
704            let q = transformed_q(&ply.q_matrix, ply.angle_deg.to_radians());
705            a[0] += q[0] * t;
706            a[1] += q[1] * t;
707            a[2] += q[2] * t;
708            a[3] += q[3] * t;
709            a[4] += q[4] * t;
710            a[5] += q[5] * t;
711        }
712        a
713    }
714
715    /// Performs a progressive failure sweep at a given load vector \[N₁, N₂, N₆\] (Pa·m).
716    ///
717    /// Returns the load factor at first-ply failure and updates ply states.
718    pub fn progressive_failure_sweep(&mut self, applied_load: [f64; 3], n_steps: usize) -> f64 {
719        let load_max = 2.0; // sweep load factor from 0 to 2
720        let d_lambda = load_max / n_steps as f64;
721        let mut first_failure_lambda = f64::INFINITY;
722
723        for step in 1..=n_steps {
724            let lambda = step as f64 * d_lambda;
725            let n = [
726                applied_load[0] * lambda,
727                applied_load[1] * lambda,
728                applied_load[2] * lambda,
729            ];
730            // Compute average in-plane strains from A-matrix (simplified: isotropic approx)
731            let a = self.a_matrix();
732            let total_t: f64 = self
733                .plies
734                .iter()
735                .map(|p| p.thickness)
736                .sum::<f64>()
737                .max(1e-12);
738            let eps1 = n[0] / (a[0].max(1e-12) * total_t);
739            let eps2 = n[1] / (a[1].max(1e-12) * total_t);
740            let gam12 = n[2] / (a[5].max(1e-12) * total_t);
741
742            let mut any_new_failure = false;
743            for (i, ply) in self.plies.iter_mut().enumerate() {
744                if ply.ff_failed && ply.iff_failed {
745                    continue;
746                }
747                // Transform strains to ply axes and compute stress
748                let theta = ply.angle_deg.to_radians();
749                let c = theta.cos();
750                let s = theta.sin();
751                let eps1_ply = c * c * eps1 + s * s * eps2 + c * s * gam12;
752                let eps2_ply = s * s * eps1 + c * c * eps2 - c * s * gam12;
753                let gam12_ply = -2.0 * c * s * eps1 + 2.0 * c * s * eps2 + (c * c - s * s) * gam12;
754                let q = &ply.q_matrix;
755                let stress = PlaneStress::new(
756                    q[0] * eps1_ply + q[2] * eps2_ply,
757                    q[2] * eps1_ply + q[1] * eps2_ply,
758                    q[3] * gam12_ply,
759                );
760                if i < self.strengths.len() {
761                    let crit = MaxStressCriterion::new(self.strengths[i].clone());
762                    if crit.has_failed(stress) && !ply.ff_failed {
763                        ply.ff_failed = true;
764                        ply.degradation = match self.rule {
765                            DegradationRule::Total => 0.01,
766                            DegradationRule::Partial => 0.5,
767                            DegradationRule::None => 1.0,
768                        };
769                        any_new_failure = true;
770                        if first_failure_lambda > lambda {
771                            first_failure_lambda = lambda;
772                        }
773                    }
774                }
775            }
776            let _ = any_new_failure;
777            if self.all_failed() {
778                self.lpf_load = lambda;
779                break;
780            }
781        }
782        if first_failure_lambda < f64::INFINITY {
783            self.fpf_load = first_failure_lambda;
784        }
785        first_failure_lambda
786    }
787}
788
789// ---------------------------------------------------------------------------
790// DelamCriterion
791// ---------------------------------------------------------------------------
792
793/// Energy release rate components for delamination.
794#[derive(Debug, Clone, Copy)]
795pub struct EnergyReleaseRate {
796    /// Mode I (opening) ERR G_I (J/m²).
797    pub g1: f64,
798    /// Mode II (sliding shear) ERR G_II (J/m²).
799    pub g2: f64,
800    /// Mode III (tearing shear) ERR G_III (J/m²).
801    pub g3: f64,
802}
803
804impl EnergyReleaseRate {
805    /// Creates a new EnergyReleaseRate.
806    pub fn new(g1: f64, g2: f64, g3: f64) -> Self {
807        Self { g1, g2, g3 }
808    }
809
810    /// Returns the total ERR G = G_I + G_II + G_III.
811    pub fn total(&self) -> f64 {
812        self.g1 + self.g2 + self.g3
813    }
814
815    /// Returns the mode mix ratio G_I / G_total.
816    pub fn mode_i_ratio(&self) -> f64 {
817        let gt = self.total();
818        if gt < 1e-20 { 0.0 } else { self.g1 / gt }
819    }
820
821    /// Returns the mode mix ratio G_II / G_total.
822    pub fn mode_ii_ratio(&self) -> f64 {
823        let gt = self.total();
824        if gt < 1e-20 { 0.0 } else { self.g2 / gt }
825    }
826}
827
828/// Delamination fracture criterion for composite laminates.
829///
830/// Implements:
831/// 1. Quadratic stress criterion (interlaminar stresses).
832/// 2. Linear fracture mechanics criterion (G vs G_c).
833/// 3. Benzeggagh-Kenane (B-K) mixed-mode criterion.
834#[derive(Debug, Clone)]
835pub struct DelamCriterion {
836    /// Interlaminar tensile strength Z_t (Pa).
837    pub z_t: f64,
838    /// Interlaminar shear strength S_xz (Pa).
839    pub s_xz: f64,
840    /// Interlaminar shear strength S_yz (Pa).
841    pub s_yz: f64,
842    /// Mode I critical ERR G_Ic (J/m²).
843    pub g1c: f64,
844    /// Mode II critical ERR G_IIc (J/m²).
845    pub g2c: f64,
846    /// Mode III critical ERR G_IIIc (J/m²).
847    pub g3c: f64,
848    /// Benzeggagh-Kenane exponent η (C/E: ≈ 1.75).
849    pub bk_eta: f64,
850}
851
852impl DelamCriterion {
853    /// Constructs a DelamCriterion.
854    pub fn new(z_t: f64, s_xz: f64, s_yz: f64, g1c: f64, g2c: f64, g3c: f64, bk_eta: f64) -> Self {
855        Self {
856            z_t,
857            s_xz,
858            s_yz,
859            g1c,
860            g2c,
861            g3c,
862            bk_eta,
863        }
864    }
865
866    /// Returns default values for carbon/epoxy (T300/914).
867    pub fn carbon_epoxy_default() -> Self {
868        Self::new(50.0e6, 100.0e6, 100.0e6, 200.0, 400.0, 400.0, 1.75)
869    }
870
871    /// Computes the quadratic interlaminar stress criterion.
872    ///
873    /// (⟨σ_z⟩/Z_t)² + (τ_xz/S_xz)² + (τ_yz/S_yz)² ≥ 1 → delamination
874    ///
875    /// where ⟨·⟩ is the Macaulay bracket (only tensile σ_z contributes).
876    pub fn quadratic_stress_index(&self, stress: InterlaminaStress) -> f64 {
877        let sz = stress.sigma_z.max(0.0); // Macaulay bracket
878        (sz / self.z_t).powi(2)
879            + (stress.tau_xz / self.s_xz).powi(2)
880            + (stress.tau_yz / self.s_yz).powi(2)
881    }
882
883    /// Returns `true` if the quadratic stress criterion is violated.
884    pub fn stress_delamination(&self, stress: InterlaminaStress) -> bool {
885        self.quadratic_stress_index(stress) >= 1.0
886    }
887
888    /// Computes the linear ERR criterion.
889    ///
890    /// G_I/G_Ic + G_II/G_IIc + G_III/G_IIIc ≥ 1 → delamination
891    pub fn linear_err_index(&self, err: EnergyReleaseRate) -> f64 {
892        err.g1 / self.g1c + err.g2 / self.g2c + err.g3 / self.g3c
893    }
894
895    /// Returns `true` if the linear ERR criterion is violated.
896    pub fn err_delamination(&self, err: EnergyReleaseRate) -> bool {
897        self.linear_err_index(err) >= 1.0
898    }
899
900    /// Computes the mixed-mode B-K criterion (Benzeggagh–Kenane 1996).
901    ///
902    /// G_Ic + (G_IIc − G_Ic) * (G_shear/G_total)^η = G_c(mm)
903    /// Delamination when G_total ≥ G_c(mm).
904    pub fn bk_criterion(&self, err: EnergyReleaseRate) -> f64 {
905        let gt = err.total();
906        if gt < 1e-20 {
907            return 0.0;
908        }
909        let g_shear = err.g2 + err.g3;
910        let mix = g_shear / gt;
911        let gc_mm = self.g1c + (self.g2c - self.g1c) * mix.powf(self.bk_eta);
912        gt / gc_mm
913    }
914
915    /// Returns `true` if B-K criterion is violated.
916    pub fn bk_delamination(&self, err: EnergyReleaseRate) -> bool {
917        self.bk_criterion(err) >= 1.0
918    }
919
920    /// Computes the mode I stress intensity factor K_I from σ_z and half-crack a (m).
921    ///
922    /// K_I = σ_z √(π a)
923    pub fn stress_intensity_mode1(&self, sigma_z: f64, half_crack: f64) -> f64 {
924        sigma_z * (PI * half_crack).sqrt()
925    }
926
927    /// Returns the critical G_I from mode I fracture toughness K_Ic
928    /// using G = K²/E.
929    ///
930    /// G_Ic = K_Ic² / E_z  where E_z is the through-thickness modulus (Pa).
931    pub fn g1c_from_k1c(k1c: f64, ez: f64) -> f64 {
932        k1c * k1c / ez
933    }
934
935    /// Computes the crack driving force (J-integral proxy) from ERR (J/m²).
936    pub fn j_integral_proxy(&self, err: EnergyReleaseRate) -> f64 {
937        err.total()
938    }
939
940    /// Estimates the delamination growth rate da/dN (m/cycle) using a Paris law.
941    ///
942    /// da/dN = C (ΔG_equiv)^m
943    ///
944    /// where ΔG_equiv = ΔG_I + ΔG_II * (G_IIc/G_Ic)^α  (approximate).
945    pub fn paris_growth_rate(
946        &self,
947        delta_g1: f64,
948        delta_g2: f64,
949        paris_c: f64,
950        paris_m: f64,
951    ) -> f64 {
952        let alpha = 0.5;
953        let g_equiv = delta_g1 + delta_g2 * (self.g2c / self.g1c).powf(alpha);
954        paris_c * g_equiv.powf(paris_m)
955    }
956}
957
958// ---------------------------------------------------------------------------
959// Helper: transformed stiffness matrix (CLT)
960// ---------------------------------------------------------------------------
961
962/// Transforms the ply stiffness matrix Q to the laminate coordinate system.
963///
964/// Returns \[Q̄₁₁, Q̄₂₂, Q̄₁₂, Q̄₁₆, Q̄₂₆, Q̄₆₆\].
965fn transformed_q(q: &[f64; 4], theta: f64) -> [f64; 6] {
966    let c = theta.cos();
967    let s = theta.sin();
968    let c2 = c * c;
969    let s2 = s * s;
970    let c4 = c2 * c2;
971    let s4 = s2 * s2;
972    let cs = c * s;
973    let cs2 = cs * cs;
974    let [q11, q22, q12, q66] = *q;
975    let q_bar11 = q11 * c4 + 2.0 * (q12 + 2.0 * q66) * cs2 + q22 * s4;
976    let q_bar22 = q11 * s4 + 2.0 * (q12 + 2.0 * q66) * cs2 + q22 * c4;
977    let q_bar12 = (q11 + q22 - 4.0 * q66) * cs2 + q12 * (c4 + s4);
978    let q_bar16 = (q11 - q12 - 2.0 * q66) * c2 * cs - (q22 - q12 - 2.0 * q66) * s2 * cs;
979    let q_bar26 = (q11 - q12 - 2.0 * q66) * s2 * cs - (q22 - q12 - 2.0 * q66) * c2 * cs;
980    let q_bar66 = (q11 + q22 - 2.0 * q12 - 2.0 * q66) * cs2 + q66 * (c4 + s4);
981    [q_bar11, q_bar22, q_bar12, q_bar16, q_bar26, q_bar66]
982}
983
984/// Computes the reserve factor (inverse of failure index) for a given criterion value.
985///
986/// Returns `None` if the criterion value is zero.
987pub fn reserve_factor(criterion_value: f64) -> Option<f64> {
988    if criterion_value <= 0.0 {
989        None
990    } else {
991        Some(1.0 / criterion_value)
992    }
993}
994
995/// Returns the Tsai-Hill failure envelope points for σ₁ sweeping from −F₁c to F₁t.
996///
997/// Each entry is (σ₁, σ₂) on the failure locus at τ₁₂ = 0.
998pub fn tsai_hill_envelope(strength: &PlyStrength, n_points: usize) -> Vec<(f64, f64)> {
999    let crit = TsaiHillCriterion::new(strength.clone());
1000    let mut pts = Vec::with_capacity(n_points);
1001    for i in 0..n_points {
1002        let sigma1 =
1003            -strength.f1c + (strength.f1t + strength.f1c) * i as f64 / (n_points - 1).max(1) as f64;
1004        // Solve: (s1/F1)² - s1*s2/F1² + (s2/F2)² = 1 for s2
1005        let f1 = if sigma1 >= 0.0 {
1006            strength.f1t
1007        } else {
1008            strength.f1c
1009        };
1010        let f2 = strength.f2t;
1011        let a = 1.0 / (f2 * f2);
1012        let b = -sigma1 / (f1 * f1);
1013        let c_coeff = (sigma1 / f1).powi(2) - 1.0;
1014        let disc = b * b - 4.0 * a * c_coeff;
1015        if disc >= 0.0 {
1016            let s2 = (-b + disc.sqrt()) / (2.0 * a);
1017            let _ = crit.failure_index_sq(PlaneStress::new(sigma1, s2, 0.0)); // verify
1018            pts.push((sigma1, s2));
1019        }
1020    }
1021    pts
1022}
1023
1024// ---------------------------------------------------------------------------
1025// Tests
1026// ---------------------------------------------------------------------------
1027
1028#[cfg(test)]
1029mod tests {
1030    use super::*;
1031
1032    // --- PlaneStress ---
1033
1034    #[test]
1035    fn test_plane_stress_zero() {
1036        let s = PlaneStress::zero();
1037        assert_eq!(s.sigma1, 0.0);
1038    }
1039
1040    #[test]
1041    fn test_plane_stress_scaled() {
1042        let s = PlaneStress::new(100.0, 50.0, 20.0).scaled(2.0);
1043        assert!((s.sigma1 - 200.0).abs() < 1e-10);
1044        assert!((s.tau12 - 40.0).abs() < 1e-10);
1045    }
1046
1047    // --- PlyStrength ---
1048
1049    #[test]
1050    fn test_carbon_epoxy_strengths_positive() {
1051        let st = PlyStrength::carbon_epoxy_t300();
1052        assert!(st.f1t > 0.0 && st.f1c > 0.0 && st.f2t > 0.0 && st.f12 > 0.0);
1053    }
1054
1055    #[test]
1056    fn test_glass_epoxy_f1t_less_than_carbon() {
1057        let ce = PlyStrength::carbon_epoxy_t300();
1058        let ge = PlyStrength::glass_epoxy();
1059        assert!(ge.f1t < ce.f1t);
1060    }
1061
1062    // --- MaxStressCriterion ---
1063
1064    #[test]
1065    fn test_max_stress_no_failure_under_threshold() {
1066        let crit = MaxStressCriterion::new(PlyStrength::carbon_epoxy_t300());
1067        let s = PlaneStress::new(100.0e6, 10.0e6, 5.0e6); // well below strength
1068        assert!(!crit.has_failed(s));
1069    }
1070
1071    #[test]
1072    fn test_max_stress_failure_at_f1t() {
1073        let crit = MaxStressCriterion::new(PlyStrength::carbon_epoxy_t300());
1074        let s = PlaneStress::new(1500.0e6, 0.0, 0.0);
1075        assert!(crit.has_failed(s));
1076    }
1077
1078    #[test]
1079    fn test_max_stress_fi_longitudinal_tension() {
1080        let crit = MaxStressCriterion::new(PlyStrength::carbon_epoxy_t300());
1081        let s = PlaneStress::new(750.0e6, 0.0, 0.0);
1082        assert!((crit.fi_longitudinal(s) - 0.5).abs() < 1e-6);
1083    }
1084
1085    #[test]
1086    fn test_max_stress_fi_longitudinal_compression() {
1087        let crit = MaxStressCriterion::new(PlyStrength::carbon_epoxy_t300());
1088        let s = PlaneStress::new(-900.0e6, 0.0, 0.0);
1089        assert!((crit.fi_longitudinal(s) - 1.0).abs() < 1e-6);
1090    }
1091
1092    #[test]
1093    fn test_max_stress_fi_transverse_zero() {
1094        let crit = MaxStressCriterion::new(PlyStrength::carbon_epoxy_t300());
1095        let s = PlaneStress::new(100.0e6, 0.0, 0.0);
1096        assert!((crit.fi_transverse(s)).abs() < 1e-10);
1097    }
1098
1099    #[test]
1100    fn test_max_stress_load_factor_gt_one_safe() {
1101        let crit = MaxStressCriterion::new(PlyStrength::carbon_epoxy_t300());
1102        let s = PlaneStress::new(100.0e6, 10.0e6, 5.0e6);
1103        assert!(crit.load_factor(s) > 1.0);
1104    }
1105
1106    #[test]
1107    fn test_max_stress_load_factor_le_one_failed() {
1108        let crit = MaxStressCriterion::new(PlyStrength::carbon_epoxy_t300());
1109        let s = PlaneStress::new(1500.0e6, 0.0, 0.0);
1110        assert!(crit.load_factor(s) <= 1.0);
1111    }
1112
1113    // --- TsaiHillCriterion ---
1114
1115    #[test]
1116    fn test_tsai_hill_no_failure_low_stress() {
1117        let crit = TsaiHillCriterion::new(PlyStrength::carbon_epoxy_t300());
1118        let s = PlaneStress::new(100.0e6, 10.0e6, 5.0e6);
1119        assert!(!crit.has_failed(s));
1120    }
1121
1122    #[test]
1123    fn test_tsai_hill_failure_at_strength() {
1124        let crit = TsaiHillCriterion::new(PlyStrength::carbon_epoxy_t300());
1125        let s = PlaneStress::new(1500.0e6, 0.0, 0.0);
1126        assert!(crit.has_failed(s));
1127    }
1128
1129    #[test]
1130    fn test_tsai_hill_safety_margin_gt_one_safe() {
1131        let crit = TsaiHillCriterion::new(PlyStrength::carbon_epoxy_t300());
1132        let s = PlaneStress::new(100.0e6, 5.0e6, 5.0e6);
1133        assert!(crit.safety_margin(s) > 1.0);
1134    }
1135
1136    #[test]
1137    fn test_tsai_hill_load_factor_positive() {
1138        let crit = TsaiHillCriterion::new(PlyStrength::carbon_epoxy_t300());
1139        let s = PlaneStress::new(300.0e6, 20.0e6, 10.0e6);
1140        assert!(crit.load_factor(s) > 0.0);
1141    }
1142
1143    #[test]
1144    fn test_tsai_hill_biaxial_strength_positive() {
1145        let crit = TsaiHillCriterion::new(PlyStrength::carbon_epoxy_t300());
1146        let sigma1 = crit.biaxial_strength(0.0);
1147        assert!(sigma1 > 0.0);
1148    }
1149
1150    #[test]
1151    fn test_tsai_hill_failure_index_at_uniaxial_f1t() {
1152        let st = PlyStrength::carbon_epoxy_t300();
1153        let crit = TsaiHillCriterion::new(st.clone());
1154        let s = PlaneStress::new(st.f1t, 0.0, 0.0);
1155        let fi = crit.failure_index_sq(s);
1156        assert!((fi - 1.0).abs() < 1e-6, "FI at F1t should be 1: {fi}");
1157    }
1158
1159    // --- TsaiWuCriterion ---
1160
1161    #[test]
1162    fn test_tsai_wu_stable_interaction_coeff() {
1163        let crit = TsaiWuCriterion::new(PlyStrength::carbon_epoxy_t300(), -0.5);
1164        assert!(crit.is_stable());
1165    }
1166
1167    #[test]
1168    fn test_tsai_wu_no_failure_low_stress() {
1169        let crit = TsaiWuCriterion::new(PlyStrength::carbon_epoxy_t300(), -0.5);
1170        let s = PlaneStress::new(100.0e6, 5.0e6, 5.0e6);
1171        assert!(!crit.has_failed(s));
1172    }
1173
1174    #[test]
1175    fn test_tsai_wu_failure_at_high_load() {
1176        let crit = TsaiWuCriterion::new(PlyStrength::carbon_epoxy_t300(), -0.5);
1177        let s = PlaneStress::new(1600.0e6, 60.0e6, 80.0e6);
1178        assert!(crit.has_failed(s));
1179    }
1180
1181    #[test]
1182    fn test_tsai_wu_load_factor_positive() {
1183        let crit = TsaiWuCriterion::new(PlyStrength::carbon_epoxy_t300(), -0.5);
1184        let s = PlaneStress::new(300.0e6, 20.0e6, 10.0e6);
1185        assert!(crit.load_factor(s) > 0.0);
1186    }
1187
1188    #[test]
1189    fn test_tsai_wu_linear_terms_sign() {
1190        let crit = TsaiWuCriterion::new(PlyStrength::carbon_epoxy_t300(), -0.5);
1191        let [f1, _f2] = crit.linear_terms();
1192        // f1 = 1/F1t - 1/F1c. Since F1t > F1c typically, sign depends on values.
1193        assert!(f1.is_finite());
1194    }
1195
1196    #[test]
1197    fn test_tsai_wu_quadratic_terms_f11_positive() {
1198        let crit = TsaiWuCriterion::new(PlyStrength::carbon_epoxy_t300(), -0.5);
1199        let [f11, f22, f66, _f12] = crit.quadratic_terms();
1200        assert!(f11 > 0.0 && f22 > 0.0 && f66 > 0.0);
1201    }
1202
1203    // --- PuckCriterion ---
1204
1205    #[test]
1206    fn test_puck_ff_none_at_low_stress() {
1207        let puck = PuckCriterion::carbon_epoxy_default();
1208        let s = PlaneStress::new(100.0e6, 10.0e6, 5.0e6);
1209        assert_eq!(puck.ff_mode(s), PuckFfMode::None);
1210    }
1211
1212    #[test]
1213    fn test_puck_ff_tension_at_f1t() {
1214        let puck = PuckCriterion::carbon_epoxy_default();
1215        let s = PlaneStress::new(1600.0e6, 0.0, 0.0);
1216        assert_eq!(puck.ff_mode(s), PuckFfMode::Tension);
1217    }
1218
1219    #[test]
1220    fn test_puck_ff_compression_at_minus_f1c() {
1221        let puck = PuckCriterion::carbon_epoxy_default();
1222        let s = PlaneStress::new(-1000.0e6, 0.0, 0.0);
1223        assert_eq!(puck.ff_mode(s), PuckFfMode::Compression);
1224    }
1225
1226    #[test]
1227    fn test_puck_iff_mode_a_tension_transverse() {
1228        let puck = PuckCriterion::carbon_epoxy_default();
1229        // High transverse tension
1230        let s = PlaneStress::new(0.0, 60.0e6, 0.0);
1231        assert!(puck.iff_exposure_mode_a(s) > 1.0);
1232    }
1233
1234    #[test]
1235    fn test_puck_iff_mode_bc_compression() {
1236        let puck = PuckCriterion::carbon_epoxy_default();
1237        let s = PlaneStress::new(0.0, -250.0e6, 80.0e6);
1238        // High compressive transverse + shear — should trigger mode BC
1239        let exp = puck.iff_exposure_mode_bc(s);
1240        assert!(exp >= 0.0);
1241    }
1242
1243    #[test]
1244    fn test_puck_fracture_angle_zero_for_tension() {
1245        let puck = PuckCriterion::carbon_epoxy_default();
1246        let s = PlaneStress::new(0.0, 30.0e6, 0.0);
1247        assert!((puck.fracture_plane_angle(s)).abs() < 1e-10);
1248    }
1249
1250    #[test]
1251    fn test_puck_fracture_angle_positive_for_compression() {
1252        let puck = PuckCriterion::carbon_epoxy_default();
1253        let s = PlaneStress::new(0.0, -100.0e6, 0.0);
1254        let angle = puck.fracture_plane_angle(s);
1255        assert!(angle >= 0.0);
1256    }
1257
1258    #[test]
1259    fn test_puck_no_failure_at_zero_stress() {
1260        let puck = PuckCriterion::carbon_epoxy_default();
1261        let s = PlaneStress::zero();
1262        assert!(!puck.has_failed(s));
1263    }
1264
1265    // --- ProgressiveDamage ---
1266
1267    #[test]
1268    fn test_ply_state_construction() {
1269        let ply = PlyState::new(0.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1270        assert!(ply.q11() > ply.q22());
1271        assert!(!ply.ff_failed);
1272    }
1273
1274    #[test]
1275    fn test_ply_state_q11_q22_ordering() {
1276        let ply = PlyState::new(0.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1277        assert!(
1278            ply.q11() > ply.q22(),
1279            "Q11 should be > Q22 for UD ply at 0°"
1280        );
1281    }
1282
1283    #[test]
1284    fn test_ply_degradation_total() {
1285        let mut ply = PlyState::new(0.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1286        let q11_before = ply.q11();
1287        ply.apply_degradation(DegradationRule::Total, true, false);
1288        assert!(ply.q11() < q11_before);
1289        assert!(ply.ff_failed);
1290    }
1291
1292    #[test]
1293    fn test_ply_degradation_none_keeps_stiffness() {
1294        let mut ply = PlyState::new(0.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1295        let q11_before = ply.q11();
1296        ply.apply_degradation(DegradationRule::None, false, true);
1297        assert!((ply.q11() - q11_before).abs() < 1e-3);
1298    }
1299
1300    #[test]
1301    fn test_progressive_damage_n_intact_all() {
1302        let ply1 = PlyState::new(0.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1303        let ply2 = PlyState::new(90.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1304        let st1 = PlyStrength::carbon_epoxy_t300();
1305        let st2 = PlyStrength::carbon_epoxy_t300();
1306        let pd = ProgressiveDamage::new(vec![ply1, ply2], vec![st1, st2], DegradationRule::Total);
1307        assert_eq!(pd.n_intact(), 2);
1308    }
1309
1310    #[test]
1311    fn test_progressive_damage_not_all_failed_initially() {
1312        let ply1 = PlyState::new(0.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1313        let pd = ProgressiveDamage::new(
1314            vec![ply1],
1315            vec![PlyStrength::carbon_epoxy_t300()],
1316            DegradationRule::Total,
1317        );
1318        assert!(!pd.all_failed());
1319    }
1320
1321    #[test]
1322    fn test_progressive_damage_a_matrix_nonzero() {
1323        let ply = PlyState::new(0.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1324        let pd = ProgressiveDamage::new(
1325            vec![ply],
1326            vec![PlyStrength::carbon_epoxy_t300()],
1327            DegradationRule::Total,
1328        );
1329        let a = pd.a_matrix();
1330        assert!(a[0] > 0.0);
1331    }
1332
1333    #[test]
1334    fn test_progressive_failure_sweep_returns_finite() {
1335        let ply = PlyState::new(0.0, 0.125e-3, 130.0e9, 10.0e9, 0.28, 5.0e9);
1336        let mut pd = ProgressiveDamage::new(
1337            vec![ply],
1338            vec![PlyStrength::carbon_epoxy_t300()],
1339            DegradationRule::Total,
1340        );
1341        let lf = pd.progressive_failure_sweep([1.0e7, 0.0, 0.0], 100);
1342        assert!(lf.is_finite() || lf == f64::INFINITY);
1343    }
1344
1345    // --- DelamCriterion ---
1346
1347    #[test]
1348    fn test_delam_no_failure_low_stress() {
1349        let dc = DelamCriterion::carbon_epoxy_default();
1350        let s = InterlaminaStress::new(10.0e6, 20.0e6, 20.0e6);
1351        assert!(!dc.stress_delamination(s));
1352    }
1353
1354    #[test]
1355    fn test_delam_failure_above_z_t() {
1356        let dc = DelamCriterion::carbon_epoxy_default();
1357        let s = InterlaminaStress::new(60.0e6, 0.0, 0.0); // σ_z > Z_t=50MPa
1358        assert!(dc.stress_delamination(s));
1359    }
1360
1361    #[test]
1362    fn test_delam_compression_no_mode1_contribution() {
1363        let dc = DelamCriterion::carbon_epoxy_default();
1364        let s = InterlaminaStress::new(-100.0e6, 0.0, 0.0); // compressive
1365        assert!(!dc.stress_delamination(s));
1366    }
1367
1368    #[test]
1369    fn test_delam_linear_err_failure() {
1370        let dc = DelamCriterion::carbon_epoxy_default();
1371        let err = EnergyReleaseRate::new(200.0, 400.0, 0.0); // at critical values
1372        assert!(dc.err_delamination(err));
1373    }
1374
1375    #[test]
1376    fn test_delam_linear_err_safe() {
1377        let dc = DelamCriterion::carbon_epoxy_default();
1378        let err = EnergyReleaseRate::new(50.0, 50.0, 0.0);
1379        assert!(!dc.err_delamination(err));
1380    }
1381
1382    #[test]
1383    fn test_bk_criterion_at_critical() {
1384        let dc = DelamCriterion::carbon_epoxy_default();
1385        // Pure mode I at G1c
1386        let err = EnergyReleaseRate::new(200.0, 0.0, 0.0);
1387        let bk = dc.bk_criterion(err);
1388        assert!((bk - 1.0).abs() < 1e-6);
1389    }
1390
1391    #[test]
1392    fn test_bk_delamination_above_critical() {
1393        let dc = DelamCriterion::carbon_epoxy_default();
1394        let err = EnergyReleaseRate::new(300.0, 0.0, 0.0);
1395        assert!(dc.bk_delamination(err));
1396    }
1397
1398    #[test]
1399    fn test_bk_no_delamination_below_critical() {
1400        let dc = DelamCriterion::carbon_epoxy_default();
1401        let err = EnergyReleaseRate::new(50.0, 50.0, 0.0);
1402        assert!(!dc.bk_delamination(err));
1403    }
1404
1405    #[test]
1406    fn test_err_mode_i_ratio() {
1407        let err = EnergyReleaseRate::new(100.0, 50.0, 50.0);
1408        let r = err.mode_i_ratio();
1409        assert!((r - 0.5).abs() < 1e-10);
1410    }
1411
1412    #[test]
1413    fn test_err_total() {
1414        let err = EnergyReleaseRate::new(100.0, 200.0, 50.0);
1415        assert!((err.total() - 350.0).abs() < 1e-10);
1416    }
1417
1418    #[test]
1419    fn test_stress_intensity_mode1() {
1420        let dc = DelamCriterion::carbon_epoxy_default();
1421        let k1 = dc.stress_intensity_mode1(1.0e6, 1.0e-3 / PI);
1422        assert!((k1 - 1.0e6 / 1000.0_f64.sqrt()).abs() < 1.0);
1423    }
1424
1425    #[test]
1426    fn test_g1c_from_k1c() {
1427        let g = DelamCriterion::g1c_from_k1c(1000.0, 5.0e9);
1428        assert!((g - 1000.0 * 1000.0 / 5.0e9).abs() < 1e-10);
1429    }
1430
1431    #[test]
1432    fn test_paris_growth_rate_positive() {
1433        let dc = DelamCriterion::carbon_epoxy_default();
1434        let rate = dc.paris_growth_rate(100.0, 200.0, 1e-10, 3.0);
1435        assert!(rate > 0.0);
1436    }
1437
1438    // --- Helper functions ---
1439
1440    #[test]
1441    fn test_transformed_q_identity_at_zero_angle() {
1442        let q = [130.0e9_f64, 10.0e9_f64, 3.0e9_f64, 5.0e9_f64];
1443        let qbar = transformed_q(&q, 0.0);
1444        assert!((qbar[0] - q[0]).abs() < 1.0, "Q11 not preserved at 0°");
1445        assert!((qbar[1] - q[1]).abs() < 1.0, "Q22 not preserved at 0°");
1446    }
1447
1448    #[test]
1449    fn test_transformed_q_90deg_swaps_11_22() {
1450        let q = [130.0e9_f64, 10.0e9_f64, 3.0e9_f64, 5.0e9_f64];
1451        let qbar0 = transformed_q(&q, 0.0);
1452        let qbar90 = transformed_q(&q, PI / 2.0);
1453        assert!(
1454            (qbar90[0] - qbar0[1]).abs() < 1.0,
1455            "Q̄11(90°) should equal Q̄22(0°)"
1456        );
1457    }
1458
1459    #[test]
1460    fn test_reserve_factor_positive() {
1461        assert!((reserve_factor(0.5).unwrap() - 2.0).abs() < 1e-10);
1462    }
1463
1464    #[test]
1465    fn test_reserve_factor_none_at_zero() {
1466        assert!(reserve_factor(0.0).is_none());
1467    }
1468
1469    #[test]
1470    fn test_tsai_hill_envelope_nonempty() {
1471        let st = PlyStrength::carbon_epoxy_t300();
1472        let pts = tsai_hill_envelope(&st, 10);
1473        assert!(!pts.is_empty());
1474    }
1475}