Skip to main content

runmat_runtime/builtins/fea/
mod.rs

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