1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use runmat_analysis_core::{
5 AnalysisInterface, AnalysisModel, AnalysisModelId, AnalysisStep, BeamElementModel,
6 BeamSectionModel, BoundaryCondition, BoundaryConditionKind, CfdDomain, ElectroThermalDomain,
7 ElectromagneticDomain, EvidenceConfidence, LoadCase, LoadKind, MaterialAcousticModel,
8 MaterialAssignment, MaterialElectricalModel, MaterialMechanicalModel, MaterialModel,
9 MaterialPlasticModel, MaterialThermalModel, ReferenceFrame, ShellElementModel,
10 ShellSectionModel, StructuralElement, StructuralElementKind, StructuralModel, StructuralNode,
11 ThermoMechanicalDomain,
12};
13use runmat_analysis_fea::ComputeBackend;
14use runmat_geometry_core::{GeometryAsset, UnitSystem};
15use runmat_geometry_io::GeometryImportOptions;
16use runmat_meshing_core::{
17 MeshBackendKind, MeshElementOrder, MeshKindRequest, MeshProfile, MeshRefinementOptions,
18 MeshTargetSize, MeshValidationPolicyOptions, QualityThresholds, RefinementConvergenceOptions,
19 RefinementFocusLevel, RefinementFocusOptions, RefinementIndicatorMode,
20 RefinementIndicatorOverrides, RefinementStrategy, VolumeElementKind, VolumeMeshingOptions,
21};
22use serde::de::{DeserializeOwned, Error as DeError};
23use serde::{Deserialize, Deserializer};
24
25use super::contracts::AnalysisOutputRequest;
26use super::{
27 analysis_create_model_op, AnalysisAcousticRunOptions, AnalysisCfdRunOptions,
28 AnalysisChtRunOptions, AnalysisCreateModelIntentSpec, AnalysisCreateModelProfile,
29 AnalysisElectromagneticRunOptions, AnalysisFsiRunOptions, AnalysisModalRunOptions,
30 AnalysisNonlinearRunOptions, AnalysisRunKind, AnalysisRunOptions, AnalysisStudySpec,
31 AnalysisStudySweepSpec, AnalysisThermalRunOptions, AnalysisTransientRunOptions,
32};
33use crate::operations::OperationContext;
34
35const FEA_DOCUMENT_VERSION: u32 = 1;
36
37#[derive(Debug, Clone, PartialEq)]
38pub enum FeaResolvedDocument {
39 Study(Box<AnalysisStudySpec>),
40 Sweep(AnalysisStudySweepSpec),
41}
42
43#[derive(Debug, Deserialize)]
44#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
45enum RawFeaDocument {
46 Study(Box<FeaStudyDocument>),
47 Sweep(FeaSweepDocument),
48}
49
50#[derive(Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct FeaSweepDocument {
53 version: u32,
54 id: String,
55 #[serde(default = "default_fail_fast")]
56 fail_fast: bool,
57 studies: Vec<FeaStudyDocument>,
58}
59
60#[derive(Debug, Deserialize)]
61#[serde(deny_unknown_fields)]
62struct FeaStudyDocument {
63 version: u32,
64 id: String,
65 geometry: FeaGeometryDocument,
66 model: FeaModelDocument,
67 run: FeaRunDocument,
68 #[serde(default)]
69 mesh: Option<FeaMeshDocument>,
70 #[serde(default)]
71 regions: BTreeMap<String, FeaRegionDocument>,
72 #[serde(default)]
73 materials: BTreeMap<String, FeaMaterialDocument>,
74 #[serde(default)]
75 material_assignments: Vec<FeaMaterialAssignmentDocument>,
76 #[serde(default)]
77 structural: Option<FeaStructuralDocument>,
78 #[serde(default)]
79 nodes: Vec<FeaStructuralNodeDocument>,
80 #[serde(default)]
81 elements: Vec<FeaStructuralElementDocument>,
82 #[serde(default)]
83 sections: Vec<FeaStructuralSectionDocument>,
84 #[serde(default)]
85 boundary_conditions: Vec<FeaBoundaryConditionDocument>,
86 #[serde(default)]
87 loads: Vec<FeaLoadDocument>,
88 #[serde(default)]
89 steps: Vec<FeaStepDocument>,
90 #[serde(default)]
91 outputs: Vec<FeaOutputDocument>,
92 #[serde(default)]
93 domains: FeaDomainsDocument,
94 #[serde(default)]
95 interfaces: Vec<AnalysisInterface>,
96}
97
98#[derive(Debug, Deserialize)]
99#[serde(deny_unknown_fields)]
100struct FeaGeometryDocument {
101 path: PathBuf,
102 #[serde(default = "default_units")]
103 units: UnitSystem,
104 #[serde(default)]
105 import: FeaGeometryImportDocument,
106}
107
108#[derive(Debug, Default, Deserialize)]
109#[serde(deny_unknown_fields)]
110struct FeaGeometryImportDocument {
111 #[serde(default)]
112 max_triangles: Option<u64>,
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(deny_unknown_fields)]
117struct FeaModelDocument {
118 #[serde(default)]
119 id: Option<String>,
120 profile: AnalysisCreateModelProfile,
121 #[serde(default)]
122 frame: Option<ReferenceFrame>,
123 #[serde(default)]
124 defaults: FeaModelDefaultsMode,
125}
126
127#[derive(Debug, Deserialize)]
128#[serde(deny_unknown_fields)]
129struct FeaMeshDocument {
130 #[serde(default)]
131 backend: MeshBackendKind,
132 #[serde(default = "default_mesh_kind")]
133 kind: MeshKindRequest,
134 #[serde(default = "default_mesh_element")]
135 element: VolumeElementKind,
136 #[serde(default = "default_mesh_element_order")]
137 element_order: MeshElementOrder,
138 #[serde(default = "default_mesh_profile")]
139 profile: MeshProfile,
140 #[serde(default = "default_mesh_max_elements")]
141 max_elements: usize,
142 #[serde(default, deserialize_with = "deserialize_optional_mesh_target_size")]
143 target_size: Option<MeshTargetSize>,
144 #[serde(default)]
145 min_size: Option<f64>,
146 #[serde(default)]
147 max_size: Option<f64>,
148 #[serde(default)]
149 growth_rate: Option<f64>,
150 #[serde(default)]
151 refinement: FeaMeshRefinementDocument,
152 #[serde(default)]
153 validation: FeaMeshValidationDocument,
154}
155
156#[derive(Debug, Default, Deserialize)]
157#[serde(deny_unknown_fields)]
158struct FeaMeshValidationDocument {
159 #[serde(default)]
160 coverage: FeaMeshCoveragePreset,
161 #[serde(default)]
162 quality: FeaMeshQualityPreset,
163 #[serde(default)]
164 min_bounds_coverage_ratio: Option<f64>,
165 #[serde(default)]
166 min_volume_coverage_ratio: Option<f64>,
167 #[serde(default)]
168 min_boundary_area_ratio: Option<f64>,
169 #[serde(default)]
170 min_boundary_face_recovery_ratio: Option<f64>,
171 #[serde(default)]
172 min_boundary_edge_recovery_ratio: Option<f64>,
173 #[serde(default)]
174 max_volume_components: Option<usize>,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
178#[serde(rename_all = "snake_case")]
179enum FeaMeshCoveragePreset {
180 Strict,
181 #[default]
182 Balanced,
183 Relaxed,
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
187#[serde(rename_all = "snake_case")]
188enum FeaMeshQualityPreset {
189 Strict,
190 #[default]
191 Balanced,
192 Relaxed,
193}
194
195#[derive(Debug, Deserialize)]
196#[serde(deny_unknown_fields)]
197struct FeaMeshRefinementDocument {
198 #[serde(default = "default_refinement_strategy")]
199 strategy: RefinementStrategy,
200 #[serde(default = "default_refinement_max_iterations")]
201 max_iterations: usize,
202 #[serde(default)]
203 convergence: FeaRefinementConvergenceDocument,
204 #[serde(default)]
205 focus: FeaRefinementFocusDocument,
206 #[serde(default)]
207 indicators: BTreeMap<String, BTreeMap<String, RefinementIndicatorMode>>,
208}
209
210impl Default for FeaMeshRefinementDocument {
211 fn default() -> Self {
212 Self {
213 strategy: default_refinement_strategy(),
214 max_iterations: default_refinement_max_iterations(),
215 convergence: FeaRefinementConvergenceDocument::default(),
216 focus: FeaRefinementFocusDocument::default(),
217 indicators: BTreeMap::new(),
218 }
219 }
220}
221
222#[derive(Debug, Deserialize)]
223#[serde(deny_unknown_fields)]
224struct FeaRefinementConvergenceDocument {
225 #[serde(default = "default_field_change_tolerance")]
226 field_change_tolerance: f64,
227 #[serde(default = "default_energy_change_tolerance")]
228 energy_change_tolerance: f64,
229 #[serde(default, deserialize_with = "deserialize_optional_auto_f64")]
230 residual_tolerance: Option<f64>,
231}
232
233impl Default for FeaRefinementConvergenceDocument {
234 fn default() -> Self {
235 Self {
236 field_change_tolerance: default_field_change_tolerance(),
237 energy_change_tolerance: default_energy_change_tolerance(),
238 residual_tolerance: None,
239 }
240 }
241}
242
243#[derive(Debug, Deserialize)]
244#[serde(deny_unknown_fields)]
245struct FeaRefinementFocusDocument {
246 #[serde(default = "default_focus_fine")]
247 loads: RefinementFocusLevel,
248 #[serde(default = "default_focus_fine")]
249 constraints: RefinementFocusLevel,
250 #[serde(default = "default_focus_normal")]
251 interfaces: RefinementFocusLevel,
252 #[serde(default = "default_true")]
253 curvature: bool,
254 #[serde(default = "default_true")]
255 small_features: bool,
256}
257
258impl Default for FeaRefinementFocusDocument {
259 fn default() -> Self {
260 Self {
261 loads: default_focus_fine(),
262 constraints: default_focus_fine(),
263 interfaces: default_focus_normal(),
264 curvature: true,
265 small_features: true,
266 }
267 }
268}
269
270#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
271#[serde(rename_all = "snake_case")]
272enum FeaModelDefaultsMode {
273 #[default]
274 ProfileScaffold,
275 None,
276}
277
278#[derive(Debug, Deserialize)]
279#[serde(deny_unknown_fields)]
280struct FeaRegionDocument {
281 selector: String,
282}
283
284#[derive(Debug, Deserialize)]
285#[serde(deny_unknown_fields)]
286struct FeaMaterialDocument {
287 #[serde(default)]
288 name: Option<String>,
289 mechanical: MaterialMechanicalModel,
290 #[serde(default)]
291 thermal: Option<MaterialThermalModel>,
292 #[serde(default)]
293 acoustic: Option<MaterialAcousticModel>,
294 #[serde(default)]
295 electrical: Option<MaterialElectricalModel>,
296 #[serde(default)]
297 plastic: Option<MaterialPlasticModel>,
298}
299
300#[derive(Debug, Deserialize)]
301#[serde(deny_unknown_fields)]
302struct FeaMaterialAssignmentDocument {
303 region: String,
304 material: String,
305 #[serde(default)]
306 expected_material: Option<String>,
307 #[serde(default = "default_assignment_confidence")]
308 confidence: EvidenceConfidence,
309}
310
311#[derive(Debug, Default, Deserialize)]
312#[serde(deny_unknown_fields)]
313struct FeaStructuralDocument {
314 #[serde(default)]
315 nodes: Vec<FeaStructuralNodeDocument>,
316 #[serde(default)]
317 elements: Vec<FeaStructuralElementDocument>,
318 #[serde(default)]
319 sections: Vec<FeaStructuralSectionDocument>,
320}
321
322#[derive(Debug, Clone, Deserialize)]
323#[serde(deny_unknown_fields)]
324struct FeaStructuralNodeDocument {
325 id: u32,
326 coordinates_m: [f64; 3],
327}
328
329#[derive(Debug, Clone, Deserialize)]
330#[serde(deny_unknown_fields)]
331struct FeaStructuralElementDocument {
332 id: String,
333 region: String,
334 #[serde(rename = "type", alias = "kind")]
335 element_type: FeaStructuralElementType,
336 nodes: Vec<u32>,
337 section: String,
338 #[serde(default)]
339 reference_axis: Option<[f64; 3]>,
340}
341
342#[derive(Debug, Clone, Copy, Deserialize)]
343#[serde(rename_all = "snake_case")]
344enum FeaStructuralElementType {
345 Beam,
346 Shell,
347}
348
349#[derive(Debug, Clone, Deserialize)]
350#[serde(deny_unknown_fields)]
351struct FeaStructuralSectionDocument {
352 id: String,
353 #[serde(rename = "type", alias = "kind", default)]
354 section_type: FeaStructuralSectionType,
355 #[serde(default)]
356 area_m2: Option<f64>,
357 #[serde(default)]
358 iy_m4: Option<f64>,
359 #[serde(default)]
360 iz_m4: Option<f64>,
361 #[serde(default)]
362 torsion_j_m4: Option<f64>,
363 #[serde(default)]
364 thickness_m: Option<f64>,
365 #[serde(default)]
366 shear_correction: Option<f64>,
367 #[serde(default)]
368 drilling_stiffness_scale: Option<f64>,
369 #[serde(default)]
370 outer_fiber_y_m: f64,
371 #[serde(default)]
372 outer_fiber_z_m: f64,
373 #[serde(default)]
374 torsion_outer_radius_m: f64,
375}
376
377#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
378#[serde(rename_all = "snake_case")]
379enum FeaStructuralSectionType {
380 #[default]
381 Beam,
382 BeamSection,
383 Shell,
384 ShellSection,
385}
386
387impl FeaStructuralSectionType {
388 fn is_beam(self) -> bool {
389 matches!(self, Self::Beam | Self::BeamSection)
390 }
391
392 fn is_shell(self) -> bool {
393 matches!(self, Self::Shell | Self::ShellSection)
394 }
395}
396
397#[derive(Debug, Deserialize)]
398#[serde(deny_unknown_fields)]
399struct FeaBoundaryConditionDocument {
400 id: String,
401 region: String,
402 #[serde(alias = "type")]
403 kind: FeaBoundaryConditionKindDocument,
404 #[serde(default)]
405 rx: Option<f64>,
406 #[serde(default)]
407 ry: Option<f64>,
408 #[serde(default)]
409 rz: Option<f64>,
410 #[serde(default)]
411 specific_impedance_pa_s_per_m: Option<f64>,
412 #[serde(default)]
413 temperature_k: Option<f64>,
414 #[serde(default)]
415 heat_flux_w_per_m2: Option<f64>,
416 #[serde(default)]
417 ambient_temperature_k: Option<f64>,
418 #[serde(default)]
419 coefficient_w_per_m2k: Option<f64>,
420 #[serde(default)]
421 velocity_m_per_s: Option<f64>,
422 #[serde(default)]
423 pressure_pa: Option<f64>,
424}
425
426#[derive(Debug, Deserialize)]
427#[serde(untagged)]
428enum FeaBoundaryConditionKindDocument {
429 Native(BoundaryConditionKind),
430 Named(FeaBoundaryConditionType),
431}
432
433#[derive(Debug, Clone, Copy, Deserialize)]
434#[serde(rename_all = "snake_case")]
435enum FeaBoundaryConditionType {
436 Fixed,
437 PrescribedDisplacement,
438 PrescribedRotation,
439 MagneticInsulation,
440 VectorPotentialGround,
441 AcousticRigidWall,
442 AcousticRadiation,
443 AcousticImpedance,
444 ThermalPrescribedTemperature,
445 ThermalHeatFlux,
446 ThermalConvection,
447 CfdInletVelocity,
448 CfdOutletPressure,
449 CfdNoSlipWall,
450 CfdSlipWall,
451 CfdSymmetry,
452}
453
454#[derive(Debug, Deserialize)]
455#[serde(deny_unknown_fields)]
456struct FeaLoadDocument {
457 id: String,
458 region: String,
459 #[serde(rename = "type", alias = "kind")]
460 load_type: FeaLoadType,
461 #[serde(default)]
462 vector: Option<[f64; 3]>,
463 #[serde(default)]
464 force: Option<[f64; 3]>,
465 #[serde(default)]
466 moment: Option<[f64; 3]>,
467 #[serde(default)]
468 point: Option<[f64; 3]>,
469 #[serde(default)]
470 magnitude_pa: Option<f64>,
471 #[serde(default)]
472 current_a: Option<f64>,
473 #[serde(default)]
474 phase_rad: Option<f64>,
475 #[serde(default)]
476 amplitude_scale: Option<f64>,
477 #[serde(default)]
478 volumetric_w_per_m3: Option<f64>,
479}
480
481#[derive(Debug, Clone, Copy, Deserialize)]
482#[serde(rename_all = "snake_case")]
483enum FeaLoadType {
484 Force,
485 Moment,
486 Torque,
487 Wrench,
488 Pressure,
489 BodyForce,
490 CurrentDensity,
491 CoilCurrent,
492 HeatSource,
493}
494
495#[derive(Debug, Deserialize)]
496#[serde(deny_unknown_fields)]
497struct FeaStepDocument {
498 id: String,
499 kind: runmat_analysis_core::AnalysisStepKind,
500}
501
502#[derive(Debug, Deserialize)]
503#[serde(deny_unknown_fields)]
504struct FeaOutputDocument {
505 id: String,
506 #[serde(rename = "field_id", alias = "field", alias = "name")]
507 field_id: String,
508 #[serde(default, alias = "target")]
509 location: Option<String>,
510 #[serde(default, rename = "kind", alias = "type")]
511 kind: Option<String>,
512}
513
514#[derive(Debug, Default, Deserialize)]
515#[serde(deny_unknown_fields)]
516struct FeaDomainsDocument {
517 #[serde(default)]
518 thermo_mechanical: Option<ThermoMechanicalDomain>,
519 #[serde(default)]
520 electro_thermal: Option<ElectroThermalDomain>,
521 #[serde(default)]
522 electromagnetic: Option<ElectromagneticDomain>,
523 #[serde(default)]
524 cfd: Option<CfdDomain>,
525}
526
527#[derive(Debug, Deserialize)]
528#[serde(deny_unknown_fields)]
529struct FeaRunDocument {
530 #[serde(default)]
531 kind: Option<AnalysisRunKind>,
532 #[serde(default = "default_backend")]
533 backend: ComputeBackend,
534 #[serde(default)]
535 options: Option<serde_yaml::Value>,
536}
537
538#[derive(Debug, Clone, PartialEq)]
539struct ResolvedStudyParts {
540 spec: AnalysisStudySpec,
541}
542
543pub fn is_fea_file_path(path: &Path) -> bool {
544 path.extension()
545 .and_then(|ext| ext.to_str())
546 .is_some_and(|ext| ext.eq_ignore_ascii_case("fea"))
547}
548
549pub async fn load_fea_document_from_path_async(path: &Path) -> Result<FeaResolvedDocument, String> {
550 if !is_fea_file_path(path) {
551 return Err(format!(
552 "unsupported FEA document extension: {}",
553 path.display()
554 ));
555 }
556 let input = runmat_filesystem::read_to_string_async(path)
557 .await
558 .map_err(|err| format!("failed to read FEA document {}: {err}", path.display()))?;
559 let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
560 parse_and_resolve_fea_document(&input, base_dir).await
561}
562
563pub async fn parse_and_resolve_fea_document(
564 input: &str,
565 base_dir: &Path,
566) -> Result<FeaResolvedDocument, String> {
567 let raw = serde_yaml::from_str::<RawFeaDocument>(input)
568 .map_err(|err| format!("failed to parse FEA YAML: {err}"))?;
569 match raw {
570 RawFeaDocument::Study(study) => {
571 let resolved = resolve_study(*study, base_dir).await?;
572 Ok(FeaResolvedDocument::Study(Box::new(resolved.spec)))
573 }
574 RawFeaDocument::Sweep(sweep) => resolve_sweep(sweep, base_dir).await,
575 }
576}
577
578async fn resolve_sweep(
579 sweep: FeaSweepDocument,
580 base_dir: &Path,
581) -> Result<FeaResolvedDocument, String> {
582 validate_version(sweep.version)?;
583 if sweep.id.trim().is_empty() {
584 return Err("sweep id must be non-empty".to_string());
585 }
586 let mut studies = Vec::with_capacity(sweep.studies.len());
587 for study in sweep.studies {
588 studies.push(resolve_study(study, base_dir).await?.spec);
589 }
590 Ok(FeaResolvedDocument::Sweep(AnalysisStudySweepSpec {
591 sweep_id: sweep.id,
592 studies,
593 fail_fast: sweep.fail_fast,
594 }))
595}
596
597async fn resolve_study(
598 study: FeaStudyDocument,
599 base_dir: &Path,
600) -> Result<ResolvedStudyParts, String> {
601 validate_version(study.version)?;
602 if study.id.trim().is_empty() {
603 return Err("study id must be non-empty".to_string());
604 }
605
606 let geometry = load_geometry(&study.geometry, base_dir).await?;
607 let model_id = study
608 .model
609 .id
610 .clone()
611 .unwrap_or_else(|| format!("{}_model", sanitize_id(&study.id)));
612 let intent = AnalysisCreateModelIntentSpec {
613 model_id: model_id.clone(),
614 profile: study.model.profile,
615 prep_context: None,
616 };
617 let model = resolve_model(&study, &geometry, &intent)?;
618 let run_kind = resolve_run_kind(study.model.profile, &study.run)?;
619 let run_options = resolve_run_options(&study.run, run_kind)?;
620 if let Some(mesh) = study.mesh.as_ref() {
621 validate_refinement_indicator_applicability(
622 &mesh.refinement.indicators,
623 study.model.profile,
624 run_kind,
625 &study.domains,
626 )?;
627 }
628 let mesh_options = resolve_mesh_options(study.mesh.as_ref(), study.model.profile, run_kind)?;
629 let outputs = study.outputs.iter().map(resolve_output_request).collect();
630 let spec = AnalysisStudySpec {
631 study_id: study.id,
632 geometry,
633 create_model_intent: intent,
634 model,
635 run_kind,
636 backend: study.run.backend,
637 mesh_options,
638 outputs,
639 analysis_mesh_artifact_path: None,
640 analysis_mesh_evidence_artifact_path: None,
641 linear_static_run_options: run_options.linear_static,
642 modal_run_options: run_options.modal,
643 acoustic_run_options: run_options.acoustic,
644 thermal_run_options: run_options.thermal,
645 transient_run_options: run_options.transient,
646 cfd_run_options: run_options.cfd,
647 cht_run_options: run_options.cht,
648 fsi_run_options: run_options.fsi,
649 nonlinear_run_options: run_options.nonlinear,
650 electromagnetic_run_options: run_options.electromagnetic,
651 };
652 Ok(ResolvedStudyParts { spec })
653}
654
655fn resolve_output_request(output: &FeaOutputDocument) -> AnalysisOutputRequest {
656 AnalysisOutputRequest {
657 id: output.id.clone(),
658 field_id: output.field_id.clone(),
659 location: output.location.clone(),
660 kind: output.kind.clone(),
661 }
662}
663
664fn resolve_mesh_options(
665 mesh: Option<&FeaMeshDocument>,
666 profile: AnalysisCreateModelProfile,
667 run_kind: AnalysisRunKind,
668) -> Result<Option<VolumeMeshingOptions>, String> {
669 let Some(mesh) = mesh else {
670 return Ok(default_mesh_options_for_study(profile, run_kind));
671 };
672 if mesh.max_elements == 0 {
673 return Err("mesh.max_elements must be greater than zero".to_string());
674 }
675 if !matches!(mesh.kind, MeshKindRequest::Solid) {
676 return Err(format!(
677 "mesh.kind {:?} is not supported by the current analysis mesher; use mesh.kind: solid",
678 mesh.kind
679 ));
680 }
681 if !mesh.element.is_supported_for_solid_solve() {
682 return Err(format!(
683 "mesh.element {:?} is not supported for mesh.kind solid; use mesh.element: tetrahedron4",
684 mesh.element
685 ));
686 }
687 if let Some(min_size) = mesh.min_size {
688 if !min_size.is_finite() || min_size <= 0.0 {
689 return Err("mesh.min_size must be finite and positive".to_string());
690 }
691 }
692 if let Some(max_size) = mesh.max_size {
693 if !max_size.is_finite() || max_size <= 0.0 {
694 return Err("mesh.max_size must be finite and positive".to_string());
695 }
696 }
697 if let (Some(min_size), Some(max_size)) = (mesh.min_size, mesh.max_size) {
698 if min_size > max_size {
699 return Err("mesh.min_size must be less than or equal to mesh.max_size".to_string());
700 }
701 }
702 if let Some(growth_rate) = mesh.growth_rate {
703 if !growth_rate.is_finite() || growth_rate < 1.0 {
704 return Err("mesh.growth_rate must be finite and at least 1.0".to_string());
705 }
706 }
707 if !mesh
708 .refinement
709 .convergence
710 .field_change_tolerance
711 .is_finite()
712 || mesh.refinement.convergence.field_change_tolerance <= 0.0
713 {
714 return Err(
715 "mesh.refinement.convergence.field_change_tolerance must be finite and positive"
716 .to_string(),
717 );
718 }
719 if !mesh
720 .refinement
721 .convergence
722 .energy_change_tolerance
723 .is_finite()
724 || mesh.refinement.convergence.energy_change_tolerance <= 0.0
725 {
726 return Err(
727 "mesh.refinement.convergence.energy_change_tolerance must be finite and positive"
728 .to_string(),
729 );
730 }
731 if let Some(residual_tolerance) = mesh.refinement.convergence.residual_tolerance {
732 if !residual_tolerance.is_finite() || residual_tolerance <= 0.0 {
733 return Err(
734 "mesh.refinement.convergence.residual_tolerance must be finite and positive"
735 .to_string(),
736 );
737 }
738 }
739 let validation = resolve_mesh_validation_policy(&mesh.validation)?;
740 if matches!(mesh.refinement.strategy, RefinementStrategy::None)
741 && mesh.refinement.max_iterations > 0
742 {
743 return Err(
744 "mesh.refinement.max_iterations must be 0 when mesh.refinement.strategy is none"
745 .to_string(),
746 );
747 }
748 validate_refinement_indicators(&mesh.refinement.indicators)?;
749
750 Ok(Some(VolumeMeshingOptions {
751 backend: mesh.backend,
752 kind: mesh.kind,
753 element: mesh.element,
754 element_order: mesh.element_order,
755 profile: mesh.profile,
756 max_elements: mesh.max_elements,
757 target_size: mesh.target_size.unwrap_or(MeshTargetSize::Auto),
758 min_size_m: mesh.min_size,
759 max_size_m: mesh.max_size,
760 growth_rate: mesh.growth_rate,
761 refinement: MeshRefinementOptions {
762 strategy: mesh.refinement.strategy,
763 max_iterations: mesh.refinement.max_iterations,
764 convergence: RefinementConvergenceOptions {
765 field_change_tolerance: mesh.refinement.convergence.field_change_tolerance,
766 energy_change_tolerance: mesh.refinement.convergence.energy_change_tolerance,
767 residual_tolerance: mesh.refinement.convergence.residual_tolerance,
768 },
769 focus: RefinementFocusOptions {
770 loads: mesh.refinement.focus.loads,
771 constraints: mesh.refinement.focus.constraints,
772 interfaces: mesh.refinement.focus.interfaces,
773 curvature: mesh.refinement.focus.curvature,
774 small_features: mesh.refinement.focus.small_features,
775 },
776 indicators: RefinementIndicatorOverrides {
777 namespaces: mesh.refinement.indicators.clone(),
778 },
779 },
780 validation,
781 }))
782}
783
784fn resolve_mesh_validation_policy(
785 validation: &FeaMeshValidationDocument,
786) -> Result<MeshValidationPolicyOptions, String> {
787 let mut policy = coverage_preset_options(validation.coverage);
788 policy.quality = quality_preset_thresholds(validation.quality);
789 apply_optional_unit_ratio(
790 "mesh.validation.min_bounds_coverage_ratio",
791 validation.min_bounds_coverage_ratio,
792 &mut policy.min_bounds_coverage_ratio,
793 )?;
794 apply_optional_unit_ratio(
795 "mesh.validation.min_volume_coverage_ratio",
796 validation.min_volume_coverage_ratio,
797 &mut policy.min_volume_coverage_ratio,
798 )?;
799 apply_optional_unit_ratio(
800 "mesh.validation.min_boundary_area_ratio",
801 validation.min_boundary_area_ratio,
802 &mut policy.min_boundary_area_ratio,
803 )?;
804 apply_optional_unit_ratio(
805 "mesh.validation.min_boundary_face_recovery_ratio",
806 validation.min_boundary_face_recovery_ratio,
807 &mut policy.min_boundary_face_recovery_ratio,
808 )?;
809 apply_optional_unit_ratio(
810 "mesh.validation.min_boundary_edge_recovery_ratio",
811 validation.min_boundary_edge_recovery_ratio,
812 &mut policy.min_boundary_edge_recovery_ratio,
813 )?;
814 if matches!(validation.max_volume_components, Some(0)) {
815 return Err("mesh.validation.max_volume_components must be greater than zero".to_string());
816 }
817 policy.max_volume_component_count = validation.max_volume_components;
818 Ok(policy)
819}
820
821fn coverage_preset_options(preset: FeaMeshCoveragePreset) -> MeshValidationPolicyOptions {
822 match preset {
823 FeaMeshCoveragePreset::Strict => MeshValidationPolicyOptions {
824 min_bounds_coverage_ratio: 1.0,
825 min_volume_coverage_ratio: 1.0,
826 min_boundary_area_ratio: 1.0,
827 min_boundary_face_recovery_ratio: 1.0,
828 min_boundary_edge_recovery_ratio: 1.0,
829 ..MeshValidationPolicyOptions::default()
830 },
831 FeaMeshCoveragePreset::Balanced => MeshValidationPolicyOptions::default(),
832 FeaMeshCoveragePreset::Relaxed => MeshValidationPolicyOptions {
833 min_bounds_coverage_ratio: 0.80,
834 min_volume_coverage_ratio: 0.80,
835 min_boundary_area_ratio: 0.80,
836 min_boundary_face_recovery_ratio: 0.95,
837 min_boundary_edge_recovery_ratio: 0.95,
838 ..MeshValidationPolicyOptions::default()
839 },
840 }
841}
842
843fn quality_preset_thresholds(preset: FeaMeshQualityPreset) -> QualityThresholds {
844 match preset {
845 FeaMeshQualityPreset::Strict => QualityThresholds {
846 min_scaled_jacobian: 0.25,
847 max_aspect_ratio: 10.0,
848 max_boundary_projection_error_m: 1.0e-8,
849 allow_inverted_elements: false,
850 },
851 FeaMeshQualityPreset::Balanced => QualityThresholds::default(),
852 FeaMeshQualityPreset::Relaxed => QualityThresholds {
853 min_scaled_jacobian: 0.05,
854 max_aspect_ratio: 50.0,
855 max_boundary_projection_error_m: 1.0e-4,
856 allow_inverted_elements: false,
857 },
858 }
859}
860
861fn apply_optional_unit_ratio(
862 label: &str,
863 value: Option<f64>,
864 target: &mut f64,
865) -> Result<(), String> {
866 if let Some(value) = value {
867 validate_unit_ratio(label, value)?;
868 *target = value;
869 }
870 Ok(())
871}
872
873fn validate_unit_ratio(label: &str, value: f64) -> Result<(), String> {
874 if !value.is_finite() || value <= 0.0 || value > 1.0 {
875 return Err(format!("{label} must be finite and in the range (0, 1]"));
876 }
877 Ok(())
878}
879
880fn default_mesh_options_for_study(
881 profile: AnalysisCreateModelProfile,
882 run_kind: AnalysisRunKind,
883) -> Option<VolumeMeshingOptions> {
884 (matches!(profile, AnalysisCreateModelProfile::LinearStaticStructural)
885 && matches!(run_kind, AnalysisRunKind::LinearStatic))
886 .then(VolumeMeshingOptions::default)
887}
888
889fn validate_refinement_indicators(
890 indicators: &BTreeMap<String, BTreeMap<String, RefinementIndicatorMode>>,
891) -> Result<(), String> {
892 for (namespace, names) in indicators {
893 let allowed = allowed_refinement_indicator_names(namespace)
894 .ok_or_else(|| format!("unknown mesh.refinement.indicators namespace `{namespace}`"))?;
895 for name in names.keys() {
896 if !allowed.contains(&name.as_str()) {
897 return Err(format!(
898 "unknown mesh.refinement.indicators.{namespace}.{name}"
899 ));
900 }
901 }
902 }
903 Ok(())
904}
905
906fn validate_refinement_indicator_applicability(
907 indicators: &BTreeMap<String, BTreeMap<String, RefinementIndicatorMode>>,
908 profile: AnalysisCreateModelProfile,
909 run_kind: AnalysisRunKind,
910 domains: &FeaDomainsDocument,
911) -> Result<(), String> {
912 for namespace in indicators.keys() {
913 if !refinement_indicator_namespace_applies(namespace, profile, run_kind, domains) {
914 return Err(format!(
915 "mesh.refinement.indicators.{namespace} does not apply to profile `{}` and run kind `{}`",
916 profile.as_snake_case(),
917 run_kind.as_snake_case()
918 ));
919 }
920 }
921 Ok(())
922}
923
924fn refinement_indicator_namespace_applies(
925 namespace: &str,
926 profile: AnalysisCreateModelProfile,
927 run_kind: AnalysisRunKind,
928 domains: &FeaDomainsDocument,
929) -> bool {
930 match namespace {
931 "structural" => {
932 matches!(
933 profile,
934 AnalysisCreateModelProfile::LinearStaticStructural
935 | AnalysisCreateModelProfile::TransientStructural
936 | AnalysisCreateModelProfile::NonlinearStructural
937 | AnalysisCreateModelProfile::ThermoMechanicalCoupled
938 | AnalysisCreateModelProfile::FsiCoupled
939 ) || matches!(
940 run_kind,
941 AnalysisRunKind::LinearStatic
942 | AnalysisRunKind::Transient
943 | AnalysisRunKind::Nonlinear
944 | AnalysisRunKind::Fsi
945 )
946 }
947 "modal" => {
948 matches!(profile, AnalysisCreateModelProfile::ModalStructural)
949 || matches!(run_kind, AnalysisRunKind::Modal)
950 }
951 "thermal" => {
952 matches!(
953 profile,
954 AnalysisCreateModelProfile::ThermalStandalone
955 | AnalysisCreateModelProfile::ThermoMechanicalCoupled
956 | AnalysisCreateModelProfile::ElectroThermalCoupled
957 | AnalysisCreateModelProfile::ChtCoupled
958 ) || matches!(run_kind, AnalysisRunKind::Thermal | AnalysisRunKind::Cht)
959 || domains.thermo_mechanical.is_some()
960 || domains.electro_thermal.is_some()
961 }
962 "thermo_mechanical" => {
963 matches!(profile, AnalysisCreateModelProfile::ThermoMechanicalCoupled)
964 || domains.thermo_mechanical.is_some()
965 }
966 "electro_thermal" => {
967 matches!(profile, AnalysisCreateModelProfile::ElectroThermalCoupled)
968 || domains.electro_thermal.is_some()
969 }
970 "electromagnetic" => {
971 matches!(
972 profile,
973 AnalysisCreateModelProfile::ElectromagneticStatic
974 | AnalysisCreateModelProfile::ElectroThermalCoupled
975 ) || matches!(run_kind, AnalysisRunKind::Electromagnetic)
976 || domains.electromagnetic.is_some()
977 || domains.electro_thermal.is_some()
978 }
979 "acoustic" => {
980 matches!(profile, AnalysisCreateModelProfile::AcousticHarmonic)
981 || matches!(run_kind, AnalysisRunKind::Acoustic)
982 }
983 "cfd" => {
984 matches!(
985 profile,
986 AnalysisCreateModelProfile::CfdSteadyState
987 | AnalysisCreateModelProfile::CfdTransient
988 | AnalysisCreateModelProfile::ChtCoupled
989 | AnalysisCreateModelProfile::FsiCoupled
990 ) || matches!(
991 run_kind,
992 AnalysisRunKind::Cfd | AnalysisRunKind::Cht | AnalysisRunKind::Fsi
993 ) || domains.cfd.is_some()
994 }
995 "cht" => {
996 matches!(profile, AnalysisCreateModelProfile::ChtCoupled)
997 || matches!(run_kind, AnalysisRunKind::Cht)
998 }
999 "fsi" => {
1000 matches!(profile, AnalysisCreateModelProfile::FsiCoupled)
1001 || matches!(run_kind, AnalysisRunKind::Fsi)
1002 }
1003 _ => false,
1004 }
1005}
1006
1007fn allowed_refinement_indicator_names(namespace: &str) -> Option<&'static [&'static str]> {
1008 match namespace {
1009 "structural" => Some(&[
1010 "stress_gradient",
1011 "strain_energy_density",
1012 "displacement_gradient",
1013 "load_regions",
1014 "constraint_regions",
1015 "plastic_strain",
1016 "contact_pressure",
1017 "contact_gap",
1018 ]),
1019 "modal" => Some(&[
1020 "mode_shape_curvature",
1021 "modal_strain_energy",
1022 "frequency_residual",
1023 ]),
1024 "thermal" => Some(&[
1025 "temperature_gradient",
1026 "heat_flux_gradient",
1027 "heat_source",
1028 "convection_regions",
1029 "prescribed_temperature_regions",
1030 ]),
1031 "thermo_mechanical" => Some(&[
1032 "thermal_gradient",
1033 "thermal_stress",
1034 "structural_von_mises",
1035 "strain_energy_density",
1036 "region_temperature_delta",
1037 ]),
1038 "electro_thermal" => Some(&[
1039 "electric_field_gradient",
1040 "current_density_gradient",
1041 "joule_heat_density",
1042 "thermal_gradient",
1043 "source_regions",
1044 "ground_regions",
1045 ]),
1046 "electromagnetic" => Some(&[
1047 "flux_density_gradient",
1048 "electric_field_gradient",
1049 "current_density_gradient",
1050 "energy_density",
1051 "source_regions",
1052 "ground_regions",
1053 "insulation_regions",
1054 ]),
1055 "acoustic" => Some(&[
1056 "pressure_gradient",
1057 "pressure_curvature",
1058 "wavelength",
1059 "impedance_regions",
1060 "source_regions",
1061 ]),
1062 "cfd" => Some(&[
1063 "velocity_gradient",
1064 "pressure_gradient",
1065 "vorticity",
1066 "wall_shear",
1067 "boundary_layer",
1068 "inlet_regions",
1069 "outlet_regions",
1070 ]),
1071 "cht" => Some(&[
1072 "interface_heat_flux_jump",
1073 "interface_temperature_jump",
1074 "solid_heat_flux_gradient",
1075 "fluid_boundary_layer",
1076 ]),
1077 "fsi" => Some(&[
1078 "interface_displacement_jump",
1079 "interface_traction_jump",
1080 "structural_stress_gradient",
1081 "fluid_pressure_gradient",
1082 "fluid_velocity_gradient",
1083 ]),
1084 _ => None,
1085 }
1086}
1087
1088async fn load_geometry(
1089 geometry: &FeaGeometryDocument,
1090 base_dir: &Path,
1091) -> Result<GeometryAsset, String> {
1092 let path = resolve_document_path(base_dir, &geometry.path);
1093 let bytes = runmat_filesystem::read_async(&path)
1094 .await
1095 .map_err(|err| format!("failed to read geometry file {}: {err}", path.display()))?;
1096 let options = GeometryImportOptions {
1097 max_triangles: geometry.import.max_triangles.or(Some(16_000_000)),
1098 budget_policy: runmat_geometry_io::GeometryImportBudgetPolicy::Strict,
1099 units: geometry.units,
1100 tessellation_profile: Default::default(),
1101 relative_deflection: false,
1102 };
1103 crate::geometry::geometry_load_with_options_op(
1104 &path.to_string_lossy(),
1105 &bytes,
1106 options,
1107 OperationContext::new(None, None),
1108 )
1109 .map(|envelope| envelope.data)
1110 .map_err(|err| {
1111 format!(
1112 "failed to load geometry {}: {}",
1113 path.display(),
1114 err.message
1115 )
1116 })
1117}
1118
1119fn resolve_model(
1120 study: &FeaStudyDocument,
1121 geometry: &GeometryAsset,
1122 intent: &AnalysisCreateModelIntentSpec,
1123) -> Result<Option<AnalysisModel>, String> {
1124 if !has_explicit_model_data(study)
1125 && study.model.defaults == FeaModelDefaultsMode::ProfileScaffold
1126 {
1127 return Ok(None);
1128 }
1129
1130 let mut model = match study.model.defaults {
1131 FeaModelDefaultsMode::ProfileScaffold => {
1132 analysis_create_model_op(geometry, intent.clone(), OperationContext::new(None, None))
1133 .map(|envelope| envelope.data)
1134 .map_err(|err| format!("failed to create FEA model scaffold: {}", err.message))?
1135 }
1136 FeaModelDefaultsMode::None => empty_model(intent.model_id.clone(), geometry),
1137 };
1138
1139 if let Some(frame) = &study.model.frame {
1140 model.frame = frame.clone();
1141 }
1142 if !study.materials.is_empty() {
1143 model.materials = study
1144 .materials
1145 .iter()
1146 .map(|(id, material)| resolve_material(id, material))
1147 .collect();
1148 }
1149 if !study.material_assignments.is_empty() {
1150 model.material_assignments = study
1151 .material_assignments
1152 .iter()
1153 .map(|assignment| resolve_material_assignment(assignment, geometry, &study.regions))
1154 .collect::<Result<Vec<_>, _>>()?;
1155 }
1156 if has_structural_model_data(study) {
1157 model.structural = Some(resolve_structural_model(study, geometry)?);
1158 }
1159 if !study.boundary_conditions.is_empty() {
1160 model.boundary_conditions = study
1161 .boundary_conditions
1162 .iter()
1163 .map(|bc| resolve_boundary_condition(bc, geometry, &study.regions))
1164 .collect::<Result<Vec<_>, _>>()?;
1165 }
1166 if !study.loads.is_empty() {
1167 model.loads = study
1168 .loads
1169 .iter()
1170 .map(|load| resolve_load(load, geometry, &study.regions))
1171 .collect::<Result<Vec<_>, _>>()?;
1172 }
1173 if !study.steps.is_empty() {
1174 model.steps = study
1175 .steps
1176 .iter()
1177 .map(|step| AnalysisStep {
1178 step_id: step.id.clone(),
1179 kind: step.kind.clone(),
1180 })
1181 .collect();
1182 }
1183 if study.domains.thermo_mechanical.is_some() {
1184 model.thermo_mechanical = study.domains.thermo_mechanical.clone();
1185 }
1186 if study.domains.electro_thermal.is_some() {
1187 model.electro_thermal = study.domains.electro_thermal.clone();
1188 }
1189 if study.domains.electromagnetic.is_some() {
1190 model.electromagnetic = study.domains.electromagnetic.clone();
1191 }
1192 if study.domains.cfd.is_some() {
1193 model.cfd = study.domains.cfd.clone();
1194 }
1195 if !study.interfaces.is_empty() {
1196 model.interfaces = study.interfaces.clone();
1197 }
1198
1199 Ok(Some(model))
1200}
1201
1202fn resolve_material(id: &str, material: &FeaMaterialDocument) -> MaterialModel {
1203 MaterialModel {
1204 material_id: id.to_string(),
1205 name: material.name.clone().unwrap_or_else(|| id.to_string()),
1206 mechanical: material.mechanical.clone(),
1207 thermal: material.thermal.clone().unwrap_or_default(),
1208 acoustic: material.acoustic.clone(),
1209 electrical: material.electrical.clone(),
1210 plastic: material.plastic.clone(),
1211 }
1212}
1213
1214fn resolve_material_assignment(
1215 assignment: &FeaMaterialAssignmentDocument,
1216 geometry: &GeometryAsset,
1217 aliases: &BTreeMap<String, FeaRegionDocument>,
1218) -> Result<MaterialAssignment, String> {
1219 let region_id = resolve_region_ref(&assignment.region, geometry, aliases)?;
1220 Ok(MaterialAssignment {
1221 region_id,
1222 expected_material_id: assignment
1223 .expected_material
1224 .clone()
1225 .unwrap_or_else(|| assignment.material.clone()),
1226 assigned_material_id: assignment.material.clone(),
1227 confidence: assignment.confidence,
1228 })
1229}
1230
1231fn resolve_structural_model(
1232 study: &FeaStudyDocument,
1233 geometry: &GeometryAsset,
1234) -> Result<StructuralModel, String> {
1235 let structural = study.structural.as_ref();
1236 let node_docs = structural
1237 .map(|value| value.nodes.as_slice())
1238 .unwrap_or(study.nodes.as_slice());
1239 let element_docs = structural
1240 .map(|value| value.elements.as_slice())
1241 .unwrap_or(study.elements.as_slice());
1242 let section_docs = structural
1243 .map(|value| value.sections.as_slice())
1244 .unwrap_or(study.sections.as_slice());
1245
1246 Ok(StructuralModel {
1247 nodes: node_docs
1248 .iter()
1249 .map(|node| StructuralNode {
1250 node_id: node.id,
1251 coordinates_m: node.coordinates_m,
1252 })
1253 .collect(),
1254 elements: element_docs
1255 .iter()
1256 .map(|element| resolve_structural_element(element, geometry, &study.regions))
1257 .collect::<Result<Vec<_>, _>>()?,
1258 beam_sections: section_docs
1259 .iter()
1260 .filter(|section| section.section_type.is_beam())
1261 .map(resolve_beam_section)
1262 .collect::<Result<Vec<_>, _>>()?,
1263 shell_sections: section_docs
1264 .iter()
1265 .filter(|section| section.section_type.is_shell())
1266 .map(resolve_shell_section)
1267 .collect::<Result<Vec<_>, _>>()?,
1268 })
1269}
1270
1271fn resolve_structural_element(
1272 element: &FeaStructuralElementDocument,
1273 geometry: &GeometryAsset,
1274 aliases: &BTreeMap<String, FeaRegionDocument>,
1275) -> Result<StructuralElement, String> {
1276 let kind = match element.element_type {
1277 FeaStructuralElementType::Beam => {
1278 let node_ids: [u32; 2] = element.nodes.as_slice().try_into().map_err(|_| {
1279 format!(
1280 "beam element {} must specify exactly two node ids",
1281 element.id
1282 )
1283 })?;
1284 StructuralElementKind::Beam(BeamElementModel {
1285 node_ids,
1286 section_id: element.section.clone(),
1287 reference_axis: element.reference_axis.unwrap_or([0.0, 0.0, 1.0]),
1288 })
1289 }
1290 FeaStructuralElementType::Shell => {
1291 let node_ids: [u32; 3] = element.nodes.as_slice().try_into().map_err(|_| {
1292 format!(
1293 "shell element {} must specify exactly three node ids",
1294 element.id
1295 )
1296 })?;
1297 StructuralElementKind::Shell(ShellElementModel {
1298 node_ids,
1299 section_id: element.section.clone(),
1300 reference_axis: element.reference_axis.unwrap_or([1.0, 0.0, 0.0]),
1301 })
1302 }
1303 };
1304 Ok(StructuralElement {
1305 element_id: element.id.clone(),
1306 region_id: resolve_region_ref(&element.region, geometry, aliases)?,
1307 kind,
1308 })
1309}
1310
1311fn resolve_beam_section(
1312 section: &FeaStructuralSectionDocument,
1313) -> Result<BeamSectionModel, String> {
1314 Ok(BeamSectionModel {
1315 section_id: section.id.clone(),
1316 area_m2: required_f64(section.area_m2, "beam_section.area_m2")?,
1317 iy_m4: required_f64(section.iy_m4, "beam_section.iy_m4")?,
1318 iz_m4: required_f64(section.iz_m4, "beam_section.iz_m4")?,
1319 torsion_j_m4: required_f64(section.torsion_j_m4, "beam_section.torsion_j_m4")?,
1320 outer_fiber_y_m: section.outer_fiber_y_m,
1321 outer_fiber_z_m: section.outer_fiber_z_m,
1322 torsion_outer_radius_m: section.torsion_outer_radius_m,
1323 })
1324}
1325
1326fn resolve_shell_section(
1327 section: &FeaStructuralSectionDocument,
1328) -> Result<ShellSectionModel, String> {
1329 Ok(ShellSectionModel {
1330 section_id: section.id.clone(),
1331 thickness_m: required_f64(section.thickness_m, "shell_section.thickness_m")?,
1332 shear_correction: section.shear_correction.unwrap_or(5.0 / 6.0),
1333 drilling_stiffness_scale: section.drilling_stiffness_scale.unwrap_or(1.0e-4),
1334 })
1335}
1336
1337fn resolve_boundary_condition(
1338 bc: &FeaBoundaryConditionDocument,
1339 geometry: &GeometryAsset,
1340 aliases: &BTreeMap<String, FeaRegionDocument>,
1341) -> Result<BoundaryCondition, String> {
1342 let kind = match &bc.kind {
1343 FeaBoundaryConditionKindDocument::Native(kind) => kind.clone(),
1344 FeaBoundaryConditionKindDocument::Named(kind) => {
1345 resolve_boundary_condition_kind(bc, *kind)?
1346 }
1347 };
1348 Ok(BoundaryCondition {
1349 bc_id: bc.id.clone(),
1350 region_id: resolve_region_ref(&bc.region, geometry, aliases)?,
1351 kind,
1352 })
1353}
1354
1355fn resolve_boundary_condition_kind(
1356 bc: &FeaBoundaryConditionDocument,
1357 kind: FeaBoundaryConditionType,
1358) -> Result<BoundaryConditionKind, String> {
1359 Ok(match kind {
1360 FeaBoundaryConditionType::Fixed => BoundaryConditionKind::Fixed,
1361 FeaBoundaryConditionType::PrescribedDisplacement => {
1362 BoundaryConditionKind::PrescribedDisplacement
1363 }
1364 FeaBoundaryConditionType::PrescribedRotation => BoundaryConditionKind::PrescribedRotation {
1365 rx: required_f64(bc.rx, "boundary.prescribed_rotation.rx")?,
1366 ry: required_f64(bc.ry, "boundary.prescribed_rotation.ry")?,
1367 rz: required_f64(bc.rz, "boundary.prescribed_rotation.rz")?,
1368 },
1369 FeaBoundaryConditionType::MagneticInsulation => BoundaryConditionKind::MagneticInsulation,
1370 FeaBoundaryConditionType::VectorPotentialGround => {
1371 BoundaryConditionKind::VectorPotentialGround
1372 }
1373 FeaBoundaryConditionType::AcousticRigidWall => BoundaryConditionKind::AcousticRigidWall,
1374 FeaBoundaryConditionType::AcousticRadiation => BoundaryConditionKind::AcousticRadiation,
1375 FeaBoundaryConditionType::AcousticImpedance => BoundaryConditionKind::AcousticImpedance {
1376 specific_impedance_pa_s_per_m: required_f64(
1377 bc.specific_impedance_pa_s_per_m,
1378 "boundary.acoustic_impedance.specific_impedance_pa_s_per_m",
1379 )?,
1380 },
1381 FeaBoundaryConditionType::ThermalPrescribedTemperature => {
1382 BoundaryConditionKind::ThermalPrescribedTemperature {
1383 temperature_k: required_f64(
1384 bc.temperature_k,
1385 "boundary.thermal_prescribed_temperature.temperature_k",
1386 )?,
1387 }
1388 }
1389 FeaBoundaryConditionType::ThermalHeatFlux => BoundaryConditionKind::ThermalHeatFlux {
1390 heat_flux_w_per_m2: required_f64(
1391 bc.heat_flux_w_per_m2,
1392 "boundary.thermal_heat_flux.heat_flux_w_per_m2",
1393 )?,
1394 },
1395 FeaBoundaryConditionType::ThermalConvection => BoundaryConditionKind::ThermalConvection {
1396 ambient_temperature_k: required_f64(
1397 bc.ambient_temperature_k,
1398 "boundary.thermal_convection.ambient_temperature_k",
1399 )?,
1400 coefficient_w_per_m2k: required_f64(
1401 bc.coefficient_w_per_m2k,
1402 "boundary.thermal_convection.coefficient_w_per_m2k",
1403 )?,
1404 },
1405 FeaBoundaryConditionType::CfdInletVelocity => BoundaryConditionKind::CfdInletVelocity {
1406 velocity_m_per_s: required_f64(
1407 bc.velocity_m_per_s,
1408 "boundary.cfd_inlet_velocity.velocity_m_per_s",
1409 )?,
1410 },
1411 FeaBoundaryConditionType::CfdOutletPressure => BoundaryConditionKind::CfdOutletPressure {
1412 pressure_pa: required_f64(bc.pressure_pa, "boundary.cfd_outlet_pressure.pressure_pa")?,
1413 },
1414 FeaBoundaryConditionType::CfdNoSlipWall => BoundaryConditionKind::CfdNoSlipWall,
1415 FeaBoundaryConditionType::CfdSlipWall => BoundaryConditionKind::CfdSlipWall,
1416 FeaBoundaryConditionType::CfdSymmetry => BoundaryConditionKind::CfdSymmetry,
1417 })
1418}
1419
1420fn resolve_load(
1421 load: &FeaLoadDocument,
1422 geometry: &GeometryAsset,
1423 aliases: &BTreeMap<String, FeaRegionDocument>,
1424) -> Result<LoadCase, String> {
1425 let kind = match load.load_type {
1426 FeaLoadType::Force => {
1427 let [fx, fy, fz] = load_vector(load, "force")?;
1428 LoadKind::Force { fx, fy, fz }
1429 }
1430 FeaLoadType::Moment | FeaLoadType::Torque => {
1431 let [mx, my, mz] = load_vector(load, "moment")?;
1432 LoadKind::Moment { mx, my, mz }
1433 }
1434 FeaLoadType::Wrench => {
1435 let [fx, fy, fz] = load_force(load)?;
1436 let [mx, my, mz] = load_moment(load)?;
1437 let [px, py, pz] = point_in_meters(load_point(load)?, geometry.units);
1438 LoadKind::Wrench {
1439 fx,
1440 fy,
1441 fz,
1442 mx,
1443 my,
1444 mz,
1445 px,
1446 py,
1447 pz,
1448 }
1449 }
1450 FeaLoadType::Pressure => LoadKind::Pressure {
1451 magnitude_pa: required_f64(load.magnitude_pa, "pressure.magnitude_pa")?,
1452 },
1453 FeaLoadType::BodyForce => {
1454 let [gx, gy, gz] = load_vector(load, "body_force")?;
1455 LoadKind::BodyForce { gx, gy, gz }
1456 }
1457 FeaLoadType::CurrentDensity => {
1458 let [jx, jy, jz] = load_vector(load, "current_density")?;
1459 LoadKind::CurrentDensity {
1460 jx,
1461 jy,
1462 jz,
1463 phase_rad: load.phase_rad.unwrap_or_default(),
1464 amplitude_scale: load.amplitude_scale.unwrap_or(1.0),
1465 }
1466 }
1467 FeaLoadType::CoilCurrent => LoadKind::CoilCurrent {
1468 current_a: required_f64(load.current_a, "coil_current.current_a")?,
1469 phase_rad: load.phase_rad.unwrap_or_default(),
1470 amplitude_scale: load.amplitude_scale.unwrap_or(1.0),
1471 },
1472 FeaLoadType::HeatSource => LoadKind::HeatSource {
1473 volumetric_w_per_m3: required_f64(
1474 load.volumetric_w_per_m3,
1475 "heat_source.volumetric_w_per_m3",
1476 )?,
1477 },
1478 };
1479 Ok(LoadCase {
1480 load_id: load.id.clone(),
1481 region_id: resolve_region_ref(&load.region, geometry, aliases)?,
1482 kind,
1483 })
1484}
1485
1486#[derive(Debug, Default)]
1487struct ResolvedRunOptions {
1488 linear_static: Option<AnalysisRunOptions>,
1489 modal: Option<AnalysisModalRunOptions>,
1490 acoustic: Option<AnalysisAcousticRunOptions>,
1491 thermal: Option<AnalysisThermalRunOptions>,
1492 transient: Option<AnalysisTransientRunOptions>,
1493 cfd: Option<AnalysisCfdRunOptions>,
1494 cht: Option<AnalysisChtRunOptions>,
1495 fsi: Option<AnalysisFsiRunOptions>,
1496 nonlinear: Option<AnalysisNonlinearRunOptions>,
1497 electromagnetic: Option<AnalysisElectromagneticRunOptions>,
1498}
1499
1500fn resolve_run_kind(
1501 profile: AnalysisCreateModelProfile,
1502 run: &FeaRunDocument,
1503) -> Result<AnalysisRunKind, String> {
1504 let derived = profile.derived_run_kind();
1505 if let Some(explicit) = run.kind {
1506 if explicit != derived {
1507 return Err(format!(
1508 "run.kind {:?} does not match the solver selected by model.profile {:?}; omit run.kind unless you need an advanced matching solver override",
1509 explicit, profile
1510 ));
1511 }
1512 }
1513 Ok(derived)
1514}
1515
1516fn resolve_run_options(
1517 run: &FeaRunDocument,
1518 run_kind: AnalysisRunKind,
1519) -> Result<ResolvedRunOptions, String> {
1520 let Some(options) = run.options.clone() else {
1521 return Ok(ResolvedRunOptions::default());
1522 };
1523 let mut resolved = ResolvedRunOptions::default();
1524 match run_kind {
1525 AnalysisRunKind::LinearStatic => {
1526 resolved.linear_static = Some(parse_options(options, "linear_static options")?);
1527 }
1528 AnalysisRunKind::Modal => {
1529 resolved.modal = Some(parse_options(options, "modal options")?);
1530 }
1531 AnalysisRunKind::Acoustic => {
1532 resolved.acoustic = Some(parse_options(options, "acoustic options")?);
1533 }
1534 AnalysisRunKind::Thermal => {
1535 resolved.thermal = Some(parse_options(options, "thermal options")?);
1536 }
1537 AnalysisRunKind::Transient => {
1538 resolved.transient = Some(parse_options(options, "transient options")?);
1539 }
1540 AnalysisRunKind::Cfd => {
1541 resolved.cfd = Some(parse_options(options, "cfd options")?);
1542 }
1543 AnalysisRunKind::Cht => {
1544 resolved.cht = Some(parse_options(options, "cht options")?);
1545 }
1546 AnalysisRunKind::Fsi => {
1547 resolved.fsi = Some(parse_options(options, "fsi options")?);
1548 }
1549 AnalysisRunKind::Nonlinear => {
1550 resolved.nonlinear = Some(parse_options(options, "nonlinear options")?);
1551 }
1552 AnalysisRunKind::Electromagnetic => {
1553 resolved.electromagnetic = Some(parse_options(options, "electromagnetic options")?);
1554 }
1555 }
1556 Ok(resolved)
1557}
1558
1559fn parse_options<T: DeserializeOwned>(
1560 options: serde_yaml::Value,
1561 label: &str,
1562) -> Result<T, String> {
1563 serde_yaml::from_value(options).map_err(|err| format!("invalid {label}: {err}"))
1564}
1565
1566fn empty_model(model_id: String, geometry: &GeometryAsset) -> AnalysisModel {
1567 AnalysisModel {
1568 model_id: AnalysisModelId(model_id),
1569 geometry_id: geometry.geometry_id.clone(),
1570 geometry_revision: geometry.revision,
1571 units: geometry.units,
1572 frame: ReferenceFrame::Global,
1573 materials: Vec::new(),
1574 material_assignments: Vec::new(),
1575 structural: None,
1576 thermo_mechanical: None,
1577 electro_thermal: None,
1578 electromagnetic: None,
1579 cfd: None,
1580 interfaces: Vec::new(),
1581 boundary_conditions: Vec::new(),
1582 loads: Vec::new(),
1583 steps: Vec::new(),
1584 }
1585}
1586
1587fn has_explicit_model_data(study: &FeaStudyDocument) -> bool {
1588 !study.materials.is_empty()
1589 || !study.material_assignments.is_empty()
1590 || !study.boundary_conditions.is_empty()
1591 || !study.loads.is_empty()
1592 || !study.steps.is_empty()
1593 || has_structural_model_data(study)
1594 || study.domains.thermo_mechanical.is_some()
1595 || study.domains.electro_thermal.is_some()
1596 || study.domains.electromagnetic.is_some()
1597 || study.domains.cfd.is_some()
1598 || !study.interfaces.is_empty()
1599 || study.model.frame.is_some()
1600}
1601
1602fn has_structural_model_data(study: &FeaStudyDocument) -> bool {
1603 study.structural.as_ref().is_some_and(|structural| {
1604 !structural.nodes.is_empty()
1605 || !structural.elements.is_empty()
1606 || !structural.sections.is_empty()
1607 }) || !study.nodes.is_empty()
1608 || !study.elements.is_empty()
1609 || !study.sections.is_empty()
1610}
1611
1612fn resolve_region_ref(
1613 reference: &str,
1614 geometry: &GeometryAsset,
1615 aliases: &BTreeMap<String, FeaRegionDocument>,
1616) -> Result<String, String> {
1617 if reference.strip_prefix("node:").is_some() || reference.parse::<u32>().is_ok() {
1618 return Ok(reference.to_string());
1619 }
1620 if let Some(alias) = aliases.get(reference) {
1621 return resolve_region_selector(&alias.selector, geometry);
1622 }
1623 resolve_region_selector(reference, geometry)
1624}
1625
1626fn resolve_region_selector(selector: &str, geometry: &GeometryAsset) -> Result<String, String> {
1627 if let Some(id) = selector
1628 .strip_prefix("id:")
1629 .or_else(|| selector.strip_prefix("region:"))
1630 {
1631 return require_region_id(id, geometry);
1632 }
1633 if let Some(tag) = selector.strip_prefix("tag:") {
1634 return geometry
1635 .regions
1636 .iter()
1637 .find(|region| region.tag.as_deref() == Some(tag))
1638 .map(|region| region.region_id.clone())
1639 .ok_or_else(|| format!("region tag `{tag}` was not found in geometry"));
1640 }
1641 if let Some(name) = selector.strip_prefix("name:") {
1642 return geometry
1643 .regions
1644 .iter()
1645 .find(|region| region.name == name)
1646 .map(|region| region.region_id.clone())
1647 .ok_or_else(|| format!("region name `{name}` was not found in geometry"));
1648 }
1649 require_region_id(selector, geometry)
1650}
1651
1652fn require_region_id(region_id: &str, geometry: &GeometryAsset) -> Result<String, String> {
1653 geometry
1654 .regions
1655 .iter()
1656 .find(|region| region.region_id == region_id)
1657 .map(|region| region.region_id.clone())
1658 .ok_or_else(|| format!("region id `{region_id}` was not found in geometry"))
1659}
1660
1661fn load_vector(load: &FeaLoadDocument, label: &str) -> Result<[f64; 3], String> {
1662 load.vector
1663 .ok_or_else(|| format!("{label} load requires vector: [x, y, z]"))
1664}
1665
1666fn load_force(load: &FeaLoadDocument) -> Result<[f64; 3], String> {
1667 load.force
1668 .ok_or_else(|| "wrench load requires force: [fx, fy, fz]".to_string())
1669}
1670
1671fn load_moment(load: &FeaLoadDocument) -> Result<[f64; 3], String> {
1672 load.moment
1673 .ok_or_else(|| "wrench load requires moment: [mx, my, mz]".to_string())
1674}
1675
1676fn load_point(load: &FeaLoadDocument) -> Result<[f64; 3], String> {
1677 load.point
1678 .ok_or_else(|| "wrench load requires point: [px, py, pz]".to_string())
1679}
1680
1681fn point_in_meters(point: [f64; 3], units: UnitSystem) -> [f64; 3] {
1682 let scale = geometry_unit_scale_to_meters(units);
1683 [point[0] * scale, point[1] * scale, point[2] * scale]
1684}
1685
1686fn geometry_unit_scale_to_meters(units: UnitSystem) -> f64 {
1687 match units {
1688 UnitSystem::Meter | UnitSystem::Unspecified => 1.0,
1689 UnitSystem::Millimeter => 1.0e-3,
1690 UnitSystem::Inch => 0.0254,
1691 }
1692}
1693
1694fn required_f64(value: Option<f64>, label: &str) -> Result<f64, String> {
1695 value.ok_or_else(|| format!("{label} is required"))
1696}
1697
1698fn resolve_document_path(base_dir: &Path, path: &Path) -> PathBuf {
1699 if path.is_absolute() {
1700 path.to_path_buf()
1701 } else {
1702 base_dir.join(path)
1703 }
1704}
1705
1706fn validate_version(version: u32) -> Result<(), String> {
1707 if version == FEA_DOCUMENT_VERSION {
1708 Ok(())
1709 } else {
1710 Err(format!(
1711 "unsupported FEA document version {version}; expected {FEA_DOCUMENT_VERSION}"
1712 ))
1713 }
1714}
1715
1716fn sanitize_id(id: &str) -> String {
1717 id.chars()
1718 .map(|ch| {
1719 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
1720 ch
1721 } else {
1722 '_'
1723 }
1724 })
1725 .collect()
1726}
1727
1728fn default_units() -> UnitSystem {
1729 UnitSystem::Meter
1730}
1731
1732fn default_backend() -> ComputeBackend {
1733 ComputeBackend::Cpu
1734}
1735
1736fn default_mesh_kind() -> MeshKindRequest {
1737 MeshKindRequest::Solid
1738}
1739
1740fn default_mesh_element() -> VolumeElementKind {
1741 VolumeElementKind::Tetrahedron4
1742}
1743
1744fn default_mesh_element_order() -> MeshElementOrder {
1745 MeshElementOrder::Linear
1746}
1747
1748fn default_mesh_profile() -> MeshProfile {
1749 MeshProfile::AnalysisReady
1750}
1751
1752fn default_mesh_max_elements() -> usize {
1753 250_000
1754}
1755
1756fn default_refinement_strategy() -> RefinementStrategy {
1757 RefinementStrategy::Auto
1758}
1759
1760fn default_refinement_max_iterations() -> usize {
1761 4
1762}
1763
1764fn default_field_change_tolerance() -> f64 {
1765 0.05
1766}
1767
1768fn default_energy_change_tolerance() -> f64 {
1769 0.02
1770}
1771
1772fn default_focus_fine() -> RefinementFocusLevel {
1773 RefinementFocusLevel::Fine
1774}
1775
1776fn default_focus_normal() -> RefinementFocusLevel {
1777 RefinementFocusLevel::Normal
1778}
1779
1780fn default_true() -> bool {
1781 true
1782}
1783
1784fn default_fail_fast() -> bool {
1785 true
1786}
1787
1788fn default_assignment_confidence() -> EvidenceConfidence {
1789 EvidenceConfidence::Verified
1790}
1791
1792fn deserialize_optional_mesh_target_size<'de, D>(
1793 deserializer: D,
1794) -> Result<Option<MeshTargetSize>, D::Error>
1795where
1796 D: Deserializer<'de>,
1797{
1798 let Some(value) = Option::<serde_yaml::Value>::deserialize(deserializer)? else {
1799 return Ok(None);
1800 };
1801 match value {
1802 serde_yaml::Value::Null => Ok(None),
1803 serde_yaml::Value::String(value) if value.eq_ignore_ascii_case("auto") => Ok(None),
1804 serde_yaml::Value::Number(value) => {
1805 let value = value
1806 .as_f64()
1807 .ok_or_else(|| D::Error::custom("mesh.target_size must be a finite number"))?;
1808 if !value.is_finite() || value <= 0.0 {
1809 return Err(D::Error::custom(
1810 "mesh.target_size must be finite and positive",
1811 ));
1812 }
1813 Ok(Some(MeshTargetSize::LengthM(value)))
1814 }
1815 _ => Err(D::Error::custom(
1816 "mesh.target_size must be auto, null, or a positive number in geometry units",
1817 )),
1818 }
1819}
1820
1821fn deserialize_optional_auto_f64<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
1822where
1823 D: Deserializer<'de>,
1824{
1825 let Some(value) = Option::<serde_yaml::Value>::deserialize(deserializer)? else {
1826 return Ok(None);
1827 };
1828 match value {
1829 serde_yaml::Value::Null => Ok(None),
1830 serde_yaml::Value::String(value) if value.eq_ignore_ascii_case("auto") => Ok(None),
1831 serde_yaml::Value::Number(value) => {
1832 let value = value
1833 .as_f64()
1834 .ok_or_else(|| D::Error::custom("value must be a finite number"))?;
1835 if !value.is_finite() || value <= 0.0 {
1836 return Err(D::Error::custom("value must be finite and positive"));
1837 }
1838 Ok(Some(value))
1839 }
1840 _ => Err(D::Error::custom(
1841 "value must be auto, null, or a positive number",
1842 )),
1843 }
1844}
1845
1846#[cfg(test)]
1847mod tests {
1848 use super::*;
1849 use runmat_geometry_core::{
1850 GeometrySource, MeshDescriptor, MeshKind, Region, SourceGeometry, SourceGeometryKind,
1851 SurfaceMesh, TessellationProfile,
1852 };
1853
1854 fn sample_geometry() -> GeometryAsset {
1855 GeometryAsset {
1856 geometry_id: "geo:fea_document_test".to_string(),
1857 source: GeometrySource {
1858 path: "fixture.step".to_string(),
1859 sha256: "fixture".to_string(),
1860 importer_version: "test".to_string(),
1861 },
1862 source_geometry: SourceGeometry {
1863 kind: SourceGeometryKind::Cad,
1864 assembly: None,
1865 material_evidence: Vec::new(),
1866 cad_evaluators: Vec::new(),
1867 },
1868 tessellation_profile: TessellationProfile::default(),
1869 units: UnitSystem::Meter,
1870 revision: 1,
1871 meshes: vec![MeshDescriptor {
1872 mesh_id: "mesh_1".to_string(),
1873 kind: MeshKind::Surface,
1874 vertex_count: 3,
1875 element_count: 1,
1876 }],
1877 surface_meshes: vec![SurfaceMesh::new(
1878 "mesh_1",
1879 vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1880 vec![[0, 1, 2]],
1881 )],
1882 regions: vec![Region {
1883 region_id: "tip".to_string(),
1884 name: "Tip".to_string(),
1885 tag: Some("tip".to_string()),
1886 cad_ownership: None,
1887 }],
1888 region_entity_mappings: Vec::new(),
1889 diagnostics: Vec::new(),
1890 }
1891 }
1892
1893 fn resolve_linear_static_mesh_options(
1894 mesh: Option<&FeaMeshDocument>,
1895 ) -> Result<Option<VolumeMeshingOptions>, String> {
1896 resolve_mesh_options(
1897 mesh,
1898 AnalysisCreateModelProfile::LinearStaticStructural,
1899 AnalysisRunKind::LinearStatic,
1900 )
1901 }
1902
1903 #[test]
1904 fn fea_document_resolves_moment_and_torque_loads() {
1905 let geometry = sample_geometry();
1906 for (load_type, expected_id) in [("moment", "tip_moment"), ("torque", "tip_torque")] {
1907 let load: FeaLoadDocument = serde_yaml::from_str(&format!(
1908 r#"
1909id: {expected_id}
1910region: tag:tip
1911type: {load_type}
1912vector: [1.0, 2.0, 3.0]
1913"#
1914 ))
1915 .expect("load document should parse");
1916
1917 let resolved = resolve_load(&load, &geometry, &BTreeMap::new())
1918 .expect("load should resolve against geometry");
1919
1920 assert_eq!(resolved.load_id, expected_id);
1921 assert_eq!(resolved.region_id, "tip");
1922 assert!(matches!(
1923 resolved.kind,
1924 LoadKind::Moment {
1925 mx: 1.0,
1926 my: 2.0,
1927 mz: 3.0
1928 }
1929 ));
1930 }
1931 }
1932
1933 #[test]
1934 fn fea_document_moment_requires_vector() {
1935 let geometry = sample_geometry();
1936 let load: FeaLoadDocument = serde_yaml::from_str(
1937 r#"
1938id: tip_moment
1939region: tip
1940type: moment
1941"#,
1942 )
1943 .expect("load document should parse");
1944
1945 let err = resolve_load(&load, &geometry, &BTreeMap::new())
1946 .expect_err("moment without vector should fail");
1947
1948 assert!(err.contains("moment load requires vector: [x, y, z]"));
1949 }
1950
1951 #[test]
1952 fn fea_document_resolves_wrench_load() {
1953 let geometry = sample_geometry();
1954 let load: FeaLoadDocument = serde_yaml::from_str(
1955 r#"
1956id: tip_wrench
1957region: tag:tip
1958type: wrench
1959force: [10.0, 20.0, 30.0]
1960moment: [1.0, 2.0, 3.0]
1961point: [0.1, 0.2, 0.3]
1962"#,
1963 )
1964 .expect("load document should parse");
1965
1966 let resolved = resolve_load(&load, &geometry, &BTreeMap::new())
1967 .expect("load should resolve against geometry");
1968
1969 assert_eq!(resolved.load_id, "tip_wrench");
1970 assert_eq!(resolved.region_id, "tip");
1971 assert!(matches!(
1972 resolved.kind,
1973 LoadKind::Wrench {
1974 fx: 10.0,
1975 fy: 20.0,
1976 fz: 30.0,
1977 mx: 1.0,
1978 my: 2.0,
1979 mz: 3.0,
1980 px: 0.1,
1981 py: 0.2,
1982 pz: 0.3,
1983 }
1984 ));
1985 }
1986
1987 #[test]
1988 fn fea_document_scales_wrench_point_from_geometry_units_to_meters() {
1989 let mut geometry = sample_geometry();
1990 geometry.units = UnitSystem::Millimeter;
1991 let load: FeaLoadDocument = serde_yaml::from_str(
1992 r#"
1993id: tip_wrench
1994region: tag:tip
1995type: wrench
1996force: [10.0, 20.0, 30.0]
1997moment: [1.0, 2.0, 3.0]
1998point: [100.0, 200.0, 300.0]
1999"#,
2000 )
2001 .expect("load document should parse");
2002
2003 let resolved = resolve_load(&load, &geometry, &BTreeMap::new())
2004 .expect("load should resolve against geometry");
2005
2006 assert!(matches!(
2007 resolved.kind,
2008 LoadKind::Wrench {
2009 px,
2010 py,
2011 pz,
2012 ..
2013 } if (px - 0.1).abs() <= 1.0e-12
2014 && (py - 0.2).abs() <= 1.0e-12
2015 && (pz - 0.3).abs() <= 1.0e-12
2016 ));
2017 }
2018
2019 #[test]
2020 fn fea_document_wrench_requires_force_moment_and_point() {
2021 let geometry = sample_geometry();
2022 let load: FeaLoadDocument = serde_yaml::from_str(
2023 r#"
2024id: tip_wrench
2025region: tip
2026type: wrench
2027force: [1.0, 0.0, 0.0]
2028moment: [0.0, 0.0, 1.0]
2029"#,
2030 )
2031 .expect("load document should parse");
2032
2033 let err = resolve_load(&load, &geometry, &BTreeMap::new())
2034 .expect_err("wrench without point should fail");
2035
2036 assert!(err.contains("wrench load requires point: [px, py, pz]"));
2037 }
2038
2039 #[test]
2040 fn fea_document_moment_rejects_unknown_fields() {
2041 let err = serde_yaml::from_str::<FeaLoadDocument>(
2042 r#"
2043id: tip_moment
2044region: tip
2045type: moment
2046vector: [1.0, 2.0, 3.0]
2047units: n_m
2048"#,
2049 )
2050 .expect_err("unknown moment load fields should be rejected");
2051
2052 assert!(err.to_string().contains("unknown field"));
2053 }
2054
2055 #[test]
2056 fn fea_document_mesh_options_accept_auto_refinement_controls() {
2057 let mesh: FeaMeshDocument = serde_yaml::from_str(
2058 r#"
2059kind: solid
2060element: tetrahedron4
2061element_order: linear
2062profile: adaptive
2063max_elements: 250000
2064target_size: auto
2065min_size: 0.001
2066max_size: 0.02
2067growth_rate: 1.35
2068refinement:
2069 strategy: auto
2070 max_iterations: 4
2071 convergence:
2072 field_change_tolerance: 0.05
2073 energy_change_tolerance: 0.02
2074 residual_tolerance: auto
2075 focus:
2076 loads: fine
2077 constraints: fine
2078 interfaces: normal
2079 curvature: true
2080 small_features: true
2081 indicators:
2082 structural:
2083 stress_gradient: true
2084 displacement_gradient: false
2085 plastic_strain: auto
2086 modal:
2087 modal_strain_energy: on
2088 frequency_residual: off
2089 thermal:
2090 temperature_gradient: true
2091"#,
2092 )
2093 .expect("mesh document should parse");
2094
2095 let options = resolve_linear_static_mesh_options(Some(&mesh))
2096 .expect("mesh options should resolve")
2097 .expect("mesh options should be present");
2098
2099 assert_eq!(options.backend, MeshBackendKind::Auto);
2100 assert_eq!(options.kind, MeshKindRequest::Solid);
2101 assert_eq!(options.element, VolumeElementKind::Tetrahedron4);
2102 assert_eq!(options.element_order, MeshElementOrder::Linear);
2103 assert_eq!(options.profile, MeshProfile::Adaptive);
2104 assert_eq!(options.max_elements, 250_000);
2105 assert_eq!(options.target_size, MeshTargetSize::Auto);
2106 assert_eq!(options.min_size_m, Some(0.001));
2107 assert_eq!(options.max_size_m, Some(0.02));
2108 assert_eq!(options.growth_rate, Some(1.35));
2109 assert_eq!(options.refinement.strategy, RefinementStrategy::Auto);
2110 assert_eq!(options.refinement.max_iterations, 4);
2111 assert_eq!(options.refinement.convergence.field_change_tolerance, 0.05);
2112 assert_eq!(options.refinement.convergence.energy_change_tolerance, 0.02);
2113 assert_eq!(options.refinement.convergence.residual_tolerance, None);
2114 assert_eq!(options.validation.min_bounds_coverage_ratio, 0.90);
2115 assert_eq!(options.validation.min_volume_coverage_ratio, 0.90);
2116 assert_eq!(options.validation.min_boundary_area_ratio, 0.90);
2117 assert_eq!(options.validation.min_boundary_face_recovery_ratio, 1.0);
2118 assert_eq!(options.validation.min_boundary_edge_recovery_ratio, 1.0);
2119 assert_eq!(options.validation.quality, QualityThresholds::default());
2120 assert_eq!(options.refinement.focus.loads, RefinementFocusLevel::Fine);
2121 assert_eq!(
2122 options.refinement.focus.constraints,
2123 RefinementFocusLevel::Fine
2124 );
2125 assert_eq!(
2126 options.refinement.focus.interfaces,
2127 RefinementFocusLevel::Normal
2128 );
2129 assert!(options.refinement.focus.curvature);
2130 assert!(options.refinement.focus.small_features);
2131 let indicators = &options.refinement.indicators.namespaces;
2132 assert_eq!(
2133 indicators["structural"]["stress_gradient"],
2134 RefinementIndicatorMode::On
2135 );
2136 assert_eq!(
2137 indicators["structural"]["displacement_gradient"],
2138 RefinementIndicatorMode::Off
2139 );
2140 assert_eq!(
2141 indicators["structural"]["plastic_strain"],
2142 RefinementIndicatorMode::Auto
2143 );
2144 assert_eq!(
2145 indicators["modal"]["modal_strain_energy"],
2146 RefinementIndicatorMode::On
2147 );
2148 assert_eq!(
2149 indicators["modal"]["frequency_residual"],
2150 RefinementIndicatorMode::Off
2151 );
2152 assert_eq!(
2153 indicators["thermal"]["temperature_gradient"],
2154 RefinementIndicatorMode::On
2155 );
2156 }
2157
2158 #[test]
2159 fn fea_document_mesh_options_accept_validation_policy_controls() {
2160 let mesh: FeaMeshDocument = serde_yaml::from_str(
2161 r#"
2162validation:
2163 coverage: strict
2164 quality: strict
2165 min_bounds_coverage_ratio: 0.95
2166 max_volume_components: 2
2167"#,
2168 )
2169 .expect("mesh document should parse validation policy");
2170
2171 let options = resolve_linear_static_mesh_options(Some(&mesh))
2172 .expect("mesh options should resolve")
2173 .expect("mesh options should be present");
2174
2175 assert_eq!(options.validation.min_bounds_coverage_ratio, 0.95);
2176 assert_eq!(options.validation.min_volume_coverage_ratio, 1.0);
2177 assert_eq!(options.validation.min_boundary_area_ratio, 1.0);
2178 assert_eq!(options.validation.min_boundary_face_recovery_ratio, 1.0);
2179 assert_eq!(options.validation.min_boundary_edge_recovery_ratio, 1.0);
2180 assert_eq!(options.validation.max_volume_component_count, Some(2));
2181 assert_eq!(
2182 options.validation.quality,
2183 QualityThresholds {
2184 min_scaled_jacobian: 0.25,
2185 max_aspect_ratio: 10.0,
2186 max_boundary_projection_error_m: 1.0e-8,
2187 allow_inverted_elements: false,
2188 }
2189 );
2190 }
2191
2192 #[test]
2193 fn fea_document_mesh_options_accept_relaxed_validation_presets() {
2194 let mesh: FeaMeshDocument = serde_yaml::from_str(
2195 r#"
2196validation:
2197 coverage: relaxed
2198 quality: relaxed
2199"#,
2200 )
2201 .expect("mesh document should parse validation presets");
2202
2203 let options = resolve_linear_static_mesh_options(Some(&mesh))
2204 .expect("mesh options should resolve")
2205 .expect("mesh options should be present");
2206
2207 assert_eq!(options.validation.min_bounds_coverage_ratio, 0.80);
2208 assert_eq!(options.validation.min_volume_coverage_ratio, 0.80);
2209 assert_eq!(options.validation.min_boundary_area_ratio, 0.80);
2210 assert_eq!(options.validation.min_boundary_face_recovery_ratio, 0.95);
2211 assert_eq!(options.validation.min_boundary_edge_recovery_ratio, 0.95);
2212 assert_eq!(
2213 options.validation.quality,
2214 QualityThresholds {
2215 min_scaled_jacobian: 0.05,
2216 max_aspect_ratio: 50.0,
2217 max_boundary_projection_error_m: 1.0e-4,
2218 allow_inverted_elements: false,
2219 }
2220 );
2221 }
2222
2223 #[test]
2224 fn fea_document_mesh_options_reject_invalid_validation_policy_controls() {
2225 let mesh: FeaMeshDocument = serde_yaml::from_str(
2226 r#"
2227validation:
2228 min_volume_coverage_ratio: 1.2
2229"#,
2230 )
2231 .expect("mesh document should parse before semantic validation");
2232
2233 let err = resolve_linear_static_mesh_options(Some(&mesh))
2234 .expect_err("invalid validation ratio should fail");
2235
2236 assert!(err.contains("mesh.validation.min_volume_coverage_ratio"));
2237
2238 let mesh: FeaMeshDocument = serde_yaml::from_str(
2239 r#"
2240validation:
2241 max_volume_components: 0
2242"#,
2243 )
2244 .expect("mesh document should parse before semantic validation");
2245
2246 let err = resolve_linear_static_mesh_options(Some(&mesh))
2247 .expect_err("invalid component count should fail");
2248
2249 assert!(err.contains("mesh.validation.max_volume_components"));
2250 }
2251
2252 #[test]
2253 fn fea_document_mesh_options_reject_invalid_size_envelope_controls() {
2254 let mesh: FeaMeshDocument = serde_yaml::from_str(
2255 r#"
2256min_size: 0.02
2257max_size: 0.01
2258growth_rate: 1.2
2259"#,
2260 )
2261 .expect("mesh document should parse before semantic validation");
2262
2263 let err = resolve_linear_static_mesh_options(Some(&mesh))
2264 .expect_err("invalid size envelope should fail");
2265 assert!(err.contains("mesh.min_size"));
2266
2267 let mesh: FeaMeshDocument = serde_yaml::from_str(
2268 r#"
2269min_size: 0.001
2270max_size: 0.01
2271growth_rate: 0.95
2272"#,
2273 )
2274 .expect("mesh document should parse before semantic validation");
2275
2276 let err = resolve_linear_static_mesh_options(Some(&mesh))
2277 .expect_err("invalid growth rate should fail");
2278 assert!(err.contains("mesh.growth_rate"));
2279 }
2280
2281 #[test]
2282 fn fea_document_defaults_linear_static_structural_mesh_options() {
2283 let options = resolve_linear_static_mesh_options(None)
2284 .expect("default structural mesh options should resolve")
2285 .expect("linear static structural study should default a solid analysis mesh");
2286
2287 assert_eq!(options.kind, MeshKindRequest::Solid);
2288 assert_eq!(options.element, VolumeElementKind::Tetrahedron4);
2289 assert_eq!(options.profile, MeshProfile::AnalysisReady);
2290 assert_eq!(options.backend, MeshBackendKind::Auto);
2291 assert_eq!(options.refinement.strategy, RefinementStrategy::Auto);
2292
2293 let modal_default = resolve_mesh_options(
2294 None,
2295 AnalysisCreateModelProfile::ModalStructural,
2296 AnalysisRunKind::Modal,
2297 )
2298 .expect("modal default should resolve");
2299 assert!(modal_default.is_none());
2300 }
2301
2302 #[test]
2303 fn fea_document_mesh_options_accept_backend_selection() {
2304 let mesh: FeaMeshDocument = serde_yaml::from_str(
2305 r#"
2306backend: structured_grid_tetrahedron
2307"#,
2308 )
2309 .expect("mesh document should parse backend");
2310
2311 let options = resolve_linear_static_mesh_options(Some(&mesh))
2312 .expect("mesh options should resolve")
2313 .expect("mesh options should be present");
2314
2315 assert_eq!(options.backend, MeshBackendKind::StructuredGridTetrahedron);
2316 }
2317
2318 #[test]
2319 fn fea_document_mesh_options_accept_physics_refinement_namespaces() {
2320 let mesh: FeaMeshDocument = serde_yaml::from_str(
2321 r#"
2322refinement:
2323 indicators:
2324 structural:
2325 load_regions: auto
2326 constraint_regions: auto
2327 modal:
2328 mode_shape_curvature: on
2329 thermal:
2330 convection_regions: auto
2331 prescribed_temperature_regions: auto
2332 thermo_mechanical:
2333 strain_energy_density: auto
2334 region_temperature_delta: auto
2335 electro_thermal:
2336 source_regions: auto
2337 ground_regions: auto
2338 electromagnetic:
2339 source_regions: auto
2340 ground_regions: auto
2341 insulation_regions: auto
2342 acoustic:
2343 pressure_curvature: auto
2344 impedance_regions: auto
2345 source_regions: auto
2346 cfd:
2347 inlet_regions: auto
2348 outlet_regions: auto
2349 cht:
2350 interface_heat_flux_jump: on
2351 interface_temperature_jump: on
2352 solid_heat_flux_gradient: auto
2353 fluid_boundary_layer: auto
2354 fsi:
2355 interface_displacement_jump: on
2356 interface_traction_jump: on
2357 structural_stress_gradient: auto
2358 fluid_pressure_gradient: auto
2359 fluid_velocity_gradient: auto
2360"#,
2361 )
2362 .expect("mesh document should parse");
2363
2364 let options = resolve_linear_static_mesh_options(Some(&mesh))
2365 .expect("mesh options should resolve")
2366 .expect("mesh options should be present");
2367
2368 let indicators = &options.refinement.indicators.namespaces;
2369 assert_eq!(
2370 indicators["structural"]["load_regions"],
2371 RefinementIndicatorMode::Auto
2372 );
2373 assert_eq!(
2374 indicators["thermal"]["convection_regions"],
2375 RefinementIndicatorMode::Auto
2376 );
2377 assert_eq!(
2378 indicators["thermo_mechanical"]["strain_energy_density"],
2379 RefinementIndicatorMode::Auto
2380 );
2381 assert_eq!(
2382 indicators["electromagnetic"]["insulation_regions"],
2383 RefinementIndicatorMode::Auto
2384 );
2385 assert_eq!(
2386 indicators["cht"]["interface_heat_flux_jump"],
2387 RefinementIndicatorMode::On
2388 );
2389 assert_eq!(
2390 indicators["fsi"]["interface_traction_jump"],
2391 RefinementIndicatorMode::On
2392 );
2393 }
2394
2395 #[test]
2396 fn fea_document_mesh_options_accept_numeric_size_and_residual() {
2397 let mesh: FeaMeshDocument = serde_yaml::from_str(
2398 r#"
2399target_size: 0.002
2400refinement:
2401 strategy: adaptive
2402 max_iterations: 2
2403 convergence:
2404 residual_tolerance: 1.0e-6
2405"#,
2406 )
2407 .expect("mesh document should parse");
2408
2409 let options = resolve_linear_static_mesh_options(Some(&mesh))
2410 .expect("mesh options should resolve")
2411 .expect("mesh options should be present");
2412
2413 assert_eq!(options.target_size, MeshTargetSize::LengthM(0.002));
2414 assert_eq!(options.refinement.strategy, RefinementStrategy::Adaptive);
2415 assert_eq!(options.refinement.max_iterations, 2);
2416 assert_eq!(
2417 options.refinement.convergence.residual_tolerance,
2418 Some(1.0e-6)
2419 );
2420 }
2421
2422 #[test]
2423 fn fea_document_mesh_options_reject_none_refinement_iterations() {
2424 let mesh: FeaMeshDocument = serde_yaml::from_str(
2425 r#"
2426refinement:
2427 strategy: none
2428 max_iterations: 1
2429"#,
2430 )
2431 .expect("mesh document should parse");
2432
2433 let err = resolve_linear_static_mesh_options(Some(&mesh))
2434 .expect_err("none refinement should reject positive iteration count");
2435
2436 assert!(err.contains("mesh.refinement.max_iterations"));
2437 }
2438
2439 #[test]
2440 fn fea_document_mesh_options_reject_invalid_numeric_controls() {
2441 let err = serde_yaml::from_str::<FeaMeshDocument>(
2442 r#"
2443target_size: -0.002
2444"#,
2445 )
2446 .expect_err("negative target size should fail during parsing");
2447
2448 assert!(err.to_string().contains("mesh.target_size"));
2449 }
2450
2451 #[test]
2452 fn fea_document_mesh_options_reject_unsupported_mesh_kind() {
2453 let mesh: FeaMeshDocument = serde_yaml::from_str(
2454 r#"
2455kind: display_only
2456"#,
2457 )
2458 .expect("display-only mesh document should parse");
2459
2460 let err = resolve_linear_static_mesh_options(Some(&mesh))
2461 .expect_err("display-only mesh kind is unsupported for analysis");
2462
2463 assert!(err.contains("mesh.kind"));
2464 assert!(err.contains("solid"));
2465 }
2466
2467 #[test]
2468 fn fea_document_mesh_options_reject_unsupported_solid_element() {
2469 let mesh: FeaMeshDocument = serde_yaml::from_str(
2470 r#"
2471element: hex8
2472"#,
2473 )
2474 .expect("mesh document should parse");
2475
2476 let err = resolve_linear_static_mesh_options(Some(&mesh))
2477 .expect_err("hex8 solid element is not supported yet");
2478
2479 assert!(err.contains("mesh.element"));
2480 assert!(err.contains("tetrahedron4"));
2481 }
2482
2483 #[test]
2484 fn fea_document_mesh_options_reject_unknown_refinement_indicators() {
2485 let mesh: FeaMeshDocument = serde_yaml::from_str(
2486 r#"
2487refinement:
2488 indicators:
2489 structural:
2490 stress_gradient: true
2491 made_up_indicator: true
2492"#,
2493 )
2494 .expect("mesh document should parse before semantic validation");
2495
2496 let err = resolve_linear_static_mesh_options(Some(&mesh))
2497 .expect_err("unknown refinement indicator should fail validation");
2498
2499 assert!(err.contains("mesh.refinement.indicators.structural.made_up_indicator"));
2500
2501 let mesh: FeaMeshDocument = serde_yaml::from_str(
2502 r#"
2503refinement:
2504 indicators:
2505 made_up_physics:
2506 stress_gradient: true
2507"#,
2508 )
2509 .expect("mesh document should parse before semantic validation");
2510
2511 let err = resolve_linear_static_mesh_options(Some(&mesh))
2512 .expect_err("unknown refinement namespace should fail validation");
2513
2514 assert!(err.contains("mesh.refinement.indicators namespace `made_up_physics`"));
2515
2516 let mesh: FeaMeshDocument = serde_yaml::from_str(
2517 r#"
2518refinement:
2519 indicators:
2520 coupling:
2521 interface_jump: true
2522"#,
2523 )
2524 .expect("mesh document should parse before semantic validation");
2525
2526 let err = resolve_linear_static_mesh_options(Some(&mesh))
2527 .expect_err("generic coupling namespace should fail validation");
2528
2529 assert!(err.contains("mesh.refinement.indicators namespace `coupling`"));
2530 }
2531
2532 #[test]
2533 fn fea_document_refinement_indicator_applicability_matches_profile_context() {
2534 let structural = BTreeMap::from([(
2535 "structural".to_string(),
2536 BTreeMap::from([("stress_gradient".to_string(), RefinementIndicatorMode::Auto)]),
2537 )]);
2538 validate_refinement_indicator_applicability(
2539 &structural,
2540 AnalysisCreateModelProfile::LinearStaticStructural,
2541 AnalysisRunKind::LinearStatic,
2542 &FeaDomainsDocument::default(),
2543 )
2544 .expect("structural indicators should apply to linear static structural studies");
2545
2546 let unrelated = BTreeMap::from([(
2547 "thermal".to_string(),
2548 BTreeMap::from([(
2549 "temperature_gradient".to_string(),
2550 RefinementIndicatorMode::Auto,
2551 )]),
2552 )]);
2553 let err = validate_refinement_indicator_applicability(
2554 &unrelated,
2555 AnalysisCreateModelProfile::LinearStaticStructural,
2556 AnalysisRunKind::LinearStatic,
2557 &FeaDomainsDocument::default(),
2558 )
2559 .expect_err("thermal indicators should not apply to uncoupled structural studies");
2560 assert!(err.contains("mesh.refinement.indicators.thermal"));
2561 assert!(err.contains("linear_static_structural"));
2562
2563 let coupled = BTreeMap::from([(
2564 "thermo_mechanical".to_string(),
2565 BTreeMap::from([("thermal_stress".to_string(), RefinementIndicatorMode::On)]),
2566 )]);
2567 validate_refinement_indicator_applicability(
2568 &coupled,
2569 AnalysisCreateModelProfile::ThermoMechanicalCoupled,
2570 AnalysisRunKind::Transient,
2571 &FeaDomainsDocument::default(),
2572 )
2573 .expect("thermo-mechanical indicators should apply to thermo-mechanical studies");
2574 }
2575}