Skip to main content

runmat_analysis_fea/post/
fields.rs

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}