Skip to main content

oxiphysics_python/
fem_api.rs

1#![allow(clippy::too_many_arguments)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Finite Element Method (FEM) simulation API for Python interop.
6//!
7//! Provides a standalone structural mechanics FEM solver supporting:
8//! - Linear elastic 2-D triangular elements (CST - Constant Strain Triangle)
9//! - Assembly of global stiffness matrix
10//! - Dirichlet boundary conditions (fixed DOFs)
11//! - Nodal load vectors
12//! - Conjugate Gradient iterative solver
13//! - Displacement, stress, and strain output
14
15#![allow(missing_docs)]
16#![allow(dead_code)]
17
18use serde::{Deserialize, Serialize};
19
20// ---------------------------------------------------------------------------
21// Material
22// ---------------------------------------------------------------------------
23
24/// Linear elastic isotropic material properties.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PyFemMaterial {
27    /// Young's modulus E (Pa).
28    pub young_modulus: f64,
29    /// Poisson's ratio ν (dimensionless, 0..0.5).
30    pub poisson_ratio: f64,
31    /// Mass density ρ (kg/m³).
32    pub density: f64,
33}
34
35impl PyFemMaterial {
36    /// Create a new material.
37    pub fn new(young_modulus: f64, poisson_ratio: f64, density: f64) -> Self {
38        Self {
39            young_modulus,
40            poisson_ratio,
41            density,
42        }
43    }
44
45    /// Steel-like material (E=200 GPa, ν=0.3, ρ=7850 kg/m³).
46    pub fn steel() -> Self {
47        Self::new(200e9, 0.3, 7850.0)
48    }
49
50    /// Aluminum-like material (E=70 GPa, ν=0.33, ρ=2700 kg/m³).
51    pub fn aluminum() -> Self {
52        Self::new(70e9, 0.33, 2700.0)
53    }
54
55    /// Rubber-like material (E=0.01 GPa, ν=0.49, ρ=1100 kg/m³).
56    pub fn rubber() -> Self {
57        Self::new(10e6, 0.49, 1100.0)
58    }
59
60    /// Concrete-like material (E=30 GPa, ν=0.2, ρ=2400 kg/m³).
61    pub fn concrete() -> Self {
62        Self::new(30e9, 0.2, 2400.0)
63    }
64
65    /// Compute the plane-stress constitutive matrix D (3×3 as flat \[f64; 9\]).
66    ///
67    /// Returns the D matrix that relates stress to strain:
68    /// σ = D ε
69    #[allow(non_snake_case)]
70    pub fn plane_stress_D(&self) -> [f64; 9] {
71        let e = self.young_modulus;
72        let nu = self.poisson_ratio;
73        let c = e / (1.0 - nu * nu);
74        [
75            c,
76            c * nu,
77            0.0,
78            c * nu,
79            c,
80            0.0,
81            0.0,
82            0.0,
83            c * (1.0 - nu) * 0.5,
84        ]
85    }
86}
87
88impl Default for PyFemMaterial {
89    fn default() -> Self {
90        Self::steel()
91    }
92}
93
94// ---------------------------------------------------------------------------
95// Node
96// ---------------------------------------------------------------------------
97
98/// A single node in the FEM mesh.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PyFemNode {
101    /// Node coordinates \[x, y\].
102    pub position: [f64; 2],
103}
104
105impl PyFemNode {
106    /// Create a new node at the given 2-D position.
107    pub fn new(x: f64, y: f64) -> Self {
108        Self { position: [x, y] }
109    }
110}
111
112// ---------------------------------------------------------------------------
113// Element (CST triangular element)
114// ---------------------------------------------------------------------------
115
116/// A triangular (CST) element defined by three node indices.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct PyFemElement {
119    /// Indices of the three corner nodes (counter-clockwise preferred).
120    pub nodes: [usize; 3],
121    /// Index into the material list.
122    pub material_id: usize,
123    /// Element thickness t (for plane-stress, metres).
124    pub thickness: f64,
125}
126
127impl PyFemElement {
128    /// Create a new triangular element.
129    pub fn new(n0: usize, n1: usize, n2: usize, material_id: usize, thickness: f64) -> Self {
130        Self {
131            nodes: [n0, n1, n2],
132            material_id,
133            thickness,
134        }
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Boundary conditions
140// ---------------------------------------------------------------------------
141
142/// A Dirichlet (fixed DOF) boundary condition.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct PyFemDirichletBC {
145    /// Node index.
146    pub node: usize,
147    /// DOF index within the node (0=x, 1=y).
148    pub dof: usize,
149    /// Prescribed displacement value.
150    pub value: f64,
151}
152
153impl PyFemDirichletBC {
154    /// Fix the x-DOF of `node` to zero.
155    pub fn fix_x(node: usize) -> Self {
156        Self {
157            node,
158            dof: 0,
159            value: 0.0,
160        }
161    }
162
163    /// Fix the y-DOF of `node` to zero.
164    pub fn fix_y(node: usize) -> Self {
165        Self {
166            node,
167            dof: 1,
168            value: 0.0,
169        }
170    }
171
172    /// Fix both DOFs of `node` (fully pinned).
173    pub fn pin(node: usize) -> Vec<Self> {
174        vec![Self::fix_x(node), Self::fix_y(node)]
175    }
176}
177
178/// A Neumann (nodal force) boundary condition.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct PyFemNodalForce {
181    /// Node index.
182    pub node: usize,
183    /// Force vector \[fx, fy\] in Newtons.
184    pub force: [f64; 2],
185}
186
187impl PyFemNodalForce {
188    /// Apply force `[fx, fy]` to `node`.
189    pub fn new(node: usize, fx: f64, fy: f64) -> Self {
190        Self {
191            node,
192            force: [fx, fy],
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// FEM Mesh
199// ---------------------------------------------------------------------------
200
201/// A 2-D FEM mesh with nodes, elements, materials, and boundary conditions.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct PyFemMesh {
204    /// All nodes in the mesh.
205    pub nodes: Vec<PyFemNode>,
206    /// All triangular elements in the mesh.
207    pub elements: Vec<PyFemElement>,
208    /// Material library.
209    pub materials: Vec<PyFemMaterial>,
210    /// Dirichlet (fixed) boundary conditions.
211    pub dirichlet_bcs: Vec<PyFemDirichletBC>,
212    /// Nodal force loads.
213    pub nodal_forces: Vec<PyFemNodalForce>,
214}
215
216impl PyFemMesh {
217    /// Create an empty mesh.
218    pub fn new() -> Self {
219        Self {
220            nodes: Vec::new(),
221            elements: Vec::new(),
222            materials: vec![PyFemMaterial::default()],
223            dirichlet_bcs: Vec::new(),
224            nodal_forces: Vec::new(),
225        }
226    }
227
228    /// Add a node at `(x, y)`. Returns node index.
229    pub fn add_node(&mut self, x: f64, y: f64) -> usize {
230        let idx = self.nodes.len();
231        self.nodes.push(PyFemNode::new(x, y));
232        idx
233    }
234
235    /// Add a material. Returns material index.
236    pub fn add_material(&mut self, mat: PyFemMaterial) -> usize {
237        let idx = self.materials.len();
238        self.materials.push(mat);
239        idx
240    }
241
242    /// Add a CST element connecting nodes `n0, n1, n2`. Returns element index.
243    pub fn add_element(&mut self, n0: usize, n1: usize, n2: usize) -> usize {
244        self.add_element_with_material(n0, n1, n2, 0, 1.0)
245    }
246
247    /// Add a CST element with explicit material and thickness. Returns element index.
248    pub fn add_element_with_material(
249        &mut self,
250        n0: usize,
251        n1: usize,
252        n2: usize,
253        material_id: usize,
254        thickness: f64,
255    ) -> usize {
256        let idx = self.elements.len();
257        self.elements
258            .push(PyFemElement::new(n0, n1, n2, material_id, thickness));
259        idx
260    }
261
262    /// Add a Dirichlet boundary condition.
263    pub fn add_dirichlet_bc(&mut self, bc: PyFemDirichletBC) {
264        self.dirichlet_bcs.push(bc);
265    }
266
267    /// Pin both DOFs of `node` (fix x and y to zero).
268    pub fn pin_node(&mut self, node: usize) {
269        self.dirichlet_bcs.push(PyFemDirichletBC::fix_x(node));
270        self.dirichlet_bcs.push(PyFemDirichletBC::fix_y(node));
271    }
272
273    /// Apply a nodal force at `node`.
274    pub fn apply_nodal_force(&mut self, node: usize, fx: f64, fy: f64) {
275        self.nodal_forces.push(PyFemNodalForce::new(node, fx, fy));
276    }
277
278    /// Number of degrees of freedom (2 per node).
279    pub fn num_dofs(&self) -> usize {
280        self.nodes.len() * 2
281    }
282
283    /// Number of nodes.
284    pub fn num_nodes(&self) -> usize {
285        self.nodes.len()
286    }
287
288    /// Number of elements.
289    pub fn num_elements(&self) -> usize {
290        self.elements.len()
291    }
292
293    /// Compute the signed area of triangular element `e`.
294    pub fn element_area(&self, e: usize) -> f64 {
295        let elem = &self.elements[e];
296        let [n0, n1, n2] = elem.nodes;
297        let [x0, y0] = self.nodes[n0].position;
298        let [x1, y1] = self.nodes[n1].position;
299        let [x2, y2] = self.nodes[n2].position;
300        0.5 * ((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0))
301    }
302
303    /// Build a simple structured rectangular mesh of triangles.
304    ///
305    /// Creates a `nx × ny` grid of quads, each quad split into 2 triangles.
306    /// Bottom-left corner at `(ox, oy)`, domain size `(lx, ly)`.
307    /// Returns the number of nodes added.
308    pub fn build_rectangle(
309        &mut self,
310        ox: f64,
311        oy: f64,
312        lx: f64,
313        ly: f64,
314        nx: usize,
315        ny: usize,
316        material_id: usize,
317        thickness: f64,
318    ) -> usize {
319        let dx = lx / nx as f64;
320        let dy = ly / ny as f64;
321        let node_start = self.nodes.len();
322
323        // Add nodes
324        for j in 0..=(ny) {
325            for i in 0..=(nx) {
326                self.add_node(ox + i as f64 * dx, oy + j as f64 * dy);
327            }
328        }
329
330        // Add triangular elements (2 per quad)
331        let row = nx + 1;
332        for j in 0..ny {
333            for i in 0..nx {
334                let n0 = node_start + j * row + i;
335                let n1 = node_start + j * row + i + 1;
336                let n2 = node_start + (j + 1) * row + i;
337                let n3 = node_start + (j + 1) * row + i + 1;
338                self.add_element_with_material(n0, n1, n3, material_id, thickness);
339                self.add_element_with_material(n0, n3, n2, material_id, thickness);
340            }
341        }
342
343        (ny + 1) * (nx + 1)
344    }
345}
346
347impl Default for PyFemMesh {
348    fn default() -> Self {
349        Self::new()
350    }
351}
352
353// ---------------------------------------------------------------------------
354// FEM Solver result
355// ---------------------------------------------------------------------------
356
357/// Result of a FEM linear static solve.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct PyFemSolveResult {
360    /// Nodal displacement vector (flat: \[ux0, uy0, ux1, uy1, ...\]).
361    pub displacements: Vec<f64>,
362    /// Von Mises stress per element.
363    pub von_mises_stress: Vec<f64>,
364    /// Number of CG solver iterations used.
365    pub solver_iterations: usize,
366    /// Residual norm at convergence.
367    pub residual_norm: f64,
368    /// Whether the solver converged.
369    pub converged: bool,
370}
371
372impl PyFemSolveResult {
373    /// Displacement of node `i` as `[ux, uy]`.
374    pub fn node_displacement(&self, i: usize) -> Option<[f64; 2]> {
375        let base = i * 2;
376        if base + 1 < self.displacements.len() {
377            Some([self.displacements[base], self.displacements[base + 1]])
378        } else {
379            None
380        }
381    }
382
383    /// Maximum von Mises stress over all elements.
384    pub fn max_von_mises(&self) -> f64 {
385        self.von_mises_stress
386            .iter()
387            .cloned()
388            .fold(f64::NEG_INFINITY, f64::max)
389    }
390
391    /// Mean von Mises stress.
392    pub fn mean_von_mises(&self) -> f64 {
393        if self.von_mises_stress.is_empty() {
394            return 0.0;
395        }
396        self.von_mises_stress.iter().sum::<f64>() / self.von_mises_stress.len() as f64
397    }
398}
399
400// ---------------------------------------------------------------------------
401// FEM Solver
402// ---------------------------------------------------------------------------
403
404/// Linear static FEM solver for 2-D plane-stress problems.
405///
406/// Assembles the global stiffness matrix K from CST elements, applies
407/// Dirichlet boundary conditions, builds the load vector f, and solves
408/// K u = f with a Conjugate Gradient (CG) iterative solver.
409#[derive(Debug, Clone)]
410pub struct PyFemSolver {
411    /// Maximum CG iterations.
412    pub max_iterations: usize,
413    /// CG convergence tolerance.
414    pub tolerance: f64,
415}
416
417impl PyFemSolver {
418    /// Create a solver with default settings.
419    pub fn new() -> Self {
420        Self {
421            max_iterations: 1000,
422            tolerance: 1e-10,
423        }
424    }
425
426    /// Create a solver with custom settings.
427    pub fn with_settings(max_iterations: usize, tolerance: f64) -> Self {
428        Self {
429            max_iterations,
430            tolerance,
431        }
432    }
433
434    /// Solve the linear static problem K u = f.
435    ///
436    /// Returns `None` if the mesh has no nodes or no elements.
437    pub fn solve(&self, mesh: &PyFemMesh) -> Option<PyFemSolveResult> {
438        let n_nodes = mesh.num_nodes();
439        if n_nodes == 0 || mesh.elements.is_empty() {
440            return None;
441        }
442        let n_dofs = n_nodes * 2;
443
444        // --- Assemble global stiffness K (stored as flat dense n_dofs×n_dofs) ---
445        let mut k = vec![0.0f64; n_dofs * n_dofs];
446        for elem in &mesh.elements {
447            let mat = mesh
448                .materials
449                .get(elem.material_id)
450                .unwrap_or(&mesh.materials[0]);
451            let ke = self.element_stiffness(mesh, elem, mat);
452            // Scatter into global K
453            let dofs: [usize; 6] = [
454                elem.nodes[0] * 2,
455                elem.nodes[0] * 2 + 1,
456                elem.nodes[1] * 2,
457                elem.nodes[1] * 2 + 1,
458                elem.nodes[2] * 2,
459                elem.nodes[2] * 2 + 1,
460            ];
461            for (i, &gi) in dofs.iter().enumerate() {
462                for (j, &gj) in dofs.iter().enumerate() {
463                    k[gi * n_dofs + gj] += ke[i * 6 + j];
464                }
465            }
466        }
467
468        // --- Assemble load vector f ---
469        let mut f = vec![0.0f64; n_dofs];
470        for nf in &mesh.nodal_forces {
471            if nf.node < n_nodes {
472                f[nf.node * 2] += nf.force[0];
473                f[nf.node * 2 + 1] += nf.force[1];
474            }
475        }
476
477        // --- Apply Dirichlet BCs (large-number method) ---
478        let large = 1e30;
479        for bc in &mesh.dirichlet_bcs {
480            if bc.node < n_nodes {
481                let dof = bc.node * 2 + bc.dof;
482                k[dof * n_dofs + dof] += large;
483                f[dof] += large * bc.value;
484            }
485        }
486
487        // --- Solve K u = f with Conjugate Gradient ---
488        let (u, iters, res, converged) =
489            conjugate_gradient(&k, &f, n_dofs, self.max_iterations, self.tolerance);
490
491        // --- Compute von Mises stress per element ---
492        let mut von_mises = Vec::with_capacity(mesh.elements.len());
493        for elem in &mesh.elements {
494            let mat = mesh
495                .materials
496                .get(elem.material_id)
497                .unwrap_or(&mesh.materials[0]);
498            let sigma = self.element_stress(mesh, elem, mat, &u);
499            // Von Mises: sqrt(sx^2 - sx*sy + sy^2 + 3*txy^2)
500            let sx = sigma[0];
501            let sy = sigma[1];
502            let txy = sigma[2];
503            let vm = (sx * sx - sx * sy + sy * sy + 3.0 * txy * txy).sqrt();
504            von_mises.push(vm);
505        }
506
507        Some(PyFemSolveResult {
508            displacements: u,
509            von_mises_stress: von_mises,
510            solver_iterations: iters,
511            residual_norm: res,
512            converged,
513        })
514    }
515
516    // -----------------------------------------------------------------------
517    // Private: element stiffness matrix (6×6 flat)
518    // -----------------------------------------------------------------------
519
520    fn element_stiffness(
521        &self,
522        mesh: &PyFemMesh,
523        elem: &PyFemElement,
524        mat: &PyFemMaterial,
525    ) -> [f64; 36] {
526        let [n0, n1, n2] = elem.nodes;
527        let [x0, y0] = mesh.nodes[n0].position;
528        let [x1, y1] = mesh.nodes[n1].position;
529        let [x2, y2] = mesh.nodes[n2].position;
530
531        // Area
532        let area = 0.5 * ((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0));
533        if area.abs() < 1e-15 {
534            return [0.0; 36];
535        }
536
537        // Shape function gradients
538        let inv_2a = 1.0 / (2.0 * area);
539        let b1x = (y1 - y2) * inv_2a;
540        let b2x = (y2 - y0) * inv_2a;
541        let b3x = (y0 - y1) * inv_2a;
542        let b1y = (x2 - x1) * inv_2a;
543        let b2y = (x0 - x2) * inv_2a;
544        let b3y = (x1 - x0) * inv_2a;
545
546        // B matrix (3×6): [ε_xx, ε_yy, γ_xy] = B u
547        let b_mat: [f64; 18] = [
548            b1x, 0.0, b2x, 0.0, b3x, 0.0, 0.0, b1y, 0.0, b2y, 0.0, b3y, b1y, b1x, b2y, b2x, b3y,
549            b3x,
550        ];
551
552        // D matrix (3×3)
553        let d = mat.plane_stress_D();
554
555        // Ke = B^T D B * area * thickness
556        let t = elem.thickness;
557        let factor = area.abs() * t;
558
559        // Compute D*B (3×6)
560        let mut db = [0.0f64; 18];
561        for i in 0..3 {
562            for j in 0..6 {
563                let mut sum = 0.0;
564                for k in 0..3 {
565                    sum += d[i * 3 + k] * b_mat[k * 6 + j];
566                }
567                db[i * 6 + j] = sum;
568            }
569        }
570
571        // Ke = B^T * (D*B) * factor (6×6)
572        let mut ke = [0.0f64; 36];
573        for i in 0..6 {
574            for j in 0..6 {
575                let mut sum = 0.0;
576                for k in 0..3 {
577                    sum += b_mat[k * 6 + i] * db[k * 6 + j];
578                }
579                ke[i * 6 + j] = sum * factor;
580            }
581        }
582        ke
583    }
584
585    // -----------------------------------------------------------------------
586    // Private: element stress vector [σx, σy, τxy]
587    // -----------------------------------------------------------------------
588
589    fn element_stress(
590        &self,
591        mesh: &PyFemMesh,
592        elem: &PyFemElement,
593        mat: &PyFemMaterial,
594        u: &[f64],
595    ) -> [f64; 3] {
596        let [n0, n1, n2] = elem.nodes;
597        let [x0, y0] = mesh.nodes[n0].position;
598        let [x1, y1] = mesh.nodes[n1].position;
599        let [x2, y2] = mesh.nodes[n2].position;
600
601        let area = 0.5 * ((x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0));
602        if area.abs() < 1e-15 {
603            return [0.0; 3];
604        }
605
606        let inv_2a = 1.0 / (2.0 * area);
607        let b1x = (y1 - y2) * inv_2a;
608        let b2x = (y2 - y0) * inv_2a;
609        let b3x = (y0 - y1) * inv_2a;
610        let b1y = (x2 - x1) * inv_2a;
611        let b2y = (x0 - x2) * inv_2a;
612        let b3y = (x1 - x0) * inv_2a;
613
614        let b_mat: [f64; 18] = [
615            b1x, 0.0, b2x, 0.0, b3x, 0.0, 0.0, b1y, 0.0, b2y, 0.0, b3y, b1y, b1x, b2y, b2x, b3y,
616            b3x,
617        ];
618
619        // Local displacement vector (6 components)
620        let ue: [f64; 6] = [
621            u.get(n0 * 2).copied().unwrap_or(0.0),
622            u.get(n0 * 2 + 1).copied().unwrap_or(0.0),
623            u.get(n1 * 2).copied().unwrap_or(0.0),
624            u.get(n1 * 2 + 1).copied().unwrap_or(0.0),
625            u.get(n2 * 2).copied().unwrap_or(0.0),
626            u.get(n2 * 2 + 1).copied().unwrap_or(0.0),
627        ];
628
629        // Strain ε = B ue (3 components)
630        let mut strain = [0.0f64; 3];
631        for i in 0..3 {
632            for j in 0..6 {
633                strain[i] += b_mat[i * 6 + j] * ue[j];
634            }
635        }
636
637        // Stress σ = D ε
638        let d = mat.plane_stress_D();
639        let mut stress = [0.0f64; 3];
640        for i in 0..3 {
641            for j in 0..3 {
642                stress[i] += d[i * 3 + j] * strain[j];
643            }
644        }
645        stress
646    }
647}
648
649impl Default for PyFemSolver {
650    fn default() -> Self {
651        Self::new()
652    }
653}
654
655// ---------------------------------------------------------------------------
656// Private: Conjugate Gradient solver
657// ---------------------------------------------------------------------------
658
659/// Simple conjugate gradient solver for A x = b.
660/// Returns (x, iterations, final_residual_norm, converged).
661fn conjugate_gradient(
662    a: &[f64],
663    b: &[f64],
664    n: usize,
665    max_iter: usize,
666    tol: f64,
667) -> (Vec<f64>, usize, f64, bool) {
668    let mut x = vec![0.0f64; n];
669    // r = b - A x  (x=0, so r = b)
670    let mut r = b.to_vec();
671    let mut p = r.clone();
672    let mut rs_old: f64 = r.iter().map(|v| v * v).sum();
673
674    if rs_old.sqrt() < tol {
675        return (x, 0, rs_old.sqrt(), true);
676    }
677
678    let mut iters = 0;
679    for _ in 0..max_iter {
680        iters += 1;
681        // Ap = A * p
682        let ap = mat_vec_mul(a, &p, n);
683        let pap: f64 = p.iter().zip(ap.iter()).map(|(pi, api)| pi * api).sum();
684        if pap.abs() < 1e-30 {
685            break;
686        }
687        let alpha = rs_old / pap;
688        for i in 0..n {
689            x[i] += alpha * p[i];
690            r[i] -= alpha * ap[i];
691        }
692        let rs_new: f64 = r.iter().map(|v| v * v).sum();
693        let res_norm = rs_new.sqrt();
694        if res_norm < tol {
695            return (x, iters, res_norm, true);
696        }
697        let beta = rs_new / rs_old;
698        for i in 0..n {
699            p[i] = r[i] + beta * p[i];
700        }
701        rs_old = rs_new;
702    }
703    let res_norm = rs_old.sqrt();
704    (x, iters, res_norm, false)
705}
706
707/// Multiply dense matrix A (n×n) by vector v (n).
708fn mat_vec_mul(a: &[f64], v: &[f64], n: usize) -> Vec<f64> {
709    let mut result = vec![0.0f64; n];
710    for i in 0..n {
711        let row = &a[i * n..(i + 1) * n];
712        result[i] = row.iter().zip(v.iter()).map(|(a, b)| a * b).sum();
713    }
714    result
715}
716
717// ---------------------------------------------------------------------------
718// Tests
719// ---------------------------------------------------------------------------
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    use crate::PyFemMesh;
725    use crate::PyFemSolveResult;
726    use crate::PyFemSolver;
727
728    fn simple_mesh() -> PyFemMesh {
729        // Two-triangle unit square patch
730        let mut mesh = PyFemMesh::new();
731        mesh.add_node(0.0, 0.0); // 0
732        mesh.add_node(1.0, 0.0); // 1
733        mesh.add_node(1.0, 1.0); // 2
734        mesh.add_node(0.0, 1.0); // 3
735        mesh.add_element(0, 1, 2);
736        mesh.add_element(0, 2, 3);
737        mesh
738    }
739
740    #[test]
741    fn test_fem_mesh_creation() {
742        let mesh = simple_mesh();
743        assert_eq!(mesh.num_nodes(), 4);
744        assert_eq!(mesh.num_elements(), 2);
745        assert_eq!(mesh.num_dofs(), 8);
746    }
747
748    #[test]
749    fn test_fem_material_steel_properties() {
750        let mat = PyFemMaterial::steel();
751        assert!((mat.young_modulus - 200e9).abs() < 1.0);
752        assert!((mat.poisson_ratio - 0.3).abs() < 1e-10);
753    }
754
755    #[test]
756    fn test_fem_material_d_matrix_symmetry() {
757        let mat = PyFemMaterial::steel();
758        let d = mat.plane_stress_D();
759        // D[0][1] == D[1][0]
760        assert!((d[1] - d[3]).abs() < 1.0, "D should be symmetric");
761    }
762
763    #[test]
764    fn test_fem_element_area_positive() {
765        let mesh = simple_mesh();
766        let area0 = mesh.element_area(0);
767        let area1 = mesh.element_area(1);
768        assert!(area0 != 0.0, "area = {}", area0);
769        assert!(area1 != 0.0, "area = {}", area1);
770        // Sum of absolute areas should be 1.0 for the unit square
771        assert!((area0.abs() + area1.abs() - 1.0).abs() < 1e-10);
772    }
773
774    #[test]
775    fn test_fem_dirichlet_bc_pin() {
776        let bcs = PyFemDirichletBC::pin(0);
777        assert_eq!(bcs.len(), 2);
778        assert_eq!(bcs[0].dof, 0);
779        assert_eq!(bcs[1].dof, 1);
780    }
781
782    #[test]
783    fn test_fem_pin_node() {
784        let mut mesh = simple_mesh();
785        mesh.pin_node(0);
786        mesh.pin_node(1);
787        assert_eq!(mesh.dirichlet_bcs.len(), 4);
788    }
789
790    #[test]
791    fn test_fem_apply_nodal_force() {
792        let mut mesh = simple_mesh();
793        mesh.apply_nodal_force(2, 0.0, -1000.0);
794        assert_eq!(mesh.nodal_forces.len(), 1);
795        assert!((mesh.nodal_forces[0].force[1] + 1000.0).abs() < 1e-10);
796    }
797
798    #[test]
799    fn test_fem_solver_creation() {
800        let solver = PyFemSolver::new();
801        assert_eq!(solver.max_iterations, 1000);
802        assert!((solver.tolerance - 1e-10).abs() < 1e-20);
803    }
804
805    #[test]
806    fn test_fem_solver_returns_none_for_empty_mesh() {
807        let mesh = PyFemMesh::new();
808        let solver = PyFemSolver::new();
809        assert!(solver.solve(&mesh).is_none());
810    }
811
812    #[test]
813    fn test_fem_solve_displacement_length() {
814        let mut mesh = simple_mesh();
815        mesh.pin_node(0);
816        mesh.pin_node(3);
817        mesh.apply_nodal_force(1, 0.0, -1000.0);
818        mesh.apply_nodal_force(2, 0.0, -1000.0);
819        let solver = PyFemSolver::new();
820        let result = solver.solve(&mesh).expect("should solve");
821        assert_eq!(result.displacements.len(), 8); // 4 nodes * 2 DOFs
822    }
823
824    #[test]
825    fn test_fem_solve_von_mises_length() {
826        let mut mesh = simple_mesh();
827        mesh.pin_node(0);
828        mesh.pin_node(3);
829        mesh.apply_nodal_force(1, 1000.0, 0.0);
830        let solver = PyFemSolver::new();
831        let result = solver.solve(&mesh).expect("should solve");
832        assert_eq!(result.von_mises_stress.len(), 2); // 2 elements
833    }
834
835    #[test]
836    fn test_fem_solve_converges() {
837        let mut mesh = simple_mesh();
838        mesh.pin_node(0);
839        mesh.pin_node(3);
840        mesh.apply_nodal_force(1, 0.0, -500.0);
841        let solver = PyFemSolver::new();
842        let result = solver.solve(&mesh).expect("should solve");
843        assert!(
844            result.converged,
845            "CG should converge for a well-posed problem"
846        );
847    }
848
849    #[test]
850    fn test_fem_result_node_displacement() {
851        let result = PyFemSolveResult {
852            displacements: vec![0.0, 0.0, 1e-4, -2e-5, 0.5e-4, -1e-5, 0.0, 0.0],
853            von_mises_stress: vec![1.0e6, 0.8e6],
854            solver_iterations: 10,
855            residual_norm: 1e-12,
856            converged: true,
857        };
858        let d0 = result.node_displacement(0).unwrap();
859        assert!((d0[0]).abs() < 1e-15);
860        let d1 = result.node_displacement(1).unwrap();
861        assert!((d1[0] - 1e-4).abs() < 1e-15);
862    }
863
864    #[test]
865    fn test_fem_result_max_von_mises() {
866        let result = PyFemSolveResult {
867            displacements: vec![0.0; 8],
868            von_mises_stress: vec![1.0e6, 2.0e6, 0.5e6],
869            solver_iterations: 5,
870            residual_norm: 1e-12,
871            converged: true,
872        };
873        assert!((result.max_von_mises() - 2.0e6).abs() < 1.0);
874    }
875
876    #[test]
877    fn test_fem_result_mean_von_mises() {
878        let result = PyFemSolveResult {
879            displacements: vec![0.0; 8],
880            von_mises_stress: vec![1.0, 2.0, 3.0],
881            solver_iterations: 5,
882            residual_norm: 1e-12,
883            converged: true,
884        };
885        assert!((result.mean_von_mises() - 2.0).abs() < 1e-10);
886    }
887
888    #[test]
889    fn test_fem_build_rectangle() {
890        let mut mesh = PyFemMesh::new();
891        let n_nodes = mesh.build_rectangle(0.0, 0.0, 1.0, 1.0, 4, 4, 0, 1.0);
892        assert_eq!(n_nodes, 25); // 5×5 = 25 nodes
893        assert_eq!(mesh.num_elements(), 32); // 4×4×2 = 32 triangles
894    }
895
896    #[test]
897    fn test_fem_material_aluminum() {
898        let mat = PyFemMaterial::aluminum();
899        assert!((mat.young_modulus - 70e9).abs() < 1.0);
900    }
901
902    #[test]
903    fn test_fem_material_rubber_high_poisson() {
904        let mat = PyFemMaterial::rubber();
905        assert!(mat.poisson_ratio > 0.4);
906    }
907
908    #[test]
909    fn test_fem_material_concrete() {
910        let mat = PyFemMaterial::concrete();
911        assert!((mat.density - 2400.0).abs() < 1.0);
912    }
913
914    #[test]
915    fn test_fem_nodal_force_new() {
916        let nf = PyFemNodalForce::new(3, 100.0, -200.0);
917        assert_eq!(nf.node, 3);
918        assert!((nf.force[0] - 100.0).abs() < 1e-10);
919        assert!((nf.force[1] + 200.0).abs() < 1e-10);
920    }
921
922    #[test]
923    fn test_fem_dirichlet_fix_x() {
924        let bc = PyFemDirichletBC::fix_x(5);
925        assert_eq!(bc.node, 5);
926        assert_eq!(bc.dof, 0);
927        assert!((bc.value).abs() < 1e-15);
928    }
929
930    #[test]
931    fn test_fem_dirichlet_fix_y() {
932        let bc = PyFemDirichletBC::fix_y(2);
933        assert_eq!(bc.dof, 1);
934    }
935
936    #[test]
937    fn test_fem_solve_pinned_both_sides_zero_displacement_at_pin() {
938        let mut mesh = simple_mesh();
939        // Pin all nodes — no load — displacement should be zero
940        for n in 0..4 {
941            mesh.pin_node(n);
942        }
943        let solver = PyFemSolver::new();
944        let result = solver.solve(&mesh).expect("should solve");
945        for &d in &result.displacements {
946            assert!(d.abs() < 1e-10, "constrained mesh with no load: d={}", d);
947        }
948    }
949
950    #[test]
951    fn test_fem_add_material_returns_index() {
952        let mut mesh = PyFemMesh::new();
953        let idx = mesh.add_material(PyFemMaterial::rubber());
954        // Default material is at index 0, so new one is at index 1
955        assert_eq!(idx, 1);
956    }
957
958    #[test]
959    fn test_fem_element_stiffness_symmetric() {
960        // The element stiffness matrix should be symmetric
961        let mut mesh = PyFemMesh::new();
962        mesh.add_node(0.0, 0.0);
963        mesh.add_node(1.0, 0.0);
964        mesh.add_node(0.5, 1.0);
965        mesh.add_element(0, 1, 2);
966
967        let solver = PyFemSolver::new();
968        let elem = &mesh.elements[0];
969        let mat = &mesh.materials[0];
970        let ke = solver.element_stiffness(&mesh, elem, mat);
971
972        for i in 0..6 {
973            for j in 0..6 {
974                let diff = (ke[i * 6 + j] - ke[j * 6 + i]).abs();
975                assert!(
976                    diff < 1e-6,
977                    "Ke not symmetric at ({},{}): diff={}",
978                    i,
979                    j,
980                    diff
981                );
982            }
983        }
984    }
985
986    #[test]
987    fn test_fem_solver_with_settings() {
988        let solver = PyFemSolver::with_settings(500, 1e-8);
989        assert_eq!(solver.max_iterations, 500);
990        assert!((solver.tolerance - 1e-8).abs() < 1e-20);
991    }
992
993    #[test]
994    fn test_fem_result_node_displacement_out_of_bounds() {
995        let result = PyFemSolveResult {
996            displacements: vec![0.0; 4],
997            von_mises_stress: vec![],
998            solver_iterations: 0,
999            residual_norm: 0.0,
1000            converged: true,
1001        };
1002        assert!(result.node_displacement(10).is_none());
1003    }
1004
1005    #[test]
1006    fn test_fem_mesh_serde_roundtrip() {
1007        let mut mesh = simple_mesh();
1008        mesh.pin_node(0);
1009        mesh.apply_nodal_force(2, 100.0, -200.0);
1010        let json = serde_json::to_string(&mesh).expect("serialize");
1011        let back: PyFemMesh = serde_json::from_str(&json).expect("deserialize");
1012        assert_eq!(back.num_nodes(), 4);
1013        assert_eq!(back.dirichlet_bcs.len(), 2);
1014        assert_eq!(back.nodal_forces.len(), 1);
1015    }
1016}
1017
1018// ===========================================================================
1019// Modal analysis (free-vibration eigenvalue problem)
1020// ===========================================================================
1021
1022/// Result of a modal analysis.
1023#[derive(Debug, Clone)]
1024#[allow(dead_code)]
1025pub struct ModalAnalysisResult {
1026    /// Natural frequencies in rad/s (ascending order).
1027    pub natural_frequencies: Vec<f64>,
1028    /// Mode shapes: each row is a normalised displacement eigenvector (flat).
1029    pub mode_shapes: Vec<Vec<f64>>,
1030    /// Number of modes extracted.
1031    pub num_modes: usize,
1032}
1033
1034impl ModalAnalysisResult {
1035    /// Return the `i`-th natural frequency in Hz.
1036    pub fn frequency_hz(&self, i: usize) -> Option<f64> {
1037        self.natural_frequencies
1038            .get(i)
1039            .map(|&f| f / (2.0 * std::f64::consts::PI))
1040    }
1041
1042    /// Return the period (in seconds) of the `i`-th mode.
1043    pub fn period_s(&self, i: usize) -> Option<f64> {
1044        self.frequency_hz(i).filter(|&f| f > 1e-15).map(|f| 1.0 / f)
1045    }
1046}
1047
1048/// Simple power-iteration modal analyzer (extracts the dominant modes of K).
1049///
1050/// This is a proof-of-concept; it uses a deflated power iteration to
1051/// approximate the lowest `num_modes` natural frequencies for undamped free
1052/// vibration (K φ = ω² M φ, with M = I).
1053#[derive(Debug, Clone)]
1054#[allow(dead_code)]
1055pub struct ModalAnalyzer {
1056    /// Number of modes to extract.
1057    pub num_modes: usize,
1058    /// Maximum power-iteration steps per mode.
1059    pub max_iter: usize,
1060    /// Convergence tolerance.
1061    pub tolerance: f64,
1062}
1063
1064impl ModalAnalyzer {
1065    /// Create an analyzer that extracts up to `num_modes` modes.
1066    pub fn new(num_modes: usize) -> Self {
1067        Self {
1068            num_modes,
1069            max_iter: 500,
1070            tolerance: 1e-8,
1071        }
1072    }
1073
1074    /// Run modal analysis on the assembled mesh.
1075    ///
1076    /// Returns `None` if the mesh has no elements.
1077    pub fn analyze(&self, mesh: &PyFemMesh) -> Option<ModalAnalysisResult> {
1078        if mesh.num_nodes() == 0 || mesh.elements.is_empty() {
1079            return None;
1080        }
1081        let solver = PyFemSolver::new();
1082        let n_dofs = mesh.num_dofs();
1083
1084        // Assemble stiffness matrix (reuse solver's internal logic via a zero-load solve)
1085        let mut k = vec![0.0f64; n_dofs * n_dofs];
1086        for elem in &mesh.elements {
1087            let mat = mesh
1088                .materials
1089                .get(elem.material_id)
1090                .unwrap_or(&mesh.materials[0]);
1091            let ke = solver.element_stiffness(mesh, elem, mat);
1092            let dofs: [usize; 6] = [
1093                elem.nodes[0] * 2,
1094                elem.nodes[0] * 2 + 1,
1095                elem.nodes[1] * 2,
1096                elem.nodes[1] * 2 + 1,
1097                elem.nodes[2] * 2,
1098                elem.nodes[2] * 2 + 1,
1099            ];
1100            for (i, &gi) in dofs.iter().enumerate() {
1101                for (j, &gj) in dofs.iter().enumerate() {
1102                    k[gi * n_dofs + gj] += ke[i * 6 + j];
1103                }
1104            }
1105        }
1106
1107        // Apply BCs: zero out rows/cols of constrained DOFs
1108        for bc in &mesh.dirichlet_bcs {
1109            if bc.node < mesh.num_nodes() {
1110                let dof = bc.node * 2 + bc.dof;
1111                for j in 0..n_dofs {
1112                    k[dof * n_dofs + j] = 0.0;
1113                    k[j * n_dofs + dof] = 0.0;
1114                }
1115                k[dof * n_dofs + dof] = 1.0;
1116            }
1117        }
1118
1119        let modes = self.num_modes.min(n_dofs);
1120        let mut natural_frequencies = Vec::with_capacity(modes);
1121        let mut mode_shapes = Vec::with_capacity(modes);
1122
1123        // Deflated inverse-free power iteration: approximate λ = ω²
1124        let mut deflated_k = k.clone();
1125
1126        for _ in 0..modes {
1127            let (lambda, phi) = power_iteration(&deflated_k, n_dofs, self.max_iter, self.tolerance);
1128            if lambda.abs() < 1e-15 {
1129                break;
1130            }
1131            let omega = lambda.abs().sqrt();
1132            natural_frequencies.push(omega);
1133            mode_shapes.push(phi.clone());
1134
1135            // Deflate: K ← K - λ φ φᵀ
1136            for i in 0..n_dofs {
1137                for j in 0..n_dofs {
1138                    deflated_k[i * n_dofs + j] -= lambda * phi[i] * phi[j];
1139                }
1140            }
1141        }
1142
1143        Some(ModalAnalysisResult {
1144            num_modes: natural_frequencies.len(),
1145            natural_frequencies,
1146            mode_shapes,
1147        })
1148    }
1149}
1150
1151/// Power iteration to find the dominant eigenvalue and eigenvector of a symmetric matrix.
1152fn power_iteration(a: &[f64], n: usize, max_iter: usize, tol: f64) -> (f64, Vec<f64>) {
1153    let mut v: Vec<f64> = (0..n).map(|i| if i == 0 { 1.0 } else { 0.0 }).collect();
1154    let mut lambda = 0.0f64;
1155    for _ in 0..max_iter {
1156        let av = mat_vec_mul(a, &v, n);
1157        let new_lambda: f64 = av.iter().zip(v.iter()).map(|(a, b)| a * b).sum();
1158        let norm: f64 = (av.iter().map(|x| x * x).sum::<f64>()).sqrt();
1159        if norm < 1e-30 {
1160            break;
1161        }
1162        v = av.iter().map(|x| x / norm).collect();
1163        if (new_lambda - lambda).abs() < tol {
1164            lambda = new_lambda;
1165            break;
1166        }
1167        lambda = new_lambda;
1168    }
1169    (lambda, v)
1170}
1171
1172// ===========================================================================
1173// Result extraction helpers
1174// ===========================================================================
1175
1176impl PyFemSolveResult {
1177    /// Return the displacement field as a list of `[ux, uy]` pairs.
1178    pub fn displacement_pairs(&self) -> Vec<[f64; 2]> {
1179        self.displacements
1180            .chunks_exact(2)
1181            .map(|c| [c[0], c[1]])
1182            .collect()
1183    }
1184
1185    /// Maximum absolute displacement component.
1186    pub fn max_displacement_magnitude(&self) -> f64 {
1187        self.displacement_pairs()
1188            .iter()
1189            .map(|[ux, uy]| (ux * ux + uy * uy).sqrt())
1190            .fold(0.0f64, f64::max)
1191    }
1192
1193    /// Return all von Mises stresses as a sorted (ascending) copy.
1194    pub fn sorted_von_mises(&self) -> Vec<f64> {
1195        let mut v = self.von_mises_stress.clone();
1196        v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1197        v
1198    }
1199
1200    /// Return the `q`-th quantile of the von Mises stress distribution (q in \[0,1\]).
1201    pub fn von_mises_quantile(&self, q: f64) -> f64 {
1202        if self.von_mises_stress.is_empty() {
1203            return 0.0;
1204        }
1205        let sorted = self.sorted_von_mises();
1206        let idx = ((q.clamp(0.0, 1.0)) * (sorted.len() - 1) as f64).round() as usize;
1207        sorted[idx]
1208    }
1209}
1210
1211// ===========================================================================
1212// Mesh query helpers
1213// ===========================================================================
1214
1215impl PyFemMesh {
1216    /// Return the bounding box of all nodes as `[xmin, ymin, xmax, ymax]`.
1217    pub fn bounding_box(&self) -> Option<[f64; 4]> {
1218        if self.nodes.is_empty() {
1219            return None;
1220        }
1221        let mut xmin = f64::INFINITY;
1222        let mut ymin = f64::INFINITY;
1223        let mut xmax = f64::NEG_INFINITY;
1224        let mut ymax = f64::NEG_INFINITY;
1225        for n in &self.nodes {
1226            xmin = xmin.min(n.position[0]);
1227            ymin = ymin.min(n.position[1]);
1228            xmax = xmax.max(n.position[0]);
1229            ymax = ymax.max(n.position[1]);
1230        }
1231        Some([xmin, ymin, xmax, ymax])
1232    }
1233
1234    /// Return the total area of all elements (sum of |area_i|).
1235    pub fn total_area(&self) -> f64 {
1236        (0..self.num_elements())
1237            .map(|e| self.element_area(e).abs())
1238            .sum()
1239    }
1240
1241    /// Return the centroid of element `e` as `[cx, cy]`.
1242    pub fn element_centroid(&self, e: usize) -> Option<[f64; 2]> {
1243        if e >= self.elements.len() {
1244            return None;
1245        }
1246        let [n0, n1, n2] = self.elements[e].nodes;
1247        let [x0, y0] = self.nodes[n0].position;
1248        let [x1, y1] = self.nodes[n1].position;
1249        let [x2, y2] = self.nodes[n2].position;
1250        Some([(x0 + x1 + x2) / 3.0, (y0 + y1 + y2) / 3.0])
1251    }
1252
1253    /// Find the node closest to position `(px, py)`.
1254    pub fn closest_node(&self, px: f64, py: f64) -> Option<usize> {
1255        if self.nodes.is_empty() {
1256            return None;
1257        }
1258        let (idx, _) = self
1259            .nodes
1260            .iter()
1261            .enumerate()
1262            .map(|(i, n)| {
1263                let dx = n.position[0] - px;
1264                let dy = n.position[1] - py;
1265                (i, dx * dx + dy * dy)
1266            })
1267            .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
1268        Some(idx)
1269    }
1270
1271    /// Return nodes on the left boundary (x <= xmin + tol).
1272    pub fn left_boundary_nodes(&self, tol: f64) -> Vec<usize> {
1273        if let Some(bb) = self.bounding_box() {
1274            self.nodes
1275                .iter()
1276                .enumerate()
1277                .filter(|(_, n)| n.position[0] <= bb[0] + tol)
1278                .map(|(i, _)| i)
1279                .collect()
1280        } else {
1281            Vec::new()
1282        }
1283    }
1284
1285    /// Return nodes on the right boundary (x >= xmax - tol).
1286    pub fn right_boundary_nodes(&self, tol: f64) -> Vec<usize> {
1287        if let Some(bb) = self.bounding_box() {
1288            self.nodes
1289                .iter()
1290                .enumerate()
1291                .filter(|(_, n)| n.position[0] >= bb[2] - tol)
1292                .map(|(i, _)| i)
1293                .collect()
1294        } else {
1295            Vec::new()
1296        }
1297    }
1298
1299    /// Pin all nodes on the left boundary.
1300    pub fn pin_left_boundary(&mut self, tol: f64) {
1301        let nodes = self.left_boundary_nodes(tol);
1302        for n in nodes {
1303            self.pin_node(n);
1304        }
1305    }
1306}
1307
1308// ===========================================================================
1309// Additional FEM tests
1310// ===========================================================================
1311
1312#[cfg(test)]
1313mod fem_ext_tests {
1314
1315    use crate::PyFemMesh;
1316    use crate::PyFemSolveResult;
1317    use crate::PyFemSolver;
1318    use crate::fem_api::ModalAnalysisResult;
1319    use crate::fem_api::ModalAnalyzer;
1320    use crate::fem_api::power_iteration;
1321
1322    fn simple_mesh() -> PyFemMesh {
1323        let mut mesh = PyFemMesh::new();
1324        mesh.add_node(0.0, 0.0);
1325        mesh.add_node(1.0, 0.0);
1326        mesh.add_node(1.0, 1.0);
1327        mesh.add_node(0.0, 1.0);
1328        mesh.add_element(0, 1, 2);
1329        mesh.add_element(0, 2, 3);
1330        mesh
1331    }
1332
1333    // --- ModalAnalyzer ---
1334
1335    #[test]
1336    fn test_modal_analysis_returns_some() {
1337        let mut mesh = simple_mesh();
1338        mesh.pin_node(0);
1339        mesh.pin_node(3);
1340        let analyzer = ModalAnalyzer::new(2);
1341        let result = analyzer.analyze(&mesh);
1342        assert!(result.is_some());
1343    }
1344
1345    #[test]
1346    fn test_modal_analysis_returns_none_empty_mesh() {
1347        let mesh = PyFemMesh::new();
1348        let analyzer = ModalAnalyzer::new(2);
1349        assert!(analyzer.analyze(&mesh).is_none());
1350    }
1351
1352    #[test]
1353    fn test_modal_analysis_num_modes() {
1354        let mut mesh = simple_mesh();
1355        mesh.pin_node(0);
1356        mesh.pin_node(3);
1357        let analyzer = ModalAnalyzer::new(2);
1358        if let Some(result) = analyzer.analyze(&mesh) {
1359            assert!(result.num_modes <= 2);
1360        }
1361    }
1362
1363    #[test]
1364    fn test_modal_analysis_frequencies_positive() {
1365        let mut mesh = simple_mesh();
1366        mesh.pin_node(0);
1367        mesh.pin_node(3);
1368        let analyzer = ModalAnalyzer::new(3);
1369        if let Some(result) = analyzer.analyze(&mesh) {
1370            for &f in &result.natural_frequencies {
1371                assert!(f >= 0.0, "frequency should be non-negative: {}", f);
1372            }
1373        }
1374    }
1375
1376    #[test]
1377    fn test_modal_analysis_frequency_hz() {
1378        let mut mesh = simple_mesh();
1379        mesh.pin_node(0);
1380        mesh.pin_node(3);
1381        let analyzer = ModalAnalyzer::new(1);
1382        if let Some(result) = analyzer.analyze(&mesh)
1383            && result.num_modes > 0
1384        {
1385            let hz = result.frequency_hz(0);
1386            assert!(hz.is_some());
1387            assert!(hz.unwrap() >= 0.0);
1388        }
1389    }
1390
1391    #[test]
1392    fn test_modal_period_s() {
1393        let result = ModalAnalysisResult {
1394            natural_frequencies: vec![2.0 * std::f64::consts::PI * 100.0], // 100 Hz
1395            mode_shapes: vec![vec![1.0, 0.0, 0.0, 0.0]],
1396            num_modes: 1,
1397        };
1398        let period = result.period_s(0).unwrap();
1399        assert!((period - 0.01).abs() < 1e-6, "period = {}", period);
1400    }
1401
1402    // --- Result extraction helpers ---
1403
1404    #[test]
1405    fn test_displacement_pairs_length() {
1406        let result = PyFemSolveResult {
1407            displacements: vec![1.0, 2.0, 3.0, 4.0],
1408            von_mises_stress: vec![],
1409            solver_iterations: 0,
1410            residual_norm: 0.0,
1411            converged: true,
1412        };
1413        let pairs = result.displacement_pairs();
1414        assert_eq!(pairs.len(), 2);
1415        assert!((pairs[0][0] - 1.0).abs() < 1e-15);
1416        assert!((pairs[1][1] - 4.0).abs() < 1e-15);
1417    }
1418
1419    #[test]
1420    fn test_max_displacement_magnitude() {
1421        let result = PyFemSolveResult {
1422            displacements: vec![3.0, 4.0, 0.0, 0.0],
1423            von_mises_stress: vec![],
1424            solver_iterations: 0,
1425            residual_norm: 0.0,
1426            converged: true,
1427        };
1428        assert!((result.max_displacement_magnitude() - 5.0).abs() < 1e-10);
1429    }
1430
1431    #[test]
1432    fn test_sorted_von_mises() {
1433        let result = PyFemSolveResult {
1434            displacements: vec![],
1435            von_mises_stress: vec![3.0, 1.0, 2.0],
1436            solver_iterations: 0,
1437            residual_norm: 0.0,
1438            converged: true,
1439        };
1440        let sorted = result.sorted_von_mises();
1441        assert!((sorted[0] - 1.0).abs() < 1e-15);
1442        assert!((sorted[2] - 3.0).abs() < 1e-15);
1443    }
1444
1445    #[test]
1446    fn test_von_mises_quantile_min() {
1447        let result = PyFemSolveResult {
1448            displacements: vec![],
1449            von_mises_stress: vec![1.0, 2.0, 3.0, 4.0, 5.0],
1450            solver_iterations: 0,
1451            residual_norm: 0.0,
1452            converged: true,
1453        };
1454        assert!((result.von_mises_quantile(0.0) - 1.0).abs() < 1e-10);
1455        assert!((result.von_mises_quantile(1.0) - 5.0).abs() < 1e-10);
1456    }
1457
1458    // --- Mesh query helpers ---
1459
1460    #[test]
1461    fn test_bounding_box_unit_square() {
1462        let mesh = simple_mesh();
1463        let bb = mesh.bounding_box().unwrap();
1464        assert!((bb[0]).abs() < 1e-15); // xmin = 0
1465        assert!((bb[2] - 1.0).abs() < 1e-15); // xmax = 1
1466    }
1467
1468    #[test]
1469    fn test_bounding_box_empty() {
1470        let mesh = PyFemMesh::new();
1471        assert!(mesh.bounding_box().is_none());
1472    }
1473
1474    #[test]
1475    fn test_total_area_unit_square() {
1476        let mesh = simple_mesh();
1477        assert!((mesh.total_area() - 1.0).abs() < 1e-10);
1478    }
1479
1480    #[test]
1481    fn test_element_centroid_valid() {
1482        let mesh = simple_mesh();
1483        let c = mesh.element_centroid(0).unwrap();
1484        // Triangle (0,0),(1,0),(1,1): centroid = (2/3, 1/3)
1485        assert!((c[0] - 2.0 / 3.0).abs() < 1e-10, "cx = {}", c[0]);
1486    }
1487
1488    #[test]
1489    fn test_element_centroid_out_of_bounds() {
1490        let mesh = simple_mesh();
1491        assert!(mesh.element_centroid(99).is_none());
1492    }
1493
1494    #[test]
1495    fn test_closest_node_corner() {
1496        let mesh = simple_mesh();
1497        let idx = mesh.closest_node(0.01, 0.01).unwrap();
1498        assert_eq!(idx, 0); // closest to (0,0)
1499    }
1500
1501    #[test]
1502    fn test_closest_node_empty() {
1503        let mesh = PyFemMesh::new();
1504        assert!(mesh.closest_node(0.0, 0.0).is_none());
1505    }
1506
1507    #[test]
1508    fn test_left_boundary_nodes() {
1509        let mesh = simple_mesh();
1510        let left = mesh.left_boundary_nodes(1e-6);
1511        assert!(left.contains(&0)); // node at (0,0)
1512        assert!(left.contains(&3)); // node at (0,1)
1513    }
1514
1515    #[test]
1516    fn test_right_boundary_nodes() {
1517        let mesh = simple_mesh();
1518        let right = mesh.right_boundary_nodes(1e-6);
1519        assert!(right.contains(&1)); // node at (1,0)
1520        assert!(right.contains(&2)); // node at (1,1)
1521    }
1522
1523    #[test]
1524    fn test_pin_left_boundary() {
1525        let mut mesh = simple_mesh();
1526        mesh.pin_left_boundary(1e-6);
1527        // 2 left nodes × 2 DOFs each = 4 BCs
1528        assert_eq!(mesh.dirichlet_bcs.len(), 4);
1529    }
1530
1531    #[test]
1532    fn test_fem_solve_with_large_mesh() {
1533        let mut mesh = PyFemMesh::new();
1534        mesh.build_rectangle(0.0, 0.0, 1.0, 0.5, 4, 2, 0, 0.01);
1535        mesh.pin_left_boundary(1e-6);
1536        // Apply load on right boundary
1537        let right = mesh.right_boundary_nodes(1e-6);
1538        for n in right {
1539            mesh.apply_nodal_force(n, 0.0, -500.0);
1540        }
1541        let solver = PyFemSolver::new();
1542        let result = solver.solve(&mesh).expect("should solve");
1543        assert!(!result.displacements.is_empty());
1544    }
1545
1546    #[test]
1547    fn test_power_iteration_identity_matrix() {
1548        // Identity matrix: eigenvalue = 1, eigenvector = [1, 0, 0]
1549        let a = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
1550        let (lambda, _phi) = power_iteration(&a, 3, 100, 1e-10);
1551        assert!((lambda - 1.0).abs() < 1e-6, "lambda = {}", lambda);
1552    }
1553
1554    #[test]
1555    fn test_fem_result_displacement_pairs_empty() {
1556        let result = PyFemSolveResult {
1557            displacements: vec![],
1558            von_mises_stress: vec![],
1559            solver_iterations: 0,
1560            residual_norm: 0.0,
1561            converged: true,
1562        };
1563        assert!(result.displacement_pairs().is_empty());
1564    }
1565
1566    #[test]
1567    fn test_fem_max_displacement_empty() {
1568        let result = PyFemSolveResult {
1569            displacements: vec![],
1570            von_mises_stress: vec![],
1571            solver_iterations: 0,
1572            residual_norm: 0.0,
1573            converged: true,
1574        };
1575        assert!((result.max_displacement_magnitude()).abs() < 1e-15);
1576    }
1577}