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