//! R8-b: the Turek–Hron flag as a 3-D Hex20 total-Lagrangian SVK solid //! ([`rtx_fea::analysis::flag3d`]). //! //! Suite (fast, run by default): //! //! 1. `flag3d_mesh_mass_and_surface_forces` — node/element counts, the //! consistent mass sums to `ρ V`, and the face integrator's totals //! (uniform traction on the top face = `t · L · span`; a uniform //! pressure on bottom + top cancels; a rigid translation of the //! configuration changes nothing). //! 2. `plane_strain_3d_reproduces_the_2d_csm1` — CSM1 (static, gravity) //! with `u_z = 0` everywhere reproduces the 2-D 35×2 Quad8 plane-strain //! model to rounding (same Newton, same banded LU). //! 3. `plane_strain_3d_reproduces_the_2d_csm3_start` — the first 60 //! Newmark steps of CSM3 agree with the 2-D stepper to rounding. //! //! Instruments (`#[ignore]`, env-driven, write under `FLAG3D_OUT`): //! //! * `flag3d_csm1_table` — CSM1 tip displacement per configuration. //! * `flag3d_csm3_march` — the full CSM3 oscillation, CSV of point A and //! of the tip's lateral corners. //! * `flag3d_modes_dump` — the linearised operators (TL tangent at u = 0, //! consistent mass) on the free DOFs, for an outside eigen-solve. use std::io::Write as _; use nalgebra::{DVector, Vector3}; use rtx_fea::analysis::flag3d::{Flag3d, Flag3dSpec, FlagSide, LateralFaces}; use rtx_fea::analysis::{ ConvergenceCriteria, DynamicState, NonlinearDynamicAnalysis, NonlinearDynamicStepper, }; use rtx_fea::assembly::dof_mapping::DofComponent; use rtx_fea::boundary::dirichlet::{DirichletBC, DirichletType}; use rtx_fea::boundary::{BoundaryCondition, BoundaryConditionSet, SpatialFunction}; use rtx_fea::elements::total_lagrangian::{internal_force_and_tangent, saint_venant_kirchhoff}; use rtx_fea::elements::{ElementMatrixComputer, StandardFiniteElement}; use rtx_fea::materials::{LinearElastic, Material as _}; use rtx_fea::mesh::{Element, ElementType, MaterialId, Mesh, Node, NodeId}; const E_MOD: f64 = 1.4e6; const NU: f64 = 0.4; /// CSM1/CSM3 density and gravity (FSI2's structure is ρ_s = 1e4). const RHO_CSM: f64 = 1000.0; const G: f64 = 2.0; fn env_str(name: &str, default: &str) -> String { std::env::var(name).unwrap_or_else(|_| default.to_string()) } fn env_num(name: &str, default: f64) -> f64 { std::env::var(name) .map(|v| v.parse().expect(name)) .unwrap_or(default) } /// The 2-D flag, exactly as the FSI2 harness builds it. fn quad8_flag(nx: usize, ny: usize) -> Mesh { let (x0, x1, y0, y1) = (0.25, 0.6, 0.19, 0.21); let mut mesh = Mesh::new(2).unwrap(); let (lx, ly) = (2 * nx + 1, 2 * ny + 1); let mut grid = vec![vec![None; ly]; lx]; for (i, column) in grid.iter_mut().enumerate() { for (j, slot) in column.iter_mut().enumerate() { if i % 2 == 1 && j % 2 == 1 { continue; } let x = x0 + (x1 - x0) * i as f64 / (2 * nx) as f64; let y = y0 + (y1 - y0) * j as f64 / (2 * ny) as f64; *slot = Some(mesh.add_node(Node::new_2d(x, y))); } } for i in 0..nx { for j in 0..ny { let (a, b) = (2 * i, 2 * j); let nodes = vec![ grid[a][b].unwrap(), grid[a + 2][b].unwrap(), grid[a + 2][b + 2].unwrap(), grid[a][b + 2].unwrap(), grid[a + 1][b].unwrap(), grid[a + 2][b + 1].unwrap(), grid[a + 1][b + 2].unwrap(), grid[a][b + 1].unwrap(), ]; mesh.add_element(Element::new(ElementType::Quad8, nodes, MaterialId(0)).unwrap()) .unwrap(); } } mesh } fn clamp_2d(mesh: &Mesh) -> BoundaryConditionSet { let clamped: Vec = mesh .nodes .iter() .filter(|(_, node)| (node.position().x - 0.25).abs() < 1e-12) .map(|(&id, _)| id) .collect(); let mut set = BoundaryConditionSet::new(); for component in [DofComponent::DisplacementX, DofComponent::DisplacementY] { set.add_condition(BoundaryCondition::Dirichlet(DirichletBC { nodes: clamped.clone(), components: vec![component], condition_type: DirichletType::Spatial(SpatialFunction(Box::new(|_| 0.0))), time_range: None, ramping_factor: 1.0, gradual_enforcement: false, })); } set } fn point_2d(mesh: &Mesh, x: f64, y: f64) -> NodeId { mesh.nodes .iter() .find(|(_, n)| (n.position().x - x).abs() < 1e-12 && (n.position().y - y).abs() < 1e-12) .map(|(&id, _)| id) .unwrap() } /// Consistent gravity nodal forces `∫ N_a ρ g dV` (per unit depth in 2-D). fn gravity_forces(mesh: &Mesh, rho: f64, g: f64) -> Vec<(NodeId, Vector3)> { let dim = mesh.spatial_dimension; let mut acc: std::collections::BTreeMap> = Default::default(); for element in mesh.elements.values() { let coords: Vec> = element .nodes .iter() .map(|id| mesh.get_node(*id).unwrap().position()) .collect(); let fe = StandardFiniteElement::new(element.element_type, coords.clone()); let f = ElementMatrixComputer::compute_body_force_vector( &fe, &coords, &|_| Vector3::new(0.0, -rho * g, 0.0), None, ) .unwrap(); for (a, id) in element.nodes.iter().enumerate() { let e = acc.entry(*id).or_insert_with(Vector3::zeros); for c in 0..dim { e[c] += f[a * dim + c]; } } } acc.into_iter().collect() } /// Newton to rounding: the static comparisons are otherwise limited by /// the default 1e-6 stopping rule (whose force scale differs between the /// 2-D per-unit-depth and the 3-D per-span loads). fn static_criteria() -> ConvergenceCriteria { ConvergenceCriteria { force_tolerance: 1e-12, displacement_tolerance: 1e-14, max_iterations: 60, ..ConvergenceCriteria::default() } } /// Static equilibrium through the dynamic stepper: one "Newmark step" of /// `dt = 1e4 s` from `u = v = a = 0` is Newton on /// `f_int(u) + M u/(β Δt²) = F` — the static problem up to a mass term /// 1e-9 of the stiffness. `load_steps` ramps the load, each step starting /// from the previous equilibrium with zero velocity and acceleration. fn static_solve<'a>( analysis: &'a NonlinearDynamicAnalysis, forces: &[(NodeId, Vector3)], load_steps: usize, ) -> (DVector, NonlinearDynamicStepper<'a>, usize) { let mut stepper = analysis.stepper().unwrap(); let n = stepper.rest_state().unwrap().displacement.len(); let mut u = DVector::zeros(n); let mut iterations = 0; for s in 1..=load_steps { let scale = s as f64 / load_steps as f64; let scaled: Vec<_> = forces.iter().map(|(id, f)| (*id, f * scale)).collect(); stepper.set_nodal_forces(&scaled); let state = DynamicState { displacement: u.clone(), velocity: DVector::zeros(n), acceleration: DVector::zeros(n), }; let (next, it) = stepper.step(&state).unwrap(); iterations += it; u = next.displacement; } (u, stepper, iterations) } fn static_2d_csm1(nx: usize, ny: usize) -> (f64, f64, usize) { let mesh = quad8_flag(nx, ny); let a = point_2d(&mesh, 0.6, 0.2); let forces = gravity_forces(&mesh, RHO_CSM, G); let analysis = NonlinearDynamicAnalysis::new( mesh.clone(), Flag3d::materials(E_MOD, NU, RHO_CSM), clamp_2d(&mesh), 1e4, 1, Default::default(), ) .with_total_lagrangian() .with_convergence_criteria(static_criteria()); let (u, stepper, it) = static_solve(&analysis, &forces, 5); let d = stepper.node_dofs(a); (u[d[0]], u[d[1]], it) } struct Tip3d { a: Vector3, /// Point A's line at the two lateral faces (z0, z1). side_low: Vector3, side_high: Vector3, iterations: usize, dofs: usize, } fn static_3d_csm1(spec: Flag3dSpec, lateral: LateralFaces, load_steps: usize) -> Tip3d { let flag = Flag3d::build(spec).unwrap(); let forces = gravity_forces(&flag.mesh, RHO_CSM, G); let analysis = flag .dynamic_analysis(E_MOD, NU, RHO_CSM, lateral, 1e4, 1, 0.5) .with_convergence_criteria(static_criteria()); let (u, stepper, iterations) = static_solve(&analysis, &forces, load_steps); let read = |id: NodeId| { let d = stepper.node_dofs(id); Vector3::new(u[d[0]], u[d[1]], u[d[2]]) }; let ym = 0.5 * (spec.y0 + spec.y1); Tip3d { a: read(flag.point_a()), side_low: read(flag.nearest_node(Vector3::new(spec.x1, ym, spec.z0))), side_high: read(flag.nearest_node(Vector3::new(spec.x1, ym, spec.z1))), iterations, dofs: u.len(), } } fn rel(a: f64, b: f64) -> f64 { ((a - b) / b).abs() } #[test] fn flag3d_mesh_mass_and_surface_forces() { let spec = Flag3dSpec::turek_hron(0.1, -0.05, 7, 2, 3); let flag = Flag3d::build(spec).unwrap(); // Serendipity lattice: points with at most one odd index. let [di, dj, dk] = flag.lattice_dims(); let mut expected = 0; for i in 0..di { for j in 0..dj { for k in 0..dk { if (i % 2) + (j % 2) + (k % 2) <= 1 { expected += 1; } } } } assert_eq!(flag.mesh.nodes.len(), expected); assert_eq!(flag.mesh.elements.len(), 7 * 2 * 3); // Consistent mass sums to ρ V; every Jacobian is positive. let mut mass = 0.0; for element in flag.mesh.elements.values() { let coords: Vec> = element .nodes .iter() .map(|id| flag.mesh.get_node(*id).unwrap().position()) .collect(); let fe = StandardFiniteElement::new(element.element_type, coords.clone()); let m = ElementMatrixComputer::compute_consistent_mass_matrix(&fe, &coords, 1e4, None).unwrap(); mass += m.matrix.sum(); } let volume = 0.35 * 0.02 * 0.1; assert!( rel(mass, 1e4 * volume) < 1e-12, "mass {mass} vs {}", 1e4 * volume ); // Uniform traction on the top face: total = t · L · span. let top = flag.surface_faces(&[FlagSide::Top]); let t0 = Vector3::new(3.0, -2.0, 0.5); let total: Vector3 = flag .face_nodal_forces(&top, None, &|_, _| t0) .iter() .map(|(_, f)| f) .sum(); assert!((total - t0 * (0.35 * 0.1)).norm() < 1e-12, "{total:?}"); // Normals point out: a pressure p on the top pushes down, on the tip // pushes −x, on the side faces pushes inward; bottom + top cancel. let p = 7.0; let pressure = |_: Vector3, n: Vector3| -p * n; let sum = |sides: &[FlagSide]| -> Vector3 { flag.face_nodal_forces(&flag.surface_faces(sides), None, &pressure) .iter() .map(|(_, f)| f) .sum() }; assert!((sum(&[FlagSide::Top]) - Vector3::new(0.0, -p * 0.035, 0.0)).norm() < 1e-12); assert!((sum(&[FlagSide::Tip]) - Vector3::new(-p * 0.002, 0.0, 0.0)).norm() < 1e-12); assert!((sum(&[FlagSide::SideHigh]) - Vector3::new(0.0, 0.0, -p * 0.007)).norm() < 1e-12); assert!((sum(&[FlagSide::SideLow]) - Vector3::new(0.0, 0.0, p * 0.007)).norm() < 1e-12); assert!(sum(&[FlagSide::Bottom, FlagSide::Top]).norm() < 1e-12); // All five wetted faces + the root would close; without the root the // pressure resultant is the root's missing +x share. let wetted: Vector3 = flag .face_nodal_forces(&flag.wetted_faces(), None, &pressure) .iter() .map(|(_, f)| f) .sum(); assert!((wetted - Vector3::new(-p * 0.002, 0.0, 0.0)).norm() < 1e-12); // A rigid translation of the configuration changes nothing. let analysis = flag.dynamic_analysis(1.4e6, 0.4, 1e4, LateralFaces::Free, 1e-3, 1, 0.5); let stepper = analysis.stepper().unwrap(); let mut u = DVector::zeros(3 * flag.mesh.nodes.len()); for id in flag.mesh.nodes.keys() { let d = stepper.node_dofs(*id); u[d[0]] = 0.01; u[d[1]] = -0.03; u[d[2]] = 0.02; } let dofs = |id: NodeId| stepper.node_dofs(id); let moved = flag.face_nodal_forces(&top, Some((&u, &dofs)), &|_, n| -p * n); let still = flag.face_nodal_forces(&top, None, &|_, n| -p * n); for ((ia, fa), (ib, fb)) in moved.iter().zip(&still) { assert_eq!(ia, ib); assert!((fa - fb).norm() < 1e-14); } } #[test] fn plane_strain_3d_reproduces_the_2d_csm1() { let (ux2, uy2, it2) = static_2d_csm1(35, 2); let tip = static_3d_csm1( Flag3dSpec::turek_hron(0.05, 0.0, 35, 2, 1), LateralFaces::PlaneStrain, 5, ); println!( " CSM1 35x2: 2-D Quad8 u(A) = ({ux2:.9e}, {uy2:.9e}) [{it2} Newton]; 3-D Hex20 \ 35x2x1 plane strain u(A) = ({:.9e}, {:.9e}, {:.2e}) [{} Newton, {} DOFs]; \ reference (−7.18777e-3, −66.1029e-3)", tip.a.x, tip.a.y, tip.a.z, tip.iterations, tip.dofs ); assert!(rel(tip.a.x, ux2) < 1e-8, "ux {} vs 2-D {ux2}", tip.a.x); assert!(rel(tip.a.y, uy2) < 1e-8, "uy {} vs 2-D {uy2}", tip.a.y); assert!(tip.a.z.abs() < 1e-15); // Span-uniform: both lateral faces carry the mid-span value. assert!((tip.side_low - tip.a).norm() < 1e-9 * tip.a.norm()); assert!((tip.side_high - tip.a).norm() < 1e-9 * tip.a.norm()); // And the 2-D model is the one pinned against FEATFLOW (1% short in // u_y at 35x2, total_lagrangian_svk.rs). assert!(rel(uy2, -66.1029e-3) < 0.02 && rel(ux2, -7.18777e-3) < 0.04); } /// The free-lateral-face path, pinned: a narrow strip (span 0.02 = the /// thickness) under CSM1 gravity. Measured with `flag3d_csm1_table` /// (R8-b, 2026-09-25): u_y(A) = −76.340e-3 — softer than plane strain /// (−65.141e-3) because the free faces relax the spanwise stress. #[test] fn free_lateral_faces_csm1_strip_pin() { let tip = static_3d_csm1( Flag3dSpec::turek_hron(0.02, -0.01, 35, 2, 1), LateralFaces::Free, 5, ); println!( " CSM1 35x2x1 span 0.02 free faces: u(A) = ({:.6e}, {:.6e}, {:.2e})", tip.a.x, tip.a.y, tip.a.z ); assert!(rel(tip.a.y, -76.340_06e-3) < 1e-5, "uy {}", tip.a.y); assert!(rel(tip.a.x, -9.680_464e-3) < 1e-5, "ux {}", tip.a.x); assert!( tip.a.z.abs() < 1e-12, "mid-span must not move in z: {}", tip.a.z ); assert!( (tip.side_low.y - tip.side_high.y).abs() < 1e-12, "span symmetry" ); } fn csm3_2d(dt: f64) -> (NonlinearDynamicAnalysis, NodeId) { let mesh = quad8_flag(35, 2); let a = point_2d(&mesh, 0.6, 0.2); let mut analysis = NonlinearDynamicAnalysis::new( mesh.clone(), Flag3d::materials(E_MOD, NU, RHO_CSM), clamp_2d(&mesh), dt, 1, Default::default(), ) .with_total_lagrangian(); analysis.set_body_force(|_| Vector3::new(0.0, -RHO_CSM * G, 0.0)); (analysis, a) } #[test] fn plane_strain_3d_reproduces_the_2d_csm3_start() { let dt = 0.005; let steps = 60; let (a2d, node2) = csm3_2d(dt); let mut s2 = a2d.stepper().unwrap(); let flag = Flag3d::build(Flag3dSpec::turek_hron(0.05, 0.0, 35, 2, 1)).unwrap(); let mut a3d = flag.dynamic_analysis(E_MOD, NU, RHO_CSM, LateralFaces::PlaneStrain, dt, 1, 0.5); a3d.set_body_force(|_| Vector3::new(0.0, -RHO_CSM * G, 0.0)); let mut s3 = a3d.stepper().unwrap(); let node3 = flag.point_a(); let (d2, d3) = (s2.node_dofs(node2), s3.node_dofs(node3)); let mut st2 = s2.rest_state().unwrap(); let mut st3 = s3.rest_state().unwrap(); let mut worst: f64 = 0.0; let mut peak: f64 = 0.0; for _ in 0..steps { st2 = s2.step(&st2).unwrap().0; st3 = s3.step(&st3).unwrap().0; for c in 0..2 { worst = worst.max((st2.displacement[d2[c]] - st3.displacement[d3[c]]).abs()); peak = peak.max(st2.displacement[d2[c]].abs()); } } println!( " CSM3 first {steps} steps (t = {:.2} s): max |u_3D − u_2D| at A {worst:.3e} m, \ peak |u| {peak:.3e} m", steps as f64 * dt ); assert!(peak > 1e-2, "the flag must have moved: {peak}"); assert!( worst < 1e-8 * peak, "3-D plane strain departs from 2-D: {worst:.3e}" ); } // --------------------------------------------------------------------------- // Instruments // --------------------------------------------------------------------------- /// `NXxNYxNZ:span:free|ps` entries, comma-separated. fn parse_configs(spec: &str) -> Vec<(usize, usize, usize, f64, LateralFaces)> { spec.split(',') .map(|entry| { let mut parts = entry.trim().split(':'); let mesh = parts.next().unwrap(); let span: f64 = parts.next().unwrap().parse().unwrap(); let lateral = match parts.next().unwrap() { "free" => LateralFaces::Free, "ps" => LateralFaces::PlaneStrain, other => panic!("lateral {other}"), }; let n: Vec = mesh.split('x').map(|t| t.parse().unwrap()).collect(); (n[0], n[1], n[2], span, lateral) }) .collect() } fn tag(nx: usize, ny: usize, nz: usize, span: f64, lateral: LateralFaces) -> String { let l = if lateral == LateralFaces::Free { "free" } else { "ps" }; format!("{nx}x{ny}x{nz}_s{span}_{l}") } #[test] #[ignore = "instrument: CSM1 tip displacement per configuration"] fn flag3d_csm1_table() { let out = env_str("FLAG3D_OUT", "."); let configs = parse_configs(&env_str( "FLAG3D_CONFIGS", "35x2x1:0.05:ps,35x2x4:0.41:free", )); let steps = env_num("FLAG3D_LOAD_STEPS", 5.0) as usize; let mut table = std::fs::OpenOptions::new() .create(true) .append(true) .open(format!("{out}/csm1_table.txt")) .unwrap(); for (nx, ny, nz, span, lateral) in configs { let start = std::time::Instant::now(); let tip = static_3d_csm1( Flag3dSpec::turek_hron(span, -0.5 * span, nx, ny, nz), lateral, steps, ); let line = format!( "CSM1 {} dofs {} newton {} | A ux {:.6e} uy {:.6e} uz {:.3e} | side_low uy {:.6e} \ side_high uy {:.6e} | {:.1} s | ref ux -7.18777e-3 uy -66.1029e-3", tag(nx, ny, nz, span, lateral), tip.dofs, tip.iterations, tip.a.x, tip.a.y, tip.a.z, tip.side_low.y, tip.side_high.y, start.elapsed().as_secs_f64() ); println!("{line}"); writeln!(table, "{line}").unwrap(); } if env_str("FLAG3D_2D", "1") == "1" { for (nx, ny) in [(35, 2), (70, 4)] { let (ux, uy, it) = static_2d_csm1(nx, ny); let line = format!("CSM1 2-D {nx}x{ny} Quad8 | A ux {ux:.6e} uy {uy:.6e} [{it} Newton]"); println!("{line}"); writeln!(table, "{line}").unwrap(); } } } #[test] #[ignore = "instrument: the CSM3 oscillation on the 3-D flag"] fn flag3d_csm3_march() { let out = env_str("FLAG3D_OUT", "."); let configs = parse_configs(&env_str("FLAG3D_CONFIGS", "35x2x1:0.05:ps")); let dt = env_num("FLAG3D_DT", 0.005); let steps = env_num("FLAG3D_STEPS", 2000.0) as usize; for (nx, ny, nz, span, lateral) in configs { let spec = Flag3dSpec::turek_hron(span, -0.5 * span, nx, ny, nz); let flag = Flag3d::build(spec).unwrap(); let mut analysis = flag.dynamic_analysis(E_MOD, NU, RHO_CSM, lateral, dt, steps, 0.5); analysis.set_body_force(|_| Vector3::new(0.0, -RHO_CSM * G, 0.0)); let mut stepper = analysis.stepper().unwrap(); let ym = 0.5 * (spec.y0 + spec.y1); let probes = [ flag.point_a(), flag.nearest_node(Vector3::new(spec.x1, ym, spec.z0)), flag.nearest_node(Vector3::new(spec.x1, ym, spec.z1)), ]; let dofs: Vec> = probes.iter().map(|p| stepper.node_dofs(*p)).collect(); let name = tag(nx, ny, nz, span, lateral); let path = format!("{out}/csm3_{name}_dt{dt}.csv"); let mut csv = std::fs::File::create(&path).unwrap(); writeln!(csv, "t,ax,ay,az,low_y,high_y,low_z,high_z,newton").unwrap(); let mut state = stepper.rest_state().unwrap(); let start = std::time::Instant::now(); let mut total = 0usize; for k in 1..=steps { let (next, it) = stepper.step(&state).unwrap(); state = next; total += it; let u = &state.displacement; writeln!( csv, "{:.6e},{:.12e},{:.12e},{:.12e},{:.12e},{:.12e},{:.12e},{:.12e},{it}", k as f64 * dt, u[dofs[0][0]], u[dofs[0][1]], u[dofs[0][2]], u[dofs[1][1]], u[dofs[2][1]], u[dofs[1][2]], u[dofs[2][2]] ) .unwrap(); } println!( "CSM3 {name} dt {dt}: {steps} steps, {total} Newton, rescues {:?}, {:.0} s → {path}", stepper.rescue_counts(), start.elapsed().as_secs_f64() ); } } #[test] #[ignore = "instrument: the 3-D flag's K and M for an outside eigen-solve"] fn flag3d_modes_dump() { let out = env_str("FLAG3D_OUT", "."); let configs = parse_configs(&env_str("FLAG3D_CONFIGS", "35x2x1:0.05:ps")); let rho = env_num("FLAG3D_RHO", 1e4); let (lambda, mu) = LinearElastic::new(E_MOD, NU) .with_density(rho) .properties() .lame_parameters(); let constitutive = saint_venant_kirchhoff(lambda, mu, 3); for (nx, ny, nz, span, lateral) in configs { let spec = Flag3dSpec::turek_hron(span, -0.5 * span, nx, ny, nz); let flag = Flag3d::build(spec).unwrap(); let mut ids: Vec = flag.mesh.nodes.keys().copied().collect(); ids.sort(); let mut free = std::collections::HashMap::new(); let mut dof_lines = Vec::new(); for id in &ids { let p = flag.mesh.get_node(*id).unwrap().position(); if (p.x - spec.x0).abs() < 1e-12 { continue; } let comps = if lateral == LateralFaces::PlaneStrain { 2 } else { 3 }; for c in 0..comps { free.insert((*id, c), dof_lines.len()); dof_lines.push(format!( "{} {} {:.12e} {:.12e} {:.12e} {c}", dof_lines.len(), id.0, p.x, p.y, p.z )); } } let mut k_trip: std::collections::BTreeMap<(usize, usize), f64> = Default::default(); let mut m_trip: std::collections::BTreeMap<(usize, usize), f64> = Default::default(); for element in flag.mesh.elements.values() { let coords: Vec> = element .nodes .iter() .map(|id| flag.mesh.get_node(*id).unwrap().position()) .collect(); let fe = StandardFiniteElement::new(element.element_type, coords.clone()); let zero = DVector::zeros(3 * element.nodes.len()); let (_, k_e) = internal_force_and_tangent(&fe, &coords, &zero, constitutive.as_ref(), None) .unwrap(); let m_s = ElementMatrixComputer::compute_consistent_mass_matrix(&fe, &coords, rho, None) .unwrap(); let local: Vec> = element .nodes .iter() .flat_map(|n| (0..3).map(move |c| (*n, c))) .map(|key| free.get(&key).copied()) .collect(); for (a, ga) in local.iter().enumerate() { let Some(ga) = ga else { continue }; for (b, gb) in local.iter().enumerate() { let Some(gb) = gb else { continue }; *k_trip.entry((*ga, *gb)).or_default() += k_e[(a, b)]; if a % 3 == b % 3 { *m_trip.entry((*ga, *gb)).or_default() += m_s.matrix[(a / 3, b / 3)]; } } } } let name = tag(nx, ny, nz, span, lateral); let write = |kind: &str, trip: &std::collections::BTreeMap<(usize, usize), f64>| { let mut f = std::fs::File::create(format!("{out}/{kind}_{name}.coo")).unwrap(); for ((i, j), v) in trip { if *v != 0.0 { writeln!(f, "{i} {j} {v:.17e}").unwrap(); } } }; write("k", &k_trip); write("m", &m_trip); std::fs::write( format!("{out}/dofs_{name}.txt"), dof_lines.join("\n") + "\n", ) .unwrap(); println!( "modes dump {name}: {} free DOFs, K nnz {}", dof_lines.len(), k_trip.len() ); } }