Skip to main content

oxiphysics_materials/
composite.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Composite material micromechanics.
5//!
6//! Provides Voigt/Reuss bounds, Hashin-Shtrikman bounds, Halpin-Tsai fiber-reinforced
7//! composite model, Classical Laminate Theory (CLT), and Mori-Tanaka particle
8//! composite scheme.
9
10#![allow(dead_code)]
11
12use std::f64::consts::PI;
13
14// ---------------------------------------------------------------------------
15// Helper: 3×3 matrix operations
16// ---------------------------------------------------------------------------
17
18fn mat3_zero() -> [[f64; 3]; 3] {
19    [[0.0; 3]; 3]
20}
21
22fn mat3_add_scaled(acc: &mut [[f64; 3]; 3], m: &[[f64; 3]; 3], scale: f64) {
23    for i in 0..3 {
24        for j in 0..3 {
25            acc[i][j] += m[i][j] * scale;
26        }
27    }
28}
29
30/// Invert a 3×3 matrix. Returns None if singular.
31fn mat3_inv(m: &[[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
32    let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
33        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
34        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
35    if det.abs() < 1e-300 {
36        return None;
37    }
38    let inv_det = 1.0 / det;
39    Some([
40        [
41            (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
42            (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
43            (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
44        ],
45        [
46            (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
47            (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
48            (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
49        ],
50        [
51            (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
52            (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
53            (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
54        ],
55    ])
56}
57
58// ---------------------------------------------------------------------------
59// IsotropicConstituent
60// ---------------------------------------------------------------------------
61
62/// An isotropic material constituent for composite micromechanics.
63#[derive(Debug, Clone, Copy)]
64pub struct IsotropicConstituent {
65    /// Young's modulus (Pa).
66    pub youngs_modulus: f64,
67    /// Poisson's ratio.
68    pub poisson_ratio: f64,
69    /// Density (kg/m³).
70    pub density: f64,
71    /// Volume fraction (0..1).
72    pub volume_fraction: f64,
73}
74
75impl IsotropicConstituent {
76    /// Create a new isotropic constituent.
77    pub fn new(
78        youngs_modulus: f64,
79        poisson_ratio: f64,
80        density: f64,
81        volume_fraction: f64,
82    ) -> Self {
83        Self {
84            youngs_modulus,
85            poisson_ratio,
86            density,
87            volume_fraction,
88        }
89    }
90
91    /// Shear modulus G = E / (2*(1+nu)).
92    pub fn shear_modulus(&self) -> f64 {
93        self.youngs_modulus / (2.0 * (1.0 + self.poisson_ratio))
94    }
95
96    /// Bulk modulus K = E / (3*(1-2*nu)).
97    pub fn bulk_modulus(&self) -> f64 {
98        self.youngs_modulus / (3.0 * (1.0 - 2.0 * self.poisson_ratio))
99    }
100}
101
102// ---------------------------------------------------------------------------
103// Voigt-Reuss bounds
104// ---------------------------------------------------------------------------
105
106/// Voigt upper bound: E_V = Σ V_i · E_i.
107pub fn voigt_modulus(constituents: &[IsotropicConstituent]) -> f64 {
108    constituents
109        .iter()
110        .map(|c| c.volume_fraction * c.youngs_modulus)
111        .sum()
112}
113
114/// Reuss lower bound: 1/E_R = Σ V_i / E_i.
115pub fn reuss_modulus(constituents: &[IsotropicConstituent]) -> f64 {
116    let inv_e: f64 = constituents
117        .iter()
118        .map(|c| c.volume_fraction / c.youngs_modulus)
119        .sum();
120    1.0 / inv_e
121}
122
123/// Voigt rule-of-mixtures density: ρ = Σ V_i · ρ_i.
124pub fn voigt_density(constituents: &[IsotropicConstituent]) -> f64 {
125    constituents
126        .iter()
127        .map(|c| c.volume_fraction * c.density)
128        .sum()
129}
130
131/// Voigt rule-of-mixtures Poisson's ratio: ν = Σ V_i · ν_i.
132pub fn voigt_poisson(constituents: &[IsotropicConstituent]) -> f64 {
133    constituents
134        .iter()
135        .map(|c| c.volume_fraction * c.poisson_ratio)
136        .sum()
137}
138
139/// Hashin-Shtrikman upper bound on bulk modulus K_HS+.
140///
141/// Uses the phase with the higher shear modulus as the reference (stiffer phase).
142pub fn hashin_shtrikman_upper(matrix: &IsotropicConstituent, fiber: &IsotropicConstituent) -> f64 {
143    // Upper bound: reference material is the stiffer phase (higher K and G)
144    let (ref_phase, other) = if fiber.bulk_modulus() >= matrix.bulk_modulus() {
145        (fiber, matrix)
146    } else {
147        (matrix, fiber)
148    };
149    let k_ref = ref_phase.bulk_modulus();
150    let g_ref = ref_phase.shear_modulus();
151    let k_other = other.bulk_modulus();
152    let v_other = other.volume_fraction;
153
154    // K_HS+ = K_ref + V_other / (1/(K_other - K_ref) + 3*V_ref/(3*K_ref + 4*G_ref))
155    let v_ref = 1.0 - v_other;
156    let denom = 1.0 / (k_other - k_ref) + 3.0 * v_ref / (3.0 * k_ref + 4.0 * g_ref);
157    k_ref + v_other / denom
158}
159
160/// Hashin-Shtrikman lower bound on bulk modulus K_HS-.
161///
162/// Uses the phase with the lower shear modulus as the reference (softer phase).
163pub fn hashin_shtrikman_lower(matrix: &IsotropicConstituent, fiber: &IsotropicConstituent) -> f64 {
164    // Lower bound: reference material is the softer phase (lower K and G)
165    let (ref_phase, other) = if matrix.bulk_modulus() <= fiber.bulk_modulus() {
166        (matrix, fiber)
167    } else {
168        (fiber, matrix)
169    };
170    let k_ref = ref_phase.bulk_modulus();
171    let g_ref = ref_phase.shear_modulus();
172    let k_other = other.bulk_modulus();
173    let v_other = other.volume_fraction;
174
175    let v_ref = 1.0 - v_other;
176    let denom = 1.0 / (k_other - k_ref) + 3.0 * v_ref / (3.0 * k_ref + 4.0 * g_ref);
177    k_ref + v_other / denom
178}
179
180// ---------------------------------------------------------------------------
181// Halpin-Tsai
182// ---------------------------------------------------------------------------
183
184/// Halpin-Tsai model for short-fiber or continuous-fiber reinforced composites.
185#[derive(Debug, Clone, Copy)]
186pub struct HalpinTsai {
187    /// Fiber constituent.
188    pub fiber: IsotropicConstituent,
189    /// Matrix constituent.
190    pub matrix: IsotropicConstituent,
191    /// Fiber aspect ratio l/d.
192    pub aspect_ratio: f64,
193}
194
195impl HalpinTsai {
196    /// Create a new Halpin-Tsai model.
197    pub fn new(
198        fiber: IsotropicConstituent,
199        matrix: IsotropicConstituent,
200        aspect_ratio: f64,
201    ) -> Self {
202        Self {
203            fiber,
204            matrix,
205            aspect_ratio,
206        }
207    }
208
209    /// Reinforcing factor ξ for longitudinal direction = 2 * aspect_ratio.
210    pub fn xi_longitudinal(&self) -> f64 {
211        2.0 * self.aspect_ratio
212    }
213
214    /// Reinforcing factor ξ for transverse direction = 2.0.
215    pub fn xi_transverse(&self) -> f64 {
216        2.0
217    }
218
219    /// Halpin-Tsai parameter η = (E_f/E_m - 1) / (E_f/E_m + ξ).
220    pub fn eta(property_ratio: f64, xi: f64) -> f64 {
221        (property_ratio - 1.0) / (property_ratio + xi)
222    }
223
224    /// Longitudinal modulus E_11 using Halpin-Tsai equation.
225    pub fn longitudinal_modulus(&self) -> f64 {
226        let v_f = self.fiber.volume_fraction;
227        let xi = self.xi_longitudinal();
228        let ratio = self.fiber.youngs_modulus / self.matrix.youngs_modulus;
229        let eta = Self::eta(ratio, xi);
230        self.matrix.youngs_modulus * (1.0 + xi * eta * v_f) / (1.0 - eta * v_f)
231    }
232
233    /// Transverse modulus E_22 using Halpin-Tsai equation.
234    pub fn transverse_modulus(&self) -> f64 {
235        let v_f = self.fiber.volume_fraction;
236        let xi = self.xi_transverse();
237        let ratio = self.fiber.youngs_modulus / self.matrix.youngs_modulus;
238        let eta = Self::eta(ratio, xi);
239        self.matrix.youngs_modulus * (1.0 + xi * eta * v_f) / (1.0 - eta * v_f)
240    }
241
242    /// In-plane shear modulus G_12 using Halpin-Tsai equation (ξ = 1 for shear).
243    pub fn shear_modulus_12(&self) -> f64 {
244        let v_f = self.fiber.volume_fraction;
245        let xi = 1.0;
246        let ratio = self.fiber.shear_modulus() / self.matrix.shear_modulus();
247        let eta = Self::eta(ratio, xi);
248        self.matrix.shear_modulus() * (1.0 + xi * eta * v_f) / (1.0 - eta * v_f)
249    }
250
251    /// Major Poisson ratio ν_12 = V_f·ν_f + V_m·ν_m (rule of mixtures).
252    pub fn poisson_12(&self) -> f64 {
253        let v_f = self.fiber.volume_fraction;
254        let v_m = self.matrix.volume_fraction;
255        v_f * self.fiber.poisson_ratio + v_m * self.matrix.poisson_ratio
256    }
257
258    /// Minor Poisson ratio ν_21 = ν_12 · E_22 / E_11.
259    pub fn poisson_21(&self) -> f64 {
260        self.poisson_12() * self.transverse_modulus() / self.longitudinal_modulus()
261    }
262}
263
264// ---------------------------------------------------------------------------
265// OrthotropicPly
266// ---------------------------------------------------------------------------
267
268/// A single orthotropic ply (unidirectional lamina) in plane stress.
269#[derive(Debug, Clone, Copy)]
270pub struct OrthotropicPly {
271    /// Longitudinal Young's modulus E_11 (fiber direction).
272    pub e11: f64,
273    /// Transverse Young's modulus E_22.
274    pub e22: f64,
275    /// In-plane shear modulus G_12.
276    pub g12: f64,
277    /// Major Poisson ratio ν_12.
278    pub nu12: f64,
279    /// Ply thickness.
280    pub thickness: f64,
281}
282
283impl OrthotropicPly {
284    /// Create a new orthotropic ply.
285    pub fn new(e11: f64, e22: f64, g12: f64, nu12: f64, thickness: f64) -> Self {
286        Self {
287            e11,
288            e22,
289            g12,
290            nu12,
291            thickness,
292        }
293    }
294
295    /// Minor Poisson ratio ν_21 = ν_12 · E_22 / E_11.
296    pub fn nu21(&self) -> f64 {
297        self.nu12 * self.e22 / self.e11
298    }
299
300    /// Reduced stiffness matrix \[Q\] in the principal material directions (plane stress).
301    ///
302    /// ```text
303    /// Q11 = E11 / (1 - nu12*nu21)
304    /// Q22 = E22 / (1 - nu12*nu21)
305    /// Q12 = nu12*E22 / (1 - nu12*nu21)
306    /// Q66 = G12
307    /// ```
308    pub fn q_matrix(&self) -> [[f64; 3]; 3] {
309        let nu21 = self.nu21();
310        let denom = 1.0 - self.nu12 * nu21;
311        let q11 = self.e11 / denom;
312        let q22 = self.e22 / denom;
313        let q12 = self.nu12 * self.e22 / denom;
314        let q66 = self.g12;
315        [[q11, q12, 0.0], [q12, q22, 0.0], [0.0, 0.0, q66]]
316    }
317
318    /// Transformed (off-axis) reduced stiffness matrix \[Q_bar\] for a ply at `angle_deg`.
319    pub fn q_bar_matrix(&self, angle_deg: f64) -> [[f64; 3]; 3] {
320        let theta = angle_deg * PI / 180.0;
321        let c = theta.cos();
322        let s = theta.sin();
323        let c2 = c * c;
324        let s2 = s * s;
325        let c4 = c2 * c2;
326        let s4 = s2 * s2;
327        let c2s2 = c2 * s2;
328
329        let q = self.q_matrix();
330        let q11 = q[0][0];
331        let q12 = q[0][1];
332        let q22 = q[1][1];
333        let q66 = q[2][2];
334
335        let qb11 = q11 * c4 + 2.0 * (q12 + 2.0 * q66) * c2s2 + q22 * s4;
336        let qb12 = (q11 + q22 - 4.0 * q66) * c2s2 + q12 * (c4 + s4);
337        let qb22 = q11 * s4 + 2.0 * (q12 + 2.0 * q66) * c2s2 + q22 * c4;
338        let qb16 = (q11 - q12 - 2.0 * q66) * c2 * c * s - (q22 - q12 - 2.0 * q66) * s2 * c * s;
339        let qb26 = (q11 - q12 - 2.0 * q66) * s2 * c * s - (q22 - q12 - 2.0 * q66) * c2 * c * s;
340        let qb66 = (q11 + q22 - 2.0 * q12 - 2.0 * q66) * c2s2 + q66 * (c4 + s4);
341
342        [[qb11, qb12, qb16], [qb12, qb22, qb26], [qb16, qb26, qb66]]
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Laminate (Classical Laminate Theory)
348// ---------------------------------------------------------------------------
349
350/// A composite laminate described by Classical Laminate Theory (CLT).
351///
352/// Plies are ordered from bottom to top; mid-plane is at z = 0.
353#[derive(Debug, Clone)]
354pub struct Laminate {
355    /// Plies as (ply, fiber angle in degrees).
356    pub plies: Vec<(OrthotropicPly, f64)>,
357}
358
359impl Laminate {
360    /// Create an empty laminate.
361    pub fn new() -> Self {
362        Self { plies: Vec::new() }
363    }
364
365    /// Append a ply with the given fiber angle.
366    pub fn add_ply(&mut self, ply: OrthotropicPly, angle_deg: f64) {
367        self.plies.push((ply, angle_deg));
368    }
369
370    /// Total laminate thickness h = Σ t_k.
371    pub fn total_thickness(&self) -> f64 {
372        self.plies.iter().map(|(p, _)| p.thickness).sum()
373    }
374
375    /// Z-coordinates of ply interfaces measured from the mid-plane.
376    fn ply_z_coords(&self) -> Vec<f64> {
377        let h = self.total_thickness();
378        let mut z = Vec::with_capacity(self.plies.len() + 1);
379        z.push(-h / 2.0);
380        for (ply, _) in &self.plies {
381            let last = *z.last().expect("collection should not be empty");
382            z.push(last + ply.thickness);
383        }
384        z
385    }
386
387    /// Extensional stiffness matrix A_ij = Σ Q_bar_ij * h_k.
388    pub fn a_matrix(&self) -> [[f64; 3]; 3] {
389        let mut a = mat3_zero();
390        for (ply, angle) in &self.plies {
391            mat3_add_scaled(&mut a, &ply.q_bar_matrix(*angle), ply.thickness);
392        }
393        a
394    }
395
396    /// Coupling stiffness matrix B_ij = 0.5 * Σ Q_bar_ij * (z_k² - z_{k-1}²).
397    pub fn b_matrix(&self) -> [[f64; 3]; 3] {
398        let z = self.ply_z_coords();
399        let mut b = mat3_zero();
400        for (k, (ply, angle)) in self.plies.iter().enumerate() {
401            let scale = 0.5 * (z[k + 1].powi(2) - z[k].powi(2));
402            mat3_add_scaled(&mut b, &ply.q_bar_matrix(*angle), scale);
403        }
404        b
405    }
406
407    /// Bending stiffness matrix D_ij = (1/3) * Σ Q_bar_ij * (z_k³ - z_{k-1}³).
408    pub fn d_matrix(&self) -> [[f64; 3]; 3] {
409        let z = self.ply_z_coords();
410        let mut d = mat3_zero();
411        for (k, (ply, angle)) in self.plies.iter().enumerate() {
412            let scale = (z[k + 1].powi(3) - z[k].powi(3)) / 3.0;
413            mat3_add_scaled(&mut d, &ply.q_bar_matrix(*angle), scale);
414        }
415        d
416    }
417
418    /// Effective x-direction modulus: 1 / (A⁻¹\[0\]\[0\] * h).
419    pub fn effective_modulus_x(&self) -> f64 {
420        let h = self.total_thickness();
421        if let Some(a_inv) = mat3_inv(&self.a_matrix()) {
422            1.0 / (a_inv[0][0] * h)
423        } else {
424            0.0
425        }
426    }
427
428    /// Effective y-direction modulus: 1 / (A⁻¹\[1\]\[1\] * h).
429    pub fn effective_modulus_y(&self) -> f64 {
430        let h = self.total_thickness();
431        if let Some(a_inv) = mat3_inv(&self.a_matrix()) {
432            1.0 / (a_inv[1][1] * h)
433        } else {
434            0.0
435        }
436    }
437
438    /// Effective in-plane shear modulus: 1 / (A⁻¹\[2\]\[2\] * h).
439    pub fn effective_shear_modulus(&self) -> f64 {
440        let h = self.total_thickness();
441        if let Some(a_inv) = mat3_inv(&self.a_matrix()) {
442            1.0 / (a_inv[2][2] * h)
443        } else {
444            0.0
445        }
446    }
447
448    /// Returns true if the laminate is symmetric (B ≈ 0).
449    pub fn is_symmetric(&self) -> bool {
450        let b = self.b_matrix();
451        let a = self.a_matrix();
452        // Scale tolerance by largest A entry
453        let a_norm = a[0][0].abs().max(a[1][1].abs()).max(a[2][2].abs());
454        let tol = 1.0e-6 * a_norm;
455        b.iter().flatten().all(|v| v.abs() < tol)
456    }
457}
458
459impl Default for Laminate {
460    fn default() -> Self {
461        Self::new()
462    }
463}
464
465// ---------------------------------------------------------------------------
466// Mori-Tanaka
467// ---------------------------------------------------------------------------
468
469/// Mori-Tanaka effective medium scheme for particle-reinforced composites.
470///
471/// Assumes spherical inclusions in an isotropic matrix.
472#[derive(Debug, Clone, Copy)]
473pub struct MoriTanaka {
474    /// Matrix phase.
475    pub matrix: IsotropicConstituent,
476    /// Inclusion (particle) phase.
477    pub inclusion: IsotropicConstituent,
478}
479
480impl MoriTanaka {
481    /// Create a new Mori-Tanaka model.
482    pub fn new(matrix: IsotropicConstituent, inclusion: IsotropicConstituent) -> Self {
483        Self { matrix, inclusion }
484    }
485
486    /// Effective bulk modulus K* via Mori-Tanaka.
487    ///
488    /// K* = K_m + V_i*(K_i - K_m) / (1 + (1 - V_i)*(K_i - K_m)/(K_m + 4*G_m/3))
489    pub fn effective_bulk_modulus(&self) -> f64 {
490        let k_m = self.matrix.bulk_modulus();
491        let g_m = self.matrix.shear_modulus();
492        let k_i = self.inclusion.bulk_modulus();
493        let v_i = self.inclusion.volume_fraction;
494
495        let alpha = k_m + 4.0 * g_m / 3.0;
496        k_m + v_i * (k_i - k_m) / (1.0 + (1.0 - v_i) * (k_i - k_m) / alpha)
497    }
498
499    /// Effective shear modulus G* via Mori-Tanaka.
500    ///
501    /// G* = G_m + V_i*(G_i - G_m) / (1 + (1 - V_i)*(G_i - G_m)/beta)
502    /// where beta = G_m*(9*K_m + 8*G_m)/(6*(K_m + 2*G_m))
503    pub fn effective_shear_modulus(&self) -> f64 {
504        let k_m = self.matrix.bulk_modulus();
505        let g_m = self.matrix.shear_modulus();
506        let g_i = self.inclusion.shear_modulus();
507        let v_i = self.inclusion.volume_fraction;
508
509        let beta = g_m * (9.0 * k_m + 8.0 * g_m) / (6.0 * (k_m + 2.0 * g_m));
510        g_m + v_i * (g_i - g_m) / (1.0 + (1.0 - v_i) * (g_i - g_m) / beta)
511    }
512
513    /// Effective Young's modulus E* derived from K* and G*.
514    ///
515    /// E = 9*K*G / (3*K + G)
516    pub fn effective_youngs_modulus(&self) -> f64 {
517        let k = self.effective_bulk_modulus();
518        let g = self.effective_shear_modulus();
519        9.0 * k * g / (3.0 * k + g)
520    }
521}
522
523// ---------------------------------------------------------------------------
524// Ply strengths
525// ---------------------------------------------------------------------------
526
527/// Strength properties for a unidirectional ply.
528#[derive(Debug, Clone, Copy)]
529pub struct PlyStrength {
530    /// Longitudinal tensile strength (Pa).
531    pub xt: f64,
532    /// Longitudinal compressive strength (Pa, positive value).
533    pub xc: f64,
534    /// Transverse tensile strength (Pa).
535    pub yt: f64,
536    /// Transverse compressive strength (Pa, positive value).
537    pub yc: f64,
538    /// In-plane shear strength (Pa).
539    pub s12: f64,
540}
541
542impl PlyStrength {
543    /// Create new ply strength values.
544    pub fn new(xt: f64, xc: f64, yt: f64, yc: f64, s12: f64) -> Self {
545        Self {
546            xt,
547            xc,
548            yt,
549            yc,
550            s12,
551        }
552    }
553
554    /// Typical CFRP ply strength (T300/914).
555    pub fn cfrp_typical() -> Self {
556        Self {
557            xt: 1500.0e6,
558            xc: 1200.0e6,
559            yt: 50.0e6,
560            yc: 250.0e6,
561            s12: 70.0e6,
562        }
563    }
564
565    /// Typical GFRP ply strength.
566    pub fn gfrp_typical() -> Self {
567        Self {
568            xt: 1000.0e6,
569            xc: 600.0e6,
570            yt: 30.0e6,
571            yc: 110.0e6,
572            s12: 55.0e6,
573        }
574    }
575}
576
577// ---------------------------------------------------------------------------
578// Maximum stress failure criterion
579// ---------------------------------------------------------------------------
580
581/// Result of a failure check on a single ply.
582#[derive(Debug, Clone, Copy)]
583pub struct PlyFailureResult {
584    /// Fiber direction failure index (>= 1.0 means failure).
585    pub fi_fiber: f64,
586    /// Matrix direction failure index.
587    pub fi_matrix: f64,
588    /// Shear failure index.
589    pub fi_shear: f64,
590    /// Whether any mode has failed.
591    pub failed: bool,
592}
593
594/// Maximum stress failure criterion.
595///
596/// Checks stress in principal material directions against allowable strengths.
597pub fn max_stress_failure(sigma: [f64; 3], strength: &PlyStrength) -> PlyFailureResult {
598    let s1 = sigma[0];
599    let s2 = sigma[1];
600    let s12 = sigma[2];
601
602    let fi_fiber = if s1 >= 0.0 {
603        s1 / strength.xt
604    } else {
605        (-s1) / strength.xc
606    };
607
608    let fi_matrix = if s2 >= 0.0 {
609        s2 / strength.yt
610    } else {
611        (-s2) / strength.yc
612    };
613
614    let fi_shear = s12.abs() / strength.s12;
615
616    PlyFailureResult {
617        fi_fiber,
618        fi_matrix,
619        fi_shear,
620        failed: fi_fiber >= 1.0 || fi_matrix >= 1.0 || fi_shear >= 1.0,
621    }
622}
623
624// ---------------------------------------------------------------------------
625// Tsai-Wu failure criterion
626// ---------------------------------------------------------------------------
627
628/// Tsai-Wu failure criterion.
629///
630/// Returns the failure index (>= 1.0 means failure).
631pub fn tsai_wu_failure(sigma: [f64; 3], strength: &PlyStrength) -> f64 {
632    let s1 = sigma[0];
633    let s2 = sigma[1];
634    let s12 = sigma[2];
635
636    let f1 = 1.0 / strength.xt - 1.0 / strength.xc;
637    let f2 = 1.0 / strength.yt - 1.0 / strength.yc;
638    let f11 = 1.0 / (strength.xt * strength.xc);
639    let f22 = 1.0 / (strength.yt * strength.yc);
640    let f66 = 1.0 / (strength.s12 * strength.s12);
641    // Interaction term: conservative estimate
642    let f12 = -0.5 * (f11 * f22).sqrt();
643
644    f1 * s1 + f2 * s2 + f11 * s1 * s1 + f22 * s2 * s2 + f66 * s12 * s12 + 2.0 * f12 * s1 * s2
645}
646
647// ---------------------------------------------------------------------------
648// Hashin failure criterion
649// ---------------------------------------------------------------------------
650
651/// Hashin failure result with separate modes.
652#[derive(Debug, Clone, Copy)]
653pub struct HashinResult {
654    /// Fiber tension failure index.
655    pub fiber_tension: f64,
656    /// Fiber compression failure index.
657    pub fiber_compression: f64,
658    /// Matrix tension failure index.
659    pub matrix_tension: f64,
660    /// Matrix compression failure index.
661    pub matrix_compression: f64,
662    /// Whether any mode has failed.
663    pub failed: bool,
664}
665
666/// Hashin failure criterion (1980 formulation).
667///
668/// Provides distinct failure modes for fiber and matrix in tension/compression.
669pub fn hashin_failure(sigma: [f64; 3], strength: &PlyStrength) -> HashinResult {
670    let s1 = sigma[0];
671    let s2 = sigma[1];
672    let s12 = sigma[2];
673
674    // Fiber tension (s1 >= 0)
675    let fiber_tension = if s1 >= 0.0 {
676        (s1 / strength.xt).powi(2) + (s12 / strength.s12).powi(2)
677    } else {
678        0.0
679    };
680
681    // Fiber compression (s1 < 0)
682    let fiber_compression = if s1 < 0.0 {
683        (-s1 / strength.xc).powi(2)
684    } else {
685        0.0
686    };
687
688    // Matrix tension (s2 >= 0)
689    let matrix_tension = if s2 >= 0.0 {
690        (s2 / strength.yt).powi(2) + (s12 / strength.s12).powi(2)
691    } else {
692        0.0
693    };
694
695    // Matrix compression (s2 < 0)
696    let matrix_compression = if s2 < 0.0 {
697        (s2 / (2.0 * strength.s12)).powi(2)
698            + ((strength.yc / (2.0 * strength.s12)).powi(2) - 1.0) * s2 / strength.yc
699            + (s12 / strength.s12).powi(2)
700    } else {
701        0.0
702    };
703
704    let failed = fiber_tension >= 1.0
705        || fiber_compression >= 1.0
706        || matrix_tension >= 1.0
707        || matrix_compression >= 1.0;
708
709    HashinResult {
710        fiber_tension,
711        fiber_compression,
712        matrix_tension,
713        matrix_compression,
714        failed,
715    }
716}
717
718// ---------------------------------------------------------------------------
719// Puck failure criterion (simplified)
720// ---------------------------------------------------------------------------
721
722/// Puck inter-fiber failure result.
723#[derive(Debug, Clone, Copy)]
724pub struct PuckResult {
725    /// Inter-fiber failure effort (>= 1.0 means failure).
726    pub iff_effort: f64,
727    /// Fiber failure effort.
728    pub ff_effort: f64,
729    /// Whether any mode has failed.
730    pub failed: bool,
731}
732
733/// Simplified Puck failure criterion.
734///
735/// Focuses on inter-fiber failure (IFF) and fiber failure (FF).
736pub fn puck_failure(sigma: [f64; 3], strength: &PlyStrength) -> PuckResult {
737    let s1 = sigma[0];
738    let s2 = sigma[1];
739    let s12 = sigma[2];
740
741    // Fiber failure
742    let ff_effort = if s1 >= 0.0 {
743        s1 / strength.xt
744    } else {
745        (-s1) / strength.xc
746    };
747
748    // Inter-fiber failure (simplified Mode A / Mode B / Mode C)
749    let iff_effort = if s2 >= 0.0 {
750        // Mode A: transverse tension
751        ((s2 / strength.yt).powi(2) + (s12 / strength.s12).powi(2)).sqrt()
752    } else {
753        // Mode B/C: transverse compression
754        let p_perp_minus = 0.25; // friction parameter
755        let tau_eff = (s12 * s12 + (p_perp_minus * s2).powi(2)).sqrt();
756        tau_eff / (strength.s12 - p_perp_minus * s2)
757    };
758
759    PuckResult {
760        iff_effort,
761        ff_effort,
762        failed: iff_effort >= 1.0 || ff_effort >= 1.0,
763    }
764}
765
766// ---------------------------------------------------------------------------
767// Ply-by-ply stress analysis
768// ---------------------------------------------------------------------------
769
770/// Stress state in a single ply (in material coordinates).
771#[derive(Debug, Clone, Copy)]
772pub struct PlyStress {
773    /// Ply index.
774    pub ply_index: usize,
775    /// Fiber angle (degrees).
776    pub angle_deg: f64,
777    /// Stress in material coordinates \[sigma_1, sigma_2, tau_12\].
778    pub stress: [f64; 3],
779    /// Strain in material coordinates \[eps_1, eps_2, gamma_12\].
780    pub strain: [f64; 3],
781    /// Z-coordinate of ply midplane.
782    pub z_mid: f64,
783}
784
785/// Transform global laminate strains to ply material coordinates.
786///
787/// `global_strain` is \[eps_x, eps_y, gamma_xy\] at the ply midplane.
788/// `angle_deg` is the fiber orientation.
789fn transform_strain_to_material(global_strain: [f64; 3], angle_deg: f64) -> [f64; 3] {
790    let theta = angle_deg * PI / 180.0;
791    let c = theta.cos();
792    let s = theta.sin();
793    let c2 = c * c;
794    let s2 = s * s;
795    let cs = c * s;
796
797    let ex = global_strain[0];
798    let ey = global_strain[1];
799    let gxy = global_strain[2];
800
801    [
802        c2 * ex + s2 * ey + cs * gxy,
803        s2 * ex + c2 * ey - cs * gxy,
804        -2.0 * cs * ex + 2.0 * cs * ey + (c2 - s2) * gxy,
805    ]
806}
807
808/// Compute ply-by-ply stresses for a laminate under given midplane strains.
809///
810/// `midplane_strain` is \[eps_x0, eps_y0, gamma_xy0\].
811/// `curvature` is \[kappa_x, kappa_y, kappa_xy\].
812#[allow(dead_code)]
813pub fn ply_by_ply_stress(
814    laminate: &Laminate,
815    midplane_strain: [f64; 3],
816    curvature: [f64; 3],
817) -> Vec<PlyStress> {
818    let z_coords = laminate.ply_z_coords();
819    let mut results = Vec::with_capacity(laminate.plies.len());
820
821    for (k, (ply, angle)) in laminate.plies.iter().enumerate() {
822        let z_mid = (z_coords[k] + z_coords[k + 1]) / 2.0;
823
824        // Global strain at ply midplane: eps = eps0 + z * kappa
825        let global_strain = [
826            midplane_strain[0] + z_mid * curvature[0],
827            midplane_strain[1] + z_mid * curvature[1],
828            midplane_strain[2] + z_mid * curvature[2],
829        ];
830
831        // Transform to material coordinates
832        let mat_strain = transform_strain_to_material(global_strain, *angle);
833
834        // Compute stress using Q matrix: sigma = Q * eps (in material coords)
835        let q = ply.q_matrix();
836        let stress = [
837            q[0][0] * mat_strain[0] + q[0][1] * mat_strain[1],
838            q[0][1] * mat_strain[0] + q[1][1] * mat_strain[1],
839            q[2][2] * mat_strain[2],
840        ];
841
842        results.push(PlyStress {
843            ply_index: k,
844            angle_deg: *angle,
845            stress,
846            strain: mat_strain,
847            z_mid,
848        });
849    }
850
851    results
852}
853
854// ---------------------------------------------------------------------------
855// Progressive failure analysis
856// ---------------------------------------------------------------------------
857
858/// Result of progressive failure analysis.
859#[allow(dead_code)]
860#[derive(Debug, Clone)]
861pub struct ProgressiveFailureResult {
862    /// Load factor at first ply failure.
863    pub first_ply_failure_load: f64,
864    /// Index of the first ply to fail.
865    pub first_failed_ply: usize,
866    /// Load factor at last ply failure (ultimate).
867    pub ultimate_load: f64,
868    /// Number of plies that failed.
869    pub n_failed_plies: usize,
870}
871
872/// Run progressive failure analysis using max-stress criterion.
873///
874/// Applies increasing load factor to the given strain until all plies fail.
875/// Returns the load factor at first ply failure and ultimate failure.
876#[allow(dead_code)]
877pub fn progressive_failure_analysis(
878    laminate: &Laminate,
879    base_strain: [f64; 3],
880    strength: &PlyStrength,
881    max_load_factor: f64,
882    n_steps: usize,
883) -> ProgressiveFailureResult {
884    let n_plies = laminate.plies.len();
885    let mut failed = vec![false; n_plies];
886    let mut first_failure_load = max_load_factor;
887    let mut first_failed_ply = 0;
888    let mut ultimate_load = max_load_factor;
889    let mut n_failed = 0;
890    let mut first_found = false;
891
892    let curvature = [0.0, 0.0, 0.0];
893
894    for step in 0..=n_steps {
895        let lf = max_load_factor * (step as f64) / (n_steps as f64);
896        let strain = [
897            base_strain[0] * lf,
898            base_strain[1] * lf,
899            base_strain[2] * lf,
900        ];
901
902        let ply_stresses = ply_by_ply_stress(laminate, strain, curvature);
903
904        let mut all_failed = true;
905        for ps in &ply_stresses {
906            if failed[ps.ply_index] {
907                continue;
908            }
909            let result = max_stress_failure(ps.stress, strength);
910            if result.failed {
911                failed[ps.ply_index] = true;
912                n_failed += 1;
913                if !first_found {
914                    first_failure_load = lf;
915                    first_failed_ply = ps.ply_index;
916                    first_found = true;
917                }
918            }
919            if !failed[ps.ply_index] {
920                all_failed = false;
921            }
922        }
923
924        if all_failed && n_plies > 0 {
925            ultimate_load = lf;
926            break;
927        }
928    }
929
930    ProgressiveFailureResult {
931        first_ply_failure_load: first_failure_load,
932        first_failed_ply,
933        ultimate_load,
934        n_failed_plies: n_failed,
935    }
936}
937
938// ---------------------------------------------------------------------------
939// Thermal residual stresses
940// ---------------------------------------------------------------------------
941
942/// Compute thermal residual strains in a ply due to temperature change.
943///
944/// `alpha` is \[alpha_1, alpha_2\] CTE in material coords.
945/// `delta_t` is the temperature change.
946#[allow(dead_code)]
947pub fn thermal_strain(alpha: [f64; 2], delta_t: f64) -> [f64; 3] {
948    [alpha[0] * delta_t, alpha[1] * delta_t, 0.0]
949}
950
951/// Compute thermal residual stresses in each ply of a laminate.
952///
953/// Assumes the laminate is constrained (cured as a whole) and computes
954/// the residual stress due to CTE mismatch between plies.
955///
956/// `alpha` is \[alpha_1, alpha_2\] CTE for the ply material.
957/// `delta_t` is the cure temperature change (e.g., from cure temp to room temp).
958#[allow(dead_code)]
959pub fn thermal_residual_stresses(
960    laminate: &Laminate,
961    alpha: [f64; 2],
962    delta_t: f64,
963) -> Vec<[f64; 3]> {
964    // Compute effective laminate CTE (using A matrix)
965    let a_mat = laminate.a_matrix();
966    let a_inv = match mat3_inv(&a_mat) {
967        Some(inv) => inv,
968        None => return vec![[0.0; 3]; laminate.plies.len()],
969    };
970
971    // Compute thermal force resultant: N_T = sum(Q_bar * alpha_bar * dT * thickness)
972    let mut n_thermal = [0.0; 3];
973    for (ply, angle) in &laminate.plies {
974        let theta = angle * PI / 180.0;
975        let c = theta.cos();
976        let s = theta.sin();
977        let c2 = c * c;
978        let s2 = s * s;
979        let cs = c * s;
980
981        // Transform CTE to global coords
982        let alpha_x = c2 * alpha[0] + s2 * alpha[1];
983        let alpha_y = s2 * alpha[0] + c2 * alpha[1];
984        let alpha_xy = 2.0 * cs * (alpha[0] - alpha[1]);
985
986        let qbar = ply.q_bar_matrix(*angle);
987        let t = ply.thickness;
988
989        n_thermal[0] +=
990            (qbar[0][0] * alpha_x + qbar[0][1] * alpha_y + qbar[0][2] * alpha_xy) * delta_t * t;
991        n_thermal[1] +=
992            (qbar[1][0] * alpha_x + qbar[1][1] * alpha_y + qbar[1][2] * alpha_xy) * delta_t * t;
993        n_thermal[2] +=
994            (qbar[2][0] * alpha_x + qbar[2][1] * alpha_y + qbar[2][2] * alpha_xy) * delta_t * t;
995    }
996
997    // Midplane strains due to thermal loads: eps0 = A^-1 * N_T
998    let eps0 = [
999        a_inv[0][0] * n_thermal[0] + a_inv[0][1] * n_thermal[1] + a_inv[0][2] * n_thermal[2],
1000        a_inv[1][0] * n_thermal[0] + a_inv[1][1] * n_thermal[1] + a_inv[1][2] * n_thermal[2],
1001        a_inv[2][0] * n_thermal[0] + a_inv[2][1] * n_thermal[1] + a_inv[2][2] * n_thermal[2],
1002    ];
1003
1004    // Compute residual stress in each ply
1005    let mut results = Vec::with_capacity(laminate.plies.len());
1006    for (ply, angle) in &laminate.plies {
1007        let theta = angle * PI / 180.0;
1008        let c = theta.cos();
1009        let s = theta.sin();
1010        let c2 = c * c;
1011        let s2 = s * s;
1012        let cs = c * s;
1013
1014        // Free thermal strain in global coords
1015        let alpha_x = c2 * alpha[0] + s2 * alpha[1];
1016        let alpha_y = s2 * alpha[0] + c2 * alpha[1];
1017        let alpha_xy = 2.0 * cs * (alpha[0] - alpha[1]);
1018
1019        // Mechanical strain = total strain - free thermal strain
1020        let mech_strain_global = [
1021            eps0[0] - alpha_x * delta_t,
1022            eps0[1] - alpha_y * delta_t,
1023            eps0[2] - alpha_xy * delta_t,
1024        ];
1025
1026        // Transform to material coords
1027        let mat_strain = transform_strain_to_material(mech_strain_global, *angle);
1028
1029        // Compute stress
1030        let q = ply.q_matrix();
1031        let stress = [
1032            q[0][0] * mat_strain[0] + q[0][1] * mat_strain[1],
1033            q[0][1] * mat_strain[0] + q[1][1] * mat_strain[1],
1034            q[2][2] * mat_strain[2],
1035        ];
1036
1037        results.push(stress);
1038    }
1039
1040    results
1041}
1042
1043// ---------------------------------------------------------------------------
1044// Interlaminar stresses (simplified)
1045// ---------------------------------------------------------------------------
1046
1047/// Estimate interlaminar shear stress at the interface between ply k and k+1.
1048///
1049/// Uses equilibrium-based approach: tau_xz at interface is proportional
1050/// to the jump in Q_bar * strain derivative.
1051#[allow(dead_code)]
1052pub fn interlaminar_shear_estimate(
1053    laminate: &Laminate,
1054    midplane_strain: [f64; 3],
1055    curvature: [f64; 3],
1056) -> Vec<f64> {
1057    let z_coords = laminate.ply_z_coords();
1058    let n = laminate.plies.len();
1059    if n < 2 {
1060        return Vec::new();
1061    }
1062
1063    let mut shear_stresses = Vec::with_capacity(n - 1);
1064
1065    // Accumulate stress resultant from bottom
1066    let mut sum_sx = 0.0;
1067
1068    for k in 0..n - 1 {
1069        let z_bot = z_coords[k];
1070        let z_top = z_coords[k + 1];
1071        let (ply, angle) = &laminate.plies[k];
1072        let qbar = ply.q_bar_matrix(*angle);
1073
1074        // Integrate sigma_x through the ply thickness
1075        // sigma_x = Q11*(eps_x0 + z*kappa_x) + Q12*(eps_y0 + z*kappa_y) + Q16*(gamma_xy0 + z*kappa_xy)
1076        // Integral from z_bot to z_top:
1077        let dz = z_top - z_bot;
1078        let z_avg = (z_bot + z_top) / 2.0;
1079
1080        let eps_x_avg = midplane_strain[0] + z_avg * curvature[0];
1081        let eps_y_avg = midplane_strain[1] + z_avg * curvature[1];
1082        let gam_avg = midplane_strain[2] + z_avg * curvature[2];
1083
1084        let sigma_x = qbar[0][0] * eps_x_avg + qbar[0][1] * eps_y_avg + qbar[0][2] * gam_avg;
1085        sum_sx += sigma_x * dz;
1086
1087        // Interlaminar shear ~ derivative of integrated sigma_x w.r.t. x
1088        // Simplified: just report accumulated resultant / total thickness as a proxy
1089        let total_h = laminate.total_thickness();
1090        shear_stresses.push(sum_sx / total_h);
1091    }
1092
1093    shear_stresses
1094}
1095
1096// ---------------------------------------------------------------------------
1097// Tests
1098// ---------------------------------------------------------------------------
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103
1104    const TOL: f64 = 1.0e-6;
1105
1106    #[test]
1107    fn test_voigt_reuss_ordering() {
1108        let c1 = IsotropicConstituent::new(70.0e9, 0.33, 2700.0, 0.3);
1109        let c2 = IsotropicConstituent::new(400.0e9, 0.25, 3200.0, 0.7);
1110        let constituents = [c1, c2];
1111        let ev = voigt_modulus(&constituents);
1112        let er = reuss_modulus(&constituents);
1113        assert!(ev > er, "Voigt ({ev}) should be > Reuss ({er})");
1114    }
1115
1116    #[test]
1117    fn test_voigt_density_weighted_average() {
1118        let c1 = IsotropicConstituent::new(70.0e9, 0.33, 2700.0, 0.4);
1119        let c2 = IsotropicConstituent::new(200.0e9, 0.30, 7800.0, 0.6);
1120        let constituents = [c1, c2];
1121        let rho = voigt_density(&constituents);
1122        let expected = 0.4 * 2700.0 + 0.6 * 7800.0;
1123        assert!(
1124            (rho - expected).abs() < TOL,
1125            "density = {rho} expected {expected}"
1126        );
1127    }
1128
1129    #[test]
1130    fn test_halpin_tsai_longitudinal_between_fiber_matrix() {
1131        let fiber = IsotropicConstituent::new(230.0e9, 0.25, 1800.0, 0.6);
1132        let matrix = IsotropicConstituent::new(3.5e9, 0.35, 1200.0, 0.4);
1133        let ht = HalpinTsai::new(fiber, matrix, 50.0);
1134        let e11 = ht.longitudinal_modulus();
1135        assert!(
1136            e11 > matrix.youngs_modulus && e11 < fiber.youngs_modulus,
1137            "E11 = {e11} not between matrix ({}) and fiber ({})",
1138            matrix.youngs_modulus,
1139            fiber.youngs_modulus
1140        );
1141    }
1142
1143    #[test]
1144    fn test_orthotropic_ply_q_matrix_symmetry() {
1145        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1146        let q = ply.q_matrix();
1147        assert!(
1148            (q[0][1] - q[1][0]).abs() < TOL,
1149            "Q[0][1]={} != Q[1][0]={}",
1150            q[0][1],
1151            q[1][0]
1152        );
1153    }
1154
1155    #[test]
1156    fn test_symmetric_crossply_b_near_zero() {
1157        let t = 0.125e-3_f64;
1158        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, t);
1159        let mut lam = Laminate::new();
1160        lam.add_ply(ply, 0.0);
1161        lam.add_ply(ply, 90.0);
1162        lam.add_ply(ply, 0.0);
1163        assert!(
1164            lam.is_symmetric(),
1165            "Symmetric [0/90/0] laminate should have B ~ 0"
1166        );
1167    }
1168
1169    #[test]
1170    fn test_laminate_single_0deg_effective_modulus_x() {
1171        let e11 = 140.0e9_f64;
1172        let e22 = 10.0e9_f64;
1173        let g12 = 5.0e9_f64;
1174        let nu12 = 0.3_f64;
1175        let t = 1.0e-3_f64;
1176        let ply = OrthotropicPly::new(e11, e22, g12, nu12, t);
1177        let mut lam = Laminate::new();
1178        lam.add_ply(ply, 0.0);
1179        let ex = lam.effective_modulus_x();
1180        assert!(
1181            (ex - e11).abs() / e11 < 0.05,
1182            "effective_modulus_x = {ex}, E11 = {e11}"
1183        );
1184    }
1185
1186    #[test]
1187    fn test_mori_tanaka_modulus_between_phases() {
1188        let matrix = IsotropicConstituent::new(3.5e9, 0.35, 1200.0, 0.7);
1189        let inclusion = IsotropicConstituent::new(70.0e9, 0.22, 2700.0, 0.3);
1190        let mt = MoriTanaka::new(matrix, inclusion);
1191        let e_eff = mt.effective_youngs_modulus();
1192        assert!(
1193            e_eff > matrix.youngs_modulus && e_eff < inclusion.youngs_modulus,
1194            "E_eff = {e_eff} not between matrix ({}) and inclusion ({})",
1195            matrix.youngs_modulus,
1196            inclusion.youngs_modulus
1197        );
1198    }
1199
1200    #[test]
1201    fn test_halpin_tsai_poisson_12() {
1202        let fiber = IsotropicConstituent::new(230.0e9, 0.25, 1800.0, 0.6);
1203        let matrix = IsotropicConstituent::new(3.5e9, 0.35, 1200.0, 0.4);
1204        let ht = HalpinTsai::new(fiber, matrix, 50.0);
1205        let nu12 = ht.poisson_12();
1206        let expected = 0.6 * 0.25 + 0.4 * 0.35;
1207        assert!(
1208            (nu12 - expected).abs() < TOL,
1209            "nu_12 = {nu12} expected {expected}"
1210        );
1211    }
1212
1213    // --- Max stress failure ---
1214
1215    #[test]
1216    fn test_max_stress_no_failure() {
1217        let s = PlyStrength::cfrp_typical();
1218        let sigma = [100.0e6, 10.0e6, 5.0e6];
1219        let r = max_stress_failure(sigma, &s);
1220        assert!(!r.failed);
1221        assert!(r.fi_fiber < 1.0);
1222        assert!(r.fi_matrix < 1.0);
1223        assert!(r.fi_shear < 1.0);
1224    }
1225
1226    #[test]
1227    fn test_max_stress_fiber_tension_failure() {
1228        let s = PlyStrength::cfrp_typical();
1229        let sigma = [2000.0e6, 0.0, 0.0]; // exceeds xt=1500 MPa
1230        let r = max_stress_failure(sigma, &s);
1231        assert!(r.failed);
1232        assert!(r.fi_fiber >= 1.0);
1233    }
1234
1235    #[test]
1236    fn test_max_stress_fiber_compression_failure() {
1237        let s = PlyStrength::cfrp_typical();
1238        let sigma = [-1500.0e6, 0.0, 0.0]; // exceeds xc=1200 MPa
1239        let r = max_stress_failure(sigma, &s);
1240        assert!(r.failed);
1241        assert!(r.fi_fiber >= 1.0);
1242    }
1243
1244    #[test]
1245    fn test_max_stress_matrix_tension_failure() {
1246        let s = PlyStrength::cfrp_typical();
1247        let sigma = [0.0, 60.0e6, 0.0]; // exceeds yt=50 MPa
1248        let r = max_stress_failure(sigma, &s);
1249        assert!(r.failed);
1250        assert!(r.fi_matrix >= 1.0);
1251    }
1252
1253    #[test]
1254    fn test_max_stress_shear_failure() {
1255        let s = PlyStrength::cfrp_typical();
1256        let sigma = [0.0, 0.0, 80.0e6]; // exceeds s12=70 MPa
1257        let r = max_stress_failure(sigma, &s);
1258        assert!(r.failed);
1259        assert!(r.fi_shear >= 1.0);
1260    }
1261
1262    // --- Tsai-Wu failure ---
1263
1264    #[test]
1265    fn test_tsai_wu_no_failure() {
1266        let s = PlyStrength::cfrp_typical();
1267        let sigma = [100.0e6, 5.0e6, 5.0e6];
1268        let fi = tsai_wu_failure(sigma, &s);
1269        assert!(fi < 1.0, "Tsai-Wu FI = {fi} should be < 1");
1270    }
1271
1272    #[test]
1273    fn test_tsai_wu_failure() {
1274        let s = PlyStrength::cfrp_typical();
1275        let sigma = [0.0, 60.0e6, 0.0]; // matrix tension failure
1276        let fi = tsai_wu_failure(sigma, &s);
1277        assert!(fi >= 1.0, "Tsai-Wu FI = {fi} should be >= 1");
1278    }
1279
1280    #[test]
1281    fn test_tsai_wu_zero_stress() {
1282        let s = PlyStrength::cfrp_typical();
1283        let sigma = [0.0, 0.0, 0.0];
1284        let fi = tsai_wu_failure(sigma, &s);
1285        assert!(
1286            fi.abs() < 1e-10,
1287            "zero stress should give zero FI, got {fi}"
1288        );
1289    }
1290
1291    // --- Hashin failure ---
1292
1293    #[test]
1294    fn test_hashin_no_failure() {
1295        let s = PlyStrength::cfrp_typical();
1296        let sigma = [100.0e6, 5.0e6, 5.0e6];
1297        let r = hashin_failure(sigma, &s);
1298        assert!(!r.failed);
1299    }
1300
1301    #[test]
1302    fn test_hashin_fiber_tension() {
1303        let s = PlyStrength::cfrp_typical();
1304        let sigma = [2000.0e6, 0.0, 0.0];
1305        let r = hashin_failure(sigma, &s);
1306        assert!(r.failed);
1307        assert!(r.fiber_tension >= 1.0);
1308    }
1309
1310    #[test]
1311    fn test_hashin_fiber_compression() {
1312        let s = PlyStrength::cfrp_typical();
1313        let sigma = [-1500.0e6, 0.0, 0.0];
1314        let r = hashin_failure(sigma, &s);
1315        assert!(r.failed);
1316        assert!(r.fiber_compression >= 1.0);
1317    }
1318
1319    #[test]
1320    fn test_hashin_matrix_tension() {
1321        let s = PlyStrength::cfrp_typical();
1322        let sigma = [0.0, 60.0e6, 0.0];
1323        let r = hashin_failure(sigma, &s);
1324        assert!(r.failed);
1325        assert!(r.matrix_tension >= 1.0);
1326    }
1327
1328    #[test]
1329    fn test_hashin_matrix_compression() {
1330        let s = PlyStrength::cfrp_typical();
1331        let sigma = [0.0, -300.0e6, 0.0];
1332        let r = hashin_failure(sigma, &s);
1333        assert!(r.failed);
1334        assert!(r.matrix_compression >= 1.0);
1335    }
1336
1337    // --- Puck failure ---
1338
1339    #[test]
1340    fn test_puck_no_failure() {
1341        let s = PlyStrength::cfrp_typical();
1342        let sigma = [100.0e6, 5.0e6, 5.0e6];
1343        let r = puck_failure(sigma, &s);
1344        assert!(!r.failed);
1345    }
1346
1347    #[test]
1348    fn test_puck_fiber_failure() {
1349        let s = PlyStrength::cfrp_typical();
1350        let sigma = [2000.0e6, 0.0, 0.0];
1351        let r = puck_failure(sigma, &s);
1352        assert!(r.failed);
1353        assert!(r.ff_effort >= 1.0);
1354    }
1355
1356    #[test]
1357    fn test_puck_iff_tension() {
1358        let s = PlyStrength::cfrp_typical();
1359        let sigma = [0.0, 60.0e6, 0.0];
1360        let r = puck_failure(sigma, &s);
1361        assert!(r.failed);
1362        assert!(r.iff_effort >= 1.0);
1363    }
1364
1365    #[test]
1366    fn test_puck_iff_compression() {
1367        let s = PlyStrength::cfrp_typical();
1368        let sigma = [0.0, -300.0e6, 50.0e6];
1369        let r = puck_failure(sigma, &s);
1370        // Under heavy compression with shear, should eventually fail
1371        assert!(r.iff_effort > 0.0);
1372    }
1373
1374    // --- Ply-by-ply stress ---
1375
1376    #[test]
1377    fn test_ply_by_ply_stress_single_ply() {
1378        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1379        let mut lam = Laminate::new();
1380        lam.add_ply(ply, 0.0);
1381
1382        let strain = [0.001, 0.0, 0.0];
1383        let curv = [0.0, 0.0, 0.0];
1384        let results = ply_by_ply_stress(&lam, strain, curv);
1385
1386        assert_eq!(results.len(), 1);
1387        assert!(
1388            results[0].stress[0] > 0.0,
1389            "tensile stress in fiber direction"
1390        );
1391    }
1392
1393    #[test]
1394    fn test_ply_by_ply_stress_symmetric_laminate() {
1395        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1396        let mut lam = Laminate::new();
1397        lam.add_ply(ply, 0.0);
1398        lam.add_ply(ply, 90.0);
1399        lam.add_ply(ply, 0.0);
1400
1401        let strain = [0.001, 0.0, 0.0];
1402        let curv = [0.0, 0.0, 0.0];
1403        let results = ply_by_ply_stress(&lam, strain, curv);
1404
1405        assert_eq!(results.len(), 3);
1406        // 0-degree plies should have fiber-direction tension
1407        assert!(results[0].stress[0] > 0.0);
1408        assert!(results[2].stress[0] > 0.0);
1409    }
1410
1411    #[test]
1412    fn test_ply_by_ply_stress_with_curvature() {
1413        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1414        let mut lam = Laminate::new();
1415        lam.add_ply(ply, 0.0);
1416        lam.add_ply(ply, 0.0);
1417
1418        let strain = [0.0, 0.0, 0.0];
1419        let curv = [10.0, 0.0, 0.0]; // bending
1420        let results = ply_by_ply_stress(&lam, strain, curv);
1421
1422        // Top and bottom plies should have opposite signs of stress
1423        let s_top = results[1].stress[0];
1424        let s_bot = results[0].stress[0];
1425        assert!(
1426            s_top * s_bot < 0.0 || (s_top.abs() < 1e-3 && s_bot.abs() < 1e-3),
1427            "bending should cause opposite signs: top={s_top}, bot={s_bot}"
1428        );
1429    }
1430
1431    // --- Progressive failure ---
1432
1433    #[test]
1434    fn test_progressive_failure_returns_result() {
1435        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1436        let mut lam = Laminate::new();
1437        lam.add_ply(ply, 0.0);
1438        lam.add_ply(ply, 90.0);
1439        lam.add_ply(ply, 0.0);
1440
1441        let s = PlyStrength::cfrp_typical();
1442        let base_strain = [0.01, 0.0, 0.0]; // 1% strain at full load
1443
1444        let result = progressive_failure_analysis(&lam, base_strain, &s, 2.0, 100);
1445        assert!(result.first_ply_failure_load > 0.0);
1446        assert!(result.first_ply_failure_load <= result.ultimate_load);
1447        assert!(result.n_failed_plies > 0);
1448    }
1449
1450    #[test]
1451    fn test_progressive_failure_90deg_fails_first() {
1452        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1453        let mut lam = Laminate::new();
1454        lam.add_ply(ply, 0.0);
1455        lam.add_ply(ply, 90.0); // transverse ply, weaker in x-direction
1456        lam.add_ply(ply, 0.0);
1457
1458        let s = PlyStrength::cfrp_typical();
1459        let base_strain = [0.01, 0.0, 0.0];
1460
1461        let result = progressive_failure_analysis(&lam, base_strain, &s, 2.0, 200);
1462        // The 90-degree ply should fail first (its transverse strength is lower)
1463        assert_eq!(
1464            result.first_failed_ply, 1,
1465            "90-degree ply should fail first"
1466        );
1467    }
1468
1469    // --- Thermal residual stresses ---
1470
1471    #[test]
1472    fn test_thermal_strain() {
1473        let alpha = [1.0e-6, 30.0e-6]; // typical CFRP CTEs
1474        let dt = -150.0; // cooling from cure
1475        let eps = thermal_strain(alpha, dt);
1476        assert!((eps[0] - (-150.0e-6)).abs() < 1e-12);
1477        assert!((eps[1] - (-4500.0e-6)).abs() < 1e-12);
1478        assert!(eps[2].abs() < 1e-15);
1479    }
1480
1481    #[test]
1482    fn test_thermal_residual_single_ply_zero() {
1483        // A single ply has no CTE mismatch with itself
1484        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1485        let mut lam = Laminate::new();
1486        lam.add_ply(ply, 0.0);
1487
1488        let alpha = [1.0e-6, 30.0e-6];
1489        let residuals = thermal_residual_stresses(&lam, alpha, -150.0);
1490        assert_eq!(residuals.len(), 1);
1491        // Single unconstrained ply should have near-zero residual stress
1492        for s in &residuals[0] {
1493            assert!(s.abs() < 1.0, "single ply residual should be ~0, got {s}");
1494        }
1495    }
1496
1497    #[test]
1498    fn test_thermal_residual_crossply_has_stress() {
1499        // [0/90] crossply should develop residual stresses from CTE mismatch
1500        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1501        let mut lam = Laminate::new();
1502        lam.add_ply(ply, 0.0);
1503        lam.add_ply(ply, 90.0);
1504
1505        let alpha = [1.0e-6, 30.0e-6];
1506        let residuals = thermal_residual_stresses(&lam, alpha, -150.0);
1507        assert_eq!(residuals.len(), 2);
1508        // Should have non-zero residual stresses
1509        let has_nonzero = residuals
1510            .iter()
1511            .any(|s| s[0].abs() > 1.0 || s[1].abs() > 1.0);
1512        assert!(has_nonzero, "crossply should have residual stresses");
1513    }
1514
1515    // --- Interlaminar shear ---
1516
1517    #[test]
1518    fn test_interlaminar_shear_single_ply() {
1519        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1520        let mut lam = Laminate::new();
1521        lam.add_ply(ply, 0.0);
1522
1523        let strain = [0.001, 0.0, 0.0];
1524        let curv = [0.0, 0.0, 0.0];
1525        let shear = interlaminar_shear_estimate(&lam, strain, curv);
1526        assert!(shear.is_empty(), "single ply has no interfaces");
1527    }
1528
1529    #[test]
1530    fn test_interlaminar_shear_two_plies() {
1531        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1532        let mut lam = Laminate::new();
1533        lam.add_ply(ply, 0.0);
1534        lam.add_ply(ply, 90.0);
1535
1536        let strain = [0.001, 0.0, 0.0];
1537        let curv = [0.0, 0.0, 0.0];
1538        let shear = interlaminar_shear_estimate(&lam, strain, curv);
1539        assert_eq!(shear.len(), 1);
1540    }
1541
1542    #[test]
1543    fn test_interlaminar_shear_three_plies() {
1544        let ply = OrthotropicPly::new(140.0e9, 10.0e9, 5.0e9, 0.3, 0.125e-3);
1545        let mut lam = Laminate::new();
1546        lam.add_ply(ply, 0.0);
1547        lam.add_ply(ply, 90.0);
1548        lam.add_ply(ply, 0.0);
1549
1550        let strain = [0.001, 0.0, 0.0];
1551        let curv = [10.0, 0.0, 0.0]; // bending
1552        let shear = interlaminar_shear_estimate(&lam, strain, curv);
1553        assert_eq!(shear.len(), 2);
1554    }
1555
1556    // --- Ply strength constructors ---
1557
1558    #[test]
1559    fn test_ply_strength_cfrp() {
1560        let s = PlyStrength::cfrp_typical();
1561        assert!(s.xt > s.yt, "fiber strength should exceed transverse");
1562        assert!(
1563            s.xc > s.yc,
1564            "fiber compression should exceed transverse compression"
1565        );
1566    }
1567
1568    #[test]
1569    fn test_ply_strength_gfrp() {
1570        let s = PlyStrength::gfrp_typical();
1571        assert!(s.xt > 0.0);
1572        assert!(s.s12 > 0.0);
1573    }
1574
1575    #[test]
1576    fn test_ply_strength_new() {
1577        let s = PlyStrength::new(100.0, 80.0, 10.0, 50.0, 20.0);
1578        assert_eq!(s.xt, 100.0);
1579        assert_eq!(s.yc, 50.0);
1580    }
1581
1582    // --- Transform strain ---
1583
1584    #[test]
1585    fn test_transform_strain_zero_angle() {
1586        let strain = [0.001, 0.002, 0.0005];
1587        let transformed = transform_strain_to_material(strain, 0.0);
1588        for i in 0..3 {
1589            assert!((transformed[i] - strain[i]).abs() < 1e-12);
1590        }
1591    }
1592
1593    #[test]
1594    fn test_transform_strain_90deg() {
1595        let strain = [0.001, 0.002, 0.0];
1596        let transformed = transform_strain_to_material(strain, 90.0);
1597        // At 90 degrees, eps_1 and eps_2 should swap
1598        assert!((transformed[0] - 0.002).abs() < 1e-10);
1599        assert!((transformed[1] - 0.001).abs() < 1e-10);
1600    }
1601}