Skip to main content

runmat_runtime/analysis/
fea_document_authoring.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value as JsonValue;
3use serde_yaml::{Mapping, Value as YamlValue};
4use std::str::FromStr;
5
6use super::contracts::{AnalysisCreateModelProfile, AnalysisRunKind};
7
8pub const FEA_STUDY_DOCUMENT_OPERATION_NAMES: &[&str] = &[
9    "get_summary",
10    "create",
11    "add_region",
12    "update_region",
13    "remove_region",
14    "add_material",
15    "update_material",
16    "assign_material",
17    "add_constraint",
18    "update_constraint",
19    "remove_constraint",
20    "add_driving_condition",
21    "update_driving_condition",
22    "remove_driving_condition",
23    "set_mesh",
24    "set_outputs",
25];
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum FeaStudyDocumentOperation {
30    GetSummary,
31    Create,
32    AddRegion,
33    UpdateRegion,
34    RemoveRegion,
35    AddMaterial,
36    UpdateMaterial,
37    AssignMaterial,
38    AddConstraint,
39    UpdateConstraint,
40    RemoveConstraint,
41    AddDrivingCondition,
42    UpdateDrivingCondition,
43    RemoveDrivingCondition,
44    SetMesh,
45    SetOutputs,
46}
47
48impl FeaStudyDocumentOperation {
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Self::GetSummary => "get_summary",
52            Self::Create => "create",
53            Self::AddRegion => "add_region",
54            Self::UpdateRegion => "update_region",
55            Self::RemoveRegion => "remove_region",
56            Self::AddMaterial => "add_material",
57            Self::UpdateMaterial => "update_material",
58            Self::AssignMaterial => "assign_material",
59            Self::AddConstraint => "add_constraint",
60            Self::UpdateConstraint => "update_constraint",
61            Self::RemoveConstraint => "remove_constraint",
62            Self::AddDrivingCondition => "add_driving_condition",
63            Self::UpdateDrivingCondition => "update_driving_condition",
64            Self::RemoveDrivingCondition => "remove_driving_condition",
65            Self::SetMesh => "set_mesh",
66            Self::SetOutputs => "set_outputs",
67        }
68    }
69}
70
71impl FromStr for FeaStudyDocumentOperation {
72    type Err = String;
73
74    fn from_str(value: &str) -> Result<Self, Self::Err> {
75        match value {
76            "get_summary" => Ok(Self::GetSummary),
77            "create" => Ok(Self::Create),
78            "add_region" => Ok(Self::AddRegion),
79            "update_region" => Ok(Self::UpdateRegion),
80            "remove_region" => Ok(Self::RemoveRegion),
81            "add_material" => Ok(Self::AddMaterial),
82            "update_material" => Ok(Self::UpdateMaterial),
83            "assign_material" => Ok(Self::AssignMaterial),
84            "add_constraint" => Ok(Self::AddConstraint),
85            "update_constraint" => Ok(Self::UpdateConstraint),
86            "remove_constraint" => Ok(Self::RemoveConstraint),
87            "add_driving_condition" => Ok(Self::AddDrivingCondition),
88            "update_driving_condition" => Ok(Self::UpdateDrivingCondition),
89            "remove_driving_condition" => Ok(Self::RemoveDrivingCondition),
90            "set_mesh" => Ok(Self::SetMesh),
91            "set_outputs" => Ok(Self::SetOutputs),
92            other => Err(format!(
93                "unsupported finite element study operation: {other}"
94            )),
95        }
96    }
97}
98
99#[derive(Debug, Serialize)]
100pub struct FeaStudyDocumentOperationOutput {
101    pub source: String,
102    pub result: FeaStudyDocumentOperationResult,
103    pub write: bool,
104}
105
106#[derive(Clone, Debug, Default, Serialize)]
107pub struct FeaStudySummary {
108    #[serde(rename = "studyId")]
109    pub study_id: Option<String>,
110    #[serde(rename = "geometryPath")]
111    pub geometry_path: Option<String>,
112    #[serde(rename = "geometryUnits")]
113    pub geometry_units: Option<String>,
114    #[serde(rename = "modelProfile")]
115    pub model_profile: Option<String>,
116    #[serde(rename = "modelDefaults")]
117    pub model_defaults: Option<String>,
118    #[serde(rename = "meshProfile")]
119    pub mesh_profile: Option<String>,
120    #[serde(rename = "meshTargetSize")]
121    pub mesh_target_size: Option<String>,
122    #[serde(rename = "meshMaxElements")]
123    pub mesh_max_elements: Option<String>,
124    #[serde(rename = "runKind")]
125    pub run_kind: Option<String>,
126    #[serde(rename = "runBackend")]
127    pub run_backend: Option<String>,
128}
129
130#[derive(Clone, Debug, Serialize)]
131pub struct FeaStudyRegionEntry {
132    pub key: String,
133    pub selector: Option<String>,
134}
135
136#[derive(Clone, Debug, Serialize)]
137pub struct FeaStudyMaterialEntry {
138    pub key: String,
139    pub name: Option<String>,
140    #[serde(rename = "mechanicalSummary")]
141    pub mechanical_summary: Option<String>,
142    #[serde(rename = "thermalSummary")]
143    pub thermal_summary: Option<String>,
144    #[serde(rename = "electromagneticSummary")]
145    pub electromagnetic_summary: Option<String>,
146    #[serde(rename = "acousticSummary")]
147    pub acoustic_summary: Option<String>,
148    #[serde(rename = "fluidSummary")]
149    pub fluid_summary: Option<String>,
150    #[serde(rename = "youngsModulusPa")]
151    pub youngs_modulus_pa: Option<String>,
152    #[serde(rename = "poissonRatio")]
153    pub poisson_ratio: Option<String>,
154}
155
156#[derive(Clone, Debug, Serialize)]
157pub struct FeaStudyMaterialAssignmentEntry {
158    pub index: usize,
159    pub region: Option<String>,
160    pub material: Option<String>,
161}
162
163#[derive(Clone, Debug, Serialize)]
164pub struct FeaStudyBoundaryConditionEntry {
165    pub index: usize,
166    pub id: Option<String>,
167    pub region: Option<String>,
168    pub kind: Option<String>,
169}
170
171#[derive(Clone, Debug, Serialize)]
172pub struct FeaStudyDrivingConditionEntry {
173    pub index: usize,
174    pub id: Option<String>,
175    pub region: Option<String>,
176    #[serde(rename = "type")]
177    pub condition_type: Option<String>,
178    pub value: Option<String>,
179    pub direction: Option<String>,
180}
181
182#[derive(Clone, Debug, Serialize)]
183pub struct FeaStudyStepEntry {
184    pub index: usize,
185    pub id: Option<String>,
186    pub kind: Option<String>,
187}
188
189#[derive(Clone, Debug, Serialize)]
190pub struct FeaStudyOutputEntry {
191    pub index: usize,
192    pub id: Option<String>,
193    pub field: Option<String>,
194    pub location: Option<String>,
195    pub kind: Option<String>,
196}
197
198#[derive(Clone, Debug, Serialize)]
199pub struct FeaStudyReadiness {
200    #[serde(rename = "readyToSolve")]
201    pub ready_to_solve: bool,
202    pub blockers: Vec<&'static str>,
203}
204
205#[derive(Clone, Debug, Serialize)]
206pub struct FeaStudyDocumentDiff {
207    #[serde(rename = "changedSections")]
208    pub changed_sections: Vec<String>,
209    pub before: FeaStudySummary,
210    pub after: FeaStudySummary,
211    #[serde(rename = "sourceLengthBefore")]
212    pub source_length_before: usize,
213    #[serde(rename = "sourceLengthAfter")]
214    pub source_length_after: usize,
215}
216
217#[derive(Clone, Debug, Serialize)]
218pub struct FeaStudyDocumentCounts {
219    pub regions: usize,
220    pub materials: usize,
221    pub material_assignments: usize,
222    pub boundary_conditions: usize,
223    pub driving_conditions: usize,
224    pub steps: usize,
225    pub outputs: usize,
226}
227
228#[derive(Clone, Debug, Serialize)]
229pub struct FeaStudyDocumentOperationResult {
230    pub path: String,
231    pub document_kind: String,
232    pub summary: FeaStudySummary,
233    pub regions: Vec<FeaStudyRegionEntry>,
234    pub materials: Vec<FeaStudyMaterialEntry>,
235    pub material_assignments: Vec<FeaStudyMaterialAssignmentEntry>,
236    pub boundary_conditions: Vec<FeaStudyBoundaryConditionEntry>,
237    pub driving_conditions: Vec<FeaStudyDrivingConditionEntry>,
238    pub steps: Vec<FeaStudyStepEntry>,
239    pub outputs: Vec<FeaStudyOutputEntry>,
240    pub counts: FeaStudyDocumentCounts,
241    pub readiness: FeaStudyReadiness,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub diff: Option<FeaStudyDocumentDiff>,
244}
245
246pub fn apply_fea_study_document_operation(
247    operation: &str,
248    path: &str,
249    source: Option<&str>,
250    input: JsonValue,
251) -> Result<FeaStudyDocumentOperationOutput, String> {
252    let operation = FeaStudyDocumentOperation::from_str(operation)?;
253    apply_fea_study_document_operation_typed(operation, path, source, input)
254}
255
256pub fn apply_fea_study_document_operation_typed(
257    operation: FeaStudyDocumentOperation,
258    path: &str,
259    source: Option<&str>,
260    input: JsonValue,
261) -> Result<FeaStudyDocumentOperationOutput, String> {
262    if operation == FeaStudyDocumentOperation::Create {
263        let study_id = read_string(&input, &["study_id", "studyId"])
264            .map(str::to_string)
265            .unwrap_or_else(|| study_id_from_path(path));
266        let geometry_path = required_string(&input, &["geometry_path", "geometryPath"])?;
267        let geometry_units =
268            read_string(&input, &["geometry_units", "geometryUnits"]).unwrap_or("millimeter");
269        let model_profile = required_string(&input, &["model_profile", "modelProfile"])?;
270        let next_source =
271            create_fea_study_source(&study_id, geometry_path, geometry_units, model_profile)?;
272        return Ok(FeaStudyDocumentOperationOutput {
273            result: with_diff(path, "", &next_source, &["document"])?,
274            source: next_source,
275            write: true,
276        });
277    }
278
279    let current_source = source.unwrap_or_default();
280    if operation == FeaStudyDocumentOperation::GetSummary {
281        return Ok(FeaStudyDocumentOperationOutput {
282            source: current_source.to_string(),
283            result: summarize_fea_study_document_contract(path, current_source)?,
284            write: false,
285        });
286    }
287
288    let mut document = parse_document(current_source)?;
289    match operation {
290        FeaStudyDocumentOperation::AddRegion => {
291            let region_id = required_id(&input, &["region_id", "regionId"])?;
292            let selector = required_string(&input, &["selector"])?;
293            let mut region = Mapping::new();
294            region.insert(yaml_string("selector"), yaml_string(selector));
295            insert_map_entry(
296                &mut document,
297                "regions",
298                &region_id,
299                YamlValue::Mapping(region),
300            )?;
301        }
302        FeaStudyDocumentOperation::UpdateRegion => {
303            let region_id = required_id(&input, &["region_id", "regionId"])?;
304            let selector = required_string(&input, &["selector"])?;
305            let mut region = Mapping::new();
306            region.insert(yaml_string("selector"), yaml_string(selector));
307            replace_map_entry(
308                &mut document,
309                "regions",
310                &region_id,
311                YamlValue::Mapping(region),
312            )?;
313        }
314        FeaStudyDocumentOperation::RemoveRegion => {
315            let region_id = required_id(&input, &["region_id", "regionId"])?;
316            remove_map_entry(&mut document, "regions", &region_id)?;
317        }
318        FeaStudyDocumentOperation::AddMaterial => {
319            let material_id = required_id(&input, &["material_id", "materialId"])?;
320            insert_map_entry(
321                &mut document,
322                "materials",
323                &material_id,
324                material_document(&input)?,
325            )?;
326        }
327        FeaStudyDocumentOperation::UpdateMaterial => {
328            let material_id = required_id(&input, &["material_id", "materialId"])?;
329            replace_map_entry(
330                &mut document,
331                "materials",
332                &material_id,
333                material_document(&input)?,
334            )?;
335        }
336        FeaStudyDocumentOperation::AssignMaterial => {
337            append_sequence_entry(
338                &mut document,
339                "material_assignments",
340                YamlValue::Mapping(mapping_from_pairs([
341                    (
342                        "region",
343                        yaml_string(required_id(&input, &["region_id", "regionId"])?),
344                    ),
345                    (
346                        "material",
347                        yaml_string(required_id(&input, &["material_id", "materialId"])?),
348                    ),
349                ])),
350            );
351        }
352        FeaStudyDocumentOperation::AddConstraint => {
353            append_sequence_entry_with_unique_id(
354                &mut document,
355                "boundary_conditions",
356                constraint_document(&input)?,
357            )?;
358        }
359        FeaStudyDocumentOperation::UpdateConstraint => {
360            replace_sequence_entry_by_id(
361                &mut document,
362                "boundary_conditions",
363                &required_id(&input, &["constraint_id", "constraintId"])?,
364                constraint_document(&input)?,
365            )?;
366        }
367        FeaStudyDocumentOperation::RemoveConstraint => {
368            remove_sequence_entry_by_id(
369                &mut document,
370                "boundary_conditions",
371                &required_id(&input, &["constraint_id", "constraintId"])?,
372            )?;
373        }
374        FeaStudyDocumentOperation::AddDrivingCondition => {
375            append_sequence_entry_with_unique_id_labeled(
376                &mut document,
377                "loads",
378                "driving_conditions",
379                driving_condition_document(&input)?,
380            )?;
381        }
382        FeaStudyDocumentOperation::UpdateDrivingCondition => {
383            replace_sequence_entry_by_id_labeled(
384                &mut document,
385                "loads",
386                "driving_conditions",
387                &required_id(&input, &["driving_condition_id", "drivingConditionId"])?,
388                driving_condition_document(&input)?,
389            )?;
390        }
391        FeaStudyDocumentOperation::RemoveDrivingCondition => {
392            remove_sequence_entry_by_id_labeled(
393                &mut document,
394                "loads",
395                "driving_conditions",
396                &required_id(&input, &["driving_condition_id", "drivingConditionId"])?,
397            )?;
398        }
399        FeaStudyDocumentOperation::SetMesh => {
400            replace_optional_map_entry(&mut document, "mesh", mesh_document(&input)?);
401        }
402        FeaStudyDocumentOperation::SetOutputs => {
403            replace_sequence_block(&mut document, "outputs", output_documents(&input)?);
404        }
405        FeaStudyDocumentOperation::Create | FeaStudyDocumentOperation::GetSummary => {
406            unreachable!("create and get_summary are handled before document mutation")
407        }
408    }
409
410    let next_source = serialize_document(&document)?;
411    Ok(FeaStudyDocumentOperationOutput {
412        result: with_diff(
413            path,
414            current_source,
415            &next_source,
416            &[section_for_operation(operation)],
417        )?,
418        source: next_source,
419        write: true,
420    })
421}
422
423fn create_fea_study_source(
424    study_id: &str,
425    geometry_path: &str,
426    geometry_units: &str,
427    model_profile: &str,
428) -> Result<String, String> {
429    let normalized_model_profile = model_profile.trim().to_ascii_lowercase();
430    let model_profile = AnalysisCreateModelProfile::from_snake_case(&normalized_model_profile)
431        .ok_or_else(|| format!("unsupported physics model profile: {model_profile}"))?;
432    let run_kind = model_profile.derived_run_kind();
433    let study_id = normalize_yaml_key(study_id).ok_or_else(|| {
434        "study_id must include at least one ASCII letter, number, or underscore".to_string()
435    })?;
436    let document = mapping_from_pairs([
437        ("version", yaml_number(1.0)?),
438        ("kind", yaml_string("study")),
439        ("id", yaml_string(study_id)),
440        (
441            "geometry",
442            YamlValue::Mapping(mapping_from_pairs([
443                ("path", yaml_string(geometry_path)),
444                ("units", yaml_string(geometry_units)),
445            ])),
446        ),
447        (
448            "model",
449            YamlValue::Mapping(mapping_from_pairs([(
450                "profile",
451                yaml_string(model_profile.as_snake_case()),
452            )])),
453        ),
454        (
455            "run",
456            YamlValue::Mapping(mapping_from_pairs([
457                ("kind", yaml_string(run_kind.as_snake_case())),
458                ("backend", yaml_string("cpu")),
459            ])),
460        ),
461    ]);
462    serialize_document(&YamlValue::Mapping(document))
463}
464
465pub fn summarize_fea_study_document(path: &str, source: &str) -> Result<JsonValue, String> {
466    serde_json::to_value(summarize_fea_study_document_contract(path, source)?)
467        .map_err(|err| err.to_string())
468}
469
470pub fn summarize_fea_study_document_contract(
471    path: &str,
472    source: &str,
473) -> Result<FeaStudyDocumentOperationResult, String> {
474    let document = parse_document(source)?;
475    let document_kind =
476        read_yaml_string(&document, &["kind"]).unwrap_or_else(|| "study".to_string());
477    let summary = summarize_fea_study(&document);
478    let regions = map_entries(&document, "regions", |key, value| FeaStudyRegionEntry {
479        key,
480        selector: nested_scalar(value, &["selector"]),
481    });
482    let materials = map_entries(&document, "materials", |key, value| {
483        let youngs = nested_scalar(value, &["mechanical", "youngs_modulus_pa"]);
484        let poisson = nested_scalar(value, &["mechanical", "poisson_ratio"]);
485        let mechanical_summary = match (youngs.as_deref(), poisson.as_deref()) {
486            (Some(youngs), Some(poisson)) => Some(format!(
487                "E {}, nu {}",
488                format_engineering_value(youngs),
489                poisson
490            )),
491            (Some(youngs), None) => Some(format!("E {}", format_engineering_value(youngs))),
492            (None, Some(poisson)) => Some(format!("nu {poisson}")),
493            (None, None) => None,
494        };
495        FeaStudyMaterialEntry {
496            key,
497            name: nested_scalar(value, &["name"]),
498            mechanical_summary,
499            thermal_summary: material_group_summary(
500                value,
501                "thermal",
502                &[
503                    ("k", "thermal_conductivity_w_per_m_k"),
504                    ("cp", "specific_heat_j_per_kg_k"),
505                    ("rho", "density_kg_per_m3"),
506                ],
507            ),
508            electromagnetic_summary: material_group_summary(
509                value,
510                "electromagnetic",
511                &[
512                    ("epsilon_r", "relative_permittivity"),
513                    ("mu_r", "relative_permeability"),
514                    ("sigma", "electrical_conductivity_s_per_m"),
515                ],
516            ),
517            acoustic_summary: material_group_summary(
518                value,
519                "acoustic",
520                &[
521                    ("rho", "density_kg_per_m3"),
522                    ("c", "speed_of_sound_m_per_s"),
523                    ("Z", "acoustic_impedance_pa_s_per_m"),
524                ],
525            ),
526            fluid_summary: material_group_summary(
527                value,
528                "fluid",
529                &[
530                    ("rho", "density_kg_per_m3"),
531                    ("mu", "dynamic_viscosity_pa_s"),
532                    ("p_ref", "reference_pressure_pa"),
533                    ("T_ref", "reference_temperature_k"),
534                ],
535            ),
536            youngs_modulus_pa: youngs,
537            poisson_ratio: poisson,
538        }
539    });
540    let material_assignments =
541        sequence_entries(&document, "material_assignments", |index, value| {
542            FeaStudyMaterialAssignmentEntry {
543                index,
544                region: nested_scalar(value, &["region"]),
545                material: nested_scalar(value, &["material"]),
546            }
547        });
548    let boundary_conditions = sequence_entries(&document, "boundary_conditions", |index, value| {
549        FeaStudyBoundaryConditionEntry {
550            index,
551            id: nested_scalar(value, &["id"]),
552            region: nested_scalar(value, &["region"]),
553            kind: nested_scalar(value, &["kind"]).or_else(|| nested_scalar(value, &["type"])),
554        }
555    });
556    let driving_conditions = sequence_entries(&document, "loads", |index, value| {
557        FeaStudyDrivingConditionEntry {
558            index,
559            id: nested_scalar(value, &["id"]),
560            region: nested_scalar(value, &["region"]),
561            condition_type: nested_scalar(value, &["type"])
562                .or_else(|| nested_scalar(value, &["kind"])),
563            value: nested_scalar(value, &["vector"])
564                .or_else(|| nested_scalar(value, &["force"]))
565                .or_else(|| nested_scalar(value, &["moment"]))
566                .or_else(|| nested_scalar(value, &["magnitude_pa"]))
567                .or_else(|| nested_scalar(value, &["pressure_pa"]))
568                .or_else(|| nested_scalar(value, &["velocity_m_per_s"]))
569                .or_else(|| nested_scalar(value, &["mass_flow_kg_per_s"]))
570                .or_else(|| nested_scalar(value, &["volumetric_flow_m3_per_s"]))
571                .or_else(|| nested_scalar(value, &["temperature_k"]))
572                .or_else(|| nested_scalar(value, &["heat_flux_w_per_m2"]))
573                .or_else(|| nested_scalar(value, &["volumetric_w_per_m3"]))
574                .or_else(|| nested_scalar(value, &["current_a"]))
575                .or_else(|| nested_scalar(value, &["voltage_v"]))
576                .or_else(|| nested_scalar(value, &["power_w"]))
577                .or_else(|| nested_scalar(value, &["frequency_hz"]))
578                .or_else(|| nested_scalar(value, &["amplitude_scale"])),
579            direction: nested_scalar(value, &["direction"])
580                .or_else(|| nested_scalar(value, &["normal"])),
581        }
582    });
583    let steps = sequence_entries(&document, "steps", |index, value| FeaStudyStepEntry {
584        index,
585        id: nested_scalar(value, &["id"]),
586        kind: nested_scalar(value, &["kind"]),
587    });
588    let outputs = sequence_entries(&document, "outputs", |index, value| FeaStudyOutputEntry {
589        index,
590        id: nested_scalar(value, &["id"]),
591        field: nested_scalar(value, &["field"])
592            .or_else(|| nested_scalar(value, &["field_id"]))
593            .or_else(|| nested_scalar(value, &["name"])),
594        location: nested_scalar(value, &["location"]).or_else(|| nested_scalar(value, &["target"])),
595        kind: nested_scalar(value, &["kind"]).or_else(|| nested_scalar(value, &["type"])),
596    });
597    let readiness = study_readiness(
598        &summary,
599        &materials,
600        &material_assignments,
601        &boundary_conditions,
602        &driving_conditions,
603    );
604
605    Ok(FeaStudyDocumentOperationResult {
606        path: path.to_string(),
607        document_kind,
608        counts: FeaStudyDocumentCounts {
609            regions: regions.len(),
610            materials: materials.len(),
611            material_assignments: material_assignments.len(),
612            boundary_conditions: boundary_conditions.len(),
613            driving_conditions: driving_conditions.len(),
614            steps: steps.len(),
615            outputs: outputs.len(),
616        },
617        summary,
618        regions,
619        materials,
620        material_assignments,
621        boundary_conditions,
622        driving_conditions,
623        steps,
624        outputs,
625        readiness,
626        diff: None,
627    })
628}
629
630fn with_diff(
631    path: &str,
632    before_source: &str,
633    after_source: &str,
634    changed_sections: &[&str],
635) -> Result<FeaStudyDocumentOperationResult, String> {
636    let mut result = summarize_fea_study_document_contract(path, after_source)?;
637    let before = summarize_fea_study(&parse_document(before_source)?);
638    let after = summarize_fea_study(&parse_document(after_source)?);
639    result.diff = Some(FeaStudyDocumentDiff {
640        changed_sections: changed_sections
641            .iter()
642            .map(|section| (*section).to_string())
643            .collect(),
644        before,
645        after,
646        source_length_before: before_source.len(),
647        source_length_after: after_source.len(),
648    });
649    Ok(result)
650}
651
652fn summarize_fea_study(document: &YamlValue) -> FeaStudySummary {
653    let model_profile = read_yaml_string(document, &["model", "profile"]);
654    let explicit_run_kind = read_yaml_string(document, &["run", "kind"]);
655    let run_kind = explicit_run_kind.or_else(|| {
656        model_profile
657            .as_deref()
658            .and_then(derive_fea_run_kind_from_profile)
659            .map(str::to_string)
660    });
661    FeaStudySummary {
662        study_id: read_yaml_string(document, &["id"]),
663        geometry_path: read_yaml_string(document, &["geometry", "path"]),
664        geometry_units: read_yaml_string(document, &["geometry", "units"]),
665        model_profile,
666        model_defaults: read_yaml_string(document, &["model", "defaults"]),
667        mesh_profile: read_yaml_string(document, &["mesh", "profile"]),
668        mesh_target_size: read_yaml_string(document, &["mesh", "target_size"]),
669        mesh_max_elements: read_yaml_string(document, &["mesh", "max_elements"]),
670        run_kind,
671        run_backend: read_yaml_string(document, &["run", "backend"]),
672    }
673}
674
675fn derive_fea_run_kind_from_profile(profile: &str) -> Option<&'static str> {
676    AnalysisCreateModelProfile::from_snake_case(profile)
677        .map(AnalysisCreateModelProfile::derived_run_kind)
678        .map(AnalysisRunKind::as_snake_case)
679}
680
681#[derive(Clone, Copy, Debug, Eq, PartialEq)]
682enum FeaStudyReadinessProfile {
683    Structural,
684    Modal,
685    Thermal,
686    Electromagnetic,
687    Acoustic,
688    Cfd,
689    Coupled,
690}
691
692impl FeaStudyReadinessProfile {
693    fn from_summary(summary: &FeaStudySummary) -> Option<Self> {
694        let profile =
695            AnalysisCreateModelProfile::from_snake_case(summary.model_profile.as_deref()?.trim())?;
696        if profile.catalog_entry().family == "coupled physics" {
697            return Some(Self::Coupled);
698        }
699        match profile.derived_run_kind() {
700            AnalysisRunKind::LinearStatic
701            | AnalysisRunKind::Transient
702            | AnalysisRunKind::Nonlinear => Some(Self::Structural),
703            AnalysisRunKind::Modal => Some(Self::Modal),
704            AnalysisRunKind::Thermal => Some(Self::Thermal),
705            AnalysisRunKind::Electromagnetic => Some(Self::Electromagnetic),
706            AnalysisRunKind::Acoustic => Some(Self::Acoustic),
707            AnalysisRunKind::Cfd => Some(Self::Cfd),
708            AnalysisRunKind::Cht | AnalysisRunKind::Fsi => Some(Self::Coupled),
709        }
710    }
711
712    const fn material_blocker(self) -> &'static str {
713        match self {
714            Self::Structural | Self::Modal => {
715                "Define at least one material for the structural model."
716            }
717            Self::Thermal => "Define at least one material or medium for the thermal model.",
718            Self::Electromagnetic => {
719                "Define at least one material or medium for the electromagnetic model."
720            }
721            Self::Acoustic => "Define at least one material or medium for the acoustic model.",
722            Self::Cfd => "Define at least one fluid material or medium for the CFD model.",
723            Self::Coupled => "Define materials or media for each active coupled-physics domain.",
724        }
725    }
726
727    const fn material_assignment_blocker(self) -> &'static str {
728        match self {
729            Self::Structural | Self::Modal => {
730                "Assign material to at least one CAD region or selector."
731            }
732            Self::Thermal => "Assign thermal material or medium to at least one region.",
733            Self::Electromagnetic => {
734                "Assign electromagnetic material or medium to at least one region."
735            }
736            Self::Acoustic => "Assign acoustic material or medium to at least one region.",
737            Self::Cfd => "Assign fluid material or medium to at least one flow region.",
738            Self::Coupled => "Assign materials or media to the coupled-physics regions.",
739        }
740    }
741
742    const fn boundary_blocker(self) -> Option<&'static str> {
743        match self {
744            Self::Structural => Some("Add at least one structural boundary condition."),
745            Self::Modal => None,
746            Self::Thermal => Some("Add at least one thermal boundary condition."),
747            Self::Electromagnetic => Some("Add at least one electromagnetic boundary condition."),
748            Self::Acoustic => Some("Add at least one acoustic boundary condition."),
749            Self::Cfd => Some("Add at least one CFD boundary condition."),
750            Self::Coupled => Some("Add boundary or interface conditions for the coupled domains."),
751        }
752    }
753
754    const fn driving_condition_blocker(self) -> Option<&'static str> {
755        match self {
756            Self::Structural => Some("Add at least one structural driving condition."),
757            Self::Modal => None,
758            Self::Thermal => Some("Add at least one thermal source or driving condition."),
759            Self::Electromagnetic => {
760                Some("Add at least one electromagnetic source or driving condition.")
761            }
762            Self::Acoustic => Some("Add at least one acoustic source or excitation."),
763            Self::Cfd => Some("Add at least one flow driver or source."),
764            Self::Coupled => Some("Add driving conditions or sources for the coupled domains."),
765        }
766    }
767}
768
769fn study_readiness(
770    summary: &FeaStudySummary,
771    materials: &[FeaStudyMaterialEntry],
772    material_assignments: &[FeaStudyMaterialAssignmentEntry],
773    boundary_conditions: &[FeaStudyBoundaryConditionEntry],
774    driving_conditions: &[FeaStudyDrivingConditionEntry],
775) -> FeaStudyReadiness {
776    let mut blockers = Vec::new();
777    if summary.geometry_path.as_deref().is_none_or(str::is_empty) {
778        blockers.push("Set geometry.path to the CAD or mesh file for this study.");
779    }
780    let model_profile = summary.model_profile.as_deref();
781    if model_profile.is_none_or(str::is_empty) {
782        blockers.push("Choose a physics model profile.");
783    }
784    if summary.run_backend.as_deref().is_none_or(str::is_empty) {
785        blockers.push("Choose a run backend.");
786    }
787    if model_profile.is_some() && FeaStudyReadinessProfile::from_summary(summary).is_none() {
788        blockers.push("Choose a supported physics model profile.");
789    }
790    if let Some(profile) = FeaStudyReadinessProfile::from_summary(summary) {
791        if materials.is_empty() {
792            blockers.push(profile.material_blocker());
793        }
794        if !has_material_assignment(material_assignments) {
795            blockers.push(profile.material_assignment_blocker());
796        }
797        if let Some(blocker) = profile.boundary_blocker() {
798            if !has_boundary_condition(boundary_conditions) {
799                blockers.push(blocker);
800            }
801        }
802        if let Some(blocker) = profile.driving_condition_blocker() {
803            if !has_driving_condition(driving_conditions) {
804                blockers.push(blocker);
805            }
806        }
807    }
808    FeaStudyReadiness {
809        ready_to_solve: blockers.is_empty(),
810        blockers,
811    }
812}
813
814fn has_material_assignment(assignments: &[FeaStudyMaterialAssignmentEntry]) -> bool {
815    assignments.iter().any(|assignment| {
816        assignment
817            .region
818            .as_deref()
819            .is_some_and(|value| !value.is_empty())
820            && assignment
821                .material
822                .as_deref()
823                .is_some_and(|value| !value.is_empty())
824    })
825}
826
827fn has_boundary_condition(boundary_conditions: &[FeaStudyBoundaryConditionEntry]) -> bool {
828    boundary_conditions.iter().any(|boundary| {
829        boundary
830            .region
831            .as_deref()
832            .is_some_and(|value| !value.is_empty())
833            && boundary
834                .kind
835                .as_deref()
836                .is_some_and(|value| !value.is_empty())
837    })
838}
839
840fn has_driving_condition(driving_conditions: &[FeaStudyDrivingConditionEntry]) -> bool {
841    driving_conditions.iter().any(|condition| {
842        condition
843            .region
844            .as_deref()
845            .is_some_and(|value| !value.is_empty())
846            && condition
847                .condition_type
848                .as_deref()
849                .is_some_and(|value| !value.is_empty())
850    })
851}
852
853fn material_document(input: &JsonValue) -> Result<YamlValue, String> {
854    let mut material = Mapping::new();
855    if let Some(name) = read_string(input, &["name"]) {
856        material.insert(yaml_string("name"), yaml_string(name));
857    }
858    insert_optional_material_group(
859        &mut material,
860        "mechanical",
861        input,
862        &[
863            (
864                "youngs_modulus_pa",
865                &["youngs_modulus_pa", "youngsModulusPa"],
866            ),
867            ("poisson_ratio", &["poisson_ratio", "poissonRatio"]),
868            (
869                "density_kg_per_m3",
870                &[
871                    "mechanical_density_kg_per_m3",
872                    "mechanicalDensityKgPerM3",
873                    "density_kg_per_m3",
874                    "densityKgPerM3",
875                ],
876            ),
877        ],
878    )?;
879    insert_optional_material_group(
880        &mut material,
881        "thermal",
882        input,
883        &[
884            (
885                "thermal_conductivity_w_per_m_k",
886                &[
887                    "thermal_conductivity_w_per_m_k",
888                    "thermalConductivityWPerMK",
889                ],
890            ),
891            (
892                "specific_heat_j_per_kg_k",
893                &["specific_heat_j_per_kg_k", "specificHeatJPerKgK"],
894            ),
895            (
896                "density_kg_per_m3",
897                &["thermal_density_kg_per_m3", "thermalDensityKgPerM3"],
898            ),
899        ],
900    )?;
901    insert_optional_material_group(
902        &mut material,
903        "electromagnetic",
904        input,
905        &[
906            (
907                "relative_permittivity",
908                &["relative_permittivity", "relativePermittivity"],
909            ),
910            (
911                "relative_permeability",
912                &["relative_permeability", "relativePermeability"],
913            ),
914            (
915                "electrical_conductivity_s_per_m",
916                &[
917                    "electrical_conductivity_s_per_m",
918                    "electricalConductivitySPerM",
919                ],
920            ),
921        ],
922    )?;
923    insert_optional_material_group(
924        &mut material,
925        "acoustic",
926        input,
927        &[
928            (
929                "density_kg_per_m3",
930                &["acoustic_density_kg_per_m3", "acousticDensityKgPerM3"],
931            ),
932            (
933                "speed_of_sound_m_per_s",
934                &["speed_of_sound_m_per_s", "speedOfSoundMPerS"],
935            ),
936            (
937                "acoustic_impedance_pa_s_per_m",
938                &["acoustic_impedance_pa_s_per_m", "acousticImpedancePaSPerM"],
939            ),
940        ],
941    )?;
942    insert_optional_material_group(
943        &mut material,
944        "fluid",
945        input,
946        &[
947            (
948                "density_kg_per_m3",
949                &["fluid_density_kg_per_m3", "fluidDensityKgPerM3"],
950            ),
951            (
952                "dynamic_viscosity_pa_s",
953                &["dynamic_viscosity_pa_s", "dynamicViscosityPaS"],
954            ),
955            (
956                "reference_pressure_pa",
957                &["reference_pressure_pa", "referencePressurePa"],
958            ),
959            (
960                "reference_temperature_k",
961                &["reference_temperature_k", "referenceTemperatureK"],
962            ),
963        ],
964    )?;
965    if material.len() <= usize::from(material.contains_key(yaml_string("name"))) {
966        return Err("material requires at least one physics material property".to_string());
967    }
968    Ok(YamlValue::Mapping(material))
969}
970
971fn insert_optional_material_group(
972    material: &mut Mapping,
973    group_name: &str,
974    input: &JsonValue,
975    fields: &[(&str, &[&str])],
976) -> Result<(), String> {
977    let mut group = Mapping::new();
978    for (output_key, input_keys) in fields {
979        let Some(value) = read_number(input, input_keys) else {
980            continue;
981        };
982        group.insert(yaml_string(output_key), yaml_number(value)?);
983    }
984    if !group.is_empty() {
985        material.insert(yaml_string(group_name), YamlValue::Mapping(group));
986    }
987    Ok(())
988}
989
990fn constraint_document(input: &JsonValue) -> Result<YamlValue, String> {
991    Ok(YamlValue::Mapping(
992        mapping_from_pairs([
993            (
994                "id",
995                yaml_string(required_id(input, &["constraint_id", "constraintId"])?),
996            ),
997            (
998                "region",
999                yaml_string(required_id(input, &["region_id", "regionId"])?),
1000            ),
1001            ("kind", yaml_string(required_id(input, &["kind"])?)),
1002            ("rx", optional_yaml_number(input, &["rx"])?),
1003            ("ry", optional_yaml_number(input, &["ry"])?),
1004            ("rz", optional_yaml_number(input, &["rz"])?),
1005            (
1006                "temperature_k",
1007                optional_yaml_number(input, &["temperature_k", "temperatureK"])?,
1008            ),
1009            (
1010                "pressure_pa",
1011                optional_yaml_number(input, &["pressure_pa", "pressurePa"])?,
1012            ),
1013        ])
1014        .into_iter()
1015        .filter(|(_, value)| !matches!(value, YamlValue::Null))
1016        .collect(),
1017    ))
1018}
1019
1020fn driving_condition_document(input: &JsonValue) -> Result<YamlValue, String> {
1021    Ok(YamlValue::Mapping(
1022        mapping_from_pairs([
1023            (
1024                "id",
1025                yaml_string(required_id(
1026                    input,
1027                    &["driving_condition_id", "drivingConditionId"],
1028                )?),
1029            ),
1030            (
1031                "region",
1032                yaml_string(required_id(input, &["region_id", "regionId"])?),
1033            ),
1034            ("type", yaml_string(required_id(input, &["type", "kind"])?)),
1035            ("vector", optional_vector(input, "vector")?),
1036            ("force", optional_vector(input, "force")?),
1037            ("moment", optional_vector(input, "moment")?),
1038            ("point", optional_vector(input, "point")?),
1039            (
1040                "magnitude_pa",
1041                optional_yaml_number(input, &["magnitude_pa", "magnitudePa"])?,
1042            ),
1043            (
1044                "pressure_pa",
1045                optional_yaml_number(input, &["pressure_pa", "pressurePa"])?,
1046            ),
1047            (
1048                "velocity_m_per_s",
1049                optional_yaml_number(input, &["velocity_m_per_s", "velocityMPerS"])?,
1050            ),
1051            (
1052                "mass_flow_kg_per_s",
1053                optional_yaml_number(input, &["mass_flow_kg_per_s", "massFlowKgPerS"])?,
1054            ),
1055            (
1056                "volumetric_flow_m3_per_s",
1057                optional_yaml_number(input, &["volumetric_flow_m3_per_s", "volumetricFlowM3PerS"])?,
1058            ),
1059            (
1060                "temperature_k",
1061                optional_yaml_number(input, &["temperature_k", "temperatureK"])?,
1062            ),
1063            (
1064                "heat_flux_w_per_m2",
1065                optional_yaml_number(input, &["heat_flux_w_per_m2", "heatFluxWPerM2"])?,
1066            ),
1067            (
1068                "current_a",
1069                optional_yaml_number(input, &["current_a", "currentA"])?,
1070            ),
1071            (
1072                "voltage_v",
1073                optional_yaml_number(input, &["voltage_v", "voltageV"])?,
1074            ),
1075            (
1076                "power_w",
1077                optional_yaml_number(input, &["power_w", "powerW"])?,
1078            ),
1079            (
1080                "frequency_hz",
1081                optional_yaml_number(input, &["frequency_hz", "frequencyHz"])?,
1082            ),
1083            (
1084                "phase_rad",
1085                optional_yaml_number(input, &["phase_rad", "phaseRad"])?,
1086            ),
1087            (
1088                "amplitude_scale",
1089                optional_yaml_number(input, &["amplitude_scale", "amplitudeScale"])?,
1090            ),
1091            (
1092                "volumetric_w_per_m3",
1093                optional_yaml_number(input, &["volumetric_w_per_m3", "volumetricWPerM3"])?,
1094            ),
1095        ])
1096        .into_iter()
1097        .filter(|(_, value)| !matches!(value, YamlValue::Null))
1098        .collect(),
1099    ))
1100}
1101
1102fn output_documents(input: &JsonValue) -> Result<Vec<YamlValue>, String> {
1103    if let Some(outputs) = input.get("outputs").and_then(JsonValue::as_array) {
1104        return outputs.iter().map(output_document).collect();
1105    }
1106    Ok(vec![output_document(input)?])
1107}
1108
1109fn output_document(input: &JsonValue) -> Result<YamlValue, String> {
1110    Ok(YamlValue::Mapping(
1111        mapping_from_pairs([
1112            (
1113                "id",
1114                yaml_string(required_id(input, &["output_id", "outputId", "id"])?),
1115            ),
1116            (
1117                "field",
1118                yaml_string(required_string(
1119                    input,
1120                    &["field_id", "fieldId", "field", "name"],
1121                )?),
1122            ),
1123            (
1124                "location",
1125                optional_yaml_string(input, &["location", "target"]),
1126            ),
1127            ("kind", optional_yaml_string(input, &["kind", "type"])),
1128        ])
1129        .into_iter()
1130        .filter(|(_, value)| !matches!(value, YamlValue::Null))
1131        .collect(),
1132    ))
1133}
1134
1135fn mesh_document(input: &JsonValue) -> Result<YamlValue, String> {
1136    let mut mesh = Mapping::new();
1137    insert_optional_string(&mut mesh, "backend", input, &["backend"]);
1138    insert_optional_string(&mut mesh, "kind", input, &["kind"]);
1139    insert_optional_string(&mut mesh, "element", input, &["element"]);
1140    insert_optional_string(
1141        &mut mesh,
1142        "element_order",
1143        input,
1144        &["element_order", "elementOrder"],
1145    );
1146    insert_optional_string(&mut mesh, "profile", input, &["profile"]);
1147    insert_optional_number(
1148        &mut mesh,
1149        "max_elements",
1150        input,
1151        &["max_elements", "maxElements"],
1152    )?;
1153    insert_optional_number(
1154        &mut mesh,
1155        "target_size",
1156        input,
1157        &["target_size", "targetSize"],
1158    )?;
1159    insert_optional_number(&mut mesh, "min_size", input, &["min_size", "minSize"])?;
1160    insert_optional_number(&mut mesh, "max_size", input, &["max_size", "maxSize"])?;
1161    insert_optional_number(
1162        &mut mesh,
1163        "growth_rate",
1164        input,
1165        &["growth_rate", "growthRate"],
1166    )?;
1167
1168    let refinement = mesh_refinement_document(input)?;
1169    if let YamlValue::Mapping(refinement) = &refinement {
1170        if !refinement.is_empty() {
1171            mesh.insert(
1172                yaml_string("refinement"),
1173                YamlValue::Mapping(refinement.clone()),
1174            );
1175        }
1176    }
1177
1178    let validation = mesh_validation_document(input)?;
1179    if let YamlValue::Mapping(validation) = &validation {
1180        if !validation.is_empty() {
1181            mesh.insert(
1182                yaml_string("validation"),
1183                YamlValue::Mapping(validation.clone()),
1184            );
1185        }
1186    }
1187
1188    Ok(YamlValue::Mapping(mesh))
1189}
1190
1191fn mesh_refinement_document(input: &JsonValue) -> Result<YamlValue, String> {
1192    let mut refinement = Mapping::new();
1193    insert_optional_string(
1194        &mut refinement,
1195        "strategy",
1196        input,
1197        &["refinement_strategy", "refinementStrategy"],
1198    );
1199    insert_optional_number(
1200        &mut refinement,
1201        "max_iterations",
1202        input,
1203        &["refinement_max_iterations", "refinementMaxIterations"],
1204    )?;
1205
1206    let mut convergence = Mapping::new();
1207    insert_optional_number(
1208        &mut convergence,
1209        "field_change_tolerance",
1210        input,
1211        &["field_change_tolerance", "fieldChangeTolerance"],
1212    )?;
1213    insert_optional_number(
1214        &mut convergence,
1215        "energy_change_tolerance",
1216        input,
1217        &["energy_change_tolerance", "energyChangeTolerance"],
1218    )?;
1219    insert_optional_number(
1220        &mut convergence,
1221        "residual_tolerance",
1222        input,
1223        &["residual_tolerance", "residualTolerance"],
1224    )?;
1225    if !convergence.is_empty() {
1226        refinement.insert(yaml_string("convergence"), YamlValue::Mapping(convergence));
1227    }
1228
1229    let mut focus = Mapping::new();
1230    insert_optional_string(
1231        &mut focus,
1232        "loads",
1233        input,
1234        &["refinement_load_focus", "refinementLoadFocus"],
1235    );
1236    insert_optional_string(
1237        &mut focus,
1238        "constraints",
1239        input,
1240        &["refinement_constraint_focus", "refinementConstraintFocus"],
1241    );
1242    insert_optional_string(
1243        &mut focus,
1244        "interfaces",
1245        input,
1246        &["refinement_interface_focus", "refinementInterfaceFocus"],
1247    );
1248    insert_optional_bool(
1249        &mut focus,
1250        "curvature",
1251        input,
1252        &["refinement_curvature", "refinementCurvature"],
1253    )?;
1254    insert_optional_bool(
1255        &mut focus,
1256        "small_features",
1257        input,
1258        &["refinement_small_features", "refinementSmallFeatures"],
1259    )?;
1260    if !focus.is_empty() {
1261        refinement.insert(yaml_string("focus"), YamlValue::Mapping(focus));
1262    }
1263
1264    Ok(YamlValue::Mapping(refinement))
1265}
1266
1267fn mesh_validation_document(input: &JsonValue) -> Result<YamlValue, String> {
1268    let mut validation = Mapping::new();
1269    insert_optional_string(
1270        &mut validation,
1271        "coverage",
1272        input,
1273        &["validation_coverage", "validationCoverage"],
1274    );
1275    insert_optional_string(
1276        &mut validation,
1277        "quality",
1278        input,
1279        &["validation_quality", "validationQuality"],
1280    );
1281    insert_optional_number(
1282        &mut validation,
1283        "min_bounds_coverage_ratio",
1284        input,
1285        &["min_bounds_coverage_ratio", "minBoundsCoverageRatio"],
1286    )?;
1287    insert_optional_number(
1288        &mut validation,
1289        "min_volume_coverage_ratio",
1290        input,
1291        &["min_volume_coverage_ratio", "minVolumeCoverageRatio"],
1292    )?;
1293    insert_optional_number(
1294        &mut validation,
1295        "min_boundary_area_ratio",
1296        input,
1297        &["min_boundary_area_ratio", "minBoundaryAreaRatio"],
1298    )?;
1299    insert_optional_number(
1300        &mut validation,
1301        "min_boundary_face_recovery_ratio",
1302        input,
1303        &[
1304            "min_boundary_face_recovery_ratio",
1305            "minBoundaryFaceRecoveryRatio",
1306        ],
1307    )?;
1308    insert_optional_number(
1309        &mut validation,
1310        "min_boundary_edge_recovery_ratio",
1311        input,
1312        &[
1313            "min_boundary_edge_recovery_ratio",
1314            "minBoundaryEdgeRecoveryRatio",
1315        ],
1316    )?;
1317    insert_optional_number(
1318        &mut validation,
1319        "max_volume_components",
1320        input,
1321        &["max_volume_components", "maxVolumeComponents"],
1322    )?;
1323    Ok(YamlValue::Mapping(validation))
1324}
1325
1326fn parse_document(source: &str) -> Result<YamlValue, String> {
1327    if source.trim().is_empty() {
1328        return Ok(YamlValue::Mapping(Mapping::new()));
1329    }
1330    serde_yaml::from_str(source).map_err(|err| format!("failed to parse FEA YAML: {err}"))
1331}
1332
1333fn serialize_document(document: &YamlValue) -> Result<String, String> {
1334    serde_yaml::to_string(document)
1335        .map(|source| {
1336            let without_marker = source.strip_prefix("---\n").unwrap_or(&source);
1337            if without_marker.ends_with('\n') {
1338                without_marker.to_string()
1339            } else {
1340                format!("{without_marker}\n")
1341            }
1342        })
1343        .map_err(|err| format!("failed to serialize FEA YAML: {err}"))
1344}
1345
1346fn insert_map_entry(
1347    document: &mut YamlValue,
1348    block_key: &str,
1349    entry_key: &str,
1350    value: YamlValue,
1351) -> Result<(), String> {
1352    let root = ensure_mapping(document);
1353    let block_name = block_key;
1354    let block_key = yaml_string(block_key);
1355    let block = root
1356        .entry(block_key)
1357        .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
1358    let block = ensure_mapping(block);
1359    let key = yaml_string(entry_key);
1360    if block.contains_key(&key) {
1361        return Err(format!("{block_name} entry already exists: {entry_key}"));
1362    }
1363    block.insert(key, value);
1364    Ok(())
1365}
1366
1367fn replace_map_entry(
1368    document: &mut YamlValue,
1369    block_key: &str,
1370    entry_key: &str,
1371    value: YamlValue,
1372) -> Result<(), String> {
1373    let block = require_mapping_block(document, block_key)?;
1374    let key = yaml_string(entry_key);
1375    if !block.contains_key(&key) {
1376        return Err(format!("{block_key} entry does not exist: {entry_key}"));
1377    }
1378    block.insert(key, value);
1379    Ok(())
1380}
1381
1382fn remove_map_entry(
1383    document: &mut YamlValue,
1384    block_key: &str,
1385    entry_key: &str,
1386) -> Result<(), String> {
1387    let block = require_mapping_block(document, block_key)?;
1388    if block.remove(yaml_string(entry_key)).is_none() {
1389        return Err(format!("{block_key} entry does not exist: {entry_key}"));
1390    }
1391    Ok(())
1392}
1393
1394fn replace_optional_map_entry(document: &mut YamlValue, block_key: &str, value: YamlValue) {
1395    ensure_mapping(document).insert(yaml_string(block_key), value);
1396}
1397
1398fn append_sequence_entry(document: &mut YamlValue, block_key: &str, value: YamlValue) {
1399    let root = ensure_mapping(document);
1400    let block_key = yaml_string(block_key);
1401    let block = root
1402        .entry(block_key)
1403        .or_insert_with(|| YamlValue::Sequence(Vec::new()));
1404    match block {
1405        YamlValue::Sequence(items) => items.push(value),
1406        _ => {
1407            *block = YamlValue::Sequence(vec![value]);
1408        }
1409    }
1410}
1411
1412fn replace_sequence_block(document: &mut YamlValue, block_key: &str, values: Vec<YamlValue>) {
1413    ensure_mapping(document).insert(yaml_string(block_key), YamlValue::Sequence(values));
1414}
1415
1416fn append_sequence_entry_with_unique_id(
1417    document: &mut YamlValue,
1418    block_key: &str,
1419    value: YamlValue,
1420) -> Result<(), String> {
1421    append_sequence_entry_with_unique_id_labeled(document, block_key, block_key, value)
1422}
1423
1424fn append_sequence_entry_with_unique_id_labeled(
1425    document: &mut YamlValue,
1426    block_key: &str,
1427    public_block_label: &str,
1428    value: YamlValue,
1429) -> Result<(), String> {
1430    let id = nested_scalar(&value, &["id"])
1431        .ok_or_else(|| format!("{public_block_label} entry requires id"))?;
1432    let root = ensure_mapping(document);
1433    let block_key_value = yaml_string(block_key);
1434    let block = root
1435        .entry(block_key_value)
1436        .or_insert_with(|| YamlValue::Sequence(Vec::new()));
1437    let YamlValue::Sequence(items) = block else {
1438        return Err(format!("{public_block_label} must be a sequence"));
1439    };
1440    if items
1441        .iter()
1442        .any(|item| nested_scalar(item, &["id"]).as_deref() == Some(id.as_str()))
1443    {
1444        return Err(format!("{public_block_label} entry already exists: {id}"));
1445    }
1446    items.push(value);
1447    Ok(())
1448}
1449
1450fn replace_sequence_entry_by_id(
1451    document: &mut YamlValue,
1452    block_key: &str,
1453    id: &str,
1454    value: YamlValue,
1455) -> Result<(), String> {
1456    replace_sequence_entry_by_id_labeled(document, block_key, block_key, id, value)
1457}
1458
1459fn replace_sequence_entry_by_id_labeled(
1460    document: &mut YamlValue,
1461    block_key: &str,
1462    public_block_label: &str,
1463    id: &str,
1464    value: YamlValue,
1465) -> Result<(), String> {
1466    let items = require_sequence_block_labeled(document, block_key, public_block_label)?;
1467    let Some(existing) = items
1468        .iter_mut()
1469        .find(|item| nested_scalar(item, &["id"]).as_deref() == Some(id))
1470    else {
1471        return Err(format!("{public_block_label} entry does not exist: {id}"));
1472    };
1473    *existing = value;
1474    Ok(())
1475}
1476
1477fn remove_sequence_entry_by_id(
1478    document: &mut YamlValue,
1479    block_key: &str,
1480    id: &str,
1481) -> Result<(), String> {
1482    remove_sequence_entry_by_id_labeled(document, block_key, block_key, id)
1483}
1484
1485fn remove_sequence_entry_by_id_labeled(
1486    document: &mut YamlValue,
1487    block_key: &str,
1488    public_block_label: &str,
1489    id: &str,
1490) -> Result<(), String> {
1491    let items = require_sequence_block_labeled(document, block_key, public_block_label)?;
1492    let initial_len = items.len();
1493    items.retain(|item| nested_scalar(item, &["id"]).as_deref() != Some(id));
1494    if items.len() == initial_len {
1495        return Err(format!("{public_block_label} entry does not exist: {id}"));
1496    }
1497    Ok(())
1498}
1499
1500fn ensure_mapping(value: &mut YamlValue) -> &mut Mapping {
1501    if !matches!(value, YamlValue::Mapping(_)) {
1502        *value = YamlValue::Mapping(Mapping::new());
1503    }
1504    match value {
1505        YamlValue::Mapping(mapping) => mapping,
1506        _ => unreachable!(),
1507    }
1508}
1509
1510fn require_mapping_block<'a>(
1511    document: &'a mut YamlValue,
1512    block_key: &str,
1513) -> Result<&'a mut Mapping, String> {
1514    let root = ensure_mapping(document);
1515    let block = root
1516        .get_mut(yaml_string(block_key))
1517        .ok_or_else(|| format!("{block_key} block does not exist"))?;
1518    match block {
1519        YamlValue::Mapping(mapping) => Ok(mapping),
1520        _ => Err(format!("{block_key} must be a mapping")),
1521    }
1522}
1523
1524fn require_sequence_block_labeled<'a>(
1525    document: &'a mut YamlValue,
1526    block_key: &str,
1527    public_block_label: &str,
1528) -> Result<&'a mut Vec<YamlValue>, String> {
1529    let root = ensure_mapping(document);
1530    let block = root
1531        .get_mut(yaml_string(block_key))
1532        .ok_or_else(|| format!("{public_block_label} block does not exist"))?;
1533    match block {
1534        YamlValue::Sequence(items) => Ok(items),
1535        _ => Err(format!("{public_block_label} must be a sequence")),
1536    }
1537}
1538
1539fn map_entries<T, F>(document: &YamlValue, key: &str, mut map: F) -> Vec<T>
1540where
1541    F: FnMut(String, &YamlValue) -> T,
1542{
1543    document
1544        .as_mapping()
1545        .and_then(|root| root.get(yaml_string(key)))
1546        .and_then(YamlValue::as_mapping)
1547        .map(|items| {
1548            items
1549                .iter()
1550                .filter_map(|(key, value)| key.as_str().map(|key| map(key.to_string(), value)))
1551                .collect()
1552        })
1553        .unwrap_or_default()
1554}
1555
1556fn sequence_entries<T, F>(document: &YamlValue, key: &str, mut map: F) -> Vec<T>
1557where
1558    F: FnMut(usize, &YamlValue) -> T,
1559{
1560    document
1561        .as_mapping()
1562        .and_then(|root| root.get(yaml_string(key)))
1563        .and_then(YamlValue::as_sequence)
1564        .map(|items| {
1565            items
1566                .iter()
1567                .enumerate()
1568                .map(|(index, value)| map(index, value))
1569                .collect()
1570        })
1571        .unwrap_or_default()
1572}
1573
1574fn nested_scalar(value: &YamlValue, path: &[&str]) -> Option<String> {
1575    let mut current = value;
1576    for key in path {
1577        current = current.as_mapping()?.get(yaml_string(key))?;
1578    }
1579    scalar_to_string(current)
1580}
1581
1582fn read_yaml_string(value: &YamlValue, path: &[&str]) -> Option<String> {
1583    nested_scalar(value, path)
1584}
1585
1586fn scalar_to_string(value: &YamlValue) -> Option<String> {
1587    match value {
1588        YamlValue::String(value) => Some(value.clone()),
1589        YamlValue::Number(value) => Some(value.to_string()),
1590        YamlValue::Bool(value) => Some(value.to_string()),
1591        YamlValue::Sequence(items) => Some(format!(
1592            "[{}]",
1593            items
1594                .iter()
1595                .filter_map(scalar_to_string)
1596                .collect::<Vec<_>>()
1597                .join(", ")
1598        )),
1599        _ => None,
1600    }
1601}
1602
1603fn mapping_from_pairs<const N: usize>(pairs: [(&str, YamlValue); N]) -> Mapping {
1604    pairs
1605        .into_iter()
1606        .map(|(key, value)| (yaml_string(key), value))
1607        .collect()
1608}
1609
1610fn yaml_string(value: impl AsRef<str>) -> YamlValue {
1611    YamlValue::String(value.as_ref().to_string())
1612}
1613
1614fn yaml_number(value: f64) -> Result<YamlValue, String> {
1615    serde_yaml::to_value(value).map_err(|err| err.to_string())
1616}
1617
1618fn optional_yaml_number(input: &JsonValue, keys: &[&str]) -> Result<YamlValue, String> {
1619    read_number(input, keys)
1620        .map(yaml_number)
1621        .transpose()
1622        .map(|value| value.unwrap_or(YamlValue::Null))
1623}
1624
1625fn optional_yaml_string(input: &JsonValue, keys: &[&str]) -> YamlValue {
1626    read_string(input, keys)
1627        .map(yaml_string)
1628        .unwrap_or(YamlValue::Null)
1629}
1630
1631fn material_group_summary(
1632    value: &YamlValue,
1633    group_name: &str,
1634    fields: &[(&str, &str)],
1635) -> Option<String> {
1636    let parts = fields
1637        .iter()
1638        .filter_map(|(label, field)| {
1639            nested_scalar(value, &[group_name, field])
1640                .map(|value| format!("{label} {}", format_engineering_value(&value)))
1641        })
1642        .collect::<Vec<_>>();
1643    (!parts.is_empty()).then(|| parts.join(", "))
1644}
1645
1646fn insert_optional_string(
1647    mapping: &mut Mapping,
1648    output_key: &str,
1649    input: &JsonValue,
1650    input_keys: &[&str],
1651) {
1652    if let Some(value) = read_string(input, input_keys) {
1653        mapping.insert(yaml_string(output_key), yaml_string(value));
1654    }
1655}
1656
1657fn insert_optional_number(
1658    mapping: &mut Mapping,
1659    output_key: &str,
1660    input: &JsonValue,
1661    input_keys: &[&str],
1662) -> Result<(), String> {
1663    if let Some(value) = read_number(input, input_keys) {
1664        mapping.insert(yaml_string(output_key), yaml_number(value)?);
1665    }
1666    Ok(())
1667}
1668
1669fn insert_optional_bool(
1670    mapping: &mut Mapping,
1671    output_key: &str,
1672    input: &JsonValue,
1673    input_keys: &[&str],
1674) -> Result<(), String> {
1675    for key in input_keys {
1676        let Some(value) = input.get(*key) else {
1677            continue;
1678        };
1679        let Some(value) = value.as_bool() else {
1680            return Err(format!("{key} must be a boolean"));
1681        };
1682        mapping.insert(yaml_string(output_key), YamlValue::Bool(value));
1683        return Ok(());
1684    }
1685    Ok(())
1686}
1687
1688fn optional_vector(input: &JsonValue, key: &str) -> Result<YamlValue, String> {
1689    let Some(value) = input.get(key) else {
1690        return Ok(YamlValue::Null);
1691    };
1692    let Some(items) = value.as_array() else {
1693        return Err(format!("{key} must be a 3-number vector"));
1694    };
1695    if items.len() != 3 {
1696        return Err(format!("{key} must be a 3-number vector"));
1697    }
1698    let values = items
1699        .iter()
1700        .map(|value| {
1701            value
1702                .as_f64()
1703                .filter(|value| value.is_finite())
1704                .ok_or_else(|| format!("{key} must be a 3-number vector"))
1705                .and_then(yaml_number)
1706        })
1707        .collect::<Result<Vec<_>, _>>()?;
1708    Ok(YamlValue::Sequence(values))
1709}
1710
1711fn required_string<'a>(input: &'a JsonValue, keys: &[&str]) -> Result<&'a str, String> {
1712    read_string(input, keys).ok_or_else(|| format!("missing required field: {}", keys.join(" or ")))
1713}
1714
1715fn read_string<'a>(input: &'a JsonValue, keys: &[&str]) -> Option<&'a str> {
1716    keys.iter()
1717        .filter_map(|key| input.get(*key)?.as_str())
1718        .map(str::trim)
1719        .find(|value| !value.is_empty())
1720}
1721
1722fn required_id(input: &JsonValue, keys: &[&str]) -> Result<String, String> {
1723    let value = required_string(input, keys)?;
1724    normalize_yaml_key(value).ok_or_else(|| {
1725        format!(
1726            "{} must include at least one ASCII letter, number, or underscore",
1727            keys.join(" or ")
1728        )
1729    })
1730}
1731
1732fn read_number(input: &JsonValue, keys: &[&str]) -> Option<f64> {
1733    keys.iter().find_map(|key| {
1734        let value = input.get(*key)?;
1735        let numeric = value
1736            .as_f64()
1737            .or_else(|| value.as_str().and_then(|value| value.trim().parse().ok()))?;
1738        numeric.is_finite().then_some(numeric)
1739    })
1740}
1741
1742fn normalize_yaml_key(value: impl AsRef<str>) -> Option<String> {
1743    let mut normalized = String::new();
1744    let mut last_was_separator = false;
1745    for ch in value.as_ref().trim().chars() {
1746        if ch.is_ascii_alphanumeric() || ch == '_' {
1747            normalized.push(ch);
1748            last_was_separator = false;
1749        } else if !last_was_separator {
1750            normalized.push('_');
1751            last_was_separator = true;
1752        }
1753    }
1754    let normalized = normalized.trim_matches('_');
1755    if normalized.is_empty() {
1756        return None;
1757    }
1758    let mut normalized = normalized.to_string();
1759    if normalized
1760        .chars()
1761        .next()
1762        .is_some_and(|ch| ch.is_ascii_digit())
1763    {
1764        normalized.insert(0, '_');
1765    }
1766    Some(normalized)
1767}
1768
1769fn study_id_from_path(path: &str) -> String {
1770    let file_name = path
1771        .rsplit('/')
1772        .find(|part| !part.is_empty())
1773        .unwrap_or(path);
1774    let stem = file_name
1775        .rsplit_once('.')
1776        .map(|(stem, _)| stem)
1777        .unwrap_or(file_name);
1778    normalize_yaml_key(stem).unwrap_or_else(|| "study".to_string())
1779}
1780
1781fn section_for_operation(operation: FeaStudyDocumentOperation) -> &'static str {
1782    match operation {
1783        FeaStudyDocumentOperation::AddRegion
1784        | FeaStudyDocumentOperation::UpdateRegion
1785        | FeaStudyDocumentOperation::RemoveRegion => "regions",
1786        FeaStudyDocumentOperation::AddMaterial | FeaStudyDocumentOperation::UpdateMaterial => {
1787            "materials"
1788        }
1789        FeaStudyDocumentOperation::AssignMaterial => "material_assignments",
1790        FeaStudyDocumentOperation::AddConstraint
1791        | FeaStudyDocumentOperation::UpdateConstraint
1792        | FeaStudyDocumentOperation::RemoveConstraint => "boundary_conditions",
1793        FeaStudyDocumentOperation::AddDrivingCondition
1794        | FeaStudyDocumentOperation::UpdateDrivingCondition
1795        | FeaStudyDocumentOperation::RemoveDrivingCondition => "driving_conditions",
1796        FeaStudyDocumentOperation::SetMesh => "mesh",
1797        FeaStudyDocumentOperation::SetOutputs => "outputs",
1798        FeaStudyDocumentOperation::Create | FeaStudyDocumentOperation::GetSummary => "document",
1799    }
1800}
1801
1802fn format_engineering_value(value: &str) -> String {
1803    let Ok(numeric) = value.parse::<f64>() else {
1804        return value.to_string();
1805    };
1806    if numeric.abs() >= 1_000_000_000.0 {
1807        format!("{:.3} GPa", numeric / 1_000_000_000.0)
1808    } else if numeric.abs() >= 1_000_000.0 {
1809        format!("{:.3} MPa", numeric / 1_000_000.0)
1810    } else {
1811        format!("{numeric:.3}")
1812    }
1813}
1814
1815#[cfg(test)]
1816mod tests {
1817    use super::*;
1818    use crate::analysis::contracts::ANALYSIS_PHYSICS_PROFILE_CATALOG;
1819
1820    #[test]
1821    fn operation_names_are_the_rust_owned_contract() {
1822        let parsed = FEA_STUDY_DOCUMENT_OPERATION_NAMES
1823            .iter()
1824            .map(|name| {
1825                let operation =
1826                    FeaStudyDocumentOperation::from_str(name).expect("operation name should parse");
1827                operation.as_str()
1828            })
1829            .collect::<Vec<_>>();
1830
1831        assert_eq!(parsed, FEA_STUDY_DOCUMENT_OPERATION_NAMES);
1832        assert_eq!(
1833            FeaStudyDocumentOperation::from_str("patch_yaml")
1834                .expect_err("unknown operation should fail"),
1835            "unsupported finite element study operation: patch_yaml"
1836        );
1837    }
1838
1839    #[test]
1840    fn creates_and_summarizes_study_document() {
1841        let output = apply_fea_study_document_operation(
1842            "create",
1843            "/project/Wall Hook 30mm.fea",
1844            None,
1845            serde_json::json!({
1846                "geometry_path": "WallHook_30mm.stp",
1847                "geometry_units": "millimeter",
1848                "model_profile": "linear_static_structural"
1849            }),
1850        )
1851        .expect("create should succeed");
1852
1853        assert!(output.write);
1854        assert!(output.source.contains("kind: study"));
1855        assert_eq!(
1856            output.result.summary.study_id.as_deref(),
1857            Some("Wall_Hook_30mm")
1858        );
1859        assert_eq!(
1860            output.result.summary.geometry_path.as_deref(),
1861            Some("WallHook_30mm.stp")
1862        );
1863        assert_eq!(
1864            output.result.summary.run_kind.as_deref(),
1865            Some("linear_static")
1866        );
1867        assert!(!output.result.readiness.ready_to_solve);
1868        assert_eq!(
1869            output.result.readiness.blockers[0],
1870            "Define at least one material for the structural model."
1871        );
1872    }
1873
1874    #[test]
1875    fn document_authoring_accepts_every_supported_physics_profile() {
1876        for entry in ANALYSIS_PHYSICS_PROFILE_CATALOG {
1877            let profile = serde_json::to_value(entry.profile)
1878                .expect("profile serializes")
1879                .as_str()
1880                .expect("profile serializes as string")
1881                .to_string();
1882            let expected_run_kind = serde_json::to_value(entry.profile.derived_run_kind())
1883                .expect("run kind serializes")
1884                .as_str()
1885                .expect("run kind serializes as string")
1886                .to_string();
1887            assert!(
1888                !entry.default_outputs.is_empty(),
1889                "{profile} should publish default output fields"
1890            );
1891
1892            let created = apply_fea_study_document_operation(
1893                "create",
1894                &format!("/project/{profile}.fea"),
1895                None,
1896                serde_json::json!({
1897                    "geometry_path": "geometry/assembly.step",
1898                    "geometry_units": "millimeter",
1899                    "model_profile": profile
1900                }),
1901            )
1902            .unwrap_or_else(|err| panic!("create {profile}: {err}"));
1903
1904            assert_eq!(
1905                created.result.summary.model_profile.as_deref(),
1906                Some(profile.as_str())
1907            );
1908            assert_eq!(
1909                created.result.summary.run_kind.as_deref(),
1910                Some(expected_run_kind.as_str())
1911            );
1912            assert!(
1913                FeaStudyReadinessProfile::from_summary(&created.result.summary).is_some(),
1914                "{profile} should map to a readiness profile"
1915            );
1916        }
1917    }
1918
1919    #[test]
1920    fn create_uses_neutral_study_id_fallback_for_unusable_path_stem() {
1921        let output = apply_fea_study_document_operation(
1922            "create",
1923            "/project/---.fea",
1924            None,
1925            serde_json::json!({
1926                "geometry_path": "part.step",
1927                "model_profile": "linear_static_structural"
1928            }),
1929        )
1930        .expect("create should succeed with path-derived fallback");
1931
1932        assert_eq!(output.result.summary.study_id.as_deref(), Some("study"));
1933        assert!(output.source.contains("id: study"));
1934        assert!(!output.source.contains("fea_study"));
1935    }
1936
1937    #[test]
1938    fn rejects_ids_that_do_not_normalize_to_stable_yaml_keys() {
1939        let invalid_study = apply_fea_study_document_operation(
1940            "create",
1941            "/project/valid.fea",
1942            None,
1943            serde_json::json!({
1944                "study_id": "---",
1945                "geometry_path": "part.step",
1946                "model_profile": "linear_static_structural"
1947            }),
1948        )
1949        .expect_err("invalid explicit study id should fail");
1950        assert!(invalid_study.contains("study_id must include"));
1951
1952        let created = apply_fea_study_document_operation(
1953            "create",
1954            "/project/valid.fea",
1955            None,
1956            serde_json::json!({
1957                "geometry_path": "part.step",
1958                "model_profile": "linear_static_structural"
1959            }),
1960        )
1961        .expect("create");
1962        let invalid_region = apply_fea_study_document_operation(
1963            "add_region",
1964            "/project/valid.fea",
1965            Some(&created.source),
1966            serde_json::json!({"region_id": "---", "selector": "region:face_1"}),
1967        )
1968        .expect_err("invalid region id should fail");
1969        assert!(invalid_region.contains("region_id or regionId must include"));
1970    }
1971
1972    #[test]
1973    fn applies_typed_document_operations() {
1974        let created = apply_fea_study_document_operation(
1975            "create",
1976            "/project/bracket.fea",
1977            None,
1978            serde_json::json!({
1979                "geometry_path": "bracket.step",
1980                "model_profile": "linear_static_structural"
1981            }),
1982        )
1983        .expect("create");
1984        let with_region = apply_fea_study_document_operation(
1985            "add_region",
1986            "/project/bracket.fea",
1987            Some(&created.source),
1988            serde_json::json!({"region_id": "fixed_face", "selector": "region:face_1"}),
1989        )
1990        .expect("region");
1991        let with_material = apply_fea_study_document_operation(
1992            "add_material",
1993            "/project/bracket.fea",
1994            Some(&with_region.source),
1995            serde_json::json!({
1996                "material_id": "aluminum",
1997                "name": "Aluminum",
1998                "youngs_modulus_pa": 69e9,
1999                "poisson_ratio": 0.33,
2000                "density_kg_per_m3": 2700.0
2001            }),
2002        )
2003        .expect("material");
2004        let with_assignment = apply_fea_study_document_operation(
2005            "assign_material",
2006            "/project/bracket.fea",
2007            Some(&with_material.source),
2008            serde_json::json!({"region_id": "fixed_face", "material_id": "aluminum"}),
2009        )
2010        .expect("assignment");
2011        let with_driving_condition = apply_fea_study_document_operation(
2012            "add_driving_condition",
2013            "/project/bracket.fea",
2014            Some(&with_assignment.source),
2015            serde_json::json!({
2016                "driving_condition_id": "tip_force",
2017                "region_id": "fixed_face",
2018                "type": "force",
2019                "vector": [0.0, 0.0, -100.0]
2020            }),
2021        )
2022        .expect("driving condition");
2023        let changed = apply_fea_study_document_operation(
2024            "update_driving_condition",
2025            "/project/bracket.fea",
2026            Some(&with_driving_condition.source),
2027            serde_json::json!({
2028                "driving_condition_id": "tip_force",
2029                "region_id": "fixed_face",
2030                "type": "force",
2031                "vector": [0.0, 0.0, -250.0]
2032            }),
2033        )
2034        .expect("update driving condition");
2035
2036        assert_eq!(changed.result.counts.regions, 1);
2037        assert_eq!(changed.result.counts.materials, 1);
2038        assert_eq!(changed.result.counts.material_assignments, 1);
2039        assert_eq!(changed.result.counts.driving_conditions, 1);
2040        assert_eq!(
2041            changed.result.driving_conditions[0].id.as_deref(),
2042            Some("tip_force")
2043        );
2044        assert_eq!(
2045            changed.result.driving_conditions[0].value.as_deref(),
2046            Some("[0.0, 0.0, -250.0]")
2047        );
2048        assert!(!changed.result.readiness.ready_to_solve);
2049        assert_eq!(
2050            changed.result.readiness.blockers[0],
2051            "Add at least one structural boundary condition."
2052        );
2053        assert_eq!(
2054            changed.result.diff.as_ref().expect("diff").changed_sections[0],
2055            "driving_conditions"
2056        );
2057    }
2058
2059    #[test]
2060    fn driving_condition_operations_preserve_non_structural_source_values() {
2061        let source = r#"
2062id: duct_acoustics
2063geometry:
2064  path: geometry/duct.step
2065model:
2066  profile: acoustic_harmonic
2067run:
2068  backend: cpu
2069regions:
2070  speaker_port:
2071    selector: region:inlet
2072materials:
2073  air:
2074    name: Air
2075material_assignments:
2076  - region: speaker_port
2077    material: air
2078boundary_conditions:
2079  - id: rigid_wall
2080    region: speaker_port
2081    kind: acoustic_impedance
2082"#;
2083        let changed = apply_fea_study_document_operation(
2084            "add_driving_condition",
2085            "/project/duct.fea",
2086            Some(source),
2087            serde_json::json!({
2088                "driving_condition_id": "speaker_pressure",
2089                "region_id": "speaker_port",
2090                "type": "acoustic_pressure",
2091                "pressure_pa": 2.0,
2092                "frequency_hz": 440.0,
2093                "phase_rad": 0.5,
2094                "amplitude_scale": 1.25
2095            }),
2096        )
2097        .expect("add acoustic source");
2098
2099        assert!(changed.source.contains("type: acoustic_pressure"));
2100        assert!(changed.source.contains("pressure_pa: 2.0"));
2101        assert!(changed.source.contains("frequency_hz: 440.0"));
2102        assert!(changed.source.contains("phase_rad: 0.5"));
2103        assert_eq!(
2104            changed.result.driving_conditions[0]
2105                .condition_type
2106                .as_deref(),
2107            Some("acoustic_pressure")
2108        );
2109        assert_eq!(
2110            changed.result.driving_conditions[0].value.as_deref(),
2111            Some("2.0")
2112        );
2113        assert!(changed.result.readiness.ready_to_solve);
2114    }
2115
2116    #[test]
2117    fn material_operations_preserve_family_specific_media_properties() {
2118        let source = r#"
2119id: media_test
2120geometry:
2121  path: geometry/part.step
2122model:
2123  profile: cfd_transient
2124run:
2125  backend: cpu
2126"#;
2127
2128        let cases = [
2129            (
2130                "thermal_medium",
2131                serde_json::json!({
2132                    "material_id": "thermal_medium",
2133                    "name": "Thermal medium",
2134                    "thermal_conductivity_w_per_m_k": 16.2,
2135                    "specific_heat_j_per_kg_k": 500.0,
2136                    "thermal_density_kg_per_m3": 7800.0
2137                }),
2138                "thermal:",
2139                "thermal_conductivity_w_per_m_k: 16.2",
2140                "thermalSummary",
2141            ),
2142            (
2143                "dielectric",
2144                serde_json::json!({
2145                    "material_id": "dielectric",
2146                    "name": "Dielectric",
2147                    "relative_permittivity": 3.2,
2148                    "relative_permeability": 1.0,
2149                    "electrical_conductivity_s_per_m": 0.01
2150                }),
2151                "electromagnetic:",
2152                "relative_permittivity: 3.2",
2153                "electromagneticSummary",
2154            ),
2155            (
2156                "air_acoustic",
2157                serde_json::json!({
2158                    "material_id": "air_acoustic",
2159                    "name": "Air acoustic",
2160                    "acoustic_density_kg_per_m3": 1.225,
2161                    "speed_of_sound_m_per_s": 343.0
2162                }),
2163                "acoustic:",
2164                "speed_of_sound_m_per_s: 343.0",
2165                "acousticSummary",
2166            ),
2167            (
2168                "air_flow",
2169                serde_json::json!({
2170                    "material_id": "air_flow",
2171                    "name": "Air flow",
2172                    "fluid_density_kg_per_m3": 1.225,
2173                    "dynamic_viscosity_pa_s": 0.0000181,
2174                    "reference_pressure_pa": 101325.0
2175                }),
2176                "fluid:",
2177                "dynamic_viscosity_pa_s: 0.0000181",
2178                "fluidSummary",
2179            ),
2180        ];
2181
2182        for (material_id, input, expected_group, expected_value, expected_summary_key) in cases {
2183            let changed = apply_fea_study_document_operation(
2184                "add_material",
2185                "/project/media.fea",
2186                Some(source),
2187                input,
2188            )
2189            .unwrap_or_else(|err| panic!("add material {material_id}: {err}"));
2190            assert!(
2191                changed.source.contains(expected_group),
2192                "{material_id} should write {expected_group}"
2193            );
2194            assert!(
2195                changed.source.contains(expected_value),
2196                "{material_id} should write {expected_value}"
2197            );
2198            let payload = serde_json::to_value(&changed.result.materials[0]).expect("material");
2199            assert!(
2200                payload
2201                    .get(expected_summary_key)
2202                    .and_then(JsonValue::as_str)
2203                    .is_some_and(|summary| !summary.is_empty()),
2204                "{material_id} should expose {expected_summary_key}"
2205            );
2206            assert!(
2207                changed.result.materials[0].youngs_modulus_pa.is_none(),
2208                "{material_id} should not require structural Young's modulus"
2209            );
2210            assert!(
2211                changed.result.materials[0].poisson_ratio.is_none(),
2212                "{material_id} should not require structural Poisson ratio"
2213            );
2214        }
2215    }
2216
2217    #[test]
2218    fn material_operation_rejects_empty_material_definition() {
2219        let err = apply_fea_study_document_operation(
2220            "add_material",
2221            "/project/media.fea",
2222            Some("id: media_test\n"),
2223            serde_json::json!({
2224                "material_id": "empty",
2225                "name": "Empty"
2226            }),
2227        )
2228        .expect_err("empty material definitions should be rejected");
2229        assert!(err.contains("at least one physics material property"));
2230    }
2231
2232    #[test]
2233    fn readiness_uses_profile_specific_requirements_across_supported_physics() {
2234        let cases = [
2235            (
2236                "linear_static_structural",
2237                "Add at least one structural driving condition.",
2238                false,
2239            ),
2240            (
2241                "transient_structural",
2242                "Add at least one structural driving condition.",
2243                false,
2244            ),
2245            (
2246                "nonlinear_structural",
2247                "Add at least one structural driving condition.",
2248                false,
2249            ),
2250            (
2251                "modal_structural",
2252                "Define at least one material for the structural model.",
2253                true,
2254            ),
2255            (
2256                "thermal_standalone",
2257                "Add at least one thermal source or driving condition.",
2258                false,
2259            ),
2260            (
2261                "electromagnetic_static",
2262                "Add at least one electromagnetic source or driving condition.",
2263                false,
2264            ),
2265            (
2266                "acoustic_harmonic",
2267                "Add at least one acoustic source or excitation.",
2268                false,
2269            ),
2270            (
2271                "cfd_steady_state",
2272                "Add at least one flow driver or source.",
2273                false,
2274            ),
2275            (
2276                "cfd_transient",
2277                "Add at least one flow driver or source.",
2278                false,
2279            ),
2280            (
2281                "thermo_mechanical_coupled",
2282                "Add driving conditions or sources for the coupled domains.",
2283                false,
2284            ),
2285            (
2286                "electro_thermal_coupled",
2287                "Add driving conditions or sources for the coupled domains.",
2288                false,
2289            ),
2290            (
2291                "cht_coupled",
2292                "Add driving conditions or sources for the coupled domains.",
2293                false,
2294            ),
2295            (
2296                "fsi_coupled",
2297                "Add driving conditions or sources for the coupled domains.",
2298                false,
2299            ),
2300        ];
2301
2302        for (profile, expected_blocker, modal_without_driver) in cases {
2303            let source = format!(
2304                "version: 1\nkind: study\nid: profile_test\ngeometry:\n  path: part.step\nmodel:\n  profile: {profile}\nrun:\n  backend: cpu\n"
2305            );
2306            let result = summarize_fea_study_document_contract("/project/profile.fea", &source)
2307                .expect("summary");
2308            assert!(
2309                !result.readiness.ready_to_solve,
2310                "{profile} should not be ready with only geometry/profile/backend"
2311            );
2312            assert!(
2313                result.readiness.blockers.contains(&expected_blocker),
2314                "{profile} blockers should contain {expected_blocker:?}, got {:?}",
2315                result.readiness.blockers
2316            );
2317            assert_eq!(
2318                result.readiness.blockers.iter().any(|blocker| blocker
2319                    .contains("driving condition")
2320                    || blocker.contains("source")
2321                    || blocker.contains("driver")
2322                    || blocker.contains("excitation")),
2323                !modal_without_driver,
2324                "{profile} driving/source gate mismatch: {:?}",
2325                result.readiness.blockers
2326            );
2327        }
2328    }
2329
2330    #[test]
2331    fn typed_authoring_creates_edits_and_checks_every_supported_physics_profile() {
2332        let cases = vec![
2333            (
2334                "linear_static_structural",
2335                "linear_static",
2336                serde_json::json!({
2337                    "youngs_modulus_pa": 70e9,
2338                    "poisson_ratio": 0.33,
2339                    "mechanical_density_kg_per_m3": 2700.0
2340                }),
2341                serde_json::json!({"kind": "fixed"}),
2342                Some(serde_json::json!({
2343                    "type": "force",
2344                    "vector": [0.0, 0.0, -125.0]
2345                })),
2346                "structural.displacement",
2347                false,
2348            ),
2349            (
2350                "thermo_mechanical_coupled",
2351                "transient",
2352                serde_json::json!({
2353                    "youngs_modulus_pa": 70e9,
2354                    "poisson_ratio": 0.33,
2355                    "mechanical_density_kg_per_m3": 2700.0,
2356                    "thermal_conductivity_w_per_m_k": 205.0,
2357                    "specific_heat_j_per_kg_k": 900.0,
2358                    "thermal_density_kg_per_m3": 2700.0
2359                }),
2360                serde_json::json!({"kind": "thermal_prescribed_temperature", "temperature_k": 293.15}),
2361                Some(serde_json::json!({
2362                    "type": "heat_source",
2363                    "volumetric_w_per_m3": 12000.0
2364                })),
2365                "thermal.temperature",
2366                false,
2367            ),
2368            (
2369                "electro_thermal_coupled",
2370                "transient",
2371                serde_json::json!({
2372                    "thermal_conductivity_w_per_m_k": 205.0,
2373                    "specific_heat_j_per_kg_k": 900.0,
2374                    "thermal_density_kg_per_m3": 2700.0,
2375                    "relative_permittivity": 1.0,
2376                    "relative_permeability": 1.0,
2377                    "electrical_conductivity_s_per_m": 3.5e7
2378                }),
2379                serde_json::json!({"kind": "electric_potential"}),
2380                Some(serde_json::json!({
2381                    "type": "current_source",
2382                    "current_a": 4.0
2383                })),
2384                "electro_thermal.temperature",
2385                true,
2386            ),
2387            (
2388                "thermal_standalone",
2389                "thermal",
2390                serde_json::json!({
2391                    "thermal_conductivity_w_per_m_k": 15.0,
2392                    "specific_heat_j_per_kg_k": 460.0,
2393                    "thermal_density_kg_per_m3": 7800.0
2394                }),
2395                serde_json::json!({"kind": "thermal_prescribed_temperature", "temperature_k": 293.15}),
2396                Some(serde_json::json!({
2397                    "type": "heat_source",
2398                    "volumetric_w_per_m3": 10000.0
2399                })),
2400                "thermal.temperature",
2401                true,
2402            ),
2403            (
2404                "modal_structural",
2405                "modal",
2406                serde_json::json!({
2407                    "youngs_modulus_pa": 200e9,
2408                    "poisson_ratio": 0.29,
2409                    "mechanical_density_kg_per_m3": 7850.0
2410                }),
2411                serde_json::json!({"kind": "fixed"}),
2412                None,
2413                "structural.mode_shapes",
2414                false,
2415            ),
2416            (
2417                "acoustic_harmonic",
2418                "acoustic",
2419                serde_json::json!({
2420                    "acoustic_density_kg_per_m3": 1.225,
2421                    "speed_of_sound_m_per_s": 343.0
2422                }),
2423                serde_json::json!({"kind": "acoustic_impedance", "pressure_pa": 0.0}),
2424                Some(serde_json::json!({
2425                    "type": "acoustic_pressure",
2426                    "pressure_pa": 2.0,
2427                    "frequency_hz": 440.0
2428                })),
2429                "acoustic.pressure",
2430                true,
2431            ),
2432            (
2433                "transient_structural",
2434                "transient",
2435                serde_json::json!({
2436                    "youngs_modulus_pa": 70e9,
2437                    "poisson_ratio": 0.33,
2438                    "mechanical_density_kg_per_m3": 2700.0
2439                }),
2440                serde_json::json!({"kind": "fixed"}),
2441                Some(serde_json::json!({
2442                    "type": "force",
2443                    "vector": [0.0, 0.0, -125.0]
2444                })),
2445                "structural.displacement",
2446                false,
2447            ),
2448            (
2449                "nonlinear_structural",
2450                "nonlinear",
2451                serde_json::json!({
2452                    "youngs_modulus_pa": 70e9,
2453                    "poisson_ratio": 0.33,
2454                    "mechanical_density_kg_per_m3": 2700.0
2455                }),
2456                serde_json::json!({"kind": "fixed"}),
2457                Some(serde_json::json!({
2458                    "type": "force",
2459                    "vector": [0.0, 0.0, -125.0]
2460                })),
2461                "structural.displacement",
2462                false,
2463            ),
2464            (
2465                "electromagnetic_static",
2466                "electromagnetic",
2467                serde_json::json!({
2468                    "relative_permittivity": 3.0,
2469                    "relative_permeability": 1.0,
2470                    "electrical_conductivity_s_per_m": 5.8e7
2471                }),
2472                serde_json::json!({"kind": "electric_potential"}),
2473                Some(serde_json::json!({
2474                    "type": "current_source",
2475                    "current_a": 2.5
2476                })),
2477                "em.electric_field",
2478                true,
2479            ),
2480            (
2481                "cfd_steady_state",
2482                "cfd",
2483                serde_json::json!({
2484                    "fluid_density_kg_per_m3": 1.225,
2485                    "dynamic_viscosity_pa_s": 0.0000181,
2486                    "reference_pressure_pa": 101325.0
2487                }),
2488                serde_json::json!({"kind": "wall"}),
2489                Some(serde_json::json!({
2490                    "type": "flow_velocity",
2491                    "velocity_m_per_s": 12.0
2492                })),
2493                "fluid.velocity",
2494                true,
2495            ),
2496            (
2497                "cfd_transient",
2498                "cfd",
2499                serde_json::json!({
2500                    "fluid_density_kg_per_m3": 1.225,
2501                    "dynamic_viscosity_pa_s": 0.0000181,
2502                    "reference_pressure_pa": 101325.0
2503                }),
2504                serde_json::json!({"kind": "wall"}),
2505                Some(serde_json::json!({
2506                    "type": "flow_velocity",
2507                    "velocity_m_per_s": 12.0
2508                })),
2509                "fluid.velocity",
2510                true,
2511            ),
2512            (
2513                "cht_coupled",
2514                "cht",
2515                serde_json::json!({
2516                    "fluid_density_kg_per_m3": 1.225,
2517                    "dynamic_viscosity_pa_s": 0.0000181,
2518                    "reference_pressure_pa": 101325.0,
2519                    "thermal_conductivity_w_per_m_k": 205.0,
2520                    "specific_heat_j_per_kg_k": 900.0,
2521                    "thermal_density_kg_per_m3": 2700.0
2522                }),
2523                serde_json::json!({"kind": "thermal_convection", "temperature_k": 293.15}),
2524                Some(serde_json::json!({
2525                    "type": "heat_source",
2526                    "volumetric_w_per_m3": 8000.0
2527                })),
2528                "thermal.temperature",
2529                true,
2530            ),
2531            (
2532                "fsi_coupled",
2533                "fsi",
2534                serde_json::json!({
2535                    "youngs_modulus_pa": 70e9,
2536                    "poisson_ratio": 0.33,
2537                    "mechanical_density_kg_per_m3": 2700.0,
2538                    "fluid_density_kg_per_m3": 1.225,
2539                    "dynamic_viscosity_pa_s": 0.0000181,
2540                    "reference_pressure_pa": 101325.0
2541                }),
2542                serde_json::json!({"kind": "wall"}),
2543                Some(serde_json::json!({
2544                    "type": "flow_velocity",
2545                    "velocity_m_per_s": 12.0
2546                })),
2547                "fluid.pressure",
2548                false,
2549            ),
2550        ];
2551
2552        let covered_profiles = cases
2553            .iter()
2554            .map(|(profile, ..)| *profile)
2555            .collect::<std::collections::BTreeSet<_>>();
2556        let catalog_profiles = ANALYSIS_PHYSICS_PROFILE_CATALOG
2557            .iter()
2558            .map(|entry| entry.profile.as_snake_case())
2559            .collect::<std::collections::BTreeSet<_>>();
2560        assert_eq!(
2561            covered_profiles, catalog_profiles,
2562            "typed authoring readiness coverage must track the full supported physics profile catalog"
2563        );
2564
2565        for (
2566            profile,
2567            run_kind,
2568            material_input,
2569            constraint_input,
2570            driver_input,
2571            output_field,
2572            expect_no_structural_material,
2573        ) in cases
2574        {
2575            let path = format!("/project/{profile}.fea");
2576            let created = apply_fea_study_document_operation(
2577                "create",
2578                &path,
2579                None,
2580                serde_json::json!({
2581                    "geometry_path": "geometry/assembly.step",
2582                    "geometry_units": "millimeter",
2583                    "model_profile": profile
2584                }),
2585            )
2586            .unwrap_or_else(|err| panic!("create {profile}: {err}"));
2587            assert_eq!(
2588                created.result.summary.model_profile.as_deref(),
2589                Some(profile)
2590            );
2591            assert_eq!(created.result.summary.run_kind.as_deref(), Some(run_kind));
2592            assert!(
2593                created.source.contains(&format!("profile: {profile}")),
2594                "{profile} source should retain model profile"
2595            );
2596            assert!(
2597                created.source.contains(&format!("kind: {run_kind}")),
2598                "{profile} source should persist derived run kind"
2599            );
2600
2601            let with_region = apply_fea_study_document_operation(
2602                "add_region",
2603                &path,
2604                Some(&created.source),
2605                serde_json::json!({
2606                    "region_id": "domain",
2607                    "selector": "region:body_1"
2608                }),
2609            )
2610            .unwrap_or_else(|err| panic!("add region {profile}: {err}"));
2611
2612            let mut material_input = material_input;
2613            material_input["material_id"] = JsonValue::String("medium".to_string());
2614            material_input["name"] = JsonValue::String("Study medium".to_string());
2615            let with_material = apply_fea_study_document_operation(
2616                "add_material",
2617                &path,
2618                Some(&with_region.source),
2619                material_input,
2620            )
2621            .unwrap_or_else(|err| panic!("add material {profile}: {err}"));
2622
2623            let with_assignment = apply_fea_study_document_operation(
2624                "assign_material",
2625                &path,
2626                Some(&with_material.source),
2627                serde_json::json!({
2628                    "region_id": "domain",
2629                    "material_id": "medium"
2630                }),
2631            )
2632            .unwrap_or_else(|err| panic!("assign material {profile}: {err}"));
2633
2634            let mut constraint_input = constraint_input;
2635            constraint_input["constraint_id"] = JsonValue::String("boundary".to_string());
2636            constraint_input["region_id"] = JsonValue::String("domain".to_string());
2637            let with_constraint = apply_fea_study_document_operation(
2638                "add_constraint",
2639                &path,
2640                Some(&with_assignment.source),
2641                constraint_input,
2642            )
2643            .unwrap_or_else(|err| panic!("add constraint {profile}: {err}"));
2644
2645            let with_driver = if let Some(mut driver_input) = driver_input {
2646                driver_input["driving_condition_id"] = JsonValue::String("driver".to_string());
2647                driver_input["region_id"] = JsonValue::String("domain".to_string());
2648                apply_fea_study_document_operation(
2649                    "add_driving_condition",
2650                    &path,
2651                    Some(&with_constraint.source),
2652                    driver_input,
2653                )
2654                .unwrap_or_else(|err| panic!("add driver {profile}: {err}"))
2655            } else {
2656                with_constraint
2657            };
2658
2659            let changed = apply_fea_study_document_operation(
2660                "set_outputs",
2661                &path,
2662                Some(&with_driver.source),
2663                serde_json::json!({
2664                    "output_id": "primary",
2665                    "field_id": output_field,
2666                    "location": "domain"
2667                }),
2668            )
2669            .unwrap_or_else(|err| panic!("set outputs {profile}: {err}"));
2670
2671            assert!(
2672                changed.result.readiness.ready_to_solve,
2673                "{profile} should be ready after family-appropriate authoring: {:?}",
2674                changed.result.readiness.blockers
2675            );
2676            assert_eq!(changed.result.counts.regions, 1);
2677            assert_eq!(changed.result.counts.materials, 1);
2678            assert_eq!(changed.result.counts.material_assignments, 1);
2679            assert_eq!(changed.result.counts.boundary_conditions, 1);
2680            assert_eq!(
2681                changed.result.outputs[0].field.as_deref(),
2682                Some(output_field)
2683            );
2684            if expect_no_structural_material {
2685                assert!(
2686                    changed.result.materials[0].youngs_modulus_pa.is_none(),
2687                    "{profile} should not need structural Young's modulus"
2688                );
2689                assert!(
2690                    changed.result.materials[0].poisson_ratio.is_none(),
2691                    "{profile} should not need structural Poisson ratio"
2692                );
2693            }
2694        }
2695    }
2696
2697    #[test]
2698    fn create_rejects_unknown_physics_profile() {
2699        let err = apply_fea_study_document_operation(
2700            "create",
2701            "/project/unknown.fea",
2702            None,
2703            serde_json::json!({
2704                "geometry_path": "part.step",
2705                "model_profile": "linear_stress_only"
2706            }),
2707        )
2708        .expect_err("create should reject unknown profiles");
2709
2710        assert!(err.contains("unsupported physics model profile: linear_stress_only"));
2711    }
2712
2713    #[test]
2714    fn updates_removes_and_sets_mesh_without_text_patching() {
2715        let created = apply_fea_study_document_operation(
2716            "create",
2717            "/project/bracket.fea",
2718            None,
2719            serde_json::json!({
2720                "geometry_path": "bracket.step",
2721                "model_profile": "linear_static_structural"
2722            }),
2723        )
2724        .expect("create");
2725        let with_region = apply_fea_study_document_operation(
2726            "add_region",
2727            "/project/bracket.fea",
2728            Some(&created.source),
2729            serde_json::json!({"region_id": "load_face", "selector": "region:face_1"}),
2730        )
2731        .expect("region");
2732        let duplicate_region = apply_fea_study_document_operation(
2733            "add_region",
2734            "/project/bracket.fea",
2735            Some(&with_region.source),
2736            serde_json::json!({"region_id": "load_face", "selector": "region:face_2"}),
2737        )
2738        .expect_err("add should not replace an existing region");
2739        assert!(duplicate_region.contains("regions entry already exists: load_face"));
2740
2741        let updated_region = apply_fea_study_document_operation(
2742            "update_region",
2743            "/project/bracket.fea",
2744            Some(&with_region.source),
2745            serde_json::json!({"region_id": "load_face", "selector": "region:face_2"}),
2746        )
2747        .expect("update region");
2748        assert_eq!(
2749            updated_region.result.regions[0].selector.as_deref(),
2750            Some("region:face_2")
2751        );
2752
2753        let missing_driving_condition_update = apply_fea_study_document_operation(
2754            "update_driving_condition",
2755            "/project/bracket.fea",
2756            Some(&updated_region.source),
2757            serde_json::json!({
2758                "driving_condition_id": "missing_force",
2759                "region_id": "load_face",
2760                "type": "force",
2761                "vector": [0.0, 0.0, -250.0]
2762            }),
2763        )
2764        .expect_err("update should require an existing driving condition");
2765        assert!(
2766            missing_driving_condition_update.contains("driving_conditions block does not exist")
2767                || missing_driving_condition_update
2768                    .contains("driving_conditions entry does not exist")
2769        );
2770        assert!(!missing_driving_condition_update.contains("loads"));
2771
2772        let with_material = apply_fea_study_document_operation(
2773            "add_material",
2774            "/project/bracket.fea",
2775            Some(&updated_region.source),
2776            serde_json::json!({
2777                "material_id": "steel",
2778                "youngs_modulus_pa": 200e9,
2779                "poisson_ratio": 0.29
2780            }),
2781        )
2782        .expect("material");
2783        let updated_material = apply_fea_study_document_operation(
2784            "update_material",
2785            "/project/bracket.fea",
2786            Some(&with_material.source),
2787            serde_json::json!({
2788                "material_id": "steel",
2789                "name": "Structural steel",
2790                "youngs_modulus_pa": 210e9,
2791                "poisson_ratio": 0.30
2792            }),
2793        )
2794        .expect("update material");
2795        assert_eq!(
2796            updated_material.result.materials[0].name.as_deref(),
2797            Some("Structural steel")
2798        );
2799
2800        let with_constraint = apply_fea_study_document_operation(
2801            "add_constraint",
2802            "/project/bracket.fea",
2803            Some(&updated_material.source),
2804            serde_json::json!({
2805                "constraint_id": "fixed_mount",
2806                "region_id": "load_face",
2807                "kind": "fixed"
2808            }),
2809        )
2810        .expect("constraint");
2811        let duplicate_constraint = apply_fea_study_document_operation(
2812            "add_constraint",
2813            "/project/bracket.fea",
2814            Some(&with_constraint.source),
2815            serde_json::json!({
2816                "constraint_id": "fixed_mount",
2817                "region_id": "load_face",
2818                "kind": "fixed"
2819            }),
2820        )
2821        .expect_err("add should not duplicate constraints");
2822        assert!(duplicate_constraint.contains("boundary_conditions entry already exists"));
2823
2824        let updated_constraint = apply_fea_study_document_operation(
2825            "update_constraint",
2826            "/project/bracket.fea",
2827            Some(&with_constraint.source),
2828            serde_json::json!({
2829                "constraint_id": "fixed_mount",
2830                "region_id": "load_face",
2831                "kind": "prescribed_displacement",
2832                "rz": 0.0
2833            }),
2834        )
2835        .expect("update constraint");
2836        assert_eq!(
2837            updated_constraint.result.boundary_conditions[0]
2838                .kind
2839                .as_deref(),
2840            Some("prescribed_displacement")
2841        );
2842
2843        let with_driving_condition = apply_fea_study_document_operation(
2844            "add_driving_condition",
2845            "/project/bracket.fea",
2846            Some(&updated_constraint.source),
2847            serde_json::json!({
2848                "driving_condition_id": "tip_force",
2849                "region_id": "load_face",
2850                "type": "force",
2851                "vector": [0.0, -10.0, 0.0]
2852            }),
2853        )
2854        .expect("add driving condition");
2855        let without_driving_condition = apply_fea_study_document_operation(
2856            "remove_driving_condition",
2857            "/project/bracket.fea",
2858            Some(&with_driving_condition.source),
2859            serde_json::json!({"driving_condition_id": "tip_force"}),
2860        )
2861        .expect("remove driving condition");
2862        assert_eq!(
2863            without_driving_condition.result.counts.driving_conditions,
2864            0
2865        );
2866
2867        let with_mesh = apply_fea_study_document_operation(
2868            "set_mesh",
2869            "/project/bracket.fea",
2870            Some(&without_driving_condition.source),
2871            serde_json::json!({
2872                "backend": "auto",
2873                "kind": "solid",
2874                "element": "tetrahedron4",
2875                "profile": "balanced",
2876                "max_elements": 50000,
2877                "target_size": 0.002,
2878                "refinement_strategy": "adaptive",
2879                "refinement_max_iterations": 2,
2880                "field_change_tolerance": 0.05,
2881                "validation_coverage": "strict"
2882            }),
2883        )
2884        .expect("set mesh");
2885        assert!(with_mesh.source.contains("mesh:"));
2886        assert!(with_mesh.source.contains("max_elements: 50000"));
2887        assert_eq!(
2888            with_mesh
2889                .result
2890                .diff
2891                .as_ref()
2892                .expect("diff")
2893                .changed_sections[0],
2894            "mesh"
2895        );
2896
2897        let with_outputs = apply_fea_study_document_operation(
2898            "set_outputs",
2899            "/project/bracket.fea",
2900            Some(&with_mesh.source),
2901            serde_json::json!({
2902                "outputs": [
2903                    {
2904                        "id": "displacement_view",
2905                        "field": "structural.displacement",
2906                        "location": "nodes",
2907                        "kind": "vector"
2908                    },
2909                    {
2910                        "id": "stress_view",
2911                        "field_id": "structural.von_mises",
2912                        "location": "elements",
2913                        "kind": "scalar"
2914                    }
2915                ]
2916            }),
2917        )
2918        .expect("set outputs");
2919        assert_eq!(with_outputs.result.counts.outputs, 2);
2920        assert_eq!(
2921            with_outputs.result.outputs[0].field.as_deref(),
2922            Some("structural.displacement")
2923        );
2924        assert_eq!(
2925            with_outputs
2926                .result
2927                .diff
2928                .as_ref()
2929                .expect("diff")
2930                .changed_sections[0],
2931            "outputs"
2932        );
2933
2934        let without_constraint = apply_fea_study_document_operation(
2935            "remove_constraint",
2936            "/project/bracket.fea",
2937            Some(&with_outputs.source),
2938            serde_json::json!({"constraint_id": "fixed_mount"}),
2939        )
2940        .expect("remove constraint");
2941        assert_eq!(without_constraint.result.counts.boundary_conditions, 0);
2942
2943        let without_region = apply_fea_study_document_operation(
2944            "remove_region",
2945            "/project/bracket.fea",
2946            Some(&without_constraint.source),
2947            serde_json::json!({"region_id": "load_face"}),
2948        )
2949        .expect("remove region");
2950        assert_eq!(without_region.result.counts.regions, 0);
2951    }
2952}