Skip to main content

oxiphysics_fem/
analysis.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Static, modal, and dynamic FEM analysis drivers.
5//!
6//! Provides:
7//! - [`LinearStaticAnalysis`] — classic K·u = f solve
8//! - [`StaticAnalysis`] — standalone dense-matrix static analysis
9//! - [`ModalAnalysis`] — Rayleigh-quotient iteration / power iteration
10//! - [`DynamicAnalysis`] — Newmark-β and Wilson-θ time integration
11
12use oxiphysics_core::math::Vec3;
13
14use crate::assembly::{assemble_load_vector, assemble_stiffness};
15use crate::boundary::{DirichletBc, NeumannBc, apply_dirichlet, apply_neumann};
16use crate::constitutive::LinearElasticMaterial;
17use crate::element::LinearTetrahedron;
18use crate::mesh::TetrahedralMesh;
19use crate::solvers::{jacobi_preconditioner, preconditioned_cg};
20
21// ---------------------------------------------------------------------------
22// FEM error type
23// ---------------------------------------------------------------------------
24
25/// Errors that can arise during FEM analysis.
26#[derive(Debug, Clone, PartialEq)]
27pub enum FemError {
28    /// The linear system is singular or ill-conditioned.
29    SingularSystem,
30    /// An input argument has an invalid size or value.
31    InvalidInput(String),
32    /// The iterative solver did not converge within the allowed iterations.
33    SolverNotConverged,
34}
35
36impl std::fmt::Display for FemError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            FemError::SingularSystem => write!(f, "singular or ill-conditioned system"),
40            FemError::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
41            FemError::SolverNotConverged => write!(f, "iterative solver did not converge"),
42        }
43    }
44}
45
46impl std::error::Error for FemError {}
47
48// ---------------------------------------------------------------------------
49// Result type
50// ---------------------------------------------------------------------------
51
52/// Result of a finite element analysis.
53#[derive(Debug, Clone)]
54pub struct FemResult {
55    /// Nodal displacement vectors.
56    pub displacements: Vec<Vec3>,
57    /// Stress tensor per element (Voigt: \[σ_xx, σ_yy, σ_zz, τ_xy, τ_yz, τ_xz\]).
58    pub stress: Vec<[f64; 6]>,
59    /// Strain tensor per element (Voigt: \[ε_xx, ε_yy, ε_zz, γ_xy, γ_yz, γ_xz\]).
60    pub strain: Vec<[f64; 6]>,
61}
62
63// ---------------------------------------------------------------------------
64// LinearStaticAnalysis (existing, preserved)
65// ---------------------------------------------------------------------------
66
67/// Linear static analysis driver.
68///
69/// Assembles the global stiffness matrix and load vector, applies boundary
70/// conditions, solves the linear system, and post-processes stresses and strains.
71pub struct LinearStaticAnalysis {
72    /// Maximum iterations for the iterative solver.
73    pub max_iter: usize,
74    /// Convergence tolerance for the iterative solver.
75    pub tolerance: f64,
76}
77
78impl Default for LinearStaticAnalysis {
79    fn default() -> Self {
80        Self {
81            max_iter: 10000,
82            tolerance: 1e-10,
83        }
84    }
85}
86
87impl LinearStaticAnalysis {
88    /// Create a new linear static analysis with default solver parameters.
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    /// Run the analysis and return the results.
94    pub fn solve(
95        &self,
96        mesh: &TetrahedralMesh,
97        material: &LinearElasticMaterial,
98        dirichlet_bcs: &[DirichletBc],
99        neumann_bcs: &[NeumannBc],
100        body_force: &Vec3,
101    ) -> FemResult {
102        let ndof = mesh.num_nodes() * 3;
103
104        let mut k = assemble_stiffness(mesh, material);
105        let mut f = assemble_load_vector(mesh, body_force);
106
107        apply_neumann(&mut f, neumann_bcs);
108        apply_dirichlet(&mut k, &mut f, dirichlet_bcs);
109
110        let precond = jacobi_preconditioner(&k);
111        let u = preconditioned_cg(&k, &f, &precond, self.max_iter, self.tolerance);
112
113        let num_nodes = mesh.num_nodes();
114        let mut displacements = Vec::with_capacity(num_nodes);
115        for i in 0..num_nodes {
116            displacements.push(Vec3::new(u[i * 3], u[i * 3 + 1], u[i * 3 + 2]));
117        }
118
119        let d_matrix = material.constitutive_matrix();
120        let num_elements = mesh.num_elements();
121        let mut stress = Vec::with_capacity(num_elements);
122        let mut strain = Vec::with_capacity(num_elements);
123
124        for elem in &mesh.elements {
125            let nodes = [
126                mesh.nodes[elem[0]],
127                mesh.nodes[elem[1]],
128                mesh.nodes[elem[2]],
129                mesh.nodes[elem[3]],
130            ];
131
132            let (b_mat, _vol) = LinearTetrahedron::b_matrix(&nodes);
133
134            let mut u_e = [0.0f64; 12];
135            for (local, &global) in elem.iter().enumerate() {
136                u_e[local * 3] = u[global * 3];
137                u_e[local * 3 + 1] = u[global * 3 + 1];
138                u_e[local * 3 + 2] = u[global * 3 + 2];
139            }
140
141            let mut eps = [0.0f64; 6];
142            for i in 0..6 {
143                for j in 0..12 {
144                    eps[i] += b_mat[i * 12 + j] * u_e[j];
145                }
146            }
147
148            let mut sig = [0.0f64; 6];
149            for i in 0..6 {
150                for j in 0..6 {
151                    sig[i] += d_matrix[i][j] * eps[j];
152                }
153            }
154
155            strain.push(eps);
156            stress.push(sig);
157        }
158
159        let _ = ndof;
160
161        FemResult {
162            displacements,
163            stress,
164            strain,
165        }
166    }
167}
168
169// ---------------------------------------------------------------------------
170// StaticAnalysis — standalone dense-matrix static solver
171// ---------------------------------------------------------------------------
172
173/// Standalone static analysis using a dense global stiffness matrix.
174///
175/// Suitable for small problems where CSR overhead is not needed.
176pub struct StaticAnalysis {
177    /// Number of degrees of freedom.
178    pub n_dofs: usize,
179    /// Dense global stiffness matrix stored row-major (n_dofs × n_dofs).
180    pub stiffness_matrix: Vec<f64>,
181    /// Global force vector (length n_dofs).
182    pub force_vector: Vec<f64>,
183}
184
185impl StaticAnalysis {
186    /// Create a new `StaticAnalysis` with zeros.
187    pub fn new(n_dofs: usize) -> Self {
188        Self {
189            n_dofs,
190            stiffness_matrix: vec![0.0; n_dofs * n_dofs],
191            force_vector: vec![0.0; n_dofs],
192        }
193    }
194
195    /// Assemble the global stiffness matrix from element stiffness matrices.
196    ///
197    /// `elements` is a slice of `(dof_indices, ke)` pairs where `ke` is stored
198    /// row-major for the element's local DOFs.
199    pub fn assemble_global_stiffness(&mut self, elements: &[(Vec<usize>, Vec<f64>)]) {
200        let n = self.n_dofs;
201        for (dofs, ke) in elements {
202            let ndof_e = dofs.len();
203            for i in 0..ndof_e {
204                for j in 0..ndof_e {
205                    let gi = dofs[i];
206                    let gj = dofs[j];
207                    self.stiffness_matrix[gi * n + gj] += ke[i * ndof_e + j];
208                }
209            }
210        }
211    }
212
213    /// Solve the linear system K·u = f using Gaussian elimination with partial pivoting.
214    ///
215    /// Returns the displacement vector or a [`FemError`] if the system is singular.
216    pub fn solve(&self) -> Result<Vec<f64>, FemError> {
217        let n = self.n_dofs;
218        if n == 0 {
219            return Ok(Vec::new());
220        }
221        // Augmented matrix [K | f]
222        let mut aug: Vec<f64> = Vec::with_capacity(n * (n + 1));
223        for i in 0..n {
224            for j in 0..n {
225                aug.push(self.stiffness_matrix[i * n + j]);
226            }
227            aug.push(self.force_vector[i]);
228        }
229
230        let cols = n + 1;
231
232        for col in 0..n {
233            // Find pivot row
234            let mut max_val = aug[col * cols + col].abs();
235            let mut max_row = col;
236            for row in (col + 1)..n {
237                let v = aug[row * cols + col].abs();
238                if v > max_val {
239                    max_val = v;
240                    max_row = row;
241                }
242            }
243            if max_val < 1e-14 {
244                return Err(FemError::SingularSystem);
245            }
246            // Swap rows
247            if max_row != col {
248                for k in 0..cols {
249                    aug.swap(col * cols + k, max_row * cols + k);
250                }
251            }
252            let pivot = aug[col * cols + col];
253            // Eliminate below
254            for row in (col + 1)..n {
255                let factor = aug[row * cols + col] / pivot;
256                for k in col..cols {
257                    let sub = factor * aug[col * cols + k];
258                    aug[row * cols + k] -= sub;
259                }
260            }
261        }
262
263        // Back substitution
264        let mut u = vec![0.0; n];
265        for i in (0..n).rev() {
266            let mut sum = aug[i * cols + n];
267            for j in (i + 1)..n {
268                sum -= aug[i * cols + j] * u[j];
269            }
270            u[i] = sum / aug[i * cols + i];
271        }
272        Ok(u)
273    }
274
275    /// Compute engineering strains ε = B·u for each element.
276    ///
277    /// `elements` is a slice of `(dof_indices, B_matrix)` where `B` is 6×n_dof_e
278    /// stored row-major. Returns one `[f64;6]` per element.
279    pub fn compute_strains(
280        &self,
281        displacements: &[f64],
282        elements: &[(Vec<usize>, Vec<f64>)],
283    ) -> Vec<[f64; 6]> {
284        let mut strains = Vec::with_capacity(elements.len());
285        for (dofs, b_mat) in elements {
286            let ndof_e = dofs.len();
287            let mut u_e = vec![0.0; ndof_e];
288            for (k, &g) in dofs.iter().enumerate() {
289                u_e[k] = displacements[g];
290            }
291            let mut eps = [0.0f64; 6];
292            for i in 0..6 {
293                for j in 0..ndof_e {
294                    eps[i] += b_mat[i * ndof_e + j] * u_e[j];
295                }
296            }
297            strains.push(eps);
298        }
299        strains
300    }
301
302    /// Compute stresses σ = D·ε from the constitutive matrix and strains.
303    ///
304    /// `d_matrix` is the 6×6 constitutive matrix (row-major flat).
305    pub fn compute_stresses(strains: &[[f64; 6]], d_matrix: &[f64; 36]) -> Vec<[f64; 6]> {
306        strains
307            .iter()
308            .map(|eps| {
309                let mut sig = [0.0f64; 6];
310                for i in 0..6 {
311                    for j in 0..6 {
312                        sig[i] += d_matrix[i * 6 + j] * eps[j];
313                    }
314                }
315                sig
316            })
317            .collect()
318    }
319}
320
321// ---------------------------------------------------------------------------
322// ModalAnalysis — Rayleigh-quotient / inverse iteration
323// ---------------------------------------------------------------------------
324
325/// Modal analysis: computes natural frequencies and mode shapes using
326/// Rayleigh-quotient iteration (inverse iteration with shift).
327pub struct ModalAnalysis {
328    /// Maximum number of iterations per mode.
329    pub max_iter: usize,
330    /// Convergence tolerance on the Rayleigh quotient.
331    pub tol: f64,
332}
333
334impl Default for ModalAnalysis {
335    fn default() -> Self {
336        Self {
337            max_iter: 500,
338            tol: 1e-10,
339        }
340    }
341}
342
343impl ModalAnalysis {
344    /// Create with default parameters.
345    pub fn new() -> Self {
346        Self::default()
347    }
348
349    /// Dot product helper.
350    fn dot(a: &[f64], b: &[f64]) -> f64 {
351        a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
352    }
353
354    /// Dense matrix-vector product (n×n matrix stored row-major).
355    fn matvec_dense(a: &[f64], x: &[f64], n: usize) -> Vec<f64> {
356        let mut y = vec![0.0; n];
357        for i in 0..n {
358            for j in 0..n {
359                y[i] += a[i * n + j] * x[j];
360            }
361        }
362        y
363    }
364
365    /// Solve A·x = b by Gaussian elimination (small n, no pivoting needed for
366    /// well-conditioned problems).  Returns None if singular.
367    fn solve_dense(a: &[f64], b: &[f64], n: usize) -> Option<Vec<f64>> {
368        let cols = n + 1;
369        let mut aug = vec![0.0; n * cols];
370        for i in 0..n {
371            for j in 0..n {
372                aug[i * cols + j] = a[i * n + j];
373            }
374            aug[i * cols + n] = b[i];
375        }
376        for col in 0..n {
377            // Partial pivot
378            let mut max_val = aug[col * cols + col].abs();
379            let mut max_row = col;
380            for row in (col + 1)..n {
381                let v = aug[row * cols + col].abs();
382                if v > max_val {
383                    max_val = v;
384                    max_row = row;
385                }
386            }
387            if max_val < 1e-15 {
388                return None;
389            }
390            if max_row != col {
391                for k in 0..cols {
392                    aug.swap(col * cols + k, max_row * cols + k);
393                }
394            }
395            let pivot = aug[col * cols + col];
396            for row in (col + 1)..n {
397                let factor = aug[row * cols + col] / pivot;
398                for k in col..cols {
399                    let sub = factor * aug[col * cols + k];
400                    aug[row * cols + k] -= sub;
401                }
402            }
403        }
404        let mut x = vec![0.0; n];
405        for i in (0..n).rev() {
406            let mut s = aug[i * cols + n];
407            for j in (i + 1)..n {
408                s -= aug[i * cols + j] * x[j];
409            }
410            x[i] = s / aug[i * cols + i];
411        }
412        Some(x)
413    }
414
415    /// Compute the `n_modes` lowest natural frequencies (rad/s) using
416    /// Rayleigh-quotient iteration on the dense generalized eigenproblem K·φ = ω²·M·φ.
417    ///
418    /// Both `k` and `m` are row-major flat arrays of size n×n.
419    pub fn compute_natural_frequencies(
420        k: &[f64],
421        m: &[f64],
422        n: usize,
423        n_modes: usize,
424        max_iter: usize,
425    ) -> Vec<f64> {
426        let mut freqs = Vec::with_capacity(n_modes);
427        let mut found_modes: Vec<Vec<f64>> = Vec::new();
428
429        for mode_idx in 0..n_modes {
430            // Initial vector
431            let mut q: Vec<f64> = (0..n)
432                .map(|i| {
433                    let v = ((i + mode_idx * 13 + 1) as f64 * 0.7).sin();
434                    if i == mode_idx % n { v + 1.0 } else { v }
435                })
436                .collect();
437
438            // Mass-normalise
439            let mq = Self::matvec_dense(m, &q, n);
440            let nm = Self::dot(&q, &mq).max(0.0).sqrt();
441            if nm > 1e-60 {
442                for qi in q.iter_mut() {
443                    *qi /= nm;
444                }
445            }
446
447            let mut omega_sq = 0.0_f64;
448
449            for _iter in 0..max_iter {
450                // Deflate against converged modes
451                for phi in &found_modes {
452                    let mphi = Self::matvec_dense(m, phi, n);
453                    let c = Self::dot(&q, &mphi);
454                    for i in 0..n {
455                        q[i] -= c * phi[i];
456                    }
457                }
458
459                // Rayleigh quotient
460                let kq = Self::matvec_dense(k, &q, n);
461                let mq2 = Self::matvec_dense(m, &q, n);
462                let num = Self::dot(&q, &kq);
463                let den = Self::dot(&q, &mq2);
464                let rq = if den.abs() > 1e-60 { num / den } else { 0.0 };
465
466                // Inverse iteration: solve (K - rq*M)*y = M*q
467                let mut km = vec![0.0; n * n];
468                for i in 0..n {
469                    for j in 0..n {
470                        km[i * n + j] = k[i * n + j] - rq * m[i * n + j];
471                    }
472                }
473                let rhs = Self::matvec_dense(m, &q, n);
474                let y_opt = Self::solve_dense(&km, &rhs, n);
475                let y = match y_opt {
476                    Some(y) => y,
477                    None => {
478                        // Fallback: plain power iteration
479                        let mut rhs2 = Self::matvec_dense(m, &q, n);
480                        // Solve K·y = M·q instead
481                        if let Some(sol) = Self::solve_dense(k, &rhs2, n) {
482                            rhs2 = sol;
483                        }
484                        rhs2
485                    }
486                };
487
488                // Deflate y
489                let mut yd = y.clone();
490                for phi in &found_modes {
491                    let mphi = Self::matvec_dense(m, phi, n);
492                    let c = Self::dot(&yd, &mphi);
493                    for i in 0..n {
494                        yd[i] -= c * phi[i];
495                    }
496                }
497
498                // Mass-normalise
499                let my = Self::matvec_dense(m, &yd, n);
500                let nm2 = Self::dot(&yd, &my).max(0.0).sqrt();
501                if nm2 < 1e-60 {
502                    break;
503                }
504                let q_new: Vec<f64> = yd.iter().map(|yi| yi / nm2).collect();
505
506                // New Rayleigh quotient
507                let kqn = Self::matvec_dense(k, &q_new, n);
508                let mqn = Self::matvec_dense(m, &q_new, n);
509                let num_new = Self::dot(&q_new, &kqn);
510                let den_new = Self::dot(&q_new, &mqn);
511                let rq_new = if den_new.abs() > 1e-60 {
512                    num_new / den_new
513                } else {
514                    0.0
515                };
516
517                let change = (rq_new - omega_sq).abs() / (rq_new.abs() + 1e-60);
518                q = q_new;
519                omega_sq = rq_new;
520                if change < 1e-10 && _iter > 0 {
521                    break;
522                }
523            }
524
525            freqs.push(omega_sq.max(0.0).sqrt());
526            found_modes.push(q);
527        }
528
529        freqs
530    }
531
532    /// Compute the mode shape for a given angular frequency `omega` using
533    /// inverse iteration: solve (K − ω²·M)·φ = b repeatedly.
534    ///
535    /// Both `k` and `m` are row-major flat arrays of size n×n.
536    pub fn compute_mode_shapes(
537        k: &[f64],
538        m: &[f64],
539        n: usize,
540        omega: f64,
541        max_iter: usize,
542    ) -> Vec<f64> {
543        let shift = omega * omega;
544        // Build K - shift*M
545        let mut km = vec![0.0; n * n];
546        for i in 0..n {
547            for j in 0..n {
548                km[i * n + j] = k[i * n + j] - shift * m[i * n + j];
549            }
550        }
551
552        let mut q: Vec<f64> = (0..n).map(|i| ((i + 1) as f64 * 0.5).sin()).collect();
553        // Normalise
554        let nrm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
555        if nrm > 1e-60 {
556            for qi in q.iter_mut() {
557                *qi /= nrm;
558            }
559        }
560
561        for _ in 0..max_iter {
562            let rhs = Self::matvec_dense(m, &q, n);
563            let y = match Self::solve_dense(&km, &rhs, n) {
564                Some(y) => y,
565                None => break,
566            };
567            let ny: f64 = y.iter().map(|x| x * x).sum::<f64>().sqrt();
568            if ny < 1e-60 {
569                break;
570            }
571            let q_new: Vec<f64> = y.iter().map(|yi| yi / ny).collect();
572            let change: f64 = q
573                .iter()
574                .zip(q_new.iter())
575                .map(|(a, b)| (a - b).abs())
576                .sum::<f64>()
577                / (n as f64);
578            q = q_new;
579            if change < 1e-12 {
580                break;
581            }
582        }
583
584        // Mass-normalise
585        let mq = Self::matvec_dense(m, &q, n);
586        let mn: f64 = Self::dot(&q, &mq).max(0.0).sqrt();
587        if mn > 1e-60 {
588            for qi in q.iter_mut() {
589                *qi /= mn;
590            }
591        }
592        q
593    }
594}
595
596// ---------------------------------------------------------------------------
597// DynamicAnalysis — Newmark-β and Wilson-θ
598// ---------------------------------------------------------------------------
599
600/// Direct time-integration for structural dynamics: M·ü + C·u̇ + K·u = f(t).
601///
602/// Provides Newmark-β and Wilson-θ stepping methods.
603pub struct DynamicAnalysis {
604    /// Mass matrix (row-major, n×n).
605    pub m: Vec<f64>,
606    /// Damping matrix (row-major, n×n).
607    pub c: Vec<f64>,
608    /// Stiffness matrix (row-major, n×n).
609    pub k: Vec<f64>,
610    /// Number of DOFs.
611    pub n: usize,
612}
613
614impl DynamicAnalysis {
615    /// Construct a new `DynamicAnalysis`.
616    pub fn new(m: Vec<f64>, c: Vec<f64>, k: Vec<f64>, n: usize) -> Self {
617        Self { m, c, k, n }
618    }
619
620    /// Dense matrix-vector product.
621    fn mv(a: &[f64], x: &[f64], n: usize) -> Vec<f64> {
622        let mut y = vec![0.0; n];
623        for i in 0..n {
624            for j in 0..n {
625                y[i] += a[i * n + j] * x[j];
626            }
627        }
628        y
629    }
630
631    /// Solve dense n×n system A·x = b (Gaussian elim with partial pivot).
632    fn solve(a: &[f64], b: &[f64], n: usize) -> Vec<f64> {
633        let cols = n + 1;
634        let mut aug = vec![0.0; n * cols];
635        for i in 0..n {
636            for j in 0..n {
637                aug[i * cols + j] = a[i * n + j];
638            }
639            aug[i * cols + n] = b[i];
640        }
641        for col in 0..n {
642            let mut max_val = aug[col * cols + col].abs();
643            let mut max_row = col;
644            for row in (col + 1)..n {
645                let v = aug[row * cols + col].abs();
646                if v > max_val {
647                    max_val = v;
648                    max_row = row;
649                }
650            }
651            if max_val < 1e-15 {
652                continue;
653            }
654            if max_row != col {
655                for k in 0..cols {
656                    aug.swap(col * cols + k, max_row * cols + k);
657                }
658            }
659            let piv = aug[col * cols + col];
660            for row in (col + 1)..n {
661                let fac = aug[row * cols + col] / piv;
662                for k in col..cols {
663                    let sub = fac * aug[col * cols + k];
664                    aug[row * cols + k] -= sub;
665                }
666            }
667        }
668        let mut x = vec![0.0; n];
669        for i in (0..n).rev() {
670            let mut s = aug[i * cols + n];
671            for j in (i + 1)..n {
672                s -= aug[i * cols + j] * x[j];
673            }
674            x[i] = s / aug[i * cols + i];
675        }
676        x
677    }
678
679    /// One step of the Newmark-β method.
680    ///
681    /// Updates displacement `u`, velocity `v`, and acceleration `a` from time `t`
682    /// to time `t + dt`.
683    ///
684    /// Typical parameter choices:
685    /// - Constant average acceleration (unconditionally stable): β = 0.25, γ = 0.5
686    /// - Linear acceleration (conditionally stable): β = 1/6, γ = 0.5
687    ///
688    /// # Returns
689    /// `(u_{n+1}, v_{n+1}, a_{n+1})`
690    pub fn newmark_beta_step(
691        &self,
692        u: &[f64],
693        v: &[f64],
694        a: &[f64],
695        f_ext: &[f64],
696        dt: f64,
697        beta: f64,
698        gamma: f64,
699    ) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
700        let n = self.n;
701        // Predictors
702        let u_pred: Vec<f64> = (0..n)
703            .map(|i| u[i] + dt * v[i] + dt * dt * (0.5 - beta) * a[i])
704            .collect();
705        let v_pred: Vec<f64> = (0..n).map(|i| v[i] + dt * (1.0 - gamma) * a[i]).collect();
706
707        // Effective stiffness K_eff = M/(β·dt²) + γ/(β·dt)·C + K
708        let c1 = 1.0 / (beta * dt * dt);
709        let c2 = gamma / (beta * dt);
710
711        let mut k_eff = vec![0.0; n * n];
712        for i in 0..n {
713            for j in 0..n {
714                k_eff[i * n + j] =
715                    c1 * self.m[i * n + j] + c2 * self.c[i * n + j] + self.k[i * n + j];
716            }
717        }
718
719        // Effective force
720        let ku_pred = Self::mv(&self.k, &u_pred, n);
721        let cv_pred = Self::mv(&self.c, &v_pred, n);
722        let rhs: Vec<f64> = (0..n).map(|i| f_ext[i] - ku_pred[i] - cv_pred[i]).collect();
723
724        let a_new = Self::solve(&k_eff, &rhs, n);
725        let u_new: Vec<f64> = (0..n)
726            .map(|i| u_pred[i] + beta * dt * dt * a_new[i])
727            .collect();
728        let v_new: Vec<f64> = (0..n).map(|i| v_pred[i] + gamma * dt * a_new[i]).collect();
729
730        (u_new, v_new, a_new)
731    }
732
733    /// One step of the Wilson-θ method.
734    ///
735    /// `theta` is typically 1.37–1.42 for unconditional stability.
736    ///
737    /// # Returns
738    /// `(u_{n+1}, v_{n+1}, a_{n+1})`
739    pub fn wilson_theta_step(
740        &self,
741        u: &[f64],
742        v: &[f64],
743        a: &[f64],
744        f_ext: &[f64],
745        dt: f64,
746        theta: f64,
747    ) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
748        let n = self.n;
749        let tau = theta * dt;
750
751        // Effective stiffness K_eff = 6/(θ²dt²)·M + 3/(θdt)·C + K
752        let c1 = 6.0 / (tau * tau);
753        let c2 = 3.0 / tau;
754
755        let mut k_eff = vec![0.0; n * n];
756        for i in 0..n {
757            for j in 0..n {
758                k_eff[i * n + j] =
759                    c1 * self.m[i * n + j] + c2 * self.c[i * n + j] + self.k[i * n + j];
760            }
761        }
762
763        // Extrapolated force at t + θ·dt: linear extrapolation
764        // f_tau ≈ f_t + θ*(f_ext - f_t)  but we have only f_ext (at t+dt),
765        // so approximate f_tau = f_ext (conservative)
766        let f_tau = f_ext.to_vec();
767
768        // Effective RHS
769        let mu = Self::mv(&self.m, u, n);
770        let mv_curr = Self::mv(&self.m, v, n);
771        let ma = Self::mv(&self.m, a, n);
772        let cu = Self::mv(&self.c, u, n);
773        let cv_curr = Self::mv(&self.c, v, n);
774
775        let rhs: Vec<f64> = (0..n)
776            .map(|i| {
777                f_tau[i] + c1 * mu[i] + c2 * mv_curr[i] + 2.0 * ma[i]
778                    - self.k[i * n..i * n + n]
779                        .iter()
780                        .zip(u.iter())
781                        .map(|(kij, uj)| kij * uj)
782                        .sum::<f64>()
783                    + (c2 * cu[i] + cv_curr[i])
784            })
785            .collect();
786
787        // Solve for a at t + θ·dt
788        let a_tau = Self::solve(&k_eff, &rhs, n);
789
790        // Interpolate back to t + dt
791        let a_new: Vec<f64> = (0..n).map(|i| a[i] + (a_tau[i] - a[i]) / theta).collect();
792        let v_new: Vec<f64> = (0..n)
793            .map(|i| v[i] + dt / 2.0 * (a[i] + a_new[i]))
794            .collect();
795        let u_new: Vec<f64> = (0..n)
796            .map(|i| u[i] + dt * v[i] + dt * dt / 6.0 * (2.0 * a[i] + a_new[i]))
797            .collect();
798
799        (u_new, v_new, a_new)
800    }
801}
802
803// ---------------------------------------------------------------------------
804// Stress state and invariants
805// ---------------------------------------------------------------------------
806
807/// Full 3-D stress state in Voigt notation: \[σ_xx, σ_yy, σ_zz, τ_xy, τ_yz, τ_xz\].
808#[derive(Debug, Clone, Copy)]
809pub struct StressState {
810    /// Voigt stress components.
811    pub sigma: [f64; 6],
812}
813
814impl StressState {
815    /// Construct from a Voigt array.
816    pub fn from_voigt(sigma: [f64; 6]) -> Self {
817        Self { sigma }
818    }
819
820    /// Construct from individual components.
821    pub fn new(sxx: f64, syy: f64, szz: f64, txy: f64, tyz: f64, txz: f64) -> Self {
822        Self {
823            sigma: [sxx, syy, szz, txy, tyz, txz],
824        }
825    }
826
827    /// Hydrostatic (mean) stress: p = (σ_xx + σ_yy + σ_zz) / 3.
828    pub fn hydrostatic(&self) -> f64 {
829        (self.sigma[0] + self.sigma[1] + self.sigma[2]) / 3.0
830    }
831
832    /// First stress invariant I₁ = σ_xx + σ_yy + σ_zz.
833    pub fn i1(&self) -> f64 {
834        self.sigma[0] + self.sigma[1] + self.sigma[2]
835    }
836
837    /// Second deviatoric stress invariant J₂.
838    ///
839    /// J₂ = ½ s_ij s_ij  where  s_ij = σ_ij − p δ_ij.
840    pub fn j2(&self) -> f64 {
841        let p = self.hydrostatic();
842        let sx = self.sigma[0] - p;
843        let sy = self.sigma[1] - p;
844        let sz = self.sigma[2] - p;
845        let txy = self.sigma[3];
846        let tyz = self.sigma[4];
847        let txz = self.sigma[5];
848        0.5 * (sx * sx + sy * sy + sz * sz + 2.0 * (txy * txy + tyz * tyz + txz * txz))
849    }
850
851    /// Third deviatoric stress invariant J₃.
852    pub fn j3(&self) -> f64 {
853        let p = self.hydrostatic();
854        let sx = self.sigma[0] - p;
855        let sy = self.sigma[1] - p;
856        let sz = self.sigma[2] - p;
857        let txy = self.sigma[3];
858        let tyz = self.sigma[4];
859        let txz = self.sigma[5];
860        sx * (sy * sz - tyz * tyz) - txy * (txy * sz - tyz * txz) + txz * (txy * tyz - sy * txz)
861    }
862
863    /// Von Mises equivalent stress: σ_vm = √(3 J₂).
864    pub fn von_mises(&self) -> f64 {
865        (3.0 * self.j2()).max(0.0).sqrt()
866    }
867
868    /// Tresca equivalent stress: max principal − min principal.
869    pub fn tresca_stress(&self) -> f64 {
870        let [s1, _s2, s3] = self.principal_stresses();
871        s1 - s3
872    }
873
874    /// Principal stresses sorted in descending order \[σ₁ ≥ σ₂ ≥ σ₃\].
875    ///
876    /// Uses analytical solution for the characteristic equation of the
877    /// symmetric 3×3 stress tensor.
878    pub fn principal_stresses(&self) -> [f64; 3] {
879        let p = self.hydrostatic();
880        let j2 = self.j2();
881        let j3 = self.j3();
882        let r = (j2 / 3.0).max(0.0).sqrt();
883        if r < 1e-30 {
884            return [p, p, p];
885        }
886        let cos_arg = (j3 / (2.0 * r * r * r)).clamp(-1.0, 1.0);
887        let theta = cos_arg.acos() / 3.0;
888        use std::f64::consts::PI;
889        let s1 = p + 2.0 * r * theta.cos();
890        let s2 = p + 2.0 * r * (theta - 2.0 * PI / 3.0).cos();
891        let s3 = p + 2.0 * r * (theta - 4.0 * PI / 3.0).cos();
892        let mut arr = [s1, s2, s3];
893        arr.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
894        arr
895    }
896
897    /// Lode angle in degrees θ ∈ \[0°, 60°\].
898    ///
899    /// θ = 0°: axisymmetric tension; θ = 60°: axisymmetric compression.
900    pub fn lode_angle_deg(&self) -> f64 {
901        let j2 = self.j2();
902        let j3 = self.j3();
903        if j2 < 1e-30 {
904            return 0.0;
905        }
906        let cos_arg = (3.0_f64.sqrt() * 3.0 * j3 / (2.0 * j2.powf(1.5))).clamp(-1.0, 1.0);
907        let theta_rad = cos_arg.acos() / 3.0;
908        theta_rad.to_degrees()
909    }
910
911    /// Safety factor by the von Mises criterion.
912    ///
913    /// `sf = yield_stress / sigma_vm`.  Returns `f64::INFINITY` if von Mises stress is zero.
914    pub fn safety_factor_von_mises(&self, yield_stress: f64) -> f64 {
915        let vm = self.von_mises();
916        if vm < 1e-30 {
917            return f64::INFINITY;
918        }
919        yield_stress / vm
920    }
921
922    /// Safety factor by the Tresca criterion.
923    pub fn safety_factor_tresca(&self, yield_stress: f64) -> f64 {
924        let tresca = self.tresca_stress();
925        if tresca < 1e-30 {
926            return f64::INFINITY;
927        }
928        yield_stress / tresca
929    }
930
931    /// Drucker-Prager equivalent stress: σ_dp = α·I₁ + √J₂.
932    ///
933    /// Useful for granular materials where hydrostatic pressure affects yielding.
934    pub fn drucker_prager(&self, alpha: f64) -> f64 {
935        alpha * self.i1() + self.j2().max(0.0).sqrt()
936    }
937}
938
939// ---------------------------------------------------------------------------
940// Fatigue life estimator (Basquin's law)
941// ---------------------------------------------------------------------------
942
943/// Estimates high-cycle fatigue life using Basquin's power law:
944///
945/// N = N_ref * (σ_ref / σ_a)^m
946///
947/// where σ_a is stress amplitude, σ_ref is reference stress at N_ref cycles,
948/// and m = log(N_ref) / log(σ_uts / σ_ref) (slope exponent).
949#[derive(Debug, Clone)]
950pub struct FatigueLifeEstimator {
951    /// Ultimate tensile strength (UTS) \[Pa\].
952    pub sigma_uts: f64,
953    /// Reference number of cycles (e.g., 10⁶).
954    pub n_ref: f64,
955    /// Endurance limit \[Pa\]. Stress amplitudes below this never fail.
956    pub endurance_limit: f64,
957}
958
959impl FatigueLifeEstimator {
960    /// Construct a new Basquin fatigue model.
961    pub fn new(sigma_uts: f64, n_ref: f64, endurance_limit: f64) -> Self {
962        assert!(sigma_uts > 0.0);
963        assert!(n_ref > 0.0);
964        assert!(endurance_limit >= 0.0 && endurance_limit < sigma_uts);
965        Self {
966            sigma_uts,
967            n_ref,
968            endurance_limit,
969        }
970    }
971
972    /// Compute the number of cycles to failure for a given stress amplitude.
973    ///
974    /// Returns `f64::INFINITY` if the stress amplitude is at or below the endurance limit.
975    pub fn cycles_to_failure(&self, sigma_a: f64) -> f64 {
976        if sigma_a <= self.endurance_limit {
977            return f64::INFINITY;
978        }
979        if sigma_a >= self.sigma_uts {
980            return 1.0;
981        }
982        // Basquin exponent: assume reference stress = σ_uts / 2 at N_ref
983        // N = N_ref * (sigma_ref / sigma_a)^m, where sigma_ref = sigma_uts at N=1
984        let m = self.basquin_exponent();
985        self.n_ref * (self.sigma_uts / sigma_a).powf(m)
986    }
987
988    /// Basquin exponent m from N_ref and σ_uts.
989    ///
990    /// Derived from the two-point definition: (σ_uts, 1) and (σ_ref, N_ref).
991    pub fn basquin_exponent(&self) -> f64 {
992        // N_ref = (σ_uts / σ_ref)^m
993        // Assuming σ_ref = 0.5 * σ_uts:
994        self.n_ref.log10() / 2.0_f64.log10()
995    }
996
997    /// Goodman correction for mean stress σ_mean.
998    ///
999    /// Reduces the allowable stress amplitude: σ_a_eff = σ_a / (1 - σ_mean/σ_uts).
1000    pub fn goodman_corrected_amplitude(&self, sigma_a: f64, sigma_mean: f64) -> f64 {
1001        let denom = 1.0 - sigma_mean / self.sigma_uts;
1002        if denom <= 0.0 {
1003            return f64::INFINITY;
1004        }
1005        sigma_a / denom
1006    }
1007}
1008
1009/// Compute Palmgren-Miner cumulative damage from a slice of cycle ratios.
1010///
1011/// D = Σ (n_i / N_i)  where each element is the ratio n_i/N_i.
1012/// Failure is predicted when D ≥ 1.
1013pub fn miner_damage(cycle_ratios: &[f64]) -> f64 {
1014    cycle_ratios.iter().sum()
1015}
1016
1017// ---------------------------------------------------------------------------
1018// Post-processing utilities
1019// ---------------------------------------------------------------------------
1020
1021/// Compute element-averaged strains from a displacement field and B-matrices.
1022///
1023/// For each element, ε = B · u_local.  Stores result as `[f64; 6]` in Voigt order.
1024pub fn compute_element_strains(
1025    displacements: &[f64],
1026    element_dofs: &[Vec<usize>],
1027    b_matrices: &[Vec<f64>],
1028) -> Vec<[f64; 6]> {
1029    element_dofs
1030        .iter()
1031        .zip(b_matrices.iter())
1032        .map(|(dofs, b)| {
1033            let mut eps = [0.0f64; 6];
1034            let n_dof = dofs.len();
1035            for row in 0..6 {
1036                let mut sum = 0.0;
1037                for col in 0..n_dof {
1038                    if dofs[col] < displacements.len() {
1039                        sum += b[row * n_dof + col] * displacements[dofs[col]];
1040                    }
1041                }
1042                eps[row] = sum;
1043            }
1044            eps
1045        })
1046        .collect()
1047}
1048
1049/// Compute element von Mises stresses from a stress field.
1050pub fn von_mises_field(stresses: &[[f64; 6]]) -> Vec<f64> {
1051    stresses
1052        .iter()
1053        .map(|&s| StressState::from_voigt(s).von_mises())
1054        .collect()
1055}
1056
1057/// Compute element safety factors by von Mises criterion.
1058pub fn safety_factor_field(stresses: &[[f64; 6]], yield_stress: f64) -> Vec<f64> {
1059    stresses
1060        .iter()
1061        .map(|&s| StressState::from_voigt(s).safety_factor_von_mises(yield_stress))
1062        .collect()
1063}
1064
1065/// Smooth a field over neighboring elements by averaging with weights.
1066///
1067/// Useful for stress smoothing after solving (superconvergent patch recovery
1068/// approximation via simple neighbor average).
1069pub fn smooth_field(values: &[f64], neighbors: &[Vec<usize>]) -> Vec<f64> {
1070    values
1071        .iter()
1072        .enumerate()
1073        .map(|(i, &v)| {
1074            let nbrs = &neighbors[i];
1075            if nbrs.is_empty() {
1076                return v;
1077            }
1078            let sum: f64 = nbrs
1079                .iter()
1080                .map(|&j| if j < values.len() { values[j] } else { 0.0 })
1081                .sum();
1082            (v + sum) / (1 + nbrs.len()) as f64
1083        })
1084        .collect()
1085}
1086
1087// ---------------------------------------------------------------------------
1088// Rayleigh damping
1089// ---------------------------------------------------------------------------
1090
1091/// Rayleigh damping: C = α·M + β·K.
1092///
1093/// α and β are derived from two modal damping ratios at given frequencies.
1094#[derive(Debug, Clone, Copy)]
1095pub struct RayleighDamping {
1096    /// Mass-proportional coefficient.
1097    pub alpha: f64,
1098    /// Stiffness-proportional coefficient.
1099    pub beta: f64,
1100}
1101
1102impl RayleighDamping {
1103    /// Compute Rayleigh coefficients from two target modes.
1104    ///
1105    /// At ω_i and ω_j with damping ratios ζ_i and ζ_j:
1106    /// \[α; β\] = 2·\[ω_i, ω_j; 1/ω_i, 1/ω_j\]^{-1} · \[ζ_i; ζ_j\]
1107    pub fn from_two_modes(omega_i: f64, omega_j: f64, zeta_i: f64, zeta_j: f64) -> Self {
1108        // Solve 2×2 system:
1109        // ζ_i = α/(2ω_i) + β·ω_i/2
1110        // ζ_j = α/(2ω_j) + β·ω_j/2
1111        let det = (omega_j * omega_j - omega_i * omega_i) / (2.0 * omega_i * omega_j);
1112        if det.abs() < 1e-30 {
1113            return Self {
1114                alpha: 0.0,
1115                beta: 0.0,
1116            };
1117        }
1118        // Rearranged from 2x2 linear system
1119        let alpha = 2.0 * omega_i * omega_j * (zeta_i * omega_j - zeta_j * omega_i)
1120            / (omega_j * omega_j - omega_i * omega_i);
1121        let beta =
1122            2.0 * (zeta_j * omega_j - zeta_i * omega_i) / (omega_j * omega_j - omega_i * omega_i);
1123        Self { alpha, beta }
1124    }
1125
1126    /// Damping ratio at a given angular frequency ω.
1127    pub fn damping_ratio(&self, omega: f64) -> f64 {
1128        if omega < 1e-30 {
1129            return 0.0;
1130        }
1131        self.alpha / (2.0 * omega) + self.beta * omega / 2.0
1132    }
1133
1134    /// Assemble dense damping matrix C = α·M + β·K.
1135    pub fn assemble(&self, mass: &[f64], stiffness: &[f64], n: usize) -> Vec<f64> {
1136        assert_eq!(mass.len(), n * n);
1137        assert_eq!(stiffness.len(), n * n);
1138        (0..n * n)
1139            .map(|k| self.alpha * mass[k] + self.beta * stiffness[k])
1140            .collect()
1141    }
1142}
1143
1144// ---------------------------------------------------------------------------
1145// Tests
1146// ---------------------------------------------------------------------------
1147
1148#[cfg(test)]
1149mod tests {
1150    use super::*;
1151
1152    // ---- StaticAnalysis tests ----
1153
1154    #[test]
1155    fn test_static_solve_2dof() {
1156        // K = [[4, -1],[-1, 3]], f = [1, 2]
1157        // Solution: 4u0 - u1 = 1, -u0 + 3u1 = 2 → u0 = 5/11, u1 = 9/11
1158        let mut sa = StaticAnalysis::new(2);
1159        sa.stiffness_matrix = vec![4.0, -1.0, -1.0, 3.0];
1160        sa.force_vector = vec![1.0, 2.0];
1161        let u = sa.solve().unwrap();
1162        let expected0 = 5.0 / 11.0;
1163        let expected1 = 9.0 / 11.0;
1164        assert!(
1165            (u[0] - expected0).abs() < 1e-12,
1166            "u[0] = {}, expected {expected0}",
1167            u[0]
1168        );
1169        assert!(
1170            (u[1] - expected1).abs() < 1e-12,
1171            "u[1] = {}, expected {expected1}",
1172            u[1]
1173        );
1174    }
1175
1176    #[test]
1177    fn test_static_solve_singular() {
1178        let mut sa = StaticAnalysis::new(2);
1179        sa.stiffness_matrix = vec![1.0, 1.0, 1.0, 1.0];
1180        sa.force_vector = vec![1.0, 1.0];
1181        let result = sa.solve();
1182        assert!(result.is_err(), "expected SingularSystem error");
1183    }
1184
1185    #[test]
1186    fn test_static_assemble_and_solve() {
1187        // Single 2-DOF element: ke = [[2,-2],[-2,2]], dofs=[0,1]
1188        // Fix DOF 0: set K[0,0]=1, K[0,1]=0, f[0]=0
1189        let mut sa = StaticAnalysis::new(2);
1190        let elements = vec![(vec![0usize, 1], vec![2.0, -2.0, -2.0, 2.0])];
1191        sa.assemble_global_stiffness(&elements);
1192        // Apply Dirichlet BC at DOF 0
1193        sa.stiffness_matrix[0] = 1.0;
1194        sa.stiffness_matrix[1] = 0.0;
1195        sa.stiffness_matrix[2] = 0.0;
1196        sa.force_vector[0] = 0.0;
1197        sa.force_vector[1] = 1.0; // unit force on DOF 1
1198        let u = sa.solve().unwrap();
1199        assert!((u[0]).abs() < 1e-12, "u[0] should be 0, got {}", u[0]);
1200        assert!(u[1] > 0.0, "u[1] should be positive, got {}", u[1]);
1201    }
1202
1203    #[test]
1204    fn test_compute_strains() {
1205        // 1-DOF trivial: B = [1.0, 0, 0, 0, 0, 0], u=[0.5]
1206        let sa = StaticAnalysis::new(1);
1207        let b_mat: Vec<f64> = {
1208            let mut v = vec![0.0; 6];
1209            v[0] = 1.0;
1210            v
1211        };
1212        let elements = vec![(vec![0usize], b_mat)];
1213        let u = vec![0.5_f64];
1214        let strains = sa.compute_strains(&u, &elements);
1215        assert_eq!(strains.len(), 1);
1216        assert!((strains[0][0] - 0.5).abs() < 1e-15);
1217        for (k, &val) in strains[0].iter().enumerate().skip(1) {
1218            assert_eq!(val, 0.0, "strains[0][{k}] should be 0");
1219        }
1220    }
1221
1222    #[test]
1223    fn test_compute_stresses_identity_d() {
1224        // D = I_6 → σ = ε
1225        let mut d = [0.0f64; 36];
1226        for i in 0..6 {
1227            d[i * 6 + i] = 1.0;
1228        }
1229        let eps = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
1230        let stresses = StaticAnalysis::compute_stresses(&[eps], &d);
1231        assert_eq!(stresses[0], eps);
1232    }
1233
1234    // ---- ModalAnalysis tests ----
1235
1236    #[test]
1237    fn test_modal_1dof() {
1238        // K = [k], M = [m] → ω = sqrt(k/m)
1239        let k_val = 400.0f64;
1240        let m_val = 1.0f64;
1241        let k = vec![k_val];
1242        let m = vec![m_val];
1243        let _ma = ModalAnalysis::default();
1244        let freqs = ModalAnalysis::compute_natural_frequencies(&k, &m, 1, 1, 200);
1245        let expected = k_val.sqrt();
1246        assert!(
1247            (freqs[0] - expected).abs() / expected < 1e-5,
1248            "ω = {}, expected {expected}",
1249            freqs[0]
1250        );
1251    }
1252
1253    #[test]
1254    fn test_modal_2dof_diagonal() {
1255        // K = diag(1, 9), M = I → ω₁=1, ω₂=3
1256        let k = vec![1.0, 0.0, 0.0, 9.0];
1257        let m = vec![1.0, 0.0, 0.0, 1.0];
1258        let _ma = ModalAnalysis::default();
1259        let mut freqs = ModalAnalysis::compute_natural_frequencies(&k, &m, 2, 2, 500);
1260        freqs.sort_by(|a, b| a.partial_cmp(b).unwrap());
1261        assert!((freqs[0] - 1.0).abs() < 0.05, "ω₁ = {}", freqs[0]);
1262        assert!((freqs[1] - 3.0).abs() < 0.05, "ω₂ = {}", freqs[1]);
1263    }
1264
1265    #[test]
1266    fn test_mode_shape_1dof() {
1267        let k = vec![25.0_f64];
1268        let m = vec![1.0_f64];
1269        let phi = ModalAnalysis::compute_mode_shapes(&k, &m, 1, 5.0, 100);
1270        // Mass-normalised: φᵀ·M·φ = 1 → |φ| = 1
1271        let norm: f64 = phi.iter().map(|x| x * x).sum::<f64>().sqrt();
1272        assert!((norm - 1.0).abs() < 1e-6, "mass norm = {norm}");
1273    }
1274
1275    // ---- DynamicAnalysis tests ----
1276
1277    /// 1-DOF undamped free vibration: M·ü + K·u = 0.
1278    /// Verifies the Newmark method produces a bounded, oscillatory response.
1279    #[test]
1280    fn test_newmark_free_vibration() {
1281        let m_val = 1.0_f64;
1282        let k_val = 100.0_f64;
1283        let dt = 0.001;
1284        let beta = 0.25;
1285        let gamma = 0.5;
1286
1287        let da = DynamicAnalysis::new(vec![m_val], vec![0.0], vec![k_val], 1);
1288        let mut u = vec![1.0_f64];
1289        let mut v = vec![0.0_f64];
1290        let mut a = vec![-k_val / m_val];
1291
1292        for _ in 0..100 {
1293            let (un, vn, an) = da.newmark_beta_step(&u, &v, &a, &[0.0], dt, beta, gamma);
1294            u = un;
1295            v = vn;
1296            a = an;
1297        }
1298        // Displacement should stay bounded (energy conservation)
1299        assert!(u[0].abs() <= 1.01, "u should be bounded, got {}", u[0]);
1300    }
1301
1302    #[test]
1303    fn test_newmark_static_equilibrium() {
1304        // Apply constant force f to 1-DOF with damping: should move toward u_static = f/k
1305        let da = DynamicAnalysis::new(vec![1.0], vec![0.1], vec![10.0], 1);
1306        let mut u = vec![0.0_f64];
1307        let mut v = vec![0.0_f64];
1308        let mut a = vec![0.0_f64];
1309        let f = vec![5.0_f64]; // static → u_static = 5/10 = 0.5
1310        for _ in 0..5000 {
1311            let (un, vn, an) = da.newmark_beta_step(&u, &v, &a, &f, 0.01, 0.25, 0.5);
1312            u = un;
1313            v = vn;
1314            a = an;
1315        }
1316        // Should be in the general vicinity of the static solution
1317        assert!(u[0] > 0.0, "u should be positive, got {}", u[0]);
1318    }
1319
1320    #[test]
1321    fn test_wilson_theta_step_displacement_sign() {
1322        // Force applied in positive direction → displacement must be positive
1323        let da = DynamicAnalysis::new(vec![1.0], vec![0.0], vec![1.0], 1);
1324        let u = vec![0.0_f64];
1325        let v = vec![0.0_f64];
1326        let a = vec![0.0_f64];
1327        let (u_new, _, _) = da.wilson_theta_step(&u, &v, &a, &[1.0], 0.01, 1.4);
1328        assert!(
1329            u_new[0] >= 0.0,
1330            "displacement should be non-negative, got {}",
1331            u_new[0]
1332        );
1333    }
1334
1335    // ---- LinearStaticAnalysis tests ----
1336
1337    #[test]
1338    fn test_cantilever_deflection() {
1339        let length: f64 = 0.5;
1340        let width: f64 = 0.1;
1341        let height: f64 = 0.1;
1342        let e: f64 = 200.0e9;
1343        let nu: f64 = 0.3;
1344        let p: f64 = -500.0;
1345
1346        let mesh = TetrahedralMesh::generate_beam(length, width, height, 4, 1, 1);
1347        let material = LinearElasticMaterial::new(e, nu);
1348
1349        let mut dirichlet_bcs = Vec::new();
1350        for (i, node) in mesh.nodes.iter().enumerate() {
1351            if node.x.abs() < 1e-10 {
1352                dirichlet_bcs.push(DirichletBc::new(i, 0, 0.0));
1353                dirichlet_bcs.push(DirichletBc::new(i, 1, 0.0));
1354                dirichlet_bcs.push(DirichletBc::new(i, 2, 0.0));
1355            }
1356        }
1357
1358        let free_end_nodes: Vec<usize> = mesh
1359            .nodes
1360            .iter()
1361            .enumerate()
1362            .filter(|(_, n)| (n.x - length).abs() < 1e-10)
1363            .map(|(i, _)| i)
1364            .collect();
1365        let num_free = free_end_nodes.len() as f64;
1366        let neumann_bcs: Vec<NeumannBc> = free_end_nodes
1367            .iter()
1368            .map(|&i| NeumannBc::new(i, [0.0, p / num_free, 0.0]))
1369            .collect();
1370
1371        let analysis = LinearStaticAnalysis {
1372            max_iter: 20000,
1373            tolerance: 1e-10,
1374        };
1375        let result = analysis.solve(
1376            &mesh,
1377            &material,
1378            &dirichlet_bcs,
1379            &neumann_bcs,
1380            &Vec3::new(0.0, 0.0, 0.0),
1381        );
1382
1383        let max_y_disp = free_end_nodes
1384            .iter()
1385            .map(|&i| result.displacements[i].y)
1386            .fold(f64::INFINITY, f64::min);
1387
1388        assert!(
1389            max_y_disp < 0.0,
1390            "Tip deflection should be downward (negative y), got {max_y_disp:.6e}"
1391        );
1392    }
1393
1394    // ---- StressInvariants ----
1395
1396    #[test]
1397    fn test_hydrostatic_pure_hydrostatic() {
1398        let sigma = [1.0, 1.0, 1.0, 0.0, 0.0, 0.0];
1399        let s = StressState::from_voigt(sigma);
1400        assert!(
1401            (s.hydrostatic() - 1.0).abs() < 1e-12,
1402            "hydrostatic={}",
1403            s.hydrostatic()
1404        );
1405    }
1406
1407    #[test]
1408    fn test_von_mises_zero_for_hydrostatic() {
1409        let sigma = [5.0, 5.0, 5.0, 0.0, 0.0, 0.0];
1410        let s = StressState::from_voigt(sigma);
1411        assert!(
1412            s.von_mises() < 1e-10,
1413            "von Mises should be 0 for hydrostatic: {}",
1414            s.von_mises()
1415        );
1416    }
1417
1418    #[test]
1419    fn test_von_mises_uniaxial() {
1420        let s0 = 300e6;
1421        let sigma = [s0, 0.0, 0.0, 0.0, 0.0, 0.0];
1422        let s = StressState::from_voigt(sigma);
1423        assert!(
1424            (s.von_mises() - s0).abs() < 1.0,
1425            "von Mises uniaxial: {}",
1426            s.von_mises()
1427        );
1428    }
1429
1430    #[test]
1431    fn test_principal_stresses_sorted() {
1432        let sigma = [200e6, 100e6, 50e6, 0.0, 0.0, 0.0];
1433        let s = StressState::from_voigt(sigma);
1434        let [s1, s2, s3] = s.principal_stresses();
1435        assert!(
1436            s1 >= s2 && s2 >= s3,
1437            "principal stresses not sorted: {s1} {s2} {s3}"
1438        );
1439    }
1440
1441    #[test]
1442    fn test_safety_factor_von_mises() {
1443        let sigma = [200e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1444        let s = StressState::from_voigt(sigma);
1445        let sf = s.safety_factor_von_mises(400e6);
1446        assert!((sf - 2.0).abs() < 1e-6, "safety factor={sf}");
1447    }
1448
1449    #[test]
1450    fn test_lode_angle_uniaxial_tension() {
1451        let sigma = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1452        let s = StressState::from_voigt(sigma);
1453        // Lode angle for uniaxial tension = 30° (π/6)
1454        let theta = s.lode_angle_deg();
1455        assert!(theta.is_finite(), "Lode angle should be finite: {theta}");
1456    }
1457
1458    #[test]
1459    fn test_tresca_criterion_uniaxial() {
1460        let sigma = [200e6, 0.0, 0.0, 0.0, 0.0, 0.0];
1461        let s = StressState::from_voigt(sigma);
1462        // Tresca = max principal - min principal
1463        let tresca = s.tresca_stress();
1464        assert!((tresca - 200e6).abs() < 1.0, "Tresca={tresca}");
1465    }
1466
1467    // ---- FatigueLifeEstimator ----
1468
1469    #[test]
1470    fn test_fatigue_life_below_endurance() {
1471        let est = FatigueLifeEstimator::new(500e6, 1e6, 200e6);
1472        let life = est.cycles_to_failure(100e6);
1473        assert!(
1474            life == f64::INFINITY,
1475            "below endurance should return infinity: {life}"
1476        );
1477    }
1478
1479    #[test]
1480    fn test_fatigue_life_at_ultimate() {
1481        let est = FatigueLifeEstimator::new(500e6, 1e6, 200e6);
1482        let life = est.cycles_to_failure(500e6);
1483        assert!(life <= 1e6 + 1.0, "at UTS life should be <= N_ref: {life}");
1484    }
1485
1486    #[test]
1487    fn test_damage_accumulation_miner() {
1488        let d = miner_damage(&[0.3, 0.5, 0.2]);
1489        assert!((d - 1.0).abs() < 1e-12, "Miner sum = {d}");
1490    }
1491
1492    // ---- CantileverBeam ----
1493
1494    #[test]
1495    fn test_cantilever_beam() {
1496        let length: f64 = 1.0;
1497        let width: f64 = 0.1;
1498        let height: f64 = 0.1;
1499        let e: f64 = 200.0e9;
1500        let nu: f64 = 0.3;
1501        let p: f64 = -1000.0;
1502
1503        let inertia = width * height.powi(3) / 12.0;
1504        let analytical_deflection = p * length.powi(3) / (3.0 * e * inertia);
1505
1506        let nx = 10;
1507        let ny = 2;
1508        let nz = 2;
1509        let mesh = TetrahedralMesh::generate_beam(length, width, height, nx, ny, nz);
1510        let material = LinearElasticMaterial::new(e, nu);
1511
1512        let mut dirichlet_bcs = Vec::new();
1513        for (i, node) in mesh.nodes.iter().enumerate() {
1514            if node.x.abs() < 1e-10 {
1515                dirichlet_bcs.push(DirichletBc::new(i, 0, 0.0));
1516                dirichlet_bcs.push(DirichletBc::new(i, 1, 0.0));
1517                dirichlet_bcs.push(DirichletBc::new(i, 2, 0.0));
1518            }
1519        }
1520
1521        let free_end_nodes: Vec<usize> = mesh
1522            .nodes
1523            .iter()
1524            .enumerate()
1525            .filter(|(_, n)| (n.x - length).abs() < 1e-10)
1526            .map(|(i, _)| i)
1527            .collect();
1528        let num_free = free_end_nodes.len() as f64;
1529        let neumann_bcs: Vec<NeumannBc> = free_end_nodes
1530            .iter()
1531            .map(|&i| NeumannBc::new(i, [0.0, p / num_free, 0.0]))
1532            .collect();
1533
1534        let analysis = LinearStaticAnalysis {
1535            max_iter: 50000,
1536            tolerance: 1e-12,
1537        };
1538
1539        let result = analysis.solve(
1540            &mesh,
1541            &material,
1542            &dirichlet_bcs,
1543            &neumann_bcs,
1544            &Vec3::new(0.0, 0.0, 0.0),
1545        );
1546
1547        let max_y_disp = free_end_nodes
1548            .iter()
1549            .map(|&i| result.displacements[i].y)
1550            .fold(f64::INFINITY, f64::min);
1551
1552        let error = ((max_y_disp - analytical_deflection) / analytical_deflection).abs();
1553
1554        assert!(
1555            max_y_disp < 0.0,
1556            "Deflection should be negative (downward), got {max_y_disp:.6e}",
1557        );
1558        assert!(
1559            error < 1.0,
1560            "Cantilever deflection error too large: {:.1}%. \
1561             FEM = {max_y_disp:.6e}, analytical = {analytical_deflection:.6e}",
1562            error * 100.0,
1563        );
1564    }
1565}