Skip to main content

oxiphysics_materials/
crystal_plasticity.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Crystal plasticity — slip systems, texture evolution, and polycrystal averaging.
5//!
6//! Provides FCC/BCC slip systems, Schmid tensor, Taylor factor, latent hardening,
7//! power-law creep, Voigt/Reuss/Hill polycrystal averaging, and texture evolution.
8
9#![allow(dead_code)]
10#![allow(clippy::too_many_arguments)]
11
12use std::f64::consts::PI;
13
14// ---------------------------------------------------------------------------
15// Helpers
16// ---------------------------------------------------------------------------
17
18/// Normalise a 3-vector; returns zero vector if norm < eps.
19fn normalise3(v: [f64; 3]) -> [f64; 3] {
20    let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
21    if n < 1e-14 {
22        [0.0; 3]
23    } else {
24        [v[0] / n, v[1] / n, v[2] / n]
25    }
26}
27
28/// Dot product of two 3-vectors.
29fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
30    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
31}
32
33/// Double contraction (Frobenius inner product) of two 3×3 matrices.
34fn double_contract(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> f64 {
35    let mut s = 0.0;
36    for i in 0..3 {
37        for j in 0..3 {
38            s += a[i][j] * b[i][j];
39        }
40    }
41    s
42}
43
44/// Multiply two 3×3 matrices.
45fn matmul3(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
46    let mut c = [[0.0f64; 3]; 3];
47    for i in 0..3 {
48        for j in 0..3 {
49            for k in 0..3 {
50                c[i][j] += a[i][k] * b[k][j];
51            }
52        }
53    }
54    c
55}
56
57/// Transpose a 3×3 matrix.
58fn transpose3(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
59    let mut t = [[0.0f64; 3]; 3];
60    for i in 0..3 {
61        for j in 0..3 {
62            t[i][j] = m[j][i];
63        }
64    }
65    t
66}
67
68// ---------------------------------------------------------------------------
69// 1. SlipSystem
70// ---------------------------------------------------------------------------
71
72/// Single crystallographic slip system with a Schmid factor.
73///
74/// A slip system is defined by a slip direction **d** and a slip plane normal
75/// **n**.  The Schmid factor m = cos(φ)·cos(λ) is the projection of the
76/// loading axis onto the slip system.
77#[derive(Debug, Clone)]
78pub struct SlipSystem {
79    /// Unit slip direction (crystal frame).
80    pub slip_direction: [f64; 3],
81    /// Unit slip plane normal (crystal frame).
82    pub slip_normal: [f64; 3],
83    /// Schmid factor for uniaxial loading along e₁ = \[1,0,0\].
84    pub schmid_factor: f64,
85}
86
87impl SlipSystem {
88    /// Create a slip system from a direction and plane normal.
89    ///
90    /// Both vectors are normalised internally; the Schmid factor is computed
91    /// for a loading axis along **e₁ = \[1,0,0\]**.
92    pub fn new(direction: [f64; 3], normal: [f64; 3]) -> Self {
93        let d = normalise3(direction);
94        let n = normalise3(normal);
95        // Schmid factor for axis e1 = [1,0,0]: m = cos_phi * cos_lambda
96        let cos_phi = n[0]; // dot(n, e1)
97        let cos_lam = d[0]; // dot(d, e1)
98        let schmid_factor = cos_phi * cos_lam;
99        Self {
100            slip_direction: d,
101            slip_normal: n,
102            schmid_factor,
103        }
104    }
105
106    /// Schmid tensor P = (d ⊗ n + n ⊗ d) / 2 (symmetrised).
107    pub fn schmid_tensor(&self) -> [[f64; 3]; 3] {
108        let d = self.slip_direction;
109        let n = self.slip_normal;
110        let mut p = [[0.0f64; 3]; 3];
111        for i in 0..3 {
112            for j in 0..3 {
113                p[i][j] = 0.5 * (d[i] * n[j] + n[i] * d[j]);
114            }
115        }
116        p
117    }
118
119    /// Resolved shear stress τ = σ : P (double contraction with Cauchy stress).
120    pub fn resolved_shear_stress(&self, cauchy_stress: &[[f64; 3]; 3]) -> f64 {
121        let p = self.schmid_tensor();
122        double_contract(cauchy_stress, &p)
123    }
124}
125
126// ---------------------------------------------------------------------------
127// 2. FCC and BCC slip systems
128// ---------------------------------------------------------------------------
129
130/// Generate the 12 {111}⟨110⟩ slip systems for an FCC crystal.
131pub fn fcc_slip_systems() -> Vec<SlipSystem> {
132    // Four {111} planes.
133    let normals: [[f64; 3]; 4] = [
134        [1.0, 1.0, 1.0],
135        [-1.0, 1.0, 1.0],
136        [1.0, -1.0, 1.0],
137        [1.0, 1.0, -1.0],
138    ];
139    // Three ⟨110⟩ directions per plane (those perpendicular to the normal).
140    let directions: [[f64; 3]; 6] = [
141        [1.0, -1.0, 0.0],
142        [-1.0, 0.0, 1.0],
143        [0.0, 1.0, -1.0],
144        [1.0, 1.0, 0.0],
145        [0.0, 1.0, 1.0],
146        [1.0, 0.0, 1.0],
147    ];
148
149    // Pairs: (normal_idx, direction_idx) that are orthogonal.
150    let pairs: [(usize, usize); 12] = [
151        (0, 0),
152        (0, 1),
153        (0, 2),
154        (1, 0),
155        (1, 3),
156        (1, 4),
157        (2, 1),
158        (2, 3),
159        (2, 5),
160        (3, 2),
161        (3, 4),
162        (3, 5),
163    ];
164
165    pairs
166        .iter()
167        .map(|&(ni, di)| SlipSystem::new(directions[di], normals[ni]))
168        .collect()
169}
170
171/// Generate the 12 {110}⟨111⟩ slip systems for a BCC crystal.
172pub fn bcc_slip_systems() -> Vec<SlipSystem> {
173    // Six {110} planes.
174    let normals: [[f64; 3]; 6] = [
175        [1.0, 1.0, 0.0],
176        [1.0, -1.0, 0.0],
177        [1.0, 0.0, 1.0],
178        [1.0, 0.0, -1.0],
179        [0.0, 1.0, 1.0],
180        [0.0, 1.0, -1.0],
181    ];
182    // Four ⟨111⟩ directions.
183    let directions: [[f64; 3]; 4] = [
184        [1.0, 1.0, 1.0],
185        [-1.0, 1.0, 1.0],
186        [1.0, -1.0, 1.0],
187        [1.0, 1.0, -1.0],
188    ];
189
190    // 12 orthogonal pairs.
191    let pairs: [(usize, usize); 12] = [
192        (0, 2),
193        (0, 3),
194        (1, 0),
195        (1, 1),
196        (2, 0),
197        (2, 1),
198        (3, 2),
199        (3, 3),
200        (4, 0),
201        (4, 3),
202        (5, 1),
203        (5, 2),
204    ];
205
206    pairs
207        .iter()
208        .map(|&(ni, di)| SlipSystem::new(directions[di], normals[ni]))
209        .collect()
210}
211
212// ---------------------------------------------------------------------------
213// 3. Crystal orientation matrix (Bunge Euler angles)
214// ---------------------------------------------------------------------------
215
216/// Rotation matrix from crystal to sample frame (Bunge convention).
217///
218/// R = Rz(φ₂) · Rx(Φ) · Rz(φ₁)
219#[derive(Debug, Clone)]
220pub struct CrystalOrientationMatrix {
221    /// 3×3 rotation matrix (row-major).
222    pub r: [[f64; 3]; 3],
223}
224
225impl CrystalOrientationMatrix {
226    /// Construct from Bunge Euler angles (φ₁, Φ, φ₂) in **radians**.
227    pub fn from_euler_angles(phi1: f64, phi: f64, phi2: f64) -> Self {
228        let (c1, s1) = (phi1.cos(), phi1.sin());
229        let (c, s) = (phi.cos(), phi.sin());
230        let (c2, s2) = (phi2.cos(), phi2.sin());
231
232        // R = Rz(phi2) * Rx(phi) * Rz(phi1)
233        let r = [
234            [c2 * c1 - s2 * c * s1, -c2 * s1 - s2 * c * c1, s2 * s],
235            [s2 * c1 + c2 * c * s1, -s2 * s1 + c2 * c * c1, -c2 * s],
236            [s * s1, s * c1, c],
237        ];
238
239        Self { r }
240    }
241
242    /// Extract Bunge Euler angles (φ₁, Φ, φ₂) in **radians** from the matrix.
243    ///
244    /// Returns angles in \[0, 2π) × \[0, π\] × \[0, 2π).
245    pub fn euler_angles(&self) -> (f64, f64, f64) {
246        let r = &self.r;
247        let phi = r[2][2].clamp(-1.0, 1.0).acos();
248        let (phi1, phi2);
249        if phi.sin().abs() < 1e-10 {
250            // Gimbal lock: Φ = 0 or π
251            phi1 = r[0][1].atan2(r[0][0]);
252            phi2 = 0.0;
253        } else {
254            phi1 = r[2][0].atan2(r[2][1]);
255            phi2 = r[0][2].atan2(-r[1][2]);
256        }
257        (phi1.rem_euclid(2.0 * PI), phi, phi2.rem_euclid(2.0 * PI))
258    }
259
260    /// Rotate a Cauchy stress tensor from crystal to sample frame.
261    ///
262    /// σ_sample = R · σ_crystal · Rᵀ
263    pub fn rotate_stress(&self, sigma: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
264        let rt = transpose3(&self.r);
265        let tmp = matmul3(&self.r, sigma);
266        matmul3(&tmp, &rt)
267    }
268}
269
270// ---------------------------------------------------------------------------
271// 4. Taylor factor
272// ---------------------------------------------------------------------------
273
274/// Compute a simplified Taylor factor M for a given strain rate.
275///
276/// M = σ_flow / τ_crss, approximated as the sum of absolute Schmid factors
277/// for the active slip systems weighted by the deviatoric strain-rate
278/// component ε₁₁.
279///
280/// # Arguments
281/// * `slip_systems` – list of slip systems.
282/// * `strain_rate`  – Voigt strain-rate vector \[ε₁₁, ε₂₂, ε₃₃, 2ε₂₃, 2ε₁₃, 2ε₁₂\].
283pub fn taylor_factor(slip_systems: &[SlipSystem], strain_rate: [f64; 6]) -> f64 {
284    let e11 = strain_rate[0];
285    if e11.abs() < 1e-15 {
286        return 0.0;
287    }
288    // Build stress-like tensor from the strain rate (use it as proxy for stress direction).
289    let sigma = [
290        [strain_rate[0], strain_rate[5], strain_rate[4]],
291        [strain_rate[5], strain_rate[1], strain_rate[3]],
292        [strain_rate[4], strain_rate[3], strain_rate[2]],
293    ];
294    let total: f64 = slip_systems
295        .iter()
296        .map(|s| s.resolved_shear_stress(&sigma).abs())
297        .sum();
298    total / e11.abs()
299}
300
301// ---------------------------------------------------------------------------
302// 5. Latent hardening matrix
303// ---------------------------------------------------------------------------
304
305/// Build a latent hardening interaction matrix H_{αβ} = q + (1−q)·δ_{αβ}.
306///
307/// # Arguments
308/// * `n_systems` – number of slip systems.
309/// * `q`         – latent hardening ratio (0 = no latent, 1 = isotropic).
310pub fn hardening_matrix_latent(n_systems: usize, q: f64) -> Vec<Vec<f64>> {
311    let mut h = vec![vec![q; n_systems]; n_systems];
312    #[allow(clippy::needless_range_loop)]
313    for i in 0..n_systems {
314        h[i][i] = 1.0;
315    }
316    h
317}
318
319// ---------------------------------------------------------------------------
320// 6. PowerLawCreep (Norton)
321// ---------------------------------------------------------------------------
322
323/// Norton power-law creep model: ε̇ = A · σⁿ · exp(−Q / RT).
324#[derive(Debug, Clone)]
325pub struct PowerLawCreep {
326    /// Pre-exponential factor A (Pa⁻ⁿ s⁻¹).
327    pub a: f64,
328    /// Creep exponent n (dimensionless).
329    pub n: f64,
330    /// Activation energy Q (J/mol).
331    pub q_activation: f64,
332    /// Universal gas constant R (J/(mol·K)).
333    pub r_gas: f64,
334}
335
336impl PowerLawCreep {
337    /// Create a new Norton creep model.
338    pub fn new(a: f64, n: f64, q_activation: f64, r_gas: f64) -> Self {
339        Self {
340            a,
341            n,
342            q_activation,
343            r_gas,
344        }
345    }
346
347    /// Creep strain rate ε̇ = A · σⁿ · exp(−Q/RT).
348    ///
349    /// # Arguments
350    /// * `stress`      – applied stress (Pa).
351    /// * `temperature` – absolute temperature (K).
352    pub fn strain_rate(&self, stress: f64, temperature: f64) -> f64 {
353        let arrhenius = (-self.q_activation / (self.r_gas * temperature)).exp();
354        self.a * stress.powf(self.n) * arrhenius
355    }
356
357    /// Return the creep exponent n.
358    pub fn creep_exponent(&self) -> f64 {
359        self.n
360    }
361}
362
363// ---------------------------------------------------------------------------
364// 7. Voigt / Reuss / Hill averaging
365// ---------------------------------------------------------------------------
366
367/// Voigt upper bound: arithmetic mean of elastic constants.
368pub fn voigt_average(elastic_constants: &[f64]) -> f64 {
369    if elastic_constants.is_empty() {
370        return 0.0;
371    }
372    elastic_constants.iter().sum::<f64>() / elastic_constants.len() as f64
373}
374
375/// Reuss lower bound: harmonic mean of elastic constants.
376pub fn reuss_average(elastic_constants: &[f64]) -> f64 {
377    if elastic_constants.is_empty() {
378        return 0.0;
379    }
380    let n = elastic_constants.len() as f64;
381    let sum_inv: f64 = elastic_constants.iter().map(|c| 1.0 / c).sum();
382    n / sum_inv
383}
384
385/// Hill average = (Voigt + Reuss) / 2.
386pub fn hill_average(voigt: f64, reuss: f64) -> f64 {
387    (voigt + reuss) / 2.0
388}
389
390// ---------------------------------------------------------------------------
391// 8. TextureEvolution
392// ---------------------------------------------------------------------------
393
394/// Polycrystalline texture: a collection of orientations with weights.
395#[derive(Debug, Clone)]
396pub struct TextureEvolution {
397    /// Crystal orientations.
398    pub orientations: Vec<CrystalOrientationMatrix>,
399    /// Volume fractions (should sum to 1).
400    pub weights: Vec<f64>,
401}
402
403impl TextureEvolution {
404    /// Create an empty texture.
405    pub fn new() -> Self {
406        Self {
407            orientations: Vec::new(),
408            weights: Vec::new(),
409        }
410    }
411
412    /// Add an orientation with a given volume weight.
413    pub fn add_orientation(&mut self, ori: CrystalOrientationMatrix, weight: f64) {
414        self.orientations.push(ori);
415        self.weights.push(weight);
416    }
417
418    /// Number of orientations.
419    pub fn count(&self) -> usize {
420        self.orientations.len()
421    }
422
423    /// Volume-weighted average Taylor factor over all orientations.
424    ///
425    /// # Arguments
426    /// * `slip_systems` – slip systems used to evaluate each grain.
427    /// * `strain`       – Voigt strain-rate vector.
428    pub fn average_taylor_factor(&self, slip_systems: &[SlipSystem], strain: [f64; 6]) -> f64 {
429        if self.orientations.is_empty() {
430            return 0.0;
431        }
432        let total_weight: f64 = self.weights.iter().sum();
433        if total_weight < 1e-15 {
434            return 0.0;
435        }
436        let weighted_sum: f64 = self
437            .orientations
438            .iter()
439            .zip(self.weights.iter())
440            .map(|(ori, &w)| {
441                // Rotate slip systems into sample frame for this grain.
442                let rotated: Vec<SlipSystem> = slip_systems
443                    .iter()
444                    .map(|s| {
445                        // Rotate direction and normal by R.
446                        let rd = mat_vec3(&ori.r, s.slip_direction);
447                        let rn = mat_vec3(&ori.r, s.slip_normal);
448                        SlipSystem::new(rd, rn)
449                    })
450                    .collect();
451                w * taylor_factor(&rotated, strain)
452            })
453            .sum();
454        weighted_sum / total_weight
455    }
456}
457
458impl Default for TextureEvolution {
459    fn default() -> Self {
460        Self::new()
461    }
462}
463
464/// Multiply 3×3 matrix by a 3-vector.
465fn mat_vec3(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
466    [dot3(m[0], v), dot3(m[1], v), dot3(m[2], v)]
467}
468
469// ---------------------------------------------------------------------------
470// Tests
471// ---------------------------------------------------------------------------
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    const EPS: f64 = 1e-9;
478
479    // ── FCC slip systems ─────────────────────────────────────────────────────
480
481    #[test]
482    fn test_fcc_has_12_slip_systems() {
483        let sys = fcc_slip_systems();
484        assert_eq!(sys.len(), 12, "FCC should have 12 slip systems");
485    }
486
487    #[test]
488    fn test_fcc_slip_directions_are_unit_vectors() {
489        for s in fcc_slip_systems() {
490            let d = s.slip_direction;
491            let norm = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
492            assert!((norm - 1.0).abs() < 1e-10, "slip direction norm={norm}");
493        }
494    }
495
496    #[test]
497    fn test_fcc_slip_normals_are_unit_vectors() {
498        for s in fcc_slip_systems() {
499            let n = s.slip_normal;
500            let norm = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
501            assert!((norm - 1.0).abs() < 1e-10, "slip normal norm={norm}");
502        }
503    }
504
505    #[test]
506    fn test_fcc_schmid_factor_in_range() {
507        for s in fcc_slip_systems() {
508            assert!(
509                s.schmid_factor >= -0.5 - EPS && s.schmid_factor <= 0.5 + EPS,
510                "Schmid factor {} out of [-0.5, 0.5]",
511                s.schmid_factor
512            );
513        }
514    }
515
516    // ── BCC slip systems ─────────────────────────────────────────────────────
517
518    #[test]
519    fn test_bcc_has_12_slip_systems() {
520        let sys = bcc_slip_systems();
521        assert_eq!(sys.len(), 12, "BCC should have 12 slip systems");
522    }
523
524    #[test]
525    fn test_bcc_slip_directions_are_unit_vectors() {
526        for s in bcc_slip_systems() {
527            let d = s.slip_direction;
528            let norm = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
529            assert!((norm - 1.0).abs() < 1e-10, "BCC slip direction norm={norm}");
530        }
531    }
532
533    #[test]
534    fn test_bcc_schmid_factor_in_range() {
535        for s in bcc_slip_systems() {
536            assert!(
537                s.schmid_factor >= -0.5 - EPS && s.schmid_factor <= 0.5 + EPS,
538                "BCC Schmid factor {} out of [-0.5, 0.5]",
539                s.schmid_factor
540            );
541        }
542    }
543
544    // ── Schmid tensor ────────────────────────────────────────────────────────
545
546    #[test]
547    #[allow(clippy::needless_range_loop)]
548    fn test_schmid_tensor_symmetric() {
549        let s = SlipSystem::new([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
550        let p = s.schmid_tensor();
551        for i in 0..3 {
552            for j in 0..3 {
553                assert!(
554                    (p[i][j] - p[j][i]).abs() < EPS,
555                    "P not symmetric at ({i},{j})"
556                );
557            }
558        }
559    }
560
561    #[test]
562    fn test_resolved_shear_stress_uniaxial() {
563        // For slip direction [1,0,0] and normal [0,1,0]:
564        // P_12 = 0.5, so tau = sigma_12 + sigma_21 = 2 * 0.5 * sigma_12
565        let s = SlipSystem::new([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
566        let sigma = [[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0]];
567        let tau = s.resolved_shear_stress(&sigma);
568        // P_01 = P_10 = 0.5; tau = 0.5*1.0 + 0.5*1.0 = 1.0
569        assert!((tau - 1.0).abs() < EPS, "tau={tau}");
570    }
571
572    // ── CrystalOrientationMatrix ─────────────────────────────────────────────
573
574    #[test]
575    fn test_euler_identity() {
576        // phi1=0, phi=0, phi2=0 should give identity matrix.
577        let ori = CrystalOrientationMatrix::from_euler_angles(0.0, 0.0, 0.0);
578        for i in 0..3 {
579            for j in 0..3 {
580                let expected = if i == j { 1.0 } else { 0.0 };
581                assert!(
582                    (ori.r[i][j] - expected).abs() < 1e-10,
583                    "R[{i}][{j}]={}",
584                    ori.r[i][j]
585                );
586            }
587        }
588    }
589
590    #[test]
591    fn test_euler_angles_roundtrip() {
592        let phi1 = 0.5_f64;
593        let phi = 0.8_f64;
594        let phi2 = 1.2_f64;
595        let ori = CrystalOrientationMatrix::from_euler_angles(phi1, phi, phi2);
596        let (r1, r, r2) = ori.euler_angles();
597        // Rebuild and compare matrices (angles can differ by 2pi etc.).
598        let ori2 = CrystalOrientationMatrix::from_euler_angles(r1, r, r2);
599        for i in 0..3 {
600            for j in 0..3 {
601                assert!(
602                    (ori.r[i][j] - ori2.r[i][j]).abs() < 1e-8,
603                    "Euler roundtrip mismatch at [{i}][{j}]"
604                );
605            }
606        }
607    }
608
609    #[test]
610    fn test_rotate_stress_identity_orientation() {
611        let ori = CrystalOrientationMatrix::from_euler_angles(0.0, 0.0, 0.0);
612        let sigma = [[1.0, 2.0, 3.0], [2.0, 4.0, 5.0], [3.0, 5.0, 6.0]];
613        let rotated = ori.rotate_stress(&sigma);
614        for i in 0..3 {
615            for j in 0..3 {
616                assert!((rotated[i][j] - sigma[i][j]).abs() < 1e-10);
617            }
618        }
619    }
620
621    // ── Voigt / Reuss / Hill ─────────────────────────────────────────────────
622
623    #[test]
624    fn test_voigt_greater_than_or_equal_reuss() {
625        let constants = vec![100.0, 200.0, 150.0, 180.0];
626        let v = voigt_average(&constants);
627        let r = reuss_average(&constants);
628        assert!(v >= r - EPS, "Voigt={v}, Reuss={r}");
629    }
630
631    #[test]
632    fn test_hill_between_voigt_and_reuss() {
633        let v = 180.0_f64;
634        let r = 120.0_f64;
635        let h = hill_average(v, r);
636        assert!(h >= r - EPS && h <= v + EPS, "Hill={h} not in [{r},{v}]");
637    }
638
639    #[test]
640    fn test_voigt_single_value() {
641        let v = voigt_average(&[200.0]);
642        assert!((v - 200.0).abs() < EPS);
643    }
644
645    #[test]
646    fn test_reuss_single_value() {
647        let r = reuss_average(&[200.0]);
648        assert!((r - 200.0).abs() < EPS);
649    }
650
651    #[test]
652    fn test_hill_average_formula() {
653        let h = hill_average(180.0, 120.0);
654        assert!((h - 150.0).abs() < EPS);
655    }
656
657    #[test]
658    fn test_voigt_empty_returns_zero() {
659        assert_eq!(voigt_average(&[]), 0.0);
660    }
661
662    #[test]
663    fn test_reuss_empty_returns_zero() {
664        assert_eq!(reuss_average(&[]), 0.0);
665    }
666
667    // ── PowerLawCreep ────────────────────────────────────────────────────────
668
669    #[test]
670    fn test_power_law_strain_rate_positive() {
671        let creep = PowerLawCreep::new(1e-15, 5.0, 200_000.0, 8.314);
672        let rate = creep.strain_rate(100e6, 800.0);
673        assert!(rate > 0.0, "strain rate should be positive, got {rate}");
674    }
675
676    #[test]
677    fn test_power_law_strain_rate_increases_with_stress() {
678        let creep = PowerLawCreep::new(1e-15, 5.0, 200_000.0, 8.314);
679        let r1 = creep.strain_rate(100e6, 800.0);
680        let r2 = creep.strain_rate(200e6, 800.0);
681        assert!(r2 > r1, "strain rate should increase with stress");
682    }
683
684    #[test]
685    fn test_power_law_strain_rate_increases_with_temperature() {
686        let creep = PowerLawCreep::new(1e-15, 5.0, 200_000.0, 8.314);
687        let r1 = creep.strain_rate(100e6, 700.0);
688        let r2 = creep.strain_rate(100e6, 900.0);
689        assert!(r2 > r1, "strain rate should increase with temperature");
690    }
691
692    #[test]
693    fn test_creep_exponent_accessor() {
694        let creep = PowerLawCreep::new(1e-15, 5.0, 200_000.0, 8.314);
695        assert!((creep.creep_exponent() - 5.0).abs() < EPS);
696    }
697
698    // ── Latent hardening ─────────────────────────────────────────────────────
699
700    #[test]
701    #[allow(clippy::needless_range_loop)]
702    fn test_hardening_matrix_diagonal_is_one() {
703        let h = hardening_matrix_latent(12, 1.4);
704        for i in 0..12 {
705            assert!((h[i][i] - 1.0).abs() < EPS);
706        }
707    }
708
709    #[test]
710    #[allow(clippy::needless_range_loop)]
711    fn test_hardening_matrix_off_diagonal_is_q() {
712        let q = 1.4_f64;
713        let h = hardening_matrix_latent(12, q);
714        for i in 0..12 {
715            for j in 0..12 {
716                if i != j {
717                    assert!((h[i][j] - q).abs() < EPS);
718                }
719            }
720        }
721    }
722
723    // ── TextureEvolution ─────────────────────────────────────────────────────
724
725    #[test]
726    fn test_texture_add_orientation() {
727        let mut tex = TextureEvolution::new();
728        let ori = CrystalOrientationMatrix::from_euler_angles(0.0, 0.0, 0.0);
729        tex.add_orientation(ori, 1.0);
730        assert_eq!(tex.count(), 1);
731    }
732
733    #[test]
734    fn test_texture_empty_average_taylor_zero() {
735        let tex = TextureEvolution::new();
736        let sys = fcc_slip_systems();
737        let m = tex.average_taylor_factor(&sys, [1.0, -0.5, -0.5, 0.0, 0.0, 0.0]);
738        assert_eq!(m, 0.0);
739    }
740
741    #[test]
742    fn test_texture_single_grain_taylor_factor() {
743        let mut tex = TextureEvolution::new();
744        let ori = CrystalOrientationMatrix::from_euler_angles(0.0, 0.0, 0.0);
745        tex.add_orientation(ori, 1.0);
746        let sys = fcc_slip_systems();
747        let m = tex.average_taylor_factor(&sys, [1.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
748        assert!(m >= 0.0, "Taylor factor should be non-negative, got {m}");
749    }
750
751    // ── Taylor factor ────────────────────────────────────────────────────────
752
753    #[test]
754    fn test_taylor_factor_zero_strain_returns_zero() {
755        let sys = fcc_slip_systems();
756        let m = taylor_factor(&sys, [0.0; 6]);
757        assert_eq!(m, 0.0);
758    }
759
760    #[test]
761    fn test_taylor_factor_positive_for_nonzero_strain() {
762        let sys = fcc_slip_systems();
763        let m = taylor_factor(&sys, [1.0, -0.5, -0.5, 0.0, 0.0, 0.0]);
764        assert!(m >= 0.0);
765    }
766}