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}