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