1use runmat_analysis_core::AnalysisField;
2
3use crate::contracts::{
4 FEA_FIELD_STRUCTURAL_BEAM_AXIAL_FORCE, FEA_FIELD_STRUCTURAL_BEAM_BENDING_MOMENT,
5 FEA_FIELD_STRUCTURAL_BEAM_BENDING_STRESS, FEA_FIELD_STRUCTURAL_BEAM_SHEAR_FORCE,
6 FEA_FIELD_STRUCTURAL_BEAM_TORSION_MOMENT, FEA_FIELD_STRUCTURAL_BEAM_TORSION_STRESS,
7 FEA_FIELD_STRUCTURAL_DISPLACEMENT, FEA_FIELD_STRUCTURAL_EQUATION_SCALE,
8 FEA_FIELD_STRUCTURAL_NODAL_VON_MISES, FEA_FIELD_STRUCTURAL_REACTION_FORCE,
9 FEA_FIELD_STRUCTURAL_REACTION_MOMENT, FEA_FIELD_STRUCTURAL_RESIDUAL_NORM,
10 FEA_FIELD_STRUCTURAL_ROTATION, FEA_FIELD_STRUCTURAL_SHELL_BENDING_MOMENT,
11 FEA_FIELD_STRUCTURAL_SHELL_MEMBRANE_FORCE, FEA_FIELD_STRUCTURAL_SHELL_TRANSVERSE_SHEAR,
12 FEA_FIELD_STRUCTURAL_SHELL_VON_MISES, FEA_FIELD_STRUCTURAL_STRAIN,
13 FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY, FEA_FIELD_STRUCTURAL_STRESS,
14 FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY, FEA_FIELD_STRUCTURAL_VON_MISES,
15};
16use crate::{
17 assembly::{
18 dofs::StructuralDofKind,
19 elements::beam::{local_stiffness_matrix, BEAM_ELEMENT_DOF_COUNT},
20 elements::solid::{strain_displacement_matrix, Tetrahedron4ElementGeometry},
21 AssemblySummary, BeamRecoveryElementSummary, PrepCoordinateSummary,
22 PrepRecoveryEdgeSummary, ShellRecoveryElementSummary, SolidRecoveryElementSummary,
23 StructuralMaterialSummary,
24 },
25 operator::{apply_k, apply_k_unconstrained},
26 solve::linear::LinearSolveResult,
27};
28
29const VECTOR_COMPONENT_COUNT: usize = 3;
30const TENSOR_COMPONENT_COUNT: usize = 6;
31type LocalTriangleCoordinates = [[f64; 2]; 3];
32type LocalFrame = [[f64; 3]; 3];
33
34pub fn recover_result_fields(
35 summary: &AssemblySummary,
36 solve_result: &LinearSolveResult,
37) -> Vec<AnalysisField> {
38 if solve_result.solution.is_empty()
39 || solve_result.solution.iter().any(|value| !value.is_finite())
40 {
41 return empty_structural_fields();
42 }
43
44 let dof_count = summary.dof_count.max(3);
45 let mut displacement_values = solve_result.solution.clone();
46 if displacement_values.len() < dof_count {
47 displacement_values.resize(dof_count, 0.0);
48 }
49 if displacement_values.is_empty() {
50 displacement_values = vec![0.0; dof_count];
51 }
52
53 let node_count = structural_displacement_node_count(summary, dof_count);
54 displacement_values = recover_displacement(summary, &displacement_values, node_count);
55
56 let strain_recovery = recover_structural_strain(summary, &displacement_values);
57 let element_count = strain_recovery.element_count;
58 let strain_values = strain_recovery.values;
59 let stress_values = recover_stress(&strain_values, summary.structural_material);
60 let von_mises_values = recover_von_mises(&stress_values);
61 let nodal_von_mises_values =
62 recover_nodal_averaged_scalar(summary, &von_mises_values, node_count);
63 let strain_energy_density_values =
64 recover_strain_energy_density(&strain_values, &stress_values);
65 let internal_force = apply_k_unconstrained(&summary.operator, &solve_result.solution);
66 let reaction_values = recover_reaction_force(summary, &internal_force);
67 let rotation_values = recover_rotation(summary, &solve_result.solution);
68 let reaction_moment_values = recover_reaction_moment(summary, &internal_force);
69 let strain_energy = recover_total_strain_energy(&solve_result.solution, &internal_force);
70 let residual_metrics = recover_residual_metrics(summary, &solve_result.solution);
71
72 let mut fields = vec![
73 AnalysisField::host_f64(
74 FEA_FIELD_STRUCTURAL_DISPLACEMENT,
75 vec![node_count, VECTOR_COMPONENT_COUNT],
76 displacement_values,
77 ),
78 AnalysisField::host_f64(
79 FEA_FIELD_STRUCTURAL_ROTATION,
80 rotation_shape(summary),
81 rotation_values,
82 ),
83 AnalysisField::host_f64(
84 FEA_FIELD_STRUCTURAL_VON_MISES,
85 vec![element_count],
86 von_mises_values,
87 ),
88 AnalysisField::host_f64(
89 FEA_FIELD_STRUCTURAL_STRAIN,
90 vec![element_count, TENSOR_COMPONENT_COUNT],
91 strain_values,
92 ),
93 AnalysisField::host_f64(
94 FEA_FIELD_STRUCTURAL_STRESS,
95 vec![element_count, TENSOR_COMPONENT_COUNT],
96 stress_values,
97 ),
98 AnalysisField::host_f64(
99 FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY,
100 vec![element_count],
101 strain_energy_density_values,
102 ),
103 AnalysisField::host_f64(
104 FEA_FIELD_STRUCTURAL_NODAL_VON_MISES,
105 vec![node_count],
106 nodal_von_mises_values,
107 ),
108 AnalysisField::host_f64(
109 FEA_FIELD_STRUCTURAL_REACTION_FORCE,
110 reaction_shape(summary),
111 reaction_values,
112 ),
113 AnalysisField::host_f64(
114 FEA_FIELD_STRUCTURAL_REACTION_MOMENT,
115 rotation_shape(summary),
116 reaction_moment_values,
117 ),
118 AnalysisField::host_f64(
119 FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY,
120 vec![1],
121 vec![strain_energy],
122 ),
123 AnalysisField::host_f64(
124 FEA_FIELD_STRUCTURAL_RESIDUAL_NORM,
125 vec![1],
126 vec![residual_metrics.normalized_residual_norm],
127 ),
128 AnalysisField::host_f64(
129 FEA_FIELD_STRUCTURAL_EQUATION_SCALE,
130 vec![1],
131 vec![residual_metrics.equation_scale],
132 ),
133 ];
134 if let Some(beam_fields) = recover_beam_result_fields(summary, &solve_result.solution) {
135 fields.extend(beam_fields);
136 }
137 if let Some(shell_fields) = recover_shell_result_fields(summary, &solve_result.solution) {
138 fields.extend(shell_fields);
139 }
140 fields
141}
142
143pub fn recover_structural_stress_from_displacement(
144 summary: &AssemblySummary,
145 displacement: &[f64],
146) -> Vec<f64> {
147 let mut displacement_values = displacement.to_vec();
148 let dof_count = summary.dof_count.max(3);
149 if displacement_values.len() < dof_count {
150 displacement_values.resize(dof_count, 0.0);
151 }
152 let node_count = dof_count.div_ceil(VECTOR_COMPONENT_COUNT).max(1);
153 displacement_values.resize(node_count * VECTOR_COMPONENT_COUNT, 0.0);
154 let strain_recovery = recover_structural_strain(summary, &displacement_values);
155 recover_stress(&strain_recovery.values, summary.structural_material)
156}
157
158#[derive(Debug, Clone, Copy, PartialEq)]
159pub struct StructuralFieldRecoveryMetrics {
160 pub active_stiffness_edge_count: usize,
161 pub prep_recovery_edge_count: usize,
162 pub constrained_edge_count: usize,
163 pub recovery_element_count: usize,
164 pub solver_mesh_node_count: usize,
165 pub solver_mesh_element_count: usize,
166 pub max_edge_displacement_jump: f64,
167 pub max_edge_strain_norm: f64,
168 pub mean_edge_stiffness_ratio: f64,
169 pub mean_edge_length_m: f64,
170 pub strain_component_coverage_ratio: f64,
171 pub element_geometry_node_count: usize,
172 pub element_geometry_edge_count: usize,
173 pub element_geometry_coverage_ratio: f64,
174 pub basis: &'static str,
175}
176
177pub fn structural_field_recovery_metrics(
178 summary: &AssemblySummary,
179 displacement: &[f64],
180) -> StructuralFieldRecoveryMetrics {
181 let recovery_edges = structural_recovery_edges(summary);
182 let strain_recovery = recover_structural_strain(summary, displacement);
183 let active_stiffness_edge_count = recovery_edges.len();
184 let prep_recovery_edge_count = recovery_edges
185 .iter()
186 .filter(|edge| edge.basis == StructuralRecoveryBasis::PrepElementConnectivity)
187 .count();
188 let constrained_edge_count = constrained_recovery_edge_count(summary);
189 let mut max_edge_displacement_jump = 0.0_f64;
190 let mut max_edge_strain_norm = 0.0_f64;
191 let mut stiffness_ratio_sum = 0.0_f64;
192 let mut edge_length_sum = 0.0_f64;
193 for edge in &recovery_edges {
194 let jump = displacement
195 .get(edge.to_dof)
196 .zip(displacement.get(edge.from_dof))
197 .map(|(right, left)| (right - left).abs())
198 .unwrap_or(0.0);
199 max_edge_displacement_jump = max_edge_displacement_jump.max(jump);
200 stiffness_ratio_sum += edge.stiffness_ratio;
201 edge_length_sum += edge.edge_length_m;
202 }
203 for strain_tensor in strain_recovery.values.chunks_exact(TENSOR_COMPONENT_COUNT) {
204 let strain_norm = strain_tensor
205 .iter()
206 .map(|value| value * value)
207 .sum::<f64>()
208 .sqrt();
209 max_edge_strain_norm = max_edge_strain_norm.max(strain_norm);
210 }
211 let recovery_edge_count = recovery_edges.len();
212 let mean_edge_stiffness_ratio = if recovery_edge_count == 0 {
213 0.0
214 } else {
215 stiffness_ratio_sum / recovery_edge_count as f64
216 };
217 let mean_edge_length_m = if recovery_edge_count == 0 {
218 0.0
219 } else {
220 edge_length_sum / recovery_edge_count as f64
221 };
222 let strain_component_coverage_ratio =
223 strain_component_coverage_ratio(displacement, &recovery_edges);
224 let element_geometry_node_count = summary
225 .prep_coordinates
226 .as_ref()
227 .map(|coordinates| coordinates.element_geometry_node_count)
228 .unwrap_or(0);
229 let element_geometry_edge_count = summary
230 .prep_coordinates
231 .as_ref()
232 .map(|coordinates| coordinates.element_geometry_edge_count)
233 .unwrap_or(0);
234 let element_geometry_coverage_ratio = summary
235 .prep_coordinates
236 .as_ref()
237 .map(|coordinates| coordinates.element_geometry_coverage_ratio)
238 .unwrap_or(0.0);
239 let solver_mesh_element_count = summary.structural_solid_recovery.len();
240 let solver_mesh_node_count = if solver_mesh_element_count == 0 {
241 0
242 } else {
243 let mut nodes = summary
244 .structural_solid_recovery
245 .iter()
246 .flat_map(|element| element.node_indices)
247 .collect::<Vec<_>>();
248 nodes.sort_unstable();
249 nodes.dedup();
250 nodes.len()
251 };
252
253 StructuralFieldRecoveryMetrics {
254 active_stiffness_edge_count,
255 prep_recovery_edge_count,
256 constrained_edge_count,
257 recovery_element_count: strain_recovery.element_count,
258 solver_mesh_node_count,
259 solver_mesh_element_count,
260 max_edge_displacement_jump,
261 max_edge_strain_norm,
262 mean_edge_stiffness_ratio,
263 mean_edge_length_m,
264 strain_component_coverage_ratio,
265 element_geometry_node_count,
266 element_geometry_edge_count,
267 element_geometry_coverage_ratio,
268 basis: strain_recovery.basis,
269 }
270}
271
272struct StructuralStrainRecovery {
273 values: Vec<f64>,
274 element_count: usize,
275 basis: &'static str,
276}
277
278fn empty_structural_fields() -> Vec<AnalysisField> {
279 vec![
280 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_DISPLACEMENT, vec![0, 3], Vec::new()),
281 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_ROTATION, vec![0, 3], Vec::new()),
282 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_VON_MISES, vec![0], Vec::new()),
283 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_STRAIN, vec![0, 6], Vec::new()),
284 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_STRESS, vec![0, 6], Vec::new()),
285 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_NODAL_VON_MISES, vec![0], Vec::new()),
286 AnalysisField::host_f64(
287 FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY,
288 vec![0],
289 Vec::new(),
290 ),
291 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_REACTION_FORCE, vec![0, 3], Vec::new()),
292 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_REACTION_MOMENT, vec![0, 3], Vec::new()),
293 AnalysisField::host_f64(
294 FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY,
295 vec![0],
296 Vec::new(),
297 ),
298 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_RESIDUAL_NORM, vec![0], Vec::new()),
299 AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_EQUATION_SCALE, vec![0], Vec::new()),
300 ]
301}
302
303#[derive(Debug, Clone, Copy, PartialEq)]
304struct StructuralRecoveryEdge {
305 from_dof: usize,
306 to_dof: usize,
307 component: usize,
308 hop: usize,
309 edge_length_m: f64,
310 stiffness_ratio: f64,
311 basis: StructuralRecoveryBasis,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315enum StructuralRecoveryBasis {
316 SolidTetrahedron4ConstantStrain,
317 PrepConstantStrainBMatrix,
318 PrepElementConnectivity,
319 OperatorConnectivity,
320}
321
322impl StructuralRecoveryBasis {
323 const fn as_str(self) -> &'static str {
324 match self {
325 Self::SolidTetrahedron4ConstantStrain => "solid_tetrahedron4_constant_strain",
326 Self::PrepConstantStrainBMatrix => "prep_constant_strain_b_matrix",
327 Self::PrepElementConnectivity => "prep_element_connectivity",
328 Self::OperatorConnectivity => "operator_connectivity",
329 }
330 }
331}
332
333#[derive(Debug, Clone, Copy)]
334struct StructuralBMatrixElement {
335 nodes: [usize; 3],
336 coordinates_m: [[f64; 3]; 3],
337}
338
339fn structural_recovery_edges(summary: &AssemblySummary) -> Vec<StructuralRecoveryEdge> {
340 let prep_edges = prep_structural_recovery_edges(summary);
341 if !prep_edges.is_empty() {
342 return prep_edges;
343 }
344
345 summary
346 .operator
347 .stiffness_upper
348 .iter()
349 .enumerate()
350 .filter_map(|(from_dof, stiffness)| {
351 let to_dof = from_dof + 1;
352 if to_dof >= summary.dof_count
353 || *stiffness <= 0.0
354 || summary
355 .operator
356 .constrained
357 .get(from_dof)
358 .copied()
359 .unwrap_or(false)
360 || summary
361 .operator
362 .constrained
363 .get(to_dof)
364 .copied()
365 .unwrap_or(false)
366 {
367 return None;
368 }
369 let left_diag = summary
370 .operator
371 .stiffness_diag
372 .get(from_dof)
373 .copied()
374 .unwrap_or(0.0);
375 let right_diag = summary
376 .operator
377 .stiffness_diag
378 .get(to_dof)
379 .copied()
380 .unwrap_or(0.0);
381 let diag_scale = (0.5 * (left_diag + right_diag)).abs().max(1.0);
382 Some(StructuralRecoveryEdge {
383 from_dof,
384 to_dof,
385 component: from_dof % VECTOR_COMPONENT_COUNT,
386 hop: 1,
387 edge_length_m: 1.0,
388 stiffness_ratio: (*stiffness / diag_scale).abs(),
389 basis: StructuralRecoveryBasis::OperatorConnectivity,
390 })
391 })
392 .collect()
393}
394
395fn prep_structural_recovery_edges(summary: &AssemblySummary) -> Vec<StructuralRecoveryEdge> {
396 summary
397 .prep_recovery_edges
398 .iter()
399 .filter_map(|edge| prep_recovery_edge(summary, *edge))
400 .collect()
401}
402
403fn prep_recovery_edge(
404 summary: &AssemblySummary,
405 edge: PrepRecoveryEdgeSummary,
406) -> Option<StructuralRecoveryEdge> {
407 let from_dof = edge.from_dof.min(edge.to_dof);
408 let to_dof = edge.from_dof.max(edge.to_dof);
409 if from_dof == to_dof
410 || to_dof >= summary.dof_count
411 || summary
412 .operator
413 .constrained
414 .get(from_dof)
415 .copied()
416 .unwrap_or(false)
417 || summary
418 .operator
419 .constrained
420 .get(to_dof)
421 .copied()
422 .unwrap_or(false)
423 {
424 return None;
425 }
426
427 let left_diag = summary
428 .operator
429 .stiffness_diag
430 .get(from_dof)
431 .copied()
432 .unwrap_or(0.0);
433 let right_diag = summary
434 .operator
435 .stiffness_diag
436 .get(to_dof)
437 .copied()
438 .unwrap_or(0.0);
439 let diag_scale = (0.5 * (left_diag + right_diag)).abs().max(1.0);
440 let hop = to_dof.abs_diff(from_dof).max(1);
441 let family_scale = match edge.element_family_index {
442 0 => 0.95,
443 1 => 1.0,
444 2 => 1.05,
445 3 => 1.1,
446 _ => 0.9,
447 };
448 Some(StructuralRecoveryEdge {
449 from_dof,
450 to_dof,
451 component: from_dof % VECTOR_COMPONENT_COUNT,
452 hop,
453 edge_length_m: finite_positive_or(edge.edge_length_m, hop as f64),
454 stiffness_ratio: family_scale / (hop as f64 * diag_scale.sqrt().max(1.0)),
455 basis: StructuralRecoveryBasis::PrepElementConnectivity,
456 })
457}
458
459fn finite_positive_or(value: f64, fallback: f64) -> f64 {
460 if value.is_finite() && value > 0.0 {
461 value
462 } else {
463 fallback
464 }
465}
466
467fn constrained_recovery_edge_count(summary: &AssemblySummary) -> usize {
468 let prep_constrained = summary
469 .prep_recovery_edges
470 .iter()
471 .filter(|edge| {
472 let from_dof = edge.from_dof.min(edge.to_dof);
473 let to_dof = edge.from_dof.max(edge.to_dof);
474 to_dof < summary.dof_count
475 && (summary
476 .operator
477 .constrained
478 .get(from_dof)
479 .copied()
480 .unwrap_or(false)
481 || summary
482 .operator
483 .constrained
484 .get(to_dof)
485 .copied()
486 .unwrap_or(false))
487 })
488 .count();
489 if prep_constrained > 0 || !summary.prep_recovery_edges.is_empty() {
490 return prep_constrained;
491 }
492
493 summary
494 .operator
495 .stiffness_upper
496 .iter()
497 .enumerate()
498 .filter(|(from_dof, stiffness)| {
499 let to_dof = from_dof + 1;
500 **stiffness > 0.0
501 && to_dof < summary.dof_count
502 && (summary
503 .operator
504 .constrained
505 .get(*from_dof)
506 .copied()
507 .unwrap_or(false)
508 || summary
509 .operator
510 .constrained
511 .get(to_dof)
512 .copied()
513 .unwrap_or(false))
514 })
515 .count()
516}
517
518fn recover_structural_strain(
519 summary: &AssemblySummary,
520 displacement: &[f64],
521) -> StructuralStrainRecovery {
522 if !summary.structural_solid_recovery.is_empty() {
523 return StructuralStrainRecovery {
524 values: recover_solid_tetrahedron4_strain(
525 displacement,
526 &summary.structural_solid_recovery,
527 ),
528 element_count: summary.structural_solid_recovery.len().max(1),
529 basis: StructuralRecoveryBasis::SolidTetrahedron4ConstantStrain.as_str(),
530 };
531 }
532
533 let b_matrix_elements = prep_b_matrix_recovery_elements(summary, displacement);
534 if !b_matrix_elements.is_empty() {
535 return StructuralStrainRecovery {
536 values: recover_b_matrix_strain(displacement, &b_matrix_elements),
537 element_count: b_matrix_elements.len().max(1),
538 basis: StructuralRecoveryBasis::PrepConstantStrainBMatrix.as_str(),
539 };
540 }
541
542 let recovery_edges = structural_recovery_edges(summary);
543 StructuralStrainRecovery {
544 values: recover_edge_strain(displacement, &recovery_edges),
545 element_count: recovery_edges.len().max(1),
546 basis: recovery_edges
547 .first()
548 .map(|edge| edge.basis.as_str())
549 .unwrap_or(StructuralRecoveryBasis::OperatorConnectivity.as_str()),
550 }
551}
552
553fn recover_solid_tetrahedron4_strain(
554 displacement: &[f64],
555 elements: &[SolidRecoveryElementSummary],
556) -> Vec<f64> {
557 let mut strain = vec![0.0; elements.len().max(1) * TENSOR_COMPONENT_COUNT];
558 for (element_index, element) in elements.iter().enumerate() {
559 let Ok(b) = strain_displacement_matrix(Tetrahedron4ElementGeometry {
560 nodes_m: element.coordinates_m,
561 }) else {
562 continue;
563 };
564 let mut element_displacement = [0.0_f64; 12];
565 for (local_node, node_index) in element.node_indices.iter().copied().enumerate() {
566 let displacement_vector = nodal_displacement(displacement, node_index);
567 let base = local_node * VECTOR_COMPONENT_COUNT;
568 element_displacement[base..base + VECTOR_COMPONENT_COUNT]
569 .copy_from_slice(&displacement_vector);
570 }
571 let base = element_index * TENSOR_COMPONENT_COUNT;
572 for component in 0..TENSOR_COMPONENT_COUNT {
573 strain[base + component] = b[component]
574 .iter()
575 .zip(element_displacement.iter())
576 .map(|(shape, value)| shape * value)
577 .sum();
578 }
579 }
580 strain
581}
582
583fn recover_nodal_averaged_scalar(
584 summary: &AssemblySummary,
585 element_values: &[f64],
586 node_count: usize,
587) -> Vec<f64> {
588 let mut nodal = vec![0.0_f64; node_count];
589 let mut counts = vec![0_usize; node_count];
590 for (element_index, element) in summary.structural_solid_recovery.iter().enumerate() {
591 let Some(value) = element_values.get(element_index).copied() else {
592 continue;
593 };
594 for node_index in element.node_indices {
595 if let Some(accumulator) = nodal.get_mut(node_index) {
596 *accumulator += value;
597 counts[node_index] += 1;
598 }
599 }
600 }
601 for (value, count) in nodal.iter_mut().zip(counts) {
602 if count > 0 {
603 *value /= count as f64;
604 }
605 }
606 nodal
607}
608
609fn prep_b_matrix_recovery_elements(
610 summary: &AssemblySummary,
611 displacement: &[f64],
612) -> Vec<StructuralBMatrixElement> {
613 let Some(prep_coordinates) = summary.prep_coordinates.as_ref() else {
614 return Vec::new();
615 };
616 if prep_coordinates.element_geometry_coverage_ratio <= 0.0
617 || prep_coordinates.element_geometry_node_count < 3
618 || prep_coordinates.reference_element_area_m2 <= 0.0
619 || !prep_coordinates.reference_element_area_m2.is_finite()
620 || !reference_coordinates_are_valid(prep_coordinates.reference_element_coordinates_m)
621 {
622 return Vec::new();
623 }
624
625 let node_count = displacement.len().div_ceil(VECTOR_COMPONENT_COUNT);
626 let full_elements = prep_full_b_matrix_recovery_elements(summary, node_count, prep_coordinates);
627 if !full_elements.is_empty() {
628 return full_elements;
629 }
630
631 let sample_elements = prep_sample_b_matrix_recovery_elements(
632 summary,
633 node_count,
634 prep_coordinates
635 .element_topology_sample_element_count
636 .min(4),
637 prep_coordinates.element_topology_sample_edge_count.min(8),
638 prep_coordinates.element_topology_sample_element_edges,
639 prep_coordinates.element_topology_sample_edge_nodes,
640 prep_coordinates.element_topology_sample_node_coordinates_m,
641 );
642 if !sample_elements.is_empty() {
643 return sample_elements;
644 }
645
646 fallback_b_matrix_recovery_elements(summary, node_count, prep_coordinates)
647}
648
649fn prep_full_b_matrix_recovery_elements(
650 summary: &AssemblySummary,
651 node_count: usize,
652 prep_coordinates: &PrepCoordinateSummary,
653) -> Vec<StructuralBMatrixElement> {
654 if prep_coordinates.element_topology_edge_nodes.len() < 3
655 || prep_coordinates.element_topology_element_edges.is_empty()
656 || prep_coordinates
657 .element_topology_node_coordinates_m
658 .is_empty()
659 {
660 return Vec::new();
661 }
662 prep_coordinates
663 .element_topology_element_edges
664 .iter()
665 .filter_map(|element_edges| {
666 let mut nodes = Vec::with_capacity(3);
667 for edge_index in element_edges {
668 let edge_index = *edge_index as usize;
669 let edge_nodes = *prep_coordinates
670 .element_topology_edge_nodes
671 .get(edge_index)?;
672 for node in edge_nodes {
673 let node = node as usize;
674 if node < node_count
675 && node < prep_coordinates.element_topology_node_coordinates_m.len()
676 && node_has_unconstrained_dof(summary, node)
677 && !nodes.contains(&node)
678 {
679 nodes.push(node);
680 }
681 }
682 }
683 if nodes.len() != 3 {
684 return None;
685 }
686 let coordinates_m = [
687 prep_coordinates.element_topology_node_coordinates_m[nodes[0]],
688 prep_coordinates.element_topology_node_coordinates_m[nodes[1]],
689 prep_coordinates.element_topology_node_coordinates_m[nodes[2]],
690 ];
691 reference_coordinates_are_valid(coordinates_m).then_some(StructuralBMatrixElement {
692 nodes: [nodes[0], nodes[1], nodes[2]],
693 coordinates_m,
694 })
695 })
696 .collect()
697}
698
699fn fallback_b_matrix_recovery_elements(
700 summary: &AssemblySummary,
701 node_count: usize,
702 prep_coordinates: &PrepCoordinateSummary,
703) -> Vec<StructuralBMatrixElement> {
704 let mut nodes = summary
705 .prep_recovery_edges
706 .iter()
707 .flat_map(|edge| {
708 [
709 edge.from_dof / VECTOR_COMPONENT_COUNT,
710 edge.to_dof / VECTOR_COMPONENT_COUNT,
711 ]
712 })
713 .filter(|node| *node < node_count && node_has_unconstrained_dof(summary, *node))
714 .collect::<Vec<_>>();
715 nodes.sort_unstable();
716 nodes.dedup();
717 if nodes.len() < 3 {
718 nodes = (0..node_count)
719 .filter(|node| node_has_unconstrained_dof(summary, *node))
720 .take(3)
721 .collect();
722 }
723
724 nodes
725 .chunks_exact(3)
726 .map(|chunk| StructuralBMatrixElement {
727 nodes: [chunk[0], chunk[1], chunk[2]],
728 coordinates_m: prep_coordinates.reference_element_coordinates_m,
729 })
730 .collect()
731}
732
733fn prep_sample_b_matrix_recovery_elements(
734 summary: &AssemblySummary,
735 node_count: usize,
736 sample_element_count: usize,
737 sample_edge_count: usize,
738 element_edges: [[u32; 3]; 4],
739 edge_nodes: [[u32; 2]; 8],
740 node_coordinates_m: [[f64; 3]; 8],
741) -> Vec<StructuralBMatrixElement> {
742 if sample_element_count == 0 || sample_edge_count < 3 {
743 return Vec::new();
744 }
745 element_edges
746 .iter()
747 .take(sample_element_count)
748 .filter_map(|sample_edges| {
749 let mut nodes = Vec::with_capacity(3);
750 for edge_index in sample_edges {
751 let edge_index = *edge_index as usize;
752 if edge_index >= sample_edge_count {
753 return None;
754 }
755 for node in edge_nodes[edge_index] {
756 let node = node as usize;
757 if node < node_count
758 && node < node_coordinates_m.len()
759 && node_has_unconstrained_dof(summary, node)
760 && !nodes.contains(&node)
761 {
762 nodes.push(node);
763 }
764 }
765 }
766 if nodes.len() != 3 {
767 return None;
768 }
769 let coordinates_m = [
770 node_coordinates_m[nodes[0]],
771 node_coordinates_m[nodes[1]],
772 node_coordinates_m[nodes[2]],
773 ];
774 reference_coordinates_are_valid(coordinates_m).then_some(StructuralBMatrixElement {
775 nodes: [nodes[0], nodes[1], nodes[2]],
776 coordinates_m,
777 })
778 })
779 .collect()
780}
781
782fn reference_coordinates_are_valid(coordinates: [[f64; 3]; 3]) -> bool {
783 coordinates.iter().flatten().all(|value| value.is_finite())
784 && triangle_area_3d_m2(coordinates) > 0.0
785}
786
787fn node_has_unconstrained_dof(summary: &AssemblySummary, node: usize) -> bool {
788 let base = node * VECTOR_COMPONENT_COUNT;
789 (0..VECTOR_COMPONENT_COUNT).any(|component| {
790 !summary
791 .operator
792 .constrained
793 .get(base + component)
794 .copied()
795 .unwrap_or(false)
796 })
797}
798
799fn recover_b_matrix_strain(
800 displacement: &[f64],
801 elements: &[StructuralBMatrixElement],
802) -> Vec<f64> {
803 let mut strain = vec![0.0; elements.len().max(1) * TENSOR_COMPONENT_COUNT];
804 for (element_index, element) in elements.iter().enumerate() {
805 let edge_strain = b_matrix_strain_tensor(displacement, element);
806 let base = element_index * TENSOR_COMPONENT_COUNT;
807 strain[base..base + TENSOR_COMPONENT_COUNT].copy_from_slice(&edge_strain);
808 }
809 strain
810}
811
812fn b_matrix_strain_tensor(displacement: &[f64], element: &StructuralBMatrixElement) -> [f64; 6] {
813 let Some((local_coordinates, local_basis)) = local_triangle_coordinates(element.coordinates_m)
814 else {
815 return [0.0; TENSOR_COMPONENT_COUNT];
816 };
817 let denominator = triangle_signed_area2(local_coordinates);
818 if !denominator.is_finite() || denominator.abs() <= f64::EPSILON {
819 return [0.0; TENSOR_COMPONENT_COUNT];
820 }
821
822 let mut local_displacement = [[0.0_f64; 3]; 3];
823 for (i, node) in element.nodes.iter().copied().enumerate() {
824 let displacement_vector = nodal_displacement(displacement, node);
825 local_displacement[i] = [
826 dot3(displacement_vector, local_basis[0]),
827 dot3(displacement_vector, local_basis[1]),
828 dot3(displacement_vector, local_basis[2]),
829 ];
830 }
831
832 let b = [
833 local_coordinates[1][1] - local_coordinates[2][1],
834 local_coordinates[2][1] - local_coordinates[0][1],
835 local_coordinates[0][1] - local_coordinates[1][1],
836 ];
837 let c = [
838 local_coordinates[2][0] - local_coordinates[1][0],
839 local_coordinates[0][0] - local_coordinates[2][0],
840 local_coordinates[1][0] - local_coordinates[0][0],
841 ];
842
843 let mut du_dx = 0.0_f64;
844 let mut du_dy = 0.0_f64;
845 let mut dv_dx = 0.0_f64;
846 let mut dv_dy = 0.0_f64;
847 let mut dw_dx = 0.0_f64;
848 let mut dw_dy = 0.0_f64;
849 for i in 0..3 {
850 du_dx += b[i] * local_displacement[i][0];
851 du_dy += c[i] * local_displacement[i][0];
852 dv_dx += b[i] * local_displacement[i][1];
853 dv_dy += c[i] * local_displacement[i][1];
854 dw_dx += b[i] * local_displacement[i][2];
855 dw_dy += c[i] * local_displacement[i][2];
856 }
857
858 [
859 du_dx / denominator,
860 dv_dy / denominator,
861 0.0,
862 (du_dy + dv_dx) / denominator,
863 dw_dy / denominator,
864 dw_dx / denominator,
865 ]
866}
867
868fn local_triangle_coordinates(
869 coordinates: [[f64; 3]; 3],
870) -> Option<(LocalTriangleCoordinates, LocalFrame)> {
871 let origin = coordinates[0];
872 let edge01 = sub3(coordinates[1], origin);
873 let edge02 = sub3(coordinates[2], origin);
874 let e1 = normalize3(edge01)?;
875 let normal = normalize3(cross3(edge01, edge02))?;
876 let e2 = normalize3(cross3(normal, e1))?;
877 let local = coordinates.map(|point| {
878 let relative = sub3(point, origin);
879 [dot3(relative, e1), dot3(relative, e2)]
880 });
881 Some((local, [e1, e2, normal]))
882}
883
884fn triangle_signed_area2(coordinates: [[f64; 2]; 3]) -> f64 {
885 coordinates[0][0] * (coordinates[1][1] - coordinates[2][1])
886 + coordinates[1][0] * (coordinates[2][1] - coordinates[0][1])
887 + coordinates[2][0] * (coordinates[0][1] - coordinates[1][1])
888}
889
890fn triangle_area_3d_m2(coordinates: [[f64; 3]; 3]) -> f64 {
891 let edge01 = sub3(coordinates[1], coordinates[0]);
892 let edge02 = sub3(coordinates[2], coordinates[0]);
893 0.5 * norm3(cross3(edge01, edge02))
894}
895
896fn sub3(left: [f64; 3], right: [f64; 3]) -> [f64; 3] {
897 [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
898}
899
900fn dot3(left: [f64; 3], right: [f64; 3]) -> f64 {
901 left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
902}
903
904fn cross3(left: [f64; 3], right: [f64; 3]) -> [f64; 3] {
905 [
906 left[1] * right[2] - left[2] * right[1],
907 left[2] * right[0] - left[0] * right[2],
908 left[0] * right[1] - left[1] * right[0],
909 ]
910}
911
912fn norm3(value: [f64; 3]) -> f64 {
913 dot3(value, value).sqrt()
914}
915
916fn normalize3(value: [f64; 3]) -> Option<[f64; 3]> {
917 let norm = norm3(value);
918 (norm.is_finite() && norm > f64::EPSILON).then_some([
919 value[0] / norm,
920 value[1] / norm,
921 value[2] / norm,
922 ])
923}
924
925fn recover_edge_strain(
926 displacement: &[f64],
927 recovery_edges: &[StructuralRecoveryEdge],
928) -> Vec<f64> {
929 let element_count = recovery_edges.len().max(1);
930 let mut strain = vec![0.0; element_count * TENSOR_COMPONENT_COUNT];
931 for (element_index, edge) in recovery_edges.iter().enumerate() {
932 let edge_strain = edge_strain_tensor(displacement, edge);
933 let base = element_index * TENSOR_COMPONENT_COUNT;
934 strain[base..base + TENSOR_COMPONENT_COUNT].copy_from_slice(&edge_strain);
935 }
936 strain
937}
938
939fn edge_strain_tensor(displacement: &[f64], edge: &StructuralRecoveryEdge) -> [f64; 6] {
940 let length = finite_positive_or(edge.edge_length_m, edge.hop.max(1) as f64);
941 let from_node = edge.from_dof / VECTOR_COMPONENT_COUNT;
942 let to_node = edge.to_dof / VECTOR_COMPONENT_COUNT;
943 if from_node == to_node {
944 let jump = displacement.get(edge.to_dof).copied().unwrap_or(0.0)
945 - displacement.get(edge.from_dof).copied().unwrap_or(0.0);
946 let mut tensor = [0.0; TENSOR_COMPONENT_COUNT];
947 tensor[edge.component] = jump / length;
948 return tensor;
949 }
950
951 let du = nodal_displacement(displacement, to_node);
952 let u0 = nodal_displacement(displacement, from_node);
953 let gradient = [
954 (du[0] - u0[0]) / length,
955 (du[1] - u0[1]) / length,
956 (du[2] - u0[2]) / length,
957 ];
958 [
959 gradient[0],
960 gradient[1],
961 gradient[2],
962 0.5 * (gradient[0] + gradient[1]),
963 0.5 * (gradient[1] + gradient[2]),
964 0.5 * (gradient[0] + gradient[2]),
965 ]
966}
967
968fn nodal_displacement(displacement: &[f64], node: usize) -> [f64; VECTOR_COMPONENT_COUNT] {
969 let base = node * VECTOR_COMPONENT_COUNT;
970 [
971 displacement.get(base).copied().unwrap_or(0.0),
972 displacement.get(base + 1).copied().unwrap_or(0.0),
973 displacement.get(base + 2).copied().unwrap_or(0.0),
974 ]
975}
976
977fn strain_component_coverage_ratio(
978 displacement: &[f64],
979 recovery_edges: &[StructuralRecoveryEdge],
980) -> f64 {
981 let expected_component_count = recovery_edges.len() * TENSOR_COMPONENT_COUNT;
982 if expected_component_count == 0 {
983 return 0.0;
984 }
985 let active_component_count = recovery_edges
986 .iter()
987 .map(|edge| {
988 edge_strain_tensor(displacement, edge)
989 .iter()
990 .filter(|value| value.abs() > 0.0)
991 .count()
992 })
993 .sum::<usize>();
994 active_component_count as f64 / expected_component_count as f64
995}
996
997fn recover_stress(strain: &[f64], material: StructuralMaterialSummary) -> Vec<f64> {
998 let lambda = material.lame_lambda_pa.max(0.0);
999 let mu = material.shear_modulus_pa.max(0.0);
1000 let mut stress = vec![0.0; strain.len()];
1001 for (element_index, strain_tensor) in strain.chunks_exact(TENSOR_COMPONENT_COUNT).enumerate() {
1002 let trace = strain_tensor[0] + strain_tensor[1] + strain_tensor[2];
1003 let base = element_index * TENSOR_COMPONENT_COUNT;
1004 stress[base] = lambda * trace + 2.0 * mu * strain_tensor[0];
1005 stress[base + 1] = lambda * trace + 2.0 * mu * strain_tensor[1];
1006 stress[base + 2] = lambda * trace + 2.0 * mu * strain_tensor[2];
1007 stress[base + 3] = mu * strain_tensor[3];
1008 stress[base + 4] = mu * strain_tensor[4];
1009 stress[base + 5] = mu * strain_tensor[5];
1010 }
1011 stress
1012}
1013
1014fn recover_von_mises(stress: &[f64]) -> Vec<f64> {
1015 stress
1016 .chunks_exact(TENSOR_COMPONENT_COUNT)
1017 .map(|tensor| {
1018 let sxx = tensor[0];
1019 let syy = tensor[1];
1020 let szz = tensor[2];
1021 let txy = tensor[3];
1022 let tyz = tensor[4];
1023 let txz = tensor[5];
1024 (0.5 * ((sxx - syy).powi(2) + (syy - szz).powi(2) + (szz - sxx).powi(2))
1025 + 3.0 * (txy.powi(2) + tyz.powi(2) + txz.powi(2)))
1026 .sqrt()
1027 })
1028 .collect()
1029}
1030
1031fn recover_strain_energy_density(strain: &[f64], stress: &[f64]) -> Vec<f64> {
1032 strain
1033 .chunks_exact(TENSOR_COMPONENT_COUNT)
1034 .zip(stress.chunks_exact(TENSOR_COMPONENT_COUNT))
1035 .map(|(strain_tensor, stress_tensor)| {
1036 0.5 * strain_tensor
1037 .iter()
1038 .zip(stress_tensor.iter())
1039 .map(|(strain, stress)| strain * stress)
1040 .sum::<f64>()
1041 })
1042 .collect()
1043}
1044
1045struct BeamResultValues {
1046 axial_force: Vec<f64>,
1047 shear_force: Vec<f64>,
1048 torsion_moment: Vec<f64>,
1049 bending_moment: Vec<f64>,
1050 bending_stress: Vec<f64>,
1051 torsion_stress: Vec<f64>,
1052}
1053
1054fn recover_beam_result_fields(
1055 summary: &AssemblySummary,
1056 solution: &[f64],
1057) -> Option<Vec<AnalysisField>> {
1058 if summary.structural_beam_recovery.is_empty() {
1059 return None;
1060 }
1061 let values = recover_beam_result_values(summary, solution);
1062 let element_count = summary.structural_beam_recovery.len();
1063 Some(vec![
1064 AnalysisField::host_f64(
1065 FEA_FIELD_STRUCTURAL_BEAM_AXIAL_FORCE,
1066 vec![element_count],
1067 values.axial_force,
1068 ),
1069 AnalysisField::host_f64(
1070 FEA_FIELD_STRUCTURAL_BEAM_SHEAR_FORCE,
1071 vec![element_count, 2],
1072 values.shear_force,
1073 ),
1074 AnalysisField::host_f64(
1075 FEA_FIELD_STRUCTURAL_BEAM_TORSION_MOMENT,
1076 vec![element_count],
1077 values.torsion_moment,
1078 ),
1079 AnalysisField::host_f64(
1080 FEA_FIELD_STRUCTURAL_BEAM_BENDING_MOMENT,
1081 vec![element_count, 2],
1082 values.bending_moment,
1083 ),
1084 AnalysisField::host_f64(
1085 FEA_FIELD_STRUCTURAL_BEAM_BENDING_STRESS,
1086 vec![element_count, 2],
1087 values.bending_stress,
1088 ),
1089 AnalysisField::host_f64(
1090 FEA_FIELD_STRUCTURAL_BEAM_TORSION_STRESS,
1091 vec![element_count],
1092 values.torsion_stress,
1093 ),
1094 ])
1095}
1096
1097fn recover_beam_result_values(summary: &AssemblySummary, solution: &[f64]) -> BeamResultValues {
1098 let mut axial_force = Vec::with_capacity(summary.structural_beam_recovery.len());
1099 let mut shear_force = Vec::with_capacity(summary.structural_beam_recovery.len() * 2);
1100 let mut torsion_moment = Vec::with_capacity(summary.structural_beam_recovery.len());
1101 let mut bending_moment = Vec::with_capacity(summary.structural_beam_recovery.len() * 2);
1102 let mut bending_stress = Vec::with_capacity(summary.structural_beam_recovery.len() * 2);
1103 let mut torsion_stress = Vec::with_capacity(summary.structural_beam_recovery.len());
1104
1105 for beam in &summary.structural_beam_recovery {
1106 let q_global = beam_global_displacement(summary, solution, beam);
1107 let q_local = multiply_beam_matrix_vector(&beam.transform_global_to_local, &q_global);
1108 let k_local = local_stiffness_matrix(beam.section, beam.material, beam.length_m)
1109 .unwrap_or([[0.0; BEAM_ELEMENT_DOF_COUNT]; BEAM_ELEMENT_DOF_COUNT]);
1110 let f_local = multiply_beam_matrix_vector(&k_local, &q_local);
1111
1112 let axial = signed_peak_pair(f_local[0], f_local[6]);
1113 let shear_y = signed_peak_pair(f_local[1], f_local[7]);
1114 let shear_z = signed_peak_pair(f_local[2], f_local[8]);
1115 let torsion = signed_peak_pair(f_local[3], f_local[9]);
1116 let bending_y = signed_peak_pair(f_local[4], f_local[10]);
1117 let bending_z = signed_peak_pair(f_local[5], f_local[11]);
1118
1119 axial_force.push(axial);
1120 shear_force.extend([shear_y, shear_z]);
1121 torsion_moment.push(torsion);
1122 bending_moment.extend([bending_y, bending_z]);
1123 bending_stress.extend([
1124 bending_stress_from_moment(bending_y, beam.section.outer_fiber_z_m, beam.section.iy_m4),
1125 bending_stress_from_moment(bending_z, beam.section.outer_fiber_y_m, beam.section.iz_m4),
1126 ]);
1127 torsion_stress.push(torsion_stress_from_moment(
1128 torsion,
1129 beam.section.torsion_outer_radius_m,
1130 beam.section.torsion_j_m4,
1131 ));
1132 }
1133
1134 BeamResultValues {
1135 axial_force,
1136 shear_force,
1137 torsion_moment,
1138 bending_moment,
1139 bending_stress,
1140 torsion_stress,
1141 }
1142}
1143
1144fn beam_global_displacement(
1145 summary: &AssemblySummary,
1146 solution: &[f64],
1147 beam: &BeamRecoveryElementSummary,
1148) -> [f64; BEAM_ELEMENT_DOF_COUNT] {
1149 let mut q = [0.0; BEAM_ELEMENT_DOF_COUNT];
1150 for (node_offset, node_index) in [beam.node_i_index, beam.node_j_index].iter().enumerate() {
1151 for (component, kind) in StructuralDofKind::ORDER.iter().enumerate() {
1152 let target = node_offset * StructuralDofKind::ORDER.len() + component;
1153 q[target] = summary
1154 .structural_dof_layout
1155 .index(*node_index, *kind)
1156 .and_then(|row| solution.get(row).copied())
1157 .unwrap_or(0.0);
1158 }
1159 }
1160 q
1161}
1162
1163fn multiply_beam_matrix_vector(
1164 matrix: &[[f64; BEAM_ELEMENT_DOF_COUNT]; BEAM_ELEMENT_DOF_COUNT],
1165 vector: &[f64; BEAM_ELEMENT_DOF_COUNT],
1166) -> [f64; BEAM_ELEMENT_DOF_COUNT] {
1167 let mut out = [0.0; BEAM_ELEMENT_DOF_COUNT];
1168 for (row, out_value) in out.iter_mut().enumerate() {
1169 *out_value = matrix[row]
1170 .iter()
1171 .zip(vector.iter())
1172 .map(|(entry, value)| entry * value)
1173 .sum();
1174 }
1175 out
1176}
1177
1178fn signed_peak_pair(a: f64, b: f64) -> f64 {
1179 if a.abs() >= b.abs() {
1180 a
1181 } else {
1182 b
1183 }
1184}
1185
1186fn bending_stress_from_moment(moment_n_m: f64, outer_fiber_m: f64, inertia_m4: f64) -> f64 {
1187 if outer_fiber_m > 0.0 && inertia_m4 > 0.0 {
1188 moment_n_m * outer_fiber_m / inertia_m4
1189 } else {
1190 0.0
1191 }
1192}
1193
1194fn torsion_stress_from_moment(moment_n_m: f64, outer_radius_m: f64, torsion_j_m4: f64) -> f64 {
1195 if outer_radius_m > 0.0 && torsion_j_m4 > 0.0 {
1196 moment_n_m * outer_radius_m / torsion_j_m4
1197 } else {
1198 0.0
1199 }
1200}
1201
1202struct ShellResultValues {
1203 membrane_force: Vec<f64>,
1204 bending_moment: Vec<f64>,
1205 transverse_shear: Vec<f64>,
1206 von_mises: Vec<f64>,
1207}
1208
1209fn recover_shell_result_fields(
1210 summary: &AssemblySummary,
1211 solution: &[f64],
1212) -> Option<Vec<AnalysisField>> {
1213 if summary.structural_shell_recovery.is_empty() {
1214 return None;
1215 }
1216 let values = recover_shell_result_values(summary, solution);
1217 let element_count = summary.structural_shell_recovery.len();
1218 Some(vec![
1219 AnalysisField::host_f64(
1220 FEA_FIELD_STRUCTURAL_SHELL_MEMBRANE_FORCE,
1221 vec![element_count, 3],
1222 values.membrane_force,
1223 ),
1224 AnalysisField::host_f64(
1225 FEA_FIELD_STRUCTURAL_SHELL_BENDING_MOMENT,
1226 vec![element_count, 3],
1227 values.bending_moment,
1228 ),
1229 AnalysisField::host_f64(
1230 FEA_FIELD_STRUCTURAL_SHELL_TRANSVERSE_SHEAR,
1231 vec![element_count, 2],
1232 values.transverse_shear,
1233 ),
1234 AnalysisField::host_f64(
1235 FEA_FIELD_STRUCTURAL_SHELL_VON_MISES,
1236 vec![element_count],
1237 values.von_mises,
1238 ),
1239 ])
1240}
1241
1242fn recover_shell_result_values(summary: &AssemblySummary, solution: &[f64]) -> ShellResultValues {
1243 let mut membrane_force = Vec::with_capacity(summary.structural_shell_recovery.len() * 3);
1244 let mut bending_moment = Vec::with_capacity(summary.structural_shell_recovery.len() * 3);
1245 let mut transverse_shear = Vec::with_capacity(summary.structural_shell_recovery.len() * 2);
1246 let mut von_mises = Vec::with_capacity(summary.structural_shell_recovery.len());
1247
1248 for shell in &summary.structural_shell_recovery {
1249 let q = shell_node_values(summary, solution, shell);
1250 let edge_scale = shell_area_scale(shell.area_m2);
1251 let t = shell.section.thickness_m.max(1.0e-12);
1252 let e = shell.material.youngs_modulus_pa;
1253 let g = shell.material.shear_modulus_pa;
1254 let nu = shell.material.poisson_ratio;
1255 let membrane_stiffness = e * t / (1.0 - nu * nu).max(1.0e-9);
1256 let bending_stiffness = e * t.powi(3) / (12.0 * (1.0 - nu * nu).max(1.0e-9));
1257 let shear_stiffness = g * t * shell.section.shear_correction;
1258
1259 let ex = component_spread(&q, 0) * edge_scale;
1260 let ey = component_spread(&q, 1) * edge_scale;
1261 let gamma_xy = (component_spread(&q, 0) + component_spread(&q, 1)) * 0.5 * edge_scale;
1262 let kx = component_spread(&q, 3) * edge_scale;
1263 let ky = component_spread(&q, 4) * edge_scale;
1264 let kxy = component_spread(&q, 5) * edge_scale;
1265 let qx =
1266 shear_stiffness * (component_spread(&q, 2) * edge_scale + average_component(&q, 4));
1267 let qy =
1268 shear_stiffness * (component_spread(&q, 2) * edge_scale + average_component(&q, 3));
1269
1270 let nx = membrane_stiffness * (ex + nu * ey);
1271 let ny = membrane_stiffness * (ey + nu * ex);
1272 let nxy = g * t * gamma_xy;
1273 let mx = bending_stiffness * (kx + nu * ky);
1274 let my = bending_stiffness * (ky + nu * kx);
1275 let mxy = g * t.powi(3) * kxy / 12.0;
1276 let membrane_vm = (nx.powi(2) - nx * ny + ny.powi(2) + 3.0 * nxy.powi(2))
1277 .abs()
1278 .sqrt()
1279 / t;
1280 let bending_vm = 6.0
1281 * (mx.powi(2) - mx * my + my.powi(2) + 3.0 * mxy.powi(2))
1282 .abs()
1283 .sqrt()
1284 / t.powi(2);
1285
1286 membrane_force.extend([nx, ny, nxy]);
1287 bending_moment.extend([mx, my, mxy]);
1288 transverse_shear.extend([qx, qy]);
1289 von_mises.push(membrane_vm + bending_vm);
1290 }
1291
1292 ShellResultValues {
1293 membrane_force,
1294 bending_moment,
1295 transverse_shear,
1296 von_mises,
1297 }
1298}
1299
1300fn shell_node_values(
1301 summary: &AssemblySummary,
1302 solution: &[f64],
1303 shell: &ShellRecoveryElementSummary,
1304) -> [[f64; 6]; 3] {
1305 let mut out = [[0.0; 6]; 3];
1306 for (node_offset, node_index) in shell.node_indices.iter().enumerate() {
1307 for (component, kind) in StructuralDofKind::ORDER.iter().enumerate() {
1308 out[node_offset][component] = summary
1309 .structural_dof_layout
1310 .index(*node_index, *kind)
1311 .and_then(|row| solution.get(row).copied())
1312 .unwrap_or(0.0);
1313 }
1314 }
1315 out
1316}
1317
1318fn shell_area_scale(area_m2: f64) -> f64 {
1319 1.0 / area_m2.max(1.0e-18).sqrt()
1320}
1321
1322fn component_spread(values: &[[f64; 6]; 3], component: usize) -> f64 {
1323 let min = values
1324 .iter()
1325 .map(|value| value[component])
1326 .fold(f64::INFINITY, f64::min);
1327 let max = values
1328 .iter()
1329 .map(|value| value[component])
1330 .fold(f64::NEG_INFINITY, f64::max);
1331 max - min
1332}
1333
1334fn average_component(values: &[[f64; 6]; 3], component: usize) -> f64 {
1335 values.iter().map(|value| value[component]).sum::<f64>() / values.len() as f64
1336}
1337
1338fn recover_reaction_force(summary: &AssemblySummary, internal_force: &[f64]) -> Vec<f64> {
1339 let mut reactions = vec![0.0; reaction_shape(summary).iter().product()];
1340 let mut constrained_ordinal = 0usize;
1341 for (dof, is_constrained) in summary.operator.constrained.iter().enumerate() {
1342 if !*is_constrained {
1343 continue;
1344 }
1345 let row = constrained_ordinal / VECTOR_COMPONENT_COUNT;
1346 let component = constrained_ordinal % VECTOR_COMPONENT_COUNT;
1347 let rhs = summary.operator.rhs.get(dof).copied().unwrap_or(0.0);
1348 let internal = internal_force.get(dof).copied().unwrap_or(0.0);
1349 reactions[row * VECTOR_COMPONENT_COUNT + component] = internal - rhs;
1350 constrained_ordinal += 1;
1351 }
1352 reactions
1353}
1354
1355fn recover_rotation(summary: &AssemblySummary, solution: &[f64]) -> Vec<f64> {
1356 let mut rotation = vec![0.0; rotation_shape(summary).iter().product()];
1357 if rotation.is_empty() {
1358 return rotation;
1359 }
1360 for row in 0..summary.structural_dof_layout.total_dof_count() {
1361 let Some(address) = summary.structural_dof_layout.address(row) else {
1362 continue;
1363 };
1364 let Some(component) = rotational_component(address.kind) else {
1365 continue;
1366 };
1367 let target = address.node_index * VECTOR_COMPONENT_COUNT + component;
1368 if target < rotation.len() {
1369 rotation[target] = solution.get(row).copied().unwrap_or(0.0);
1370 }
1371 }
1372 rotation
1373}
1374
1375fn recover_displacement(
1376 summary: &AssemblySummary,
1377 solution: &[f64],
1378 node_count: usize,
1379) -> Vec<f64> {
1380 if summary.structural_rotational_dof_count == 0 {
1381 let mut displacement = solution.to_vec();
1382 displacement.resize(node_count * VECTOR_COMPONENT_COUNT, 0.0);
1383 return displacement;
1384 }
1385
1386 let mut displacement = vec![0.0; node_count * VECTOR_COMPONENT_COUNT];
1387 for row in 0..summary.structural_dof_layout.total_dof_count() {
1388 let Some(address) = summary.structural_dof_layout.address(row) else {
1389 continue;
1390 };
1391 let Some(component) = translational_component(address.kind) else {
1392 continue;
1393 };
1394 let target = address.node_index * VECTOR_COMPONENT_COUNT + component;
1395 if target < displacement.len() {
1396 displacement[target] = solution.get(row).copied().unwrap_or(0.0);
1397 }
1398 }
1399 displacement
1400}
1401
1402fn translational_component(kind: StructuralDofKind) -> Option<usize> {
1403 match kind {
1404 StructuralDofKind::Ux => Some(0),
1405 StructuralDofKind::Uy => Some(1),
1406 StructuralDofKind::Uz => Some(2),
1407 _ => None,
1408 }
1409}
1410
1411fn recover_reaction_moment(summary: &AssemblySummary, internal_force: &[f64]) -> Vec<f64> {
1412 let mut reactions = vec![0.0; rotation_shape(summary).iter().product()];
1413 if reactions.is_empty() {
1414 return reactions;
1415 }
1416 for (dof, is_constrained) in summary.operator.constrained.iter().enumerate() {
1417 if !*is_constrained {
1418 continue;
1419 }
1420 let Some(address) = summary.structural_dof_layout.address(dof) else {
1421 continue;
1422 };
1423 let Some(component) = rotational_component(address.kind) else {
1424 continue;
1425 };
1426 let target = address.node_index * VECTOR_COMPONENT_COUNT + component;
1427 if target < reactions.len() {
1428 let rhs = summary.operator.rhs.get(dof).copied().unwrap_or(0.0);
1429 let internal = internal_force.get(dof).copied().unwrap_or(0.0);
1430 reactions[target] = internal - rhs;
1431 }
1432 }
1433 reactions
1434}
1435
1436fn rotational_component(kind: StructuralDofKind) -> Option<usize> {
1437 match kind {
1438 StructuralDofKind::Rx => Some(0),
1439 StructuralDofKind::Ry => Some(1),
1440 StructuralDofKind::Rz => Some(2),
1441 _ => None,
1442 }
1443}
1444
1445fn rotation_shape(summary: &AssemblySummary) -> Vec<usize> {
1446 if summary.structural_rotational_dof_count == 0 {
1447 return vec![0, VECTOR_COMPONENT_COUNT];
1448 }
1449 vec![
1450 summary.structural_dof_layout.node_count(),
1451 VECTOR_COMPONENT_COUNT,
1452 ]
1453}
1454
1455fn structural_displacement_node_count(summary: &AssemblySummary, dof_count: usize) -> usize {
1456 if summary.structural_rotational_dof_count > 0 {
1457 summary.structural_node_count.max(1)
1458 } else {
1459 dof_count.div_ceil(VECTOR_COMPONENT_COUNT).max(1)
1460 }
1461}
1462
1463fn reaction_shape(summary: &AssemblySummary) -> Vec<usize> {
1464 let rows = summary
1465 .constrained_dof_count
1466 .div_ceil(VECTOR_COMPONENT_COUNT);
1467 vec![rows, VECTOR_COMPONENT_COUNT]
1468}
1469
1470fn recover_total_strain_energy(displacement: &[f64], internal_force: &[f64]) -> f64 {
1471 0.5 * displacement
1472 .iter()
1473 .zip(internal_force.iter())
1474 .map(|(u, force)| u * force)
1475 .sum::<f64>()
1476}
1477
1478struct StructuralResidualMetrics {
1479 normalized_residual_norm: f64,
1480 equation_scale: f64,
1481}
1482
1483fn recover_residual_metrics(
1484 summary: &AssemblySummary,
1485 displacement: &[f64],
1486) -> StructuralResidualMetrics {
1487 let mut solution = displacement.to_vec();
1488 solution.resize(summary.dof_count, 0.0);
1489 let applied = apply_k(&summary.operator, &solution);
1490 let residual_norm = applied
1491 .iter()
1492 .zip(summary.operator.rhs.iter())
1493 .map(|(lhs, rhs)| {
1494 let residual = lhs - rhs;
1495 residual * residual
1496 })
1497 .sum::<f64>()
1498 .sqrt();
1499 let equation_scale = summary
1500 .operator
1501 .rhs
1502 .iter()
1503 .map(|value| value * value)
1504 .sum::<f64>()
1505 .sqrt()
1506 .max(1.0);
1507 StructuralResidualMetrics {
1508 normalized_residual_norm: residual_norm / equation_scale,
1509 equation_scale,
1510 }
1511}
1512
1513#[cfg(test)]
1514mod tests {
1515 use super::*;
1516 use crate::{
1517 assembly::assemble_linear_system,
1518 fixtures::{fixture_model, FixtureId},
1519 };
1520 use runmat_meshing_core::{
1521 contracts::artifact::ANALYSIS_MESH_SCHEMA_VERSION, AnalysisMeshArtifact, AnalysisMeshNode,
1522 AnalysisMeshProvenance, AnalysisMeshQualityReport, AnalysisVolumeElement, MeshSizingField,
1523 VolumeElementKind,
1524 };
1525
1526 #[test]
1527 fn solid_tetrahedron4_recovery_uses_solver_mesh_field_shapes() {
1528 let model = fixture_model(FixtureId::CantileverLinearStatic);
1529 let summary = assemble_linear_system(&model, None, Some(tetrahedron4_mesh()), None, None);
1530 let solve = LinearSolveResult {
1531 iterations: 1,
1532 residual_norm: 0.0,
1533 converged: true,
1534 host_sync_count: 0,
1535 solver_backend: "test".to_string(),
1536 device_apply_k_count: 0,
1537 device_apply_k_attempt_count: 0,
1538 solution: vec![
1539 0.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0, 0.02, 0.0, 0.0, 0.0, 0.03,
1540 ],
1541 solver_method: "test".to_string(),
1542 preconditioner: "none".to_string(),
1543 diagnostics: Vec::new(),
1544 };
1545
1546 let fields = recover_result_fields(&summary, &solve);
1547 assert_eq!(
1548 field_shape(&fields, FEA_FIELD_STRUCTURAL_DISPLACEMENT),
1549 &[4, 3]
1550 );
1551 assert_eq!(field_shape(&fields, FEA_FIELD_STRUCTURAL_STRAIN), &[1, 6]);
1552 assert_eq!(field_shape(&fields, FEA_FIELD_STRUCTURAL_STRESS), &[1, 6]);
1553 assert_eq!(field_shape(&fields, FEA_FIELD_STRUCTURAL_VON_MISES), &[1]);
1554 assert_eq!(
1555 field_shape(&fields, FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY),
1556 &[1]
1557 );
1558 assert_eq!(
1559 field_shape(&fields, FEA_FIELD_STRUCTURAL_NODAL_VON_MISES),
1560 &[4]
1561 );
1562 assert!(host_field(&fields, FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY)[0] >= 0.0);
1563 let element_von_mises = host_field(&fields, FEA_FIELD_STRUCTURAL_VON_MISES)[0];
1564 assert!(host_field(&fields, FEA_FIELD_STRUCTURAL_NODAL_VON_MISES)
1565 .iter()
1566 .all(|value| (*value - element_von_mises).abs() <= 1.0e-12));
1567 let metrics = structural_field_recovery_metrics(&summary, &solve.solution);
1568 assert_eq!(metrics.basis, "solid_tetrahedron4_constant_strain");
1569 assert_eq!(metrics.solver_mesh_node_count, 4);
1570 assert_eq!(metrics.solver_mesh_element_count, 1);
1571 }
1572
1573 #[test]
1574 fn finite_nonconverged_solve_still_recovers_solver_mesh_fields() {
1575 let model = fixture_model(FixtureId::CantileverLinearStatic);
1576 let summary = assemble_linear_system(&model, None, Some(tetrahedron4_mesh()), None, None);
1577 let solve = LinearSolveResult {
1578 iterations: 10,
1579 residual_norm: 1.0e-4,
1580 converged: false,
1581 host_sync_count: 0,
1582 solver_backend: "test".to_string(),
1583 device_apply_k_count: 0,
1584 device_apply_k_attempt_count: 0,
1585 solution: vec![
1586 0.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0, 0.02, 0.0, 0.0, 0.0, 0.03,
1587 ],
1588 solver_method: "test".to_string(),
1589 preconditioner: "none".to_string(),
1590 diagnostics: Vec::new(),
1591 };
1592
1593 let fields = recover_result_fields(&summary, &solve);
1594
1595 assert_eq!(field_shape(&fields, FEA_FIELD_STRUCTURAL_VON_MISES), &[1]);
1596 assert_eq!(field_shape(&fields, FEA_FIELD_STRUCTURAL_STRAIN), &[1, 6]);
1597 assert_eq!(
1598 field_shape(&fields, FEA_FIELD_STRUCTURAL_DISPLACEMENT),
1599 &[4, 3]
1600 );
1601 }
1602
1603 fn field_shape<'a>(fields: &'a [AnalysisField], field_id: &str) -> &'a [usize] {
1604 fields
1605 .iter()
1606 .find(|field| field.field_id == field_id)
1607 .map(|field| field.shape.as_slice())
1608 .expect("field should be present")
1609 }
1610
1611 fn host_field<'a>(fields: &'a [AnalysisField], field_id: &str) -> &'a [f64] {
1612 fields
1613 .iter()
1614 .find(|field| field.field_id == field_id)
1615 .and_then(AnalysisField::as_host_f64)
1616 .expect("field should be present")
1617 }
1618
1619 fn tetrahedron4_mesh() -> AnalysisMeshArtifact {
1620 let mut mesh = AnalysisMeshArtifact {
1621 schema_version: ANALYSIS_MESH_SCHEMA_VERSION.to_string(),
1622 mesh_id: "unit_tetrahedron".to_string(),
1623 nodes: vec![
1624 node(1, [0.0, 0.0, 0.0]),
1625 node(2, [1.0, 0.0, 0.0]),
1626 node(3, [0.0, 1.0, 0.0]),
1627 node(4, [0.0, 0.0, 1.0]),
1628 ],
1629 volume_elements: vec![AnalysisVolumeElement {
1630 element_id: "tetrahedron_1".to_string(),
1631 kind: VolumeElementKind::Tetrahedron4,
1632 node_ids: vec![1, 2, 3, 4],
1633 material_region_id: "solid".to_string(),
1634 provenance: Vec::new(),
1635 }],
1636 boundary_faces: Vec::new(),
1637 boundary_edges: Vec::new(),
1638 quality: AnalysisMeshQualityReport::default(),
1639 sizing: MeshSizingField::default(),
1640 field_topology: Vec::new(),
1641 backend: Default::default(),
1642 adaptive_iterations: Vec::new(),
1643 provenance: AnalysisMeshProvenance {
1644 algorithm: "test".to_string(),
1645 source_geometry_id: "geo:test".to_string(),
1646 source_geometry_revision: 1,
1647 source_geometry_sha256: None,
1648 },
1649 };
1650 mesh.refresh_field_topology();
1651 mesh
1652 }
1653
1654 fn node(node_id: u32, coordinates_m: [f64; 3]) -> AnalysisMeshNode {
1655 AnalysisMeshNode {
1656 node_id,
1657 coordinates_m,
1658 provenance: Vec::new(),
1659 }
1660 }
1661}