1pub mod dofs;
2pub mod elements;
3pub mod solid;
4mod solid_boundary;
5
6use std::{collections::BTreeMap, fmt};
7
8use runmat_analysis_core::{
9 AnalysisModel, BeamSectionModel, BoundaryConditionKind, LoadKind, ShellSectionModel,
10 StructuralElementKind, StructuralModel,
11};
12use runmat_meshing_core::AnalysisMeshArtifact;
13use serde::{Deserialize, Serialize};
14
15use self::elements::solid::SolidMaterial;
16use self::{
17 dofs::{StructuralDofKind, StructuralDofLayout, StructuralNodeDofSet},
18 elements::beam::{
19 global_stiffness_matrix as beam_global_stiffness_matrix, transformation_matrix,
20 BeamElementGeometry, BeamMaterial, BeamSection, BeamTransform12, BEAM_ELEMENT_DOF_COUNT,
21 },
22 elements::shell::{
23 global_stiffness_matrix as shell_global_stiffness_matrix, ShellElementGeometry,
24 ShellMaterial, ShellSection, SHELL_ELEMENT_DOF_COUNT, SHELL_NODE_DOF_COUNT,
25 },
26 solid::{
27 assemble_solid_stiffness_csr_with_materials, solid_topology_from_analysis_mesh,
28 SolidAssemblyError,
29 },
30 solid_boundary::apply_analysis_mesh_structural_regions,
31};
32
33use crate::operator::{CsrMatrix, OperatorSystem};
34use crate::physics::coupling::thermo_mechanical;
35use crate::{
36 FeaElectroThermalContext, FeaPrepCalibrationProfile, FeaPrepContext, FeaThermoMechanicalContext,
37};
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct AssemblySummary {
41 pub dof_count: usize,
42 #[serde(default)]
43 pub structural_node_count: usize,
44 #[serde(default)]
45 pub structural_translational_dof_count: usize,
46 #[serde(default)]
47 pub structural_rotational_dof_count: usize,
48 #[serde(default)]
49 pub structural_rotation_node_count: usize,
50 #[serde(default)]
51 pub structural_moment_load_count: usize,
52 #[serde(default)]
53 pub structural_direct_rotational_moment_load_count: usize,
54 #[serde(default)]
55 pub structural_wrench_lowering: Vec<WrenchLoweringSummary>,
56 #[serde(default)]
57 pub structural_rotational_constraint_count: usize,
58 #[serde(default)]
59 pub structural_beam_element_count: usize,
60 #[serde(default)]
61 pub structural_shell_element_count: usize,
62 #[serde(default)]
63 pub structural_solid_element_count: usize,
64 #[serde(default)]
65 pub structural_solid_recovery: Vec<SolidRecoveryElementSummary>,
66 #[serde(default)]
67 pub structural_dof_layout: StructuralDofLayout,
68 #[serde(default)]
69 pub structural_beam_recovery: Vec<BeamRecoveryElementSummary>,
70 #[serde(default)]
71 pub structural_shell_recovery: Vec<ShellRecoveryElementSummary>,
72 pub constrained_dof_count: usize,
73 pub load_count: usize,
74 pub structural_material: StructuralMaterialSummary,
75 pub prep_assembly: Option<PrepAssemblySummary>,
76 pub prep_operator_topology: Option<PrepOperatorTopologySummary>,
77 pub prep_region_topology: Option<PrepRegionTopologySummary>,
78 pub prep_element_assembly: Option<PrepElementAssemblySummary>,
79 pub prep_element_connectivity: Option<PrepElementConnectivitySummary>,
80 pub prep_graph_assembly: Option<PrepGraphAssemblySummary>,
81 pub prep_recovery_edges: Vec<PrepRecoveryEdgeSummary>,
82 pub prep_calibration: Option<PrepCalibrationSummary>,
83 pub prep_acceptance: Option<PrepAcceptanceSummary>,
84 pub prep_coordinates: Option<PrepCoordinateSummary>,
85 pub thermo_mechanical: Option<ThermoMechanicalAssemblySummary>,
86 pub electro_thermal: Option<ElectroThermalAssemblySummary>,
87 pub operator: OperatorSystem,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum LinearAssemblyError {
92 SolidStiffness(SolidAssemblyError),
93 AnalysisMeshRegionMapping(AnalysisMeshRegionMappingError),
94}
95
96impl fmt::Display for LinearAssemblyError {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 match self {
99 LinearAssemblyError::SolidStiffness(err) => {
100 write!(f, "solid stiffness assembly failed: {err:?}")
101 }
102 LinearAssemblyError::AnalysisMeshRegionMapping(err) => {
103 write!(f, "{err}")
104 }
105 }
106 }
107}
108
109impl std::error::Error for LinearAssemblyError {}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum AnalysisMeshRegionMappingError {
113 UnmappedLoadRegion {
114 load_id: String,
115 region_id: String,
116 load_kind: &'static str,
117 },
118 UnmappedBoundaryConditionRegion {
119 bc_id: String,
120 region_id: String,
121 boundary_condition_kind: &'static str,
122 },
123}
124
125impl fmt::Display for AnalysisMeshRegionMappingError {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 match self {
128 AnalysisMeshRegionMappingError::UnmappedLoadRegion {
129 load_id,
130 region_id,
131 load_kind,
132 } => write!(
133 f,
134 "analysis mesh load region did not resolve to solver boundary entities: load_id={load_id} region_id={region_id} load_kind={load_kind}"
135 ),
136 AnalysisMeshRegionMappingError::UnmappedBoundaryConditionRegion {
137 bc_id,
138 region_id,
139 boundary_condition_kind,
140 } => write!(
141 f,
142 "analysis mesh boundary condition region did not resolve to solver boundary entities: bc_id={bc_id} region_id={region_id} boundary_condition_kind={boundary_condition_kind}"
143 ),
144 }
145 }
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
149pub struct StructuralMaterialSummary {
150 pub youngs_modulus_pa: f64,
151 pub poisson_ratio: f64,
152 #[serde(default)]
153 pub density_kg_per_m3: f64,
154 pub lame_lambda_pa: f64,
155 pub shear_modulus_pa: f64,
156}
157
158#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
159pub struct BeamRecoveryElementSummary {
160 pub element_id: String,
161 pub region_id: String,
162 pub node_i_index: usize,
163 pub node_j_index: usize,
164 pub length_m: f64,
165 pub section: BeamSection,
166 pub material: BeamMaterial,
167 pub transform_global_to_local: BeamTransform12,
168}
169
170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct ShellRecoveryElementSummary {
172 pub element_id: String,
173 pub region_id: String,
174 pub node_indices: [usize; 3],
175 pub area_m2: f64,
176 pub section: ShellSection,
177 pub material: ShellMaterial,
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181pub struct SolidRecoveryElementSummary {
182 pub element_id: String,
183 pub region_id: String,
184 pub node_indices: [usize; 4],
185 pub coordinates_m: [[f64; 3]; 4],
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct WrenchLoweringSummary {
190 pub load_id: String,
191 pub region_id: String,
192 pub target_node_count: usize,
193 pub applied_force: [f64; 3],
194 pub applied_moment_at_point: [f64; 3],
195 pub force_residual: [f64; 3],
196 pub moment_residual: [f64; 3],
197 pub moment_couple_applied: bool,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct PrepAssemblySummary {
202 pub active_region_count: usize,
203 pub mapped_load_count: usize,
204 pub mapped_bc_count: usize,
205 pub mapped_load_ratio: f64,
206 pub constrained_prep_ratio: f64,
207 pub layout_seed: u64,
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct PrepOperatorTopologySummary {
212 pub stiffness_scale: f64,
213 pub mass_scale: f64,
214 pub damping_scale: f64,
215 pub rhs_scale: f64,
216 pub coupling_nonzero_ratio: f64,
217 pub stiffness_spread_ratio: f64,
218 pub topology_fingerprint: u64,
219}
220
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct PrepRegionTopologySummary {
223 pub region_block_count: usize,
224 pub inter_block_edge_count: usize,
225 pub coupling_nonzero_ratio: f64,
226 pub block_size_min: usize,
227 pub block_size_max: usize,
228 pub block_size_mean: f64,
229 pub region_topology_fingerprint: u64,
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233pub struct PrepElementAssemblySummary {
234 pub assembled_element_count: usize,
235 pub triangle_element_count: usize,
236 pub quad_element_count: usize,
237 pub tetrahedron_element_count: usize,
238 pub hex_element_count: usize,
239 pub mixed_element_count: usize,
240 pub scatter_nnz_count: usize,
241 pub assembly_fingerprint: u64,
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
245pub struct PrepElementConnectivitySummary {
246 pub assembled_element_count: usize,
247 pub stiffness_offdiag_nnz_count: usize,
248 pub mass_offdiag_nnz_count: usize,
249 pub damping_offdiag_nnz_count: usize,
250 pub triangle_contrib_share: f64,
251 pub quad_contrib_share: f64,
252 pub tetrahedron_contrib_share: f64,
253 pub hex_contrib_share: f64,
254 pub mixed_contrib_share: f64,
255 pub mean_connectivity_hop: f64,
256 pub connectivity_fingerprint: u64,
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
260pub struct PrepGraphAssemblySummary {
261 pub node_count: usize,
262 pub edge_count: usize,
263 pub degree_min: usize,
264 pub degree_max: usize,
265 pub degree_mean: f64,
266 pub degree_p95: f64,
267 pub fill_ratio: f64,
268 pub connected_component_count: usize,
269 pub ordering_bandwidth_before: usize,
270 pub ordering_bandwidth_after: usize,
271 pub ordering_reduction_ratio: f64,
272 pub ordering_fingerprint: u64,
273 pub recommend_ilu0: bool,
274 pub graph_fingerprint: u64,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
278pub struct PrepRecoveryEdgeSummary {
279 pub from_dof: usize,
280 pub to_dof: usize,
281 pub element_family_index: usize,
282 pub edge_length_m: f64,
283}
284
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286pub struct PrepCalibrationSummary {
287 pub profile: String,
288 pub triangle_weight: f64,
289 pub quad_weight: f64,
290 pub tetrahedron_weight: f64,
291 pub hex_weight: f64,
292 pub mixed_weight: f64,
293 pub stiffness_calibration_scale: f64,
294 pub mass_calibration_scale: f64,
295 pub damping_calibration_scale: f64,
296 pub calibration_fingerprint: u64,
297}
298
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct PrepAcceptanceSummary {
301 pub profile: String,
302 pub accepted: bool,
303 pub bounded_displacement_scale: bool,
304 pub bounded_stress_scale: bool,
305 pub bounded_connectivity_fill: bool,
306 pub acceptance_score: f64,
307 pub acceptance_fingerprint: u64,
308}
309
310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
311pub struct PrepCoordinateSummary {
312 pub span_m: [f64; 3],
313 pub active_dimension_count: usize,
314 pub characteristic_length_m: f64,
315 pub element_geometry_node_count: usize,
316 pub element_geometry_edge_count: usize,
317 pub mean_element_edge_length_m: f64,
318 pub mean_element_area_m2: f64,
319 pub element_geometry_coverage_ratio: f64,
320 pub reference_element_coordinates_m: [[f64; 3]; 3],
321 pub reference_element_area_m2: f64,
322 pub element_topology_sample_element_count: usize,
323 pub element_topology_sample_edge_count: usize,
324 pub element_topology_sample_edge_nodes: [[u32; 2]; 8],
325 pub element_topology_sample_node_coordinates_m: [[f64; 3]; 8],
326 pub element_topology_sample_element_edges: [[u32; 3]; 4],
327 pub element_topology_sample_element_orientations: [[i8; 3]; 4],
328 pub element_topology_sample_element_areas_m2: [f64; 4],
329 pub element_topology_node_coordinates_m: Vec<[f64; 3]>,
330 pub element_topology_edge_nodes: Vec<[u32; 2]>,
331 pub element_topology_element_edges: Vec<[u32; 3]>,
332 pub element_topology_element_orientations: Vec<[i8; 3]>,
333 pub element_topology_element_areas_m2: Vec<f64>,
334}
335
336#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
337pub struct ThermoMechanicalAssemblySummary {
338 pub enabled: bool,
339 pub reference_temperature_k: f64,
340 pub applied_temperature_delta_k: f64,
341 pub thermal_expansion_coefficient: f64,
342 pub thermal_strain_scale: f64,
343 pub thermal_load_scale: f64,
344 pub constitutive_temperature_factor: f64,
345 pub constitutive_poisson_coupling: f64,
346 pub effective_modulus_scale: f64,
347 pub constitutive_material_spread_ratio: f64,
348 pub assignment_heterogeneity_index: f64,
349 pub spatial_gradient_index: f64,
350 pub spatial_coverage_ratio: f64,
351 pub temporal_profile_variation: f64,
352 pub region_delta_count: usize,
353 pub coupling_fingerprint: u64,
354}
355
356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
357pub struct ElectroThermalAssemblySummary {
358 pub enabled: bool,
359 pub reference_temperature_k: f64,
360 pub applied_voltage_v: f64,
361 pub base_electrical_conductivity_s_per_m: f64,
362 pub resistive_heating_coefficient: f64,
363 pub joule_heating_scale: f64,
364 pub conductivity_spread_ratio: f64,
365 pub temporal_profile_variation: f64,
366 pub region_scale_count: usize,
367 pub coupling_fingerprint: u64,
368 #[serde(default)]
369 pub prep_recovery_edges: Vec<PrepRecoveryEdgeSummary>,
370 #[serde(default)]
371 pub prep_coordinates: Option<PrepCoordinateSummary>,
372}
373
374pub fn assemble_linear_system(
375 model: &AnalysisModel,
376 prep_context: Option<FeaPrepContext>,
377 analysis_mesh: Option<AnalysisMeshArtifact>,
378 thermo_mechanical_context: Option<FeaThermoMechanicalContext>,
379 electro_thermal_context: Option<FeaElectroThermalContext>,
380) -> AssemblySummary {
381 assemble_linear_system_impl(
382 model,
383 prep_context,
384 analysis_mesh,
385 thermo_mechanical_context,
386 electro_thermal_context,
387 false,
388 )
389 .expect("non-strict assembly should build operator topology")
390}
391
392pub fn try_assemble_linear_system(
393 model: &AnalysisModel,
394 prep_context: Option<FeaPrepContext>,
395 analysis_mesh: Option<AnalysisMeshArtifact>,
396 thermo_mechanical_context: Option<FeaThermoMechanicalContext>,
397 electro_thermal_context: Option<FeaElectroThermalContext>,
398) -> Result<AssemblySummary, LinearAssemblyError> {
399 assemble_linear_system_impl(
400 model,
401 prep_context,
402 analysis_mesh,
403 thermo_mechanical_context,
404 electro_thermal_context,
405 true,
406 )
407}
408
409fn assemble_linear_system_impl(
410 model: &AnalysisModel,
411 prep_context: Option<FeaPrepContext>,
412 analysis_mesh: Option<AnalysisMeshArtifact>,
413 thermo_mechanical_context: Option<FeaThermoMechanicalContext>,
414 electro_thermal_context: Option<FeaElectroThermalContext>,
415 strict_analysis_mesh_stiffness: bool,
416) -> Result<AssemblySummary, LinearAssemblyError> {
417 if analysis_mesh.is_none() {
418 if let Some(summary) = assemble_beam_system(model) {
419 return Ok(summary);
420 }
421 }
422
423 let base_dof_count = (model.loads.len() * 3).max(3);
424 let prep_context_ref = prep_context.as_ref();
425 let solid_topology = analysis_mesh
426 .as_ref()
427 .and_then(|mesh| solid_topology_from_analysis_mesh(mesh, base_dof_count).ok());
428 let dof_count = solid_topology
429 .as_ref()
430 .map(|topology| topology.dof_count)
431 .unwrap_or(base_dof_count);
432 let structural_solid_recovery = analysis_mesh
433 .as_ref()
434 .map(solid_recovery_from_analysis_mesh)
435 .unwrap_or_default();
436
437 let avg_youngs_modulus = if model.materials.is_empty() {
438 1.0e9
439 } else {
440 model
441 .materials
442 .iter()
443 .map(|material| material.mechanical.youngs_modulus_pa.max(1.0))
444 .sum::<f64>()
445 / model.materials.len() as f64
446 };
447 let avg_poisson_ratio = if model.materials.is_empty() {
448 0.3
449 } else {
450 model
451 .materials
452 .iter()
453 .map(|material| material.mechanical.poisson_ratio.clamp(0.0, 0.49))
454 .sum::<f64>()
455 / model.materials.len() as f64
456 };
457 let avg_reference_temperature_k = if model.materials.is_empty() {
458 293.15
459 } else {
460 model
461 .materials
462 .iter()
463 .map(|material| material.thermal.reference_temperature_k)
464 .sum::<f64>()
465 / model.materials.len() as f64
466 };
467 let shear_modulus_pa = avg_youngs_modulus / (2.0 * (1.0 + avg_poisson_ratio)).max(1.0e-9);
468 let avg_density_kg_per_m3 = if model.materials.is_empty() {
469 7850.0
470 } else {
471 model
472 .materials
473 .iter()
474 .map(|material| material.mechanical.density_kg_per_m3.max(1.0))
475 .sum::<f64>()
476 / model.materials.len() as f64
477 };
478 let lame_lambda_pa = avg_youngs_modulus * avg_poisson_ratio
479 / ((1.0 + avg_poisson_ratio) * (1.0 - 2.0 * avg_poisson_ratio)).max(1.0e-9);
480 let structural_material = StructuralMaterialSummary {
481 youngs_modulus_pa: avg_youngs_modulus,
482 poisson_ratio: avg_poisson_ratio,
483 density_kg_per_m3: avg_density_kg_per_m3,
484 lame_lambda_pa,
485 shear_modulus_pa,
486 };
487 let stiffness_base = (avg_youngs_modulus / 2.0e3).max(1.0e5);
488
489 let mut stiffness_diag = vec![0.0; dof_count];
490 let mut stiffness_upper = vec![0.0; dof_count.saturating_sub(1)];
491 let mut mass_diag = vec![0.0; dof_count];
492 let mut damping_diag = vec![0.0; dof_count];
493 for i in 0..dof_count {
494 let factor = 1.0 + (i as f64) * 0.05;
495 stiffness_diag[i] = stiffness_base * factor;
496 mass_diag[i] = 1.0 + (i as f64) * 0.01;
497 damping_diag[i] = 0.05 * factor;
498 }
499
500 let mut rhs = vec![0.0; dof_count];
501 let load_base_index = |i: usize, prep: Option<&FeaPrepContext>| -> usize {
502 if let Some(prep) = prep {
503 let stride = (1 + prep.mapped_load_count.max(1)).min(dof_count.max(1));
504 let offset = (prep.layout_seed as usize) % dof_count.max(1);
505 (offset + i.saturating_mul(stride)) % dof_count.max(1)
506 } else {
507 (i * 3) % dof_count
508 }
509 };
510 for (i, load) in model.loads.iter().enumerate() {
511 let base = load_base_index(i, prep_context_ref);
512 match &load.kind {
513 runmat_analysis_core::LoadKind::Force { fx, fy, fz } => {
514 rhs[base] += *fx;
515 if base + 1 < dof_count {
516 rhs[base + 1] += *fy;
517 }
518 if base + 2 < dof_count {
519 rhs[base + 2] += *fz;
520 }
521 }
522 runmat_analysis_core::LoadKind::Moment { .. } => {}
523 runmat_analysis_core::LoadKind::Wrench { fx, fy, fz, .. } => {
524 rhs[base] += *fx;
525 if base + 1 < dof_count {
526 rhs[base + 1] += *fy;
527 }
528 if base + 2 < dof_count {
529 rhs[base + 2] += *fz;
530 }
531 }
532 runmat_analysis_core::LoadKind::Pressure { magnitude_pa } => {
533 rhs[base] += magnitude_pa * 1.0e-3;
534 if base + 1 < dof_count {
535 rhs[base + 1] -= magnitude_pa * 1.0e-3;
536 }
537 }
538 runmat_analysis_core::LoadKind::BodyForce { gx, gy, gz } => {
539 rhs[base] += *gx;
540 if base + 1 < dof_count {
541 rhs[base + 1] += *gy;
542 }
543 if base + 2 < dof_count {
544 rhs[base + 2] += *gz;
545 }
546 }
547 runmat_analysis_core::LoadKind::CurrentDensity { jx, jy, jz, .. } => {
548 rhs[base] += *jx * 1.0e-3;
549 if base + 1 < dof_count {
550 rhs[base + 1] += *jy * 1.0e-3;
551 }
552 if base + 2 < dof_count {
553 rhs[base + 2] += *jz * 1.0e-3;
554 }
555 }
556 runmat_analysis_core::LoadKind::CoilCurrent { current_a, .. } => {
557 rhs[base] += *current_a * 1.0e-2;
558 }
559 runmat_analysis_core::LoadKind::HeatSource { .. } => {}
560 }
561 }
562
563 let structural_dof_layout = StructuralDofLayout::legacy_translational_rows(dof_count);
564 let structural_moment_load_count = model
565 .loads
566 .iter()
567 .filter(|load| matches!(load.kind, runmat_analysis_core::LoadKind::Moment { .. }))
568 .count();
569 let structural_direct_rotational_moment_load_count =
570 if structural_dof_layout.has_rotational_dofs() {
571 structural_moment_load_count
572 } else {
573 0
574 };
575
576 let legacy_constrained_dof_count = model.boundary_conditions.len().min(dof_count);
577 let mut constrained = vec![false; dof_count];
578 let constraint_offset = prep_context_ref
579 .map(|prep| (prep.layout_seed as usize) % dof_count.max(1))
580 .unwrap_or(0);
581 for idx in 0..legacy_constrained_dof_count {
582 let dof = (constraint_offset + idx * 2) % dof_count.max(1);
583 constrained[dof] = true;
584 rhs[dof] = 0.0;
585 }
586 let mut structural_wrench_lowering = Vec::new();
587 if let (Some(mesh), Some(_)) = (analysis_mesh.as_ref(), solid_topology.as_ref()) {
588 structural_wrench_lowering = apply_analysis_mesh_structural_regions(
589 model,
590 mesh,
591 &structural_dof_layout,
592 &mut constrained,
593 &mut rhs,
594 strict_analysis_mesh_stiffness,
595 )
596 .map_err(LinearAssemblyError::AnalysisMeshRegionMapping)?;
597 }
598
599 let mut prep_load_bonus = 0usize;
600 let mut prep_assembly = None;
601 let mut prep_operator_topology = None;
602 let mut prep_region_topology = None;
603 let mut prep_element_assembly = None;
604 let mut prep_element_connectivity = None;
605 let mut prep_graph_assembly = None;
606 let mut prep_recovery_edges = Vec::new();
607 let mut prep_calibration = None;
608 let mut prep_acceptance = None;
609 let mut prep_coordinates = None;
610 let mut thermo_mechanical = None;
611 let mut electro_thermal = None;
612 let mut topology_stiffness_scale = 1.0;
613 let mut topology_mass_scale = 1.0;
614 let mut topology_damping_scale = 1.0;
615 let mut topology_rhs_scale = 1.0;
616 let mut topology_coupling_scale = 1.0;
617 let mut topology_coupling_anisotropy = 1.0;
618 let mut region_block_sizes = Vec::new();
619 let mut region_boundary_positions = Vec::new();
620 let mut region_coupling_weight = 1.0;
621 if let Some(prep) = prep_context_ref {
622 let mesh_scale = 1.0 + (prep.prepared_mesh_count.min(32) as f64) * 0.01;
623 let density = if prep.prepared_node_count == 0 {
624 1.0
625 } else {
626 prep.prepared_element_count as f64 / prep.prepared_node_count as f64
627 };
628 let quality_scale = (prep.min_scaled_jacobian.clamp(0.5, 1.0)
629 / prep.mean_aspect_ratio.clamp(1.0, 4.0))
630 .clamp(0.25, 1.0);
631 let stiffness_scale =
632 (mesh_scale * (1.0 + 0.05 * density.min(2.0)) * quality_scale).clamp(0.5, 1.5);
633 let rhs_scale = (1.0 / quality_scale).clamp(1.0, 2.0);
634 let region_span_norm = (prep.topology_region_span_mean / 12.0).clamp(0.0, 1.0);
635 topology_stiffness_scale = (1.0
636 + 0.12 * prep.topology_volume_core_ratio
637 + 0.06 * prep.topology_surface_patch_ratio
638 + 0.05 * prep.mapped_region_participation_ratio
639 - 0.08 * prep.topology_mixed_family_ratio)
640 .clamp(0.85, 1.25);
641 topology_mass_scale =
642 (1.0 + 0.05 * prep.topology_surface_patch_ratio + 0.04 * region_span_norm
643 - 0.04 * prep.topology_mixed_family_ratio)
644 .clamp(0.9, 1.2);
645 topology_damping_scale = (1.0
646 + 0.03 * prep.topology_volume_core_ratio
647 + 0.05 * prep.topology_mixed_family_ratio
648 + 0.02 * prep.mapped_region_participation_ratio)
649 .clamp(0.9, 1.2);
650 topology_rhs_scale = (1.0
651 + 0.04 * prep.topology_surface_patch_ratio
652 + 0.03 * prep.topology_region_span_mean.clamp(1.0, 24.0) / 24.0)
653 .clamp(1.0, 1.18);
654 topology_coupling_scale = (1.0
655 + 0.12 * prep.topology_volume_core_ratio
656 + 0.08 * prep.mapped_region_participation_ratio
657 - 0.06 * prep.topology_mixed_family_ratio)
658 .clamp(0.8, 1.3);
659 topology_coupling_anisotropy =
660 (1.0 - 0.18 * prep.topology_mixed_family_ratio).clamp(0.78, 1.0);
661 region_coupling_weight = (1.0
662 + 0.10 * prep.mapped_region_participation_ratio
663 + 0.05 * prep.topology_region_mesh_variance.clamp(0.0, 6.0) / 6.0
664 - 0.04 * prep.topology_mixed_family_ratio)
665 .clamp(0.85, 1.2);
666 let region_block_count = prep.topology_region_block_count.clamp(1, dof_count.max(1));
667 region_block_sizes = build_region_block_sizes(
668 dof_count,
669 region_block_count,
670 prep.layout_seed,
671 prep.topology_region_mesh_mean,
672 prep.topology_region_mesh_variance,
673 prep.mapped_region_participation_ratio,
674 );
675 let region_block_offsets = block_offsets(®ion_block_sizes);
676 region_boundary_positions = region_block_offsets
677 .iter()
678 .skip(1)
679 .map(|offset| offset.saturating_sub(1))
680 .filter(|index| *index < dof_count.saturating_sub(1))
681 .collect::<Vec<_>>();
682
683 for (block_index, offset) in region_block_offsets.iter().enumerate() {
684 let block_size = region_block_sizes[block_index];
685 let block_end = offset.saturating_add(block_size).min(dof_count);
686 let block_bias = block_bias(prep.layout_seed, block_index)
687 * (0.04 + 0.02 * prep.topology_region_mesh_mean.clamp(1.0, 6.0) / 6.0);
688 let stiffness_block_scale = (1.0 + block_bias).clamp(0.9, 1.1);
689 let mass_block_scale = (1.0 + 0.6 * block_bias).clamp(0.92, 1.08);
690 let damping_block_scale = (1.0 + 0.8 * block_bias).clamp(0.9, 1.1);
691 for idx in *offset..block_end {
692 stiffness_diag[idx] *= stiffness_block_scale;
693 mass_diag[idx] *= mass_block_scale;
694 damping_diag[idx] *= damping_block_scale;
695 }
696 }
697
698 for value in &mut stiffness_diag {
699 *value *= stiffness_scale * topology_stiffness_scale;
700 }
701 for value in &mut mass_diag {
702 *value *= (1.0 + 0.02 * density.min(2.0)).clamp(1.0, 1.04) * topology_mass_scale;
703 }
704 for value in &mut damping_diag {
705 *value *= (1.0 + 0.03 * prep.mean_aspect_ratio.min(3.0)).clamp(1.0, 1.09)
706 * topology_damping_scale;
707 }
708 for value in &mut rhs {
709 *value *= rhs_scale * topology_rhs_scale;
710 }
711 prep_element_assembly = Some(apply_prep_native_element_assembly(
712 prep,
713 dof_count,
714 &constrained,
715 &mut stiffness_diag,
716 &mut mass_diag,
717 &mut damping_diag,
718 &mut rhs,
719 ));
720 prep_load_bonus = prep
721 .mapped_region_count
722 .saturating_add(prep.inverted_element_count.min(8));
723 prep_coordinates = Some(PrepCoordinateSummary {
724 span_m: [
725 prep.coordinate_span_x_m,
726 prep.coordinate_span_y_m,
727 prep.coordinate_span_z_m,
728 ],
729 active_dimension_count: prep.coordinate_active_dimension_count.max(1),
730 characteristic_length_m: prep.coordinate_characteristic_length_m,
731 element_geometry_node_count: prep.element_geometry_node_count,
732 element_geometry_edge_count: prep.element_geometry_edge_count,
733 mean_element_edge_length_m: prep.mean_element_edge_length_m,
734 mean_element_area_m2: prep.mean_element_area_m2,
735 element_geometry_coverage_ratio: prep.element_geometry_coverage_ratio,
736 reference_element_coordinates_m: prep.reference_element_coordinates_m,
737 reference_element_area_m2: prep.reference_element_area_m2,
738 element_topology_sample_element_count: prep.element_topology_sample_element_count,
739 element_topology_sample_edge_count: prep.element_topology_sample_edge_count,
740 element_topology_sample_edge_nodes: prep.element_topology_sample_edge_nodes,
741 element_topology_sample_node_coordinates_m: prep
742 .element_topology_sample_node_coordinates_m,
743 element_topology_sample_element_edges: prep.element_topology_sample_element_edges,
744 element_topology_sample_element_orientations: prep
745 .element_topology_sample_element_orientations,
746 element_topology_sample_element_areas_m2: prep.element_topology_sample_element_areas_m2,
747 element_topology_node_coordinates_m: prep.element_topology_node_coordinates_m.clone(),
748 element_topology_edge_nodes: prep.element_topology_edge_nodes.clone(),
749 element_topology_element_edges: prep.element_topology_element_edges.clone(),
750 element_topology_element_orientations: prep
751 .element_topology_element_orientations
752 .clone(),
753 element_topology_element_areas_m2: prep.element_topology_element_areas_m2.clone(),
754 });
755
756 prep_assembly = Some(PrepAssemblySummary {
757 active_region_count: prep.mapped_region_count,
758 mapped_load_count: prep.mapped_load_count,
759 mapped_bc_count: prep.mapped_bc_count,
760 mapped_load_ratio: if model.loads.is_empty() {
761 0.0
762 } else {
763 prep.mapped_load_count as f64 / model.loads.len() as f64
764 },
765 constrained_prep_ratio: if model.boundary_conditions.is_empty() {
766 0.0
767 } else {
768 prep.mapped_bc_count as f64 / model.boundary_conditions.len() as f64
769 },
770 layout_seed: prep.layout_seed,
771 });
772 }
773
774 let bandwidth_stride = prep_context_ref
775 .map(|prep| prep.topology_bandwidth_estimate.max(1) as usize)
776 .unwrap_or(1);
777 for i in 0..stiffness_upper.len() {
778 let at_region_boundary = region_boundary_positions.binary_search(&i).is_ok();
779 let local_coupling_scale = if i % 2 == 0 {
780 topology_coupling_scale
781 } else {
782 topology_coupling_scale * topology_coupling_anisotropy
783 };
784 let region_boundary_scale = if at_region_boundary {
785 region_coupling_weight * 0.75
786 } else {
787 region_coupling_weight
788 };
789 let coupling = 0.05
790 * stiffness_diag[i].min(stiffness_diag[i + 1])
791 * local_coupling_scale
792 * region_boundary_scale;
793 let in_sparse_band = bandwidth_stride <= 1 || (i % bandwidth_stride != 0);
794 stiffness_upper[i] = if constrained[i] || constrained[i + 1] || !in_sparse_band {
795 0.0
796 } else {
797 coupling
798 };
799 }
800
801 if let Some(prep) = prep_context_ref {
802 if let Some(element_summary) = prep_element_assembly.as_ref() {
803 let (connectivity_summary, graph_summary, recovery_edges) =
804 apply_prep_element_connectivity_scatter(
805 prep,
806 &constrained,
807 &mut stiffness_upper,
808 &mut mass_diag,
809 &mut damping_diag,
810 element_summary,
811 );
812 prep_element_connectivity = Some(connectivity_summary);
813 prep_graph_assembly = Some(graph_summary);
814 prep_recovery_edges = recovery_edges;
815 }
816 if let Some(calibration) = apply_prep_calibration(
817 prep,
818 avg_youngs_modulus,
819 prep_graph_assembly.as_ref(),
820 &mut stiffness_diag,
821 &mut mass_diag,
822 &mut damping_diag,
823 &mut rhs,
824 ) {
825 let acceptance = evaluate_prep_acceptance(
826 prep,
827 &calibration,
828 prep_graph_assembly.as_ref(),
829 &stiffness_diag,
830 );
831 prep_calibration = Some(calibration);
832 prep_acceptance = Some(acceptance);
833 }
834 let coupling_nonzero_ratio = if stiffness_upper.is_empty() {
835 0.0
836 } else {
837 stiffness_upper
838 .iter()
839 .filter(|value| value.abs() > 0.0)
840 .count() as f64
841 / stiffness_upper.len() as f64
842 };
843 let max_stiffness = stiffness_diag.iter().copied().fold(0.0_f64, f64::max);
844 let min_stiffness = stiffness_diag
845 .iter()
846 .copied()
847 .filter(|value| *value > 0.0)
848 .fold(f64::INFINITY, f64::min);
849 let stiffness_spread_ratio = if min_stiffness.is_finite() && min_stiffness > 0.0 {
850 max_stiffness / min_stiffness
851 } else {
852 0.0
853 };
854 prep_operator_topology = Some(PrepOperatorTopologySummary {
855 stiffness_scale: topology_stiffness_scale,
856 mass_scale: topology_mass_scale,
857 damping_scale: topology_damping_scale,
858 rhs_scale: topology_rhs_scale,
859 coupling_nonzero_ratio,
860 stiffness_spread_ratio,
861 topology_fingerprint: topology_fingerprint(
862 prep,
863 topology_stiffness_scale,
864 topology_mass_scale,
865 topology_damping_scale,
866 topology_rhs_scale,
867 coupling_nonzero_ratio,
868 stiffness_spread_ratio,
869 ),
870 });
871
872 if !region_block_sizes.is_empty() {
873 let block_size_min = region_block_sizes.iter().copied().min().unwrap_or(0);
874 let block_size_max = region_block_sizes.iter().copied().max().unwrap_or(0);
875 let block_size_mean =
876 region_block_sizes.iter().sum::<usize>() as f64 / region_block_sizes.len() as f64;
877 let inter_block_edge_count = region_boundary_positions.len();
878 prep_region_topology = Some(PrepRegionTopologySummary {
879 region_block_count: region_block_sizes.len(),
880 inter_block_edge_count,
881 coupling_nonzero_ratio,
882 block_size_min,
883 block_size_max,
884 block_size_mean,
885 region_topology_fingerprint: region_topology_fingerprint(
886 prep,
887 ®ion_block_sizes,
888 inter_block_edge_count,
889 coupling_nonzero_ratio,
890 ),
891 });
892 }
893 }
894
895 if let Some(context) = thermo_mechanical_context {
896 if context.enabled {
897 let thermal_strain_scale = (context.thermal_expansion_coefficient
898 * context.applied_temperature_delta_k.abs())
899 .clamp(0.0, 0.05);
900 let thermal_load_scale = (context.applied_temperature_delta_k / 50.0).clamp(-2.0, 2.0);
901 let constitutive_temperature_factor = if model.materials.is_empty() {
902 (-(2.5e-4) * context.applied_temperature_delta_k).clamp(-0.25, 0.25)
903 } else {
904 let response = model
905 .materials
906 .iter()
907 .map(|material| {
908 let adjusted_delta = context.applied_temperature_delta_k
909 + (context.reference_temperature_k
910 - material.thermal.reference_temperature_k)
911 + (avg_reference_temperature_k
912 - material.thermal.reference_temperature_k)
913 * 0.1;
914 material.thermal.modulus_temp_coeff_per_k * adjusted_delta
915 })
916 .sum::<f64>()
917 / model.materials.len() as f64;
918 response.clamp(-0.25, 0.25)
919 };
920 let constitutive_poisson_coupling =
921 (0.6 + avg_poisson_ratio.clamp(0.0, 0.49)).clamp(0.6, 1.2);
922 let modulus_temperature_scale = (1.0
923 + constitutive_temperature_factor * constitutive_poisson_coupling)
924 .clamp(0.72, 1.15);
925 let thermal_stiffening_scale = (1.0 + 0.35 * thermal_strain_scale).clamp(1.0, 1.06);
926 let effective_modulus_scale =
927 (modulus_temperature_scale * thermal_stiffening_scale).clamp(0.75, 1.2);
928 let mut dof_adjustments = vec![0.0_f64; dof_count];
929 let assignment_heterogeneity_index = apply_thermo_material_heterogeneity(
930 model,
931 dof_count,
932 constitutive_temperature_factor,
933 context.reference_temperature_k,
934 context.applied_temperature_delta_k,
935 &mut dof_adjustments,
936 );
937 let spatial_field =
938 apply_thermo_spatial_field(&context, dof_count, &mut dof_adjustments);
939 let temporal_profile_variation =
940 thermo_mechanical::temporal_profile_variation(Some(&context));
941 let mut local_modulus_scales = vec![effective_modulus_scale; dof_count];
942 for i in 0..dof_count {
943 let thermal_bias = 1.0 + thermal_strain_scale * (1.0 + (i % 3) as f64 * 0.1);
944 let local_scale =
945 (effective_modulus_scale * (1.0 + dof_adjustments[i])).clamp(0.75, 1.2);
946 local_modulus_scales[i] = local_scale;
947 stiffness_diag[i] *= thermal_bias * local_scale;
948 if !constrained[i] {
949 rhs[i] += thermal_load_scale * (1.0 + (i % 5) as f64 * 0.05);
950 }
951 }
952 for i in 0..stiffness_upper.len() {
953 let edge_scale = 0.5 * (local_modulus_scales[i] + local_modulus_scales[i + 1]);
954 stiffness_upper[i] *= edge_scale;
955 }
956 let min_modulus_scale = local_modulus_scales
957 .iter()
958 .copied()
959 .fold(f64::INFINITY, f64::min);
960 let max_modulus_scale = local_modulus_scales.iter().copied().fold(0.0_f64, f64::max);
961 let constitutive_material_spread_ratio =
962 if min_modulus_scale.is_finite() && min_modulus_scale > 0.0 {
963 max_modulus_scale / min_modulus_scale
964 } else {
965 1.0
966 };
967 thermo_mechanical = Some(ThermoMechanicalAssemblySummary {
968 enabled: true,
969 reference_temperature_k: context.reference_temperature_k,
970 applied_temperature_delta_k: context.applied_temperature_delta_k,
971 thermal_expansion_coefficient: context.thermal_expansion_coefficient,
972 thermal_strain_scale,
973 thermal_load_scale,
974 constitutive_temperature_factor,
975 constitutive_poisson_coupling,
976 effective_modulus_scale,
977 constitutive_material_spread_ratio,
978 assignment_heterogeneity_index,
979 spatial_gradient_index: spatial_field.gradient_index,
980 spatial_coverage_ratio: spatial_field.coverage_ratio,
981 temporal_profile_variation,
982 region_delta_count: context.region_temperature_deltas.len(),
983 coupling_fingerprint: thermo_mechanical_fingerprint(
984 &context,
985 ThermoMechanicalFingerprintInputs {
986 dof_count,
987 constitutive_temperature_factor,
988 constitutive_poisson_coupling,
989 effective_modulus_scale,
990 constitutive_material_spread_ratio,
991 assignment_heterogeneity_index,
992 spatial_gradient_index: spatial_field.gradient_index,
993 temporal_profile_variation,
994 },
995 ),
996 });
997 }
998 }
999
1000 if let Some(context) = electro_thermal_context {
1001 if context.enabled {
1002 let temporal_variation = if context.time_profile.len() < 2 {
1003 0.0
1004 } else {
1005 let min_scale = context
1006 .time_profile
1007 .iter()
1008 .map(|point| point.current_scale)
1009 .fold(f64::INFINITY, f64::min);
1010 let max_scale = context
1011 .time_profile
1012 .iter()
1013 .map(|point| point.current_scale)
1014 .fold(-f64::INFINITY, f64::max);
1015 ((max_scale - min_scale).abs() / 2.0).clamp(0.0, 1.0)
1016 };
1017 let mut conductivity_scales = vec![1.0_f64; dof_count];
1018 for (idx, scale) in context.region_conductivity_scales.iter().enumerate() {
1019 let cursor = (idx * 5 + scale.region_id.len()) % dof_count.max(1);
1020 conductivity_scales[cursor] = scale.conductivity_scale.clamp(0.2, 2.5);
1021 }
1022 let min_scale = conductivity_scales
1023 .iter()
1024 .copied()
1025 .fold(f64::INFINITY, f64::min)
1026 .max(1.0e-6);
1027 let max_scale = conductivity_scales.iter().copied().fold(0.0_f64, f64::max);
1028 let conductivity_spread_ratio = (max_scale / min_scale).clamp(1.0, 8.0);
1029 let joule_heating_scale = (context.applied_voltage_v.powi(2)
1030 * context.base_electrical_conductivity_s_per_m.max(1.0e-9)
1031 * context.resistive_heating_coefficient.max(0.0)
1032 / 1.0e6)
1033 .clamp(0.0, 10.0);
1034
1035 for i in 0..dof_count {
1036 let local = conductivity_scales[i];
1037 damping_diag[i] *= (1.0 + 0.02 * local).clamp(1.0, 1.1);
1038 if !constrained[i] {
1039 rhs[i] += joule_heating_scale * local * (1.0 + (i % 7) as f64 * 0.01);
1040 }
1041 }
1042
1043 electro_thermal = Some(ElectroThermalAssemblySummary {
1044 enabled: true,
1045 reference_temperature_k: context.reference_temperature_k,
1046 applied_voltage_v: context.applied_voltage_v,
1047 base_electrical_conductivity_s_per_m: context.base_electrical_conductivity_s_per_m,
1048 resistive_heating_coefficient: context.resistive_heating_coefficient,
1049 joule_heating_scale,
1050 conductivity_spread_ratio,
1051 temporal_profile_variation: temporal_variation,
1052 region_scale_count: context.region_conductivity_scales.len(),
1053 coupling_fingerprint: electro_thermal_fingerprint(
1054 &context,
1055 dof_count,
1056 joule_heating_scale,
1057 conductivity_spread_ratio,
1058 temporal_variation,
1059 ),
1060 prep_recovery_edges: prep_recovery_edges.clone(),
1061 prep_coordinates: prep_coordinates.clone(),
1062 });
1063 }
1064 }
1065
1066 let stiffness_csr = match analysis_mesh.as_ref() {
1067 Some(mesh) => match assemble_solid_stiffness_csr_with_materials(
1068 mesh,
1069 SolidMaterial {
1070 youngs_modulus_pa: structural_material.youngs_modulus_pa,
1071 poisson_ratio: structural_material.poisson_ratio,
1072 },
1073 &solid_materials_by_region(model),
1074 base_dof_count,
1075 ) {
1076 Ok(dense) => Some(dense),
1077 Err(err) if strict_analysis_mesh_stiffness => {
1078 return Err(LinearAssemblyError::SolidStiffness(err));
1079 }
1080 Err(_) => None,
1081 },
1082 None => None,
1083 };
1084 if let Some(csr) = stiffness_csr.as_ref() {
1085 apply_csr_constraints(csr, &constrained, &mut rhs, dof_count);
1086 for (i, diagonal) in stiffness_diag.iter_mut().enumerate().take(dof_count) {
1087 let start = csr.row_offsets[i];
1088 let end = csr.row_offsets[i + 1];
1089 *diagonal = csr.column_indices[start..end]
1090 .iter()
1091 .zip(csr.values[start..end].iter())
1092 .find_map(|(&column, &value)| (column == i).then_some(value.abs()))
1093 .unwrap_or(1.0e-12)
1094 .max(1.0e-12);
1095 }
1096 stiffness_upper.fill(0.0);
1097 }
1098
1099 let constrained_dof_count = constrained.iter().filter(|value| **value).count();
1100
1101 Ok(AssemblySummary {
1102 dof_count,
1103 structural_node_count: structural_dof_layout.node_count(),
1104 structural_translational_dof_count: structural_dof_layout.translational_dof_count(),
1105 structural_rotational_dof_count: structural_dof_layout.rotational_dof_count(),
1106 structural_rotation_node_count: structural_dof_layout.rotation_node_count(),
1107 structural_moment_load_count,
1108 structural_direct_rotational_moment_load_count,
1109 structural_wrench_lowering,
1110 structural_rotational_constraint_count: 0,
1111 structural_beam_element_count: 0,
1112 structural_shell_element_count: 0,
1113 structural_solid_element_count: solid_topology
1114 .as_ref()
1115 .map(|topology| topology.volume_element_count)
1116 .unwrap_or(0),
1117 structural_solid_recovery,
1118 structural_dof_layout,
1119 structural_beam_recovery: Vec::new(),
1120 structural_shell_recovery: Vec::new(),
1121 constrained_dof_count,
1122 load_count: model.loads.len().saturating_add(prep_load_bonus),
1123 structural_material,
1124 prep_assembly,
1125 prep_operator_topology,
1126 prep_region_topology,
1127 prep_element_assembly,
1128 prep_element_connectivity,
1129 prep_graph_assembly,
1130 prep_recovery_edges,
1131 prep_calibration,
1132 prep_acceptance,
1133 prep_coordinates,
1134 thermo_mechanical,
1135 electro_thermal,
1136 operator: OperatorSystem {
1137 dof_count,
1138 constrained,
1139 stiffness_dense: None,
1140 stiffness_csr,
1141 stiffness_diag,
1142 stiffness_upper,
1143 mass_diag,
1144 damping_diag,
1145 rhs,
1146 },
1147 })
1148}
1149
1150fn solid_recovery_from_analysis_mesh(
1151 mesh: &AnalysisMeshArtifact,
1152) -> Vec<SolidRecoveryElementSummary> {
1153 let node_indices = mesh
1154 .nodes
1155 .iter()
1156 .enumerate()
1157 .map(|(index, node)| (node.node_id, index))
1158 .collect::<BTreeMap<_, _>>();
1159
1160 mesh.volume_elements
1161 .iter()
1162 .filter(|element| element.node_ids.len() == 4)
1163 .filter_map(|element| {
1164 let mut indices = [0_usize; 4];
1165 let mut coordinates_m = [[0.0_f64; 3]; 4];
1166 for (local, node_id) in element.node_ids.iter().copied().enumerate() {
1167 let node_index = *node_indices.get(&node_id)?;
1168 indices[local] = node_index;
1169 coordinates_m[local] = mesh.nodes.get(node_index)?.coordinates_m;
1170 }
1171 Some(SolidRecoveryElementSummary {
1172 element_id: element.element_id.clone(),
1173 region_id: element.material_region_id.clone(),
1174 node_indices: indices,
1175 coordinates_m,
1176 })
1177 })
1178 .collect()
1179}
1180
1181fn electro_thermal_fingerprint(
1182 context: &FeaElectroThermalContext,
1183 dof_count: usize,
1184 joule_heating_scale: f64,
1185 conductivity_spread_ratio: f64,
1186 temporal_profile_variation: f64,
1187) -> u64 {
1188 let mut hash = 1469598103934665603_u64;
1189 for value in [
1190 context.reference_temperature_k.to_bits(),
1191 context.applied_voltage_v.to_bits(),
1192 context.base_electrical_conductivity_s_per_m.to_bits(),
1193 context.resistive_heating_coefficient.to_bits(),
1194 joule_heating_scale.to_bits(),
1195 conductivity_spread_ratio.to_bits(),
1196 temporal_profile_variation.to_bits(),
1197 dof_count as u64,
1198 context.region_conductivity_scales.len() as u64,
1199 context.time_profile.len() as u64,
1200 ] {
1201 hash ^= value;
1202 hash = hash.wrapping_mul(1099511628211);
1203 }
1204 hash
1205}
1206
1207fn topology_fingerprint(
1208 prep: &FeaPrepContext,
1209 stiffness_scale: f64,
1210 mass_scale: f64,
1211 damping_scale: f64,
1212 rhs_scale: f64,
1213 coupling_nonzero_ratio: f64,
1214 stiffness_spread_ratio: f64,
1215) -> u64 {
1216 let mut hash = 1469598103934665603_u64;
1217 for value in [
1218 stiffness_scale.to_bits(),
1219 mass_scale.to_bits(),
1220 damping_scale.to_bits(),
1221 rhs_scale.to_bits(),
1222 coupling_nonzero_ratio.to_bits(),
1223 stiffness_spread_ratio.to_bits(),
1224 prep.topology_surface_patch_ratio.to_bits(),
1225 prep.topology_volume_core_ratio.to_bits(),
1226 prep.topology_mixed_family_ratio.to_bits(),
1227 prep.topology_region_span_mean.to_bits(),
1228 prep.mapped_region_participation_ratio.to_bits(),
1229 prep.topology_dof_multiplier.to_bits(),
1230 prep.topology_bandwidth_estimate as u64,
1231 prep.layout_seed,
1232 ] {
1233 hash ^= value;
1234 hash = hash.wrapping_mul(1099511628211_u64);
1235 }
1236 hash
1237}
1238
1239fn assemble_beam_system(model: &AnalysisModel) -> Option<AssemblySummary> {
1240 let structural = model.structural.as_ref()?;
1241 let beam_elements = structural
1242 .elements
1243 .iter()
1244 .filter(|element| matches!(element.kind, StructuralElementKind::Beam(_)))
1245 .collect::<Vec<_>>();
1246 let shell_elements = structural
1247 .elements
1248 .iter()
1249 .filter(|element| matches!(element.kind, StructuralElementKind::Shell(_)))
1250 .collect::<Vec<_>>();
1251 if (beam_elements.is_empty() && shell_elements.is_empty()) || structural.nodes.is_empty() {
1252 return None;
1253 }
1254
1255 let structural_material = structural_material_summary(model);
1256 let beam_material = BeamMaterial {
1257 youngs_modulus_pa: structural_material.youngs_modulus_pa,
1258 shear_modulus_pa: structural_material.shear_modulus_pa,
1259 };
1260 let shell_material = ShellMaterial {
1261 youngs_modulus_pa: structural_material.youngs_modulus_pa,
1262 poisson_ratio: structural_material.poisson_ratio,
1263 shear_modulus_pa: structural_material.shear_modulus_pa,
1264 };
1265 let node_count = structural.nodes.len();
1266 let structural_dof_layout = StructuralDofLayout::from_node_sets(vec![
1267 StructuralNodeDofSet::translational_rotational();
1268 node_count
1269 ]);
1270 let dof_count = structural_dof_layout.total_dof_count();
1271 let mut dense = vec![0.0_f64; dof_count * dof_count];
1272 let mut rhs = vec![0.0_f64; dof_count];
1273 let mut mass_diag = vec![0.0_f64; dof_count];
1274 let mut damping_diag = vec![0.0_f64; dof_count];
1275 let mut structural_beam_recovery = Vec::new();
1276 let mut structural_shell_recovery = Vec::new();
1277
1278 for element in &beam_elements {
1279 let StructuralElementKind::Beam(beam) = &element.kind else {
1280 continue;
1281 };
1282 let node_i_index = structural_node_index(structural, beam.node_ids[0])?;
1283 let node_j_index = structural_node_index(structural, beam.node_ids[1])?;
1284 let section = structural_beam_section(structural, &beam.section_id)?;
1285 let geometry = BeamElementGeometry {
1286 node_i_m: structural.nodes[node_i_index].coordinates_m,
1287 node_j_m: structural.nodes[node_j_index].coordinates_m,
1288 reference_axis: beam.reference_axis,
1289 };
1290 let frame = geometry.local_frame().ok()?;
1291 let transform_global_to_local = transformation_matrix(frame);
1292 let stiffness = beam_global_stiffness_matrix(section, beam_material, geometry).ok()?;
1293 let element_dofs =
1294 beam_element_dof_indices(&structural_dof_layout, node_i_index, node_j_index)?;
1295 for (local_row, &global_row) in element_dofs.iter().enumerate() {
1296 for (local_col, &global_col) in element_dofs.iter().enumerate() {
1297 dense[global_row * dof_count + global_col] += stiffness[local_row][local_col];
1298 }
1299 }
1300 structural_beam_recovery.push(BeamRecoveryElementSummary {
1301 element_id: element.element_id.clone(),
1302 region_id: element.region_id.clone(),
1303 node_i_index,
1304 node_j_index,
1305 length_m: frame.length_m,
1306 section,
1307 material: beam_material,
1308 transform_global_to_local,
1309 });
1310
1311 add_lumped_beam_mass_and_damping(
1312 &mut mass_diag,
1313 &mut damping_diag,
1314 &element_dofs,
1315 section,
1316 structural_material.density_kg_per_m3,
1317 frame.length_m,
1318 &transform_global_to_local,
1319 );
1320 }
1321
1322 for element in &shell_elements {
1323 let StructuralElementKind::Shell(shell) = &element.kind else {
1324 continue;
1325 };
1326 let node_indices = [
1327 structural_node_index(structural, shell.node_ids[0])?,
1328 structural_node_index(structural, shell.node_ids[1])?,
1329 structural_node_index(structural, shell.node_ids[2])?,
1330 ];
1331 let section = structural_shell_section(structural, &shell.section_id)?;
1332 let geometry = ShellElementGeometry {
1333 nodes_m: [
1334 structural.nodes[node_indices[0]].coordinates_m,
1335 structural.nodes[node_indices[1]].coordinates_m,
1336 structural.nodes[node_indices[2]].coordinates_m,
1337 ],
1338 reference_axis: shell.reference_axis,
1339 };
1340 let frame = geometry.local_frame().ok()?;
1341 let stiffness = shell_global_stiffness_matrix(section, shell_material, geometry).ok()?;
1342 let element_dofs = shell_element_dof_indices(&structural_dof_layout, node_indices)?;
1343 for (local_row, &global_row) in element_dofs.iter().enumerate() {
1344 for (local_col, &global_col) in element_dofs.iter().enumerate() {
1345 dense[global_row * dof_count + global_col] += stiffness[local_row][local_col];
1346 }
1347 }
1348 add_lumped_shell_mass_and_damping(
1349 &mut mass_diag,
1350 &mut damping_diag,
1351 &element_dofs,
1352 section,
1353 structural_material.density_kg_per_m3,
1354 frame.area_m2,
1355 );
1356 structural_shell_recovery.push(ShellRecoveryElementSummary {
1357 element_id: element.element_id.clone(),
1358 region_id: element.region_id.clone(),
1359 node_indices,
1360 area_m2: frame.area_m2,
1361 section,
1362 material: shell_material,
1363 });
1364 }
1365
1366 let mut direct_rotational_moment_load_count = 0usize;
1367 let mut structural_wrench_lowering = Vec::new();
1368 for load in &model.loads {
1369 let target_nodes = structural_target_nodes(structural, &load.region_id);
1370 if target_nodes.is_empty() {
1371 continue;
1372 }
1373 let scale = 1.0 / target_nodes.len() as f64;
1374 match load.kind {
1375 LoadKind::Force { fx, fy, fz } => {
1376 for node_index in target_nodes {
1377 add_rhs(
1378 &structural_dof_layout,
1379 &mut rhs,
1380 node_index,
1381 StructuralDofKind::Ux,
1382 fx * scale,
1383 );
1384 add_rhs(
1385 &structural_dof_layout,
1386 &mut rhs,
1387 node_index,
1388 StructuralDofKind::Uy,
1389 fy * scale,
1390 );
1391 add_rhs(
1392 &structural_dof_layout,
1393 &mut rhs,
1394 node_index,
1395 StructuralDofKind::Uz,
1396 fz * scale,
1397 );
1398 }
1399 }
1400 LoadKind::Moment { mx, my, mz } => {
1401 direct_rotational_moment_load_count += 1;
1402 for node_index in target_nodes {
1403 add_rhs(
1404 &structural_dof_layout,
1405 &mut rhs,
1406 node_index,
1407 StructuralDofKind::Rx,
1408 mx * scale,
1409 );
1410 add_rhs(
1411 &structural_dof_layout,
1412 &mut rhs,
1413 node_index,
1414 StructuralDofKind::Ry,
1415 my * scale,
1416 );
1417 add_rhs(
1418 &structural_dof_layout,
1419 &mut rhs,
1420 node_index,
1421 StructuralDofKind::Rz,
1422 mz * scale,
1423 );
1424 }
1425 }
1426 LoadKind::Wrench {
1427 fx,
1428 fy,
1429 fz,
1430 mx,
1431 my,
1432 mz,
1433 px,
1434 py,
1435 pz,
1436 } => {
1437 let summary = add_wrench_rhs(
1438 structural,
1439 &structural_dof_layout,
1440 &mut rhs,
1441 &target_nodes,
1442 [fx, fy, fz],
1443 [mx, my, mz],
1444 [px, py, pz],
1445 );
1446 structural_wrench_lowering.push(WrenchLoweringSummary {
1447 load_id: load.load_id.clone(),
1448 region_id: load.region_id.clone(),
1449 ..summary
1450 });
1451 }
1452 _ => {}
1453 }
1454 }
1455
1456 let mut constrained = vec![false; dof_count];
1457 for bc in &model.boundary_conditions {
1458 let target_nodes = structural_target_nodes(structural, &bc.region_id);
1459 for node_index in target_nodes {
1460 match bc.kind {
1461 BoundaryConditionKind::Fixed => {
1462 for kind in StructuralDofKind::ORDER {
1463 constrain_dof(
1464 &structural_dof_layout,
1465 &mut constrained,
1466 &mut rhs,
1467 node_index,
1468 kind,
1469 0.0,
1470 );
1471 }
1472 }
1473 BoundaryConditionKind::PrescribedDisplacement => {
1474 for kind in [
1475 StructuralDofKind::Ux,
1476 StructuralDofKind::Uy,
1477 StructuralDofKind::Uz,
1478 ] {
1479 constrain_dof(
1480 &structural_dof_layout,
1481 &mut constrained,
1482 &mut rhs,
1483 node_index,
1484 kind,
1485 0.0,
1486 );
1487 }
1488 }
1489 BoundaryConditionKind::PrescribedRotation { rx, ry, rz } => {
1490 constrain_dof(
1491 &structural_dof_layout,
1492 &mut constrained,
1493 &mut rhs,
1494 node_index,
1495 StructuralDofKind::Rx,
1496 rx,
1497 );
1498 constrain_dof(
1499 &structural_dof_layout,
1500 &mut constrained,
1501 &mut rhs,
1502 node_index,
1503 StructuralDofKind::Ry,
1504 ry,
1505 );
1506 constrain_dof(
1507 &structural_dof_layout,
1508 &mut constrained,
1509 &mut rhs,
1510 node_index,
1511 StructuralDofKind::Rz,
1512 rz,
1513 );
1514 }
1515 _ => {}
1516 }
1517 }
1518 }
1519 apply_dense_constraints(&dense, &constrained, &mut rhs, dof_count);
1520
1521 let stiffness_diag = (0..dof_count)
1522 .map(|idx| {
1523 if constrained[idx] {
1524 1.0
1525 } else {
1526 dense[idx * dof_count + idx].abs().max(1.0e-12)
1527 }
1528 })
1529 .collect::<Vec<_>>();
1530 let stiffness_upper = vec![0.0; dof_count.saturating_sub(1)];
1531 let constrained_dof_count = constrained.iter().filter(|value| **value).count();
1532 let rotational_constraint_count = constrained
1533 .iter()
1534 .enumerate()
1535 .filter(|(_, is_constrained)| **is_constrained)
1536 .filter(|(row, _)| {
1537 structural_dof_layout
1538 .address(*row)
1539 .is_some_and(|address| address.kind.is_rotational())
1540 })
1541 .count();
1542
1543 Some(AssemblySummary {
1544 dof_count,
1545 structural_node_count: structural_dof_layout.node_count(),
1546 structural_translational_dof_count: structural_dof_layout.translational_dof_count(),
1547 structural_rotational_dof_count: structural_dof_layout.rotational_dof_count(),
1548 structural_rotation_node_count: structural_dof_layout.rotation_node_count(),
1549 structural_moment_load_count: model
1550 .loads
1551 .iter()
1552 .filter(|load| matches!(load.kind, LoadKind::Moment { .. }))
1553 .count(),
1554 structural_direct_rotational_moment_load_count: direct_rotational_moment_load_count,
1555 structural_wrench_lowering,
1556 structural_rotational_constraint_count: rotational_constraint_count,
1557 structural_beam_element_count: beam_elements.len(),
1558 structural_shell_element_count: shell_elements.len(),
1559 structural_solid_element_count: 0,
1560 structural_solid_recovery: Vec::new(),
1561 structural_dof_layout,
1562 structural_beam_recovery,
1563 structural_shell_recovery,
1564 constrained_dof_count,
1565 load_count: model.loads.len(),
1566 structural_material,
1567 prep_assembly: None,
1568 prep_operator_topology: None,
1569 prep_region_topology: None,
1570 prep_element_assembly: None,
1571 prep_element_connectivity: None,
1572 prep_graph_assembly: None,
1573 prep_recovery_edges: Vec::new(),
1574 prep_calibration: None,
1575 prep_acceptance: None,
1576 prep_coordinates: None,
1577 thermo_mechanical: None,
1578 electro_thermal: None,
1579 operator: OperatorSystem {
1580 dof_count,
1581 constrained,
1582 stiffness_dense: Some(dense),
1583 stiffness_csr: None,
1584 stiffness_diag,
1585 stiffness_upper,
1586 mass_diag,
1587 damping_diag,
1588 rhs,
1589 },
1590 })
1591}
1592
1593fn structural_material_summary(model: &AnalysisModel) -> StructuralMaterialSummary {
1594 let avg_youngs_modulus = if model.materials.is_empty() {
1595 1.0e9
1596 } else {
1597 model
1598 .materials
1599 .iter()
1600 .map(|material| material.mechanical.youngs_modulus_pa.max(1.0))
1601 .sum::<f64>()
1602 / model.materials.len() as f64
1603 };
1604 let avg_poisson_ratio = if model.materials.is_empty() {
1605 0.3
1606 } else {
1607 model
1608 .materials
1609 .iter()
1610 .map(|material| material.mechanical.poisson_ratio.clamp(0.0, 0.49))
1611 .sum::<f64>()
1612 / model.materials.len() as f64
1613 };
1614 let shear_modulus_pa = avg_youngs_modulus / (2.0 * (1.0 + avg_poisson_ratio)).max(1.0e-9);
1615 let avg_density_kg_per_m3 = if model.materials.is_empty() {
1616 7850.0
1617 } else {
1618 model
1619 .materials
1620 .iter()
1621 .map(|material| material.mechanical.density_kg_per_m3.max(1.0))
1622 .sum::<f64>()
1623 / model.materials.len() as f64
1624 };
1625 let lame_lambda_pa = avg_youngs_modulus * avg_poisson_ratio
1626 / ((1.0 + avg_poisson_ratio) * (1.0 - 2.0 * avg_poisson_ratio)).max(1.0e-9);
1627 StructuralMaterialSummary {
1628 youngs_modulus_pa: avg_youngs_modulus,
1629 poisson_ratio: avg_poisson_ratio,
1630 density_kg_per_m3: avg_density_kg_per_m3,
1631 lame_lambda_pa,
1632 shear_modulus_pa,
1633 }
1634}
1635
1636fn solid_materials_by_region(model: &AnalysisModel) -> BTreeMap<String, SolidMaterial> {
1637 let materials_by_id = model
1638 .materials
1639 .iter()
1640 .map(|material| (material.material_id.as_str(), material))
1641 .collect::<BTreeMap<_, _>>();
1642
1643 model
1644 .material_assignments
1645 .iter()
1646 .filter_map(|assignment| {
1647 let material = materials_by_id.get(assignment.assigned_material_id.as_str())?;
1648 Some((
1649 assignment.region_id.clone(),
1650 SolidMaterial {
1651 youngs_modulus_pa: material.mechanical.youngs_modulus_pa.max(1.0),
1652 poisson_ratio: material.mechanical.poisson_ratio.clamp(0.0, 0.49),
1653 },
1654 ))
1655 })
1656 .collect()
1657}
1658
1659fn structural_node_index(structural: &StructuralModel, node_id: u32) -> Option<usize> {
1660 structural
1661 .nodes
1662 .iter()
1663 .position(|node| node.node_id == node_id)
1664}
1665
1666fn structural_beam_section(structural: &StructuralModel, section_id: &str) -> Option<BeamSection> {
1667 structural
1668 .beam_sections
1669 .iter()
1670 .find(|section| section.section_id == section_id)
1671 .map(section_from_model)
1672}
1673
1674fn structural_shell_section(
1675 structural: &StructuralModel,
1676 section_id: &str,
1677) -> Option<ShellSection> {
1678 structural
1679 .shell_sections
1680 .iter()
1681 .find(|section| section.section_id == section_id)
1682 .map(shell_section_from_model)
1683}
1684
1685fn section_from_model(section: &BeamSectionModel) -> BeamSection {
1686 BeamSection {
1687 area_m2: section.area_m2,
1688 iy_m4: section.iy_m4,
1689 iz_m4: section.iz_m4,
1690 torsion_j_m4: section.torsion_j_m4,
1691 outer_fiber_y_m: section.outer_fiber_y_m,
1692 outer_fiber_z_m: section.outer_fiber_z_m,
1693 torsion_outer_radius_m: section.torsion_outer_radius_m,
1694 }
1695}
1696
1697fn shell_section_from_model(section: &ShellSectionModel) -> ShellSection {
1698 ShellSection {
1699 thickness_m: section.thickness_m,
1700 shear_correction: section.shear_correction,
1701 drilling_stiffness_scale: section.drilling_stiffness_scale,
1702 }
1703}
1704
1705fn beam_element_dof_indices(
1706 layout: &StructuralDofLayout,
1707 node_i_index: usize,
1708 node_j_index: usize,
1709) -> Option<[usize; BEAM_ELEMENT_DOF_COUNT]> {
1710 let mut indices = [0usize; BEAM_ELEMENT_DOF_COUNT];
1711 for (local, kind) in StructuralDofKind::ORDER.iter().copied().enumerate() {
1712 indices[local] = layout.index(node_i_index, kind)?;
1713 indices[local + 6] = layout.index(node_j_index, kind)?;
1714 }
1715 Some(indices)
1716}
1717
1718fn shell_element_dof_indices(
1719 layout: &StructuralDofLayout,
1720 node_indices: [usize; 3],
1721) -> Option<[usize; SHELL_ELEMENT_DOF_COUNT]> {
1722 let mut indices = [0usize; SHELL_ELEMENT_DOF_COUNT];
1723 for (node_offset, node_index) in node_indices.iter().enumerate() {
1724 for (component, kind) in StructuralDofKind::ORDER.iter().enumerate() {
1725 indices[node_offset * SHELL_NODE_DOF_COUNT + component] =
1726 layout.index(*node_index, *kind)?;
1727 }
1728 }
1729 Some(indices)
1730}
1731
1732fn add_lumped_beam_mass_and_damping(
1733 mass_diag: &mut [f64],
1734 damping_diag: &mut [f64],
1735 element_dofs: &[usize; BEAM_ELEMENT_DOF_COUNT],
1736 section: BeamSection,
1737 density_kg_per_m3: f64,
1738 length_m: f64,
1739 transform_global_to_local: &BeamTransform12,
1740) {
1741 let density = density_kg_per_m3.max(1.0);
1742 let length = length_m.max(1.0e-12);
1743 let nodal_mass = density * section.area_m2.max(1.0e-18) * length / 2.0;
1744 let local_rotational_inertia = [
1745 density * section.torsion_j_m4.max(1.0e-24) * length / 2.0,
1746 density * section.iy_m4.max(1.0e-24) * length / 2.0,
1747 density * section.iz_m4.max(1.0e-24) * length / 2.0,
1748 ];
1749
1750 for node_offset in [0usize, 6] {
1751 for component in 0..3 {
1752 let dof = element_dofs[node_offset + component];
1753 mass_diag[dof] += nodal_mass;
1754 damping_diag[dof] += 0.01;
1755 }
1756 for global_component in 0..3 {
1757 let local_col = node_offset + 3 + global_component;
1758 let inertia = local_rotational_inertia
1759 .iter()
1760 .enumerate()
1761 .map(|(local_component, local_inertia)| {
1762 let row = node_offset + 3 + local_component;
1763 local_inertia * transform_global_to_local[row][local_col].powi(2)
1764 })
1765 .sum::<f64>();
1766 let dof = element_dofs[node_offset + 3 + global_component];
1767 mass_diag[dof] += inertia.max(1.0e-18);
1768 damping_diag[dof] += 0.01;
1769 }
1770 }
1771}
1772
1773fn add_lumped_shell_mass_and_damping(
1774 mass_diag: &mut [f64],
1775 damping_diag: &mut [f64],
1776 element_dofs: &[usize; SHELL_ELEMENT_DOF_COUNT],
1777 section: ShellSection,
1778 density_kg_per_m3: f64,
1779 area_m2: f64,
1780) {
1781 let density = density_kg_per_m3.max(1.0);
1782 let thickness = section.thickness_m.max(1.0e-12);
1783 let area = area_m2.max(1.0e-18);
1784 let nodal_mass = density * thickness * area / 3.0;
1785 let nodal_rotary_inertia = nodal_mass * thickness.powi(2) / 12.0;
1786 for node_offset in [0usize, 6, 12] {
1787 for component in 0..3 {
1788 let dof = element_dofs[node_offset + component];
1789 mass_diag[dof] += nodal_mass;
1790 damping_diag[dof] += 0.01;
1791 }
1792 for component in 3..6 {
1793 let dof = element_dofs[node_offset + component];
1794 mass_diag[dof] += nodal_rotary_inertia.max(1.0e-18);
1795 damping_diag[dof] += 0.01;
1796 }
1797 }
1798}
1799
1800fn structural_target_nodes(structural: &StructuralModel, region_id: &str) -> Vec<usize> {
1801 if let Some(node_id) = structural_node_selector(region_id) {
1802 return structural_node_index(structural, node_id)
1803 .into_iter()
1804 .collect();
1805 }
1806 let mut nodes = Vec::new();
1807 for element in &structural.elements {
1808 if element.region_id != region_id {
1809 continue;
1810 }
1811 match &element.kind {
1812 StructuralElementKind::Beam(beam) => {
1813 for node_id in beam.node_ids {
1814 if let Some(index) = structural_node_index(structural, node_id) {
1815 if !nodes.contains(&index) {
1816 nodes.push(index);
1817 }
1818 }
1819 }
1820 }
1821 StructuralElementKind::Shell(shell) => {
1822 for node_id in shell.node_ids {
1823 if let Some(index) = structural_node_index(structural, node_id) {
1824 if !nodes.contains(&index) {
1825 nodes.push(index);
1826 }
1827 }
1828 }
1829 }
1830 }
1831 }
1832 nodes
1833}
1834
1835fn structural_node_selector(region_id: &str) -> Option<u32> {
1836 region_id
1837 .strip_prefix("node:")
1838 .unwrap_or(region_id)
1839 .parse::<u32>()
1840 .ok()
1841}
1842
1843fn add_rhs(
1844 layout: &StructuralDofLayout,
1845 rhs: &mut [f64],
1846 node_index: usize,
1847 kind: StructuralDofKind,
1848 value: f64,
1849) {
1850 if let Some(dof) = layout.index(node_index, kind) {
1851 rhs[dof] += value;
1852 }
1853}
1854
1855fn add_wrench_rhs(
1856 structural: &StructuralModel,
1857 layout: &StructuralDofLayout,
1858 rhs: &mut [f64],
1859 target_nodes: &[usize],
1860 force: [f64; 3],
1861 moment_at_point: [f64; 3],
1862 point_m: [f64; 3],
1863) -> WrenchLoweringSummary {
1864 if target_nodes.is_empty() {
1865 return WrenchLoweringSummary {
1866 load_id: String::new(),
1867 region_id: String::new(),
1868 target_node_count: 0,
1869 applied_force: [0.0; 3],
1870 applied_moment_at_point: [0.0; 3],
1871 force_residual: force,
1872 moment_residual: moment_at_point,
1873 moment_couple_applied: false,
1874 };
1875 }
1876
1877 let centroid = target_centroid(structural, target_nodes);
1878 let scale = 1.0 / target_nodes.len() as f64;
1879 let mut nodal_forces = Vec::with_capacity(target_nodes.len());
1880 for &node_index in target_nodes {
1881 let nodal_force = scale_vec(force, scale);
1882 add_translational_rhs(layout, rhs, node_index, nodal_force);
1883 nodal_forces.push(nodal_force);
1884 }
1885
1886 let force_arm = [
1887 centroid[0] - point_m[0],
1888 centroid[1] - point_m[1],
1889 centroid[2] - point_m[2],
1890 ];
1891 let force_moment = cross(force_arm, force);
1892 let couple = [
1893 moment_at_point[0] - force_moment[0],
1894 moment_at_point[1] - force_moment[1],
1895 moment_at_point[2] - force_moment[2],
1896 ];
1897 let mut moment_couple_applied = false;
1898
1899 if !couple
1900 .iter()
1901 .all(|component| component.abs() <= f64::EPSILON)
1902 {
1903 let mut coupling = [[0.0_f64; 3]; 3];
1904 let offsets = target_nodes
1905 .iter()
1906 .map(|&node_index| {
1907 let node = structural.nodes[node_index].coordinates_m;
1908 [
1909 node[0] - centroid[0],
1910 node[1] - centroid[1],
1911 node[2] - centroid[2],
1912 ]
1913 })
1914 .collect::<Vec<_>>();
1915 for offset in &offsets {
1916 let r2 = dot(*offset, *offset);
1917 for row in 0..3 {
1918 coupling[row][row] += r2;
1919 for col in 0..3 {
1920 coupling[row][col] -= offset[row] * offset[col];
1921 }
1922 }
1923 }
1924
1925 if let Some(inv) = invert_3x3(coupling) {
1926 let lambda = mat_vec(inv, couple);
1927 for ((&node_index, offset), nodal_force) in target_nodes
1928 .iter()
1929 .zip(offsets.iter())
1930 .zip(nodal_forces.iter_mut())
1931 {
1932 let couple_force = cross(lambda, *offset);
1933 add_translational_rhs(layout, rhs, node_index, couple_force);
1934 nodal_force[0] += couple_force[0];
1935 nodal_force[1] += couple_force[1];
1936 nodal_force[2] += couple_force[2];
1937 }
1938 moment_couple_applied = true;
1939 }
1940 }
1941
1942 let (applied_force, applied_moment_at_point) =
1943 wrench_resultants(structural, target_nodes, &nodal_forces, point_m);
1944 WrenchLoweringSummary {
1945 load_id: String::new(),
1946 region_id: String::new(),
1947 target_node_count: target_nodes.len(),
1948 applied_force,
1949 applied_moment_at_point,
1950 force_residual: [
1951 force[0] - applied_force[0],
1952 force[1] - applied_force[1],
1953 force[2] - applied_force[2],
1954 ],
1955 moment_residual: [
1956 moment_at_point[0] - applied_moment_at_point[0],
1957 moment_at_point[1] - applied_moment_at_point[1],
1958 moment_at_point[2] - applied_moment_at_point[2],
1959 ],
1960 moment_couple_applied,
1961 }
1962}
1963
1964fn add_translational_rhs(
1965 layout: &StructuralDofLayout,
1966 rhs: &mut [f64],
1967 node_index: usize,
1968 force: [f64; 3],
1969) {
1970 add_rhs(layout, rhs, node_index, StructuralDofKind::Ux, force[0]);
1971 add_rhs(layout, rhs, node_index, StructuralDofKind::Uy, force[1]);
1972 add_rhs(layout, rhs, node_index, StructuralDofKind::Uz, force[2]);
1973}
1974
1975fn target_centroid(structural: &StructuralModel, target_nodes: &[usize]) -> [f64; 3] {
1976 let mut centroid = [0.0_f64; 3];
1977 for &node_index in target_nodes {
1978 let node = structural.nodes[node_index].coordinates_m;
1979 centroid[0] += node[0];
1980 centroid[1] += node[1];
1981 centroid[2] += node[2];
1982 }
1983 scale_vec(centroid, 1.0 / target_nodes.len() as f64)
1984}
1985
1986fn wrench_resultants(
1987 structural: &StructuralModel,
1988 target_nodes: &[usize],
1989 nodal_forces: &[[f64; 3]],
1990 point_m: [f64; 3],
1991) -> ([f64; 3], [f64; 3]) {
1992 let mut applied_force = [0.0_f64; 3];
1993 let mut applied_moment = [0.0_f64; 3];
1994 for (&node_index, &force) in target_nodes.iter().zip(nodal_forces.iter()) {
1995 applied_force[0] += force[0];
1996 applied_force[1] += force[1];
1997 applied_force[2] += force[2];
1998 let node = structural.nodes[node_index].coordinates_m;
1999 let arm = [
2000 node[0] - point_m[0],
2001 node[1] - point_m[1],
2002 node[2] - point_m[2],
2003 ];
2004 let moment = cross(arm, force);
2005 applied_moment[0] += moment[0];
2006 applied_moment[1] += moment[1];
2007 applied_moment[2] += moment[2];
2008 }
2009 (applied_force, applied_moment)
2010}
2011
2012fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
2013 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
2014}
2015
2016fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
2017 [
2018 a[1] * b[2] - a[2] * b[1],
2019 a[2] * b[0] - a[0] * b[2],
2020 a[0] * b[1] - a[1] * b[0],
2021 ]
2022}
2023
2024fn scale_vec(value: [f64; 3], scale: f64) -> [f64; 3] {
2025 [value[0] * scale, value[1] * scale, value[2] * scale]
2026}
2027
2028fn mat_vec(matrix: [[f64; 3]; 3], value: [f64; 3]) -> [f64; 3] {
2029 [
2030 dot(matrix[0], value),
2031 dot(matrix[1], value),
2032 dot(matrix[2], value),
2033 ]
2034}
2035
2036fn invert_3x3(matrix: [[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
2037 let m = matrix;
2038 let c00 = m[1][1] * m[2][2] - m[1][2] * m[2][1];
2039 let c01 = -(m[1][0] * m[2][2] - m[1][2] * m[2][0]);
2040 let c02 = m[1][0] * m[2][1] - m[1][1] * m[2][0];
2041 let c10 = -(m[0][1] * m[2][2] - m[0][2] * m[2][1]);
2042 let c11 = m[0][0] * m[2][2] - m[0][2] * m[2][0];
2043 let c12 = -(m[0][0] * m[2][1] - m[0][1] * m[2][0]);
2044 let c20 = m[0][1] * m[1][2] - m[0][2] * m[1][1];
2045 let c21 = -(m[0][0] * m[1][2] - m[0][2] * m[1][0]);
2046 let c22 = m[0][0] * m[1][1] - m[0][1] * m[1][0];
2047 let det = m[0][0] * c00 + m[0][1] * c01 + m[0][2] * c02;
2048 if det.abs() <= 1.0e-18 {
2049 return None;
2050 }
2051 let inv_det = 1.0 / det;
2052 Some([
2053 [c00 * inv_det, c10 * inv_det, c20 * inv_det],
2054 [c01 * inv_det, c11 * inv_det, c21 * inv_det],
2055 [c02 * inv_det, c12 * inv_det, c22 * inv_det],
2056 ])
2057}
2058
2059fn constrain_dof(
2060 layout: &StructuralDofLayout,
2061 constrained: &mut [bool],
2062 rhs: &mut [f64],
2063 node_index: usize,
2064 kind: StructuralDofKind,
2065 value: f64,
2066) {
2067 if let Some(dof) = layout.index(node_index, kind) {
2068 constrained[dof] = true;
2069 rhs[dof] = value;
2070 }
2071}
2072
2073fn apply_dense_constraints(dense: &[f64], constrained: &[bool], rhs: &mut [f64], dof_count: usize) {
2074 for dof in 0..dof_count {
2075 if !constrained[dof] {
2076 continue;
2077 }
2078 for row in 0..dof_count {
2079 if !constrained[row] {
2080 rhs[row] -= dense[row * dof_count + dof] * rhs[dof];
2081 }
2082 }
2083 }
2084}
2085
2086fn apply_csr_constraints(csr: &CsrMatrix, constrained: &[bool], rhs: &mut [f64], dof_count: usize) {
2087 for row in 0..dof_count {
2088 if constrained[row] {
2089 continue;
2090 }
2091 let start = csr.row_offsets[row];
2092 let end = csr.row_offsets[row + 1];
2093 for entry in start..end {
2094 let column = csr.column_indices[entry];
2095 if constrained[column] {
2096 rhs[row] -= csr.values[entry] * rhs[column];
2097 }
2098 }
2099 }
2100}
2101
2102fn apply_prep_native_element_assembly(
2103 prep: &FeaPrepContext,
2104 dof_count: usize,
2105 constrained: &[bool],
2106 stiffness_diag: &mut [f64],
2107 mass_diag: &mut [f64],
2108 damping_diag: &mut [f64],
2109 rhs: &mut [f64],
2110) -> PrepElementAssemblySummary {
2111 let element_count = prep
2112 .prepared_element_count
2113 .max(prep.prepared_mesh_count)
2114 .max(1);
2115 let triangle_count = ((element_count as f64)
2116 * prep.topology_triangle_family_ratio.clamp(0.0, 1.0))
2117 .round() as usize;
2118 let quad_count =
2119 ((element_count as f64) * prep.topology_quad_family_ratio.clamp(0.0, 1.0)).round() as usize;
2120 let tetrahedron_count = ((element_count as f64)
2121 * prep.topology_tetrahedron_family_ratio.clamp(0.0, 1.0))
2122 .round() as usize;
2123 let mut hex_count =
2124 ((element_count as f64) * prep.topology_hex_family_ratio.clamp(0.0, 1.0)).round() as usize;
2125 let assigned = triangle_count + quad_count + tetrahedron_count + hex_count;
2126 if assigned > element_count {
2127 let overflow = assigned - element_count;
2128 hex_count = hex_count.saturating_sub(overflow);
2129 }
2130 let mixed_count =
2131 element_count.saturating_sub(triangle_count + quad_count + tetrahedron_count + hex_count);
2132
2133 let mut touched_diag = vec![false; dof_count];
2134 let mut touched_rhs = vec![false; dof_count];
2135 let mut element_cursor = 0usize;
2136 let stride = (prep.topology_bandwidth_estimate.max(1) as usize + 1)
2137 .saturating_add(prep.topology_region_block_count.saturating_sub(1));
2138 let span_scale = 1.0 + 0.08 * prep.topology_region_span_mean.clamp(1.0, 24.0) / 24.0;
2139
2140 let mut apply_family = |count: usize, stiffness_factor: f64, mass_factor: f64| {
2141 for _ in 0..count {
2142 let base = ((prep.layout_seed as usize)
2143 .wrapping_add(element_cursor.saturating_mul(stride.max(1))))
2144 % dof_count.max(1);
2145 let wave = 1.0 + ((element_cursor % 17) as f64) / 80.0;
2146 let assembly_scale = stiffness_factor * span_scale * wave;
2147 let mass_scale = mass_factor * (1.0 + 0.04 * prep.topology_surface_patch_ratio);
2148 let damping_scale = (0.9 + 0.2 * prep.mapped_region_participation_ratio)
2149 * (1.0 + 0.05 * prep.topology_mixed_family_ratio);
2150
2151 stiffness_diag[base] += 7.5e4 * assembly_scale;
2152 mass_diag[base] += 0.25 * mass_scale;
2153 damping_diag[base] += 0.012 * damping_scale;
2154 touched_diag[base] = true;
2155
2156 if !constrained[base] {
2157 rhs[base] += 0.5 * assembly_scale;
2158 touched_rhs[base] = true;
2159 }
2160 if base + 1 < dof_count {
2161 stiffness_diag[base + 1] += 2.0e4 * assembly_scale;
2162 mass_diag[base + 1] += 0.08 * mass_scale;
2163 damping_diag[base + 1] += 0.004 * damping_scale;
2164 touched_diag[base + 1] = true;
2165 }
2166 element_cursor = element_cursor.saturating_add(1);
2167 }
2168 };
2169
2170 apply_family(triangle_count, 0.92, 0.95);
2171 apply_family(quad_count, 1.00, 1.00);
2172 apply_family(tetrahedron_count, 1.07, 1.05);
2173 apply_family(hex_count, 1.15, 1.12);
2174 apply_family(mixed_count, 0.98, 1.02);
2175
2176 let scatter_nnz_count = touched_diag.iter().filter(|&&hit| hit).count()
2177 + touched_rhs.iter().filter(|&&hit| hit).count();
2178 PrepElementAssemblySummary {
2179 assembled_element_count: element_count,
2180 triangle_element_count: triangle_count,
2181 quad_element_count: quad_count,
2182 tetrahedron_element_count: tetrahedron_count,
2183 hex_element_count: hex_count,
2184 mixed_element_count: mixed_count,
2185 scatter_nnz_count,
2186 assembly_fingerprint: element_assembly_fingerprint(
2187 prep,
2188 ElementAssemblyFingerprintInputs {
2189 element_count,
2190 triangle_count,
2191 quad_count,
2192 tetrahedron_count,
2193 hex_count,
2194 mixed_count,
2195 scatter_nnz_count,
2196 },
2197 ),
2198 }
2199}
2200
2201fn apply_prep_element_connectivity_scatter(
2202 prep: &FeaPrepContext,
2203 constrained: &[bool],
2204 stiffness_upper: &mut [f64],
2205 mass_diag: &mut [f64],
2206 damping_diag: &mut [f64],
2207 element_summary: &PrepElementAssemblySummary,
2208) -> (
2209 PrepElementConnectivitySummary,
2210 PrepGraphAssemblySummary,
2211 Vec<PrepRecoveryEdgeSummary>,
2212) {
2213 if stiffness_upper.is_empty() {
2214 let connectivity_summary = PrepElementConnectivitySummary {
2215 assembled_element_count: element_summary.assembled_element_count,
2216 stiffness_offdiag_nnz_count: 0,
2217 mass_offdiag_nnz_count: 0,
2218 damping_offdiag_nnz_count: 0,
2219 triangle_contrib_share: 0.0,
2220 quad_contrib_share: 0.0,
2221 tetrahedron_contrib_share: 0.0,
2222 hex_contrib_share: 0.0,
2223 mixed_contrib_share: 0.0,
2224 mean_connectivity_hop: 0.0,
2225 connectivity_fingerprint: element_connectivity_fingerprint(
2226 prep,
2227 ElementConnectivityFingerprintInputs {
2228 element_summary,
2229 stiffness_offdiag_nnz_count: 0,
2230 mass_offdiag_nnz_count: 0,
2231 damping_offdiag_nnz_count: 0,
2232 mean_connectivity_hop: 0.0,
2233 shares: [0.0; 5],
2234 graph_fingerprint: 0,
2235 },
2236 ),
2237 };
2238 let graph_summary = PrepGraphAssemblySummary {
2239 node_count: constrained.len(),
2240 edge_count: 0,
2241 degree_min: 0,
2242 degree_max: 0,
2243 degree_mean: 0.0,
2244 degree_p95: 0.0,
2245 fill_ratio: 0.0,
2246 connected_component_count: constrained.len().max(1),
2247 ordering_bandwidth_before: 0,
2248 ordering_bandwidth_after: 0,
2249 ordering_reduction_ratio: 0.0,
2250 ordering_fingerprint: 0,
2251 recommend_ilu0: false,
2252 graph_fingerprint: graph_fingerprint(prep, 0, constrained.len().max(1), 0.0, 0.0),
2253 };
2254 return (connectivity_summary, graph_summary, Vec::new());
2255 }
2256
2257 let node_count = constrained.len().max(1);
2258 let edges = build_prep_graph_edges(prep, node_count, element_summary);
2259 let recovery_edges = edges
2260 .iter()
2261 .map(|(left, right, family_index)| PrepRecoveryEdgeSummary {
2262 from_dof: *left,
2263 to_dof: *right,
2264 element_family_index: *family_index,
2265 edge_length_m: prep_recovery_edge_length_m(prep, (*left).abs_diff(*right)),
2266 })
2267 .collect::<Vec<_>>();
2268 let (degree_min, degree_max, degree_mean, degree_p95, component_count) =
2269 graph_degree_stats(node_count, &edges);
2270 let max_edges = node_count.saturating_mul(node_count.saturating_sub(1)) / 2;
2271 let fill_ratio = if max_edges == 0 {
2272 0.0
2273 } else {
2274 edges.len() as f64 / max_edges as f64
2275 };
2276
2277 let mut touched_stiffness = vec![false; stiffness_upper.len()];
2278 let mut touched_mass = vec![false; mass_diag.len()];
2279 let mut touched_damping = vec![false; damping_diag.len()];
2280 let mut family_contrib = [0.0_f64; 5];
2281 let mut hops = Vec::new();
2282 let family_stiffness = [0.85_f64, 0.95_f64, 1.05_f64, 1.15_f64, 0.9_f64];
2283 let family_mass = [0.10_f64, 0.11_f64, 0.12_f64, 0.14_f64, 0.105_f64];
2284 let family_damping = [0.004_f64, 0.0045_f64, 0.005_f64, 0.0055_f64, 0.0042_f64];
2285 let region_bias = 1.0 + 0.05 * prep.topology_region_mesh_mean.clamp(1.0, 8.0) / 8.0;
2286
2287 for (edge_cursor, (left, right, family_index)) in edges.iter().copied().enumerate() {
2288 if constrained[left] || constrained[right] {
2289 continue;
2290 }
2291 let hop = right.abs_diff(left).max(1);
2292 hops.push(hop as f64);
2293 let wave = 1.0 + ((edge_cursor % 23) as f64) / 100.0;
2294 let stiffness_add = 0.012
2295 * family_stiffness[family_index]
2296 * region_bias
2297 * wave
2298 * prep.topology_dof_multiplier.clamp(1.0, 4.0)
2299 / hop as f64;
2300 let lo = left.min(right);
2301 let hi = left.max(right);
2302 for band in lo..hi {
2303 if band >= stiffness_upper.len() {
2304 continue;
2305 }
2306 let attenuation = 1.0 / (1.0 + (band - lo) as f64);
2307 stiffness_upper[band] +=
2308 stiffness_add * attenuation * (stiffness_upper[band].abs() + 1.0);
2309 touched_stiffness[band] = true;
2310 }
2311 family_contrib[family_index] += stiffness_add.abs();
2312
2313 let mass_add = family_mass[family_index] * wave;
2314 mass_diag[left] += mass_add;
2315 mass_diag[right] += mass_add * 0.8;
2316 touched_mass[left] = true;
2317 touched_mass[right] = true;
2318
2319 let damping_add =
2320 family_damping[family_index] * (1.0 + 0.5 * prep.topology_mixed_family_ratio);
2321 damping_diag[left] += damping_add;
2322 damping_diag[right] += damping_add * 0.85;
2323 touched_damping[left] = true;
2324 touched_damping[right] = true;
2325 }
2326
2327 let stiffness_offdiag_nnz_count = touched_stiffness.iter().filter(|&&hit| hit).count();
2328 let mass_offdiag_nnz_count = touched_mass.iter().filter(|&&hit| hit).count();
2329 let damping_offdiag_nnz_count = touched_damping.iter().filter(|&&hit| hit).count();
2330 let total_contrib = family_contrib.iter().sum::<f64>().max(1.0e-12);
2331 let shares = [
2332 family_contrib[0] / total_contrib,
2333 family_contrib[1] / total_contrib,
2334 family_contrib[2] / total_contrib,
2335 family_contrib[3] / total_contrib,
2336 family_contrib[4] / total_contrib,
2337 ];
2338 let mean_connectivity_hop = if hops.is_empty() {
2339 0.0
2340 } else {
2341 hops.iter().sum::<f64>() / hops.len() as f64
2342 };
2343
2344 let graph_fingerprint_value =
2345 graph_fingerprint(prep, edges.len(), component_count, degree_mean, degree_p95);
2346 let ordering_permutation = graph_ordering_permutation(node_count, &edges);
2347 let ordering_bandwidth_before = graph_bandwidth(&edges, None);
2348 let ordering_bandwidth_after = graph_bandwidth(&edges, Some(&ordering_permutation));
2349 let ordering_reduction_ratio = if ordering_bandwidth_before == 0 {
2350 0.0
2351 } else {
2352 1.0 - (ordering_bandwidth_after as f64 / ordering_bandwidth_before as f64)
2353 };
2354 let ordering_fingerprint = graph_ordering_fingerprint(prep, &ordering_permutation);
2355 let recommend_ilu0 = degree_p95 >= 4.0 || fill_ratio >= 0.02 || component_count <= 3;
2356 let graph_summary = PrepGraphAssemblySummary {
2357 node_count,
2358 edge_count: edges.len(),
2359 degree_min,
2360 degree_max,
2361 degree_mean,
2362 degree_p95,
2363 fill_ratio,
2364 connected_component_count: component_count,
2365 ordering_bandwidth_before,
2366 ordering_bandwidth_after,
2367 ordering_reduction_ratio,
2368 ordering_fingerprint,
2369 recommend_ilu0,
2370 graph_fingerprint: graph_fingerprint_value,
2371 };
2372
2373 let connectivity_summary = PrepElementConnectivitySummary {
2374 assembled_element_count: element_summary.assembled_element_count,
2375 stiffness_offdiag_nnz_count,
2376 mass_offdiag_nnz_count,
2377 damping_offdiag_nnz_count,
2378 triangle_contrib_share: shares[0],
2379 quad_contrib_share: shares[1],
2380 tetrahedron_contrib_share: shares[2],
2381 hex_contrib_share: shares[3],
2382 mixed_contrib_share: shares[4],
2383 mean_connectivity_hop,
2384 connectivity_fingerprint: element_connectivity_fingerprint(
2385 prep,
2386 ElementConnectivityFingerprintInputs {
2387 element_summary,
2388 stiffness_offdiag_nnz_count,
2389 mass_offdiag_nnz_count,
2390 damping_offdiag_nnz_count,
2391 mean_connectivity_hop,
2392 shares,
2393 graph_fingerprint: graph_fingerprint_value,
2394 },
2395 ),
2396 };
2397
2398 (connectivity_summary, graph_summary, recovery_edges)
2399}
2400
2401fn prep_recovery_edge_length_m(prep: &FeaPrepContext, hop: usize) -> f64 {
2402 let measured_edge_length = if prep.element_geometry_coverage_ratio > 0.0 {
2403 prep.mean_element_edge_length_m
2404 } else {
2405 0.0
2406 };
2407 let characteristic = if measured_edge_length.is_finite() && measured_edge_length > 0.0 {
2408 measured_edge_length
2409 } else {
2410 prep.coordinate_characteristic_length_m
2411 };
2412 let length = characteristic * hop.max(1) as f64;
2413 if length.is_finite() && length > 0.0 {
2414 length
2415 } else {
2416 hop.max(1) as f64
2417 }
2418}
2419
2420fn apply_prep_calibration(
2421 prep: &FeaPrepContext,
2422 avg_youngs_modulus: f64,
2423 graph_summary: Option<&PrepGraphAssemblySummary>,
2424 stiffness_diag: &mut [f64],
2425 mass_diag: &mut [f64],
2426 damping_diag: &mut [f64],
2427 rhs: &mut [f64],
2428) -> Option<PrepCalibrationSummary> {
2429 if stiffness_diag.is_empty() {
2430 return None;
2431 }
2432 let profile = select_calibration_profile(prep, avg_youngs_modulus, graph_summary);
2433 let (profile_gain, profile_name) = match profile {
2434 CalibrationProfile::Fast => (0.92, "fast"),
2435 CalibrationProfile::Balanced => (1.0, "balanced"),
2436 CalibrationProfile::Conservative => (1.08, "conservative"),
2437 };
2438
2439 let triangle_weight = (0.95 + 0.08 * prep.topology_triangle_family_ratio) * profile_gain;
2440 let quad_weight = (1.0 + 0.06 * prep.topology_quad_family_ratio) * profile_gain;
2441 let tetrahedron_weight = (1.04 + 0.10 * prep.topology_tetrahedron_family_ratio) * profile_gain;
2442 let hex_weight = (1.08 + 0.12 * prep.topology_hex_family_ratio) * profile_gain;
2443 let mixed_weight = (0.9 + 0.05 * prep.topology_mixed_family_ratio) * profile_gain;
2444
2445 let stiffness_calibration_scale = (triangle_weight * prep.topology_triangle_family_ratio
2446 + quad_weight * prep.topology_quad_family_ratio
2447 + tetrahedron_weight * prep.topology_tetrahedron_family_ratio
2448 + hex_weight * prep.topology_hex_family_ratio
2449 + mixed_weight * prep.topology_mixed_family_ratio.max(0.01))
2450 .clamp(0.8, 1.3);
2451 let mass_calibration_scale = (0.96
2452 + 0.03 * prep.topology_surface_patch_ratio
2453 + 0.04 * prep.topology_region_mesh_mean.clamp(1.0, 6.0) / 6.0)
2454 .clamp(0.9, 1.2)
2455 * profile_gain;
2456 let damping_calibration_scale = (0.94
2457 + 0.05 * prep.topology_mixed_family_ratio
2458 + 0.03 * prep.mapped_region_participation_ratio)
2459 .clamp(0.9, 1.2)
2460 * profile_gain;
2461
2462 for value in stiffness_diag.iter_mut() {
2463 *value *= stiffness_calibration_scale;
2464 }
2465 for value in mass_diag.iter_mut() {
2466 *value *= mass_calibration_scale;
2467 }
2468 for value in damping_diag.iter_mut() {
2469 *value *= damping_calibration_scale;
2470 }
2471 for value in rhs.iter_mut() {
2472 *value *= (2.0 - stiffness_calibration_scale).clamp(0.8, 1.2);
2473 }
2474
2475 Some(PrepCalibrationSummary {
2476 profile: profile_name.to_string(),
2477 triangle_weight,
2478 quad_weight,
2479 tetrahedron_weight,
2480 hex_weight,
2481 mixed_weight,
2482 stiffness_calibration_scale,
2483 mass_calibration_scale,
2484 damping_calibration_scale,
2485 calibration_fingerprint: calibration_fingerprint(
2486 prep,
2487 profile_name,
2488 stiffness_calibration_scale,
2489 mass_calibration_scale,
2490 damping_calibration_scale,
2491 ),
2492 })
2493}
2494
2495fn evaluate_prep_acceptance(
2496 prep: &FeaPrepContext,
2497 calibration: &PrepCalibrationSummary,
2498 graph_summary: Option<&PrepGraphAssemblySummary>,
2499 stiffness_diag: &[f64],
2500) -> PrepAcceptanceSummary {
2501 let bounded_displacement_scale = (0.8..=1.3).contains(&calibration.stiffness_calibration_scale);
2502 let bounded_stress_scale = (0.9..=1.25).contains(&calibration.damping_calibration_scale);
2503 let bounded_connectivity_fill = graph_summary
2504 .map(|graph| graph.fill_ratio <= 0.25 && graph.connected_component_count <= 64)
2505 .unwrap_or(true);
2506 let stiffness_max = stiffness_diag.iter().copied().fold(0.0_f64, f64::max);
2507 let stiffness_min = stiffness_diag
2508 .iter()
2509 .copied()
2510 .filter(|value| *value > 0.0)
2511 .fold(f64::INFINITY, f64::min);
2512 let spread = if stiffness_min.is_finite() && stiffness_min > 0.0 {
2513 stiffness_max / stiffness_min
2514 } else {
2515 0.0
2516 };
2517 let spread_penalty = (spread / 100.0).clamp(0.0, 1.0);
2518 let mut acceptance_score = 1.0 - spread_penalty;
2519 if !bounded_displacement_scale {
2520 acceptance_score -= 0.2;
2521 }
2522 if !bounded_stress_scale {
2523 acceptance_score -= 0.2;
2524 }
2525 if !bounded_connectivity_fill {
2526 acceptance_score -= 0.3;
2527 }
2528 acceptance_score = acceptance_score.clamp(0.0, 1.0);
2529 let accepted = bounded_displacement_scale
2530 && bounded_stress_scale
2531 && bounded_connectivity_fill
2532 && acceptance_score >= 0.4
2533 && prep.min_scaled_jacobian >= 0.45;
2534
2535 PrepAcceptanceSummary {
2536 profile: calibration.profile.clone(),
2537 accepted,
2538 bounded_displacement_scale,
2539 bounded_stress_scale,
2540 bounded_connectivity_fill,
2541 acceptance_score,
2542 acceptance_fingerprint: acceptance_fingerprint(
2543 prep,
2544 &calibration.profile,
2545 accepted,
2546 acceptance_score,
2547 spread,
2548 ),
2549 }
2550}
2551
2552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2553enum CalibrationProfile {
2554 Fast,
2555 Balanced,
2556 Conservative,
2557}
2558
2559fn select_calibration_profile(
2560 prep: &FeaPrepContext,
2561 avg_youngs_modulus: f64,
2562 graph_summary: Option<&PrepGraphAssemblySummary>,
2563) -> CalibrationProfile {
2564 if let Some(profile) = prep.calibration_profile_override {
2565 return match profile {
2566 FeaPrepCalibrationProfile::Fast => CalibrationProfile::Fast,
2567 FeaPrepCalibrationProfile::Balanced => CalibrationProfile::Balanced,
2568 FeaPrepCalibrationProfile::Conservative => CalibrationProfile::Conservative,
2569 };
2570 }
2571 let stiffness_regime = avg_youngs_modulus;
2572 let ordering_gain = graph_summary
2573 .map(|graph| graph.ordering_reduction_ratio)
2574 .unwrap_or(0.0);
2575 if prep.min_scaled_jacobian < 0.65 || prep.topology_mixed_family_ratio > 0.25 {
2576 CalibrationProfile::Conservative
2577 } else if stiffness_regime < 5.0e10 || ordering_gain > 0.2 {
2578 CalibrationProfile::Fast
2579 } else {
2580 CalibrationProfile::Balanced
2581 }
2582}
2583
2584fn build_prep_graph_edges(
2585 prep: &FeaPrepContext,
2586 node_count: usize,
2587 element_summary: &PrepElementAssemblySummary,
2588) -> Vec<(usize, usize, usize)> {
2589 use std::collections::BTreeSet;
2590
2591 let family_counts = [
2592 element_summary.triangle_element_count,
2593 element_summary.quad_element_count,
2594 element_summary.tetrahedron_element_count,
2595 element_summary.hex_element_count,
2596 element_summary.mixed_element_count,
2597 ];
2598 let family_valence = [2usize, 3, 4, 5, 3];
2599 let stride = (prep.topology_bandwidth_estimate.max(1) as usize)
2600 .saturating_add(prep.topology_region_block_count.max(1));
2601 let max_hop = prep
2602 .topology_region_span_mean
2603 .round()
2604 .clamp(1.0, node_count as f64) as usize;
2605 let mut edges = BTreeSet::new();
2606 let mut cursor = 0usize;
2607 for family_index in 0..family_counts.len() {
2608 for _ in 0..family_counts[family_index] {
2609 let base = ((prep.layout_seed as usize)
2610 .wrapping_add(family_index.saturating_mul(31))
2611 .wrapping_add(cursor.saturating_mul(stride.max(1))))
2612 % node_count.max(1);
2613 for k in 0..family_valence[family_index] {
2614 let hop = 1
2615 + ((prep.layout_seed as usize)
2616 .wrapping_add(k)
2617 .wrapping_add(cursor)
2618 .wrapping_add(family_index.saturating_mul(7))
2619 % max_hop.max(1));
2620 let target = (base + hop) % node_count.max(1);
2621 if base == target {
2622 continue;
2623 }
2624 let lo = base.min(target);
2625 let hi = base.max(target);
2626 edges.insert((lo, hi, family_index));
2627 }
2628 cursor = cursor.saturating_add(1);
2629 }
2630 }
2631 edges.into_iter().collect()
2632}
2633
2634fn graph_degree_stats(
2635 node_count: usize,
2636 edges: &[(usize, usize, usize)],
2637) -> (usize, usize, f64, f64, usize) {
2638 if node_count == 0 {
2639 return (0, 0, 0.0, 0.0, 0);
2640 }
2641 let mut degree = vec![0usize; node_count];
2642 let mut parent = (0..node_count).collect::<Vec<_>>();
2643
2644 fn find(parent: &mut [usize], x: usize) -> usize {
2645 if parent[x] != x {
2646 let root = find(parent, parent[x]);
2647 parent[x] = root;
2648 }
2649 parent[x]
2650 }
2651 fn union(parent: &mut [usize], a: usize, b: usize) {
2652 let ra = find(parent, a);
2653 let rb = find(parent, b);
2654 if ra != rb {
2655 parent[rb] = ra;
2656 }
2657 }
2658
2659 for (a, b, _) in edges {
2660 degree[*a] = degree[*a].saturating_add(1);
2661 degree[*b] = degree[*b].saturating_add(1);
2662 union(&mut parent, *a, *b);
2663 }
2664 let degree_min = degree.iter().copied().min().unwrap_or(0);
2665 let degree_max = degree.iter().copied().max().unwrap_or(0);
2666 let degree_mean = degree.iter().sum::<usize>() as f64 / degree.len() as f64;
2667 let mut sorted_degree = degree.iter().map(|d| *d as f64).collect::<Vec<_>>();
2668 sorted_degree.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2669 let degree_p95 = if sorted_degree.is_empty() {
2670 0.0
2671 } else {
2672 let index = ((sorted_degree.len() - 1) as f64 * 0.95).round() as usize;
2673 sorted_degree[index]
2674 };
2675
2676 let mut roots = std::collections::BTreeSet::new();
2677 for idx in 0..node_count {
2678 roots.insert(find(&mut parent, idx));
2679 }
2680 (degree_min, degree_max, degree_mean, degree_p95, roots.len())
2681}
2682
2683fn graph_ordering_permutation(node_count: usize, edges: &[(usize, usize, usize)]) -> Vec<usize> {
2684 if node_count == 0 {
2685 return Vec::new();
2686 }
2687 let mut degree = vec![0usize; node_count];
2688 for (a, b, _) in edges {
2689 degree[*a] = degree[*a].saturating_add(1);
2690 degree[*b] = degree[*b].saturating_add(1);
2691 }
2692 let mut order = (0..node_count).collect::<Vec<_>>();
2693 order.sort_by(|a, b| degree[*a].cmp(°ree[*b]).then_with(|| a.cmp(b)));
2694 let mut permutation = vec![0usize; node_count];
2695 for (new_idx, old_idx) in order.iter().copied().enumerate() {
2696 permutation[old_idx] = new_idx;
2697 }
2698 permutation
2699}
2700
2701fn graph_bandwidth(edges: &[(usize, usize, usize)], permutation: Option<&[usize]>) -> usize {
2702 let mut max_bw = 0usize;
2703 for (a, b, _) in edges {
2704 let lhs = permutation.map(|perm| perm[*a]).unwrap_or(*a);
2705 let rhs = permutation.map(|perm| perm[*b]).unwrap_or(*b);
2706 max_bw = max_bw.max(lhs.abs_diff(rhs));
2707 }
2708 max_bw
2709}
2710
2711fn graph_ordering_fingerprint(prep: &FeaPrepContext, permutation: &[usize]) -> u64 {
2712 let mut hash = 1469598103934665603_u64;
2713 hash ^= prep.layout_seed;
2714 hash = hash.wrapping_mul(1099511628211_u64);
2715 for value in permutation.iter().take(256) {
2716 hash ^= *value as u64;
2717 hash = hash.wrapping_mul(1099511628211_u64);
2718 }
2719 hash
2720}
2721
2722fn build_region_block_sizes(
2723 dof_count: usize,
2724 block_count: usize,
2725 layout_seed: u64,
2726 region_mesh_mean: f64,
2727 region_mesh_variance: f64,
2728 mapped_region_participation_ratio: f64,
2729) -> Vec<usize> {
2730 let mut sizes = vec![dof_count / block_count; block_count];
2731 for size in &mut sizes {
2732 if *size == 0 {
2733 *size = 1;
2734 }
2735 }
2736 let assigned = sizes.iter().sum::<usize>();
2737 let mut remainder = dof_count.saturating_sub(assigned);
2738 let seed_bias = ((layout_seed % 13) as usize).max(1);
2739 let participation_bias =
2740 (mapped_region_participation_ratio.clamp(0.0, 1.0) * 7.0).round() as usize;
2741 let variance_bias = region_mesh_variance.clamp(0.0, 16.0).round() as usize;
2742 let stride = (seed_bias + participation_bias + variance_bias).max(1);
2743 let mut cursor = (region_mesh_mean.round() as usize + seed_bias) % block_count.max(1);
2744 while remainder > 0 {
2745 sizes[cursor % block_count] = sizes[cursor % block_count].saturating_add(1);
2746 cursor = cursor.saturating_add(stride);
2747 remainder -= 1;
2748 }
2749 sizes
2750}
2751
2752fn block_offsets(sizes: &[usize]) -> Vec<usize> {
2753 let mut offsets = Vec::with_capacity(sizes.len());
2754 let mut current = 0usize;
2755 for size in sizes {
2756 offsets.push(current);
2757 current = current.saturating_add(*size);
2758 }
2759 offsets
2760}
2761
2762fn block_bias(layout_seed: u64, block_index: usize) -> f64 {
2763 let mut hash = layout_seed ^ ((block_index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15));
2764 hash ^= hash >> 33;
2765 hash = hash.wrapping_mul(0xff51afd7ed558ccd);
2766 hash ^= hash >> 33;
2767 let normalized = (hash % 1000) as f64 / 999.0;
2768 normalized * 2.0 - 1.0
2769}
2770
2771fn region_topology_fingerprint(
2772 prep: &FeaPrepContext,
2773 block_sizes: &[usize],
2774 inter_block_edge_count: usize,
2775 coupling_nonzero_ratio: f64,
2776) -> u64 {
2777 let mut hash = 1469598103934665603_u64;
2778 for value in [
2779 prep.layout_seed,
2780 prep.topology_region_block_count as u64,
2781 prep.topology_region_mesh_mean.to_bits(),
2782 prep.topology_region_mesh_variance.to_bits(),
2783 prep.topology_region_span_mean.to_bits(),
2784 prep.mapped_region_participation_ratio.to_bits(),
2785 inter_block_edge_count as u64,
2786 coupling_nonzero_ratio.to_bits(),
2787 ] {
2788 hash ^= value;
2789 hash = hash.wrapping_mul(1099511628211_u64);
2790 }
2791 for size in block_sizes {
2792 hash ^= *size as u64;
2793 hash = hash.wrapping_mul(1099511628211_u64);
2794 }
2795 hash
2796}
2797
2798#[derive(Debug, Clone, Copy)]
2799struct ElementAssemblyFingerprintInputs {
2800 element_count: usize,
2801 triangle_count: usize,
2802 quad_count: usize,
2803 tetrahedron_count: usize,
2804 hex_count: usize,
2805 mixed_count: usize,
2806 scatter_nnz_count: usize,
2807}
2808
2809fn element_assembly_fingerprint(
2810 prep: &FeaPrepContext,
2811 inputs: ElementAssemblyFingerprintInputs,
2812) -> u64 {
2813 let mut hash = 1469598103934665603_u64;
2814 for value in [
2815 prep.layout_seed,
2816 prep.prepared_element_count as u64,
2817 inputs.element_count as u64,
2818 inputs.triangle_count as u64,
2819 inputs.quad_count as u64,
2820 inputs.tetrahedron_count as u64,
2821 inputs.hex_count as u64,
2822 inputs.mixed_count as u64,
2823 inputs.scatter_nnz_count as u64,
2824 prep.topology_triangle_family_ratio.to_bits(),
2825 prep.topology_quad_family_ratio.to_bits(),
2826 prep.topology_tetrahedron_family_ratio.to_bits(),
2827 prep.topology_hex_family_ratio.to_bits(),
2828 prep.topology_mixed_family_ratio.to_bits(),
2829 ] {
2830 hash ^= value;
2831 hash = hash.wrapping_mul(1099511628211_u64);
2832 }
2833 hash
2834}
2835
2836#[derive(Debug, Clone, Copy)]
2837struct ElementConnectivityFingerprintInputs<'a> {
2838 element_summary: &'a PrepElementAssemblySummary,
2839 stiffness_offdiag_nnz_count: usize,
2840 mass_offdiag_nnz_count: usize,
2841 damping_offdiag_nnz_count: usize,
2842 mean_connectivity_hop: f64,
2843 shares: [f64; 5],
2844 graph_fingerprint: u64,
2845}
2846
2847fn element_connectivity_fingerprint(
2848 prep: &FeaPrepContext,
2849 inputs: ElementConnectivityFingerprintInputs<'_>,
2850) -> u64 {
2851 let mut hash = 1469598103934665603_u64;
2852 for value in [
2853 prep.layout_seed,
2854 inputs.element_summary.assembly_fingerprint,
2855 inputs.element_summary.assembled_element_count as u64,
2856 inputs.stiffness_offdiag_nnz_count as u64,
2857 inputs.mass_offdiag_nnz_count as u64,
2858 inputs.damping_offdiag_nnz_count as u64,
2859 inputs.mean_connectivity_hop.to_bits(),
2860 inputs.shares[0].to_bits(),
2861 inputs.shares[1].to_bits(),
2862 inputs.shares[2].to_bits(),
2863 inputs.shares[3].to_bits(),
2864 inputs.shares[4].to_bits(),
2865 prep.topology_bandwidth_estimate as u64,
2866 inputs.graph_fingerprint,
2867 ] {
2868 hash ^= value;
2869 hash = hash.wrapping_mul(1099511628211_u64);
2870 }
2871 hash
2872}
2873
2874fn graph_fingerprint(
2875 prep: &FeaPrepContext,
2876 edge_count: usize,
2877 connected_component_count: usize,
2878 degree_mean: f64,
2879 degree_p95: f64,
2880) -> u64 {
2881 let mut hash = 1469598103934665603_u64;
2882 for value in [
2883 prep.layout_seed,
2884 prep.topology_bandwidth_estimate as u64,
2885 prep.topology_region_block_count as u64,
2886 edge_count as u64,
2887 connected_component_count as u64,
2888 degree_mean.to_bits(),
2889 degree_p95.to_bits(),
2890 prep.topology_region_span_mean.to_bits(),
2891 prep.topology_mixed_family_ratio.to_bits(),
2892 ] {
2893 hash ^= value;
2894 hash = hash.wrapping_mul(1099511628211_u64);
2895 }
2896 hash
2897}
2898
2899fn calibration_fingerprint(
2900 prep: &FeaPrepContext,
2901 profile: &str,
2902 stiffness_scale: f64,
2903 mass_scale: f64,
2904 damping_scale: f64,
2905) -> u64 {
2906 let mut hash = 1469598103934665603_u64;
2907 for byte in profile.as_bytes() {
2908 hash ^= *byte as u64;
2909 hash = hash.wrapping_mul(1099511628211_u64);
2910 }
2911 for value in [
2912 prep.layout_seed,
2913 prep.prepared_element_count as u64,
2914 stiffness_scale.to_bits(),
2915 mass_scale.to_bits(),
2916 damping_scale.to_bits(),
2917 prep.topology_triangle_family_ratio.to_bits(),
2918 prep.topology_quad_family_ratio.to_bits(),
2919 prep.topology_tetrahedron_family_ratio.to_bits(),
2920 prep.topology_hex_family_ratio.to_bits(),
2921 ] {
2922 hash ^= value;
2923 hash = hash.wrapping_mul(1099511628211_u64);
2924 }
2925 hash
2926}
2927
2928fn acceptance_fingerprint(
2929 prep: &FeaPrepContext,
2930 profile: &str,
2931 accepted: bool,
2932 score: f64,
2933 spread: f64,
2934) -> u64 {
2935 let mut hash = calibration_fingerprint(
2936 prep,
2937 profile,
2938 score,
2939 spread,
2940 if accepted { 1.0 } else { 0.0 },
2941 );
2942 hash ^= accepted as u64;
2943 hash = hash.wrapping_mul(1099511628211_u64);
2944 hash
2945}
2946
2947#[derive(Debug, Clone, Copy)]
2948struct ThermoMechanicalFingerprintInputs {
2949 dof_count: usize,
2950 constitutive_temperature_factor: f64,
2951 constitutive_poisson_coupling: f64,
2952 effective_modulus_scale: f64,
2953 constitutive_material_spread_ratio: f64,
2954 assignment_heterogeneity_index: f64,
2955 spatial_gradient_index: f64,
2956 temporal_profile_variation: f64,
2957}
2958
2959fn thermo_mechanical_fingerprint(
2960 context: &FeaThermoMechanicalContext,
2961 inputs: ThermoMechanicalFingerprintInputs,
2962) -> u64 {
2963 let mut hash = 1469598103934665603_u64;
2964 for value in [
2965 inputs.dof_count as u64,
2966 context.reference_temperature_k.to_bits(),
2967 context.applied_temperature_delta_k.to_bits(),
2968 context.thermal_expansion_coefficient.to_bits(),
2969 inputs.constitutive_temperature_factor.to_bits(),
2970 inputs.constitutive_poisson_coupling.to_bits(),
2971 inputs.effective_modulus_scale.to_bits(),
2972 inputs.constitutive_material_spread_ratio.to_bits(),
2973 inputs.assignment_heterogeneity_index.to_bits(),
2974 inputs.spatial_gradient_index.to_bits(),
2975 inputs.temporal_profile_variation.to_bits(),
2976 ] {
2977 hash ^= value;
2978 hash = hash.wrapping_mul(1099511628211_u64);
2979 }
2980 hash
2981}
2982
2983#[derive(Debug, Clone, Copy, PartialEq)]
2984struct ThermoSpatialFieldSummary {
2985 gradient_index: f64,
2986 coverage_ratio: f64,
2987}
2988
2989fn apply_thermo_spatial_field(
2990 context: &FeaThermoMechanicalContext,
2991 dof_count: usize,
2992 dof_adjustments: &mut [f64],
2993) -> ThermoSpatialFieldSummary {
2994 if dof_count == 0 || context.region_temperature_deltas.is_empty() {
2995 return ThermoSpatialFieldSummary {
2996 gradient_index: 0.0,
2997 coverage_ratio: 0.0,
2998 };
2999 }
3000 let mut touched = vec![false; dof_count];
3001 let mut min_delta = f64::INFINITY;
3002 let mut max_delta = -f64::INFINITY;
3003 for (idx, region_delta) in context.region_temperature_deltas.iter().enumerate() {
3004 min_delta = min_delta.min(region_delta.temperature_delta_k);
3005 max_delta = max_delta.max(region_delta.temperature_delta_k);
3006 let normalized = ((region_delta.temperature_delta_k - context.applied_temperature_delta_k)
3007 / 240.0)
3008 .clamp(-0.45, 0.45);
3009 let start =
3010 ((region_hash(®ion_delta.region_id) as usize).wrapping_add(idx * 5)) % dof_count;
3011 let stride = context
3012 .region_temperature_deltas
3013 .len()
3014 .saturating_add(3)
3015 .max(2);
3016 let mut cursor = start;
3017 for hop in 0..dof_count {
3018 if hop > 0 && cursor == start {
3019 break;
3020 }
3021 let wave = 1.0 + ((hop + idx) % 7) as f64 * 0.02;
3022 dof_adjustments[cursor] += normalized * wave;
3023 touched[cursor] = true;
3024 cursor = (cursor + stride) % dof_count;
3025 }
3026 }
3027 if !min_delta.is_finite() || !max_delta.is_finite() {
3028 return ThermoSpatialFieldSummary {
3029 gradient_index: 0.0,
3030 coverage_ratio: 0.0,
3031 };
3032 }
3033 let touched_count = touched.iter().filter(|entry| **entry).count() as f64;
3034 ThermoSpatialFieldSummary {
3035 gradient_index: ((max_delta - min_delta).abs() / 240.0).clamp(0.0, 1.0),
3036 coverage_ratio: (touched_count / dof_count as f64).clamp(0.0, 1.0),
3037 }
3038}
3039
3040fn apply_thermo_material_heterogeneity(
3041 model: &AnalysisModel,
3042 dof_count: usize,
3043 constitutive_temperature_factor: f64,
3044 reference_temperature_k: f64,
3045 applied_temperature_delta_k: f64,
3046 dof_adjustments: &mut [f64],
3047) -> f64 {
3048 if dof_count == 0 || model.material_assignments.is_empty() {
3049 return 0.0;
3050 }
3051 let base_amplitude = (constitutive_temperature_factor.abs() * 0.8).clamp(0.0, 0.15);
3052 if base_amplitude <= 0.0 {
3053 return 0.0;
3054 }
3055 let mut weighted_activity = 0.0_f64;
3056 let mut weight_sum = 0.0_f64;
3057 for (idx, assignment) in model.material_assignments.iter().enumerate() {
3058 let confidence_weight = match assignment.confidence {
3059 runmat_analysis_core::EvidenceConfidence::Verified => 1.0,
3060 runmat_analysis_core::EvidenceConfidence::Probable => 0.65,
3061 runmat_analysis_core::EvidenceConfidence::Inferred => 0.4,
3062 };
3063 let expected_modulus = model
3064 .materials
3065 .iter()
3066 .find(|material| material.material_id == assignment.expected_material_id)
3067 .map(|material| material.mechanical.youngs_modulus_pa)
3068 .unwrap_or(1.0e9)
3069 .max(1.0);
3070 let assigned_modulus = model
3071 .materials
3072 .iter()
3073 .find(|material| material.material_id == assignment.assigned_material_id)
3074 .map(|material| material.mechanical.youngs_modulus_pa)
3075 .unwrap_or(expected_modulus)
3076 .max(1.0);
3077 let modulus_delta_ratio =
3078 ((assigned_modulus - expected_modulus) / expected_modulus).clamp(-0.6, 0.6);
3079 let expected_temp_response = model
3080 .materials
3081 .iter()
3082 .find(|material| material.material_id == assignment.expected_material_id)
3083 .map(|material| {
3084 material.thermal.modulus_temp_coeff_per_k
3085 * (applied_temperature_delta_k
3086 + (reference_temperature_k - material.thermal.reference_temperature_k))
3087 })
3088 .unwrap_or(constitutive_temperature_factor)
3089 .clamp(-0.4, 0.2);
3090 let assigned_temp_response = model
3091 .materials
3092 .iter()
3093 .find(|material| material.material_id == assignment.assigned_material_id)
3094 .map(|material| {
3095 material.thermal.modulus_temp_coeff_per_k
3096 * (applied_temperature_delta_k
3097 + (reference_temperature_k - material.thermal.reference_temperature_k))
3098 })
3099 .unwrap_or(expected_temp_response)
3100 .clamp(-0.4, 0.2);
3101 let response_delta = (assigned_temp_response - expected_temp_response).clamp(-0.35, 0.35);
3102 let region_phase = ((region_hash(&assignment.region_id) % 11) as f64) / 10.0;
3103 let activity =
3104 (0.7 * modulus_delta_ratio.abs() + 0.3 * response_delta.abs()).clamp(0.0, 1.0);
3105 let signed_bias = base_amplitude
3106 * confidence_weight
3107 * (0.45 * modulus_delta_ratio
3108 + 0.35 * response_delta
3109 + 0.2 * modulus_delta_ratio.signum() * region_phase);
3110 let stride = model.material_assignments.len().saturating_add(1).max(2);
3111 let start = ((region_hash(&assignment.region_id) as usize).wrapping_add(idx * 3))
3112 % dof_count.max(1);
3113 let mut cursor = start;
3114 for hop in 0..dof_count {
3115 if hop > 0 && cursor == start {
3116 break;
3117 }
3118 let wave = 1.0 + ((hop + idx) % 5) as f64 * 0.03;
3119 dof_adjustments[cursor] += signed_bias * wave;
3120 cursor = (cursor + stride) % dof_count.max(1);
3121 }
3122 weighted_activity += activity * confidence_weight;
3123 weight_sum += confidence_weight;
3124 }
3125 for value in dof_adjustments.iter_mut() {
3126 *value = value.clamp(-0.18, 0.18);
3127 }
3128 if weight_sum > 0.0 {
3129 (weighted_activity / weight_sum).clamp(0.0, 1.0)
3130 } else {
3131 0.0
3132 }
3133}
3134
3135fn region_hash(region_id: &str) -> u64 {
3136 let mut hash = 1469598103934665603_u64;
3137 for byte in region_id.as_bytes() {
3138 hash ^= *byte as u64;
3139 hash = hash.wrapping_mul(1099511628211_u64);
3140 }
3141 hash
3142}
3143
3144#[cfg(test)]
3145mod tests {
3146 use super::*;
3147 use crate::fixtures::{fixture_model, FixtureId};
3148 use runmat_meshing_core::{
3149 contracts::artifact::ANALYSIS_MESH_SCHEMA_VERSION, AnalysisBoundaryFace,
3150 AnalysisMeshArtifact, AnalysisMeshNode, AnalysisMeshProvenance, AnalysisMeshQualityReport,
3151 AnalysisVolumeElement, BoundaryElementKind, MeshSizingField, VolumeElementKind,
3152 };
3153
3154 #[test]
3155 fn analysis_mesh_populates_sparse_solid_stiffness_operator() {
3156 let model = fixture_model(FixtureId::CantileverLinearStatic);
3157 let summary = assemble_linear_system(&model, None, Some(tetrahedron4_mesh()), None, None);
3158
3159 assert_eq!(summary.dof_count, 12);
3160 assert_eq!(summary.structural_solid_element_count, 1);
3161 assert_eq!(summary.structural_solid_recovery.len(), 1);
3162 assert_eq!(
3163 summary.structural_solid_recovery[0].node_indices,
3164 [0, 1, 2, 3]
3165 );
3166 assert_eq!(
3167 summary.structural_solid_recovery[0].coordinates_m,
3168 [
3169 [0.0, 0.0, 0.0],
3170 [1.0, 0.0, 0.0],
3171 [0.0, 1.0, 0.0],
3172 [0.0, 0.0, 1.0],
3173 ]
3174 );
3175 assert!(summary.operator.stiffness_dense.is_none());
3176 let csr = summary
3177 .operator
3178 .stiffness_csr
3179 .as_ref()
3180 .expect("analysis mesh should assemble a sparse solid stiffness matrix");
3181 assert_eq!(csr.row_offsets.len(), summary.dof_count + 1);
3182 assert_eq!(csr.row_offsets.last().copied(), Some(csr.values.len()));
3183 assert_eq!(csr.column_indices.len(), csr.values.len());
3184 assert!(summary
3185 .operator
3186 .stiffness_diag
3187 .iter()
3188 .all(|value| *value > 0.0));
3189 for row in 0..summary.dof_count {
3190 let start = csr.row_offsets[row];
3191 let end = csr.row_offsets[row + 1];
3192 let diagonal = csr.column_indices[start..end]
3193 .iter()
3194 .zip(csr.values[start..end].iter())
3195 .find_map(|(&column, &value)| (column == row).then_some(value.abs()))
3196 .expect("csr row should include diagonal");
3197 assert!((summary.operator.stiffness_diag[row] - diagonal) <= 1.0e-8);
3198 }
3199 }
3200
3201 #[test]
3202 fn analysis_mesh_preempts_explicit_beam_topology() {
3203 let mut model = fixture_model(FixtureId::CantileverLinearStatic);
3204 model.structural = Some(runmat_analysis_core::StructuralModel {
3205 nodes: vec![
3206 runmat_analysis_core::StructuralNode {
3207 node_id: 1,
3208 coordinates_m: [0.0, 0.0, 0.0],
3209 },
3210 runmat_analysis_core::StructuralNode {
3211 node_id: 2,
3212 coordinates_m: [1.0, 0.0, 0.0],
3213 },
3214 ],
3215 elements: vec![runmat_analysis_core::StructuralElement {
3216 element_id: "beam_1".to_string(),
3217 region_id: "span".to_string(),
3218 kind: runmat_analysis_core::StructuralElementKind::Beam(
3219 runmat_analysis_core::BeamElementModel {
3220 node_ids: [1, 2],
3221 section_id: "rect".to_string(),
3222 reference_axis: [0.0, 1.0, 0.0],
3223 },
3224 ),
3225 }],
3226 beam_sections: vec![runmat_analysis_core::BeamSectionModel {
3227 section_id: "rect".to_string(),
3228 area_m2: 1.0e-4,
3229 iy_m4: 1.0e-9,
3230 iz_m4: 1.0e-9,
3231 torsion_j_m4: 1.0e-9,
3232 outer_fiber_y_m: 0.01,
3233 outer_fiber_z_m: 0.01,
3234 torsion_outer_radius_m: 0.01,
3235 }],
3236 shell_sections: Vec::new(),
3237 });
3238
3239 let summary = assemble_linear_system(&model, None, Some(tetrahedron4_mesh()), None, None);
3240
3241 assert_eq!(summary.structural_solid_element_count, 1);
3242 assert_eq!(summary.structural_solid_recovery.len(), 1);
3243 assert_eq!(summary.structural_beam_element_count, 0);
3244 assert!(summary.operator.stiffness_csr.is_some());
3245 assert!(summary.operator.stiffness_dense.is_none());
3246 }
3247
3248 #[test]
3249 fn analysis_mesh_material_regions_select_assigned_solid_materials() {
3250 let mut soft_model = fixture_model(FixtureId::CantileverLinearStatic);
3251 let mut hard = soft_model.materials[0].clone();
3252 hard.material_id = "mat_hard".to_string();
3253 hard.mechanical.youngs_modulus_pa = 200.0e9;
3254 hard.mechanical.poisson_ratio = 0.3;
3255 let mut soft = hard.clone();
3256 soft.material_id = "mat_soft".to_string();
3257 soft.mechanical.youngs_modulus_pa = 20.0e9;
3258 soft_model.materials = vec![hard.clone(), soft.clone()];
3259 soft_model.material_assignments = vec![runmat_analysis_core::MaterialAssignment {
3260 region_id: "soft_region".to_string(),
3261 expected_material_id: "mat_hard".to_string(),
3262 assigned_material_id: "mat_soft".to_string(),
3263 confidence: runmat_analysis_core::EvidenceConfidence::Verified,
3264 }];
3265 let mut soft_mesh = tetrahedron4_mesh();
3266 soft_mesh.volume_elements[0].material_region_id = "soft_region".to_string();
3267
3268 let mut hard_model = soft_model.clone();
3269 hard_model.material_assignments = vec![runmat_analysis_core::MaterialAssignment {
3270 region_id: "soft_region".to_string(),
3271 expected_material_id: "mat_hard".to_string(),
3272 assigned_material_id: "mat_hard".to_string(),
3273 confidence: runmat_analysis_core::EvidenceConfidence::Verified,
3274 }];
3275 let hard_mesh = soft_mesh.clone();
3276
3277 let soft_summary = assemble_linear_system(&soft_model, None, Some(soft_mesh), None, None);
3278 let hard_summary = assemble_linear_system(&hard_model, None, Some(hard_mesh), None, None);
3279
3280 assert!(
3281 first_csr_diagonal(&soft_summary) < first_csr_diagonal(&hard_summary) * 0.2,
3282 "soft material assignment should lower solid element stiffness"
3283 );
3284 assert_eq!(
3285 soft_summary.structural_solid_recovery[0].region_id,
3286 "soft_region"
3287 );
3288 }
3289
3290 #[test]
3291 fn strict_analysis_mesh_assembly_rejects_invalid_tetrahedron4_stiffness() {
3292 let model = fixture_model(FixtureId::CantileverLinearStatic);
3293 let mut mesh = tetrahedron4_mesh();
3294 mesh.volume_elements[0].node_ids = vec![1, 3, 2, 4];
3295 mesh.boundary_faces = vec![
3296 boundary_face("root_face", vec![1, 2, 3], &["root"]),
3297 boundary_face("tip_face", vec![1, 2, 4], &["tip"]),
3298 ];
3299
3300 let err = try_assemble_linear_system(&model, None, Some(mesh), None, None).expect_err(
3301 "strict analysis mesh assembly should reject inverted Tetrahedron4 stiffness",
3302 );
3303
3304 assert!(matches!(
3305 err,
3306 LinearAssemblyError::SolidStiffness(SolidAssemblyError::ElementStiffness { .. })
3307 ));
3308 }
3309
3310 #[test]
3311 fn analysis_mesh_boundary_regions_drive_solid_loads_and_constraints() {
3312 let mut model = fixture_model(FixtureId::CantileverLinearStatic);
3313 model.boundary_conditions = vec![runmat_analysis_core::BoundaryCondition {
3314 bc_id: "fixed_root".to_string(),
3315 region_id: "root".to_string(),
3316 kind: BoundaryConditionKind::Fixed,
3317 }];
3318 model.loads = vec![runmat_analysis_core::LoadCase {
3319 load_id: "load_tip".to_string(),
3320 region_id: "tip".to_string(),
3321 kind: LoadKind::Force {
3322 fx: 0.0,
3323 fy: -12.0,
3324 fz: 0.0,
3325 },
3326 }];
3327 let mut mesh = tetrahedron4_mesh();
3328 mesh.boundary_faces = vec![
3329 boundary_face("root_face", vec![1, 2, 3], &["root"]),
3330 boundary_face("tip_face", vec![1, 2, 4], &["tip"]),
3331 ];
3332
3333 let summary = assemble_linear_system(&model, None, Some(mesh), None, None);
3334
3335 assert_eq!(summary.constrained_dof_count, 9);
3336 assert!(summary.operator.constrained[0]);
3337 assert!(summary.operator.constrained[1]);
3338 assert!(summary.operator.constrained[2]);
3339 assert_eq!(summary.operator.rhs[10], -4.0);
3340 }
3341
3342 #[test]
3343 fn strict_analysis_mesh_assembly_rejects_unmapped_load_region() {
3344 let mut model = fixture_model(FixtureId::CantileverLinearStatic);
3345 model.boundary_conditions = vec![runmat_analysis_core::BoundaryCondition {
3346 bc_id: "fixed_root".to_string(),
3347 region_id: "root".to_string(),
3348 kind: BoundaryConditionKind::Fixed,
3349 }];
3350 model.loads = vec![runmat_analysis_core::LoadCase {
3351 load_id: "load_tip".to_string(),
3352 region_id: "missing_tip".to_string(),
3353 kind: LoadKind::Force {
3354 fx: 0.0,
3355 fy: -12.0,
3356 fz: 0.0,
3357 },
3358 }];
3359 let mut mesh = tetrahedron4_mesh();
3360 mesh.boundary_faces = vec![boundary_face("root_face", vec![1, 2, 3], &["root"])];
3361
3362 let err = try_assemble_linear_system(&model, None, Some(mesh), None, None)
3363 .expect_err("strict analysis mesh assembly should reject unmapped loads");
3364
3365 assert!(matches!(
3366 err,
3367 LinearAssemblyError::AnalysisMeshRegionMapping(
3368 AnalysisMeshRegionMappingError::UnmappedLoadRegion { .. }
3369 )
3370 ));
3371 let message = err.to_string();
3372 assert!(message.contains("load_id=load_tip"));
3373 assert!(message.contains("region_id=missing_tip"));
3374 }
3375
3376 #[test]
3377 fn strict_analysis_mesh_assembly_rejects_unmapped_constraint_region() {
3378 let mut model = fixture_model(FixtureId::CantileverLinearStatic);
3379 model.boundary_conditions = vec![runmat_analysis_core::BoundaryCondition {
3380 bc_id: "fixed_root".to_string(),
3381 region_id: "missing_root".to_string(),
3382 kind: BoundaryConditionKind::Fixed,
3383 }];
3384 model.loads = vec![runmat_analysis_core::LoadCase {
3385 load_id: "load_tip".to_string(),
3386 region_id: "tip".to_string(),
3387 kind: LoadKind::Force {
3388 fx: 0.0,
3389 fy: -12.0,
3390 fz: 0.0,
3391 },
3392 }];
3393 let mut mesh = tetrahedron4_mesh();
3394 mesh.boundary_faces = vec![boundary_face("tip_face", vec![1, 2, 4], &["tip"])];
3395
3396 let err = try_assemble_linear_system(&model, None, Some(mesh), None, None)
3397 .expect_err("strict analysis mesh assembly should reject unmapped constraints");
3398
3399 assert!(matches!(
3400 err,
3401 LinearAssemblyError::AnalysisMeshRegionMapping(
3402 AnalysisMeshRegionMappingError::UnmappedBoundaryConditionRegion { .. }
3403 )
3404 ));
3405 let message = err.to_string();
3406 assert!(message.contains("bc_id=fixed_root"));
3407 assert!(message.contains("region_id=missing_root"));
3408 }
3409
3410 #[test]
3411 fn analysis_mesh_boundary_regions_integrate_pressure_loads() {
3412 let mut model = fixture_model(FixtureId::CantileverLinearStatic);
3413 model.boundary_conditions = Vec::new();
3414 model.loads = vec![runmat_analysis_core::LoadCase {
3415 load_id: "pressure_tip".to_string(),
3416 region_id: "tip".to_string(),
3417 kind: LoadKind::Pressure { magnitude_pa: 12.0 },
3418 }];
3419 let mut mesh = tetrahedron4_mesh();
3420 mesh.boundary_faces = vec![boundary_face("tip_face", vec![1, 2, 4], &["tip"])];
3421
3422 let summary = assemble_linear_system(&model, None, Some(mesh), None, None);
3423
3424 assert_close(summary.operator.rhs[1], -2.0);
3425 assert_close(summary.operator.rhs[4], -2.0);
3426 assert_close(summary.operator.rhs[10], -2.0);
3427 }
3428
3429 #[test]
3430 fn analysis_mesh_boundary_regions_lower_wrench_moments() {
3431 let mut model = fixture_model(FixtureId::CantileverLinearStatic);
3432 model.boundary_conditions = Vec::new();
3433 model.loads = vec![runmat_analysis_core::LoadCase {
3434 load_id: "wrench_tip".to_string(),
3435 region_id: "tip".to_string(),
3436 kind: LoadKind::Wrench {
3437 fx: 0.0,
3438 fy: 0.0,
3439 fz: 0.0,
3440 mx: 0.0,
3441 my: 6.0,
3442 mz: 0.0,
3443 px: 0.0,
3444 py: 0.0,
3445 pz: 0.0,
3446 },
3447 }];
3448 let mut mesh = tetrahedron4_mesh();
3449 mesh.boundary_faces = vec![boundary_face("tip_face", vec![1, 2, 4], &["tip"])];
3450
3451 let summary = assemble_linear_system(&model, None, Some(mesh), None, None);
3452
3453 assert_eq!(summary.structural_wrench_lowering.len(), 1);
3454 let lowering = &summary.structural_wrench_lowering[0];
3455 assert_eq!(lowering.load_id, "wrench_tip");
3456 assert_eq!(lowering.region_id, "tip");
3457 assert!(lowering.moment_couple_applied);
3458 assert_close(lowering.applied_moment_at_point[1], 6.0);
3459 assert_close(lowering.moment_residual[1], 0.0);
3460 assert!(summary.operator.rhs.iter().any(|value| value.abs() > 0.0));
3461 }
3462
3463 fn tetrahedron4_mesh() -> AnalysisMeshArtifact {
3464 let mut mesh = AnalysisMeshArtifact {
3465 schema_version: ANALYSIS_MESH_SCHEMA_VERSION.to_string(),
3466 mesh_id: "unit_tetrahedron".to_string(),
3467 nodes: vec![
3468 node(1, [0.0, 0.0, 0.0]),
3469 node(2, [1.0, 0.0, 0.0]),
3470 node(3, [0.0, 1.0, 0.0]),
3471 node(4, [0.0, 0.0, 1.0]),
3472 ],
3473 volume_elements: vec![AnalysisVolumeElement {
3474 element_id: "tetrahedron_1".to_string(),
3475 kind: VolumeElementKind::Tetrahedron4,
3476 node_ids: vec![1, 2, 3, 4],
3477 material_region_id: "solid".to_string(),
3478 provenance: Vec::new(),
3479 }],
3480 boundary_faces: Vec::new(),
3481 boundary_edges: Vec::new(),
3482 quality: AnalysisMeshQualityReport::default(),
3483 sizing: MeshSizingField::default(),
3484 field_topology: Vec::new(),
3485 backend: Default::default(),
3486 adaptive_iterations: Vec::new(),
3487 provenance: AnalysisMeshProvenance {
3488 algorithm: "test".to_string(),
3489 source_geometry_id: "geo:test".to_string(),
3490 source_geometry_revision: 1,
3491 source_geometry_sha256: None,
3492 },
3493 };
3494 mesh.refresh_field_topology();
3495 mesh
3496 }
3497
3498 fn boundary_face(
3499 face_id: &str,
3500 node_ids: Vec<u32>,
3501 region_ids: &[&str],
3502 ) -> AnalysisBoundaryFace {
3503 AnalysisBoundaryFace {
3504 face_id: face_id.to_string(),
3505 kind: BoundaryElementKind::Tri3,
3506 node_ids,
3507 adjacent_volume_element_ids: Vec::new(),
3508 region_ids: region_ids
3509 .iter()
3510 .map(|region| (*region).to_string())
3511 .collect(),
3512 provenance: Vec::new(),
3513 }
3514 }
3515
3516 fn assert_close(actual: f64, expected: f64) {
3517 assert!(
3518 (actual - expected).abs() <= 1.0e-8,
3519 "expected {expected}, got {actual}"
3520 );
3521 }
3522
3523 fn first_csr_diagonal(summary: &AssemblySummary) -> f64 {
3524 let csr = summary
3525 .operator
3526 .stiffness_csr
3527 .as_ref()
3528 .expect("analysis mesh should assemble CSR stiffness");
3529 csr.column_indices[csr.row_offsets[0]..csr.row_offsets[1]]
3530 .iter()
3531 .zip(csr.values[csr.row_offsets[0]..csr.row_offsets[1]].iter())
3532 .find_map(|(&column, &value)| (column == 0).then_some(value.abs()))
3533 .expect("first row should contain diagonal")
3534 }
3535
3536 fn node(node_id: u32, coordinates_m: [f64; 3]) -> AnalysisMeshNode {
3537 AnalysisMeshNode {
3538 node_id,
3539 coordinates_m,
3540 provenance: Vec::new(),
3541 }
3542 }
3543}