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 serde::de::DeserializeOwned;
17use serde::Deserialize;
18
19use super::{
20 analysis_create_model_op, AnalysisAcousticRunOptions, AnalysisCfdRunOptions,
21 AnalysisChtRunOptions, AnalysisCreateModelIntentSpec, AnalysisCreateModelProfile,
22 AnalysisElectromagneticRunOptions, AnalysisFsiRunOptions, AnalysisModalRunOptions,
23 AnalysisNonlinearRunOptions, AnalysisRunKind, AnalysisRunOptions, AnalysisStudySpec,
24 AnalysisStudySweepSpec, AnalysisThermalRunOptions, AnalysisTransientRunOptions,
25};
26use crate::operations::OperationContext;
27
28const FEA_DOCUMENT_VERSION: u32 = 1;
29
30#[derive(Debug, Clone, PartialEq)]
31pub enum FeaResolvedDocument {
32 Study(Box<AnalysisStudySpec>),
33 Sweep(AnalysisStudySweepSpec),
34}
35
36#[derive(Debug, Deserialize)]
37#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
38enum RawFeaDocument {
39 Study(Box<FeaStudyDocument>),
40 Sweep(FeaSweepDocument),
41}
42
43#[derive(Debug, Deserialize)]
44#[serde(deny_unknown_fields)]
45struct FeaSweepDocument {
46 version: u32,
47 id: String,
48 #[serde(default = "default_fail_fast")]
49 fail_fast: bool,
50 studies: Vec<FeaStudyDocument>,
51}
52
53#[derive(Debug, Deserialize)]
54#[serde(deny_unknown_fields)]
55struct FeaStudyDocument {
56 version: u32,
57 id: String,
58 geometry: FeaGeometryDocument,
59 model: FeaModelDocument,
60 run: FeaRunDocument,
61 #[serde(default)]
62 regions: BTreeMap<String, FeaRegionDocument>,
63 #[serde(default)]
64 materials: BTreeMap<String, FeaMaterialDocument>,
65 #[serde(default)]
66 material_assignments: Vec<FeaMaterialAssignmentDocument>,
67 #[serde(default)]
68 structural: Option<FeaStructuralDocument>,
69 #[serde(default)]
70 nodes: Vec<FeaStructuralNodeDocument>,
71 #[serde(default)]
72 elements: Vec<FeaStructuralElementDocument>,
73 #[serde(default)]
74 sections: Vec<FeaStructuralSectionDocument>,
75 #[serde(default)]
76 boundary_conditions: Vec<FeaBoundaryConditionDocument>,
77 #[serde(default)]
78 loads: Vec<FeaLoadDocument>,
79 #[serde(default)]
80 steps: Vec<FeaStepDocument>,
81 #[serde(default)]
82 domains: FeaDomainsDocument,
83 #[serde(default)]
84 interfaces: Vec<AnalysisInterface>,
85}
86
87#[derive(Debug, Deserialize)]
88#[serde(deny_unknown_fields)]
89struct FeaGeometryDocument {
90 path: PathBuf,
91 #[serde(default = "default_units")]
92 units: UnitSystem,
93 #[serde(default)]
94 import: FeaGeometryImportDocument,
95}
96
97#[derive(Debug, Default, Deserialize)]
98#[serde(deny_unknown_fields)]
99struct FeaGeometryImportDocument {
100 #[serde(default)]
101 max_triangles: Option<u64>,
102}
103
104#[derive(Debug, Deserialize)]
105#[serde(deny_unknown_fields)]
106struct FeaModelDocument {
107 #[serde(default)]
108 id: Option<String>,
109 profile: AnalysisCreateModelProfile,
110 #[serde(default)]
111 frame: Option<ReferenceFrame>,
112 #[serde(default)]
113 defaults: FeaModelDefaultsMode,
114}
115
116#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
117#[serde(rename_all = "snake_case")]
118enum FeaModelDefaultsMode {
119 #[default]
120 ProfileScaffold,
121 None,
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(deny_unknown_fields)]
126struct FeaRegionDocument {
127 selector: String,
128}
129
130#[derive(Debug, Deserialize)]
131#[serde(deny_unknown_fields)]
132struct FeaMaterialDocument {
133 #[serde(default)]
134 name: Option<String>,
135 mechanical: MaterialMechanicalModel,
136 #[serde(default)]
137 thermal: Option<MaterialThermalModel>,
138 #[serde(default)]
139 acoustic: Option<MaterialAcousticModel>,
140 #[serde(default)]
141 electrical: Option<MaterialElectricalModel>,
142 #[serde(default)]
143 plastic: Option<MaterialPlasticModel>,
144}
145
146#[derive(Debug, Deserialize)]
147#[serde(deny_unknown_fields)]
148struct FeaMaterialAssignmentDocument {
149 region: String,
150 material: String,
151 #[serde(default)]
152 expected_material: Option<String>,
153 #[serde(default = "default_assignment_confidence")]
154 confidence: EvidenceConfidence,
155}
156
157#[derive(Debug, Default, Deserialize)]
158#[serde(deny_unknown_fields)]
159struct FeaStructuralDocument {
160 #[serde(default)]
161 nodes: Vec<FeaStructuralNodeDocument>,
162 #[serde(default)]
163 elements: Vec<FeaStructuralElementDocument>,
164 #[serde(default)]
165 sections: Vec<FeaStructuralSectionDocument>,
166}
167
168#[derive(Debug, Clone, Deserialize)]
169#[serde(deny_unknown_fields)]
170struct FeaStructuralNodeDocument {
171 id: u32,
172 coordinates_m: [f64; 3],
173}
174
175#[derive(Debug, Clone, Deserialize)]
176#[serde(deny_unknown_fields)]
177struct FeaStructuralElementDocument {
178 id: String,
179 region: String,
180 #[serde(rename = "type", alias = "kind")]
181 element_type: FeaStructuralElementType,
182 nodes: Vec<u32>,
183 section: String,
184 #[serde(default)]
185 reference_axis: Option<[f64; 3]>,
186}
187
188#[derive(Debug, Clone, Copy, Deserialize)]
189#[serde(rename_all = "snake_case")]
190enum FeaStructuralElementType {
191 Beam,
192 Shell,
193}
194
195#[derive(Debug, Clone, Deserialize)]
196#[serde(deny_unknown_fields)]
197struct FeaStructuralSectionDocument {
198 id: String,
199 #[serde(rename = "type", alias = "kind", default)]
200 section_type: FeaStructuralSectionType,
201 #[serde(default)]
202 area_m2: Option<f64>,
203 #[serde(default)]
204 iy_m4: Option<f64>,
205 #[serde(default)]
206 iz_m4: Option<f64>,
207 #[serde(default)]
208 torsion_j_m4: Option<f64>,
209 #[serde(default)]
210 thickness_m: Option<f64>,
211 #[serde(default)]
212 shear_correction: Option<f64>,
213 #[serde(default)]
214 drilling_stiffness_scale: Option<f64>,
215 #[serde(default)]
216 outer_fiber_y_m: f64,
217 #[serde(default)]
218 outer_fiber_z_m: f64,
219 #[serde(default)]
220 torsion_outer_radius_m: f64,
221}
222
223#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
224#[serde(rename_all = "snake_case")]
225enum FeaStructuralSectionType {
226 #[default]
227 Beam,
228 BeamSection,
229 Shell,
230 ShellSection,
231}
232
233impl FeaStructuralSectionType {
234 fn is_beam(self) -> bool {
235 matches!(self, Self::Beam | Self::BeamSection)
236 }
237
238 fn is_shell(self) -> bool {
239 matches!(self, Self::Shell | Self::ShellSection)
240 }
241}
242
243#[derive(Debug, Deserialize)]
244#[serde(deny_unknown_fields)]
245struct FeaBoundaryConditionDocument {
246 id: String,
247 region: String,
248 #[serde(alias = "type")]
249 kind: FeaBoundaryConditionKindDocument,
250 #[serde(default)]
251 rx: Option<f64>,
252 #[serde(default)]
253 ry: Option<f64>,
254 #[serde(default)]
255 rz: Option<f64>,
256 #[serde(default)]
257 specific_impedance_pa_s_per_m: Option<f64>,
258 #[serde(default)]
259 temperature_k: Option<f64>,
260 #[serde(default)]
261 heat_flux_w_per_m2: Option<f64>,
262 #[serde(default)]
263 ambient_temperature_k: Option<f64>,
264 #[serde(default)]
265 coefficient_w_per_m2k: Option<f64>,
266 #[serde(default)]
267 velocity_m_per_s: Option<f64>,
268 #[serde(default)]
269 pressure_pa: Option<f64>,
270}
271
272#[derive(Debug, Deserialize)]
273#[serde(untagged)]
274enum FeaBoundaryConditionKindDocument {
275 Native(BoundaryConditionKind),
276 Named(FeaBoundaryConditionType),
277}
278
279#[derive(Debug, Clone, Copy, Deserialize)]
280#[serde(rename_all = "snake_case")]
281enum FeaBoundaryConditionType {
282 Fixed,
283 PrescribedDisplacement,
284 PrescribedRotation,
285 MagneticInsulation,
286 VectorPotentialGround,
287 AcousticRigidWall,
288 AcousticRadiation,
289 AcousticImpedance,
290 ThermalPrescribedTemperature,
291 ThermalHeatFlux,
292 ThermalConvection,
293 CfdInletVelocity,
294 CfdOutletPressure,
295 CfdNoSlipWall,
296 CfdSlipWall,
297 CfdSymmetry,
298}
299
300#[derive(Debug, Deserialize)]
301#[serde(deny_unknown_fields)]
302struct FeaLoadDocument {
303 id: String,
304 region: String,
305 #[serde(rename = "type", alias = "kind")]
306 load_type: FeaLoadType,
307 #[serde(default)]
308 vector: Option<[f64; 3]>,
309 #[serde(default)]
310 magnitude_pa: Option<f64>,
311 #[serde(default)]
312 current_a: Option<f64>,
313 #[serde(default)]
314 phase_rad: Option<f64>,
315 #[serde(default)]
316 amplitude_scale: Option<f64>,
317 #[serde(default)]
318 volumetric_w_per_m3: Option<f64>,
319}
320
321#[derive(Debug, Clone, Copy, Deserialize)]
322#[serde(rename_all = "snake_case")]
323enum FeaLoadType {
324 Force,
325 Moment,
326 Torque,
327 Pressure,
328 BodyForce,
329 CurrentDensity,
330 CoilCurrent,
331 HeatSource,
332}
333
334#[derive(Debug, Deserialize)]
335#[serde(deny_unknown_fields)]
336struct FeaStepDocument {
337 id: String,
338 kind: runmat_analysis_core::AnalysisStepKind,
339}
340
341#[derive(Debug, Default, Deserialize)]
342#[serde(deny_unknown_fields)]
343struct FeaDomainsDocument {
344 #[serde(default)]
345 thermo_mechanical: Option<ThermoMechanicalDomain>,
346 #[serde(default)]
347 electro_thermal: Option<ElectroThermalDomain>,
348 #[serde(default)]
349 electromagnetic: Option<ElectromagneticDomain>,
350 #[serde(default)]
351 cfd: Option<CfdDomain>,
352}
353
354#[derive(Debug, Deserialize)]
355#[serde(deny_unknown_fields)]
356struct FeaRunDocument {
357 #[serde(default)]
358 kind: Option<AnalysisRunKind>,
359 #[serde(default = "default_backend")]
360 backend: ComputeBackend,
361 #[serde(default)]
362 options: Option<serde_yaml::Value>,
363}
364
365#[derive(Debug, Clone, PartialEq)]
366struct ResolvedStudyParts {
367 spec: AnalysisStudySpec,
368}
369
370pub fn is_fea_file_path(path: &Path) -> bool {
371 path.extension()
372 .and_then(|ext| ext.to_str())
373 .is_some_and(|ext| ext.eq_ignore_ascii_case("fea"))
374}
375
376pub async fn load_fea_document_from_path_async(path: &Path) -> Result<FeaResolvedDocument, String> {
377 if !is_fea_file_path(path) {
378 return Err(format!(
379 "unsupported FEA document extension: {}",
380 path.display()
381 ));
382 }
383 let input = runmat_filesystem::read_to_string_async(path)
384 .await
385 .map_err(|err| format!("failed to read FEA document {}: {err}", path.display()))?;
386 let base_dir = path.parent().unwrap_or_else(|| Path::new(""));
387 parse_and_resolve_fea_document(&input, base_dir).await
388}
389
390pub async fn parse_and_resolve_fea_document(
391 input: &str,
392 base_dir: &Path,
393) -> Result<FeaResolvedDocument, String> {
394 let raw = serde_yaml::from_str::<RawFeaDocument>(input)
395 .map_err(|err| format!("failed to parse FEA YAML: {err}"))?;
396 match raw {
397 RawFeaDocument::Study(study) => {
398 let resolved = resolve_study(*study, base_dir).await?;
399 Ok(FeaResolvedDocument::Study(Box::new(resolved.spec)))
400 }
401 RawFeaDocument::Sweep(sweep) => resolve_sweep(sweep, base_dir).await,
402 }
403}
404
405async fn resolve_sweep(
406 sweep: FeaSweepDocument,
407 base_dir: &Path,
408) -> Result<FeaResolvedDocument, String> {
409 validate_version(sweep.version)?;
410 if sweep.id.trim().is_empty() {
411 return Err("sweep id must be non-empty".to_string());
412 }
413 let mut studies = Vec::with_capacity(sweep.studies.len());
414 for study in sweep.studies {
415 studies.push(resolve_study(study, base_dir).await?.spec);
416 }
417 Ok(FeaResolvedDocument::Sweep(AnalysisStudySweepSpec {
418 sweep_id: sweep.id,
419 studies,
420 fail_fast: sweep.fail_fast,
421 }))
422}
423
424async fn resolve_study(
425 study: FeaStudyDocument,
426 base_dir: &Path,
427) -> Result<ResolvedStudyParts, String> {
428 validate_version(study.version)?;
429 if study.id.trim().is_empty() {
430 return Err("study id must be non-empty".to_string());
431 }
432
433 let geometry = load_geometry(&study.geometry, base_dir).await?;
434 let model_id = study
435 .model
436 .id
437 .clone()
438 .unwrap_or_else(|| format!("{}_model", sanitize_id(&study.id)));
439 let intent = AnalysisCreateModelIntentSpec {
440 model_id: model_id.clone(),
441 profile: study.model.profile,
442 prep_context: None,
443 };
444 let model = resolve_model(&study, &geometry, &intent)?;
445 let run_kind = resolve_run_kind(study.model.profile, &study.run)?;
446 let run_options = resolve_run_options(&study.run, run_kind)?;
447 let spec = AnalysisStudySpec {
448 study_id: study.id,
449 geometry,
450 create_model_intent: intent,
451 model,
452 run_kind,
453 backend: study.run.backend,
454 linear_static_run_options: run_options.linear_static,
455 modal_run_options: run_options.modal,
456 acoustic_run_options: run_options.acoustic,
457 thermal_run_options: run_options.thermal,
458 transient_run_options: run_options.transient,
459 cfd_run_options: run_options.cfd,
460 cht_run_options: run_options.cht,
461 fsi_run_options: run_options.fsi,
462 nonlinear_run_options: run_options.nonlinear,
463 electromagnetic_run_options: run_options.electromagnetic,
464 };
465 Ok(ResolvedStudyParts { spec })
466}
467
468async fn load_geometry(
469 geometry: &FeaGeometryDocument,
470 base_dir: &Path,
471) -> Result<GeometryAsset, String> {
472 let path = resolve_document_path(base_dir, &geometry.path);
473 let bytes = runmat_filesystem::read_async(&path)
474 .await
475 .map_err(|err| format!("failed to read geometry file {}: {err}", path.display()))?;
476 let options = GeometryImportOptions {
477 max_triangles: geometry.import.max_triangles.or(Some(16_000_000)),
478 budget_policy: runmat_geometry_io::GeometryImportBudgetPolicy::Strict,
479 units: geometry.units,
480 tessellation_profile: Default::default(),
481 relative_deflection: false,
482 };
483 crate::geometry::geometry_load_with_options_op(
484 &path.to_string_lossy(),
485 &bytes,
486 options,
487 OperationContext::new(None, None),
488 )
489 .map(|envelope| envelope.data)
490 .map_err(|err| {
491 format!(
492 "failed to load geometry {}: {}",
493 path.display(),
494 err.message
495 )
496 })
497}
498
499fn resolve_model(
500 study: &FeaStudyDocument,
501 geometry: &GeometryAsset,
502 intent: &AnalysisCreateModelIntentSpec,
503) -> Result<Option<AnalysisModel>, String> {
504 if !has_explicit_model_data(study)
505 && study.model.defaults == FeaModelDefaultsMode::ProfileScaffold
506 {
507 return Ok(None);
508 }
509
510 let mut model = match study.model.defaults {
511 FeaModelDefaultsMode::ProfileScaffold => {
512 analysis_create_model_op(geometry, intent.clone(), OperationContext::new(None, None))
513 .map(|envelope| envelope.data)
514 .map_err(|err| format!("failed to create FEA model scaffold: {}", err.message))?
515 }
516 FeaModelDefaultsMode::None => empty_model(intent.model_id.clone(), geometry),
517 };
518
519 if let Some(frame) = &study.model.frame {
520 model.frame = frame.clone();
521 }
522 if !study.materials.is_empty() {
523 model.materials = study
524 .materials
525 .iter()
526 .map(|(id, material)| resolve_material(id, material))
527 .collect();
528 }
529 if !study.material_assignments.is_empty() {
530 model.material_assignments = study
531 .material_assignments
532 .iter()
533 .map(|assignment| resolve_material_assignment(assignment, geometry, &study.regions))
534 .collect::<Result<Vec<_>, _>>()?;
535 }
536 if has_structural_model_data(study) {
537 model.structural = Some(resolve_structural_model(study, geometry)?);
538 }
539 if !study.boundary_conditions.is_empty() {
540 model.boundary_conditions = study
541 .boundary_conditions
542 .iter()
543 .map(|bc| resolve_boundary_condition(bc, geometry, &study.regions))
544 .collect::<Result<Vec<_>, _>>()?;
545 }
546 if !study.loads.is_empty() {
547 model.loads = study
548 .loads
549 .iter()
550 .map(|load| resolve_load(load, geometry, &study.regions))
551 .collect::<Result<Vec<_>, _>>()?;
552 }
553 if !study.steps.is_empty() {
554 model.steps = study
555 .steps
556 .iter()
557 .map(|step| AnalysisStep {
558 step_id: step.id.clone(),
559 kind: step.kind.clone(),
560 })
561 .collect();
562 }
563 if study.domains.thermo_mechanical.is_some() {
564 model.thermo_mechanical = study.domains.thermo_mechanical.clone();
565 }
566 if study.domains.electro_thermal.is_some() {
567 model.electro_thermal = study.domains.electro_thermal.clone();
568 }
569 if study.domains.electromagnetic.is_some() {
570 model.electromagnetic = study.domains.electromagnetic.clone();
571 }
572 if study.domains.cfd.is_some() {
573 model.cfd = study.domains.cfd.clone();
574 }
575 if !study.interfaces.is_empty() {
576 model.interfaces = study.interfaces.clone();
577 }
578
579 Ok(Some(model))
580}
581
582fn resolve_material(id: &str, material: &FeaMaterialDocument) -> MaterialModel {
583 MaterialModel {
584 material_id: id.to_string(),
585 name: material.name.clone().unwrap_or_else(|| id.to_string()),
586 mechanical: material.mechanical.clone(),
587 thermal: material.thermal.clone().unwrap_or_default(),
588 acoustic: material.acoustic.clone(),
589 electrical: material.electrical.clone(),
590 plastic: material.plastic.clone(),
591 }
592}
593
594fn resolve_material_assignment(
595 assignment: &FeaMaterialAssignmentDocument,
596 geometry: &GeometryAsset,
597 aliases: &BTreeMap<String, FeaRegionDocument>,
598) -> Result<MaterialAssignment, String> {
599 let region_id = resolve_region_ref(&assignment.region, geometry, aliases)?;
600 Ok(MaterialAssignment {
601 region_id,
602 expected_material_id: assignment
603 .expected_material
604 .clone()
605 .unwrap_or_else(|| assignment.material.clone()),
606 assigned_material_id: assignment.material.clone(),
607 confidence: assignment.confidence,
608 })
609}
610
611fn resolve_structural_model(
612 study: &FeaStudyDocument,
613 geometry: &GeometryAsset,
614) -> Result<StructuralModel, String> {
615 let structural = study.structural.as_ref();
616 let node_docs = structural
617 .map(|value| value.nodes.as_slice())
618 .unwrap_or(study.nodes.as_slice());
619 let element_docs = structural
620 .map(|value| value.elements.as_slice())
621 .unwrap_or(study.elements.as_slice());
622 let section_docs = structural
623 .map(|value| value.sections.as_slice())
624 .unwrap_or(study.sections.as_slice());
625
626 Ok(StructuralModel {
627 nodes: node_docs
628 .iter()
629 .map(|node| StructuralNode {
630 node_id: node.id,
631 coordinates_m: node.coordinates_m,
632 })
633 .collect(),
634 elements: element_docs
635 .iter()
636 .map(|element| resolve_structural_element(element, geometry, &study.regions))
637 .collect::<Result<Vec<_>, _>>()?,
638 beam_sections: section_docs
639 .iter()
640 .filter(|section| section.section_type.is_beam())
641 .map(resolve_beam_section)
642 .collect::<Result<Vec<_>, _>>()?,
643 shell_sections: section_docs
644 .iter()
645 .filter(|section| section.section_type.is_shell())
646 .map(resolve_shell_section)
647 .collect::<Result<Vec<_>, _>>()?,
648 })
649}
650
651fn resolve_structural_element(
652 element: &FeaStructuralElementDocument,
653 geometry: &GeometryAsset,
654 aliases: &BTreeMap<String, FeaRegionDocument>,
655) -> Result<StructuralElement, String> {
656 let kind = match element.element_type {
657 FeaStructuralElementType::Beam => {
658 let node_ids: [u32; 2] = element.nodes.as_slice().try_into().map_err(|_| {
659 format!(
660 "beam element {} must specify exactly two node ids",
661 element.id
662 )
663 })?;
664 StructuralElementKind::Beam(BeamElementModel {
665 node_ids,
666 section_id: element.section.clone(),
667 reference_axis: element.reference_axis.unwrap_or([0.0, 0.0, 1.0]),
668 })
669 }
670 FeaStructuralElementType::Shell => {
671 let node_ids: [u32; 3] = element.nodes.as_slice().try_into().map_err(|_| {
672 format!(
673 "shell element {} must specify exactly three node ids",
674 element.id
675 )
676 })?;
677 StructuralElementKind::Shell(ShellElementModel {
678 node_ids,
679 section_id: element.section.clone(),
680 reference_axis: element.reference_axis.unwrap_or([1.0, 0.0, 0.0]),
681 })
682 }
683 };
684 Ok(StructuralElement {
685 element_id: element.id.clone(),
686 region_id: resolve_region_ref(&element.region, geometry, aliases)?,
687 kind,
688 })
689}
690
691fn resolve_beam_section(
692 section: &FeaStructuralSectionDocument,
693) -> Result<BeamSectionModel, String> {
694 Ok(BeamSectionModel {
695 section_id: section.id.clone(),
696 area_m2: required_f64(section.area_m2, "beam_section.area_m2")?,
697 iy_m4: required_f64(section.iy_m4, "beam_section.iy_m4")?,
698 iz_m4: required_f64(section.iz_m4, "beam_section.iz_m4")?,
699 torsion_j_m4: required_f64(section.torsion_j_m4, "beam_section.torsion_j_m4")?,
700 outer_fiber_y_m: section.outer_fiber_y_m,
701 outer_fiber_z_m: section.outer_fiber_z_m,
702 torsion_outer_radius_m: section.torsion_outer_radius_m,
703 })
704}
705
706fn resolve_shell_section(
707 section: &FeaStructuralSectionDocument,
708) -> Result<ShellSectionModel, String> {
709 Ok(ShellSectionModel {
710 section_id: section.id.clone(),
711 thickness_m: required_f64(section.thickness_m, "shell_section.thickness_m")?,
712 shear_correction: section.shear_correction.unwrap_or(5.0 / 6.0),
713 drilling_stiffness_scale: section.drilling_stiffness_scale.unwrap_or(1.0e-4),
714 })
715}
716
717fn resolve_boundary_condition(
718 bc: &FeaBoundaryConditionDocument,
719 geometry: &GeometryAsset,
720 aliases: &BTreeMap<String, FeaRegionDocument>,
721) -> Result<BoundaryCondition, String> {
722 let kind = match &bc.kind {
723 FeaBoundaryConditionKindDocument::Native(kind) => kind.clone(),
724 FeaBoundaryConditionKindDocument::Named(kind) => {
725 resolve_boundary_condition_kind(bc, *kind)?
726 }
727 };
728 Ok(BoundaryCondition {
729 bc_id: bc.id.clone(),
730 region_id: resolve_region_ref(&bc.region, geometry, aliases)?,
731 kind,
732 })
733}
734
735fn resolve_boundary_condition_kind(
736 bc: &FeaBoundaryConditionDocument,
737 kind: FeaBoundaryConditionType,
738) -> Result<BoundaryConditionKind, String> {
739 Ok(match kind {
740 FeaBoundaryConditionType::Fixed => BoundaryConditionKind::Fixed,
741 FeaBoundaryConditionType::PrescribedDisplacement => {
742 BoundaryConditionKind::PrescribedDisplacement
743 }
744 FeaBoundaryConditionType::PrescribedRotation => BoundaryConditionKind::PrescribedRotation {
745 rx: required_f64(bc.rx, "boundary.prescribed_rotation.rx")?,
746 ry: required_f64(bc.ry, "boundary.prescribed_rotation.ry")?,
747 rz: required_f64(bc.rz, "boundary.prescribed_rotation.rz")?,
748 },
749 FeaBoundaryConditionType::MagneticInsulation => BoundaryConditionKind::MagneticInsulation,
750 FeaBoundaryConditionType::VectorPotentialGround => {
751 BoundaryConditionKind::VectorPotentialGround
752 }
753 FeaBoundaryConditionType::AcousticRigidWall => BoundaryConditionKind::AcousticRigidWall,
754 FeaBoundaryConditionType::AcousticRadiation => BoundaryConditionKind::AcousticRadiation,
755 FeaBoundaryConditionType::AcousticImpedance => BoundaryConditionKind::AcousticImpedance {
756 specific_impedance_pa_s_per_m: required_f64(
757 bc.specific_impedance_pa_s_per_m,
758 "boundary.acoustic_impedance.specific_impedance_pa_s_per_m",
759 )?,
760 },
761 FeaBoundaryConditionType::ThermalPrescribedTemperature => {
762 BoundaryConditionKind::ThermalPrescribedTemperature {
763 temperature_k: required_f64(
764 bc.temperature_k,
765 "boundary.thermal_prescribed_temperature.temperature_k",
766 )?,
767 }
768 }
769 FeaBoundaryConditionType::ThermalHeatFlux => BoundaryConditionKind::ThermalHeatFlux {
770 heat_flux_w_per_m2: required_f64(
771 bc.heat_flux_w_per_m2,
772 "boundary.thermal_heat_flux.heat_flux_w_per_m2",
773 )?,
774 },
775 FeaBoundaryConditionType::ThermalConvection => BoundaryConditionKind::ThermalConvection {
776 ambient_temperature_k: required_f64(
777 bc.ambient_temperature_k,
778 "boundary.thermal_convection.ambient_temperature_k",
779 )?,
780 coefficient_w_per_m2k: required_f64(
781 bc.coefficient_w_per_m2k,
782 "boundary.thermal_convection.coefficient_w_per_m2k",
783 )?,
784 },
785 FeaBoundaryConditionType::CfdInletVelocity => BoundaryConditionKind::CfdInletVelocity {
786 velocity_m_per_s: required_f64(
787 bc.velocity_m_per_s,
788 "boundary.cfd_inlet_velocity.velocity_m_per_s",
789 )?,
790 },
791 FeaBoundaryConditionType::CfdOutletPressure => BoundaryConditionKind::CfdOutletPressure {
792 pressure_pa: required_f64(bc.pressure_pa, "boundary.cfd_outlet_pressure.pressure_pa")?,
793 },
794 FeaBoundaryConditionType::CfdNoSlipWall => BoundaryConditionKind::CfdNoSlipWall,
795 FeaBoundaryConditionType::CfdSlipWall => BoundaryConditionKind::CfdSlipWall,
796 FeaBoundaryConditionType::CfdSymmetry => BoundaryConditionKind::CfdSymmetry,
797 })
798}
799
800fn resolve_load(
801 load: &FeaLoadDocument,
802 geometry: &GeometryAsset,
803 aliases: &BTreeMap<String, FeaRegionDocument>,
804) -> Result<LoadCase, String> {
805 let kind = match load.load_type {
806 FeaLoadType::Force => {
807 let [fx, fy, fz] = load_vector(load, "force")?;
808 LoadKind::Force { fx, fy, fz }
809 }
810 FeaLoadType::Moment | FeaLoadType::Torque => {
811 let [mx, my, mz] = load_vector(load, "moment")?;
812 LoadKind::Moment { mx, my, mz }
813 }
814 FeaLoadType::Pressure => LoadKind::Pressure {
815 magnitude_pa: required_f64(load.magnitude_pa, "pressure.magnitude_pa")?,
816 },
817 FeaLoadType::BodyForce => {
818 let [gx, gy, gz] = load_vector(load, "body_force")?;
819 LoadKind::BodyForce { gx, gy, gz }
820 }
821 FeaLoadType::CurrentDensity => {
822 let [jx, jy, jz] = load_vector(load, "current_density")?;
823 LoadKind::CurrentDensity {
824 jx,
825 jy,
826 jz,
827 phase_rad: load.phase_rad.unwrap_or_default(),
828 amplitude_scale: load.amplitude_scale.unwrap_or(1.0),
829 }
830 }
831 FeaLoadType::CoilCurrent => LoadKind::CoilCurrent {
832 current_a: required_f64(load.current_a, "coil_current.current_a")?,
833 phase_rad: load.phase_rad.unwrap_or_default(),
834 amplitude_scale: load.amplitude_scale.unwrap_or(1.0),
835 },
836 FeaLoadType::HeatSource => LoadKind::HeatSource {
837 volumetric_w_per_m3: required_f64(
838 load.volumetric_w_per_m3,
839 "heat_source.volumetric_w_per_m3",
840 )?,
841 },
842 };
843 Ok(LoadCase {
844 load_id: load.id.clone(),
845 region_id: resolve_region_ref(&load.region, geometry, aliases)?,
846 kind,
847 })
848}
849
850#[derive(Debug, Default)]
851struct ResolvedRunOptions {
852 linear_static: Option<AnalysisRunOptions>,
853 modal: Option<AnalysisModalRunOptions>,
854 acoustic: Option<AnalysisAcousticRunOptions>,
855 thermal: Option<AnalysisThermalRunOptions>,
856 transient: Option<AnalysisTransientRunOptions>,
857 cfd: Option<AnalysisCfdRunOptions>,
858 cht: Option<AnalysisChtRunOptions>,
859 fsi: Option<AnalysisFsiRunOptions>,
860 nonlinear: Option<AnalysisNonlinearRunOptions>,
861 electromagnetic: Option<AnalysisElectromagneticRunOptions>,
862}
863
864fn resolve_run_kind(
865 profile: AnalysisCreateModelProfile,
866 run: &FeaRunDocument,
867) -> Result<AnalysisRunKind, String> {
868 let derived = profile.derived_run_kind();
869 if let Some(explicit) = run.kind {
870 if explicit != derived {
871 return Err(format!(
872 "run.kind {:?} does not match the solver selected by model.profile {:?}; omit run.kind unless you need an advanced matching solver override",
873 explicit, profile
874 ));
875 }
876 }
877 Ok(derived)
878}
879
880fn resolve_run_options(
881 run: &FeaRunDocument,
882 run_kind: AnalysisRunKind,
883) -> Result<ResolvedRunOptions, String> {
884 let Some(options) = run.options.clone() else {
885 return Ok(ResolvedRunOptions::default());
886 };
887 let mut resolved = ResolvedRunOptions::default();
888 match run_kind {
889 AnalysisRunKind::LinearStatic => {
890 resolved.linear_static = Some(parse_options(options, "linear_static options")?);
891 }
892 AnalysisRunKind::Modal => {
893 resolved.modal = Some(parse_options(options, "modal options")?);
894 }
895 AnalysisRunKind::Acoustic => {
896 resolved.acoustic = Some(parse_options(options, "acoustic options")?);
897 }
898 AnalysisRunKind::Thermal => {
899 resolved.thermal = Some(parse_options(options, "thermal options")?);
900 }
901 AnalysisRunKind::Transient => {
902 resolved.transient = Some(parse_options(options, "transient options")?);
903 }
904 AnalysisRunKind::Cfd => {
905 resolved.cfd = Some(parse_options(options, "cfd options")?);
906 }
907 AnalysisRunKind::Cht => {
908 resolved.cht = Some(parse_options(options, "cht options")?);
909 }
910 AnalysisRunKind::Fsi => {
911 resolved.fsi = Some(parse_options(options, "fsi options")?);
912 }
913 AnalysisRunKind::Nonlinear => {
914 resolved.nonlinear = Some(parse_options(options, "nonlinear options")?);
915 }
916 AnalysisRunKind::Electromagnetic => {
917 resolved.electromagnetic = Some(parse_options(options, "electromagnetic options")?);
918 }
919 }
920 Ok(resolved)
921}
922
923fn parse_options<T: DeserializeOwned>(
924 options: serde_yaml::Value,
925 label: &str,
926) -> Result<T, String> {
927 serde_yaml::from_value(options).map_err(|err| format!("invalid {label}: {err}"))
928}
929
930fn empty_model(model_id: String, geometry: &GeometryAsset) -> AnalysisModel {
931 AnalysisModel {
932 model_id: AnalysisModelId(model_id),
933 geometry_id: geometry.geometry_id.clone(),
934 geometry_revision: geometry.revision,
935 units: geometry.units,
936 frame: ReferenceFrame::Global,
937 materials: Vec::new(),
938 material_assignments: Vec::new(),
939 structural: None,
940 thermo_mechanical: None,
941 electro_thermal: None,
942 electromagnetic: None,
943 cfd: None,
944 interfaces: Vec::new(),
945 boundary_conditions: Vec::new(),
946 loads: Vec::new(),
947 steps: Vec::new(),
948 }
949}
950
951fn has_explicit_model_data(study: &FeaStudyDocument) -> bool {
952 !study.materials.is_empty()
953 || !study.material_assignments.is_empty()
954 || !study.boundary_conditions.is_empty()
955 || !study.loads.is_empty()
956 || !study.steps.is_empty()
957 || has_structural_model_data(study)
958 || study.domains.thermo_mechanical.is_some()
959 || study.domains.electro_thermal.is_some()
960 || study.domains.electromagnetic.is_some()
961 || study.domains.cfd.is_some()
962 || !study.interfaces.is_empty()
963 || study.model.frame.is_some()
964}
965
966fn has_structural_model_data(study: &FeaStudyDocument) -> bool {
967 study.structural.as_ref().is_some_and(|structural| {
968 !structural.nodes.is_empty()
969 || !structural.elements.is_empty()
970 || !structural.sections.is_empty()
971 }) || !study.nodes.is_empty()
972 || !study.elements.is_empty()
973 || !study.sections.is_empty()
974}
975
976fn resolve_region_ref(
977 reference: &str,
978 geometry: &GeometryAsset,
979 aliases: &BTreeMap<String, FeaRegionDocument>,
980) -> Result<String, String> {
981 if reference.strip_prefix("node:").is_some() || reference.parse::<u32>().is_ok() {
982 return Ok(reference.to_string());
983 }
984 if let Some(alias) = aliases.get(reference) {
985 return resolve_region_selector(&alias.selector, geometry);
986 }
987 resolve_region_selector(reference, geometry)
988}
989
990fn resolve_region_selector(selector: &str, geometry: &GeometryAsset) -> Result<String, String> {
991 if let Some(id) = selector
992 .strip_prefix("id:")
993 .or_else(|| selector.strip_prefix("region:"))
994 {
995 return require_region_id(id, geometry);
996 }
997 if let Some(tag) = selector.strip_prefix("tag:") {
998 return geometry
999 .regions
1000 .iter()
1001 .find(|region| region.tag.as_deref() == Some(tag))
1002 .map(|region| region.region_id.clone())
1003 .ok_or_else(|| format!("region tag `{tag}` was not found in geometry"));
1004 }
1005 if let Some(name) = selector.strip_prefix("name:") {
1006 return geometry
1007 .regions
1008 .iter()
1009 .find(|region| region.name == name)
1010 .map(|region| region.region_id.clone())
1011 .ok_or_else(|| format!("region name `{name}` was not found in geometry"));
1012 }
1013 require_region_id(selector, geometry)
1014}
1015
1016fn require_region_id(region_id: &str, geometry: &GeometryAsset) -> Result<String, String> {
1017 geometry
1018 .regions
1019 .iter()
1020 .find(|region| region.region_id == region_id)
1021 .map(|region| region.region_id.clone())
1022 .ok_or_else(|| format!("region id `{region_id}` was not found in geometry"))
1023}
1024
1025fn load_vector(load: &FeaLoadDocument, label: &str) -> Result<[f64; 3], String> {
1026 load.vector
1027 .ok_or_else(|| format!("{label} load requires vector: [x, y, z]"))
1028}
1029
1030fn required_f64(value: Option<f64>, label: &str) -> Result<f64, String> {
1031 value.ok_or_else(|| format!("{label} is required"))
1032}
1033
1034fn resolve_document_path(base_dir: &Path, path: &Path) -> PathBuf {
1035 if path.is_absolute() {
1036 path.to_path_buf()
1037 } else {
1038 base_dir.join(path)
1039 }
1040}
1041
1042fn validate_version(version: u32) -> Result<(), String> {
1043 if version == FEA_DOCUMENT_VERSION {
1044 Ok(())
1045 } else {
1046 Err(format!(
1047 "unsupported FEA document version {version}; expected {FEA_DOCUMENT_VERSION}"
1048 ))
1049 }
1050}
1051
1052fn sanitize_id(id: &str) -> String {
1053 id.chars()
1054 .map(|ch| {
1055 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
1056 ch
1057 } else {
1058 '_'
1059 }
1060 })
1061 .collect()
1062}
1063
1064fn default_units() -> UnitSystem {
1065 UnitSystem::Meter
1066}
1067
1068fn default_backend() -> ComputeBackend {
1069 ComputeBackend::Cpu
1070}
1071
1072fn default_fail_fast() -> bool {
1073 true
1074}
1075
1076fn default_assignment_confidence() -> EvidenceConfidence {
1077 EvidenceConfidence::Verified
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::*;
1083 use runmat_geometry_core::{
1084 GeometrySource, MeshDescriptor, MeshKind, Region, SourceGeometry, SourceGeometryKind,
1085 SurfaceMesh, TessellationProfile,
1086 };
1087
1088 fn sample_geometry() -> GeometryAsset {
1089 GeometryAsset {
1090 geometry_id: "geo:fea_document_test".to_string(),
1091 source: GeometrySource {
1092 path: "fixture.step".to_string(),
1093 sha256: "fixture".to_string(),
1094 importer_version: "test".to_string(),
1095 },
1096 source_geometry: SourceGeometry {
1097 kind: SourceGeometryKind::Cad,
1098 assembly: None,
1099 material_evidence: Vec::new(),
1100 },
1101 tessellation_profile: TessellationProfile::default(),
1102 units: UnitSystem::Meter,
1103 revision: 1,
1104 meshes: vec![MeshDescriptor {
1105 mesh_id: "mesh_1".to_string(),
1106 kind: MeshKind::Surface,
1107 vertex_count: 3,
1108 element_count: 1,
1109 }],
1110 surface_meshes: vec![SurfaceMesh::new(
1111 "mesh_1",
1112 vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1113 vec![[0, 1, 2]],
1114 )],
1115 regions: vec![Region {
1116 region_id: "tip".to_string(),
1117 name: "Tip".to_string(),
1118 tag: Some("tip".to_string()),
1119 cad_ownership: None,
1120 }],
1121 region_entity_mappings: Vec::new(),
1122 diagnostics: Vec::new(),
1123 }
1124 }
1125
1126 #[test]
1127 fn fea_document_resolves_moment_and_torque_loads() {
1128 let geometry = sample_geometry();
1129 for (load_type, expected_id) in [("moment", "tip_moment"), ("torque", "tip_torque")] {
1130 let load: FeaLoadDocument = serde_yaml::from_str(&format!(
1131 r#"
1132id: {expected_id}
1133region: tag:tip
1134type: {load_type}
1135vector: [1.0, 2.0, 3.0]
1136"#
1137 ))
1138 .expect("load document should parse");
1139
1140 let resolved = resolve_load(&load, &geometry, &BTreeMap::new())
1141 .expect("load should resolve against geometry");
1142
1143 assert_eq!(resolved.load_id, expected_id);
1144 assert_eq!(resolved.region_id, "tip");
1145 assert!(matches!(
1146 resolved.kind,
1147 LoadKind::Moment {
1148 mx: 1.0,
1149 my: 2.0,
1150 mz: 3.0
1151 }
1152 ));
1153 }
1154 }
1155
1156 #[test]
1157 fn fea_document_moment_requires_vector() {
1158 let geometry = sample_geometry();
1159 let load: FeaLoadDocument = serde_yaml::from_str(
1160 r#"
1161id: tip_moment
1162region: tip
1163type: moment
1164"#,
1165 )
1166 .expect("load document should parse");
1167
1168 let err = resolve_load(&load, &geometry, &BTreeMap::new())
1169 .expect_err("moment without vector should fail");
1170
1171 assert!(err.contains("moment load requires vector: [x, y, z]"));
1172 }
1173
1174 #[test]
1175 fn fea_document_moment_rejects_unknown_fields() {
1176 let err = serde_yaml::from_str::<FeaLoadDocument>(
1177 r#"
1178id: tip_moment
1179region: tip
1180type: moment
1181vector: [1.0, 2.0, 3.0]
1182units: n_m
1183"#,
1184 )
1185 .expect_err("unknown moment load fields should be rejected");
1186
1187 assert!(err.to_string().contains("unknown field"));
1188 }
1189}