Skip to main content

runmat_runtime/analysis/
study_authoring.rs

1use std::collections::BTreeMap;
2
3use runmat_analysis_core::{EvidenceConfidence, LoadKind, MaterialAssignment};
4use runmat_meshing_core::VolumeMeshingOptions;
5
6use crate::operations::{
7    operation_error, OperationContext, OperationEnvelope, OperationErrorEnvelope,
8    OperationErrorSeverity, OperationErrorSpec, OperationErrorType,
9};
10
11use super::{
12    analysis_create_model_op, persist_study_evidence, profile_supports_run_kind, study_fingerprint,
13    validate_study_issue_codes, AnalysisCreateModelIntentSpec, AnalysisStudyAuthoringData,
14    AnalysisStudyAuthoringEvidence, AnalysisStudyAuthoringIntent, AnalysisStudyDiagramObservation,
15    AnalysisStudySpec, ANALYSIS_AUTHOR_STUDY_OPERATION, ANALYSIS_AUTHOR_STUDY_OP_VERSION,
16};
17
18pub fn analysis_author_study_op(
19    intent: AnalysisStudyAuthoringIntent,
20    context: OperationContext,
21) -> Result<OperationEnvelope<AnalysisStudyAuthoringData>, OperationErrorEnvelope> {
22    if intent.study_id.trim().is_empty() {
23        return Err(author_study_error(
24            &context,
25            "RM.FEA.AUTHOR_STUDY.ID_EMPTY",
26            "study_id must be non-empty",
27            BTreeMap::new(),
28        ));
29    }
30    if !intent.mesh_authoring_summary.solve_ready {
31        return Err(author_study_error(
32            &context,
33            "RM.FEA.AUTHOR_STUDY.MESH_NOT_SOLVE_READY",
34            "mesh authoring summary is not solve-ready",
35            BTreeMap::from([
36                (
37                    "mesh_id".to_string(),
38                    intent.mesh_authoring_summary.mesh_id.clone(),
39                ),
40                (
41                    "validation_error_code".to_string(),
42                    intent
43                        .mesh_authoring_summary
44                        .validation_error_code
45                        .clone()
46                        .unwrap_or_default(),
47                ),
48            ]),
49        ));
50    }
51    if !profile_supports_run_kind(intent.profile, intent.run_kind) {
52        return Err(author_study_error(
53            &context,
54            "RM.FEA.AUTHOR_STUDY.RUN_KIND_PROFILE_MISMATCH",
55            "profile does not support requested run kind",
56            BTreeMap::from([
57                ("profile".to_string(), format!("{:?}", intent.profile)),
58                ("run_kind".to_string(), format!("{:?}", intent.run_kind)),
59            ]),
60        ));
61    }
62
63    let (material_region_id, material_region_source) = select_authoring_material_region(
64        &intent.mesh_authoring_summary,
65        intent.material_region_id.as_deref(),
66        intent.diagram_observation.as_ref(),
67    )
68    .map_err(|message| {
69        author_study_error(
70            &context,
71            "RM.FEA.AUTHOR_STUDY.MATERIAL_REGION_UNAVAILABLE",
72            message,
73            BTreeMap::from([(
74                "mesh_id".to_string(),
75                intent.mesh_authoring_summary.mesh_id.clone(),
76            )]),
77        )
78    })?;
79    let model_id = intent
80        .model_id
81        .clone()
82        .filter(|model_id| !model_id.trim().is_empty())
83        .unwrap_or_else(|| format!("{}_model", intent.study_id.trim()));
84    let create_model_intent = AnalysisCreateModelIntentSpec {
85        model_id: model_id.clone(),
86        profile: intent.profile,
87        prep_context: None,
88    };
89    let mut model = analysis_create_model_op(
90        &intent.geometry,
91        create_model_intent.clone(),
92        context.clone(),
93    )?
94    .data;
95    let material_id = model
96        .materials
97        .first()
98        .map(|material| material.material_id.clone())
99        .unwrap_or_else(|| "mat_default_steel".to_string());
100    model.material_assignments = vec![MaterialAssignment {
101        region_id: material_region_id.clone(),
102        expected_material_id: material_id.clone(),
103        assigned_material_id: material_id,
104        confidence: EvidenceConfidence::Inferred,
105    }];
106    let (boundary_condition_region_id, boundary_condition_region_source) =
107        if model.boundary_conditions.is_empty() {
108            (None, None)
109        } else {
110            let (region_id, source) = select_authoring_boundary_region(
111                &intent.mesh_authoring_summary,
112                intent.boundary_condition_region_id.as_deref(),
113                intent
114                    .diagram_observation
115                    .as_ref()
116                    .and_then(|observation| observation.boundary_condition_region_id.as_deref()),
117                None,
118            )
119            .map_err(|message| {
120                author_study_error(
121                    &context,
122                    "RM.FEA.AUTHOR_STUDY.BOUNDARY_CONDITION_REGION_UNAVAILABLE",
123                    message,
124                    BTreeMap::from([(
125                        "mesh_id".to_string(),
126                        intent.mesh_authoring_summary.mesh_id.clone(),
127                    )]),
128                )
129            })?;
130            (Some(region_id), Some(source))
131        };
132    let (driving_condition_region_id, driving_condition_region_source) = if model.loads.is_empty() {
133        (None, None)
134    } else {
135        let (region_id, source) = select_authoring_boundary_region(
136            &intent.mesh_authoring_summary,
137            intent.driving_condition_region_id.as_deref(),
138            intent
139                .diagram_observation
140                .as_ref()
141                .and_then(|observation| observation.driving_condition_region_id.as_deref()),
142            boundary_condition_region_id.as_deref(),
143        )
144        .map_err(|message| {
145            author_study_error(
146                &context,
147                "RM.FEA.AUTHOR_STUDY.DRIVING_CONDITION_REGION_UNAVAILABLE",
148                message,
149                BTreeMap::from([(
150                    "mesh_id".to_string(),
151                    intent.mesh_authoring_summary.mesh_id.clone(),
152                )]),
153            )
154        })?;
155        (Some(region_id), Some(source))
156    };
157    if let Some(boundary) = model.boundary_conditions.first_mut() {
158        if let Some(region_id) = &boundary_condition_region_id {
159            boundary.region_id = region_id.clone();
160        }
161    }
162    let mut selected_structural_force_n = None;
163    if let Some(load) = model.loads.first_mut() {
164        if let Some(region_id) = &driving_condition_region_id {
165            load.region_id = region_id.clone();
166        }
167        if let LoadKind::Force { fx, fy, fz } = &mut load.kind {
168            let force_n = intent
169                .structural_force_n
170                .or_else(|| {
171                    intent
172                        .diagram_observation
173                        .as_ref()
174                        .and_then(|observation| observation.structural_force_n)
175                })
176                .unwrap_or([0.0, -1000.0, 0.0]);
177            *fx = force_n[0];
178            *fy = force_n[1];
179            *fz = force_n[2];
180            selected_structural_force_n = Some(force_n);
181        }
182    }
183    let selected_driving_condition_kind = model
184        .loads
185        .first()
186        .map(|load| load_kind_label(&load.kind).to_string());
187
188    let study = AnalysisStudySpec {
189        study_id: intent.study_id,
190        geometry: intent.geometry,
191        create_model_intent,
192        model: Some(model),
193        run_kind: intent.run_kind,
194        backend: intent.backend,
195        mesh_options: Some(VolumeMeshingOptions::default()),
196        outputs: Vec::new(),
197        analysis_mesh_artifact_path: intent.analysis_mesh_artifact_path.clone(),
198        analysis_mesh_evidence_artifact_path: intent.analysis_mesh_evidence_artifact_path.clone(),
199        linear_static_run_options: None,
200        modal_run_options: None,
201        acoustic_run_options: None,
202        thermal_run_options: None,
203        transient_run_options: None,
204        cfd_run_options: None,
205        cht_run_options: None,
206        fsi_run_options: None,
207        nonlinear_run_options: None,
208        electromagnetic_run_options: None,
209    };
210    let issue_codes = validate_study_issue_codes(&study);
211    if !issue_codes.is_empty() {
212        return Err(author_study_error(
213            &context,
214            "RM.FEA.AUTHOR_STUDY.INVALID_AUTHORED_STUDY",
215            "authored study failed validation",
216            BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
217        ));
218    }
219
220    let evidence = AnalysisStudyAuthoringEvidence {
221        schema_version: "fea_study_authoring_evidence/v1".to_string(),
222        mesh_id: intent.mesh_authoring_summary.mesh_id.clone(),
223        mesh_authoring_summary_schema_version: intent.mesh_authoring_summary.schema_version.clone(),
224        tetrahedron_generation_family: intent.mesh_authoring_summary.tetrahedron_generation_family,
225        tetrahedron_generation_attempted_family_count: intent
226            .mesh_authoring_summary
227            .tetrahedron_generation_attempted_family_count,
228        tetrahedron_generation_rejected_family_count: intent
229            .mesh_authoring_summary
230            .tetrahedron_generation_rejected_family_count,
231        tetrahedron_generation_selected_family_index: intent
232            .mesh_authoring_summary
233            .tetrahedron_generation_selected_family_index,
234        tetrahedron_generation_interior_support_candidate_count: intent
235            .mesh_authoring_summary
236            .tetrahedron_generation_interior_support_candidate_count,
237        tetrahedron_generation_interior_support_accepted_count: intent
238            .mesh_authoring_summary
239            .tetrahedron_generation_interior_support_accepted_count,
240        nested_tetrahedron_shell: intent.mesh_authoring_summary.nested_tetrahedron_shell,
241        selected_material_region_id: material_region_id,
242        selected_boundary_condition_region_id: boundary_condition_region_id,
243        selected_driving_condition_region_id: driving_condition_region_id,
244        selected_driving_condition_kind,
245        selected_structural_force_n,
246        diagram_artifact_path: intent
247            .diagram_observation
248            .as_ref()
249            .and_then(|observation| observation.artifact_path.clone()),
250        diagram_source_mime_type: intent
251            .diagram_observation
252            .as_ref()
253            .and_then(|observation| observation.source_mime_type.clone()),
254        diagram_summary: intent
255            .diagram_observation
256            .as_ref()
257            .and_then(|observation| observation.summary.clone()),
258        diagram_confidence: intent
259            .diagram_observation
260            .as_ref()
261            .and_then(|observation| observation.confidence),
262        analysis_mesh_artifact_path: intent.analysis_mesh_artifact_path,
263        analysis_mesh_evidence_artifact_path: intent.analysis_mesh_evidence_artifact_path,
264        material_region_source,
265        boundary_condition_region_source,
266        driving_condition_region_source,
267    };
268    let study_fingerprint = study_fingerprint(&study);
269    let evidence_artifact_path = persist_study_evidence(
270        &study_fingerprint,
271        "author",
272        serde_json::json!({
273            "schema_version": "fea_study_authoring_artifact/v1",
274            "study_id": study.study_id.clone(),
275            "study_fingerprint": study_fingerprint,
276            "evidence": evidence,
277            "study": study,
278        }),
279    )
280    .map_err(|err| {
281        author_study_error(
282            &context,
283            "RM.FEA.AUTHOR_STUDY.ARTIFACT_STORE_FAILED",
284            format!("failed to persist study authoring evidence artifact: {err}"),
285            BTreeMap::from([("study_id".to_string(), study.study_id.clone())]),
286        )
287    })?;
288
289    Ok(OperationEnvelope::new(
290        ANALYSIS_AUTHOR_STUDY_OPERATION,
291        ANALYSIS_AUTHOR_STUDY_OP_VERSION,
292        &context,
293        AnalysisStudyAuthoringData {
294            study,
295            evidence,
296            evidence_artifact_path,
297        },
298    ))
299}
300
301fn load_kind_label(load: &LoadKind) -> &'static str {
302    match load {
303        LoadKind::Force { .. } => "force",
304        LoadKind::Moment { .. } => "moment",
305        LoadKind::Wrench { .. } => "wrench",
306        LoadKind::Pressure { .. } => "pressure",
307        LoadKind::BodyForce { .. } => "body_force",
308        LoadKind::CurrentDensity { .. } => "current_density",
309        LoadKind::CoilCurrent { .. } => "coil_current",
310        LoadKind::HeatSource { .. } => "heat_source",
311    }
312}
313
314fn author_study_error(
315    context: &OperationContext,
316    error_code: &'static str,
317    message: impl Into<String>,
318    context_values: BTreeMap<String, String>,
319) -> OperationErrorEnvelope {
320    operation_error(
321        ANALYSIS_AUTHOR_STUDY_OPERATION,
322        ANALYSIS_AUTHOR_STUDY_OP_VERSION,
323        context,
324        OperationErrorSpec {
325            error_code,
326            error_type: OperationErrorType::Validation,
327            retryable: false,
328            severity: OperationErrorSeverity::Error,
329        },
330        message,
331        context_values,
332    )
333}
334
335fn select_authoring_material_region(
336    summary: &runmat_meshing_evidence::MeshAuthoringSummary,
337    requested: Option<&str>,
338    diagram_observation: Option<&AnalysisStudyDiagramObservation>,
339) -> Result<(String, String), String> {
340    if let Some(requested) = requested {
341        let Some(region) = summary
342            .regions
343            .material_regions
344            .iter()
345            .find(|region| region.region_id == requested && region.element_count > 0)
346        else {
347            return Err(format!(
348                "requested material region `{requested}` is not available in mesh authoring evidence"
349            ));
350        };
351        return Ok((region.region_id.clone(), "requested".to_string()));
352    }
353
354    if let Some(region_id) =
355        diagram_observation.and_then(|observation| observation.material_region_id.as_deref())
356    {
357        let Some(region) = summary
358            .regions
359            .material_regions
360            .iter()
361            .find(|region| region.region_id == region_id && region.element_count > 0)
362        else {
363            return Err(format!(
364                "diagram-selected material region `{region_id}` is not available in mesh authoring evidence"
365            ));
366        };
367        return Ok((region.region_id.clone(), "diagram".to_string()));
368    }
369
370    for required in &summary.regions.required_material_region_ids {
371        if summary
372            .regions
373            .material_regions
374            .iter()
375            .any(|region| region.region_id == *required && region.element_count > 0)
376        {
377            return Ok((required.clone(), "required".to_string()));
378        }
379    }
380    summary
381        .regions
382        .material_regions
383        .iter()
384        .find(|region| region.element_count > 0)
385        .map(|region| (region.region_id.clone(), "available".to_string()))
386        .ok_or_else(|| "mesh authoring evidence has no material regions".to_string())
387}
388
389fn select_authoring_boundary_region(
390    summary: &runmat_meshing_evidence::MeshAuthoringSummary,
391    requested: Option<&str>,
392    diagram_region_id: Option<&str>,
393    avoid_region_id: Option<&str>,
394) -> Result<(String, String), String> {
395    if let Some(requested) = requested {
396        let Some(region) = summary.regions.boundary_regions.iter().find(|region| {
397            region.region_id == requested && region.face_count > 0 && region.fully_recovered
398        }) else {
399            return Err(format!(
400                "requested boundary region `{requested}` is not available and fully recovered in mesh authoring evidence"
401            ));
402        };
403        return Ok((region.region_id.clone(), "requested".to_string()));
404    }
405
406    if let Some(region_id) = diagram_region_id {
407        if avoid_region_id != Some(region_id) {
408            let Some(region) = summary.regions.boundary_regions.iter().find(|region| {
409                region.region_id == region_id && region.face_count > 0 && region.fully_recovered
410            }) else {
411                return Err(format!(
412                    "diagram-selected boundary region `{region_id}` is not available and fully recovered in mesh authoring evidence"
413                ));
414            };
415            return Ok((region.region_id.clone(), "diagram".to_string()));
416        }
417    }
418
419    for required in &summary.regions.required_boundary_region_ids {
420        if avoid_region_id == Some(required.as_str()) {
421            continue;
422        }
423        if summary.regions.boundary_regions.iter().any(|region| {
424            region.region_id == *required && region.face_count > 0 && region.fully_recovered
425        }) {
426            return Ok((required.clone(), "required".to_string()));
427        }
428    }
429    if let Some(region) = summary.regions.boundary_regions.iter().find(|region| {
430        avoid_region_id != Some(region.region_id.as_str())
431            && region.face_count > 0
432            && region.fully_recovered
433    }) {
434        return Ok((region.region_id.clone(), "available".to_string()));
435    }
436    summary
437        .regions
438        .boundary_regions
439        .iter()
440        .find(|region| region.face_count > 0 && region.fully_recovered)
441        .map(|region| (region.region_id.clone(), "available".to_string()))
442        .ok_or_else(|| {
443            "mesh authoring evidence has no fully recovered boundary regions".to_string()
444        })
445}