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 ®ion_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 ®ion_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", ®ion_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}