1#![allow(clippy::too_many_arguments)]
2#![allow(missing_docs)]
16#![allow(dead_code)]
17
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PyFemMaterial {
27 pub young_modulus: f64,
29 pub poisson_ratio: f64,
31 pub density: f64,
33}
34
35impl PyFemMaterial {
36 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 pub fn steel() -> Self {
47 Self::new(200e9, 0.3, 7850.0)
48 }
49
50 pub fn aluminum() -> Self {
52 Self::new(70e9, 0.33, 2700.0)
53 }
54
55 pub fn rubber() -> Self {
57 Self::new(10e6, 0.49, 1100.0)
58 }
59
60 pub fn concrete() -> Self {
62 Self::new(30e9, 0.2, 2400.0)
63 }
64
65 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PyFemNode {
101 pub position: [f64; 2],
103}
104
105impl PyFemNode {
106 pub fn new(x: f64, y: f64) -> Self {
108 Self { position: [x, y] }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct PyFemElement {
119 pub nodes: [usize; 3],
121 pub material_id: usize,
123 pub thickness: f64,
125}
126
127impl PyFemElement {
128 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#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct PyFemDirichletBC {
145 pub node: usize,
147 pub dof: usize,
149 pub value: f64,
151}
152
153impl PyFemDirichletBC {
154 pub fn fix_x(node: usize) -> Self {
156 Self {
157 node,
158 dof: 0,
159 value: 0.0,
160 }
161 }
162
163 pub fn fix_y(node: usize) -> Self {
165 Self {
166 node,
167 dof: 1,
168 value: 0.0,
169 }
170 }
171
172 pub fn pin(node: usize) -> Vec<Self> {
174 vec![Self::fix_x(node), Self::fix_y(node)]
175 }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct PyFemNodalForce {
181 pub node: usize,
183 pub force: [f64; 2],
185}
186
187impl PyFemNodalForce {
188 pub fn new(node: usize, fx: f64, fy: f64) -> Self {
190 Self {
191 node,
192 force: [fx, fy],
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct PyFemMesh {
204 pub nodes: Vec<PyFemNode>,
206 pub elements: Vec<PyFemElement>,
208 pub materials: Vec<PyFemMaterial>,
210 pub dirichlet_bcs: Vec<PyFemDirichletBC>,
212 pub nodal_forces: Vec<PyFemNodalForce>,
214}
215
216impl PyFemMesh {
217 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 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 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 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 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 pub fn add_dirichlet_bc(&mut self, bc: PyFemDirichletBC) {
264 self.dirichlet_bcs.push(bc);
265 }
266
267 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 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 pub fn num_dofs(&self) -> usize {
280 self.nodes.len() * 2
281 }
282
283 pub fn num_nodes(&self) -> usize {
285 self.nodes.len()
286 }
287
288 pub fn num_elements(&self) -> usize {
290 self.elements.len()
291 }
292
293 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct PyFemSolveResult {
360 pub displacements: Vec<f64>,
362 pub von_mises_stress: Vec<f64>,
364 pub solver_iterations: usize,
366 pub residual_norm: f64,
368 pub converged: bool,
370}
371
372impl PyFemSolveResult {
373 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 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 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#[derive(Debug, Clone)]
410pub struct PyFemSolver {
411 pub max_iterations: usize,
413 pub tolerance: f64,
415}
416
417impl PyFemSolver {
418 pub fn new() -> Self {
420 Self {
421 max_iterations: 1000,
422 tolerance: 1e-10,
423 }
424 }
425
426 pub fn with_settings(max_iterations: usize, tolerance: f64) -> Self {
428 Self {
429 max_iterations,
430 tolerance,
431 }
432 }
433
434 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 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 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 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 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 let (u, iters, res, converged) =
489 conjugate_gradient(&k, &f, n_dofs, self.max_iterations, self.tolerance);
490
491 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 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 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 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 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 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 let d = mat.plane_stress_D();
554
555 let t = elem.thickness;
557 let factor = area.abs() * t;
558
559 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 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 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 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 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 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
655fn 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 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 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
707fn 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#[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 let mut mesh = PyFemMesh::new();
731 mesh.add_node(0.0, 0.0); mesh.add_node(1.0, 0.0); mesh.add_node(1.0, 1.0); mesh.add_node(0.0, 1.0); 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 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 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); }
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); }
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); assert_eq!(mesh.num_elements(), 32); }
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 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 assert_eq!(idx, 1);
956 }
957
958 #[test]
959 fn test_fem_element_stiffness_symmetric() {
960 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#[derive(Debug, Clone)]
1024#[allow(dead_code)]
1025pub struct ModalAnalysisResult {
1026 pub natural_frequencies: Vec<f64>,
1028 pub mode_shapes: Vec<Vec<f64>>,
1030 pub num_modes: usize,
1032}
1033
1034impl ModalAnalysisResult {
1035 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 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#[derive(Debug, Clone)]
1054#[allow(dead_code)]
1055pub struct ModalAnalyzer {
1056 pub num_modes: usize,
1058 pub max_iter: usize,
1060 pub tolerance: f64,
1062}
1063
1064impl ModalAnalyzer {
1065 pub fn new(num_modes: usize) -> Self {
1067 Self {
1068 num_modes,
1069 max_iter: 500,
1070 tolerance: 1e-8,
1071 }
1072 }
1073
1074 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 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 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 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 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
1151fn 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
1172impl PyFemSolveResult {
1177 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 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 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 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
1211impl PyFemMesh {
1216 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 pub fn total_area(&self) -> f64 {
1236 (0..self.num_elements())
1237 .map(|e| self.element_area(e).abs())
1238 .sum()
1239 }
1240
1241 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 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 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 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 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#[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 #[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], 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 #[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 #[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); assert!((bb[2] - 1.0).abs() < 1e-15); }
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 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); }
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)); assert!(left.contains(&3)); }
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)); assert!(right.contains(&2)); }
1522
1523 #[test]
1524 fn test_pin_left_boundary() {
1525 let mut mesh = simple_mesh();
1526 mesh.pin_left_boundary(1e-6);
1527 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 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 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}