Skip to main content

oxiphysics_materials/
composite_materials.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Composite material mechanics.
5//!
6//! Provides models for fiber-reinforced composites, including the rule of
7//! mixtures, Halpin-Tsai equations, classical lamination theory (CLT), and
8//! failure criteria such as Tsai-Wu.
9
10/// Properties of a reinforcing fiber phase.
11#[allow(dead_code)]
12#[derive(Debug, Clone, PartialEq)]
13pub struct FiberProperties {
14    /// Longitudinal (axial) Young's modulus \[Pa\].
15    pub modulus_longitudinal: f64,
16    /// Transverse Young's modulus \[Pa\].
17    pub modulus_transverse: f64,
18    /// Longitudinal tensile strength \[Pa\].
19    pub strength_longitudinal: f64,
20    /// Fiber volume fraction (0–1).
21    pub volume_fraction: f64,
22    /// In-plane orientation angle of the fiber \[radians\].
23    pub orientation: f64,
24}
25
26impl FiberProperties {
27    /// Create a new `FiberProperties`.
28    pub fn new(
29        modulus_longitudinal: f64,
30        modulus_transverse: f64,
31        strength_longitudinal: f64,
32        volume_fraction: f64,
33        orientation: f64,
34    ) -> Self {
35        Self {
36            modulus_longitudinal,
37            modulus_transverse,
38            strength_longitudinal,
39            volume_fraction,
40            orientation,
41        }
42    }
43}
44
45/// Properties of the matrix (binder) phase.
46#[allow(dead_code)]
47#[derive(Debug, Clone, PartialEq)]
48pub struct MatrixProperties {
49    /// Young's modulus of the matrix \[Pa\].
50    pub modulus: f64,
51    /// Poisson's ratio of the matrix (dimensionless).
52    pub poisson_ratio: f64,
53}
54
55impl MatrixProperties {
56    /// Create a new `MatrixProperties`.
57    pub fn new(modulus: f64, poisson_ratio: f64) -> Self {
58        Self {
59            modulus,
60            poisson_ratio,
61        }
62    }
63}
64
65/// A single ply in a composite laminate.
66#[allow(dead_code)]
67#[derive(Debug, Clone, PartialEq)]
68pub struct Ply {
69    /// Ply angle measured from the laminate reference axis \[radians\].
70    pub angle: f64,
71    /// Ply thickness \[m\].
72    pub thickness: f64,
73    /// Longitudinal modulus E11 \[Pa\].
74    pub e11: f64,
75    /// Transverse modulus E22 \[Pa\].
76    pub e22: f64,
77    /// In-plane shear modulus G12 \[Pa\].
78    pub g12: f64,
79    /// Major Poisson's ratio ν12 (dimensionless).
80    pub nu12: f64,
81}
82
83impl Ply {
84    /// Create a new `Ply`.
85    #[allow(clippy::too_many_arguments)]
86    pub fn new(angle: f64, thickness: f64, e11: f64, e22: f64, g12: f64, nu12: f64) -> Self {
87        Self {
88            angle,
89            thickness,
90            e11,
91            e22,
92            g12,
93            nu12,
94        }
95    }
96}
97
98/// A stack of plies forming a composite laminate.
99#[allow(dead_code)]
100#[derive(Debug, Clone)]
101pub struct CompositeLayup {
102    /// Ordered stack of plies (bottom to top).
103    pub plies: Vec<Ply>,
104}
105
106impl CompositeLayup {
107    /// Create a new `CompositeLayup` from a vector of plies.
108    pub fn new(plies: Vec<Ply>) -> Self {
109        Self { plies }
110    }
111
112    /// Total laminate thickness \[m\].
113    pub fn total_thickness(&self) -> f64 {
114        self.plies.iter().map(|p| p.thickness).sum()
115    }
116}
117
118/// Rule-of-mixtures modulus bounds.
119///
120/// Returns `(E_voigt, E_reuss)` where:
121/// - `E_voigt` is the upper (Voigt) bound: iso-strain assumption.
122/// - `E_reuss` is the lower (Reuss) bound: iso-stress assumption.
123///
124/// # Arguments
125/// * `e_fiber` – fiber modulus \[Pa\]
126/// * `e_matrix` – matrix modulus \[Pa\]
127/// * `vf` – fiber volume fraction (0–1)
128pub fn rule_of_mixtures_modulus(e_fiber: f64, e_matrix: f64, vf: f64) -> (f64, f64) {
129    let vm = 1.0 - vf;
130    let e_voigt = e_fiber * vf + e_matrix * vm;
131    let e_reuss = 1.0 / (vf / e_fiber + vm / e_matrix);
132    (e_voigt, e_reuss)
133}
134
135/// Halpin-Tsai composite equations.
136///
137/// Returns `(E11, E22, G12)` for a unidirectional composite.
138///
139/// # Arguments
140/// * `fiber` – fiber properties
141/// * `matrix` – matrix properties
142/// * `xi_e` – Halpin-Tsai reinforcement factor for E22 (typical: 2 for circular fibers)
143/// * `xi_g` – Halpin-Tsai reinforcement factor for G12 (typical: 1)
144pub fn halpin_tsai(
145    fiber: &FiberProperties,
146    matrix: &MatrixProperties,
147    xi_e: f64,
148    xi_g: f64,
149) -> (f64, f64, f64) {
150    let vf = fiber.volume_fraction;
151    let vm = 1.0 - vf;
152
153    // E11 — rule of mixtures (Voigt)
154    let e11 = fiber.modulus_longitudinal * vf + matrix.modulus * vm;
155
156    // E22 — Halpin-Tsai
157    let eta_e = (fiber.modulus_transverse / matrix.modulus - 1.0)
158        / (fiber.modulus_transverse / matrix.modulus + xi_e);
159    let e22 = matrix.modulus * (1.0 + xi_e * eta_e * vf) / (1.0 - eta_e * vf);
160
161    // G12 — Halpin-Tsai (approximate fiber shear modulus from isotropic assumption)
162    let g_fiber = fiber.modulus_longitudinal / (2.0 * (1.0 + 0.25)); // ν_f ≈ 0.25
163    let g_matrix = matrix.modulus / (2.0 * (1.0 + matrix.poisson_ratio));
164    let eta_g = (g_fiber / g_matrix - 1.0) / (g_fiber / g_matrix + xi_g);
165    let g12 = g_matrix * (1.0 + xi_g * eta_g * vf) / (1.0 - eta_g * vf);
166
167    (e11, e22, g12)
168}
169
170/// ABD matrices from Classical Lamination Theory (CLT).
171///
172/// Returns `(A, B, D)` where each matrix is stored in **Voigt notation** as a
173/// flat 6-element array `[A11, A12, A16, A22, A26, A66]` (and likewise for B
174/// and D).  This covers the in-plane (`A`), coupling (`B`), and bending (`D`)
175/// stiffness sub-matrices.
176///
177/// # Arguments
178/// * `layup` – the composite laminate definition
179pub fn classical_lamination_theory(layup: &CompositeLayup) -> ([f64; 6], [f64; 6], [f64; 6]) {
180    let mut a = [0.0f64; 6];
181    let mut b = [0.0f64; 6];
182    let mut d = [0.0f64; 6];
183
184    // Mid-plane z coordinate (origin at laminate mid-plane).
185    let total_h = layup.total_thickness();
186    let mut z_bottom = -total_h / 2.0;
187
188    for ply in &layup.plies {
189        let z_top = z_bottom + ply.thickness;
190        let z_mid = (z_bottom + z_top) / 2.0;
191
192        // Reduced stiffness in principal axes.
193        let nu21 = ply.nu12 * ply.e22 / ply.e11;
194        let denom = 1.0 - ply.nu12 * nu21;
195        let q11 = ply.e11 / denom;
196        let q22 = ply.e22 / denom;
197        let q12 = ply.nu12 * ply.e22 / denom;
198        let q66 = ply.g12;
199
200        // Transform Q to laminate axes.
201        let c = ply.angle.cos();
202        let s = ply.angle.sin();
203        let c2 = c * c;
204        let s2 = s * s;
205        let cs = c * s;
206
207        // Q-bar components (Voigt: [11, 12, 16, 22, 26, 66]).
208        let qb11 = q11 * c2 * c2 + 2.0 * (q12 + 2.0 * q66) * s2 * c2 + q22 * s2 * s2;
209        let qb12 = (q11 + q22 - 4.0 * q66) * s2 * c2 + q12 * (c2 * c2 + s2 * s2);
210        let qb16 = (q11 - q12 - 2.0 * q66) * cs * c2 - (q22 - q12 - 2.0 * q66) * cs * s2;
211        let qb22 = q11 * s2 * s2 + 2.0 * (q12 + 2.0 * q66) * s2 * c2 + q22 * c2 * c2;
212        let qb26 = (q11 - q12 - 2.0 * q66) * cs * s2 - (q22 - q12 - 2.0 * q66) * cs * c2;
213        let qb66 = (q11 + q22 - 2.0 * q12 - 2.0 * q66) * s2 * c2 + q66 * (c2 * c2 + s2 * s2);
214
215        let qbar = [qb11, qb12, qb16, qb22, qb26, qb66];
216
217        let dz = z_top - z_bottom;
218        let dz2 = (z_top * z_top - z_bottom * z_bottom) / 2.0;
219        let dz3 = (z_top * z_top * z_top - z_bottom * z_bottom * z_bottom) / 3.0;
220
221        for i in 0..6 {
222            a[i] += qbar[i] * dz;
223            b[i] += qbar[i] * dz2;
224            d[i] += qbar[i] * dz3;
225        }
226
227        // Suppress unused variable warnings for z_mid (used conceptually).
228        let _ = z_mid;
229        z_bottom = z_top;
230    }
231
232    (a, b, d)
233}
234
235/// Tsai-Wu tensor failure criterion.
236///
237/// Returns the Tsai-Wu failure index `FI`.  Failure is predicted when `FI ≥ 1`.
238///
239/// # Arguments
240/// * `sigma` – stress state `[σ1, σ2, τ12]` \[Pa\]
241/// * `f1t`   – longitudinal tensile strength \[Pa\]
242/// * `f1c`   – longitudinal compressive strength \[Pa\] (positive value)
243/// * `f2t`   – transverse tensile strength \[Pa\]
244/// * `f2c`   – transverse compressive strength \[Pa\] (positive value)
245/// * `f12`   – in-plane shear strength \[Pa\]
246#[allow(clippy::too_many_arguments)]
247pub fn failure_criterion_tsai_wu(
248    sigma: [f64; 3],
249    f1t: f64,
250    f1c: f64,
251    f2t: f64,
252    f2c: f64,
253    f12: f64,
254) -> f64 {
255    let [s1, s2, t12] = sigma;
256
257    // Strength tensors (first-order and second-order).
258    let f1 = 1.0 / f1t - 1.0 / f1c;
259    let f2 = 1.0 / f2t - 1.0 / f2c;
260    let f11 = 1.0 / (f1t * f1c);
261    let f22 = 1.0 / (f2t * f2c);
262    let f66 = 1.0 / (f12 * f12);
263    // Interaction term F12 (approximate: -0.5 * sqrt(F11 * F22)).
264    let f12_int = -0.5 * (f11 * f22).sqrt();
265
266    f1 * s1 + f2 * s2 + f11 * s1 * s1 + f22 * s2 * s2 + f66 * t12 * t12 + 2.0 * f12_int * s1 * s2
267}
268
269/// Interlaminar shear stress (ILSS) from short beam shear theory.
270///
271/// Returns the maximum interlaminar shear stress \[Pa\] from a 3-point bend test.
272///
273/// # Arguments
274/// * `p_max`  – maximum applied load \[N\]
275/// * `width`  – specimen width \[m\]
276/// * `thickness` – specimen thickness \[m\]
277pub fn interlaminar_shear(p_max: f64, width: f64, thickness: f64) -> f64 {
278    // ILSS = 0.75 * P_max / (width * thickness)  (short-beam formula)
279    0.75 * p_max / (width * thickness)
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::f64::consts::PI;
286
287    const EPS: f64 = 1e-9;
288
289    // ------------------------------------------------------------------
290    // FiberProperties / MatrixProperties construction
291    // ------------------------------------------------------------------
292
293    #[test]
294    fn test_fiber_props_new() {
295        let f = FiberProperties::new(230e9, 15e9, 3.5e9, 0.6, 0.0);
296        assert_eq!(f.modulus_longitudinal, 230e9);
297        assert_eq!(f.volume_fraction, 0.6);
298    }
299
300    #[test]
301    fn test_matrix_props_new() {
302        let m = MatrixProperties::new(3.5e9, 0.35);
303        assert_eq!(m.modulus, 3.5e9);
304        assert!((m.poisson_ratio - 0.35).abs() < EPS);
305    }
306
307    // ------------------------------------------------------------------
308    // Rule of Mixtures
309    // ------------------------------------------------------------------
310
311    #[test]
312    fn test_rom_voigt_bounds() {
313        // Voigt ≥ Reuss always.
314        let (ev, er) = rule_of_mixtures_modulus(200e9, 3e9, 0.5);
315        assert!(ev > er, "Voigt {ev} should exceed Reuss {er}");
316    }
317
318    #[test]
319    fn test_rom_pure_fiber() {
320        // When vf = 1.0, both bounds equal E_fiber.
321        let (ev, er) = rule_of_mixtures_modulus(200e9, 3e9, 1.0);
322        assert!((ev - 200e9).abs() < 1.0, "Voigt at vf=1 should be E_fiber");
323        assert!((er - 200e9).abs() < 1.0, "Reuss at vf=1 should be E_fiber");
324    }
325
326    #[test]
327    fn test_rom_pure_matrix() {
328        // When vf = 0.0, both bounds equal E_matrix.
329        let (ev, er) = rule_of_mixtures_modulus(200e9, 3e9, 0.0);
330        assert!((ev - 3e9).abs() < 1.0);
331        assert!((er - 3e9).abs() < 1.0);
332    }
333
334    #[test]
335    fn test_rom_midpoint_voigt() {
336        // vf = 0.5: Voigt = (E_f + E_m) / 2
337        let ef = 100.0;
338        let em = 50.0;
339        let (ev, _) = rule_of_mixtures_modulus(ef, em, 0.5);
340        assert!((ev - 75.0).abs() < EPS);
341    }
342
343    #[test]
344    fn test_rom_reuss_harmonic_mean() {
345        let ef = 100.0;
346        let em = 50.0;
347        let vf = 0.4;
348        let (_, er) = rule_of_mixtures_modulus(ef, em, vf);
349        let expected = 1.0 / (vf / ef + (1.0 - vf) / em);
350        assert!((er - expected).abs() < EPS);
351    }
352
353    #[test]
354    fn test_rom_symmetry_voigt() {
355        // Swapping fiber and matrix fractions should give the same Voigt value
356        // if E_f = E_m (degenerate case).
357        let (ev1, _) = rule_of_mixtures_modulus(100.0, 100.0, 0.3);
358        let (ev2, _) = rule_of_mixtures_modulus(100.0, 100.0, 0.7);
359        assert!((ev1 - ev2).abs() < EPS, "Degenerate: ev1={ev1}, ev2={ev2}");
360    }
361
362    // ------------------------------------------------------------------
363    // Halpin-Tsai
364    // ------------------------------------------------------------------
365
366    #[test]
367    fn test_halpin_tsai_e11_rule_of_mixtures() {
368        let fiber = FiberProperties::new(230e9, 15e9, 3.5e9, 0.6, 0.0);
369        let matrix = MatrixProperties::new(3.5e9, 0.35);
370        let (e11, _, _) = halpin_tsai(&fiber, &matrix, 2.0, 1.0);
371        let (ev, _) = rule_of_mixtures_modulus(
372            fiber.modulus_longitudinal,
373            matrix.modulus,
374            fiber.volume_fraction,
375        );
376        assert!(
377            (e11 - ev).abs() < 1.0,
378            "E11 from HT should equal Voigt ROM: e11={e11}, ev={ev}"
379        );
380    }
381
382    #[test]
383    fn test_halpin_tsai_e22_positive() {
384        let fiber = FiberProperties::new(230e9, 15e9, 3.5e9, 0.6, 0.0);
385        let matrix = MatrixProperties::new(3.5e9, 0.35);
386        let (_, e22, _) = halpin_tsai(&fiber, &matrix, 2.0, 1.0);
387        assert!(e22 > 0.0, "E22 should be positive, got {e22}");
388    }
389
390    #[test]
391    fn test_halpin_tsai_g12_positive() {
392        let fiber = FiberProperties::new(230e9, 15e9, 3.5e9, 0.6, 0.0);
393        let matrix = MatrixProperties::new(3.5e9, 0.35);
394        let (_, _, g12) = halpin_tsai(&fiber, &matrix, 2.0, 1.0);
395        assert!(g12 > 0.0, "G12 should be positive, got {g12}");
396    }
397
398    #[test]
399    fn test_halpin_tsai_e22_above_matrix_modulus() {
400        // With fibers present E22 should exceed the pure matrix modulus.
401        let fiber = FiberProperties::new(230e9, 15e9, 3.5e9, 0.5, 0.0);
402        let matrix = MatrixProperties::new(3.5e9, 0.35);
403        let (_, e22, _) = halpin_tsai(&fiber, &matrix, 2.0, 1.0);
404        assert!(e22 > matrix.modulus, "E22 should exceed matrix modulus");
405    }
406
407    // ------------------------------------------------------------------
408    // Classical Lamination Theory
409    // ------------------------------------------------------------------
410
411    #[test]
412    fn test_clt_a_matrix_positive_diagonal() {
413        // A symmetric [0/90]s laminate should have positive A11 and A22.
414        let ply_0 = Ply::new(0.0, 0.001, 140e9, 10e9, 5e9, 0.3);
415        let ply_90 = Ply::new(PI / 2.0, 0.001, 140e9, 10e9, 5e9, 0.3);
416        let layup = CompositeLayup::new(vec![
417            ply_0,
418            ply_90.clone(),
419            ply_90,
420            Ply::new(0.0, 0.001, 140e9, 10e9, 5e9, 0.3),
421        ]);
422        let (a, _, _) = classical_lamination_theory(&layup);
423        assert!(a[0] > 0.0, "A11 should be positive, got {}", a[0]);
424        assert!(a[3] > 0.0, "A22 should be positive, got {}", a[3]);
425    }
426
427    #[test]
428    fn test_clt_b_matrix_zero_symmetric() {
429        // A symmetric laminate ([0/90]s) has zero B matrix.
430        let ply = Ply::new(0.0, 0.001, 140e9, 10e9, 5e9, 0.3);
431        let ply90 = Ply::new(PI / 2.0, 0.001, 140e9, 10e9, 5e9, 0.3);
432        let layup = CompositeLayup::new(vec![ply.clone(), ply90.clone(), ply90, ply]);
433        let (_, b, _) = classical_lamination_theory(&layup);
434        for (i, &bi) in b.iter().enumerate() {
435            assert!(
436                bi.abs() < 1.0,
437                "B[{i}] = {bi} should be ~0 for symmetric laminate"
438            );
439        }
440    }
441
442    #[test]
443    fn test_clt_single_ply_thickness() {
444        // For a single unidirectional ply, A11 ≈ Q11 * h.
445        let h = 0.002;
446        let ply = Ply::new(0.0, h, 140e9, 10e9, 5e9, 0.3);
447        let layup = CompositeLayup::new(vec![ply]);
448        let (a, _, _) = classical_lamination_theory(&layup);
449        let nu21 = 0.3 * 10e9 / 140e9;
450        let q11 = 140e9 / (1.0 - 0.3 * nu21);
451        let expected_a11 = q11 * h;
452        let rel_err = (a[0] - expected_a11).abs() / expected_a11;
453        assert!(rel_err < 1e-6, "A11={} expected={expected_a11}", a[0]);
454    }
455
456    #[test]
457    fn test_clt_d_matrix_positive_diagonal() {
458        let ply = Ply::new(0.0, 0.002, 140e9, 10e9, 5e9, 0.3);
459        let layup = CompositeLayup::new(vec![ply]);
460        let (_, _, d) = classical_lamination_theory(&layup);
461        assert!(d[0] > 0.0, "D11 should be positive");
462        assert!(d[3] > 0.0, "D22 should be positive");
463    }
464
465    // ------------------------------------------------------------------
466    // Tsai-Wu failure criterion
467    // ------------------------------------------------------------------
468
469    #[test]
470    fn test_tsai_wu_zero_stress() {
471        let fi = failure_criterion_tsai_wu([0.0, 0.0, 0.0], 1.5e9, 1.0e9, 0.05e9, 0.25e9, 0.07e9);
472        assert!((fi).abs() < EPS, "FI at zero stress should be ~0, got {fi}");
473    }
474
475    #[test]
476    fn test_tsai_wu_uniaxial_at_strength() {
477        // Apply exactly the longitudinal tensile strength → FI should be ~1.
478        let f1t = 1.5e9_f64;
479        let f1c = 1.0e9_f64;
480        // f1*σ1 + f11*σ1² = 1  when σ1 = f1t (by definition of the criterion).
481        let fi = failure_criterion_tsai_wu([f1t, 0.0, 0.0], f1t, f1c, 0.05e9, 0.25e9, 0.07e9);
482        // Should be close to 1.
483        assert!(
484            (fi - 1.0).abs() < 0.1,
485            "FI at longitudinal tensile strength should be ~1, got {fi}"
486        );
487    }
488
489    #[test]
490    fn test_tsai_wu_positive_above_threshold() {
491        // For any nonzero shear the FI should increase.
492        let fi0 = failure_criterion_tsai_wu([0.0, 0.0, 0.0], 1.5e9, 1.0e9, 0.05e9, 0.25e9, 0.07e9);
493        let fi1 = failure_criterion_tsai_wu([0.0, 0.0, 1e7], 1.5e9, 1.0e9, 0.05e9, 0.25e9, 0.07e9);
494        assert!(fi1 > fi0, "Non-zero shear should increase FI");
495    }
496
497    // ------------------------------------------------------------------
498    // Interlaminar shear
499    // ------------------------------------------------------------------
500
501    #[test]
502    fn test_ilss_formula() {
503        let ilss = interlaminar_shear(1000.0, 0.02, 0.004);
504        let expected = 0.75 * 1000.0 / (0.02 * 0.004);
505        assert!((ilss - expected).abs() < EPS);
506    }
507
508    #[test]
509    fn test_ilss_scales_with_load() {
510        let i1 = interlaminar_shear(500.0, 0.02, 0.004);
511        let i2 = interlaminar_shear(1000.0, 0.02, 0.004);
512        assert!(
513            (i2 - 2.0 * i1).abs() < EPS,
514            "ILSS should double when load doubles"
515        );
516    }
517
518    #[test]
519    fn test_ilss_positive() {
520        let ilss = interlaminar_shear(800.0, 0.015, 0.003);
521        assert!(ilss > 0.0, "ILSS must be positive");
522    }
523
524    // ------------------------------------------------------------------
525    // CompositeLayup helpers
526    // ------------------------------------------------------------------
527
528    #[test]
529    fn test_total_thickness() {
530        let plies = vec![
531            Ply::new(0.0, 0.001, 140e9, 10e9, 5e9, 0.3),
532            Ply::new(PI / 4.0, 0.002, 140e9, 10e9, 5e9, 0.3),
533        ];
534        let layup = CompositeLayup::new(plies);
535        assert!((layup.total_thickness() - 0.003).abs() < EPS);
536    }
537
538    #[test]
539    fn test_ply_new_fields() {
540        let ply = Ply::new(PI / 4.0, 0.001, 140e9, 10e9, 5e9, 0.28);
541        assert!((ply.angle - PI / 4.0).abs() < EPS);
542        assert!((ply.nu12 - 0.28).abs() < EPS);
543    }
544
545    // ------------------------------------------------------------------
546    // Additional edge-case tests
547    // ------------------------------------------------------------------
548
549    #[test]
550    fn test_rom_modulus_between_bounds() {
551        let (ev, er) = rule_of_mixtures_modulus(200e9, 5e9, 0.3);
552        // Any physically meaningful effective modulus lies within the bounds.
553        assert!(ev >= er);
554        assert!(ev >= 5e9);
555        assert!(er >= 5e9);
556        assert!(ev <= 200e9);
557    }
558
559    #[test]
560    fn test_halpin_tsai_varies_with_vf() {
561        let f1 = FiberProperties::new(230e9, 15e9, 3.5e9, 0.3, 0.0);
562        let f2 = FiberProperties::new(230e9, 15e9, 3.5e9, 0.6, 0.0);
563        let matrix = MatrixProperties::new(3.5e9, 0.35);
564        let (e11_low, _, _) = halpin_tsai(&f1, &matrix, 2.0, 1.0);
565        let (e11_high, _, _) = halpin_tsai(&f2, &matrix, 2.0, 1.0);
566        assert!(
567            e11_high > e11_low,
568            "Higher fiber volume fraction should give higher E11"
569        );
570    }
571
572    #[test]
573    fn test_tsai_wu_compressive_stress_increases_fi() {
574        // Compressive σ1 (negative) also contributes to failure index via F11.
575        let f1t = 1.5e9_f64;
576        let f1c = 1.0e9_f64;
577        let fi_ten = failure_criterion_tsai_wu([1e8, 0.0, 0.0], f1t, f1c, 0.05e9, 0.25e9, 0.07e9);
578        let fi_comp = failure_criterion_tsai_wu([-1e8, 0.0, 0.0], f1t, f1c, 0.05e9, 0.25e9, 0.07e9);
579        // Both should have positive FI contribution from the quadratic term.
580        assert!(fi_ten.is_finite());
581        assert!(fi_comp.is_finite());
582    }
583
584    #[test]
585    fn test_clt_quasi_isotropic_a_symmetry() {
586        // A quasi-isotropic [0/60/-60] laminate should have A11 ≈ A22.
587        let h = 0.001;
588        let e11 = 140e9;
589        let e22 = 10e9;
590        let g12 = 5e9;
591        let nu12 = 0.3;
592        let plies = vec![
593            Ply::new(0.0, h, e11, e22, g12, nu12),
594            Ply::new(PI / 3.0, h, e11, e22, g12, nu12),
595            Ply::new(-PI / 3.0, h, e11, e22, g12, nu12),
596        ];
597        let layup = CompositeLayup::new(plies);
598        let (a, _, _) = classical_lamination_theory(&layup);
599        let diff = (a[0] - a[3]).abs() / a[0];
600        assert!(
601            diff < 0.05,
602            "Quasi-isotropic laminate: A11={:.3e} should ≈ A22={:.3e} (rel diff={diff:.4})",
603            a[0],
604            a[3]
605        );
606    }
607
608    #[test]
609    fn test_tsai_wu_transverse_stress() {
610        // Transverse tensile stress near its own strength → FI near 1.
611        let f2t = 0.05e9_f64;
612        let fi = failure_criterion_tsai_wu([0.0, f2t, 0.0], 1.5e9, 1.0e9, f2t, 0.25e9, 0.07e9);
613        assert!(
614            (fi - 1.0).abs() < 0.1,
615            "FI at transverse tensile strength should be ~1, got {fi}"
616        );
617    }
618
619    #[test]
620    fn test_halpin_tsai_xi_effect() {
621        // Higher ξ should give higher E22 (more reinforcement efficiency).
622        let fiber = FiberProperties::new(230e9, 15e9, 3.5e9, 0.5, 0.0);
623        let matrix = MatrixProperties::new(3.5e9, 0.35);
624        let (_, e22_low, _) = halpin_tsai(&fiber, &matrix, 1.0, 1.0);
625        let (_, e22_high, _) = halpin_tsai(&fiber, &matrix, 4.0, 1.0);
626        assert!(e22_high > e22_low, "Higher ξ_E should give higher E22");
627    }
628}