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 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}