1use runmat_analysis_core::{
2 AnalysisField, AnalysisFieldValues, AnalysisInterface, AnalysisInterfaceKind, AnalysisModel,
3 AnalysisModelId, AnalysisStep, AnalysisStepKind, BoundaryCondition, BoundaryConditionKind,
4 EvidenceConfidence, LoadCase, LoadKind, MaterialAcousticModel, MaterialAssignment,
5 MaterialElectricalModel, MaterialMechanicalModel, MaterialModel, MaterialPlasticModel,
6 MaterialThermalModel, ReferenceFrame,
7};
8use runmat_analysis_fea::ComputeBackend;
9use runmat_builtins::{
10 Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
11 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
12 ClassDef, MethodDef, ObjectInstance, Tensor, Value,
13};
14use runmat_geometry_core::GeometryAsset;
15use runmat_macros::runtime_builtin;
16use serde::de::DeserializeOwned;
17use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::sync::OnceLock;
21
22use crate::analysis::{
23 analysis_create_model_op, analysis_plan_study_op, analysis_plan_study_sweep_op,
24 analysis_results_by_run_id_op, analysis_results_compare_op, analysis_run_study_op,
25 analysis_run_study_sweep_op, analysis_trends_op, analysis_validate_study_op,
26 analysis_validate_study_sweep_op, load_fea_document_from_path_async,
27 AnalysisAcousticRunOptions, AnalysisCfdRunOptions, AnalysisChtRunOptions,
28 AnalysisCreateModelIntentSpec, AnalysisCreateModelProfile, AnalysisElectromagneticRunOptions,
29 AnalysisFieldDescriptor, AnalysisFsiRunOptions, AnalysisModalRunOptions,
30 AnalysisNonlinearRunOptions, AnalysisResultsCompareQuery, AnalysisResultsQuery,
31 AnalysisRunKind, AnalysisRunOptions, AnalysisStudySpec, AnalysisStudySweepSpec,
32 AnalysisThermalRunOptions, AnalysisTransientRunOptions, AnalysisTrendsQuery,
33 FeaResolvedDocument,
34};
35use crate::builtins::geometry::{GEOMETRY_ASSET_CLASS, GEOMETRY_ASSET_JSON_PROPERTY};
36use crate::builtins::io::json::jsondecode::value_from_json;
37use crate::operations::{OperationContext, OperationEnvelope, OperationErrorEnvelope};
38use crate::{build_runtime_error, BuiltinResult, RuntimeError};
39
40const FEA_STUDY_CLASS: &str = "fea.Study";
41const FEA_SWEEP_CLASS: &str = "fea.Sweep";
42const FEA_VALIDATION_CLASS: &str = "fea.Validation";
43const FEA_PLAN_CLASS: &str = "fea.Plan";
44const FEA_RUN_RESULT_CLASS: &str = "fea.RunResult";
45const FEA_MODEL_CLASS: &str = "fea.Model";
46const FEA_MATERIAL_CLASS: &str = "fea.Material";
47const FEA_MATERIAL_ASSIGNMENT_CLASS: &str = "fea.MaterialAssignment";
48const FEA_BOUNDARY_CONDITION_CLASS: &str = "fea.BoundaryCondition";
49const FEA_LOAD_CASE_CLASS: &str = "fea.LoadCase";
50const FEA_STEP_CLASS: &str = "fea.Step";
51const FEA_DOMAIN_CLASS: &str = "fea.Domain";
52const FEA_INTERFACE_CLASS: &str = "fea.Interface";
53const FEA_RUN_OPTIONS_CLASS: &str = "fea.RunOptions";
54const FEA_RESULTS_CLASS: &str = "fea.Results";
55const FEA_FIELD_CLASS: &str = "fea.Field";
56const FEA_COMPARE_CLASS: &str = "fea.Compare";
57const FEA_TRENDS_CLASS: &str = "fea.Trends";
58const FEA_STUDY_SPEC_JSON_PROPERTY: &str = "__runmat_fea_study_spec_json";
59const FEA_SWEEP_SPEC_JSON_PROPERTY: &str = "__runmat_fea_sweep_spec_json";
60const FEA_PAYLOAD_JSON_PROPERTY: &str = "__runmat_fea_payload_json";
61const FEA_STUDY_CONTEXT_JSON_PROPERTY: &str = "__runmat_fea_study_context_json";
62const FEA_RUN_ID_CONTEXT_PROPERTY: &str = "__runmat_fea_run_id";
63
64const LOAD_NAME: &str = "fea.load";
65const STUDY_NAME: &str = "fea.study";
66const SWEEP_NAME: &str = "fea.sweep";
67const MODEL_NAME: &str = "fea.model";
68const MATERIAL_NAME: &str = "fea.material";
69const MATERIAL_ASSIGNMENT_NAME: &str = "fea.materialAssignment";
70const BOUNDARY_CONDITION_NAME: &str = "fea.boundaryCondition";
71const LOAD_CASE_NAME: &str = "fea.loadCase";
72const STEP_NAME: &str = "fea.step";
73const DOMAIN_NAME: &str = "fea.domain";
74const INTERFACE_NAME: &str = "fea.interface";
75const RUN_OPTIONS_NAME: &str = "fea.runOptions";
76const VALIDATE_NAME: &str = "fea.validate";
77const PLAN_NAME: &str = "fea.plan";
78const RUN_NAME: &str = "fea.run";
79const RESULTS_NAME: &str = "fea.results";
80const FIELD_NAME: &str = "fea.field";
81const PLOT_NAME: &str = "fea.plot";
82const COMPARE_NAME: &str = "fea.compare";
83const TRENDS_NAME: &str = "fea.trends";
84
85const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
86 name: "result",
87 ty: BuiltinParamType::Any,
88 arity: BuiltinParamArity::Required,
89 default: None,
90 description: "FEA object or operation result.",
91}];
92const IN_PATH: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
93 name: "path",
94 ty: BuiltinParamType::StringScalar,
95 arity: BuiltinParamArity::Required,
96 default: None,
97 description: "Path to a .fea file.",
98}];
99const IN_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
100 name: "study",
101 ty: BuiltinParamType::Any,
102 arity: BuiltinParamArity::Required,
103 default: None,
104 description: "A .fea path, fea.Study object, or fea.Sweep object.",
105}];
106const IN_STUDY_ARGS: [BuiltinParamDescriptor; 3] = [
107 BuiltinParamDescriptor {
108 name: "id",
109 ty: BuiltinParamType::StringScalar,
110 arity: BuiltinParamArity::Required,
111 default: None,
112 description: "Study id.",
113 },
114 BuiltinParamDescriptor {
115 name: "geometry",
116 ty: BuiltinParamType::Any,
117 arity: BuiltinParamArity::Required,
118 default: None,
119 description: "geometry.Asset returned by geometry.load.",
120 },
121 BuiltinParamDescriptor {
122 name: "Name, Value",
123 ty: BuiltinParamType::Any,
124 arity: BuiltinParamArity::Variadic,
125 default: None,
126 description: "Profile, Backend, ModelId, and model setup options.",
127 },
128];
129const IN_VARIADIC_ARGS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
130 name: "args",
131 ty: BuiltinParamType::Any,
132 arity: BuiltinParamArity::Variadic,
133 default: None,
134 description: "Constructor or query arguments.",
135}];
136
137const LOAD_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
138 label: "doc = fea.load(path)",
139 inputs: &IN_PATH,
140 outputs: &OUT_ANY,
141}];
142const STUDY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
143 label: "study = fea.study(id, geometry, Name, Value, ...)",
144 inputs: &IN_STUDY_ARGS,
145 outputs: &OUT_ANY,
146}];
147const VALIDATE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
148 label: "result = fea.validate(study)",
149 inputs: &IN_INPUT,
150 outputs: &OUT_ANY,
151}];
152const PLAN_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
153 label: "plan = fea.plan(study)",
154 inputs: &IN_INPUT,
155 outputs: &OUT_ANY,
156}];
157const RUN_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
158 label: "run = fea.run(study)",
159 inputs: &IN_INPUT,
160 outputs: &OUT_ANY,
161}];
162const SWEEP_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
163 label: "sweep = fea.sweep(id, studies, Name, Value, ...)",
164 inputs: &IN_VARIADIC_ARGS,
165 outputs: &OUT_ANY,
166}];
167const MODEL_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
168 label: "model = fea.model(id, geometry, Name, Value, ...)",
169 inputs: &IN_VARIADIC_ARGS,
170 outputs: &OUT_ANY,
171}];
172const MATERIAL_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
173 label: "material = fea.material(id, Name, Value, ...)",
174 inputs: &IN_VARIADIC_ARGS,
175 outputs: &OUT_ANY,
176}];
177const COMPONENT_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
178 label: "component = fea.component(args, ...)",
179 inputs: &IN_VARIADIC_ARGS,
180 outputs: &OUT_ANY,
181}];
182const RESULTS_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
183 label: "results = fea.results(runOrRunId, Name, Value, ...)",
184 inputs: &IN_VARIADIC_ARGS,
185 outputs: &OUT_ANY,
186}];
187const FIELD_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
188 label: "field = fea.field(resultsOrRun, fieldId)",
189 inputs: &IN_VARIADIC_ARGS,
190 outputs: &OUT_ANY,
191}];
192const PLOT_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
193 label: "figure = fea.plot(runOrResultsOrField, fieldId)",
194 inputs: &IN_VARIADIC_ARGS,
195 outputs: &OUT_ANY,
196}];
197const COMPARE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
198 label: "comparison = fea.compare(baselineRunId, candidateRunId)",
199 inputs: &IN_VARIADIC_ARGS,
200 outputs: &OUT_ANY,
201}];
202const TRENDS_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
203 label: "trends = fea.trends(Name, Value, ...)",
204 inputs: &IN_VARIADIC_ARGS,
205 outputs: &OUT_ANY,
206}];
207
208const ERROR_LOAD: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
209 code: "RM.FEA.BUILTIN.LOAD_FAILED",
210 identifier: Some("RunMat:fea:LoadFailed"),
211 when: "A .fea document cannot be read, parsed, or resolved.",
212 message: "fea: failed to load FEA document",
213};
214const ERROR_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
215 code: "RM.FEA.BUILTIN.INVALID_INPUT",
216 identifier: Some("RunMat:fea:InvalidInput"),
217 when: "A builtin receives an unsupported argument pattern or object type.",
218 message: "fea: invalid input",
219};
220const ERROR_OPERATION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
221 code: "RM.FEA.BUILTIN.OPERATION_FAILED",
222 identifier: Some("RunMat:fea:OperationFailed"),
223 when: "The validation, planning, or run operation fails.",
224 message: "fea: operation failed",
225};
226const ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
227 code: "RM.FEA.BUILTIN.INTERNAL",
228 identifier: Some("RunMat:fea:Internal"),
229 when: "An FEA object or operation result cannot be converted to a RunMat value.",
230 message: "fea: internal error",
231};
232const ERRORS: [BuiltinErrorDescriptor; 4] =
233 [ERROR_LOAD, ERROR_INPUT, ERROR_OPERATION, ERROR_INTERNAL];
234
235pub const FEA_LOAD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
236 signatures: &LOAD_SIGNATURES,
237 output_mode: BuiltinOutputMode::Fixed,
238 completion_policy: BuiltinCompletionPolicy::Public,
239 errors: &ERRORS,
240};
241pub const FEA_STUDY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
242 signatures: &STUDY_SIGNATURES,
243 output_mode: BuiltinOutputMode::Fixed,
244 completion_policy: BuiltinCompletionPolicy::Public,
245 errors: &ERRORS,
246};
247pub const FEA_VALIDATE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
248 signatures: &VALIDATE_SIGNATURES,
249 output_mode: BuiltinOutputMode::Fixed,
250 completion_policy: BuiltinCompletionPolicy::Public,
251 errors: &ERRORS,
252};
253pub const FEA_PLAN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
254 signatures: &PLAN_SIGNATURES,
255 output_mode: BuiltinOutputMode::Fixed,
256 completion_policy: BuiltinCompletionPolicy::Public,
257 errors: &ERRORS,
258};
259pub const FEA_RUN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
260 signatures: &RUN_SIGNATURES,
261 output_mode: BuiltinOutputMode::Fixed,
262 completion_policy: BuiltinCompletionPolicy::Public,
263 errors: &ERRORS,
264};
265pub const FEA_SWEEP_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
266 signatures: &SWEEP_SIGNATURES,
267 output_mode: BuiltinOutputMode::Fixed,
268 completion_policy: BuiltinCompletionPolicy::Public,
269 errors: &ERRORS,
270};
271pub const FEA_MODEL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
272 signatures: &MODEL_SIGNATURES,
273 output_mode: BuiltinOutputMode::Fixed,
274 completion_policy: BuiltinCompletionPolicy::Public,
275 errors: &ERRORS,
276};
277pub const FEA_MATERIAL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
278 signatures: &MATERIAL_SIGNATURES,
279 output_mode: BuiltinOutputMode::Fixed,
280 completion_policy: BuiltinCompletionPolicy::Public,
281 errors: &ERRORS,
282};
283pub const FEA_COMPONENT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
284 signatures: &COMPONENT_SIGNATURES,
285 output_mode: BuiltinOutputMode::Fixed,
286 completion_policy: BuiltinCompletionPolicy::Public,
287 errors: &ERRORS,
288};
289pub const FEA_RESULTS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
290 signatures: &RESULTS_SIGNATURES,
291 output_mode: BuiltinOutputMode::Fixed,
292 completion_policy: BuiltinCompletionPolicy::Public,
293 errors: &ERRORS,
294};
295pub const FEA_FIELD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
296 signatures: &FIELD_SIGNATURES,
297 output_mode: BuiltinOutputMode::Fixed,
298 completion_policy: BuiltinCompletionPolicy::Public,
299 errors: &ERRORS,
300};
301pub const FEA_PLOT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
302 signatures: &PLOT_SIGNATURES,
303 output_mode: BuiltinOutputMode::Fixed,
304 completion_policy: BuiltinCompletionPolicy::Public,
305 errors: &ERRORS,
306};
307pub const FEA_COMPARE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
308 signatures: &COMPARE_SIGNATURES,
309 output_mode: BuiltinOutputMode::Fixed,
310 completion_policy: BuiltinCompletionPolicy::Public,
311 errors: &ERRORS,
312};
313pub const FEA_TRENDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
314 signatures: &TRENDS_SIGNATURES,
315 output_mode: BuiltinOutputMode::Fixed,
316 completion_policy: BuiltinCompletionPolicy::Public,
317 errors: &ERRORS,
318};
319
320#[runtime_builtin(
321 name = "fea.load",
322 category = "fea",
323 summary = "Load a .fea study or sweep document.",
324 keywords = "fea,study,sweep,load,yaml",
325 descriptor(crate::builtins::fea::FEA_LOAD_DESCRIPTOR),
326 builtin_path = "crate::builtins::fea"
327)]
328pub async fn fea_load_builtin(path: String) -> BuiltinResult<Value> {
329 load_document_object(PathBuf::from(path)).await
330}
331
332#[runtime_builtin(
333 name = "fea.study",
334 category = "fea",
335 summary = "Create a typed FEA study from geometry, model data, and run settings.",
336 keywords = "fea,study,geometry,run",
337 descriptor(crate::builtins::fea::FEA_STUDY_DESCRIPTOR),
338 builtin_path = "crate::builtins::fea"
339)]
340pub async fn fea_study_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
341 if args.len() == 1 {
342 let path = scalar_string(&args[0], STUDY_NAME, &ERROR_INPUT)?;
343 return load_document_object(PathBuf::from(path)).await;
344 }
345 create_study_object_from_args(args)
346}
347
348#[runtime_builtin(
349 name = "fea.sweep",
350 category = "fea",
351 summary = "Create a FEA study sweep from study objects.",
352 keywords = "fea,sweep,study,run",
353 descriptor(crate::builtins::fea::FEA_SWEEP_DESCRIPTOR),
354 builtin_path = "crate::builtins::fea"
355)]
356pub async fn fea_sweep_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
357 create_sweep_object_from_args(args)
358}
359
360#[runtime_builtin(
361 name = "fea.model",
362 category = "fea",
363 summary = "Create a typed FEA model object from geometry and model components.",
364 keywords = "fea,model,materials,boundary,loads,domains",
365 descriptor(crate::builtins::fea::FEA_MODEL_DESCRIPTOR),
366 builtin_path = "crate::builtins::fea"
367)]
368pub async fn fea_model_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
369 create_model_object_from_args(args)
370}
371
372#[runtime_builtin(
373 name = "fea.material",
374 category = "fea",
375 summary = "Create a typed FEA material object.",
376 keywords = "fea,material,mechanical,thermal,electrical,plastic",
377 descriptor(crate::builtins::fea::FEA_MATERIAL_DESCRIPTOR),
378 builtin_path = "crate::builtins::fea"
379)]
380pub async fn fea_material_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
381 create_material_object_from_args(args)
382}
383
384#[runtime_builtin(
385 name = "fea.materialAssignment",
386 category = "fea",
387 summary = "Create a typed FEA material assignment.",
388 keywords = "fea,material,assignment,region",
389 descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
390 builtin_path = "crate::builtins::fea"
391)]
392pub async fn fea_material_assignment_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
393 create_material_assignment_object_from_args(args)
394}
395
396#[runtime_builtin(
397 name = "fea.boundaryCondition",
398 category = "fea",
399 summary = "Create a typed FEA boundary condition.",
400 keywords = "fea,boundary,condition,region,prescribed,rotation",
401 descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
402 builtin_path = "crate::builtins::fea"
403)]
404pub async fn fea_boundary_condition_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
405 create_boundary_condition_object_from_args(args)
406}
407
408#[runtime_builtin(
409 name = "fea.loadCase",
410 category = "fea",
411 summary = "Create a typed FEA load case.",
412 keywords = "fea,load,force,moment,torque,pressure,current",
413 descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
414 builtin_path = "crate::builtins::fea"
415)]
416pub async fn fea_load_case_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
417 create_load_case_object_from_args(args)
418}
419
420#[runtime_builtin(
421 name = "fea.step",
422 category = "fea",
423 summary = "Create a typed FEA analysis step.",
424 keywords = "fea,step,static,modal,transient",
425 descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
426 builtin_path = "crate::builtins::fea"
427)]
428pub async fn fea_step_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
429 create_step_object_from_args(args)
430}
431
432#[runtime_builtin(
433 name = "fea.domain",
434 category = "fea",
435 summary = "Create a typed FEA physics domain object.",
436 keywords = "fea,domain,thermal,electromagnetic,cfd",
437 descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
438 builtin_path = "crate::builtins::fea"
439)]
440pub async fn fea_domain_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
441 create_domain_object_from_args(args)
442}
443
444#[runtime_builtin(
445 name = "fea.interface",
446 category = "fea",
447 summary = "Create a typed FEA interface object.",
448 keywords = "fea,interface,contact,region",
449 descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
450 builtin_path = "crate::builtins::fea"
451)]
452pub async fn fea_interface_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
453 create_interface_object_from_args(args)
454}
455
456#[runtime_builtin(
457 name = "fea.runOptions",
458 category = "fea",
459 summary = "Create typed FEA run options for a solver.",
460 keywords = "fea,run,options,solver,quality",
461 descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
462 builtin_path = "crate::builtins::fea"
463)]
464pub async fn fea_run_options_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
465 create_run_options_object_from_args(args)
466}
467
468#[runtime_builtin(
469 name = "fea.validate",
470 category = "fea",
471 summary = "Validate a FEA study or sweep without planning or solving.",
472 keywords = "fea,validate,study,sweep",
473 descriptor(crate::builtins::fea::FEA_VALIDATE_DESCRIPTOR),
474 builtin_path = "crate::builtins::fea"
475)]
476pub async fn fea_validate_builtin(input: Value) -> BuiltinResult<Value> {
477 match resolve_document_input(input, VALIDATE_NAME).await? {
478 FeaResolvedDocument::Study(spec) => operation_result_to_object(
479 VALIDATE_NAME,
480 &ERROR_OPERATION,
481 &ERROR_INTERNAL,
482 FEA_VALIDATION_CLASS,
483 analysis_validate_study_op(&spec, OperationContext::new(None, None)),
484 None,
485 ),
486 FeaResolvedDocument::Sweep(spec) => operation_result_to_object(
487 VALIDATE_NAME,
488 &ERROR_OPERATION,
489 &ERROR_INTERNAL,
490 FEA_VALIDATION_CLASS,
491 analysis_validate_study_sweep_op(&spec, OperationContext::new(None, None)),
492 None,
493 ),
494 }
495}
496
497#[runtime_builtin(
498 name = "fea.plan",
499 category = "fea",
500 summary = "Plan a FEA study or sweep without solving it.",
501 keywords = "fea,plan,study,sweep",
502 descriptor(crate::builtins::fea::FEA_PLAN_DESCRIPTOR),
503 builtin_path = "crate::builtins::fea"
504)]
505pub async fn fea_plan_builtin(input: Value) -> BuiltinResult<Value> {
506 match resolve_document_input(input, PLAN_NAME).await? {
507 FeaResolvedDocument::Study(spec) => operation_result_to_object(
508 PLAN_NAME,
509 &ERROR_OPERATION,
510 &ERROR_INTERNAL,
511 FEA_PLAN_CLASS,
512 analysis_plan_study_op(&spec, OperationContext::new(None, None)),
513 None,
514 ),
515 FeaResolvedDocument::Sweep(spec) => operation_result_to_object(
516 PLAN_NAME,
517 &ERROR_OPERATION,
518 &ERROR_INTERNAL,
519 FEA_PLAN_CLASS,
520 analysis_plan_study_sweep_op(&spec, OperationContext::new(None, None)),
521 None,
522 ),
523 }
524}
525
526#[runtime_builtin(
527 name = "fea.run",
528 category = "fea",
529 summary = "Run a FEA study or sweep.",
530 keywords = "fea,run,study,sweep,solve",
531 descriptor(crate::builtins::fea::FEA_RUN_DESCRIPTOR),
532 builtin_path = "crate::builtins::fea"
533)]
534pub async fn fea_run_builtin(input: Value) -> BuiltinResult<Value> {
535 match resolve_document_input(input, RUN_NAME).await? {
536 FeaResolvedDocument::Study(spec) => run_study_result_to_object(&spec),
537 FeaResolvedDocument::Sweep(spec) => operation_result_to_object(
538 RUN_NAME,
539 &ERROR_OPERATION,
540 &ERROR_INTERNAL,
541 FEA_RUN_RESULT_CLASS,
542 analysis_run_study_sweep_op(&spec, OperationContext::new(None, None)),
543 Some(FEA_PAYLOAD_JSON_PROPERTY),
544 ),
545 }
546}
547
548#[runtime_builtin(
549 name = "fea.results",
550 category = "fea",
551 summary = "Load or project FEA run results for post-processing.",
552 keywords = "fea,results,run_id,fields,diagnostics",
553 descriptor(crate::builtins::fea::FEA_RESULTS_DESCRIPTOR),
554 builtin_path = "crate::builtins::fea"
555)]
556pub async fn fea_results_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
557 create_results_object_from_args(args)
558}
559
560#[runtime_builtin(
561 name = "fea.field",
562 category = "fea",
563 summary = "Extract a field from FEA results or a run result.",
564 keywords = "fea,field,displacement,von_mises,post",
565 descriptor(crate::builtins::fea::FEA_FIELD_DESCRIPTOR),
566 builtin_path = "crate::builtins::fea"
567)]
568pub async fn fea_field_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
569 create_field_object_from_args(args)
570}
571
572#[runtime_builtin(
573 name = "fea.plot",
574 category = "fea",
575 summary = "Create a RunMat figure for an FEA result field on its geometry mesh.",
576 keywords = "fea,plot,visualize,mesh,von_mises,stress,field",
577 descriptor(crate::builtins::fea::FEA_PLOT_DESCRIPTOR),
578 builtin_path = "crate::builtins::fea"
579)]
580pub async fn fea_plot_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
581 create_plot_from_args(args)
582}
583
584#[runtime_builtin(
585 name = "fea.compare",
586 category = "fea",
587 summary = "Compare two persisted FEA runs by run id.",
588 keywords = "fea,compare,run_id,quality",
589 descriptor(crate::builtins::fea::FEA_COMPARE_DESCRIPTOR),
590 builtin_path = "crate::builtins::fea"
591)]
592pub async fn fea_compare_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
593 create_compare_object_from_args(args)
594}
595
596#[runtime_builtin(
597 name = "fea.trends",
598 category = "fea",
599 summary = "Summarize recent persisted FEA run trends.",
600 keywords = "fea,trends,history,quality",
601 descriptor(crate::builtins::fea::FEA_TRENDS_DESCRIPTOR),
602 builtin_path = "crate::builtins::fea"
603)]
604pub async fn fea_trends_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
605 create_trends_object_from_args(args)
606}
607
608async fn load_document_object(path: PathBuf) -> BuiltinResult<Value> {
609 let document = load_fea_document_from_path_async(&path)
610 .await
611 .map_err(|err| builtin_error(LOAD_NAME, &ERROR_LOAD, err))?;
612 resolved_document_to_object(document)
613}
614
615async fn resolve_document_input(
616 input: Value,
617 builtin: &'static str,
618) -> BuiltinResult<FeaResolvedDocument> {
619 match input {
620 Value::Object(object) if object.class_name == FEA_STUDY_CLASS => {
621 let spec: AnalysisStudySpec =
622 object_json_property(builtin, &object, FEA_STUDY_SPEC_JSON_PROPERTY, &ERROR_INPUT)?;
623 Ok(FeaResolvedDocument::Study(Box::new(spec)))
624 }
625 Value::Object(object) if object.class_name == FEA_SWEEP_CLASS => {
626 let spec: AnalysisStudySweepSpec =
627 object_json_property(builtin, &object, FEA_SWEEP_SPEC_JSON_PROPERTY, &ERROR_INPUT)?;
628 Ok(FeaResolvedDocument::Sweep(spec))
629 }
630 Value::String(path) => load_fea_document_from_path_async(&PathBuf::from(path))
631 .await
632 .map_err(|err| builtin_error(builtin, &ERROR_LOAD, err)),
633 Value::CharArray(chars) if chars.rows == 1 => {
634 let path: String = chars.data.iter().collect();
635 load_fea_document_from_path_async(&PathBuf::from(path))
636 .await
637 .map_err(|err| builtin_error(builtin, &ERROR_LOAD, err))
638 }
639 other => Err(builtin_error(
640 builtin,
641 &ERROR_INPUT,
642 format!("expected .fea path, {FEA_STUDY_CLASS}, or {FEA_SWEEP_CLASS}; got {other:?}"),
643 )),
644 }
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize)]
648struct RunOptionsPayload {
649 run_kind: AnalysisRunKind,
650 options: serde_json::Value,
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize)]
654struct DomainPayload {
655 kind: String,
656 data: serde_json::Value,
657}
658
659#[derive(Debug, Clone, Copy, PartialEq, Eq)]
660enum ModelDefaultsMode {
661 ProfileScaffold,
662 None,
663}
664
665impl Default for ModelDefaultsMode {
666 fn default() -> Self {
667 Self::ProfileScaffold
668 }
669}
670
671#[derive(Debug, Default)]
672struct StudyConstructorOptions {
673 run_kind: Option<AnalysisRunKind>,
674 profile: Option<AnalysisCreateModelProfile>,
675 backend: Option<ComputeBackend>,
676 model_id: Option<String>,
677 model: Option<AnalysisModel>,
678 frame: Option<ReferenceFrame>,
679 model_defaults: ModelDefaultsMode,
680 materials: Vec<MaterialModel>,
681 material_assignments: Vec<MaterialAssignment>,
682 boundary_conditions: Vec<BoundaryCondition>,
683 loads: Vec<LoadCase>,
684 steps: Vec<AnalysisStep>,
685 domains: Vec<DomainPayload>,
686 interfaces: Vec<AnalysisInterface>,
687 run_options: Option<RunOptionsPayload>,
688}
689
690fn create_study_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
691 if args.len() < 2 {
692 return Err(builtin_error(
693 STUDY_NAME,
694 &ERROR_INPUT,
695 "fea.study requires id and geometry arguments",
696 ));
697 }
698 let study_id = scalar_string(&args[0], STUDY_NAME, &ERROR_INPUT)?;
699 let geometry = geometry_asset_from_value(&args[1])?;
700 let options = StudyConstructorOptions::parse(&args[2..])?;
701 let (profile, run_kind) = resolve_study_profile_and_run_kind(&options)?;
702 let model_id = options.model_id.clone().unwrap_or_else(|| {
703 options
704 .model
705 .as_ref()
706 .map(|model| model.model_id.0.clone())
707 .unwrap_or_else(|| format!("{}_model", sanitize_id(&study_id)))
708 });
709 let model = match options.model {
710 Some(model) => Some(model),
711 None if options.has_model_components() => Some(build_model_from_parts(
712 STUDY_NAME,
713 &geometry,
714 model_id.clone(),
715 profile,
716 options.model_defaults,
717 options.frame,
718 options.materials,
719 options.material_assignments,
720 options.boundary_conditions,
721 options.loads,
722 options.steps,
723 options.domains,
724 options.interfaces,
725 )?),
726 None => None,
727 };
728 let run_options = options
729 .run_options
730 .map(|payload| resolved_run_options_from_payload(STUDY_NAME, payload, run_kind))
731 .transpose()?
732 .unwrap_or_default();
733 let spec = AnalysisStudySpec {
734 study_id,
735 geometry,
736 create_model_intent: AnalysisCreateModelIntentSpec {
737 model_id,
738 profile,
739 prep_context: None,
740 },
741 model,
742 run_kind,
743 backend: options.backend.unwrap_or(ComputeBackend::Cpu),
744 linear_static_run_options: run_options.linear_static,
745 modal_run_options: run_options.modal,
746 acoustic_run_options: run_options.acoustic,
747 thermal_run_options: run_options.thermal,
748 transient_run_options: run_options.transient,
749 cfd_run_options: run_options.cfd,
750 cht_run_options: run_options.cht,
751 fsi_run_options: run_options.fsi,
752 nonlinear_run_options: run_options.nonlinear,
753 electromagnetic_run_options: run_options.electromagnetic,
754 };
755 study_to_object(spec)
756}
757
758impl StudyConstructorOptions {
759 fn parse(args: &[Value]) -> BuiltinResult<Self> {
760 if !args.len().is_multiple_of(2) {
761 return Err(builtin_error(
762 STUDY_NAME,
763 &ERROR_INPUT,
764 "fea.study options must be Name, Value pairs",
765 ));
766 }
767 let mut options = Self::default();
768 for pair in args.chunks(2) {
769 let key = option_key(&pair[0], STUDY_NAME)?;
770 match key.as_str() {
771 "runkind" | "kind" => {
772 let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
773 options.run_kind = Some(parse_scalar_enum(&text, "RunKind")?);
774 }
775 "profile" => {
776 let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
777 options.profile = Some(parse_scalar_enum(&text, "Profile")?);
778 }
779 "backend" => {
780 let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
781 options.backend = Some(parse_scalar_enum(&text, "Backend")?);
782 }
783 "modelid" => {
784 options.model_id = Some(scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?);
785 }
786 "model" => {
787 options.model = Some(model_from_value(STUDY_NAME, &pair[1])?);
788 }
789 "frame" => {
790 let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
791 options.frame = Some(parse_scalar_enum(&text, "Frame")?);
792 }
793 "defaults" => {
794 options.model_defaults = parse_model_defaults_mode(&scalar_string(
795 &pair[1],
796 STUDY_NAME,
797 &ERROR_INPUT,
798 )?)?;
799 }
800 "materials" => options.materials = material_vec_from_value(STUDY_NAME, &pair[1])?,
801 "materialassignments" | "assignments" => {
802 options.material_assignments =
803 material_assignment_vec_from_value(STUDY_NAME, &pair[1])?;
804 }
805 "boundaryconditions" | "bcs" => {
806 options.boundary_conditions =
807 boundary_condition_vec_from_value(STUDY_NAME, &pair[1])?;
808 }
809 "loads" | "loadcases" => {
810 options.loads = load_case_vec_from_value(STUDY_NAME, &pair[1])?;
811 }
812 "steps" => options.steps = step_vec_from_value(STUDY_NAME, &pair[1])?,
813 "domains" => options.domains = domain_vec_from_value(STUDY_NAME, &pair[1])?,
814 "interfaces" => {
815 options.interfaces = interface_vec_from_value(STUDY_NAME, &pair[1])?;
816 }
817 "runoptions" | "options" => {
818 options.run_options =
819 Some(run_options_payload_from_value(STUDY_NAME, &pair[1])?);
820 }
821 other => {
822 return Err(builtin_error(
823 STUDY_NAME,
824 &ERROR_INPUT,
825 format!("unsupported fea.study option `{other}`"),
826 ));
827 }
828 }
829 }
830 Ok(options)
831 }
832
833 fn has_model_components(&self) -> bool {
834 self.frame.is_some()
835 || !self.materials.is_empty()
836 || !self.material_assignments.is_empty()
837 || !self.boundary_conditions.is_empty()
838 || !self.loads.is_empty()
839 || !self.steps.is_empty()
840 || !self.domains.is_empty()
841 || !self.interfaces.is_empty()
842 }
843}
844
845#[derive(Debug, Default)]
846struct ModelConstructorOptions {
847 profile: Option<AnalysisCreateModelProfile>,
848 frame: Option<ReferenceFrame>,
849 defaults: ModelDefaultsMode,
850 materials: Vec<MaterialModel>,
851 material_assignments: Vec<MaterialAssignment>,
852 boundary_conditions: Vec<BoundaryCondition>,
853 loads: Vec<LoadCase>,
854 steps: Vec<AnalysisStep>,
855 domains: Vec<DomainPayload>,
856 interfaces: Vec<AnalysisInterface>,
857}
858
859#[derive(Debug, Default)]
860struct ResolvedRunOptions {
861 linear_static: Option<AnalysisRunOptions>,
862 modal: Option<AnalysisModalRunOptions>,
863 acoustic: Option<AnalysisAcousticRunOptions>,
864 thermal: Option<AnalysisThermalRunOptions>,
865 transient: Option<AnalysisTransientRunOptions>,
866 cfd: Option<AnalysisCfdRunOptions>,
867 cht: Option<AnalysisChtRunOptions>,
868 fsi: Option<AnalysisFsiRunOptions>,
869 nonlinear: Option<AnalysisNonlinearRunOptions>,
870 electromagnetic: Option<AnalysisElectromagneticRunOptions>,
871}
872
873fn create_sweep_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
874 if args.len() < 2 {
875 return Err(builtin_error(
876 SWEEP_NAME,
877 &ERROR_INPUT,
878 "fea.sweep requires id and studies arguments",
879 ));
880 }
881 let sweep_id = scalar_string(&args[0], SWEEP_NAME, &ERROR_INPUT)?;
882 let studies = study_vec_from_value(SWEEP_NAME, &args[1])?;
883 let mut fail_fast = true;
884 for pair in expect_name_value_tail(SWEEP_NAME, &args[2..])? {
885 match pair.key.as_str() {
886 "failfast" => fail_fast = bool_from_value(SWEEP_NAME, pair.value)?,
887 other => {
888 return Err(builtin_error(
889 SWEEP_NAME,
890 &ERROR_INPUT,
891 format!("unsupported fea.sweep option `{other}`"),
892 ));
893 }
894 }
895 }
896 sweep_to_object(AnalysisStudySweepSpec {
897 sweep_id,
898 studies,
899 fail_fast,
900 })
901}
902
903fn create_model_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
904 if args.len() < 2 {
905 return Err(builtin_error(
906 MODEL_NAME,
907 &ERROR_INPUT,
908 "fea.model requires id and geometry arguments",
909 ));
910 }
911 let model_id = scalar_string(&args[0], MODEL_NAME, &ERROR_INPUT)?;
912 let geometry = geometry_asset_from_value(&args[1])?;
913 let options = parse_model_constructor_options(MODEL_NAME, &args[2..])?;
914 let profile = options
915 .profile
916 .unwrap_or(AnalysisCreateModelProfile::LinearStaticStructural);
917 let model = build_model_from_parts(
918 MODEL_NAME,
919 &geometry,
920 model_id,
921 profile,
922 options.defaults,
923 options.frame,
924 options.materials,
925 options.material_assignments,
926 options.boundary_conditions,
927 options.loads,
928 options.steps,
929 options.domains,
930 options.interfaces,
931 )?;
932 serializable_to_object(
933 MODEL_NAME,
934 &ERROR_INTERNAL,
935 FEA_MODEL_CLASS,
936 &model,
937 Some(FEA_PAYLOAD_JSON_PROPERTY),
938 )
939}
940
941fn parse_model_constructor_options(
942 builtin: &'static str,
943 args: &[Value],
944) -> BuiltinResult<ModelConstructorOptions> {
945 let mut options = ModelConstructorOptions::default();
946 for pair in expect_name_value_tail(builtin, args)? {
947 match pair.key.as_str() {
948 "profile" => {
949 let text = scalar_string(pair.value, builtin, &ERROR_INPUT)?;
950 options.profile = Some(parse_scalar_enum(&text, "Profile")?);
951 }
952 "frame" => {
953 let text = scalar_string(pair.value, builtin, &ERROR_INPUT)?;
954 options.frame = Some(parse_scalar_enum(&text, "Frame")?);
955 }
956 "defaults" => {
957 options.defaults =
958 parse_model_defaults_mode(&scalar_string(pair.value, builtin, &ERROR_INPUT)?)?;
959 }
960 "materials" => options.materials = material_vec_from_value(builtin, pair.value)?,
961 "materialassignments" | "assignments" => {
962 options.material_assignments =
963 material_assignment_vec_from_value(builtin, pair.value)?;
964 }
965 "boundaryconditions" | "bcs" => {
966 options.boundary_conditions =
967 boundary_condition_vec_from_value(builtin, pair.value)?;
968 }
969 "loads" | "loadcases" => options.loads = load_case_vec_from_value(builtin, pair.value)?,
970 "steps" => options.steps = step_vec_from_value(builtin, pair.value)?,
971 "domains" => options.domains = domain_vec_from_value(builtin, pair.value)?,
972 "interfaces" => options.interfaces = interface_vec_from_value(builtin, pair.value)?,
973 other => {
974 return Err(builtin_error(
975 builtin,
976 &ERROR_INPUT,
977 format!("unsupported {builtin} option `{other}`"),
978 ));
979 }
980 }
981 }
982 Ok(options)
983}
984
985fn create_material_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
986 if args.is_empty() {
987 return Err(builtin_error(
988 MATERIAL_NAME,
989 &ERROR_INPUT,
990 "fea.material requires a material id",
991 ));
992 }
993 let material_id = scalar_string(&args[0], MATERIAL_NAME, &ERROR_INPUT)?;
994 let mut fields = json_fields_from_name_values(MATERIAL_NAME, &args[1..])?;
995 let name = fields
996 .remove("name")
997 .map(json_to_string)
998 .transpose()?
999 .unwrap_or_else(|| material_id.clone());
1000 let mechanical = if let Some(value) = fields.remove("mechanical") {
1001 json_deserialize(MATERIAL_NAME, value, "mechanical material model")?
1002 } else {
1003 let youngs = remove_required_f64(&mut fields, MATERIAL_NAME, "youngs_modulus_pa")?;
1004 let poisson = remove_required_f64(&mut fields, MATERIAL_NAME, "poisson_ratio")?;
1005 let density = remove_optional_f64(&mut fields, "density_kg_per_m3")?.unwrap_or(7850.0);
1006 MaterialMechanicalModel {
1007 youngs_modulus_pa: youngs,
1008 poisson_ratio: poisson,
1009 density_kg_per_m3: density,
1010 }
1011 };
1012 let thermal = if let Some(value) = fields.remove("thermal") {
1013 json_deserialize(MATERIAL_NAME, value, "thermal material model")?
1014 } else {
1015 let mut thermal = serde_json::to_value(MaterialThermalModel::default())
1016 .map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
1017 move_known_fields(
1018 &mut fields,
1019 thermal.as_object_mut().expect("thermal model is object"),
1020 &[
1021 "reference_temperature_k",
1022 "modulus_temp_coeff_per_k",
1023 "conductivity_w_per_mk",
1024 "specific_heat_j_per_kgk",
1025 "expansion_coefficient_per_k",
1026 ],
1027 );
1028 json_deserialize(MATERIAL_NAME, thermal, "thermal material model")?
1029 };
1030 let electrical = if let Some(value) = fields.remove("electrical") {
1031 Some(json_deserialize(
1032 MATERIAL_NAME,
1033 value,
1034 "electrical material model",
1035 )?)
1036 } else {
1037 let mut electrical = serde_json::to_value(MaterialElectricalModel::default())
1038 .map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
1039 let moved = move_known_fields(
1040 &mut fields,
1041 electrical
1042 .as_object_mut()
1043 .expect("electrical material model is object"),
1044 &[
1045 "reference_temperature_k",
1046 "conductivity_s_per_m",
1047 "resistive_heating_coefficient",
1048 "relative_permittivity",
1049 "relative_permeability",
1050 "conductivity_frequency_response",
1051 ],
1052 );
1053 if moved {
1054 Some(json_deserialize(
1055 MATERIAL_NAME,
1056 electrical,
1057 "electrical material model",
1058 )?)
1059 } else {
1060 None
1061 }
1062 };
1063 let acoustic = if let Some(value) = fields.remove("acoustic") {
1064 Some(json_deserialize(
1065 MATERIAL_NAME,
1066 value,
1067 "acoustic material model",
1068 )?)
1069 } else {
1070 let mut acoustic = serde_json::to_value(MaterialAcousticModel::default())
1071 .map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
1072 let moved = move_known_fields(
1073 &mut fields,
1074 acoustic
1075 .as_object_mut()
1076 .expect("acoustic material model is object"),
1077 &[
1078 "density_kg_per_m3",
1079 "speed_of_sound_m_per_s",
1080 "damping_ratio",
1081 ],
1082 );
1083 if moved {
1084 Some(json_deserialize(
1085 MATERIAL_NAME,
1086 acoustic,
1087 "acoustic material model",
1088 )?)
1089 } else {
1090 None
1091 }
1092 };
1093 let plastic = if let Some(value) = fields.remove("plastic") {
1094 Some(json_deserialize(
1095 MATERIAL_NAME,
1096 value,
1097 "plastic material model",
1098 )?)
1099 } else if fields.contains_key("yield_strain")
1100 || fields.contains_key("hardening_modulus_ratio")
1101 || fields.contains_key("saturation_exponent")
1102 {
1103 Some(MaterialPlasticModel {
1104 yield_strain: remove_required_f64(&mut fields, MATERIAL_NAME, "yield_strain")?,
1105 hardening_modulus_ratio: remove_required_f64(
1106 &mut fields,
1107 MATERIAL_NAME,
1108 "hardening_modulus_ratio",
1109 )?,
1110 saturation_exponent: remove_required_f64(
1111 &mut fields,
1112 MATERIAL_NAME,
1113 "saturation_exponent",
1114 )?,
1115 })
1116 } else {
1117 None
1118 };
1119 reject_unknown_fields(MATERIAL_NAME, fields)?;
1120 material_to_object(MaterialModel {
1121 material_id,
1122 name,
1123 mechanical,
1124 thermal,
1125 acoustic,
1126 electrical,
1127 plastic,
1128 })
1129}
1130
1131fn create_material_assignment_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1132 if args.len() < 2 {
1133 return Err(builtin_error(
1134 MATERIAL_ASSIGNMENT_NAME,
1135 &ERROR_INPUT,
1136 "fea.materialAssignment requires region and material arguments",
1137 ));
1138 }
1139 let region_id = scalar_string(&args[0], MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
1140 let assigned_material_id = scalar_string(&args[1], MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
1141 let mut expected_material_id = assigned_material_id.clone();
1142 let mut confidence = EvidenceConfidence::Verified;
1143 for pair in expect_name_value_tail(MATERIAL_ASSIGNMENT_NAME, &args[2..])? {
1144 match pair.key.as_str() {
1145 "expectedmaterial" | "expectedmaterialid" => {
1146 expected_material_id =
1147 scalar_string(pair.value, MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
1148 }
1149 "confidence" => {
1150 let text = scalar_string(pair.value, MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
1151 confidence = parse_scalar_enum(&text, "Confidence")?;
1152 }
1153 other => {
1154 return Err(builtin_error(
1155 MATERIAL_ASSIGNMENT_NAME,
1156 &ERROR_INPUT,
1157 format!("unsupported fea.materialAssignment option `{other}`"),
1158 ));
1159 }
1160 }
1161 }
1162 material_assignment_to_object(MaterialAssignment {
1163 region_id,
1164 expected_material_id,
1165 assigned_material_id,
1166 confidence,
1167 })
1168}
1169
1170fn create_boundary_condition_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1171 if args.len() < 3 {
1172 return Err(builtin_error(
1173 BOUNDARY_CONDITION_NAME,
1174 &ERROR_INPUT,
1175 "fea.boundaryCondition requires id, region, and kind arguments",
1176 ));
1177 }
1178 let bc_id = scalar_string(&args[0], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
1179 let region_id = scalar_string(&args[1], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
1180 let kind_text = scalar_string(&args[2], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
1181 let mut fields = json_fields_from_name_values(BOUNDARY_CONDITION_NAME, &args[3..])?;
1182 let kind = match normalize_token(&kind_text).as_str() {
1183 "prescribedrotation" => BoundaryConditionKind::PrescribedRotation {
1184 rx: remove_required_f64(&mut fields, BOUNDARY_CONDITION_NAME, "rx")?,
1185 ry: remove_required_f64(&mut fields, BOUNDARY_CONDITION_NAME, "ry")?,
1186 rz: remove_required_f64(&mut fields, BOUNDARY_CONDITION_NAME, "rz")?,
1187 },
1188 "acousticimpedance" => BoundaryConditionKind::AcousticImpedance {
1189 specific_impedance_pa_s_per_m: remove_required_f64(
1190 &mut fields,
1191 BOUNDARY_CONDITION_NAME,
1192 "specific_impedance_pa_s_per_m",
1193 )?,
1194 },
1195 "thermalprescribedtemperature" => BoundaryConditionKind::ThermalPrescribedTemperature {
1196 temperature_k: remove_required_f64(
1197 &mut fields,
1198 BOUNDARY_CONDITION_NAME,
1199 "temperature_k",
1200 )?,
1201 },
1202 "thermalheatflux" => BoundaryConditionKind::ThermalHeatFlux {
1203 heat_flux_w_per_m2: remove_required_f64(
1204 &mut fields,
1205 BOUNDARY_CONDITION_NAME,
1206 "heat_flux_w_per_m2",
1207 )?,
1208 },
1209 "thermalconvection" => BoundaryConditionKind::ThermalConvection {
1210 ambient_temperature_k: remove_required_f64(
1211 &mut fields,
1212 BOUNDARY_CONDITION_NAME,
1213 "ambient_temperature_k",
1214 )?,
1215 coefficient_w_per_m2k: remove_required_f64(
1216 &mut fields,
1217 BOUNDARY_CONDITION_NAME,
1218 "coefficient_w_per_m2k",
1219 )?,
1220 },
1221 _ => parse_scalar_enum::<BoundaryConditionKind>(&kind_text, "BoundaryConditionKind")?,
1222 };
1223 reject_unknown_fields(BOUNDARY_CONDITION_NAME, fields)?;
1224 boundary_condition_to_object(BoundaryCondition {
1225 bc_id,
1226 region_id,
1227 kind,
1228 })
1229}
1230
1231fn create_load_case_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1232 if args.len() < 3 {
1233 return Err(builtin_error(
1234 LOAD_CASE_NAME,
1235 &ERROR_INPUT,
1236 "fea.loadCase requires id, region, and kind arguments",
1237 ));
1238 }
1239 let load_id = scalar_string(&args[0], LOAD_CASE_NAME, &ERROR_INPUT)?;
1240 let region_id = scalar_string(&args[1], LOAD_CASE_NAME, &ERROR_INPUT)?;
1241 let kind_text = scalar_string(&args[2], LOAD_CASE_NAME, &ERROR_INPUT)?;
1242 let mut fields = json_fields_from_name_values(LOAD_CASE_NAME, &args[3..])?;
1243 let kind = match normalize_token(&kind_text).as_str() {
1244 "force" => {
1245 let [fx, fy, fz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
1246 LoadKind::Force { fx, fy, fz }
1247 }
1248 "moment" | "torque" => {
1249 let [mx, my, mz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
1250 LoadKind::Moment { mx, my, mz }
1251 }
1252 "pressure" => LoadKind::Pressure {
1253 magnitude_pa: remove_required_f64(&mut fields, LOAD_CASE_NAME, "magnitude_pa")?,
1254 },
1255 "bodyforce" => {
1256 let [gx, gy, gz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
1257 LoadKind::BodyForce { gx, gy, gz }
1258 }
1259 "currentdensity" => {
1260 let [jx, jy, jz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
1261 LoadKind::CurrentDensity {
1262 jx,
1263 jy,
1264 jz,
1265 phase_rad: remove_optional_f64(&mut fields, "phase_rad")?.unwrap_or_default(),
1266 amplitude_scale: remove_optional_f64(&mut fields, "amplitude_scale")?
1267 .unwrap_or(1.0),
1268 }
1269 }
1270 "coilcurrent" => LoadKind::CoilCurrent {
1271 current_a: remove_required_f64(&mut fields, LOAD_CASE_NAME, "current_a")?,
1272 phase_rad: remove_optional_f64(&mut fields, "phase_rad")?.unwrap_or_default(),
1273 amplitude_scale: remove_optional_f64(&mut fields, "amplitude_scale")?.unwrap_or(1.0),
1274 },
1275 "heatsource" => LoadKind::HeatSource {
1276 volumetric_w_per_m3: remove_required_f64(
1277 &mut fields,
1278 LOAD_CASE_NAME,
1279 "volumetric_w_per_m3",
1280 )?,
1281 },
1282 other => {
1283 return Err(builtin_error(
1284 LOAD_CASE_NAME,
1285 &ERROR_INPUT,
1286 format!("unsupported load kind `{other}`"),
1287 ));
1288 }
1289 };
1290 reject_unknown_fields(LOAD_CASE_NAME, fields)?;
1291 load_case_to_object(LoadCase {
1292 load_id,
1293 region_id,
1294 kind,
1295 })
1296}
1297
1298fn create_step_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1299 if args.len() < 2 {
1300 return Err(builtin_error(
1301 STEP_NAME,
1302 &ERROR_INPUT,
1303 "fea.step requires id and kind arguments",
1304 ));
1305 }
1306 let step_id = scalar_string(&args[0], STEP_NAME, &ERROR_INPUT)?;
1307 let kind_text = scalar_string(&args[1], STEP_NAME, &ERROR_INPUT)?;
1308 let kind = parse_scalar_enum::<AnalysisStepKind>(&kind_text, "AnalysisStepKind")?;
1309 step_to_object(AnalysisStep { step_id, kind })
1310}
1311
1312fn create_domain_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1313 if args.is_empty() {
1314 return Err(builtin_error(
1315 DOMAIN_NAME,
1316 &ERROR_INPUT,
1317 "fea.domain requires a domain kind",
1318 ));
1319 }
1320 let kind_text = scalar_string(&args[0], DOMAIN_NAME, &ERROR_INPUT)?;
1321 let kind = normalize_token(&kind_text);
1322 let fields = json_fields_from_name_values(DOMAIN_NAME, &args[1..])?;
1323 let payload = match kind.as_str() {
1324 "thermomechanical" => DomainPayload {
1325 kind: "thermo_mechanical".to_string(),
1326 data: json_with_overrides(
1327 DOMAIN_NAME,
1328 serde_json::json!({
1329 "enabled": true,
1330 "reference_temperature_k": 293.15,
1331 "applied_temperature_delta_k": 0.0,
1332 "field_artifact_id": null,
1333 "field_source": null,
1334 "region_temperature_deltas": [],
1335 "time_profile": []
1336 }),
1337 fields,
1338 "thermo_mechanical domain",
1339 )?,
1340 },
1341 "electrothermal" => DomainPayload {
1342 kind: "electro_thermal".to_string(),
1343 data: json_with_overrides(
1344 DOMAIN_NAME,
1345 serde_json::json!({
1346 "enabled": true,
1347 "reference_temperature_k": 293.15,
1348 "applied_voltage_v": 0.0,
1349 "region_conductivity_scales": [],
1350 "time_profile": []
1351 }),
1352 fields,
1353 "electro_thermal domain",
1354 )?,
1355 },
1356 "electromagnetic" => DomainPayload {
1357 kind: "electromagnetic".to_string(),
1358 data: json_with_overrides(
1359 DOMAIN_NAME,
1360 serde_json::json!({
1361 "enabled": true,
1362 "reference_frequency_hz": 0.0,
1363 "applied_current_a": 0.0
1364 }),
1365 fields,
1366 "electromagnetic domain",
1367 )?,
1368 },
1369 "cfd" => DomainPayload {
1370 kind: "cfd".to_string(),
1371 data: json_with_overrides(
1372 DOMAIN_NAME,
1373 serde_json::json!({
1374 "enabled": true,
1375 "solve_family": "steady_state",
1376 "reference_density_kg_per_m3": 1.225,
1377 "dynamic_viscosity_pa_s": 1.8e-5,
1378 "inlet_velocity_m_per_s": 0.0,
1379 "turbulence_intensity": 0.0,
1380 "time_profile": []
1381 }),
1382 fields,
1383 "cfd domain",
1384 )?,
1385 },
1386 other => {
1387 return Err(builtin_error(
1388 DOMAIN_NAME,
1389 &ERROR_INPUT,
1390 format!("unsupported FEA domain kind `{other}`"),
1391 ));
1392 }
1393 };
1394 domain_to_object(payload)
1395}
1396
1397fn create_interface_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1398 if args.len() < 3 {
1399 return Err(builtin_error(
1400 INTERFACE_NAME,
1401 &ERROR_INPUT,
1402 "fea.interface requires id, primary region, and secondary region arguments",
1403 ));
1404 }
1405 let interface_id = scalar_string(&args[0], INTERFACE_NAME, &ERROR_INPUT)?;
1406 let primary_region_id = scalar_string(&args[1], INTERFACE_NAME, &ERROR_INPUT)?;
1407 let secondary_region_id = scalar_string(&args[2], INTERFACE_NAME, &ERROR_INPUT)?;
1408 let mut kind = "contact".to_string();
1409 let mut fields = serde_json::Map::new();
1410 for pair in expect_name_value_tail(INTERFACE_NAME, &args[3..])? {
1411 if pair.key == "kind" {
1412 kind = scalar_string(pair.value, INTERFACE_NAME, &ERROR_INPUT)?;
1413 } else {
1414 fields.insert(
1415 canonical_field_name(&scalar_string(pair.name, INTERFACE_NAME, &ERROR_INPUT)?),
1416 value_to_json(INTERFACE_NAME, pair.value)?,
1417 );
1418 }
1419 }
1420 let kind = match normalize_token(&kind).as_str() {
1421 "contact" => AnalysisInterfaceKind::Contact(json_deserialize(
1422 INTERFACE_NAME,
1423 json_with_overrides(
1424 INTERFACE_NAME,
1425 serde_json::json!({
1426 "penalty_stiffness_scale": 1.0,
1427 "max_penetration_ratio": 0.0,
1428 "friction_coefficient": 0.0
1429 }),
1430 fields,
1431 "contact interface",
1432 )?,
1433 "contact interface",
1434 )?),
1435 "fluid_structure" | "fluidstructure" | "fsi" => {
1436 AnalysisInterfaceKind::FluidStructure(json_deserialize(
1437 INTERFACE_NAME,
1438 json_with_overrides(
1439 INTERFACE_NAME,
1440 serde_json::json!({
1441 "normal_stiffness_pa_per_m": 1.0e9,
1442 "damping_ratio": 0.0,
1443 "relaxation_factor": 0.5
1444 }),
1445 fields,
1446 "fluid-structure interface",
1447 )?,
1448 "fluid-structure interface",
1449 )?)
1450 }
1451 "conjugate_heat_transfer" | "conjugateheattransfer" | "cht" => {
1452 AnalysisInterfaceKind::ConjugateHeatTransfer(json_deserialize(
1453 INTERFACE_NAME,
1454 json_with_overrides(
1455 INTERFACE_NAME,
1456 serde_json::json!({
1457 "thermal_conductance_w_per_m2k": 500.0,
1458 "contact_resistance_m2k_per_w": 0.0,
1459 "relaxation_factor": 0.5
1460 }),
1461 fields,
1462 "conjugate heat-transfer interface",
1463 )?,
1464 "conjugate heat-transfer interface",
1465 )?)
1466 }
1467 other => {
1468 return Err(builtin_error(
1469 INTERFACE_NAME,
1470 &ERROR_INPUT,
1471 format!("unsupported interface kind `{other}`"),
1472 ));
1473 }
1474 };
1475 interface_to_object(AnalysisInterface {
1476 interface_id,
1477 primary_region_id,
1478 secondary_region_id,
1479 kind,
1480 })
1481}
1482
1483fn create_run_options_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1484 if args.is_empty() {
1485 return Err(builtin_error(
1486 RUN_OPTIONS_NAME,
1487 &ERROR_INPUT,
1488 "fea.runOptions requires a solver",
1489 ));
1490 }
1491 let kind_text = scalar_string(&args[0], RUN_OPTIONS_NAME, &ERROR_INPUT)?;
1492 let run_kind = parse_scalar_enum::<AnalysisRunKind>(&kind_text, "solver")?;
1493 let fields = json_fields_from_name_values(RUN_OPTIONS_NAME, &args[1..])?;
1494 let data = run_options_json_for_kind(RUN_OPTIONS_NAME, run_kind, fields)?;
1495 run_options_to_object(RunOptionsPayload {
1496 run_kind,
1497 options: data,
1498 })
1499}
1500
1501fn create_results_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1502 if args.is_empty() {
1503 return Err(builtin_error(
1504 RESULTS_NAME,
1505 &ERROR_INPUT,
1506 "fea.results requires a run id or fea.RunResult",
1507 ));
1508 }
1509 if let Value::Object(object) = &args[0] {
1510 if object.class_name == FEA_RESULTS_CLASS && args.len() == 1 {
1511 return Ok(args[0].clone());
1512 }
1513 }
1514 let run_id = run_id_from_value(RESULTS_NAME, &args[0])?;
1515 let query = results_query_from_args(&args[1..])?;
1516 let envelope = analysis_results_by_run_id_op(&run_id, query, OperationContext::new(None, None))
1517 .map_err(|err| operation_error(RESULTS_NAME, &ERROR_OPERATION, err))?;
1518 let mut object = serializable_to_object_value(
1519 RESULTS_NAME,
1520 &ERROR_INTERNAL,
1521 FEA_RESULTS_CLASS,
1522 &envelope.data,
1523 Some(FEA_PAYLOAD_JSON_PROPERTY),
1524 )?;
1525 object
1526 .properties
1527 .insert("run_id".to_string(), Value::String(run_id.clone()));
1528 object.properties.insert(
1529 FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
1530 Value::String(run_id),
1531 );
1532 copy_study_context_property(&args[0], &mut object);
1533 Ok(Value::Object(object))
1534}
1535
1536fn create_field_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1537 if args.len() < 2 {
1538 return Err(builtin_error(
1539 FIELD_NAME,
1540 &ERROR_INPUT,
1541 "fea.field requires results/run input and field id",
1542 ));
1543 }
1544 let field_id = scalar_string(&args[1], FIELD_NAME, &ERROR_INPUT)?;
1545 let results = results_data_from_value(FIELD_NAME, &args[0])?;
1546 let field = find_field(results.fields.into_iter(), &field_id).ok_or_else(|| {
1547 builtin_error(
1548 FIELD_NAME,
1549 &ERROR_INPUT,
1550 format!("FEA field `{field_id}` was not found in results"),
1551 )
1552 })?;
1553 let descriptor = find_descriptor(results.field_descriptors.iter(), &field_id)
1554 .cloned()
1555 .unwrap_or_else(|| AnalysisFieldDescriptor::from_field(&field));
1556 let mut object = field_to_object(&field, &descriptor)?;
1557 copy_study_context_property(&args[0], &mut object);
1558 copy_run_id_context_property(&args[0], &mut object);
1559 Ok(Value::Object(object))
1560}
1561
1562fn create_plot_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1563 #[cfg(feature = "plot-core")]
1564 {
1565 let request = plot_request_from_args(&args)?;
1566 let mut figures = generate_plot_figures(&request.study, &request.run_id)?;
1567 let figure = select_generated_figure(&mut figures, request.field_id.as_deref())?;
1568 let handle = import_generated_figure(figure)?;
1569 Ok(Value::Num(f64::from(handle)))
1570 }
1571 #[cfg(not(feature = "plot-core"))]
1572 {
1573 let _ = args;
1574 Err(builtin_error(
1575 PLOT_NAME,
1576 &ERROR_OPERATION,
1577 "fea.plot requires the plot-core runtime feature",
1578 ))
1579 }
1580}
1581
1582fn create_compare_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1583 if args.len() < 2 {
1584 return Err(builtin_error(
1585 COMPARE_NAME,
1586 &ERROR_INPUT,
1587 "fea.compare requires baseline and candidate run ids",
1588 ));
1589 }
1590 let baseline_run_id = scalar_string(&args[0], COMPARE_NAME, &ERROR_INPUT)?;
1591 let candidate_run_id = scalar_string(&args[1], COMPARE_NAME, &ERROR_INPUT)?;
1592 operation_result_to_object(
1593 COMPARE_NAME,
1594 &ERROR_OPERATION,
1595 &ERROR_INTERNAL,
1596 FEA_COMPARE_CLASS,
1597 analysis_results_compare_op(
1598 AnalysisResultsCompareQuery {
1599 baseline_run_id,
1600 candidate_run_id,
1601 },
1602 OperationContext::new(None, None),
1603 ),
1604 Some(FEA_PAYLOAD_JSON_PROPERTY),
1605 )
1606}
1607
1608fn create_trends_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1609 let mut window_size = AnalysisTrendsQuery::default().window_size;
1610 for pair in expect_name_value_tail(TRENDS_NAME, args.as_slice())? {
1611 match pair.key.as_str() {
1612 "windowsize" => window_size = usize_from_value(TRENDS_NAME, pair.value)?,
1613 other => {
1614 return Err(builtin_error(
1615 TRENDS_NAME,
1616 &ERROR_INPUT,
1617 format!("unsupported fea.trends option `{other}`"),
1618 ));
1619 }
1620 }
1621 }
1622 operation_result_to_object(
1623 TRENDS_NAME,
1624 &ERROR_OPERATION,
1625 &ERROR_INTERNAL,
1626 FEA_TRENDS_CLASS,
1627 analysis_trends_op(
1628 AnalysisTrendsQuery { window_size },
1629 OperationContext::new(None, None),
1630 ),
1631 Some(FEA_PAYLOAD_JSON_PROPERTY),
1632 )
1633}
1634
1635fn build_model_from_parts(
1636 builtin: &'static str,
1637 geometry: &GeometryAsset,
1638 model_id: String,
1639 profile: AnalysisCreateModelProfile,
1640 defaults: ModelDefaultsMode,
1641 frame: Option<ReferenceFrame>,
1642 materials: Vec<MaterialModel>,
1643 material_assignments: Vec<MaterialAssignment>,
1644 boundary_conditions: Vec<BoundaryCondition>,
1645 loads: Vec<LoadCase>,
1646 steps: Vec<AnalysisStep>,
1647 domains: Vec<DomainPayload>,
1648 interfaces: Vec<AnalysisInterface>,
1649) -> BuiltinResult<AnalysisModel> {
1650 let mut model = match defaults {
1651 ModelDefaultsMode::ProfileScaffold => analysis_create_model_op(
1652 geometry,
1653 AnalysisCreateModelIntentSpec {
1654 model_id: model_id.clone(),
1655 profile,
1656 prep_context: None,
1657 },
1658 OperationContext::new(None, None),
1659 )
1660 .map(|envelope| envelope.data)
1661 .map_err(|err| operation_error(builtin, &ERROR_OPERATION, err))?,
1662 ModelDefaultsMode::None => empty_model(model_id, geometry),
1663 };
1664
1665 if let Some(frame) = frame {
1666 model.frame = frame;
1667 }
1668 if !materials.is_empty() {
1669 model.materials = materials;
1670 }
1671 if !material_assignments.is_empty() {
1672 model.material_assignments = material_assignments
1673 .into_iter()
1674 .map(|mut assignment| {
1675 assignment.region_id = resolve_region_selector(&assignment.region_id, geometry)?;
1676 Ok(assignment)
1677 })
1678 .collect::<BuiltinResult<Vec<_>>>()?;
1679 }
1680 if !boundary_conditions.is_empty() {
1681 model.boundary_conditions = boundary_conditions
1682 .into_iter()
1683 .map(|mut bc| {
1684 bc.region_id = resolve_region_selector(&bc.region_id, geometry)?;
1685 Ok(bc)
1686 })
1687 .collect::<BuiltinResult<Vec<_>>>()?;
1688 }
1689 if !loads.is_empty() {
1690 model.loads = loads
1691 .into_iter()
1692 .map(|mut load| {
1693 load.region_id = resolve_region_selector(&load.region_id, geometry)?;
1694 Ok(load)
1695 })
1696 .collect::<BuiltinResult<Vec<_>>>()?;
1697 }
1698 if !steps.is_empty() {
1699 model.steps = steps;
1700 }
1701 for domain in domains {
1702 match domain.kind.as_str() {
1703 "thermo_mechanical" => {
1704 model.thermo_mechanical = Some(json_deserialize(
1705 builtin,
1706 domain.data,
1707 "thermo_mechanical domain",
1708 )?);
1709 }
1710 "electro_thermal" => {
1711 model.electro_thermal = Some(json_deserialize(
1712 builtin,
1713 domain.data,
1714 "electro_thermal domain",
1715 )?);
1716 }
1717 "electromagnetic" => {
1718 model.electromagnetic = Some(json_deserialize(
1719 builtin,
1720 domain.data,
1721 "electromagnetic domain",
1722 )?);
1723 }
1724 "cfd" => {
1725 model.cfd = Some(json_deserialize(builtin, domain.data, "cfd domain")?);
1726 }
1727 other => {
1728 return Err(builtin_error(
1729 builtin,
1730 &ERROR_INPUT,
1731 format!("unsupported domain payload `{other}`"),
1732 ));
1733 }
1734 }
1735 }
1736 if !interfaces.is_empty() {
1737 model.interfaces = interfaces
1738 .into_iter()
1739 .map(|mut interface| {
1740 interface.primary_region_id =
1741 resolve_region_selector(&interface.primary_region_id, geometry)?;
1742 interface.secondary_region_id =
1743 resolve_region_selector(&interface.secondary_region_id, geometry)?;
1744 Ok(interface)
1745 })
1746 .collect::<BuiltinResult<Vec<_>>>()?;
1747 }
1748 Ok(model)
1749}
1750
1751fn empty_model(model_id: String, geometry: &GeometryAsset) -> AnalysisModel {
1752 AnalysisModel {
1753 model_id: AnalysisModelId(model_id),
1754 geometry_id: geometry.geometry_id.clone(),
1755 geometry_revision: geometry.revision,
1756 units: geometry.units,
1757 frame: ReferenceFrame::Global,
1758 materials: Vec::new(),
1759 material_assignments: Vec::new(),
1760 structural: None,
1761 thermo_mechanical: None,
1762 electro_thermal: None,
1763 electromagnetic: None,
1764 cfd: None,
1765 interfaces: Vec::new(),
1766 boundary_conditions: Vec::new(),
1767 loads: Vec::new(),
1768 steps: Vec::new(),
1769 }
1770}
1771
1772fn resolve_region_selector(selector: &str, geometry: &GeometryAsset) -> BuiltinResult<String> {
1773 if let Some(id) = selector
1774 .strip_prefix("id:")
1775 .or_else(|| selector.strip_prefix("region:"))
1776 {
1777 return require_region_id(id, geometry);
1778 }
1779 if let Some(tag) = selector.strip_prefix("tag:") {
1780 return geometry
1781 .regions
1782 .iter()
1783 .find(|region| region.tag.as_deref() == Some(tag))
1784 .map(|region| region.region_id.clone())
1785 .ok_or_else(|| {
1786 builtin_error(
1787 MODEL_NAME,
1788 &ERROR_INPUT,
1789 format!("region tag `{tag}` was not found in geometry"),
1790 )
1791 });
1792 }
1793 if let Some(name) = selector.strip_prefix("name:") {
1794 return geometry
1795 .regions
1796 .iter()
1797 .find(|region| region.name == name)
1798 .map(|region| region.region_id.clone())
1799 .ok_or_else(|| {
1800 builtin_error(
1801 MODEL_NAME,
1802 &ERROR_INPUT,
1803 format!("region name `{name}` was not found in geometry"),
1804 )
1805 });
1806 }
1807 require_region_id(selector, geometry)
1808}
1809
1810fn require_region_id(region_id: &str, geometry: &GeometryAsset) -> BuiltinResult<String> {
1811 geometry
1812 .regions
1813 .iter()
1814 .find(|region| region.region_id == region_id)
1815 .map(|region| region.region_id.clone())
1816 .ok_or_else(|| {
1817 builtin_error(
1818 MODEL_NAME,
1819 &ERROR_INPUT,
1820 format!("region id `{region_id}` was not found in geometry"),
1821 )
1822 })
1823}
1824
1825fn material_to_object(material: MaterialModel) -> BuiltinResult<Value> {
1826 serializable_to_object(
1827 MATERIAL_NAME,
1828 &ERROR_INTERNAL,
1829 FEA_MATERIAL_CLASS,
1830 &material,
1831 Some(FEA_PAYLOAD_JSON_PROPERTY),
1832 )
1833}
1834
1835fn material_assignment_to_object(assignment: MaterialAssignment) -> BuiltinResult<Value> {
1836 serializable_to_object(
1837 MATERIAL_ASSIGNMENT_NAME,
1838 &ERROR_INTERNAL,
1839 FEA_MATERIAL_ASSIGNMENT_CLASS,
1840 &assignment,
1841 Some(FEA_PAYLOAD_JSON_PROPERTY),
1842 )
1843}
1844
1845fn boundary_condition_to_object(bc: BoundaryCondition) -> BuiltinResult<Value> {
1846 serializable_to_object(
1847 BOUNDARY_CONDITION_NAME,
1848 &ERROR_INTERNAL,
1849 FEA_BOUNDARY_CONDITION_CLASS,
1850 &bc,
1851 Some(FEA_PAYLOAD_JSON_PROPERTY),
1852 )
1853}
1854
1855fn load_case_to_object(load: LoadCase) -> BuiltinResult<Value> {
1856 serializable_to_object(
1857 LOAD_CASE_NAME,
1858 &ERROR_INTERNAL,
1859 FEA_LOAD_CASE_CLASS,
1860 &load,
1861 Some(FEA_PAYLOAD_JSON_PROPERTY),
1862 )
1863}
1864
1865fn step_to_object(step: AnalysisStep) -> BuiltinResult<Value> {
1866 serializable_to_object(
1867 STEP_NAME,
1868 &ERROR_INTERNAL,
1869 FEA_STEP_CLASS,
1870 &step,
1871 Some(FEA_PAYLOAD_JSON_PROPERTY),
1872 )
1873}
1874
1875fn domain_to_object(domain: DomainPayload) -> BuiltinResult<Value> {
1876 serializable_to_object(
1877 DOMAIN_NAME,
1878 &ERROR_INTERNAL,
1879 FEA_DOMAIN_CLASS,
1880 &domain,
1881 Some(FEA_PAYLOAD_JSON_PROPERTY),
1882 )
1883}
1884
1885fn interface_to_object(interface: AnalysisInterface) -> BuiltinResult<Value> {
1886 serializable_to_object(
1887 INTERFACE_NAME,
1888 &ERROR_INTERNAL,
1889 FEA_INTERFACE_CLASS,
1890 &interface,
1891 Some(FEA_PAYLOAD_JSON_PROPERTY),
1892 )
1893}
1894
1895fn run_options_to_object(payload: RunOptionsPayload) -> BuiltinResult<Value> {
1896 serializable_to_object(
1897 RUN_OPTIONS_NAME,
1898 &ERROR_INTERNAL,
1899 FEA_RUN_OPTIONS_CLASS,
1900 &payload,
1901 Some(FEA_PAYLOAD_JSON_PROPERTY),
1902 )
1903}
1904
1905fn model_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<AnalysisModel> {
1906 object_payload(builtin, value, FEA_MODEL_CLASS)
1907}
1908
1909fn study_vec_from_value(
1910 builtin: &'static str,
1911 value: &Value,
1912) -> BuiltinResult<Vec<AnalysisStudySpec>> {
1913 object_vec_from_value_with_property(
1914 builtin,
1915 value,
1916 FEA_STUDY_CLASS,
1917 FEA_STUDY_SPEC_JSON_PROPERTY,
1918 )
1919}
1920
1921fn material_vec_from_value(
1922 builtin: &'static str,
1923 value: &Value,
1924) -> BuiltinResult<Vec<MaterialModel>> {
1925 object_vec_from_value(builtin, value, FEA_MATERIAL_CLASS)
1926}
1927
1928fn material_assignment_vec_from_value(
1929 builtin: &'static str,
1930 value: &Value,
1931) -> BuiltinResult<Vec<MaterialAssignment>> {
1932 object_vec_from_value(builtin, value, FEA_MATERIAL_ASSIGNMENT_CLASS)
1933}
1934
1935fn boundary_condition_vec_from_value(
1936 builtin: &'static str,
1937 value: &Value,
1938) -> BuiltinResult<Vec<BoundaryCondition>> {
1939 object_vec_from_value(builtin, value, FEA_BOUNDARY_CONDITION_CLASS)
1940}
1941
1942fn load_case_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<LoadCase>> {
1943 object_vec_from_value(builtin, value, FEA_LOAD_CASE_CLASS)
1944}
1945
1946fn step_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<AnalysisStep>> {
1947 object_vec_from_value(builtin, value, FEA_STEP_CLASS)
1948}
1949
1950fn domain_vec_from_value(
1951 builtin: &'static str,
1952 value: &Value,
1953) -> BuiltinResult<Vec<DomainPayload>> {
1954 object_vec_from_value(builtin, value, FEA_DOMAIN_CLASS)
1955}
1956
1957fn interface_vec_from_value(
1958 builtin: &'static str,
1959 value: &Value,
1960) -> BuiltinResult<Vec<AnalysisInterface>> {
1961 object_vec_from_value(builtin, value, FEA_INTERFACE_CLASS)
1962}
1963
1964fn object_vec_from_value<T: DeserializeOwned>(
1965 builtin: &'static str,
1966 value: &Value,
1967 expected_class: &'static str,
1968) -> BuiltinResult<Vec<T>> {
1969 object_vec_from_value_with_property(builtin, value, expected_class, FEA_PAYLOAD_JSON_PROPERTY)
1970}
1971
1972fn object_vec_from_value_with_property<T: DeserializeOwned>(
1973 builtin: &'static str,
1974 value: &Value,
1975 expected_class: &'static str,
1976 payload_property: &'static str,
1977) -> BuiltinResult<Vec<T>> {
1978 match value {
1979 Value::Cell(cell) => cell
1980 .data
1981 .iter()
1982 .map(|item| {
1983 object_payload_with_property(builtin, item, expected_class, payload_property)
1984 })
1985 .collect(),
1986 Value::Object(_) => Ok(vec![object_payload_with_property(
1987 builtin,
1988 value,
1989 expected_class,
1990 payload_property,
1991 )?]),
1992 other => Err(builtin_error(
1993 builtin,
1994 &ERROR_INPUT,
1995 format!("expected {expected_class} object or cell array; got {other:?}"),
1996 )),
1997 }
1998}
1999
2000fn object_payload<T: DeserializeOwned>(
2001 builtin: &'static str,
2002 value: &Value,
2003 expected_class: &'static str,
2004) -> BuiltinResult<T> {
2005 object_payload_with_property(builtin, value, expected_class, FEA_PAYLOAD_JSON_PROPERTY)
2006}
2007
2008fn object_payload_with_property<T: DeserializeOwned>(
2009 builtin: &'static str,
2010 value: &Value,
2011 expected_class: &'static str,
2012 payload_property: &'static str,
2013) -> BuiltinResult<T> {
2014 let Value::Object(object) = value else {
2015 return Err(builtin_error(
2016 builtin,
2017 &ERROR_INPUT,
2018 format!("expected {expected_class} object"),
2019 ));
2020 };
2021 if object.class_name != expected_class {
2022 return Err(builtin_error(
2023 builtin,
2024 &ERROR_INPUT,
2025 format!("expected {expected_class}, got {}", object.class_name),
2026 ));
2027 }
2028 object_json_property(builtin, object, payload_property, &ERROR_INPUT)
2029}
2030
2031fn run_options_payload_from_value(
2032 builtin: &'static str,
2033 value: &Value,
2034) -> BuiltinResult<RunOptionsPayload> {
2035 object_payload(builtin, value, FEA_RUN_OPTIONS_CLASS)
2036}
2037
2038fn resolved_run_options_from_payload(
2039 builtin: &'static str,
2040 payload: RunOptionsPayload,
2041 expected_kind: AnalysisRunKind,
2042) -> BuiltinResult<ResolvedRunOptions> {
2043 if payload.run_kind != expected_kind {
2044 return Err(builtin_error(
2045 builtin,
2046 &ERROR_INPUT,
2047 format!(
2048 "run options kind {:?} does not match selected study solver {:?}",
2049 payload.run_kind, expected_kind
2050 ),
2051 ));
2052 }
2053 let mut resolved = ResolvedRunOptions::default();
2054 match payload.run_kind {
2055 AnalysisRunKind::LinearStatic => {
2056 resolved.linear_static = Some(json_deserialize(
2057 builtin,
2058 payload.options,
2059 "linear_static run options",
2060 )?);
2061 }
2062 AnalysisRunKind::Modal => {
2063 resolved.modal = Some(json_deserialize(
2064 builtin,
2065 payload.options,
2066 "modal run options",
2067 )?);
2068 }
2069 AnalysisRunKind::Acoustic => {
2070 resolved.acoustic = Some(json_deserialize(
2071 builtin,
2072 payload.options,
2073 "acoustic run options",
2074 )?);
2075 }
2076 AnalysisRunKind::Thermal => {
2077 resolved.thermal = Some(json_deserialize(
2078 builtin,
2079 payload.options,
2080 "thermal run options",
2081 )?);
2082 }
2083 AnalysisRunKind::Transient => {
2084 resolved.transient = Some(json_deserialize(
2085 builtin,
2086 payload.options,
2087 "transient run options",
2088 )?);
2089 }
2090 AnalysisRunKind::Cfd => {
2091 resolved.cfd = Some(json_deserialize(
2092 builtin,
2093 payload.options,
2094 "cfd run options",
2095 )?);
2096 }
2097 AnalysisRunKind::Cht => {
2098 resolved.cht = Some(json_deserialize(
2099 builtin,
2100 payload.options,
2101 "cht run options",
2102 )?);
2103 }
2104 AnalysisRunKind::Fsi => {
2105 resolved.fsi = Some(json_deserialize(
2106 builtin,
2107 payload.options,
2108 "fsi run options",
2109 )?);
2110 }
2111 AnalysisRunKind::Nonlinear => {
2112 resolved.nonlinear = Some(json_deserialize(
2113 builtin,
2114 payload.options,
2115 "nonlinear run options",
2116 )?);
2117 }
2118 AnalysisRunKind::Electromagnetic => {
2119 resolved.electromagnetic = Some(json_deserialize(
2120 builtin,
2121 payload.options,
2122 "electromagnetic run options",
2123 )?);
2124 }
2125 }
2126 Ok(resolved)
2127}
2128
2129fn run_options_json_for_kind(
2130 builtin: &'static str,
2131 run_kind: AnalysisRunKind,
2132 fields: serde_json::Map<String, serde_json::Value>,
2133) -> BuiltinResult<serde_json::Value> {
2134 match run_kind {
2135 AnalysisRunKind::LinearStatic => typed_json_with_overrides::<AnalysisRunOptions>(
2136 builtin,
2137 AnalysisRunOptions::default(),
2138 fields,
2139 "linear_static run options",
2140 ),
2141 AnalysisRunKind::Modal => typed_json_with_overrides::<AnalysisModalRunOptions>(
2142 builtin,
2143 AnalysisModalRunOptions::default(),
2144 fields,
2145 "modal run options",
2146 ),
2147 AnalysisRunKind::Acoustic => typed_json_with_overrides::<AnalysisAcousticRunOptions>(
2148 builtin,
2149 AnalysisAcousticRunOptions::default(),
2150 fields,
2151 "acoustic run options",
2152 ),
2153 AnalysisRunKind::Thermal => typed_json_with_overrides::<AnalysisThermalRunOptions>(
2154 builtin,
2155 AnalysisThermalRunOptions::default(),
2156 fields,
2157 "thermal run options",
2158 ),
2159 AnalysisRunKind::Transient => typed_json_with_overrides::<AnalysisTransientRunOptions>(
2160 builtin,
2161 AnalysisTransientRunOptions::default(),
2162 fields,
2163 "transient run options",
2164 ),
2165 AnalysisRunKind::Cfd => typed_json_with_overrides::<AnalysisCfdRunOptions>(
2166 builtin,
2167 AnalysisCfdRunOptions::default(),
2168 fields,
2169 "cfd run options",
2170 ),
2171 AnalysisRunKind::Cht => typed_json_with_overrides::<AnalysisChtRunOptions>(
2172 builtin,
2173 AnalysisChtRunOptions::default(),
2174 fields,
2175 "cht run options",
2176 ),
2177 AnalysisRunKind::Fsi => typed_json_with_overrides::<AnalysisFsiRunOptions>(
2178 builtin,
2179 AnalysisFsiRunOptions::default(),
2180 fields,
2181 "fsi run options",
2182 ),
2183 AnalysisRunKind::Nonlinear => typed_json_with_overrides::<AnalysisNonlinearRunOptions>(
2184 builtin,
2185 AnalysisNonlinearRunOptions::default(),
2186 fields,
2187 "nonlinear run options",
2188 ),
2189 AnalysisRunKind::Electromagnetic => {
2190 typed_json_with_overrides::<AnalysisElectromagneticRunOptions>(
2191 builtin,
2192 AnalysisElectromagneticRunOptions::default(),
2193 fields,
2194 "electromagnetic run options",
2195 )
2196 }
2197 }
2198}
2199
2200fn results_query_from_args(args: &[Value]) -> BuiltinResult<AnalysisResultsQuery> {
2201 let mut query = AnalysisResultsQuery::default();
2202 for pair in expect_name_value_tail(RESULTS_NAME, args)? {
2203 match pair.key.as_str() {
2204 "includefields" | "fields" => {
2205 query.include_fields = string_vec_from_value(RESULTS_NAME, pair.value)?;
2206 }
2207 "includefieldvalues" | "fieldvalues" => {
2208 query.include_field_values = bool_from_value(RESULTS_NAME, pair.value)?;
2209 }
2210 "includediagnostics" => {
2211 query.include_diagnostics = bool_from_value(RESULTS_NAME, pair.value)?;
2212 }
2213 "diagnosticcodes" => {
2214 query.diagnostic_codes = string_vec_from_value(RESULTS_NAME, pair.value)?;
2215 }
2216 "includemodalresults" => {
2217 query.include_modal_results = bool_from_value(RESULTS_NAME, pair.value)?;
2218 }
2219 "modeindices" => {
2220 query.mode_indices = usize_vec_from_value(RESULTS_NAME, pair.value)?;
2221 }
2222 "includetransientresults" => {
2223 query.include_transient_results = bool_from_value(RESULTS_NAME, pair.value)?;
2224 }
2225 "transientsnapshotindices" => {
2226 query.transient_snapshot_indices = usize_vec_from_value(RESULTS_NAME, pair.value)?;
2227 }
2228 "includenonlinearresults" => {
2229 query.include_nonlinear_results = bool_from_value(RESULTS_NAME, pair.value)?;
2230 }
2231 "includeelectromagneticresults" => {
2232 query.include_electromagnetic_results = bool_from_value(RESULTS_NAME, pair.value)?;
2233 }
2234 other => {
2235 return Err(builtin_error(
2236 RESULTS_NAME,
2237 &ERROR_INPUT,
2238 format!("unsupported fea.results option `{other}`"),
2239 ));
2240 }
2241 }
2242 }
2243 Ok(query)
2244}
2245
2246fn run_id_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<String> {
2247 match value {
2248 Value::Object(object) if object.class_name == FEA_RUN_RESULT_CLASS => {
2249 run_id_from_object(object).ok_or_else(|| {
2250 builtin_error(
2251 builtin,
2252 &ERROR_INPUT,
2253 "fea.RunResult does not contain a run_id; sweep results expose run_entries",
2254 )
2255 })
2256 }
2257 Value::String(_) | Value::CharArray(_) | Value::StringArray(_) => {
2258 scalar_string(value, builtin, &ERROR_INPUT)
2259 }
2260 other => Err(builtin_error(
2261 builtin,
2262 &ERROR_INPUT,
2263 format!("expected run id string or fea.RunResult; got {other:?}"),
2264 )),
2265 }
2266}
2267
2268fn run_id_from_object(object: &ObjectInstance) -> Option<String> {
2269 object
2270 .properties
2271 .get(FEA_RUN_ID_CONTEXT_PROPERTY)
2272 .or_else(|| object.properties.get("run_id"))
2273 .or_else(|| object.properties.get("runId"))
2274 .and_then(|value| match value {
2275 Value::String(run_id) => Some(run_id.clone()),
2276 _ => None,
2277 })
2278}
2279
2280fn results_data_from_value(
2281 builtin: &'static str,
2282 value: &Value,
2283) -> BuiltinResult<crate::analysis::AnalysisResultsData> {
2284 match value {
2285 Value::Object(object) if object.class_name == FEA_RESULTS_CLASS => {
2286 object_json_property(builtin, object, FEA_PAYLOAD_JSON_PROPERTY, &ERROR_INPUT)
2287 }
2288 _ => {
2289 let run_id = run_id_from_value(builtin, value)?;
2290 analysis_results_by_run_id_op(
2291 &run_id,
2292 AnalysisResultsQuery::default(),
2293 OperationContext::new(None, None),
2294 )
2295 .map(|envelope| envelope.data)
2296 .map_err(|err| operation_error(builtin, &ERROR_OPERATION, err))
2297 }
2298 }
2299}
2300
2301fn run_study_result_to_object(spec: &AnalysisStudySpec) -> BuiltinResult<Value> {
2302 let envelope = analysis_run_study_op(spec, OperationContext::new(None, None))
2303 .map_err(|err| operation_error(RUN_NAME, &ERROR_OPERATION, err))?;
2304 let mut object = serializable_to_object_value(
2305 RUN_NAME,
2306 &ERROR_INTERNAL,
2307 FEA_RUN_RESULT_CLASS,
2308 &envelope.data,
2309 Some(FEA_PAYLOAD_JSON_PROPERTY),
2310 )?;
2311 object.properties.insert(
2312 FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
2313 Value::String(envelope.data.run_id.clone()),
2314 );
2315 object.properties.insert(
2316 "run_id".to_string(),
2317 Value::String(envelope.data.run_id.clone()),
2318 );
2319 object.properties.insert(
2320 "runId".to_string(),
2321 Value::String(envelope.data.run_id.clone()),
2322 );
2323 insert_study_context(&mut object, spec)?;
2324 Ok(Value::Object(object))
2325}
2326
2327fn insert_study_context(
2328 object: &mut ObjectInstance,
2329 spec: &AnalysisStudySpec,
2330) -> BuiltinResult<()> {
2331 let json = serde_json::to_string(spec).map_err(|err| {
2332 builtin_error_with_source(RUN_NAME, &ERROR_INTERNAL, err.to_string(), err)
2333 })?;
2334 object.properties.insert(
2335 FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
2336 Value::String(json),
2337 );
2338 Ok(())
2339}
2340
2341fn copy_study_context_property(source: &Value, target: &mut ObjectInstance) {
2342 if let Some(json) = study_context_json_from_value(source) {
2343 target.properties.insert(
2344 FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
2345 Value::String(json),
2346 );
2347 }
2348}
2349
2350fn copy_run_id_context_property(source: &Value, target: &mut ObjectInstance) {
2351 if let Some(run_id) = run_id_context_from_value(source) {
2352 target.properties.insert(
2353 FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
2354 Value::String(run_id.clone()),
2355 );
2356 target
2357 .properties
2358 .entry("run_id".to_string())
2359 .or_insert(Value::String(run_id.clone()));
2360 target
2361 .properties
2362 .entry("runId".to_string())
2363 .or_insert(Value::String(run_id));
2364 }
2365}
2366
2367fn study_context_json_from_value(value: &Value) -> Option<String> {
2368 let Value::Object(object) = value else {
2369 return None;
2370 };
2371 if object.class_name == FEA_STUDY_CLASS {
2372 if let Some(Value::String(json)) = object.properties.get(FEA_STUDY_SPEC_JSON_PROPERTY) {
2373 return Some(json.clone());
2374 }
2375 }
2376 object
2377 .properties
2378 .get(FEA_STUDY_CONTEXT_JSON_PROPERTY)
2379 .and_then(|value| match value {
2380 Value::String(json) => Some(json.clone()),
2381 _ => None,
2382 })
2383}
2384
2385fn study_context_from_value(
2386 builtin: &'static str,
2387 value: &Value,
2388) -> BuiltinResult<AnalysisStudySpec> {
2389 let Some(json) = study_context_json_from_value(value) else {
2390 return Err(builtin_error(
2391 builtin,
2392 &ERROR_INPUT,
2393 format!("{builtin}: FEA plot requires study geometry context; pass a fea.RunResult from fea.run(study), a derived fea.Results/fea.Field, or call fea.plot(study, runId, fieldId)"),
2394 ));
2395 };
2396 serde_json::from_str(&json)
2397 .map_err(|err| builtin_error_with_source(builtin, &ERROR_INPUT, err.to_string(), err))
2398}
2399
2400fn run_id_context_from_value(value: &Value) -> Option<String> {
2401 let Value::Object(object) = value else {
2402 return None;
2403 };
2404 run_id_from_object(object)
2405}
2406
2407fn field_to_object(
2408 field: &AnalysisField,
2409 descriptor: &AnalysisFieldDescriptor,
2410) -> BuiltinResult<ObjectInstance> {
2411 ensure_fea_classes_registered();
2412 let mut object = ObjectInstance::new(FEA_FIELD_CLASS.to_string());
2413 object.properties.insert(
2414 "field_id".to_string(),
2415 Value::String(field.field_id.clone()),
2416 );
2417 object
2418 .properties
2419 .insert("id".to_string(), Value::String(field.field_id.clone()));
2420 object.properties.insert(
2421 "shape".to_string(),
2422 usize_slice_tensor(&field.shape, 1, field.shape.len())?,
2423 );
2424 object
2425 .properties
2426 .insert("values".to_string(), field_values_value(field)?);
2427 object.properties.insert(
2428 "unit".to_string(),
2429 Value::String(descriptor.unit.clone().unwrap_or_default()),
2430 );
2431 object.properties.insert(
2432 "location".to_string(),
2433 Value::String(format!("{:?}", descriptor.location).to_ascii_lowercase()),
2434 );
2435 object.properties.insert(
2436 "kind".to_string(),
2437 Value::String(format!("{:?}", descriptor.kind).to_ascii_lowercase()),
2438 );
2439 object.properties.insert(
2440 "family".to_string(),
2441 Value::String(descriptor.family.clone()),
2442 );
2443 object.properties.insert(
2444 "quantity".to_string(),
2445 Value::String(descriptor.quantity.clone()),
2446 );
2447 object.properties.insert(
2448 "component_count".to_string(),
2449 descriptor
2450 .component_count
2451 .map(|value| Value::Num(value as f64))
2452 .unwrap_or_else(empty_double_value),
2453 );
2454 object.properties.insert(
2455 "element_count".to_string(),
2456 Value::Num(descriptor.element_count as f64),
2457 );
2458 object.properties.insert(
2459 "storage".to_string(),
2460 Value::String(format!("{:?}", descriptor.storage).to_ascii_lowercase()),
2461 );
2462 object.properties.insert(
2463 "descriptor".to_string(),
2464 serializable_to_value(FIELD_NAME, &ERROR_INTERNAL, descriptor)?,
2465 );
2466 let json = serde_json::to_string(field).map_err(|err| {
2467 builtin_error_with_source(FIELD_NAME, &ERROR_INTERNAL, err.to_string(), err)
2468 })?;
2469 object
2470 .properties
2471 .insert(FEA_PAYLOAD_JSON_PROPERTY.to_string(), Value::String(json));
2472 Ok(object)
2473}
2474
2475fn field_values_value(field: &AnalysisField) -> BuiltinResult<Value> {
2476 match &field.values {
2477 AnalysisFieldValues::HostF64(values) => Tensor::new(values.clone(), field.shape.clone())
2478 .map(Value::Tensor)
2479 .map_err(|err| {
2480 builtin_error(
2481 FIELD_NAME,
2482 &ERROR_INTERNAL,
2483 format!("fea.field: failed to build values tensor: {err}"),
2484 )
2485 }),
2486 AnalysisFieldValues::DeviceRef(device) => {
2487 serializable_to_value(FIELD_NAME, &ERROR_INTERNAL, device)
2488 }
2489 }
2490}
2491
2492fn usize_slice_tensor(values: &[usize], rows: usize, cols: usize) -> BuiltinResult<Value> {
2493 Tensor::new_2d(
2494 values.iter().map(|value| *value as f64).collect(),
2495 rows,
2496 cols,
2497 )
2498 .map(Value::Tensor)
2499 .map_err(|err| {
2500 builtin_error(
2501 FIELD_NAME,
2502 &ERROR_INTERNAL,
2503 format!("fea.field: failed to build metadata tensor: {err}"),
2504 )
2505 })
2506}
2507
2508fn empty_double_value() -> Value {
2509 Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor shape is valid"))
2510}
2511
2512fn find_field<I>(fields: I, requested: &str) -> Option<AnalysisField>
2513where
2514 I: IntoIterator<Item = AnalysisField>,
2515{
2516 let mut suffix_matches = Vec::new();
2517 for field in fields {
2518 if field.field_id == requested {
2519 return Some(field);
2520 }
2521 if field_id_matches(&field.field_id, requested) {
2522 suffix_matches.push(field);
2523 }
2524 }
2525 if suffix_matches.len() == 1 {
2526 suffix_matches.pop()
2527 } else {
2528 None
2529 }
2530}
2531
2532fn find_descriptor<'a, I>(descriptors: I, requested: &str) -> Option<&'a AnalysisFieldDescriptor>
2533where
2534 I: IntoIterator<Item = &'a AnalysisFieldDescriptor>,
2535{
2536 let mut suffix_matches = Vec::new();
2537 for descriptor in descriptors {
2538 if descriptor.field_id == requested {
2539 return Some(descriptor);
2540 }
2541 if field_id_matches(&descriptor.field_id, requested) {
2542 suffix_matches.push(descriptor);
2543 }
2544 }
2545 if suffix_matches.len() == 1 {
2546 suffix_matches.pop()
2547 } else {
2548 None
2549 }
2550}
2551
2552fn field_id_matches(candidate: &str, requested: &str) -> bool {
2553 candidate == requested
2554 || candidate
2555 .strip_suffix(requested)
2556 .is_some_and(|prefix| prefix.ends_with('.'))
2557 || candidate
2558 .rsplit_once('.')
2559 .is_some_and(|(_, tail)| tail == requested)
2560}
2561
2562struct FeaPlotRequest {
2563 study: AnalysisStudySpec,
2564 run_id: String,
2565 field_id: Option<String>,
2566}
2567
2568fn plot_request_from_args(args: &[Value]) -> BuiltinResult<FeaPlotRequest> {
2569 if args.is_empty() {
2570 return Err(builtin_error(
2571 PLOT_NAME,
2572 &ERROR_INPUT,
2573 "fea.plot requires a run, results, field, or study/run pair",
2574 ));
2575 }
2576
2577 let (core, field_from_name_value) = split_plot_field_name_value(args)?;
2578 match core {
2579 [single] => plot_request_from_context_value(single, field_from_name_value),
2580 [first, second] if is_fea_study(first) => {
2581 let study = study_context_from_value(PLOT_NAME, first)?;
2582 let run_id = run_id_from_value(PLOT_NAME, second)?;
2583 Ok(FeaPlotRequest {
2584 study,
2585 run_id,
2586 field_id: field_from_name_value,
2587 })
2588 }
2589 [first, second] => {
2590 let mut request = plot_request_from_context_value(first, None)?;
2591 request.field_id = Some(scalar_string(second, PLOT_NAME, &ERROR_INPUT)?);
2592 if field_from_name_value.is_some() {
2593 request.field_id = field_from_name_value;
2594 }
2595 Ok(request)
2596 }
2597 [first, second, third] if is_fea_study(first) => {
2598 let study = study_context_from_value(PLOT_NAME, first)?;
2599 let run_id = run_id_from_value(PLOT_NAME, second)?;
2600 let field_id = match field_from_name_value {
2601 Some(field_id) => Some(field_id),
2602 None => Some(scalar_string(third, PLOT_NAME, &ERROR_INPUT)?),
2603 };
2604 Ok(FeaPlotRequest {
2605 study,
2606 run_id,
2607 field_id,
2608 })
2609 }
2610 _ => Err(builtin_error(
2611 PLOT_NAME,
2612 &ERROR_INPUT,
2613 "fea.plot supports plot(run, field), plot(results, field), plot(field), or plot(study, runId, field)",
2614 )),
2615 }
2616}
2617
2618fn split_plot_field_name_value(args: &[Value]) -> BuiltinResult<(&[Value], Option<String>)> {
2619 if args.len() >= 3 && is_field_option_name(&args[args.len() - 2]) {
2620 let field_id = scalar_string(&args[args.len() - 1], PLOT_NAME, &ERROR_INPUT)?;
2621 Ok((&args[..args.len() - 2], Some(field_id)))
2622 } else {
2623 Ok((args, None))
2624 }
2625}
2626
2627fn is_field_option_name(value: &Value) -> bool {
2628 scalar_string(value, PLOT_NAME, &ERROR_INPUT)
2629 .map(|name| {
2630 matches!(
2631 name.to_ascii_lowercase().as_str(),
2632 "field" | "fieldid" | "field_id"
2633 )
2634 })
2635 .unwrap_or(false)
2636}
2637
2638fn is_fea_study(value: &Value) -> bool {
2639 matches!(value, Value::Object(object) if object.class_name == FEA_STUDY_CLASS)
2640}
2641
2642fn plot_request_from_context_value(
2643 value: &Value,
2644 field_override: Option<String>,
2645) -> BuiltinResult<FeaPlotRequest> {
2646 let study = study_context_from_value(PLOT_NAME, value)?;
2647 let run_id = run_id_context_from_value(value)
2648 .or_else(|| run_id_from_value(PLOT_NAME, value).ok())
2649 .ok_or_else(|| {
2650 builtin_error(
2651 PLOT_NAME,
2652 &ERROR_INPUT,
2653 "fea.plot requires a run_id; use a fea.RunResult from fea.run or pass fea.plot(study, runId, field)",
2654 )
2655 })?;
2656 let field_id = field_override.or_else(|| match value {
2657 Value::Object(object) if object.class_name == FEA_FIELD_CLASS => object
2658 .properties
2659 .get("field_id")
2660 .and_then(|value| match value {
2661 Value::String(field_id) => Some(field_id.clone()),
2662 _ => None,
2663 }),
2664 _ => None,
2665 });
2666 Ok(FeaPlotRequest {
2667 study,
2668 run_id,
2669 field_id,
2670 })
2671}
2672
2673#[cfg(feature = "plot-core")]
2674fn generate_plot_figures(
2675 study: &AnalysisStudySpec,
2676 run_id: &str,
2677) -> BuiltinResult<Vec<crate::analysis::AnalysisGeneratedFigure>> {
2678 crate::analysis::analysis_generate_study_run_figures(
2679 study,
2680 run_id,
2681 crate::analysis::AnalysisFigureGenerationOptions {
2682 include_comparison: false,
2683 include_trends: false,
2684 max_mesh_result_figures: 8,
2685 ..crate::analysis::AnalysisFigureGenerationOptions::default()
2686 },
2687 )
2688 .map_err(|err| builtin_error(PLOT_NAME, &ERROR_OPERATION, err))
2689}
2690
2691#[cfg(feature = "plot-core")]
2692fn select_generated_figure(
2693 figures: &mut Vec<crate::analysis::AnalysisGeneratedFigure>,
2694 field_id: Option<&str>,
2695) -> BuiltinResult<crate::analysis::AnalysisGeneratedFigure> {
2696 if figures.is_empty() {
2697 return Err(builtin_error(
2698 PLOT_NAME,
2699 &ERROR_OPERATION,
2700 "fea.plot could not generate a renderable FEA figure for this run",
2701 ));
2702 }
2703 let Some(field_id) = field_id else {
2704 return Ok(figures.remove(0));
2705 };
2706 if let Some(index) = figures.iter().position(|figure| {
2707 figure
2708 .field_ids
2709 .iter()
2710 .any(|candidate| field_id_matches(candidate, field_id))
2711 }) {
2712 return Ok(figures.remove(index));
2713 }
2714 let available = figures
2715 .iter()
2716 .flat_map(|figure| figure.field_ids.iter())
2717 .cloned()
2718 .collect::<Vec<_>>()
2719 .join(", ");
2720 Err(builtin_error(
2721 PLOT_NAME,
2722 &ERROR_INPUT,
2723 format!("FEA field `{field_id}` did not produce a mesh figure; available figure fields: {available}"),
2724 ))
2725}
2726
2727#[cfg(feature = "plot-core")]
2728fn import_generated_figure(figure: crate::analysis::AnalysisGeneratedFigure) -> BuiltinResult<u32> {
2729 Ok(crate::builtins::plotting::import_runtime_figure(
2730 figure.figure,
2731 ))
2732}
2733
2734struct NameValuePair<'a> {
2735 name: &'a Value,
2736 key: String,
2737 value: &'a Value,
2738}
2739
2740fn expect_name_value_tail<'a>(
2741 builtin: &'static str,
2742 args: &'a [Value],
2743) -> BuiltinResult<Vec<NameValuePair<'a>>> {
2744 if !args.len().is_multiple_of(2) {
2745 return Err(builtin_error(
2746 builtin,
2747 &ERROR_INPUT,
2748 format!("{builtin} options must be Name, Value pairs"),
2749 ));
2750 }
2751 args.chunks(2)
2752 .map(|pair| {
2753 let key = option_key(&pair[0], builtin)?;
2754 Ok(NameValuePair {
2755 name: &pair[0],
2756 key,
2757 value: &pair[1],
2758 })
2759 })
2760 .collect()
2761}
2762
2763fn json_fields_from_name_values(
2764 builtin: &'static str,
2765 args: &[Value],
2766) -> BuiltinResult<serde_json::Map<String, serde_json::Value>> {
2767 let mut fields = serde_json::Map::new();
2768 for pair in expect_name_value_tail(builtin, args)? {
2769 let raw = scalar_string(pair.name, builtin, &ERROR_INPUT)?;
2770 fields.insert(
2771 canonical_field_name(&raw),
2772 value_to_json(builtin, pair.value)?,
2773 );
2774 }
2775 Ok(fields)
2776}
2777
2778fn option_key(value: &Value, builtin: &'static str) -> BuiltinResult<String> {
2779 Ok(normalize_token(&scalar_string(
2780 value,
2781 builtin,
2782 &ERROR_INPUT,
2783 )?))
2784}
2785
2786fn normalize_token(text: &str) -> String {
2787 text.chars()
2788 .filter(|ch| ch.is_ascii_alphanumeric())
2789 .flat_map(|ch| ch.to_lowercase())
2790 .collect()
2791}
2792
2793fn canonical_field_name(text: &str) -> String {
2794 let mut out = String::new();
2795 let mut previous_lower_or_digit = false;
2796 for ch in text.chars() {
2797 if ch == '-' || ch == ' ' {
2798 if !out.ends_with('_') && !out.is_empty() {
2799 out.push('_');
2800 }
2801 previous_lower_or_digit = false;
2802 continue;
2803 }
2804 if ch == '_' {
2805 if !out.ends_with('_') && !out.is_empty() {
2806 out.push('_');
2807 }
2808 previous_lower_or_digit = false;
2809 continue;
2810 }
2811 if ch.is_ascii_uppercase() {
2812 if previous_lower_or_digit && !out.ends_with('_') {
2813 out.push('_');
2814 }
2815 out.push(ch.to_ascii_lowercase());
2816 previous_lower_or_digit = false;
2817 } else if ch.is_ascii_alphanumeric() {
2818 out.push(ch.to_ascii_lowercase());
2819 previous_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
2820 }
2821 }
2822 match normalize_token(&out).as_str() {
2823 "youngsmoduluspa" => "youngs_modulus_pa".to_string(),
2824 "poissonratio" => "poisson_ratio".to_string(),
2825 "density" | "densitykgperm3" => "density_kg_per_m3".to_string(),
2826 "magnitude" | "magnitudepa" => "magnitude_pa".to_string(),
2827 "current" | "currenta" => "current_a".to_string(),
2828 "phase" | "phaserad" => "phase_rad".to_string(),
2829 "amplitudescale" => "amplitude_scale".to_string(),
2830 "deterministicmode" => "deterministic_mode".to_string(),
2831 "precisionmode" => "precision_mode".to_string(),
2832 "preconditionermode" => "preconditioner_mode".to_string(),
2833 "qualitypolicy" => "quality_policy".to_string(),
2834 "prepcalibrationprofile" => "prep_calibration_profile".to_string(),
2835 "prepartifactid" => "prep_artifact_id".to_string(),
2836 "sweepfrequencyhz" => "sweep_frequency_hz".to_string(),
2837 "sweepenabled" => "sweep_enabled".to_string(),
2838 _ => out.trim_matches('_').to_string(),
2839 }
2840}
2841
2842fn value_to_json(builtin: &'static str, value: &Value) -> BuiltinResult<serde_json::Value> {
2843 match value {
2844 Value::Num(n) => json_number(builtin, *n),
2845 Value::Int(i) => Ok(serde_json::Value::Number(i.to_i64().into())),
2846 Value::Bool(b) => Ok(serde_json::Value::Bool(*b)),
2847 Value::String(s) => Ok(serde_json::Value::String(s.clone())),
2848 Value::CharArray(chars) if chars.rows == 1 => {
2849 Ok(serde_json::Value::String(chars.data.iter().collect()))
2850 }
2851 Value::StringArray(array) if array.data.len() == 1 => {
2852 Ok(serde_json::Value::String(array.data[0].clone()))
2853 }
2854 Value::StringArray(array) => Ok(serde_json::Value::Array(
2855 array
2856 .data
2857 .iter()
2858 .cloned()
2859 .map(serde_json::Value::String)
2860 .collect(),
2861 )),
2862 Value::Tensor(tensor) if tensor.data.len() == 1 => json_number(builtin, tensor.data[0]),
2863 Value::Tensor(tensor) => Ok(serde_json::Value::Array(
2864 tensor
2865 .data
2866 .iter()
2867 .map(|value| json_number(builtin, *value))
2868 .collect::<BuiltinResult<Vec<_>>>()?,
2869 )),
2870 Value::Cell(cell) => Ok(serde_json::Value::Array(
2871 cell.data
2872 .iter()
2873 .map(|item| value_to_json(builtin, item))
2874 .collect::<BuiltinResult<Vec<_>>>()?,
2875 )),
2876 Value::Struct(fields) => {
2877 let mut object = serde_json::Map::new();
2878 for (key, value) in &fields.fields {
2879 object.insert(canonical_field_name(key), value_to_json(builtin, value)?);
2880 }
2881 Ok(serde_json::Value::Object(object))
2882 }
2883 Value::Object(object) => {
2884 if let Some(Value::String(json)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY) {
2885 serde_json::from_str(json).map_err(|err| {
2886 builtin_error_with_source(builtin, &ERROR_INPUT, err.to_string(), err)
2887 })
2888 } else {
2889 let mut object_json = serde_json::Map::new();
2890 for (key, value) in &object.properties {
2891 if key.starts_with("__runmat_") {
2892 continue;
2893 }
2894 object_json.insert(canonical_field_name(key), value_to_json(builtin, value)?);
2895 }
2896 Ok(serde_json::Value::Object(object_json))
2897 }
2898 }
2899 other => Err(builtin_error(
2900 builtin,
2901 &ERROR_INPUT,
2902 format!("cannot convert value to FEA JSON payload: {other:?}"),
2903 )),
2904 }
2905}
2906
2907fn json_number(builtin: &'static str, value: f64) -> BuiltinResult<serde_json::Value> {
2908 serde_json::Number::from_f64(value)
2909 .map(serde_json::Value::Number)
2910 .ok_or_else(|| {
2911 builtin_error(
2912 builtin,
2913 &ERROR_INPUT,
2914 "FEA numeric option values must be finite JSON numbers",
2915 )
2916 })
2917}
2918
2919fn typed_json_with_overrides<T: Serialize + DeserializeOwned>(
2920 builtin: &'static str,
2921 default: T,
2922 fields: serde_json::Map<String, serde_json::Value>,
2923 label: &str,
2924) -> BuiltinResult<serde_json::Value> {
2925 let base = serde_json::to_value(default)
2926 .map_err(|err| builtin_error(builtin, &ERROR_INTERNAL, err.to_string()))?;
2927 json_with_overrides(builtin, base, fields, label)
2928}
2929
2930fn json_with_overrides(
2931 builtin: &'static str,
2932 mut base: serde_json::Value,
2933 fields: serde_json::Map<String, serde_json::Value>,
2934 label: &str,
2935) -> BuiltinResult<serde_json::Value> {
2936 let Some(object) = base.as_object_mut() else {
2937 return Err(builtin_error(
2938 builtin,
2939 &ERROR_INTERNAL,
2940 format!("{label} default payload is not an object"),
2941 ));
2942 };
2943 for (key, value) in fields {
2944 if !object.contains_key(&key) {
2945 return Err(builtin_error(
2946 builtin,
2947 &ERROR_INPUT,
2948 format!("unsupported {label} option `{key}`"),
2949 ));
2950 }
2951 object.insert(key, value);
2952 }
2953 Ok(base)
2954}
2955
2956fn json_deserialize<T: DeserializeOwned>(
2957 builtin: &'static str,
2958 value: serde_json::Value,
2959 label: &str,
2960) -> BuiltinResult<T> {
2961 serde_json::from_value(value)
2962 .map_err(|err| builtin_error(builtin, &ERROR_INPUT, format!("invalid {label}: {err}")))
2963}
2964
2965fn json_to_string(value: serde_json::Value) -> BuiltinResult<String> {
2966 serde_json::from_value(value).map_err(|err| {
2967 builtin_error(
2968 MATERIAL_NAME,
2969 &ERROR_INPUT,
2970 format!("invalid string option: {err}"),
2971 )
2972 })
2973}
2974
2975fn remove_required_f64(
2976 fields: &mut serde_json::Map<String, serde_json::Value>,
2977 builtin: &'static str,
2978 key: &str,
2979) -> BuiltinResult<f64> {
2980 let Some(value) = fields.remove(key) else {
2981 return Err(builtin_error(
2982 builtin,
2983 &ERROR_INPUT,
2984 format!("missing required option `{key}`"),
2985 ));
2986 };
2987 serde_json::from_value(value).map_err(|err| {
2988 builtin_error(
2989 builtin,
2990 &ERROR_INPUT,
2991 format!("invalid numeric option `{key}`: {err}"),
2992 )
2993 })
2994}
2995
2996fn remove_optional_f64(
2997 fields: &mut serde_json::Map<String, serde_json::Value>,
2998 key: &str,
2999) -> BuiltinResult<Option<f64>> {
3000 fields
3001 .remove(key)
3002 .map(|value| {
3003 serde_json::from_value(value).map_err(|err| {
3004 builtin_error(
3005 LOAD_CASE_NAME,
3006 &ERROR_INPUT,
3007 format!("invalid numeric option `{key}`: {err}"),
3008 )
3009 })
3010 })
3011 .transpose()
3012}
3013
3014fn remove_required_vector3(
3015 fields: &mut serde_json::Map<String, serde_json::Value>,
3016 builtin: &'static str,
3017 key: &str,
3018) -> BuiltinResult<[f64; 3]> {
3019 let Some(value) = fields.remove(key) else {
3020 return Err(builtin_error(
3021 builtin,
3022 &ERROR_INPUT,
3023 format!("missing required vector option `{key}`"),
3024 ));
3025 };
3026 let values: Vec<f64> = serde_json::from_value(value).map_err(|err| {
3027 builtin_error(
3028 builtin,
3029 &ERROR_INPUT,
3030 format!("invalid vector option `{key}`: {err}"),
3031 )
3032 })?;
3033 if values.len() != 3 {
3034 return Err(builtin_error(
3035 builtin,
3036 &ERROR_INPUT,
3037 format!("vector option `{key}` must contain exactly 3 values"),
3038 ));
3039 }
3040 Ok([values[0], values[1], values[2]])
3041}
3042
3043fn move_known_fields(
3044 source: &mut serde_json::Map<String, serde_json::Value>,
3045 target: &mut serde_json::Map<String, serde_json::Value>,
3046 keys: &[&str],
3047) -> bool {
3048 let mut moved = false;
3049 for key in keys {
3050 if let Some(value) = source.remove(*key) {
3051 target.insert((*key).to_string(), value);
3052 moved = true;
3053 }
3054 }
3055 moved
3056}
3057
3058fn reject_unknown_fields(
3059 builtin: &'static str,
3060 fields: serde_json::Map<String, serde_json::Value>,
3061) -> BuiltinResult<()> {
3062 if fields.is_empty() {
3063 return Ok(());
3064 }
3065 let keys = fields.keys().cloned().collect::<Vec<_>>().join(", ");
3066 Err(builtin_error(
3067 builtin,
3068 &ERROR_INPUT,
3069 format!("unsupported option field(s): {keys}"),
3070 ))
3071}
3072
3073fn bool_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<bool> {
3074 bool::try_from(value).map_err(|err| builtin_error(builtin, &ERROR_INPUT, err))
3075}
3076
3077fn usize_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<usize> {
3078 match value {
3079 Value::Int(int) => Ok(int.to_i64().max(0) as usize),
3080 Value::Num(n) if *n >= 0.0 => Ok(*n as usize),
3081 other => Err(builtin_error(
3082 builtin,
3083 &ERROR_INPUT,
3084 format!("expected non-negative integer value; got {other:?}"),
3085 )),
3086 }
3087}
3088
3089fn string_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<String>> {
3090 match value {
3091 Value::Cell(cell) => cell
3092 .data
3093 .iter()
3094 .map(|item| scalar_string(item, builtin, &ERROR_INPUT))
3095 .collect(),
3096 Value::StringArray(array) => Ok(array.data.clone()),
3097 Value::String(_) | Value::CharArray(_) => {
3098 Ok(vec![scalar_string(value, builtin, &ERROR_INPUT)?])
3099 }
3100 other => Err(builtin_error(
3101 builtin,
3102 &ERROR_INPUT,
3103 format!("expected string, string array, or cell array of strings; got {other:?}"),
3104 )),
3105 }
3106}
3107
3108fn usize_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<usize>> {
3109 match value {
3110 Value::Tensor(Tensor { data, .. }) => {
3111 Ok(data.iter().map(|value| *value as usize).collect())
3112 }
3113 Value::Cell(cell) => cell
3114 .data
3115 .iter()
3116 .map(|item| usize_from_value(builtin, item))
3117 .collect(),
3118 Value::Int(_) | Value::Num(_) => Ok(vec![usize_from_value(builtin, value)?]),
3119 other => Err(builtin_error(
3120 builtin,
3121 &ERROR_INPUT,
3122 format!("expected numeric vector or cell array of indices; got {other:?}"),
3123 )),
3124 }
3125}
3126
3127fn parse_model_defaults_mode(text: &str) -> BuiltinResult<ModelDefaultsMode> {
3128 match normalize_token(text).as_str() {
3129 "profilescaffold" | "scaffold" | "profile" => Ok(ModelDefaultsMode::ProfileScaffold),
3130 "none" | "empty" => Ok(ModelDefaultsMode::None),
3131 other => Err(builtin_error(
3132 MODEL_NAME,
3133 &ERROR_INPUT,
3134 format!("unsupported model defaults mode `{other}`"),
3135 )),
3136 }
3137}
3138
3139fn resolved_document_to_object(document: FeaResolvedDocument) -> BuiltinResult<Value> {
3140 match document {
3141 FeaResolvedDocument::Study(spec) => study_to_object(*spec),
3142 FeaResolvedDocument::Sweep(spec) => sweep_to_object(spec),
3143 }
3144}
3145
3146fn study_to_object(spec: AnalysisStudySpec) -> BuiltinResult<Value> {
3147 let mut object = serializable_to_object(
3148 STUDY_NAME,
3149 &ERROR_INTERNAL,
3150 FEA_STUDY_CLASS,
3151 &spec,
3152 Some(FEA_STUDY_SPEC_JSON_PROPERTY),
3153 )?;
3154 if let Value::Object(ref mut object) = object {
3155 object
3156 .properties
3157 .insert("id".to_string(), Value::String(spec.study_id));
3158 }
3159 Ok(object)
3160}
3161
3162fn sweep_to_object(spec: AnalysisStudySweepSpec) -> BuiltinResult<Value> {
3163 let mut object = serializable_to_object(
3164 LOAD_NAME,
3165 &ERROR_INTERNAL,
3166 FEA_SWEEP_CLASS,
3167 &spec,
3168 Some(FEA_SWEEP_SPEC_JSON_PROPERTY),
3169 )?;
3170 if let Value::Object(ref mut object) = object {
3171 object
3172 .properties
3173 .insert("id".to_string(), Value::String(spec.sweep_id));
3174 }
3175 Ok(object)
3176}
3177
3178fn operation_result_to_object<T: Serialize>(
3179 builtin: &'static str,
3180 operation_error_descriptor: &'static BuiltinErrorDescriptor,
3181 internal_error_descriptor: &'static BuiltinErrorDescriptor,
3182 class_name: &'static str,
3183 result: Result<OperationEnvelope<T>, OperationErrorEnvelope>,
3184 hidden_json_property: Option<&'static str>,
3185) -> BuiltinResult<Value> {
3186 let envelope =
3187 result.map_err(|err| operation_error(builtin, operation_error_descriptor, err))?;
3188 serializable_to_object(
3189 builtin,
3190 internal_error_descriptor,
3191 class_name,
3192 &envelope.data,
3193 hidden_json_property,
3194 )
3195}
3196
3197fn serializable_to_object<T: Serialize>(
3198 builtin: &'static str,
3199 error: &'static BuiltinErrorDescriptor,
3200 class_name: &'static str,
3201 value: &T,
3202 hidden_json_property: Option<&'static str>,
3203) -> BuiltinResult<Value> {
3204 serializable_to_object_value(builtin, error, class_name, value, hidden_json_property)
3205 .map(Value::Object)
3206}
3207
3208fn serializable_to_value<T: Serialize>(
3209 builtin: &'static str,
3210 error: &'static BuiltinErrorDescriptor,
3211 value: &T,
3212) -> BuiltinResult<Value> {
3213 let json = serde_json::to_value(value)
3214 .map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))?;
3215 value_from_json(&json)
3216 .map_err(|err| builtin_error_with_source(builtin, error, err.message().to_string(), err))
3217}
3218
3219fn serializable_to_object_value<T: Serialize>(
3220 builtin: &'static str,
3221 error: &'static BuiltinErrorDescriptor,
3222 class_name: &'static str,
3223 value: &T,
3224 hidden_json_property: Option<&'static str>,
3225) -> BuiltinResult<ObjectInstance> {
3226 ensure_fea_classes_registered();
3227 let json = serde_json::to_value(value)
3228 .map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))?;
3229 let converted = value_from_json(&json)
3230 .map_err(|err| builtin_error_with_source(builtin, error, err.message().to_string(), err))?;
3231 let mut object = ObjectInstance::new(class_name.to_string());
3232 if let Value::Struct(fields) = converted {
3233 object.properties = fields.fields.into_iter().collect();
3234 } else {
3235 object.properties.insert("value".to_string(), converted);
3236 }
3237 if let Some(property) = hidden_json_property {
3238 object
3239 .properties
3240 .insert(property.to_string(), Value::String(json.to_string()));
3241 }
3242 Ok(object)
3243}
3244
3245fn geometry_asset_from_value(value: &Value) -> BuiltinResult<GeometryAsset> {
3246 let Value::Object(object) = value else {
3247 return Err(builtin_error(
3248 STUDY_NAME,
3249 &ERROR_INPUT,
3250 format!("fea.study geometry must be {GEOMETRY_ASSET_CLASS}"),
3251 ));
3252 };
3253 if object.class_name != GEOMETRY_ASSET_CLASS {
3254 return Err(builtin_error(
3255 STUDY_NAME,
3256 &ERROR_INPUT,
3257 format!(
3258 "fea.study geometry must be {GEOMETRY_ASSET_CLASS}, got {}",
3259 object.class_name
3260 ),
3261 ));
3262 }
3263 object_json_property(
3264 STUDY_NAME,
3265 object,
3266 GEOMETRY_ASSET_JSON_PROPERTY,
3267 &ERROR_INPUT,
3268 )
3269}
3270
3271fn object_json_property<T: DeserializeOwned>(
3272 builtin: &'static str,
3273 object: &ObjectInstance,
3274 property: &'static str,
3275 error: &'static BuiltinErrorDescriptor,
3276) -> BuiltinResult<T> {
3277 let Some(Value::String(json)) = object.properties.get(property) else {
3278 return Err(builtin_error(
3279 builtin,
3280 error,
3281 format!(
3282 "{} is missing required runtime payload property `{property}`",
3283 object.class_name
3284 ),
3285 ));
3286 };
3287 serde_json::from_str(json)
3288 .map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))
3289}
3290
3291fn scalar_string(
3292 value: &Value,
3293 builtin: &'static str,
3294 error: &'static BuiltinErrorDescriptor,
3295) -> BuiltinResult<String> {
3296 String::try_from(value).map_err(|err| builtin_error(builtin, error, err))
3297}
3298
3299fn parse_scalar_enum<T: DeserializeOwned>(text: &str, label: &str) -> BuiltinResult<T> {
3300 serde_yaml::from_str::<T>(&text.to_ascii_lowercase()).map_err(|err| {
3301 builtin_error(
3302 STUDY_NAME,
3303 &ERROR_INPUT,
3304 format!("invalid {label} value `{text}`: {err}"),
3305 )
3306 })
3307}
3308
3309fn default_profile_for_run_kind(run_kind: AnalysisRunKind) -> AnalysisCreateModelProfile {
3310 match run_kind {
3311 AnalysisRunKind::LinearStatic => AnalysisCreateModelProfile::LinearStaticStructural,
3312 AnalysisRunKind::Modal => AnalysisCreateModelProfile::ModalStructural,
3313 AnalysisRunKind::Acoustic => AnalysisCreateModelProfile::AcousticHarmonic,
3314 AnalysisRunKind::Thermal => AnalysisCreateModelProfile::ThermalStandalone,
3315 AnalysisRunKind::Transient => AnalysisCreateModelProfile::TransientStructural,
3316 AnalysisRunKind::Cfd => AnalysisCreateModelProfile::CfdSteadyState,
3317 AnalysisRunKind::Cht => AnalysisCreateModelProfile::ChtCoupled,
3318 AnalysisRunKind::Fsi => AnalysisCreateModelProfile::FsiCoupled,
3319 AnalysisRunKind::Nonlinear => AnalysisCreateModelProfile::NonlinearStructural,
3320 AnalysisRunKind::Electromagnetic => AnalysisCreateModelProfile::ElectromagneticStatic,
3321 }
3322}
3323
3324fn resolve_study_profile_and_run_kind(
3325 options: &StudyConstructorOptions,
3326) -> BuiltinResult<(AnalysisCreateModelProfile, AnalysisRunKind)> {
3327 let profile = match (options.profile, options.run_kind) {
3328 (Some(profile), _) => profile,
3329 (None, Some(run_kind)) => default_profile_for_run_kind(run_kind),
3330 (None, None) => AnalysisCreateModelProfile::LinearStaticStructural,
3331 };
3332 let run_kind = profile.derived_run_kind();
3333 if let Some(explicit_run_kind) = options.run_kind {
3334 if explicit_run_kind != run_kind {
3335 return Err(builtin_error(
3336 STUDY_NAME,
3337 &ERROR_INPUT,
3338 format!(
3339 "explicit solver {:?} does not match Profile {:?}; omit RunKind or choose a matching Profile",
3340 explicit_run_kind, profile
3341 ),
3342 ));
3343 }
3344 }
3345 Ok((profile, run_kind))
3346}
3347
3348fn ensure_fea_classes_registered() {
3349 static REGISTER: OnceLock<()> = OnceLock::new();
3350 REGISTER.get_or_init(|| {
3351 let workflow_methods = workflow_methods();
3352 for class_name in [FEA_STUDY_CLASS, FEA_SWEEP_CLASS] {
3353 runmat_builtins::register_class(ClassDef {
3354 name: class_name.to_string(),
3355 parent: None,
3356 properties: HashMap::new(),
3357 methods: workflow_methods.clone(),
3358 });
3359 }
3360 runmat_builtins::register_class(ClassDef {
3361 name: FEA_RUN_RESULT_CLASS.to_string(),
3362 parent: None,
3363 properties: HashMap::new(),
3364 methods: run_result_methods(),
3365 });
3366 runmat_builtins::register_class(ClassDef {
3367 name: FEA_RESULTS_CLASS.to_string(),
3368 parent: None,
3369 properties: HashMap::new(),
3370 methods: results_methods(),
3371 });
3372 for class_name in [FEA_VALIDATION_CLASS, FEA_PLAN_CLASS, FEA_RUN_RESULT_CLASS] {
3373 if class_name == FEA_RUN_RESULT_CLASS {
3374 continue;
3375 }
3376 runmat_builtins::register_class(ClassDef {
3377 name: class_name.to_string(),
3378 parent: None,
3379 properties: HashMap::new(),
3380 methods: HashMap::new(),
3381 });
3382 }
3383 for class_name in [
3384 FEA_MODEL_CLASS,
3385 FEA_MATERIAL_CLASS,
3386 FEA_MATERIAL_ASSIGNMENT_CLASS,
3387 FEA_BOUNDARY_CONDITION_CLASS,
3388 FEA_LOAD_CASE_CLASS,
3389 FEA_STEP_CLASS,
3390 FEA_DOMAIN_CLASS,
3391 FEA_INTERFACE_CLASS,
3392 FEA_RUN_OPTIONS_CLASS,
3393 FEA_FIELD_CLASS,
3394 FEA_COMPARE_CLASS,
3395 FEA_TRENDS_CLASS,
3396 ] {
3397 runmat_builtins::register_class(ClassDef {
3398 name: class_name.to_string(),
3399 parent: None,
3400 properties: HashMap::new(),
3401 methods: if class_name == FEA_FIELD_CLASS {
3402 field_methods()
3403 } else {
3404 HashMap::new()
3405 },
3406 });
3407 }
3408 });
3409}
3410
3411fn workflow_methods() -> HashMap<String, MethodDef> {
3412 [
3413 ("validate", VALIDATE_NAME),
3414 ("plan", PLAN_NAME),
3415 ("run", RUN_NAME),
3416 ]
3417 .into_iter()
3418 .map(|(name, function_name)| {
3419 (
3420 name.to_string(),
3421 MethodDef {
3422 name: name.to_string(),
3423 is_static: false,
3424 is_abstract: false,
3425 is_sealed: false,
3426 access: Access::Public,
3427 function_name: function_name.to_string(),
3428 implicit_class_argument: None,
3429 },
3430 )
3431 })
3432 .collect()
3433}
3434
3435fn run_result_methods() -> HashMap<String, MethodDef> {
3436 [
3437 ("results", RESULTS_NAME),
3438 ("field", FIELD_NAME),
3439 ("plot", PLOT_NAME),
3440 ]
3441 .into_iter()
3442 .map(|(name, function_name)| {
3443 (
3444 name.to_string(),
3445 MethodDef {
3446 name: name.to_string(),
3447 is_static: false,
3448 is_abstract: false,
3449 is_sealed: false,
3450 access: Access::Public,
3451 function_name: function_name.to_string(),
3452 implicit_class_argument: None,
3453 },
3454 )
3455 })
3456 .collect()
3457}
3458
3459fn results_methods() -> HashMap<String, MethodDef> {
3460 [("field", FIELD_NAME), ("plot", PLOT_NAME)]
3461 .into_iter()
3462 .map(|(name, function_name)| {
3463 (
3464 name.to_string(),
3465 MethodDef {
3466 name: name.to_string(),
3467 is_static: false,
3468 is_abstract: false,
3469 is_sealed: false,
3470 access: Access::Public,
3471 function_name: function_name.to_string(),
3472 implicit_class_argument: None,
3473 },
3474 )
3475 })
3476 .collect()
3477}
3478
3479fn field_methods() -> HashMap<String, MethodDef> {
3480 [("plot", PLOT_NAME)]
3481 .into_iter()
3482 .map(|(name, function_name)| {
3483 (
3484 name.to_string(),
3485 MethodDef {
3486 name: name.to_string(),
3487 is_static: false,
3488 is_abstract: false,
3489 is_sealed: false,
3490 access: Access::Public,
3491 function_name: function_name.to_string(),
3492 implicit_class_argument: None,
3493 },
3494 )
3495 })
3496 .collect()
3497}
3498
3499fn operation_error(
3500 builtin: &'static str,
3501 error: &'static BuiltinErrorDescriptor,
3502 source: OperationErrorEnvelope,
3503) -> RuntimeError {
3504 let message = format!(
3505 "{}: {}: {}",
3506 error.message, source.error_code, source.message
3507 );
3508 build_runtime_error(message)
3509 .with_builtin(builtin)
3510 .with_identifier(
3511 error
3512 .identifier
3513 .unwrap_or(ERROR_OPERATION.identifier.expect("descriptor identifier")),
3514 )
3515 .build()
3516}
3517
3518fn builtin_error(
3519 builtin: &'static str,
3520 error: &'static BuiltinErrorDescriptor,
3521 message: impl Into<String>,
3522) -> RuntimeError {
3523 build_runtime_error(format!("{}: {}", error.message, message.into()))
3524 .with_builtin(builtin)
3525 .with_identifier(
3526 error
3527 .identifier
3528 .unwrap_or(ERROR_INTERNAL.identifier.expect("descriptor identifier")),
3529 )
3530 .build()
3531}
3532
3533fn builtin_error_with_source<E>(
3534 builtin: &'static str,
3535 error: &'static BuiltinErrorDescriptor,
3536 message: impl Into<String>,
3537 source: E,
3538) -> RuntimeError
3539where
3540 E: std::error::Error + Send + Sync + 'static,
3541{
3542 build_runtime_error(format!("{}: {}", error.message, message.into()))
3543 .with_builtin(builtin)
3544 .with_identifier(
3545 error
3546 .identifier
3547 .unwrap_or(ERROR_INTERNAL.identifier.expect("descriptor identifier")),
3548 )
3549 .with_source(source)
3550 .build()
3551}
3552
3553fn sanitize_id(id: &str) -> String {
3554 id.chars()
3555 .map(|ch| {
3556 if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
3557 ch
3558 } else {
3559 '_'
3560 }
3561 })
3562 .collect()
3563}
3564
3565#[cfg(test)]
3566mod tests {
3567 use super::*;
3568 use futures::executor::block_on;
3569 use runmat_builtins::CellArray;
3570
3571 const TRIANGLE_STL: &str = "solid tri\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid tri\n";
3572 const SIMPLE_STEP: &str = "ISO-10303-21;\nHEADER;\nFILE_NAME('Assembly_A');\nENDSEC;\nDATA;\n#10=PRODUCT('Bracket_A','',(#1));\nENDSEC;\nEND-ISO-10303-21;\n";
3573
3574 fn cell(values: Vec<Value>) -> Value {
3575 let cols = values.len().max(1);
3576 Value::Cell(CellArray::new(values, 1, cols).expect("cell should build"))
3577 }
3578
3579 fn force_vector() -> Value {
3580 Value::Tensor(Tensor::new_2d(vec![0.0, -1000.0, 0.0], 1, 3).expect("tensor should build"))
3581 }
3582
3583 fn moment_vector() -> Value {
3584 Value::Tensor(Tensor::new_2d(vec![10.0, 20.0, 30.0], 1, 3).expect("tensor should build"))
3585 }
3586
3587 #[test]
3588 fn fea_study_requires_geometry_asset() {
3589 let err = block_on(fea_study_builtin(vec![
3590 Value::String("demo".to_string()),
3591 Value::Num(1.0),
3592 ]))
3593 .expect_err("invalid geometry should fail");
3594 assert_eq!(err.identifier(), Some("RunMat:fea:InvalidInput"));
3595 }
3596
3597 #[test]
3598 fn fea_load_validate_and_plan_document_workflow() {
3599 let tmp = tempfile::tempdir().expect("tempdir should be created");
3600 std::fs::write(tmp.path().join("part.stl"), TRIANGLE_STL)
3601 .expect("geometry fixture should write");
3602 let fea_path = tmp.path().join("bracket.fea");
3603 std::fs::write(
3604 &fea_path,
3605 r#"
3606version: 1
3607kind: study
3608id: bracket_static
3609geometry:
3610 path: part.stl
3611 units: meter
3612model:
3613 profile: linear_static_structural
3614run:
3615 backend: cpu
3616"#,
3617 )
3618 .expect("FEA fixture should write");
3619
3620 let study = block_on(fea_load_builtin(fea_path.to_string_lossy().to_string()))
3621 .expect("FEA document should load");
3622 let Value::Object(study_object) = study.clone() else {
3623 panic!("expected loaded FEA study object");
3624 };
3625 assert_eq!(study_object.class_name, FEA_STUDY_CLASS);
3626 assert!(study_object
3627 .properties
3628 .contains_key(FEA_STUDY_SPEC_JSON_PROPERTY));
3629
3630 let validation =
3631 block_on(fea_validate_builtin(study.clone())).expect("FEA study should validate");
3632 let Value::Object(validation_object) = validation else {
3633 panic!("expected validation object");
3634 };
3635 assert_eq!(validation_object.class_name, FEA_VALIDATION_CLASS);
3636 assert_eq!(
3637 validation_object.properties.get("valid"),
3638 Some(&Value::Bool(true))
3639 );
3640
3641 let plan = block_on(fea_plan_builtin(study)).expect("FEA study should plan");
3642 let Value::Object(plan_object) = plan else {
3643 panic!("expected plan object");
3644 };
3645 assert_eq!(plan_object.class_name, FEA_PLAN_CLASS);
3646 assert!(plan_object.properties.contains_key("operation_sequence"));
3647 }
3648
3649 #[test]
3650 fn fea_load_case_accepts_moment_and_torque_alias() {
3651 for kind in ["moment", "torque"] {
3652 let load = block_on(fea_load_case_builtin(vec![
3653 Value::String(format!("tip_{kind}")),
3654 Value::String("tip_node".to_string()),
3655 Value::String(kind.to_string()),
3656 Value::String("Vector".to_string()),
3657 moment_vector(),
3658 ]))
3659 .expect("moment load should build");
3660 assert_object_class(&load, FEA_LOAD_CASE_CLASS);
3661
3662 let Value::Object(object) = load else {
3663 panic!("expected load object");
3664 };
3665 let Some(Value::String(payload)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY)
3666 else {
3667 panic!("expected load JSON payload");
3668 };
3669 let decoded: LoadCase =
3670 serde_json::from_str(payload).expect("load payload should decode");
3671 assert_eq!(decoded.load_id, format!("tip_{kind}"));
3672 assert_eq!(decoded.region_id, "tip_node");
3673 assert!(matches!(
3674 decoded.kind,
3675 LoadKind::Moment {
3676 mx: 10.0,
3677 my: 20.0,
3678 mz: 30.0
3679 }
3680 ));
3681 }
3682 }
3683
3684 #[test]
3685 fn fea_load_case_doc_keywords_include_moment_and_torque() {
3686 let doc = runmat_builtins::builtin_docs()
3687 .into_iter()
3688 .find(|doc| doc.name == "fea.loadCase")
3689 .expect("fea.loadCase doc metadata should be registered");
3690 let keywords = doc
3691 .keywords
3692 .expect("fea.loadCase should advertise keywords");
3693 let keyword_set = keywords
3694 .split(',')
3695 .map(str::trim)
3696 .collect::<std::collections::BTreeSet<_>>();
3697
3698 assert!(keyword_set.contains("moment"));
3699 assert!(keyword_set.contains("torque"));
3700 }
3701
3702 #[test]
3703 fn fea_boundary_condition_accepts_prescribed_rotation() {
3704 let boundary = block_on(fea_boundary_condition_builtin(vec![
3705 Value::String("tip_rotation".to_string()),
3706 Value::String("tip_node".to_string()),
3707 Value::String("prescribedRotation".to_string()),
3708 Value::String("rx".to_string()),
3709 Value::Num(0.1),
3710 Value::String("ry".to_string()),
3711 Value::Num(0.2),
3712 Value::String("rz".to_string()),
3713 Value::Num(0.3),
3714 ]))
3715 .expect("prescribed rotation boundary condition should build");
3716 assert_object_class(&boundary, FEA_BOUNDARY_CONDITION_CLASS);
3717
3718 let Value::Object(object) = boundary else {
3719 panic!("expected boundary condition object");
3720 };
3721 let Some(Value::String(payload)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY) else {
3722 panic!("expected boundary condition JSON payload");
3723 };
3724 let decoded: BoundaryCondition =
3725 serde_json::from_str(payload).expect("boundary condition payload should decode");
3726 assert_eq!(decoded.bc_id, "tip_rotation");
3727 assert_eq!(decoded.region_id, "tip_node");
3728 assert!(matches!(
3729 decoded.kind,
3730 BoundaryConditionKind::PrescribedRotation {
3731 rx: 0.1,
3732 ry: 0.2,
3733 rz: 0.3
3734 }
3735 ));
3736 }
3737
3738 #[test]
3739 fn typed_constructors_build_full_study_and_sweep_objects() {
3740 let tmp = tempfile::tempdir().expect("tempdir should be created");
3741 let geometry_path = tmp.path().join("part.step");
3742 std::fs::write(&geometry_path, SIMPLE_STEP).expect("geometry fixture should write");
3743
3744 let geometry = block_on(crate::builtins::geometry::geometry_load_builtin(
3745 geometry_path.to_string_lossy().to_string(),
3746 ))
3747 .expect("geometry should load");
3748 let asset = geometry_asset_from_value(&geometry).expect("geometry payload should decode");
3749 let region_id = asset
3750 .regions
3751 .first()
3752 .expect("fixture should import a region")
3753 .region_id
3754 .clone();
3755
3756 let material = block_on(fea_material_builtin(vec![
3757 Value::String("steel".to_string()),
3758 Value::String("YoungsModulusPa".to_string()),
3759 Value::Num(200e9),
3760 Value::String("PoissonRatio".to_string()),
3761 Value::Num(0.30),
3762 ]))
3763 .expect("material should build");
3764 assert_object_class(&material, FEA_MATERIAL_CLASS);
3765
3766 let assignment = block_on(fea_material_assignment_builtin(vec![
3767 Value::String(region_id.clone()),
3768 Value::String("steel".to_string()),
3769 ]))
3770 .expect("material assignment should build");
3771 assert_object_class(&assignment, FEA_MATERIAL_ASSIGNMENT_CLASS);
3772
3773 let fixed = block_on(fea_boundary_condition_builtin(vec![
3774 Value::String("fixed_base".to_string()),
3775 Value::String(region_id.clone()),
3776 Value::String("fixed".to_string()),
3777 ]))
3778 .expect("boundary condition should build");
3779 assert_object_class(&fixed, FEA_BOUNDARY_CONDITION_CLASS);
3780
3781 let load = block_on(fea_load_case_builtin(vec![
3782 Value::String("tip_force".to_string()),
3783 Value::String(region_id),
3784 Value::String("force".to_string()),
3785 Value::String("Vector".to_string()),
3786 force_vector(),
3787 ]))
3788 .expect("load case should build");
3789 assert_object_class(&load, FEA_LOAD_CASE_CLASS);
3790
3791 let step = block_on(fea_step_builtin(vec![
3792 Value::String("static_step".to_string()),
3793 Value::String("static".to_string()),
3794 ]))
3795 .expect("analysis step should build");
3796 assert_object_class(&step, FEA_STEP_CLASS);
3797
3798 let model = block_on(fea_model_builtin(vec![
3799 Value::String("bracket_static_model".to_string()),
3800 geometry.clone(),
3801 Value::String("Defaults".to_string()),
3802 Value::String("none".to_string()),
3803 Value::String("Profile".to_string()),
3804 Value::String("linear_static_structural".to_string()),
3805 Value::String("Materials".to_string()),
3806 cell(vec![material]),
3807 Value::String("MaterialAssignments".to_string()),
3808 cell(vec![assignment]),
3809 Value::String("BoundaryConditions".to_string()),
3810 cell(vec![fixed]),
3811 Value::String("Loads".to_string()),
3812 cell(vec![load]),
3813 Value::String("Steps".to_string()),
3814 cell(vec![step]),
3815 ]))
3816 .expect("model should build");
3817 assert_object_class(&model, FEA_MODEL_CLASS);
3818
3819 let run_options = block_on(fea_run_options_builtin(vec![
3820 Value::String("linear_static".to_string()),
3821 Value::String("DeterministicMode".to_string()),
3822 Value::Bool(true),
3823 Value::String("PrecisionMode".to_string()),
3824 Value::String("fp64".to_string()),
3825 Value::String("QualityPolicy".to_string()),
3826 Value::String("balanced".to_string()),
3827 ]))
3828 .expect("run options should build");
3829 assert_object_class(&run_options, FEA_RUN_OPTIONS_CLASS);
3830
3831 let study = block_on(fea_study_builtin(vec![
3832 Value::String("bracket_static".to_string()),
3833 geometry,
3834 Value::String("Profile".to_string()),
3835 Value::String("linear_static_structural".to_string()),
3836 Value::String("Backend".to_string()),
3837 Value::String("cpu".to_string()),
3838 Value::String("Model".to_string()),
3839 model,
3840 Value::String("RunOptions".to_string()),
3841 run_options,
3842 ]))
3843 .expect("study should build");
3844 assert_object_class(&study, FEA_STUDY_CLASS);
3845
3846 let sweep = block_on(fea_sweep_builtin(vec![
3847 Value::String("bracket_sweep".to_string()),
3848 cell(vec![study]),
3849 Value::String("FailFast".to_string()),
3850 Value::Bool(false),
3851 ]))
3852 .expect("sweep should build");
3853 assert_object_class(&sweep, FEA_SWEEP_CLASS);
3854 }
3855
3856 #[test]
3857 fn fea_results_field_exposes_values_metadata_and_plot_context() {
3858 let (run_value, _study) = synthetic_plot_run_value();
3859
3860 let results = block_on(fea_results_builtin(vec![run_value])).expect("results should load");
3861 let Value::Object(results_object) = results.clone() else {
3862 panic!("expected results object");
3863 };
3864 assert_eq!(results_object.class_name, FEA_RESULTS_CLASS);
3865 assert_eq!(
3866 results_object.properties.get("run_id"),
3867 Some(&Value::String("synthetic_plot_run".to_string()))
3868 );
3869 assert!(results_object
3870 .properties
3871 .contains_key(FEA_STUDY_CONTEXT_JSON_PROPERTY));
3872
3873 let field = block_on(fea_field_builtin(vec![
3874 results,
3875 Value::String("von_mises".to_string()),
3876 ]))
3877 .expect("field should resolve by unique suffix");
3878 let Value::Object(field_object) = field else {
3879 panic!("expected field object");
3880 };
3881 assert_eq!(field_object.class_name, FEA_FIELD_CLASS);
3882 assert_eq!(
3883 field_object.properties.get("field_id"),
3884 Some(&Value::String("structural.von_mises".to_string()))
3885 );
3886 assert_eq!(
3887 field_object.properties.get("unit"),
3888 Some(&Value::String("Pa".to_string()))
3889 );
3890 assert_eq!(
3891 field_object.properties.get("location"),
3892 Some(&Value::String("element".to_string()))
3893 );
3894 let Some(Value::Tensor(values)) = field_object.properties.get("values") else {
3895 panic!("expected values tensor");
3896 };
3897 assert_eq!(values.shape, vec![1]);
3898 assert_eq!(values.data, vec![42.0]);
3899 assert!(field_object
3900 .properties
3901 .contains_key(FEA_STUDY_CONTEXT_JSON_PROPERTY));
3902 assert_eq!(
3903 field_object.properties.get(FEA_RUN_ID_CONTEXT_PROPERTY),
3904 Some(&Value::String("synthetic_plot_run".to_string()))
3905 );
3906 }
3907
3908 #[cfg(feature = "plot-core")]
3909 #[test]
3910 fn fea_plot_returns_figure_handle_for_contextual_run_results_and_fields() {
3911 let (run_value, _study) = synthetic_plot_run_value();
3912
3913 let run_handle = block_on(fea_plot_builtin(vec![
3914 run_value.clone(),
3915 Value::String("von_mises".to_string()),
3916 ]))
3917 .expect("run plot should create a figure");
3918 assert!(matches!(run_handle, Value::Num(handle) if handle >= 1.0));
3919
3920 let results = block_on(fea_results_builtin(vec![run_value])).expect("results should load");
3921 let field = block_on(fea_field_builtin(vec![
3922 results,
3923 Value::String("structural.von_mises".to_string()),
3924 ]))
3925 .expect("field should resolve");
3926 let field_handle =
3927 block_on(fea_plot_builtin(vec![field])).expect("field plot should create a figure");
3928 assert!(matches!(field_handle, Value::Num(handle) if handle >= 1.0));
3929 }
3930
3931 fn synthetic_plot_run_value() -> (Value, Value) {
3932 crate::analysis::storage::configure_artifact_store(
3933 crate::analysis::storage::AnalysisArtifactStoreConfig::InMemory,
3934 )
3935 .expect("artifact store should configure");
3936
3937 let tmp = tempfile::tempdir().expect("tempdir should be created");
3938 std::fs::write(tmp.path().join("part.stl"), TRIANGLE_STL)
3939 .expect("geometry fixture should write");
3940 let fea_path = tmp.path().join("plot.fea");
3941 std::fs::write(
3942 &fea_path,
3943 r#"
3944version: 1
3945kind: study
3946id: synthetic_plot
3947geometry:
3948 path: part.stl
3949 units: meter
3950model:
3951 profile: linear_static_structural
3952run:
3953 backend: cpu
3954"#,
3955 )
3956 .expect("FEA fixture should write");
3957 let study = block_on(fea_load_builtin(fea_path.to_string_lossy().to_string()))
3958 .expect("study should load");
3959 let Value::Object(study_object) = &study else {
3960 panic!("expected study object");
3961 };
3962 let study_json = match study_object.properties.get(FEA_STUDY_SPEC_JSON_PROPERTY) {
3963 Some(Value::String(json)) => json.clone(),
3964 _ => panic!("expected study spec payload"),
3965 };
3966
3967 let run = crate::analysis::AnalysisRunResult {
3968 run_id: "synthetic_plot_run".to_string(),
3969 run: runmat_analysis_fea::FeaRunResult {
3970 backend: ComputeBackend::Cpu,
3971 solver_backend: "synthetic".to_string(),
3972 solver_device_apply_k_ratio: 0.0,
3973 solver_method: "synthetic".to_string(),
3974 preconditioner: "none".to_string(),
3975 solver_host_sync_count: 0,
3976 diagnostics: Vec::new(),
3977 fields: vec![AnalysisField::host_f64(
3978 "structural.von_mises",
3979 vec![1],
3980 vec![42.0],
3981 )],
3982 },
3983 render_topology: None,
3984 modal_results: None,
3985 thermal_results: None,
3986 transient_results: None,
3987 nonlinear_results: None,
3988 electromagnetic_results: None,
3989 model_validity: crate::analysis::QualityGate::Pass,
3990 solver_convergence: crate::analysis::QualityGate::Pass,
3991 result_quality: crate::analysis::QualityGate::Pass,
3992 run_status: crate::analysis::RunStatus::Publishable,
3993 publishable: true,
3994 quality_reasons: Vec::new(),
3995 provenance: crate::analysis::RunProvenance {
3996 backend: ComputeBackend::Cpu,
3997 solver_backend: "synthetic".to_string(),
3998 solver_device_apply_k_ratio: 0.0,
3999 solver_host_sync_count: 0,
4000 precision_mode: "fp64".to_string(),
4001 deterministic_mode: true,
4002 solver_method: "synthetic".to_string(),
4003 preconditioner: "none".to_string(),
4004 quality_policy: "balanced".to_string(),
4005 fallback_events: Vec::new(),
4006 },
4007 };
4008 crate::analysis::storage::persist_run_result(&run).expect("run should persist");
4009
4010 let mut object = ObjectInstance::new(FEA_RUN_RESULT_CLASS.to_string());
4011 object.properties.insert(
4012 "run_id".to_string(),
4013 Value::String("synthetic_plot_run".to_string()),
4014 );
4015 object.properties.insert(
4016 FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
4017 Value::String("synthetic_plot_run".to_string()),
4018 );
4019 object.properties.insert(
4020 FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
4021 Value::String(study_json),
4022 );
4023 (Value::Object(object), study)
4024 }
4025
4026 fn assert_object_class(value: &Value, expected: &str) {
4027 let Value::Object(object) = value else {
4028 panic!("expected object value");
4029 };
4030 assert_eq!(object.class_name, expected);
4031 assert!(
4032 object.properties.contains_key(FEA_PAYLOAD_JSON_PROPERTY)
4033 || object.properties.contains_key(FEA_STUDY_SPEC_JSON_PROPERTY)
4034 || object.properties.contains_key(FEA_SWEEP_SPEC_JSON_PROPERTY)
4035 );
4036 }
4037}