Skip to main content

runmat_runtime/analysis/
fea_document.rs

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}