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    CfdDomain, ElectroThermalDomain, ElectromagneticDomain, EvidenceConfidence, LoadCase, LoadKind,
5    MaterialAcousticModel, MaterialAssignment, MaterialElectricalModel, MaterialMechanicalModel,
6    MaterialModel, MaterialPlasticModel, MaterialThermalModel, ReferenceFrame,
7    ThermoMechanicalDomain,
8};
9use runmat_analysis_fea::ComputeBackend;
10use runmat_builtins::{
11    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor,
12    BuiltinIntegerAuditDescriptor, BuiltinIntegerAuditKind, BuiltinIntegerBackendRule,
13    BuiltinIntegerCapabilityDescriptor, BuiltinIntegerComputationDomain,
14    BuiltinIntegerInputAvailability, BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule,
15    BuiltinIntegerOverflowRule, BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule,
16    BuiltinOutputMode, BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType,
17    BuiltinSignatureDescriptor,
18};
19use runmat_geometry_core::GeometryAsset;
20use runmat_macros::runtime_builtin;
21use runmat_types::MemberAccess;
22use runmat_value::{IntValue, IntegerStorage, NumericScalar, ObjectInstance, Tensor, Value};
23use serde::de::DeserializeOwned;
24use serde::{Deserialize, Serialize};
25use std::collections::{HashMap, HashSet};
26use std::path::PathBuf;
27use std::sync::OnceLock;
28
29use crate::analysis::{
30    analysis_create_model_op, analysis_plan_study_op, analysis_plan_study_sweep_op,
31    analysis_results_by_run_id_op, analysis_results_compare_op, analysis_run_study_op,
32    analysis_run_study_sweep_op, analysis_trends_op, analysis_validate_study_op,
33    analysis_validate_study_sweep_op, load_fea_document_from_path_async,
34    AnalysisAcousticRunOptions, AnalysisCfdRunOptions, AnalysisChtRunOptions,
35    AnalysisCreateModelIntentSpec, AnalysisCreateModelProfile, AnalysisElectromagneticRunOptions,
36    AnalysisFieldDescriptor, AnalysisFsiRunOptions, AnalysisModalRunOptions,
37    AnalysisNonlinearRunOptions, AnalysisResultsCompareQuery, AnalysisResultsQuery,
38    AnalysisRunKind, AnalysisRunOptions, AnalysisStudySpec, AnalysisStudySweepData,
39    AnalysisStudySweepFailureEntry, AnalysisStudySweepPlanData, AnalysisStudySweepSpec,
40    AnalysisThermalRunOptions, AnalysisTransientRunOptions, AnalysisTrendsQuery,
41    FeaResolvedDocument,
42};
43use crate::builtins::common::{json::int_value_to_json, tensor as tensor_utils};
44use crate::builtins::geometry::{GEOMETRY_ASSET_CLASS, GEOMETRY_ASSET_JSON_PROPERTY};
45use crate::builtins::io::json::jsondecode::value_from_json;
46use crate::operations::{OperationContext, OperationEnvelope, OperationErrorEnvelope};
47use crate::{build_runtime_error, BuiltinResult, RuntimeError};
48
49mod author_study;
50
51const FEA_STUDY_CLASS: &str = "fea.Study";
52const FEA_SWEEP_CLASS: &str = "fea.Sweep";
53const FEA_VALIDATION_CLASS: &str = "fea.Validation";
54const FEA_PLAN_CLASS: &str = "fea.Plan";
55const FEA_RUN_RESULT_CLASS: &str = "fea.RunResult";
56const FEA_MODEL_CLASS: &str = "fea.Model";
57const FEA_MATERIAL_CLASS: &str = "fea.Material";
58const FEA_MATERIAL_ASSIGNMENT_CLASS: &str = "fea.MaterialAssignment";
59const FEA_BOUNDARY_CONDITION_CLASS: &str = "fea.BoundaryCondition";
60const FEA_LOAD_CASE_CLASS: &str = "fea.LoadCase";
61const FEA_STEP_CLASS: &str = "fea.Step";
62const FEA_DOMAIN_CLASS: &str = "fea.Domain";
63const FEA_INTERFACE_CLASS: &str = "fea.Interface";
64const FEA_RUN_OPTIONS_CLASS: &str = "fea.RunOptions";
65const FEA_RESULTS_CLASS: &str = "fea.Results";
66const FEA_FIELD_CLASS: &str = "fea.Field";
67const FEA_COMPARE_CLASS: &str = "fea.Compare";
68const FEA_TRENDS_CLASS: &str = "fea.Trends";
69const FEA_STUDY_SPEC_JSON_PROPERTY: &str = "__runmat_fea_study_spec_json";
70const FEA_SWEEP_SPEC_JSON_PROPERTY: &str = "__runmat_fea_sweep_spec_json";
71const FEA_PAYLOAD_JSON_PROPERTY: &str = "__runmat_fea_payload_json";
72const FEA_STUDY_CONTEXT_JSON_PROPERTY: &str = "__runmat_fea_study_context_json";
73const FEA_RUN_ID_CONTEXT_PROPERTY: &str = "__runmat_fea_run_id";
74
75const LOAD_NAME: &str = "fea.load";
76const STUDY_NAME: &str = "fea.study";
77const AUTHOR_STUDY_NAME: &str = "fea.authorStudy";
78const SWEEP_NAME: &str = "fea.sweep";
79const MODEL_NAME: &str = "fea.model";
80const MATERIAL_NAME: &str = "fea.material";
81const MATERIAL_ASSIGNMENT_NAME: &str = "fea.materialAssignment";
82const BOUNDARY_CONDITION_NAME: &str = "fea.boundaryCondition";
83const LOAD_CASE_NAME: &str = "fea.loadCase";
84const STEP_NAME: &str = "fea.step";
85const DOMAIN_NAME: &str = "fea.domain";
86const INTERFACE_NAME: &str = "fea.interface";
87const RUN_OPTIONS_NAME: &str = "fea.runOptions";
88const VALIDATE_NAME: &str = "fea.validate";
89const PLAN_NAME: &str = "fea.plan";
90const RUN_NAME: &str = "fea.run";
91const RESULTS_NAME: &str = "fea.results";
92const FIELD_NAME: &str = "fea.field";
93const PLOT_NAME: &str = "fea.plot";
94const COMPARE_NAME: &str = "fea.compare";
95const TRENDS_NAME: &str = "fea.trends";
96
97const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
98    name: "result",
99    ty: BuiltinParamType::Any,
100    arity: BuiltinParamArity::Required,
101    default: None,
102    description: "FEA object or operation result.",
103}];
104const IN_PATH: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
105    name: "path",
106    ty: BuiltinParamType::StringScalar,
107    arity: BuiltinParamArity::Required,
108    default: None,
109    description: "Path to a .fea file.",
110}];
111const IN_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
112    name: "study",
113    ty: BuiltinParamType::Any,
114    arity: BuiltinParamArity::Required,
115    default: None,
116    description: "A .fea path, fea.Study object, or fea.Sweep object.",
117}];
118const IN_STUDY_ARGS: [BuiltinParamDescriptor; 3] = [
119    BuiltinParamDescriptor {
120        name: "id",
121        ty: BuiltinParamType::StringScalar,
122        arity: BuiltinParamArity::Required,
123        default: None,
124        description: "Study id.",
125    },
126    BuiltinParamDescriptor {
127        name: "geometry",
128        ty: BuiltinParamType::Any,
129        arity: BuiltinParamArity::Required,
130        default: None,
131        description: "geometry.Asset returned by geometry.load.",
132    },
133    BuiltinParamDescriptor {
134        name: "Name, Value",
135        ty: BuiltinParamType::Any,
136        arity: BuiltinParamArity::Variadic,
137        default: None,
138        description: "Required Profile plus Backend, ModelId, and model setup options.",
139    },
140];
141const IN_AUTHOR_STUDY_ARGS: [BuiltinParamDescriptor; 4] = [
142    BuiltinParamDescriptor {
143        name: "id",
144        ty: BuiltinParamType::StringScalar,
145        arity: BuiltinParamArity::Required,
146        default: None,
147        description: "Study id.",
148    },
149    BuiltinParamDescriptor {
150        name: "geometry",
151        ty: BuiltinParamType::Any,
152        arity: BuiltinParamArity::Required,
153        default: None,
154        description: "geometry.Asset returned by geometry.load.",
155    },
156    BuiltinParamDescriptor {
157        name: "meshAuthoringSummary",
158        ty: BuiltinParamType::Any,
159        arity: BuiltinParamArity::Required,
160        default: None,
161        description: "Compact mesh authoring evidence summary.",
162    },
163    BuiltinParamDescriptor {
164        name: "Name, Value",
165        ty: BuiltinParamType::Any,
166        arity: BuiltinParamArity::Variadic,
167        default: None,
168        description:
169            "Required Profile plus Backend, boundary/driving region selectors, structural force vector, and analysis mesh artifact paths.",
170    },
171];
172const IN_VARIADIC_ARGS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
173    name: "args",
174    ty: BuiltinParamType::Any,
175    arity: BuiltinParamArity::Variadic,
176    default: None,
177    description: "Constructor or query arguments.",
178}];
179
180const MODEL_INPUTS: [BuiltinParamDescriptor; 3] = [
181    BuiltinParamDescriptor {
182        name: "id",
183        ty: BuiltinParamType::StringScalar,
184        arity: BuiltinParamArity::Required,
185        default: None,
186        description: "Model id.",
187    },
188    BuiltinParamDescriptor {
189        name: "geometry",
190        ty: BuiltinParamType::Any,
191        arity: BuiltinParamArity::Required,
192        default: None,
193        description: "Existing geometry.Asset object.",
194    },
195    BuiltinParamDescriptor {
196        name: "Name, Value",
197        ty: BuiltinParamType::Any,
198        arity: BuiltinParamArity::Variadic,
199        default: None,
200        description: "Profile, frame, defaults, and typed model-component options.",
201    },
202];
203const MATERIAL_INPUTS: [BuiltinParamDescriptor; 2] = [
204    BuiltinParamDescriptor {
205        name: "id",
206        ty: BuiltinParamType::StringScalar,
207        arity: BuiltinParamArity::Required,
208        default: None,
209        description: "Material id.",
210    },
211    BuiltinParamDescriptor {
212        name: "Name, Value",
213        ty: BuiltinParamType::Any,
214        arity: BuiltinParamArity::Variadic,
215        default: None,
216        description: "Mechanical, thermal, acoustic, electrical, and plastic material fields.",
217    },
218];
219const MATERIAL_ASSIGNMENT_INPUTS: [BuiltinParamDescriptor; 3] = [
220    BuiltinParamDescriptor {
221        name: "region",
222        ty: BuiltinParamType::StringScalar,
223        arity: BuiltinParamArity::Required,
224        default: None,
225        description: "Geometry region selector.",
226    },
227    BuiltinParamDescriptor {
228        name: "material",
229        ty: BuiltinParamType::StringScalar,
230        arity: BuiltinParamArity::Required,
231        default: None,
232        description: "Assigned material id.",
233    },
234    BuiltinParamDescriptor {
235        name: "Name, Value",
236        ty: BuiltinParamType::Any,
237        arity: BuiltinParamArity::Variadic,
238        default: None,
239        description: "Expected-material and confidence options.",
240    },
241];
242const LOAD_CASE_INPUTS: [BuiltinParamDescriptor; 4] = [
243    BuiltinParamDescriptor {
244        name: "id",
245        ty: BuiltinParamType::StringScalar,
246        arity: BuiltinParamArity::Required,
247        default: None,
248        description: "Load-case id.",
249    },
250    BuiltinParamDescriptor {
251        name: "region",
252        ty: BuiltinParamType::StringScalar,
253        arity: BuiltinParamArity::Required,
254        default: None,
255        description: "Geometry region selector.",
256    },
257    BuiltinParamDescriptor {
258        name: "kind",
259        ty: BuiltinParamType::StringScalar,
260        arity: BuiltinParamArity::Required,
261        default: None,
262        description: "Load kind.",
263    },
264    BuiltinParamDescriptor {
265        name: "Name, Value",
266        ty: BuiltinParamType::Any,
267        arity: BuiltinParamArity::Variadic,
268        default: None,
269        description: "Numeric fields required by the selected load kind.",
270    },
271];
272const DOMAIN_INPUTS: [BuiltinParamDescriptor; 2] = [
273    BuiltinParamDescriptor {
274        name: "kind",
275        ty: BuiltinParamType::StringScalar,
276        arity: BuiltinParamArity::Required,
277        default: None,
278        description: "Physics-domain kind.",
279    },
280    BuiltinParamDescriptor {
281        name: "Name, Value",
282        ty: BuiltinParamType::Any,
283        arity: BuiltinParamArity::Variadic,
284        default: None,
285        description: "Typed fields for the selected domain kind.",
286    },
287];
288const INTERFACE_INPUTS: [BuiltinParamDescriptor; 4] = [
289    BuiltinParamDescriptor {
290        name: "id",
291        ty: BuiltinParamType::StringScalar,
292        arity: BuiltinParamArity::Required,
293        default: None,
294        description: "Interface id.",
295    },
296    BuiltinParamDescriptor {
297        name: "primaryRegion",
298        ty: BuiltinParamType::StringScalar,
299        arity: BuiltinParamArity::Required,
300        default: None,
301        description: "Primary geometry region selector.",
302    },
303    BuiltinParamDescriptor {
304        name: "secondaryRegion",
305        ty: BuiltinParamType::StringScalar,
306        arity: BuiltinParamArity::Required,
307        default: None,
308        description: "Secondary geometry region selector.",
309    },
310    BuiltinParamDescriptor {
311        name: "Name, Value",
312        ty: BuiltinParamType::Any,
313        arity: BuiltinParamArity::Variadic,
314        default: None,
315        description: "Interface kind and numeric fields.",
316    },
317];
318const STEP_INPUTS: [BuiltinParamDescriptor; 2] = [
319    BuiltinParamDescriptor {
320        name: "id",
321        ty: BuiltinParamType::StringScalar,
322        arity: BuiltinParamArity::Required,
323        default: None,
324        description: "Analysis-step id.",
325    },
326    BuiltinParamDescriptor {
327        name: "kind",
328        ty: BuiltinParamType::StringScalar,
329        arity: BuiltinParamArity::Required,
330        default: None,
331        description:
332            "Static, modal, transient, thermal, nonlinear, electromagnetic, or CFD step kind.",
333    },
334];
335const RUN_OPTIONS_INPUTS: [BuiltinParamDescriptor; 2] = [
336    BuiltinParamDescriptor {
337        name: "solver",
338        ty: BuiltinParamType::StringScalar,
339        arity: BuiltinParamArity::Required,
340        default: None,
341        description: "FEA solver family.",
342    },
343    BuiltinParamDescriptor {
344        name: "Name, Value",
345        ty: BuiltinParamType::Any,
346        arity: BuiltinParamArity::Variadic,
347        default: None,
348        description:
349            "Family-specific structural, tolerance, timing, precision, and quality options.",
350    },
351];
352const SWEEP_INPUTS: [BuiltinParamDescriptor; 3] = [
353    BuiltinParamDescriptor {
354        name: "id",
355        ty: BuiltinParamType::StringScalar,
356        arity: BuiltinParamArity::Required,
357        default: None,
358        description: "Sweep id.",
359    },
360    BuiltinParamDescriptor {
361        name: "studies",
362        ty: BuiltinParamType::Any,
363        arity: BuiltinParamArity::Required,
364        default: None,
365        description: "A fea.Study or cell array of fea.Study objects.",
366    },
367    BuiltinParamDescriptor {
368        name: "Name, Value",
369        ty: BuiltinParamType::Any,
370        arity: BuiltinParamArity::Variadic,
371        default: None,
372        description: "Optional logical FailFast control.",
373    },
374];
375const RESULTS_INPUTS: [BuiltinParamDescriptor; 2] = [
376    BuiltinParamDescriptor {
377        name: "runOrRunId",
378        ty: BuiltinParamType::Any,
379        arity: BuiltinParamArity::Required,
380        default: None,
381        description: "Persisted run id, fea.RunResult, or fea.Results object.",
382    },
383    BuiltinParamDescriptor {
384        name: "Name, Value",
385        ty: BuiltinParamType::Any,
386        arity: BuiltinParamArity::Variadic,
387        default: None,
388        description: "Field, diagnostic, one-based mode/snapshot selector, and inclusion options.",
389    },
390];
391const TRENDS_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
392    name: "Name, Value",
393    ty: BuiltinParamType::Any,
394    arity: BuiltinParamArity::Variadic,
395    default: None,
396    description: "Optional positive integer WindowSize.",
397}];
398const FIELD_INPUTS: [BuiltinParamDescriptor; 2] = [
399    BuiltinParamDescriptor {
400        name: "resultsOrRun",
401        ty: BuiltinParamType::Any,
402        arity: BuiltinParamArity::Required,
403        default: None,
404        description: "fea.Results, fea.RunResult, or persisted run id.",
405    },
406    BuiltinParamDescriptor {
407        name: "fieldId",
408        ty: BuiltinParamType::StringScalar,
409        arity: BuiltinParamArity::Required,
410        default: None,
411        description: "Exact field id or unique dotted suffix.",
412    },
413];
414const PLOT_CONTEXT_INPUTS: [BuiltinParamDescriptor; 3] = [
415    BuiltinParamDescriptor {
416        name: "context",
417        ty: BuiltinParamType::Any,
418        arity: BuiltinParamArity::Required,
419        default: None,
420        description: "FEA run, results, field, or study context.",
421    },
422    BuiltinParamDescriptor {
423        name: "fieldId",
424        ty: BuiltinParamType::StringScalar,
425        arity: BuiltinParamArity::Optional,
426        default: None,
427        description: "Optional field id.",
428    },
429    BuiltinParamDescriptor {
430        name: "Name, Value",
431        ty: BuiltinParamType::Any,
432        arity: BuiltinParamArity::Variadic,
433        default: None,
434        description: "Optional Field or FieldId selector.",
435    },
436];
437const PLOT_STUDY_INPUTS: [BuiltinParamDescriptor; 4] = [
438    BuiltinParamDescriptor {
439        name: "study",
440        ty: BuiltinParamType::Any,
441        arity: BuiltinParamArity::Required,
442        default: None,
443        description: "FEA study carrying geometry context.",
444    },
445    BuiltinParamDescriptor {
446        name: "runId",
447        ty: BuiltinParamType::StringScalar,
448        arity: BuiltinParamArity::Required,
449        default: None,
450        description: "Persisted run id.",
451    },
452    BuiltinParamDescriptor {
453        name: "fieldId",
454        ty: BuiltinParamType::StringScalar,
455        arity: BuiltinParamArity::Optional,
456        default: None,
457        description: "Optional field id.",
458    },
459    BuiltinParamDescriptor {
460        name: "Name, Value",
461        ty: BuiltinParamType::Any,
462        arity: BuiltinParamArity::Variadic,
463        default: None,
464        description: "Optional Field or FieldId selector.",
465    },
466];
467const COMPARE_INPUTS: [BuiltinParamDescriptor; 2] = [
468    BuiltinParamDescriptor {
469        name: "baselineRunId",
470        ty: BuiltinParamType::StringScalar,
471        arity: BuiltinParamArity::Required,
472        default: None,
473        description: "Baseline persisted run id.",
474    },
475    BuiltinParamDescriptor {
476        name: "candidateRunId",
477        ty: BuiltinParamType::StringScalar,
478        arity: BuiltinParamArity::Required,
479        default: None,
480        description: "Candidate persisted run id.",
481    },
482];
483
484const LOAD_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
485    label: "doc = fea.load(path)",
486    inputs: &IN_PATH,
487    outputs: &OUT_ANY,
488}];
489const STUDY_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
490    BuiltinSignatureDescriptor {
491        label: "study = fea.study(path)",
492        inputs: &IN_PATH,
493        outputs: &OUT_ANY,
494    },
495    BuiltinSignatureDescriptor {
496        label: "study = fea.study(id, geometry, Name, Value, ...)",
497        inputs: &IN_STUDY_ARGS,
498        outputs: &OUT_ANY,
499    },
500];
501const AUTHOR_STUDY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
502    label: "study = fea.authorStudy(id, geometry, meshAuthoringSummary, Name, Value, ...)",
503    inputs: &IN_AUTHOR_STUDY_ARGS,
504    outputs: &OUT_ANY,
505}];
506const VALIDATE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
507    label: "result = fea.validate(studyOrSweepOrPath)",
508    inputs: &IN_INPUT,
509    outputs: &OUT_ANY,
510}];
511const PLAN_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
512    label: "plan = fea.plan(study)",
513    inputs: &IN_INPUT,
514    outputs: &OUT_ANY,
515}];
516const RUN_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
517    label: "run = fea.run(studyOrSweepOrPath)",
518    inputs: &IN_INPUT,
519    outputs: &OUT_ANY,
520}];
521const SWEEP_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
522    label: "sweep = fea.sweep(id, studies, Name, Value, ...)",
523    inputs: &SWEEP_INPUTS,
524    outputs: &OUT_ANY,
525}];
526const MODEL_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
527    label: "model = fea.model(id, geometry, Name, Value, ...)",
528    inputs: &MODEL_INPUTS,
529    outputs: &OUT_ANY,
530}];
531const MATERIAL_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
532    label: "material = fea.material(id, Name, Value, ...)",
533    inputs: &MATERIAL_INPUTS,
534    outputs: &OUT_ANY,
535}];
536const MATERIAL_ASSIGNMENT_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
537    [BuiltinSignatureDescriptor {
538        label: "assignment = fea.materialAssignment(region, material, Name, Value, ...)",
539        inputs: &MATERIAL_ASSIGNMENT_INPUTS,
540        outputs: &OUT_ANY,
541    }];
542const LOAD_CASE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
543    label: "load = fea.loadCase(id, region, kind, Name, Value, ...)",
544    inputs: &LOAD_CASE_INPUTS,
545    outputs: &OUT_ANY,
546}];
547const DOMAIN_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
548    label: "domain = fea.domain(kind, Name, Value, ...)",
549    inputs: &DOMAIN_INPUTS,
550    outputs: &OUT_ANY,
551}];
552const INTERFACE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
553    label: "interface = fea.interface(id, primaryRegion, secondaryRegion, Name, Value, ...)",
554    inputs: &INTERFACE_INPUTS,
555    outputs: &OUT_ANY,
556}];
557const COMPONENT_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
558    label: "component = fea.component(args, ...)",
559    inputs: &IN_VARIADIC_ARGS,
560    outputs: &OUT_ANY,
561}];
562const STEP_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
563    label: "step = fea.step(id, kind)",
564    inputs: &STEP_INPUTS,
565    outputs: &OUT_ANY,
566}];
567const RUN_OPTIONS_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
568    label: "options = fea.runOptions(solver, Name, Value, ...)",
569    inputs: &RUN_OPTIONS_INPUTS,
570    outputs: &OUT_ANY,
571}];
572const BOUNDARY_CONDITION_INPUTS: [BuiltinParamDescriptor; 4] = [
573    BuiltinParamDescriptor {
574        name: "id",
575        ty: BuiltinParamType::StringScalar,
576        arity: BuiltinParamArity::Required,
577        default: None,
578        description: "Boundary-condition id.",
579    },
580    BuiltinParamDescriptor {
581        name: "region",
582        ty: BuiltinParamType::StringScalar,
583        arity: BuiltinParamArity::Required,
584        default: None,
585        description: "Target region id.",
586    },
587    BuiltinParamDescriptor {
588        name: "kind",
589        ty: BuiltinParamType::StringScalar,
590        arity: BuiltinParamArity::Required,
591        default: None,
592        description: "Boundary-condition kind.",
593    },
594    BuiltinParamDescriptor {
595        name: "Name, Value",
596        ty: BuiltinParamType::Any,
597        arity: BuiltinParamArity::Variadic,
598        default: None,
599        description: "Numeric fields required by the selected kind.",
600    },
601];
602const BOUNDARY_CONDITION_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
603    [BuiltinSignatureDescriptor {
604        label: "bc = fea.boundaryCondition(id, region, kind, Name, Value, ...)",
605        inputs: &BOUNDARY_CONDITION_INPUTS,
606        outputs: &OUT_ANY,
607    }];
608const RESULTS_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
609    label: "results = fea.results(runOrRunId, Name, Value, ...)",
610    inputs: &RESULTS_INPUTS,
611    outputs: &OUT_ANY,
612}];
613const FIELD_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
614    label: "field = fea.field(resultsOrRun, fieldId)",
615    inputs: &FIELD_INPUTS,
616    outputs: &OUT_ANY,
617}];
618const PLOT_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
619    BuiltinSignatureDescriptor {
620        label: "figure = fea.plot(runOrResultsOrField, fieldId)",
621        inputs: &PLOT_CONTEXT_INPUTS,
622        outputs: &OUT_ANY,
623    },
624    BuiltinSignatureDescriptor {
625        label: "figure = fea.plot(study, runId, fieldId)",
626        inputs: &PLOT_STUDY_INPUTS,
627        outputs: &OUT_ANY,
628    },
629];
630const COMPARE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
631    label: "comparison = fea.compare(baselineRunId, candidateRunId)",
632    inputs: &COMPARE_INPUTS,
633    outputs: &OUT_ANY,
634}];
635const TRENDS_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
636    label: "trends = fea.trends(Name, Value, ...)",
637    inputs: &TRENDS_INPUTS,
638    outputs: &OUT_ANY,
639}];
640
641const ERROR_LOAD: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
642    code: "RM.FEA.BUILTIN.LOAD_FAILED",
643    identifier: Some("RunMat:fea:LoadFailed"),
644    when: "A .fea document cannot be read, parsed, or resolved.",
645    message: "fea: failed to load FEA document",
646};
647const ERROR_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
648    code: "RM.FEA.BUILTIN.INVALID_INPUT",
649    identifier: Some("RunMat:fea:InvalidInput"),
650    when: "A builtin receives an unsupported argument pattern or object type.",
651    message: "fea: invalid input",
652};
653const ERROR_OPERATION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
654    code: "RM.FEA.BUILTIN.OPERATION_FAILED",
655    identifier: Some("RunMat:fea:OperationFailed"),
656    when: "A validation, planning, run, result-query, comparison, or trend operation fails.",
657    message: "fea: operation failed",
658};
659const ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
660    code: "RM.FEA.BUILTIN.INTERNAL",
661    identifier: Some("RunMat:fea:Internal"),
662    when: "An FEA object or operation result cannot be converted to a RunMat value.",
663    message: "fea: internal error",
664};
665const ERRORS: [BuiltinErrorDescriptor; 4] =
666    [ERROR_LOAD, ERROR_INPUT, ERROR_OPERATION, ERROR_INTERNAL];
667
668pub const FEA_LOAD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
669    signatures: &LOAD_SIGNATURES,
670    output_mode: BuiltinOutputMode::Fixed,
671    completion_policy: BuiltinCompletionPolicy::Public,
672    errors: &ERRORS,
673};
674pub const FEA_STUDY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
675    signatures: &STUDY_SIGNATURES,
676    output_mode: BuiltinOutputMode::Fixed,
677    completion_policy: BuiltinCompletionPolicy::Public,
678    errors: &ERRORS,
679};
680pub const FEA_AUTHOR_STUDY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
681    signatures: &AUTHOR_STUDY_SIGNATURES,
682    output_mode: BuiltinOutputMode::Fixed,
683    completion_policy: BuiltinCompletionPolicy::Public,
684    errors: &ERRORS,
685};
686pub const FEA_VALIDATE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
687    signatures: &VALIDATE_SIGNATURES,
688    output_mode: BuiltinOutputMode::Fixed,
689    completion_policy: BuiltinCompletionPolicy::Public,
690    errors: &ERRORS,
691};
692pub const FEA_PLAN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
693    signatures: &PLAN_SIGNATURES,
694    output_mode: BuiltinOutputMode::Fixed,
695    completion_policy: BuiltinCompletionPolicy::Public,
696    errors: &ERRORS,
697};
698pub const FEA_RUN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
699    signatures: &RUN_SIGNATURES,
700    output_mode: BuiltinOutputMode::Fixed,
701    completion_policy: BuiltinCompletionPolicy::Public,
702    errors: &ERRORS,
703};
704pub const FEA_SWEEP_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
705    signatures: &SWEEP_SIGNATURES,
706    output_mode: BuiltinOutputMode::Fixed,
707    completion_policy: BuiltinCompletionPolicy::Public,
708    errors: &ERRORS,
709};
710pub const FEA_MODEL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
711    signatures: &MODEL_SIGNATURES,
712    output_mode: BuiltinOutputMode::Fixed,
713    completion_policy: BuiltinCompletionPolicy::Public,
714    errors: &ERRORS,
715};
716pub const FEA_MATERIAL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
717    signatures: &MATERIAL_SIGNATURES,
718    output_mode: BuiltinOutputMode::Fixed,
719    completion_policy: BuiltinCompletionPolicy::Public,
720    errors: &ERRORS,
721};
722pub const FEA_MATERIAL_ASSIGNMENT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
723    signatures: &MATERIAL_ASSIGNMENT_SIGNATURES,
724    output_mode: BuiltinOutputMode::Fixed,
725    completion_policy: BuiltinCompletionPolicy::Public,
726    errors: &ERRORS,
727};
728pub const FEA_LOAD_CASE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
729    signatures: &LOAD_CASE_SIGNATURES,
730    output_mode: BuiltinOutputMode::Fixed,
731    completion_policy: BuiltinCompletionPolicy::Public,
732    errors: &ERRORS,
733};
734pub const FEA_DOMAIN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
735    signatures: &DOMAIN_SIGNATURES,
736    output_mode: BuiltinOutputMode::Fixed,
737    completion_policy: BuiltinCompletionPolicy::Public,
738    errors: &ERRORS,
739};
740pub const FEA_INTERFACE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
741    signatures: &INTERFACE_SIGNATURES,
742    output_mode: BuiltinOutputMode::Fixed,
743    completion_policy: BuiltinCompletionPolicy::Public,
744    errors: &ERRORS,
745};
746pub const FEA_COMPONENT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
747    signatures: &COMPONENT_SIGNATURES,
748    output_mode: BuiltinOutputMode::Fixed,
749    completion_policy: BuiltinCompletionPolicy::Public,
750    errors: &ERRORS,
751};
752pub const FEA_STEP_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
753    signatures: &STEP_SIGNATURES,
754    output_mode: BuiltinOutputMode::Fixed,
755    completion_policy: BuiltinCompletionPolicy::Public,
756    errors: &ERRORS,
757};
758pub const FEA_RUN_OPTIONS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
759    signatures: &RUN_OPTIONS_SIGNATURES,
760    output_mode: BuiltinOutputMode::Fixed,
761    completion_policy: BuiltinCompletionPolicy::Public,
762    errors: &ERRORS,
763};
764
765const fn fea_floating_input(
766    name: &'static str,
767    scalar_double: BuiltinIntegerScalarDoubleRule,
768) -> BuiltinIntegerInputCapability {
769    BuiltinIntegerInputCapability {
770        name,
771        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
772        availability: BuiltinIntegerInputAvailability::Documented,
773        scalar_double,
774        notes: "Host integers cross once into a finite binary64 physics field; provider-resident values are rejected.",
775    }
776}
777
778const fn fea_floating_capability(
779    form: &'static str,
780    inputs: &'static [BuiltinIntegerInputCapability],
781    overload: BuiltinIntegerOverloadKind,
782) -> BuiltinIntegerCapabilityDescriptor {
783    BuiltinIntegerCapabilityDescriptor {
784        form,
785        inputs,
786        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
787        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
788        overflow: BuiltinIntegerOverflowRule::NotApplicable,
789        backend: BuiltinIntegerBackendRule::HostOnly,
790        overload,
791        notes: "The RunMat-native constructor validates its host value and performs one explicit IEEE-754 binary64 model-storage conversion; wide integers can round.",
792    }
793}
794
795const MATERIAL_MECHANICAL_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
796    [fea_floating_input(
797        "mechanical numeric fields",
798        BuiltinIntegerScalarDoubleRule::Allowed,
799    )];
800const MATERIAL_THERMAL_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
801    "thermal numeric fields",
802    BuiltinIntegerScalarDoubleRule::Allowed,
803)];
804const MATERIAL_ACOUSTIC_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
805    "acoustic numeric fields",
806    BuiltinIntegerScalarDoubleRule::Allowed,
807)];
808const MATERIAL_ELECTRICAL_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
809    [fea_floating_input(
810        "electrical numeric fields",
811        BuiltinIntegerScalarDoubleRule::Allowed,
812    )];
813const MATERIAL_RESPONSE_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
814    "conductivity response numeric fields",
815    BuiltinIntegerScalarDoubleRule::Allowed,
816)];
817const MATERIAL_PLASTIC_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
818    "plastic numeric fields",
819    BuiltinIntegerScalarDoubleRule::Allowed,
820)];
821pub const FEA_MATERIAL_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 6] = [
822    fea_floating_capability(
823        "mechanical material fields",
824        &MATERIAL_MECHANICAL_INTEGER_INPUTS,
825        BuiltinIntegerOverloadKind::ScalarOnly,
826    ),
827    fea_floating_capability(
828        "thermal material fields",
829        &MATERIAL_THERMAL_INTEGER_INPUTS,
830        BuiltinIntegerOverloadKind::ScalarOnly,
831    ),
832    fea_floating_capability(
833        "acoustic material fields",
834        &MATERIAL_ACOUSTIC_INTEGER_INPUTS,
835        BuiltinIntegerOverloadKind::ScalarOnly,
836    ),
837    fea_floating_capability(
838        "electrical material fields",
839        &MATERIAL_ELECTRICAL_INTEGER_INPUTS,
840        BuiltinIntegerOverloadKind::ScalarOnly,
841    ),
842    fea_floating_capability(
843        "electrical frequency-response fields",
844        &MATERIAL_RESPONSE_INTEGER_INPUTS,
845        BuiltinIntegerOverloadKind::Multiple,
846    ),
847    fea_floating_capability(
848        "plastic material fields",
849        &MATERIAL_PLASTIC_INTEGER_INPUTS,
850        BuiltinIntegerOverloadKind::ScalarOnly,
851    ),
852];
853
854const LOAD_VECTOR_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
855    "three-element vector",
856    BuiltinIntegerScalarDoubleRule::NotApplicable,
857)];
858const LOAD_SCALAR_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
859    "scalar physics fields",
860    BuiltinIntegerScalarDoubleRule::Allowed,
861)];
862const LOAD_CURRENT_DENSITY_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 2] = [
863    fea_floating_input(
864        "three-element vector",
865        BuiltinIntegerScalarDoubleRule::NotApplicable,
866    ),
867    fea_floating_input(
868        "phase and amplitude",
869        BuiltinIntegerScalarDoubleRule::Allowed,
870    ),
871];
872pub const FEA_LOAD_CASE_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 7] = [
873    fea_floating_capability(
874        "force vector",
875        &LOAD_VECTOR_INTEGER_INPUTS,
876        BuiltinIntegerOverloadKind::FunctionSpecific,
877    ),
878    fea_floating_capability(
879        "moment or torque vector",
880        &LOAD_VECTOR_INTEGER_INPUTS,
881        BuiltinIntegerOverloadKind::FunctionSpecific,
882    ),
883    fea_floating_capability(
884        "pressure magnitude",
885        &LOAD_SCALAR_INTEGER_INPUTS,
886        BuiltinIntegerOverloadKind::ScalarOnly,
887    ),
888    fea_floating_capability(
889        "body-force vector",
890        &LOAD_VECTOR_INTEGER_INPUTS,
891        BuiltinIntegerOverloadKind::FunctionSpecific,
892    ),
893    fea_floating_capability(
894        "current-density fields",
895        &LOAD_CURRENT_DENSITY_INTEGER_INPUTS,
896        BuiltinIntegerOverloadKind::Multiple,
897    ),
898    fea_floating_capability(
899        "coil-current fields",
900        &LOAD_SCALAR_INTEGER_INPUTS,
901        BuiltinIntegerOverloadKind::Multiple,
902    ),
903    fea_floating_capability(
904        "volumetric heat source",
905        &LOAD_SCALAR_INTEGER_INPUTS,
906        BuiltinIntegerOverloadKind::ScalarOnly,
907    ),
908];
909
910const DOMAIN_FLOATING_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
911    "domain numeric fields",
912    BuiltinIntegerScalarDoubleRule::Allowed,
913)];
914const DOMAIN_REVISION_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [BuiltinIntegerInputCapability {
915    name: "field_source.revision",
916    classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
917    availability: BuiltinIntegerInputAvailability::Documented,
918    scalar_double: BuiltinIntegerScalarDoubleRule::Rejected,
919    notes: "The structural revision is decoded exactly as u32 and rejects negative or out-of-range integer values.",
920}];
921pub const FEA_DOMAIN_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 5] = [
922    fea_floating_capability("thermo-mechanical physics fields", &DOMAIN_FLOATING_INTEGER_INPUTS, BuiltinIntegerOverloadKind::Multiple),
923    BuiltinIntegerCapabilityDescriptor { form: "thermo field-source revision", inputs: &DOMAIN_REVISION_INTEGER_INPUTS, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::NotApplicable, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::HostOnly, overload: BuiltinIntegerOverloadKind::StructuralParameter, notes: "The RunMat-native constructor preserves the exact u32 revision in both typed model storage and its public object representation." },
924    fea_floating_capability("electro-thermal physics fields", &DOMAIN_FLOATING_INTEGER_INPUTS, BuiltinIntegerOverloadKind::Multiple),
925    fea_floating_capability("electromagnetic physics fields", &DOMAIN_FLOATING_INTEGER_INPUTS, BuiltinIntegerOverloadKind::Multiple),
926    fea_floating_capability("CFD physics fields", &DOMAIN_FLOATING_INTEGER_INPUTS, BuiltinIntegerOverloadKind::Multiple),
927];
928
929const INTERFACE_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
930    "interface numeric fields",
931    BuiltinIntegerScalarDoubleRule::Allowed,
932)];
933pub const FEA_INTERFACE_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 3] = [
934    fea_floating_capability(
935        "contact interface fields",
936        &INTERFACE_INTEGER_INPUTS,
937        BuiltinIntegerOverloadKind::Multiple,
938    ),
939    fea_floating_capability(
940        "fluid-structure interface fields",
941        &INTERFACE_INTEGER_INPUTS,
942        BuiltinIntegerOverloadKind::Multiple,
943    ),
944    fea_floating_capability(
945        "conjugate-heat-transfer interface fields",
946        &INTERFACE_INTEGER_INPUTS,
947        BuiltinIntegerOverloadKind::Multiple,
948    ),
949];
950
951pub const FEA_STRUCTURAL_INTEGER_AUDIT: BuiltinIntegerAuditDescriptor = BuiltinIntegerAuditDescriptor {
952    kind: BuiltinIntegerAuditKind::NotApplicable,
953    canonical_builtin: None,
954    notes: "This RunMat-native FEA API accepts object, text, or enum inputs rather than numeric data; nested typed objects retain their already-defined numeric contracts and no provider gather occurs.",
955};
956
957const RUN_OPTIONS_EXACT_INPUTS: [BuiltinIntegerInputCapability; 1] =
958    [BuiltinIntegerInputCapability {
959        name: "structural iteration, step, retry, mode, and refresh counts",
960        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
961        availability: BuiltinIntegerInputAvailability::Documented,
962        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
963        notes: "Host integer scalars and integral scalar doubles are range-checked and decoded exactly as usize; provider-resident values are rejected.",
964    }];
965const RUN_OPTIONS_FLOATING_INPUTS: [BuiltinIntegerInputCapability; 1] = [fea_floating_input(
966    "tolerance, timing, residual, convergence, and frequency fields",
967    BuiltinIntegerScalarDoubleRule::Allowed,
968)];
969
970const fn run_options_exact_capability(form: &'static str) -> BuiltinIntegerCapabilityDescriptor {
971    BuiltinIntegerCapabilityDescriptor {
972        form,
973        inputs: &RUN_OPTIONS_EXACT_INPUTS,
974        computation_domain: BuiltinIntegerComputationDomain::Structural,
975        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
976        overflow: BuiltinIntegerOverflowRule::Error,
977        backend: BuiltinIntegerBackendRule::HostOnly,
978        overload: BuiltinIntegerOverloadKind::StructuralParameter,
979        notes: "The exact count is preserved in the typed run-options payload and public object representation.",
980    }
981}
982
983const fn run_options_floating_capability(form: &'static str) -> BuiltinIntegerCapabilityDescriptor {
984    BuiltinIntegerCapabilityDescriptor {
985        form,
986        inputs: &RUN_OPTIONS_FLOATING_INPUTS,
987        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
988        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
989        overflow: BuiltinIntegerOverflowRule::NotApplicable,
990        backend: BuiltinIntegerBackendRule::HostOnly,
991        overload: BuiltinIntegerOverloadKind::Multiple,
992        notes: "Floating solver controls use finite IEEE-754 binary64 storage; wide integer inputs can round while structural counts remain exact.",
993    }
994}
995
996pub const FEA_RUN_OPTIONS_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 18] = [
997    run_options_exact_capability("modal structural controls"),
998    run_options_floating_capability("modal floating controls"),
999    run_options_exact_capability("acoustic structural controls"),
1000    run_options_floating_capability("acoustic floating controls"),
1001    run_options_exact_capability("thermal structural controls"),
1002    run_options_floating_capability("thermal floating controls"),
1003    run_options_exact_capability("transient structural controls"),
1004    run_options_floating_capability("transient floating controls"),
1005    run_options_exact_capability("CFD structural controls"),
1006    run_options_floating_capability("CFD floating controls"),
1007    run_options_exact_capability("CHT structural controls"),
1008    run_options_floating_capability("CHT floating controls"),
1009    run_options_exact_capability("FSI structural controls"),
1010    run_options_floating_capability("FSI floating controls"),
1011    run_options_exact_capability("nonlinear structural controls"),
1012    run_options_floating_capability("nonlinear floating controls"),
1013    run_options_exact_capability("electromagnetic structural controls"),
1014    run_options_floating_capability("electromagnetic floating controls"),
1015];
1016
1017const RESULTS_SELECTOR_INPUTS: [BuiltinIntegerInputCapability; 1] =
1018    [BuiltinIntegerInputCapability {
1019        name: "one-based result indices",
1020        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
1021        availability: BuiltinIntegerInputAvailability::Documented,
1022        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
1023        notes: "Host numeric scalars or vectors are decoded exactly, require positive one-based indices, preserve order and duplicates, and reject matrix and provider-resident inputs.",
1024    }];
1025const RESULTS_FLAG_INPUTS: [BuiltinIntegerInputCapability; 1] =
1026    [BuiltinIntegerInputCapability {
1027        name: "numeric inclusion predicate",
1028        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
1029        availability: BuiltinIntegerInputAvailability::Documented,
1030        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
1031        notes: "Host scalar logical values and exact numeric zero or one are accepted; every other numeric value and provider-resident input is rejected.",
1032    }];
1033pub const FEA_RESULTS_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 3] = [
1034    BuiltinIntegerCapabilityDescriptor { form: "ModeIndices", inputs: &RESULTS_SELECTOR_INPUTS, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::FunctionSpecific, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::HostOnly, overload: BuiltinIntegerOverloadKind::StructuralParameter, notes: "The one-based public selector is translated once to the operation layer's zero-based index and public structural result fields remain exact." },
1035    BuiltinIntegerCapabilityDescriptor { form: "TransientSnapshotIndices", inputs: &RESULTS_SELECTOR_INPUTS, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::FunctionSpecific, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::HostOnly, overload: BuiltinIntegerOverloadKind::StructuralParameter, notes: "The one-based public selector is translated once to the operation layer's zero-based index and public structural result fields remain exact." },
1036    BuiltinIntegerCapabilityDescriptor { form: "numeric inclusion predicates", inputs: &RESULTS_FLAG_INPUTS, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::FunctionSpecific, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::HostOnly, overload: BuiltinIntegerOverloadKind::Multiple, notes: "Logical and numeric zero/one select query projections without converting provider data." },
1037];
1038
1039const TRENDS_WINDOW_INPUTS: [BuiltinIntegerInputCapability; 1] =
1040    [BuiltinIntegerInputCapability {
1041        name: "WindowSize",
1042        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
1043        availability: BuiltinIntegerInputAvailability::Documented,
1044        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
1045        notes: "A positive host integer scalar or integral scalar double is decoded exactly as usize; provider-resident values are rejected.",
1046    }];
1047pub const FEA_TRENDS_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
1048    [BuiltinIntegerCapabilityDescriptor { form: "WindowSize", inputs: &TRENDS_WINDOW_INPUTS, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::FunctionSpecific, overflow: BuiltinIntegerOverflowRule::Error, backend: BuiltinIntegerBackendRule::HostOnly, overload: BuiltinIntegerOverloadKind::StructuralParameter, notes: "The positive window size and structural trend counts remain exact in the public result object; time and rate fields remain binary64." }];
1049pub const FEA_BOUNDARY_CONDITION_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
1050    signatures: &BOUNDARY_CONDITION_SIGNATURES,
1051    output_mode: BuiltinOutputMode::Fixed,
1052    completion_policy: BuiltinCompletionPolicy::Public,
1053    errors: &ERRORS,
1054};
1055
1056const fn boundary_integer_input(name: &'static str) -> BuiltinIntegerInputCapability {
1057    BuiltinIntegerInputCapability {
1058        name,
1059        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
1060        availability: BuiltinIntegerInputAvailability::Documented,
1061        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
1062        notes: "An exact scalar is converted once to the model's binary64 storage field using Rust's IEEE-754 integer-to-f64 conversion.",
1063    }
1064}
1065
1066const BOUNDARY_ROTATION_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 3] = [
1067    boundary_integer_input("rx"),
1068    boundary_integer_input("ry"),
1069    boundary_integer_input("rz"),
1070];
1071const BOUNDARY_IMPEDANCE_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
1072    [boundary_integer_input("specificImpedancePaSPerM")];
1073const BOUNDARY_TEMPERATURE_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
1074    [boundary_integer_input("temperatureK")];
1075const BOUNDARY_HEAT_FLUX_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
1076    [boundary_integer_input("heatFluxWPerM2")];
1077const BOUNDARY_CONVECTION_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 2] = [
1078    boundary_integer_input("ambientTemperatureK"),
1079    boundary_integer_input("coefficientWPerM2K"),
1080];
1081const BOUNDARY_INLET_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
1082    [boundary_integer_input("velocityMPerS")];
1083const BOUNDARY_OUTLET_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
1084    [boundary_integer_input("pressurePa")];
1085
1086const fn boundary_integer_capability(
1087    form: &'static str,
1088    inputs: &'static [BuiltinIntegerInputCapability],
1089) -> BuiltinIntegerCapabilityDescriptor {
1090    BuiltinIntegerCapabilityDescriptor {
1091        form,
1092        inputs,
1093        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
1094        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
1095        overflow: BuiltinIntegerOverflowRule::NotApplicable,
1096        backend: BuiltinIntegerBackendRule::HostOnly,
1097        overload: BuiltinIntegerOverloadKind::ScalarOnly,
1098        notes: "The constructor validates scalar shape and finiteness, then performs one explicit IEEE-754 binary64 model-storage conversion; wide integers can therefore round.",
1099    }
1100}
1101
1102pub const FEA_BOUNDARY_CONDITION_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 7] = [
1103    boundary_integer_capability(
1104        "prescribedRotation integer fields",
1105        &BOUNDARY_ROTATION_INTEGER_INPUTS,
1106    ),
1107    boundary_integer_capability(
1108        "acousticImpedance integer field",
1109        &BOUNDARY_IMPEDANCE_INTEGER_INPUTS,
1110    ),
1111    boundary_integer_capability(
1112        "thermalPrescribedTemperature integer field",
1113        &BOUNDARY_TEMPERATURE_INTEGER_INPUTS,
1114    ),
1115    boundary_integer_capability(
1116        "thermalHeatFlux integer field",
1117        &BOUNDARY_HEAT_FLUX_INTEGER_INPUTS,
1118    ),
1119    boundary_integer_capability(
1120        "thermalConvection integer fields",
1121        &BOUNDARY_CONVECTION_INTEGER_INPUTS,
1122    ),
1123    boundary_integer_capability(
1124        "cfdInletVelocity integer field",
1125        &BOUNDARY_INLET_INTEGER_INPUTS,
1126    ),
1127    boundary_integer_capability(
1128        "cfdOutletPressure integer field",
1129        &BOUNDARY_OUTLET_INTEGER_INPUTS,
1130    ),
1131];
1132pub const FEA_RESULTS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
1133    signatures: &RESULTS_SIGNATURES,
1134    output_mode: BuiltinOutputMode::Fixed,
1135    completion_policy: BuiltinCompletionPolicy::Public,
1136    errors: &ERRORS,
1137};
1138pub const FEA_FIELD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
1139    signatures: &FIELD_SIGNATURES,
1140    output_mode: BuiltinOutputMode::Fixed,
1141    completion_policy: BuiltinCompletionPolicy::Public,
1142    errors: &ERRORS,
1143};
1144pub const FEA_PLOT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
1145    signatures: &PLOT_SIGNATURES,
1146    output_mode: BuiltinOutputMode::Fixed,
1147    completion_policy: BuiltinCompletionPolicy::Public,
1148    errors: &ERRORS,
1149};
1150pub const FEA_COMPARE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
1151    signatures: &COMPARE_SIGNATURES,
1152    output_mode: BuiltinOutputMode::Fixed,
1153    completion_policy: BuiltinCompletionPolicy::Public,
1154    errors: &ERRORS,
1155};
1156pub const FEA_TRENDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
1157    signatures: &TRENDS_SIGNATURES,
1158    output_mode: BuiltinOutputMode::Fixed,
1159    completion_policy: BuiltinCompletionPolicy::Public,
1160    errors: &ERRORS,
1161};
1162
1163#[runtime_builtin(
1164    name = "fea.load",
1165    category = "fea",
1166    summary = "Load a .fea study or sweep document.",
1167    keywords = "fea,study,sweep,load,yaml",
1168    descriptor(crate::builtins::fea::FEA_LOAD_DESCRIPTOR),
1169    builtin_path = "crate::builtins::fea"
1170)]
1171pub async fn fea_load_builtin(path: String) -> BuiltinResult<Value> {
1172    load_document_object(PathBuf::from(path)).await
1173}
1174
1175#[runtime_builtin(
1176    name = "fea.study",
1177    category = "fea",
1178    summary = "Create a typed FEA study from geometry, model data, and run settings.",
1179    keywords = "fea,study,geometry,run",
1180    descriptor(crate::builtins::fea::FEA_STUDY_DESCRIPTOR),
1181    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1182    builtin_path = "crate::builtins::fea"
1183)]
1184pub async fn fea_study_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1185    if args.len() == 1 {
1186        let path = scalar_string(&args[0], STUDY_NAME, &ERROR_INPUT)?;
1187        return load_document_object(PathBuf::from(path)).await;
1188    }
1189    create_study_object_from_args(args)
1190}
1191
1192#[runtime_builtin(
1193    name = "fea.authorStudy",
1194    category = "fea",
1195    summary = "Author a typed FEA study from compact mesh authoring evidence.",
1196    keywords = "fea,study,author,mesh,evidence,agent",
1197    descriptor(crate::builtins::fea::FEA_AUTHOR_STUDY_DESCRIPTOR),
1198    builtin_path = "crate::builtins::fea"
1199)]
1200pub async fn fea_author_study_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1201    author_study::create_author_study_object_from_args(args)
1202}
1203
1204#[runtime_builtin(
1205    name = "fea.sweep",
1206    category = "fea",
1207    summary = "Create a FEA study sweep from study objects.",
1208    keywords = "fea,sweep,study,run",
1209    descriptor(crate::builtins::fea::FEA_SWEEP_DESCRIPTOR),
1210    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1211    builtin_path = "crate::builtins::fea"
1212)]
1213pub async fn fea_sweep_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1214    create_sweep_object_from_args(args)
1215}
1216
1217#[runtime_builtin(
1218    name = "fea.model",
1219    category = "fea",
1220    summary = "Create a typed FEA model object from geometry and model components.",
1221    keywords = "fea,model,materials,boundary,loads,domains",
1222    descriptor(crate::builtins::fea::FEA_MODEL_DESCRIPTOR),
1223    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1224    builtin_path = "crate::builtins::fea"
1225)]
1226pub async fn fea_model_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1227    create_model_object_from_args(args)
1228}
1229
1230#[runtime_builtin(
1231    name = "fea.material",
1232    category = "fea",
1233    summary = "Create a typed FEA material object.",
1234    keywords = "fea,material,mechanical,thermal,electrical,plastic",
1235    descriptor(crate::builtins::fea::FEA_MATERIAL_DESCRIPTOR),
1236    integer_capabilities(crate::builtins::fea::FEA_MATERIAL_INTEGER_CAPABILITIES),
1237    builtin_path = "crate::builtins::fea"
1238)]
1239pub async fn fea_material_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1240    create_material_object_from_args(args)
1241}
1242
1243#[runtime_builtin(
1244    name = "fea.materialAssignment",
1245    category = "fea",
1246    summary = "Create a typed FEA material assignment.",
1247    keywords = "fea,material,assignment,region",
1248    descriptor(crate::builtins::fea::FEA_MATERIAL_ASSIGNMENT_DESCRIPTOR),
1249    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1250    builtin_path = "crate::builtins::fea"
1251)]
1252pub async fn fea_material_assignment_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1253    create_material_assignment_object_from_args(args)
1254}
1255
1256#[runtime_builtin(
1257    name = "fea.boundaryCondition",
1258    category = "fea",
1259    summary = "Create a typed FEA boundary condition.",
1260    keywords = "fea,boundary,condition,region,prescribed,rotation",
1261    descriptor(crate::builtins::fea::FEA_BOUNDARY_CONDITION_DESCRIPTOR),
1262    integer_capabilities(crate::builtins::fea::FEA_BOUNDARY_CONDITION_INTEGER_CAPABILITIES),
1263    builtin_path = "crate::builtins::fea"
1264)]
1265pub async fn fea_boundary_condition_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1266    create_boundary_condition_object_from_args(args)
1267}
1268
1269#[runtime_builtin(
1270    name = "fea.loadCase",
1271    category = "fea",
1272    summary = "Create a typed FEA load case.",
1273    keywords = "fea,load,force,moment,torque,pressure,current",
1274    descriptor(crate::builtins::fea::FEA_LOAD_CASE_DESCRIPTOR),
1275    integer_capabilities(crate::builtins::fea::FEA_LOAD_CASE_INTEGER_CAPABILITIES),
1276    builtin_path = "crate::builtins::fea"
1277)]
1278pub async fn fea_load_case_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1279    create_load_case_object_from_args(args)
1280}
1281
1282#[runtime_builtin(
1283    name = "fea.step",
1284    category = "fea",
1285    summary = "Create a typed FEA analysis step.",
1286    keywords = "fea,step,static,modal,transient",
1287    descriptor(crate::builtins::fea::FEA_STEP_DESCRIPTOR),
1288    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1289    builtin_path = "crate::builtins::fea"
1290)]
1291pub async fn fea_step_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1292    create_step_object_from_args(args)
1293}
1294
1295#[runtime_builtin(
1296    name = "fea.domain",
1297    category = "fea",
1298    summary = "Create a typed FEA physics domain object.",
1299    keywords = "fea,domain,thermal,electromagnetic,cfd",
1300    descriptor(crate::builtins::fea::FEA_DOMAIN_DESCRIPTOR),
1301    integer_capabilities(crate::builtins::fea::FEA_DOMAIN_INTEGER_CAPABILITIES),
1302    builtin_path = "crate::builtins::fea"
1303)]
1304pub async fn fea_domain_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1305    create_domain_object_from_args(args)
1306}
1307
1308#[runtime_builtin(
1309    name = "fea.interface",
1310    category = "fea",
1311    summary = "Create a typed FEA interface object.",
1312    keywords = "fea,interface,contact,region",
1313    descriptor(crate::builtins::fea::FEA_INTERFACE_DESCRIPTOR),
1314    integer_capabilities(crate::builtins::fea::FEA_INTERFACE_INTEGER_CAPABILITIES),
1315    builtin_path = "crate::builtins::fea"
1316)]
1317pub async fn fea_interface_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1318    create_interface_object_from_args(args)
1319}
1320
1321#[runtime_builtin(
1322    name = "fea.runOptions",
1323    category = "fea",
1324    summary = "Create typed FEA run options for a solver.",
1325    keywords = "fea,run,options,solver,quality",
1326    descriptor(crate::builtins::fea::FEA_RUN_OPTIONS_DESCRIPTOR),
1327    integer_capabilities(crate::builtins::fea::FEA_RUN_OPTIONS_INTEGER_CAPABILITIES),
1328    builtin_path = "crate::builtins::fea"
1329)]
1330pub async fn fea_run_options_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1331    create_run_options_object_from_args(args)
1332}
1333
1334#[runtime_builtin(
1335    name = "fea.validate",
1336    category = "fea",
1337    summary = "Validate a FEA study or sweep without planning or solving.",
1338    keywords = "fea,validate,study,sweep",
1339    descriptor(crate::builtins::fea::FEA_VALIDATE_DESCRIPTOR),
1340    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1341    builtin_path = "crate::builtins::fea"
1342)]
1343pub async fn fea_validate_builtin(input: Value) -> BuiltinResult<Value> {
1344    match resolve_document_input(input, VALIDATE_NAME).await? {
1345        FeaResolvedDocument::Study(spec) => operation_result_to_object(
1346            VALIDATE_NAME,
1347            &ERROR_OPERATION,
1348            &ERROR_INTERNAL,
1349            FEA_VALIDATION_CLASS,
1350            analysis_validate_study_op(&spec, OperationContext::new(None, None)),
1351            None,
1352        ),
1353        FeaResolvedDocument::Sweep(spec) => operation_result_to_object(
1354            VALIDATE_NAME,
1355            &ERROR_OPERATION,
1356            &ERROR_INTERNAL,
1357            FEA_VALIDATION_CLASS,
1358            analysis_validate_study_sweep_op(&spec, OperationContext::new(None, None)),
1359            None,
1360        ),
1361    }
1362}
1363
1364#[runtime_builtin(
1365    name = "fea.plan",
1366    category = "fea",
1367    summary = "Plan a FEA study or sweep without solving it.",
1368    keywords = "fea,plan,study,sweep",
1369    descriptor(crate::builtins::fea::FEA_PLAN_DESCRIPTOR),
1370    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1371    builtin_path = "crate::builtins::fea"
1372)]
1373pub async fn fea_plan_builtin(input: Value) -> BuiltinResult<Value> {
1374    match resolve_document_input(input, PLAN_NAME).await? {
1375        FeaResolvedDocument::Study(spec) => operation_result_to_object(
1376            PLAN_NAME,
1377            &ERROR_OPERATION,
1378            &ERROR_INTERNAL,
1379            FEA_PLAN_CLASS,
1380            analysis_plan_study_op(&spec, OperationContext::new(None, None)),
1381            None,
1382        ),
1383        FeaResolvedDocument::Sweep(spec) => sweep_plan_result_to_object(
1384            analysis_plan_study_sweep_op(&spec, OperationContext::new(None, None)),
1385        ),
1386    }
1387}
1388
1389#[runtime_builtin(
1390    name = "fea.run",
1391    category = "fea",
1392    summary = "Run a FEA study or sweep.",
1393    keywords = "fea,run,study,sweep,solve",
1394    descriptor(crate::builtins::fea::FEA_RUN_DESCRIPTOR),
1395    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1396    builtin_path = "crate::builtins::fea"
1397)]
1398pub async fn fea_run_builtin(input: Value) -> BuiltinResult<Value> {
1399    match resolve_document_input(input, RUN_NAME).await? {
1400        FeaResolvedDocument::Study(spec) => run_study_result_to_object(&spec),
1401        FeaResolvedDocument::Sweep(spec) => sweep_run_result_to_object(
1402            analysis_run_study_sweep_op(&spec, OperationContext::new(None, None)),
1403        ),
1404    }
1405}
1406
1407#[runtime_builtin(
1408    name = "fea.results",
1409    category = "fea",
1410    summary = "Load or project FEA run results for post-processing.",
1411    keywords = "fea,results,run_id,fields,diagnostics",
1412    descriptor(crate::builtins::fea::FEA_RESULTS_DESCRIPTOR),
1413    integer_capabilities(crate::builtins::fea::FEA_RESULTS_INTEGER_CAPABILITIES),
1414    builtin_path = "crate::builtins::fea"
1415)]
1416pub async fn fea_results_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1417    create_results_object_from_args(args)
1418}
1419
1420#[runtime_builtin(
1421    name = "fea.field",
1422    category = "fea",
1423    summary = "Extract a field from FEA results or a run result.",
1424    keywords = "fea,field,displacement,von_mises,post",
1425    descriptor(crate::builtins::fea::FEA_FIELD_DESCRIPTOR),
1426    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1427    builtin_path = "crate::builtins::fea"
1428)]
1429pub async fn fea_field_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1430    create_field_object_from_args(args)
1431}
1432
1433#[runtime_builtin(
1434    name = "fea.plot",
1435    category = "fea",
1436    summary = "Create a RunMat figure for an FEA result field on its geometry mesh.",
1437    keywords = "fea,plot,visualize,mesh,von_mises,stress,field",
1438    descriptor(crate::builtins::fea::FEA_PLOT_DESCRIPTOR),
1439    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1440    builtin_path = "crate::builtins::fea"
1441)]
1442pub async fn fea_plot_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1443    create_plot_from_args(args)
1444}
1445
1446#[runtime_builtin(
1447    name = "fea.compare",
1448    category = "fea",
1449    summary = "Compare two persisted FEA runs by run id.",
1450    keywords = "fea,compare,run_id,quality",
1451    descriptor(crate::builtins::fea::FEA_COMPARE_DESCRIPTOR),
1452    integer_audit(crate::builtins::fea::FEA_STRUCTURAL_INTEGER_AUDIT),
1453    builtin_path = "crate::builtins::fea"
1454)]
1455pub async fn fea_compare_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1456    create_compare_object_from_args(args)
1457}
1458
1459#[runtime_builtin(
1460    name = "fea.trends",
1461    category = "fea",
1462    summary = "Summarize recent persisted FEA run trends.",
1463    keywords = "fea,trends,history,quality",
1464    descriptor(crate::builtins::fea::FEA_TRENDS_DESCRIPTOR),
1465    integer_capabilities(crate::builtins::fea::FEA_TRENDS_INTEGER_CAPABILITIES),
1466    builtin_path = "crate::builtins::fea"
1467)]
1468pub async fn fea_trends_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
1469    create_trends_object_from_args(args)
1470}
1471
1472async fn load_document_object(path: PathBuf) -> BuiltinResult<Value> {
1473    let document = load_fea_document_from_path_async(&path)
1474        .await
1475        .map_err(|err| builtin_error(LOAD_NAME, &ERROR_LOAD, err))?;
1476    resolved_document_to_object(document)
1477}
1478
1479async fn resolve_document_input(
1480    input: Value,
1481    builtin: &'static str,
1482) -> BuiltinResult<FeaResolvedDocument> {
1483    match input {
1484        Value::Object(object) if object.class_name == FEA_STUDY_CLASS => {
1485            let spec: AnalysisStudySpec =
1486                object_json_property(builtin, &object, FEA_STUDY_SPEC_JSON_PROPERTY, &ERROR_INPUT)?;
1487            Ok(FeaResolvedDocument::Study(Box::new(spec)))
1488        }
1489        Value::Object(object) if object.class_name == FEA_SWEEP_CLASS => {
1490            let spec: AnalysisStudySweepSpec =
1491                object_json_property(builtin, &object, FEA_SWEEP_SPEC_JSON_PROPERTY, &ERROR_INPUT)?;
1492            Ok(FeaResolvedDocument::Sweep(spec))
1493        }
1494        Value::String(path) => load_fea_document_from_path_async(&PathBuf::from(path))
1495            .await
1496            .map_err(|err| builtin_error(builtin, &ERROR_LOAD, err)),
1497        Value::CharArray(chars) if chars.rows == 1 => {
1498            let path: String = chars.data.iter().collect();
1499            load_fea_document_from_path_async(&PathBuf::from(path))
1500                .await
1501                .map_err(|err| builtin_error(builtin, &ERROR_LOAD, err))
1502        }
1503        other => Err(builtin_error(
1504            builtin,
1505            &ERROR_INPUT,
1506            format!("expected .fea path, {FEA_STUDY_CLASS}, or {FEA_SWEEP_CLASS}; got {other:?}"),
1507        )),
1508    }
1509}
1510
1511#[derive(Debug, Clone, Serialize, Deserialize)]
1512struct RunOptionsPayload {
1513    run_kind: AnalysisRunKind,
1514    options: serde_json::Value,
1515}
1516
1517#[derive(Debug, Clone, Serialize, Deserialize)]
1518struct DomainPayload {
1519    kind: String,
1520    data: serde_json::Value,
1521}
1522
1523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1524enum ModelDefaultsMode {
1525    ProfileScaffold,
1526    None,
1527}
1528
1529impl Default for ModelDefaultsMode {
1530    fn default() -> Self {
1531        Self::ProfileScaffold
1532    }
1533}
1534
1535#[derive(Debug, Default)]
1536struct StudyConstructorOptions {
1537    run_kind: Option<AnalysisRunKind>,
1538    profile: Option<AnalysisCreateModelProfile>,
1539    backend: Option<ComputeBackend>,
1540    model_id: Option<String>,
1541    model: Option<AnalysisModel>,
1542    frame: Option<ReferenceFrame>,
1543    model_defaults: ModelDefaultsMode,
1544    materials: Vec<MaterialModel>,
1545    material_assignments: Vec<MaterialAssignment>,
1546    boundary_conditions: Vec<BoundaryCondition>,
1547    loads: Vec<LoadCase>,
1548    steps: Vec<AnalysisStep>,
1549    domains: Vec<DomainPayload>,
1550    interfaces: Vec<AnalysisInterface>,
1551    run_options: Option<RunOptionsPayload>,
1552}
1553
1554fn create_study_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1555    if args.len() < 2 {
1556        return Err(builtin_error(
1557            STUDY_NAME,
1558            &ERROR_INPUT,
1559            "fea.study requires id and geometry arguments",
1560        ));
1561    }
1562    let study_id = scalar_string(&args[0], STUDY_NAME, &ERROR_INPUT)?;
1563    let geometry = geometry_asset_from_value(STUDY_NAME, &args[1])?;
1564    let options = StudyConstructorOptions::parse(&args[2..])?;
1565    let (profile, run_kind) = resolve_study_profile_and_run_kind(&options)?;
1566    let model_id = options.model_id.clone().unwrap_or_else(|| {
1567        options
1568            .model
1569            .as_ref()
1570            .map(|model| model.model_id.0.clone())
1571            .unwrap_or_else(|| format!("{}_model", sanitize_id(&study_id)))
1572    });
1573    let model = match options.model {
1574        Some(model) => Some(model),
1575        None if options.has_model_components() => Some(build_model_from_parts(
1576            STUDY_NAME,
1577            &geometry,
1578            model_id.clone(),
1579            profile,
1580            options.model_defaults,
1581            options.frame,
1582            options.materials,
1583            options.material_assignments,
1584            options.boundary_conditions,
1585            options.loads,
1586            options.steps,
1587            options.domains,
1588            options.interfaces,
1589        )?),
1590        None => None,
1591    };
1592    let run_options = options
1593        .run_options
1594        .map(|payload| resolved_run_options_from_payload(STUDY_NAME, payload, run_kind))
1595        .transpose()?
1596        .unwrap_or_default();
1597    let spec = AnalysisStudySpec {
1598        study_id,
1599        geometry,
1600        create_model_intent: AnalysisCreateModelIntentSpec {
1601            model_id,
1602            profile,
1603            prep_context: None,
1604        },
1605        model,
1606        run_kind,
1607        backend: options.backend.unwrap_or(ComputeBackend::Cpu),
1608        mesh_options: None,
1609        outputs: Vec::new(),
1610        analysis_mesh_artifact_path: None,
1611        analysis_mesh_evidence_artifact_path: None,
1612        linear_static_run_options: run_options.linear_static,
1613        modal_run_options: run_options.modal,
1614        acoustic_run_options: run_options.acoustic,
1615        thermal_run_options: run_options.thermal,
1616        transient_run_options: run_options.transient,
1617        cfd_run_options: run_options.cfd,
1618        cht_run_options: run_options.cht,
1619        fsi_run_options: run_options.fsi,
1620        nonlinear_run_options: run_options.nonlinear,
1621        electromagnetic_run_options: run_options.electromagnetic,
1622    };
1623    study_to_object(spec)
1624}
1625
1626impl StudyConstructorOptions {
1627    fn parse(args: &[Value]) -> BuiltinResult<Self> {
1628        if !args.len().is_multiple_of(2) {
1629            return Err(builtin_error(
1630                STUDY_NAME,
1631                &ERROR_INPUT,
1632                "fea.study options must be Name, Value pairs",
1633            ));
1634        }
1635        let mut options = Self::default();
1636        let mut seen = HashSet::new();
1637        for pair in args.chunks(2) {
1638            let key = option_key(&pair[0], STUDY_NAME)?;
1639            let canonical = match key.as_str() {
1640                "runkind" | "kind" => "runkind",
1641                "materialassignments" | "assignments" => "materialassignments",
1642                "boundaryconditions" | "bcs" => "boundaryconditions",
1643                "loads" | "loadcases" => "loads",
1644                "runoptions" | "options" => "runoptions",
1645                other => other,
1646            };
1647            if !seen.insert(canonical.to_string()) {
1648                return Err(builtin_error(
1649                    STUDY_NAME,
1650                    &ERROR_INPUT,
1651                    format!("duplicate fea.study option `{canonical}`"),
1652                ));
1653            }
1654            match key.as_str() {
1655                "runkind" | "kind" => {
1656                    let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
1657                    options.run_kind = Some(parse_scalar_enum(&text, "RunKind")?);
1658                }
1659                "profile" => {
1660                    let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
1661                    options.profile = Some(parse_scalar_enum(&text, "Profile")?);
1662                }
1663                "backend" => {
1664                    let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
1665                    options.backend = Some(parse_scalar_enum(&text, "Backend")?);
1666                }
1667                "modelid" => {
1668                    options.model_id = Some(scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?);
1669                }
1670                "model" => {
1671                    options.model = Some(model_from_value(STUDY_NAME, &pair[1])?);
1672                }
1673                "frame" => {
1674                    let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
1675                    options.frame = Some(parse_scalar_enum(&text, "Frame")?);
1676                }
1677                "defaults" => {
1678                    options.model_defaults = parse_model_defaults_mode(&scalar_string(
1679                        &pair[1],
1680                        STUDY_NAME,
1681                        &ERROR_INPUT,
1682                    )?)?;
1683                }
1684                "materials" => options.materials = material_vec_from_value(STUDY_NAME, &pair[1])?,
1685                "materialassignments" | "assignments" => {
1686                    options.material_assignments =
1687                        material_assignment_vec_from_value(STUDY_NAME, &pair[1])?;
1688                }
1689                "boundaryconditions" | "bcs" => {
1690                    options.boundary_conditions =
1691                        boundary_condition_vec_from_value(STUDY_NAME, &pair[1])?;
1692                }
1693                "loads" | "loadcases" => {
1694                    options.loads = load_case_vec_from_value(STUDY_NAME, &pair[1])?;
1695                }
1696                "steps" => options.steps = step_vec_from_value(STUDY_NAME, &pair[1])?,
1697                "domains" => options.domains = domain_vec_from_value(STUDY_NAME, &pair[1])?,
1698                "interfaces" => {
1699                    options.interfaces = interface_vec_from_value(STUDY_NAME, &pair[1])?;
1700                }
1701                "runoptions" | "options" => {
1702                    options.run_options =
1703                        Some(run_options_payload_from_value(STUDY_NAME, &pair[1])?);
1704                }
1705                other => {
1706                    return Err(builtin_error(
1707                        STUDY_NAME,
1708                        &ERROR_INPUT,
1709                        format!("unsupported fea.study option `{other}`"),
1710                    ));
1711                }
1712            }
1713        }
1714        Ok(options)
1715    }
1716
1717    fn has_model_components(&self) -> bool {
1718        self.frame.is_some()
1719            || !self.materials.is_empty()
1720            || !self.material_assignments.is_empty()
1721            || !self.boundary_conditions.is_empty()
1722            || !self.loads.is_empty()
1723            || !self.steps.is_empty()
1724            || !self.domains.is_empty()
1725            || !self.interfaces.is_empty()
1726    }
1727}
1728
1729#[derive(Debug, Default)]
1730struct ModelConstructorOptions {
1731    profile: Option<AnalysisCreateModelProfile>,
1732    frame: Option<ReferenceFrame>,
1733    defaults: ModelDefaultsMode,
1734    materials: Vec<MaterialModel>,
1735    material_assignments: Vec<MaterialAssignment>,
1736    boundary_conditions: Vec<BoundaryCondition>,
1737    loads: Vec<LoadCase>,
1738    steps: Vec<AnalysisStep>,
1739    domains: Vec<DomainPayload>,
1740    interfaces: Vec<AnalysisInterface>,
1741}
1742
1743#[derive(Debug, Default)]
1744struct ResolvedRunOptions {
1745    linear_static: Option<AnalysisRunOptions>,
1746    modal: Option<AnalysisModalRunOptions>,
1747    acoustic: Option<AnalysisAcousticRunOptions>,
1748    thermal: Option<AnalysisThermalRunOptions>,
1749    transient: Option<AnalysisTransientRunOptions>,
1750    cfd: Option<AnalysisCfdRunOptions>,
1751    cht: Option<AnalysisChtRunOptions>,
1752    fsi: Option<AnalysisFsiRunOptions>,
1753    nonlinear: Option<AnalysisNonlinearRunOptions>,
1754    electromagnetic: Option<AnalysisElectromagneticRunOptions>,
1755}
1756
1757fn create_sweep_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1758    if args.len() < 2 {
1759        return Err(builtin_error(
1760            SWEEP_NAME,
1761            &ERROR_INPUT,
1762            "fea.sweep requires id and studies arguments",
1763        ));
1764    }
1765    let sweep_id = scalar_string(&args[0], SWEEP_NAME, &ERROR_INPUT)?;
1766    let studies = study_vec_from_value(SWEEP_NAME, &args[1])?;
1767    let mut fail_fast = true;
1768    let mut fail_fast_seen = false;
1769    for pair in expect_name_value_tail(SWEEP_NAME, &args[2..])? {
1770        match pair.key.as_str() {
1771            "failfast" => {
1772                if fail_fast_seen {
1773                    return Err(builtin_error(
1774                        SWEEP_NAME,
1775                        &ERROR_INPUT,
1776                        "duplicate fea.sweep option `failfast`",
1777                    ));
1778                }
1779                fail_fast_seen = true;
1780                fail_fast = logical_from_value(SWEEP_NAME, pair.value)?;
1781            }
1782            other => {
1783                return Err(builtin_error(
1784                    SWEEP_NAME,
1785                    &ERROR_INPUT,
1786                    format!("unsupported fea.sweep option `{other}`"),
1787                ));
1788            }
1789        }
1790    }
1791    sweep_to_object(AnalysisStudySweepSpec {
1792        sweep_id,
1793        studies,
1794        fail_fast,
1795    })
1796}
1797
1798fn create_model_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1799    if args.len() < 2 {
1800        return Err(builtin_error(
1801            MODEL_NAME,
1802            &ERROR_INPUT,
1803            "fea.model requires id and geometry arguments",
1804        ));
1805    }
1806    let model_id = scalar_string(&args[0], MODEL_NAME, &ERROR_INPUT)?;
1807    let geometry = geometry_asset_from_value(MODEL_NAME, &args[1])?;
1808    let options = parse_model_constructor_options(MODEL_NAME, &args[2..])?;
1809    let profile = options.profile.ok_or_else(|| {
1810        builtin_error(
1811            MODEL_NAME,
1812            &ERROR_INPUT,
1813            "fea.model requires Profile; choose a physics profile from fea.capabilities().physicsProfiles",
1814        )
1815    })?;
1816    let model = build_model_from_parts(
1817        MODEL_NAME,
1818        &geometry,
1819        model_id,
1820        profile,
1821        options.defaults,
1822        options.frame,
1823        options.materials,
1824        options.material_assignments,
1825        options.boundary_conditions,
1826        options.loads,
1827        options.steps,
1828        options.domains,
1829        options.interfaces,
1830    )?;
1831    serializable_to_object_preserving_integers(
1832        MODEL_NAME,
1833        &ERROR_INTERNAL,
1834        FEA_MODEL_CLASS,
1835        &model,
1836        Some(FEA_PAYLOAD_JSON_PROPERTY),
1837        &[],
1838        &["geometry_revision", "revision"],
1839    )
1840}
1841
1842fn parse_model_constructor_options(
1843    builtin: &'static str,
1844    args: &[Value],
1845) -> BuiltinResult<ModelConstructorOptions> {
1846    let mut options = ModelConstructorOptions::default();
1847    for pair in expect_name_value_tail(builtin, args)? {
1848        match pair.key.as_str() {
1849            "profile" => {
1850                let text = scalar_string(pair.value, builtin, &ERROR_INPUT)?;
1851                options.profile = Some(parse_scalar_enum(&text, "Profile")?);
1852            }
1853            "frame" => {
1854                let text = scalar_string(pair.value, builtin, &ERROR_INPUT)?;
1855                options.frame = Some(parse_scalar_enum(&text, "Frame")?);
1856            }
1857            "defaults" => {
1858                options.defaults =
1859                    parse_model_defaults_mode(&scalar_string(pair.value, builtin, &ERROR_INPUT)?)?;
1860            }
1861            "materials" => options.materials = material_vec_from_value(builtin, pair.value)?,
1862            "materialassignments" | "assignments" => {
1863                options.material_assignments =
1864                    material_assignment_vec_from_value(builtin, pair.value)?;
1865            }
1866            "boundaryconditions" | "bcs" => {
1867                options.boundary_conditions =
1868                    boundary_condition_vec_from_value(builtin, pair.value)?;
1869            }
1870            "loads" | "loadcases" => options.loads = load_case_vec_from_value(builtin, pair.value)?,
1871            "steps" => options.steps = step_vec_from_value(builtin, pair.value)?,
1872            "domains" => options.domains = domain_vec_from_value(builtin, pair.value)?,
1873            "interfaces" => options.interfaces = interface_vec_from_value(builtin, pair.value)?,
1874            other => {
1875                return Err(builtin_error(
1876                    builtin,
1877                    &ERROR_INPUT,
1878                    format!("unsupported {builtin} option `{other}`"),
1879                ));
1880            }
1881        }
1882    }
1883    Ok(options)
1884}
1885
1886fn create_material_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
1887    if args.is_empty() {
1888        return Err(builtin_error(
1889            MATERIAL_NAME,
1890            &ERROR_INPUT,
1891            "fea.material requires a material id",
1892        ));
1893    }
1894    let material_id = scalar_string(&args[0], MATERIAL_NAME, &ERROR_INPUT)?;
1895    let mut fields = json_fields_from_name_values(MATERIAL_NAME, &args[1..])?;
1896    let name = fields
1897        .remove("name")
1898        .map(json_to_string)
1899        .transpose()?
1900        .unwrap_or_else(|| material_id.clone());
1901    let mechanical = if let Some(value) = fields.remove("mechanical") {
1902        json_deserialize(MATERIAL_NAME, value, "mechanical material model")?
1903    } else {
1904        let youngs = remove_required_f64(&mut fields, MATERIAL_NAME, "youngs_modulus_pa")?;
1905        let poisson = remove_required_f64(&mut fields, MATERIAL_NAME, "poisson_ratio")?;
1906        let density =
1907            remove_optional_f64(&mut fields, MATERIAL_NAME, "density_kg_per_m3")?.unwrap_or(7850.0);
1908        MaterialMechanicalModel {
1909            youngs_modulus_pa: youngs,
1910            poisson_ratio: poisson,
1911            density_kg_per_m3: density,
1912        }
1913    };
1914    let thermal = if let Some(value) = fields.remove("thermal") {
1915        json_deserialize(MATERIAL_NAME, value, "thermal material model")?
1916    } else {
1917        let mut thermal = serde_json::to_value(MaterialThermalModel::default())
1918            .map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
1919        move_known_fields(
1920            &mut fields,
1921            thermal.as_object_mut().expect("thermal model is object"),
1922            &[
1923                "reference_temperature_k",
1924                "modulus_temp_coeff_per_k",
1925                "conductivity_w_per_mk",
1926                "specific_heat_j_per_kgk",
1927                "expansion_coefficient_per_k",
1928            ],
1929        );
1930        json_deserialize(MATERIAL_NAME, thermal, "thermal material model")?
1931    };
1932    let electrical = if let Some(value) = fields.remove("electrical") {
1933        Some(json_deserialize(
1934            MATERIAL_NAME,
1935            value,
1936            "electrical material model",
1937        )?)
1938    } else {
1939        let mut electrical = serde_json::to_value(MaterialElectricalModel::default())
1940            .map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
1941        let moved = move_known_fields(
1942            &mut fields,
1943            electrical
1944                .as_object_mut()
1945                .expect("electrical material model is object"),
1946            &[
1947                "reference_temperature_k",
1948                "conductivity_s_per_m",
1949                "resistive_heating_coefficient",
1950                "relative_permittivity",
1951                "relative_permeability",
1952                "conductivity_frequency_response",
1953            ],
1954        );
1955        if moved {
1956            Some(json_deserialize(
1957                MATERIAL_NAME,
1958                electrical,
1959                "electrical material model",
1960            )?)
1961        } else {
1962            None
1963        }
1964    };
1965    let acoustic = if let Some(value) = fields.remove("acoustic") {
1966        Some(json_deserialize(
1967            MATERIAL_NAME,
1968            value,
1969            "acoustic material model",
1970        )?)
1971    } else {
1972        let mut acoustic = serde_json::to_value(MaterialAcousticModel::default())
1973            .map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
1974        let moved = move_known_fields(
1975            &mut fields,
1976            acoustic
1977                .as_object_mut()
1978                .expect("acoustic material model is object"),
1979            &[
1980                "density_kg_per_m3",
1981                "speed_of_sound_m_per_s",
1982                "damping_ratio",
1983            ],
1984        );
1985        if moved {
1986            Some(json_deserialize(
1987                MATERIAL_NAME,
1988                acoustic,
1989                "acoustic material model",
1990            )?)
1991        } else {
1992            None
1993        }
1994    };
1995    let plastic = if let Some(value) = fields.remove("plastic") {
1996        Some(json_deserialize(
1997            MATERIAL_NAME,
1998            value,
1999            "plastic material model",
2000        )?)
2001    } else if fields.contains_key("yield_strain")
2002        || fields.contains_key("hardening_modulus_ratio")
2003        || fields.contains_key("saturation_exponent")
2004    {
2005        Some(MaterialPlasticModel {
2006            yield_strain: remove_required_f64(&mut fields, MATERIAL_NAME, "yield_strain")?,
2007            hardening_modulus_ratio: remove_required_f64(
2008                &mut fields,
2009                MATERIAL_NAME,
2010                "hardening_modulus_ratio",
2011            )?,
2012            saturation_exponent: remove_required_f64(
2013                &mut fields,
2014                MATERIAL_NAME,
2015                "saturation_exponent",
2016            )?,
2017        })
2018    } else {
2019        None
2020    };
2021    reject_unknown_fields(MATERIAL_NAME, fields)?;
2022    material_to_object(MaterialModel {
2023        material_id,
2024        name,
2025        mechanical,
2026        thermal,
2027        acoustic,
2028        electrical,
2029        plastic,
2030    })
2031}
2032
2033fn create_material_assignment_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2034    if args.len() < 2 {
2035        return Err(builtin_error(
2036            MATERIAL_ASSIGNMENT_NAME,
2037            &ERROR_INPUT,
2038            "fea.materialAssignment requires region and material arguments",
2039        ));
2040    }
2041    let region_id = scalar_string(&args[0], MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
2042    let assigned_material_id = scalar_string(&args[1], MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
2043    let mut expected_material_id = assigned_material_id.clone();
2044    let mut confidence = EvidenceConfidence::Verified;
2045    for pair in expect_name_value_tail(MATERIAL_ASSIGNMENT_NAME, &args[2..])? {
2046        match pair.key.as_str() {
2047            "expectedmaterial" | "expectedmaterialid" => {
2048                expected_material_id =
2049                    scalar_string(pair.value, MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
2050            }
2051            "confidence" => {
2052                let text = scalar_string(pair.value, MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
2053                confidence = parse_scalar_enum(&text, "Confidence")?;
2054            }
2055            other => {
2056                return Err(builtin_error(
2057                    MATERIAL_ASSIGNMENT_NAME,
2058                    &ERROR_INPUT,
2059                    format!("unsupported fea.materialAssignment option `{other}`"),
2060                ));
2061            }
2062        }
2063    }
2064    material_assignment_to_object(MaterialAssignment {
2065        region_id,
2066        expected_material_id,
2067        assigned_material_id,
2068        confidence,
2069    })
2070}
2071
2072fn create_boundary_condition_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2073    if args.len() < 3 {
2074        return Err(builtin_error(
2075            BOUNDARY_CONDITION_NAME,
2076            &ERROR_INPUT,
2077            "fea.boundaryCondition requires id, region, and kind arguments",
2078        ));
2079    }
2080    let bc_id = scalar_string(&args[0], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
2081    let region_id = scalar_string(&args[1], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
2082    let kind_text = scalar_string(&args[2], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
2083    let mut fields = boundary_fields_from_name_values(&args[3..])?;
2084    let kind = match normalize_token(&kind_text).as_str() {
2085        "prescribedrotation" => BoundaryConditionKind::PrescribedRotation {
2086            rx: remove_required_boundary_f64(&mut fields, "rx")?,
2087            ry: remove_required_boundary_f64(&mut fields, "ry")?,
2088            rz: remove_required_boundary_f64(&mut fields, "rz")?,
2089        },
2090        "acousticimpedance" => BoundaryConditionKind::AcousticImpedance {
2091            specific_impedance_pa_s_per_m: remove_required_boundary_f64(
2092                &mut fields,
2093                "specific_impedance_pa_s_per_m",
2094            )?,
2095        },
2096        "thermalprescribedtemperature" => BoundaryConditionKind::ThermalPrescribedTemperature {
2097            temperature_k: remove_required_boundary_f64(&mut fields, "temperature_k")?,
2098        },
2099        "thermalheatflux" => BoundaryConditionKind::ThermalHeatFlux {
2100            heat_flux_w_per_m2: remove_required_boundary_f64(&mut fields, "heat_flux_w_per_m2")?,
2101        },
2102        "thermalconvection" => BoundaryConditionKind::ThermalConvection {
2103            ambient_temperature_k: remove_required_boundary_f64(
2104                &mut fields,
2105                "ambient_temperature_k",
2106            )?,
2107            coefficient_w_per_m2k: remove_required_boundary_f64(
2108                &mut fields,
2109                "coefficient_w_per_m2k",
2110            )?,
2111        },
2112        "cfdinletvelocity" => BoundaryConditionKind::CfdInletVelocity {
2113            velocity_m_per_s: remove_required_boundary_f64(&mut fields, "velocity_m_per_s")?,
2114        },
2115        "cfdoutletpressure" => BoundaryConditionKind::CfdOutletPressure {
2116            pressure_pa: remove_required_boundary_f64(&mut fields, "pressure_pa")?,
2117        },
2118        _ => parse_scalar_enum_for_builtin::<BoundaryConditionKind>(
2119            BOUNDARY_CONDITION_NAME,
2120            &kind_text,
2121            "BoundaryConditionKind",
2122        )?,
2123    };
2124    reject_unknown_boundary_fields(fields)?;
2125    boundary_condition_to_object(BoundaryCondition {
2126        bc_id,
2127        region_id,
2128        kind,
2129    })
2130}
2131
2132fn boundary_fields_from_name_values(args: &[Value]) -> BuiltinResult<HashMap<String, &Value>> {
2133    let mut fields = HashMap::new();
2134    for pair in expect_name_value_tail(BOUNDARY_CONDITION_NAME, args)? {
2135        let raw = scalar_string(pair.name, BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
2136        let key = canonical_field_name(&raw);
2137        if fields.insert(key.clone(), pair.value).is_some() {
2138            return Err(builtin_error(
2139                BOUNDARY_CONDITION_NAME,
2140                &ERROR_INPUT,
2141                format!("duplicate fea.boundaryCondition option `{key}`"),
2142            ));
2143        }
2144    }
2145    Ok(fields)
2146}
2147
2148fn remove_required_boundary_f64(
2149    fields: &mut HashMap<String, &Value>,
2150    key: &str,
2151) -> BuiltinResult<f64> {
2152    let Some(value) = fields.remove(key) else {
2153        return Err(builtin_error(
2154            BOUNDARY_CONDITION_NAME,
2155            &ERROR_INPUT,
2156            format!("missing required option `{key}`"),
2157        ));
2158    };
2159    boundary_numeric_scalar_f64(value, key)
2160}
2161
2162fn boundary_numeric_scalar_f64(value: &Value, key: &str) -> BuiltinResult<f64> {
2163    let converted = match value {
2164        Value::Num(value) => *value,
2165        Value::Int(value) => boundary_integer_to_f64(value),
2166        Value::Tensor(tensor) if tensor_utils::is_scalar_tensor(tensor) => {
2167            boundary_numeric_storage_scalar_to_f64(
2168                tensor
2169                    .numeric_value_at(0)
2170                    .expect("validated scalar tensor storage"),
2171            )
2172        }
2173        _ => {
2174            return Err(builtin_error(
2175                BOUNDARY_CONDITION_NAME,
2176                &ERROR_INPUT,
2177                format!("numeric option `{key}` must be a real numeric scalar"),
2178            ))
2179        }
2180    };
2181    if !converted.is_finite() {
2182        return Err(builtin_error(
2183            BOUNDARY_CONDITION_NAME,
2184            &ERROR_INPUT,
2185            format!("numeric option `{key}` must be finite"),
2186        ));
2187    }
2188    Ok(converted)
2189}
2190
2191fn boundary_integer_to_f64(value: &IntValue) -> f64 {
2192    match value {
2193        IntValue::I8(value) => f64::from(*value),
2194        IntValue::I16(value) => f64::from(*value),
2195        IntValue::I32(value) => f64::from(*value),
2196        IntValue::I64(value) => *value as f64,
2197        IntValue::U8(value) => f64::from(*value),
2198        IntValue::U16(value) => f64::from(*value),
2199        IntValue::U32(value) => f64::from(*value),
2200        IntValue::U64(value) => *value as f64,
2201    }
2202}
2203
2204fn boundary_numeric_storage_scalar_to_f64(value: NumericScalar) -> f64 {
2205    match value {
2206        NumericScalar::F64(value) => value,
2207        NumericScalar::F32(value) => f64::from(value),
2208        NumericScalar::I8(value) => f64::from(value),
2209        NumericScalar::I16(value) => f64::from(value),
2210        NumericScalar::I32(value) => f64::from(value),
2211        NumericScalar::I64(value) => value as f64,
2212        NumericScalar::U8(value) => f64::from(value),
2213        NumericScalar::U16(value) => f64::from(value),
2214        NumericScalar::U32(value) => f64::from(value),
2215        NumericScalar::U64(value) => value as f64,
2216    }
2217}
2218
2219fn reject_unknown_boundary_fields(fields: HashMap<String, &Value>) -> BuiltinResult<()> {
2220    if let Some(key) = fields.keys().next() {
2221        return Err(builtin_error(
2222            BOUNDARY_CONDITION_NAME,
2223            &ERROR_INPUT,
2224            format!("unsupported fea.boundaryCondition option `{key}`"),
2225        ));
2226    }
2227    Ok(())
2228}
2229
2230fn create_load_case_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2231    if args.len() < 3 {
2232        return Err(builtin_error(
2233            LOAD_CASE_NAME,
2234            &ERROR_INPUT,
2235            "fea.loadCase requires id, region, and kind arguments",
2236        ));
2237    }
2238    let load_id = scalar_string(&args[0], LOAD_CASE_NAME, &ERROR_INPUT)?;
2239    let region_id = scalar_string(&args[1], LOAD_CASE_NAME, &ERROR_INPUT)?;
2240    let kind_text = scalar_string(&args[2], LOAD_CASE_NAME, &ERROR_INPUT)?;
2241    let mut fields = json_fields_from_name_values(LOAD_CASE_NAME, &args[3..])?;
2242    let kind = match normalize_token(&kind_text).as_str() {
2243        "force" => {
2244            let [fx, fy, fz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
2245            LoadKind::Force { fx, fy, fz }
2246        }
2247        "moment" | "torque" => {
2248            let [mx, my, mz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
2249            LoadKind::Moment { mx, my, mz }
2250        }
2251        "pressure" => LoadKind::Pressure {
2252            magnitude_pa: remove_required_f64(&mut fields, LOAD_CASE_NAME, "magnitude_pa")?,
2253        },
2254        "bodyforce" => {
2255            let [gx, gy, gz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
2256            LoadKind::BodyForce { gx, gy, gz }
2257        }
2258        "currentdensity" => {
2259            let [jx, jy, jz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
2260            LoadKind::CurrentDensity {
2261                jx,
2262                jy,
2263                jz,
2264                phase_rad: remove_optional_f64(&mut fields, LOAD_CASE_NAME, "phase_rad")?
2265                    .unwrap_or_default(),
2266                amplitude_scale: remove_optional_f64(
2267                    &mut fields,
2268                    LOAD_CASE_NAME,
2269                    "amplitude_scale",
2270                )?
2271                .unwrap_or(1.0),
2272            }
2273        }
2274        "coilcurrent" => LoadKind::CoilCurrent {
2275            current_a: remove_required_f64(&mut fields, LOAD_CASE_NAME, "current_a")?,
2276            phase_rad: remove_optional_f64(&mut fields, LOAD_CASE_NAME, "phase_rad")?
2277                .unwrap_or_default(),
2278            amplitude_scale: remove_optional_f64(&mut fields, LOAD_CASE_NAME, "amplitude_scale")?
2279                .unwrap_or(1.0),
2280        },
2281        "heatsource" => LoadKind::HeatSource {
2282            volumetric_w_per_m3: remove_required_f64(
2283                &mut fields,
2284                LOAD_CASE_NAME,
2285                "volumetric_w_per_m3",
2286            )?,
2287        },
2288        other => {
2289            return Err(builtin_error(
2290                LOAD_CASE_NAME,
2291                &ERROR_INPUT,
2292                format!("unsupported load kind `{other}`"),
2293            ));
2294        }
2295    };
2296    reject_unknown_fields(LOAD_CASE_NAME, fields)?;
2297    load_case_to_object(LoadCase {
2298        load_id,
2299        region_id,
2300        kind,
2301    })
2302}
2303
2304fn create_step_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2305    if args.len() != 2 {
2306        return Err(builtin_error(
2307            STEP_NAME,
2308            &ERROR_INPUT,
2309            "fea.step requires exactly id and kind arguments",
2310        ));
2311    }
2312    let step_id = scalar_string(&args[0], STEP_NAME, &ERROR_INPUT)?;
2313    let kind_text = scalar_string(&args[1], STEP_NAME, &ERROR_INPUT)?;
2314    let kind = parse_scalar_enum::<AnalysisStepKind>(&kind_text, "AnalysisStepKind")?;
2315    step_to_object(AnalysisStep { step_id, kind })
2316}
2317
2318fn create_domain_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2319    if args.is_empty() {
2320        return Err(builtin_error(
2321            DOMAIN_NAME,
2322            &ERROR_INPUT,
2323            "fea.domain requires a domain kind",
2324        ));
2325    }
2326    let kind_text = scalar_string(&args[0], DOMAIN_NAME, &ERROR_INPUT)?;
2327    let kind = normalize_token(&kind_text);
2328    let fields = json_fields_from_name_values(DOMAIN_NAME, &args[1..])?;
2329    let payload = match kind.as_str() {
2330        "thermomechanical" => DomainPayload {
2331            kind: "thermo_mechanical".to_string(),
2332            data: typed_domain_data::<ThermoMechanicalDomain>(
2333                DOMAIN_NAME,
2334                "thermo_mechanical domain",
2335                json_with_overrides(
2336                    DOMAIN_NAME,
2337                    serde_json::json!({
2338                        "enabled": true,
2339                        "reference_temperature_k": 293.15,
2340                        "applied_temperature_delta_k": 0.0,
2341                        "field_artifact_id": null,
2342                        "field_source": null,
2343                        "region_temperature_deltas": [],
2344                        "time_profile": []
2345                    }),
2346                    fields,
2347                    "thermo_mechanical domain",
2348                )?,
2349            )?,
2350        },
2351        "electrothermal" => DomainPayload {
2352            kind: "electro_thermal".to_string(),
2353            data: typed_domain_data::<ElectroThermalDomain>(
2354                DOMAIN_NAME,
2355                "electro_thermal domain",
2356                json_with_overrides(
2357                    DOMAIN_NAME,
2358                    serde_json::json!({
2359                        "enabled": true,
2360                        "reference_temperature_k": 293.15,
2361                        "applied_voltage_v": 0.0,
2362                        "region_conductivity_scales": [],
2363                        "time_profile": []
2364                    }),
2365                    fields,
2366                    "electro_thermal domain",
2367                )?,
2368            )?,
2369        },
2370        "electromagnetic" => DomainPayload {
2371            kind: "electromagnetic".to_string(),
2372            data: typed_domain_data::<ElectromagneticDomain>(
2373                DOMAIN_NAME,
2374                "electromagnetic domain",
2375                json_with_overrides(
2376                    DOMAIN_NAME,
2377                    serde_json::json!({
2378                        "enabled": true,
2379                        "reference_frequency_hz": 0.0,
2380                        "applied_current_a": 0.0
2381                    }),
2382                    fields,
2383                    "electromagnetic domain",
2384                )?,
2385            )?,
2386        },
2387        "cfd" => DomainPayload {
2388            kind: "cfd".to_string(),
2389            data: typed_domain_data::<CfdDomain>(
2390                DOMAIN_NAME,
2391                "cfd domain",
2392                json_with_overrides(
2393                    DOMAIN_NAME,
2394                    serde_json::json!({
2395                        "enabled": true,
2396                        "solve_family": "steady_state",
2397                        "reference_density_kg_per_m3": 1.225,
2398                        "dynamic_viscosity_pa_s": 1.8e-5,
2399                        "inlet_velocity_m_per_s": 0.0,
2400                        "turbulence_intensity": 0.0,
2401                        "time_profile": []
2402                    }),
2403                    fields,
2404                    "cfd domain",
2405                )?,
2406            )?,
2407        },
2408        other => {
2409            return Err(builtin_error(
2410                DOMAIN_NAME,
2411                &ERROR_INPUT,
2412                format!("unsupported FEA domain kind `{other}`"),
2413            ));
2414        }
2415    };
2416    domain_to_object(payload)
2417}
2418
2419fn create_interface_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2420    if args.len() < 3 {
2421        return Err(builtin_error(
2422            INTERFACE_NAME,
2423            &ERROR_INPUT,
2424            "fea.interface requires id, primary region, and secondary region arguments",
2425        ));
2426    }
2427    let interface_id = scalar_string(&args[0], INTERFACE_NAME, &ERROR_INPUT)?;
2428    let primary_region_id = scalar_string(&args[1], INTERFACE_NAME, &ERROR_INPUT)?;
2429    let secondary_region_id = scalar_string(&args[2], INTERFACE_NAME, &ERROR_INPUT)?;
2430    let mut kind = "contact".to_string();
2431    let mut kind_seen = false;
2432    let mut fields = serde_json::Map::new();
2433    for pair in expect_name_value_tail(INTERFACE_NAME, &args[3..])? {
2434        if pair.key == "kind" {
2435            if kind_seen {
2436                return Err(builtin_error(
2437                    INTERFACE_NAME,
2438                    &ERROR_INPUT,
2439                    "duplicate fea.interface option `kind`",
2440                ));
2441            }
2442            kind_seen = true;
2443            kind = scalar_string(pair.value, INTERFACE_NAME, &ERROR_INPUT)?;
2444        } else {
2445            let key =
2446                canonical_field_name(&scalar_string(pair.name, INTERFACE_NAME, &ERROR_INPUT)?);
2447            if fields
2448                .insert(key.clone(), value_to_json(INTERFACE_NAME, pair.value)?)
2449                .is_some()
2450            {
2451                return Err(builtin_error(
2452                    INTERFACE_NAME,
2453                    &ERROR_INPUT,
2454                    format!("duplicate fea.interface option `{key}`"),
2455                ));
2456            }
2457        }
2458    }
2459    let kind = match normalize_token(&kind).as_str() {
2460        "contact" => AnalysisInterfaceKind::Contact(json_deserialize(
2461            INTERFACE_NAME,
2462            json_with_overrides(
2463                INTERFACE_NAME,
2464                serde_json::json!({
2465                    "penalty_stiffness_scale": 1.0,
2466                    "max_penetration_ratio": 0.0,
2467                    "friction_coefficient": 0.0
2468                }),
2469                fields,
2470                "contact interface",
2471            )?,
2472            "contact interface",
2473        )?),
2474        "fluid_structure" | "fluidstructure" | "fsi" => {
2475            AnalysisInterfaceKind::FluidStructure(json_deserialize(
2476                INTERFACE_NAME,
2477                json_with_overrides(
2478                    INTERFACE_NAME,
2479                    serde_json::json!({
2480                        "normal_stiffness_pa_per_m": 1.0e9,
2481                        "damping_ratio": 0.0,
2482                        "relaxation_factor": 0.5
2483                    }),
2484                    fields,
2485                    "fluid-structure interface",
2486                )?,
2487                "fluid-structure interface",
2488            )?)
2489        }
2490        "conjugate_heat_transfer" | "conjugateheattransfer" | "cht" => {
2491            AnalysisInterfaceKind::ConjugateHeatTransfer(json_deserialize(
2492                INTERFACE_NAME,
2493                json_with_overrides(
2494                    INTERFACE_NAME,
2495                    serde_json::json!({
2496                        "thermal_conductance_w_per_m2k": 500.0,
2497                        "contact_resistance_m2k_per_w": 0.0,
2498                        "relaxation_factor": 0.5
2499                    }),
2500                    fields,
2501                    "conjugate heat-transfer interface",
2502                )?,
2503                "conjugate heat-transfer interface",
2504            )?)
2505        }
2506        other => {
2507            return Err(builtin_error(
2508                INTERFACE_NAME,
2509                &ERROR_INPUT,
2510                format!("unsupported interface kind `{other}`"),
2511            ));
2512        }
2513    };
2514    interface_to_object(AnalysisInterface {
2515        interface_id,
2516        primary_region_id,
2517        secondary_region_id,
2518        kind,
2519    })
2520}
2521
2522fn create_run_options_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2523    if args.is_empty() {
2524        return Err(builtin_error(
2525            RUN_OPTIONS_NAME,
2526            &ERROR_INPUT,
2527            "fea.runOptions requires a solver",
2528        ));
2529    }
2530    let kind_text = scalar_string(&args[0], RUN_OPTIONS_NAME, &ERROR_INPUT)?;
2531    let run_kind = parse_scalar_enum::<AnalysisRunKind>(&kind_text, "solver")?;
2532    let fields = run_options_fields_from_name_values(&args[1..])?;
2533    let data = run_options_json_for_kind(RUN_OPTIONS_NAME, run_kind, fields)?;
2534    run_options_to_object(RunOptionsPayload {
2535        run_kind,
2536        options: data,
2537    })
2538}
2539
2540fn run_options_fields_from_name_values(
2541    args: &[Value],
2542) -> BuiltinResult<serde_json::Map<String, serde_json::Value>> {
2543    const EXACT_FIELDS: &[&str] = &[
2544        "mode_count",
2545        "step_count",
2546        "max_linear_iters",
2547        "max_step_retries",
2548        "increment_count",
2549        "max_newton_iters",
2550        "max_line_search_backtracks",
2551        "tangent_refresh_interval",
2552        "harmonic_max_iterations",
2553    ];
2554    let mut fields = serde_json::Map::new();
2555    for pair in expect_name_value_tail(RUN_OPTIONS_NAME, args)? {
2556        let raw = scalar_string(pair.name, RUN_OPTIONS_NAME, &ERROR_INPUT)?;
2557        let key = canonical_field_name(&raw);
2558        if key == "prep_context" {
2559            return Err(builtin_error(
2560                RUN_OPTIONS_NAME,
2561                &ERROR_INPUT,
2562                "fea.runOptions does not expose the internal PrepContext; use PrepArtifactId or PrepCalibrationProfile",
2563            ));
2564        }
2565        let value = if EXACT_FIELDS.contains(&key.as_str()) {
2566            serde_json::Value::from(usize_from_value(RUN_OPTIONS_NAME, pair.value)? as u64)
2567        } else {
2568            value_to_json(RUN_OPTIONS_NAME, pair.value)?
2569        };
2570        if fields.insert(key.clone(), value).is_some() {
2571            return Err(builtin_error(
2572                RUN_OPTIONS_NAME,
2573                &ERROR_INPUT,
2574                format!("duplicate fea.runOptions option `{key}`"),
2575            ));
2576        }
2577    }
2578    Ok(fields)
2579}
2580
2581fn create_results_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2582    if args.is_empty() {
2583        return Err(builtin_error(
2584            RESULTS_NAME,
2585            &ERROR_INPUT,
2586            "fea.results requires a run id or fea.RunResult",
2587        ));
2588    }
2589    if let Value::Object(object) = &args[0] {
2590        if object.class_name == FEA_RESULTS_CLASS && args.len() == 1 {
2591            return Ok(args[0].clone());
2592        }
2593    }
2594    let run_id = run_id_from_value(RESULTS_NAME, &args[0])?;
2595    let query = results_query_from_args(&args[1..])?;
2596    let envelope = analysis_results_by_run_id_op(&run_id, query, OperationContext::new(None, None))
2597        .map_err(|err| operation_error(RESULTS_NAME, &ERROR_OPERATION, err))?;
2598    let mut public_data = envelope.data;
2599    for index in &mut public_data.summary.available_mode_indices {
2600        *index = index.checked_add(1).ok_or_else(|| {
2601            builtin_error(
2602                RESULTS_NAME,
2603                &ERROR_INTERNAL,
2604                "available mode index cannot be represented at the one-based public boundary",
2605            )
2606        })?;
2607    }
2608    let value = serializable_to_object_preserving_integers(
2609        RESULTS_NAME,
2610        &ERROR_INTERNAL,
2611        FEA_RESULTS_CLASS,
2612        &public_data,
2613        Some(FEA_PAYLOAD_JSON_PROPERTY),
2614        &[],
2615        &[
2616            "shape",
2617            "element_count",
2618            "component_count",
2619            "size_bytes",
2620            "solver_host_sync_count",
2621            "field_count",
2622            "total_elements",
2623            "mode_count",
2624            "available_mode_indices",
2625            "snapshot_count",
2626            "increment_count",
2627            "failed_increment_count",
2628            "max_nonlinear_iteration_count",
2629            "nonlinear_line_search_backtracks",
2630            "nonlinear_max_backtracks_per_increment",
2631            "nonlinear_tangent_rebuild_count",
2632            "nonlinear_iteration_spike_count",
2633            "nonlinear_convergence_stall_count",
2634            "nonlinear_backtrack_burst_count",
2635            "prep_calibration_fingerprint",
2636            "prep_acceptance_fingerprint",
2637            "thermo_coupling_fingerprint",
2638            "electro_thermal_coupling_fingerprint",
2639            "iteration_counts",
2640            "failed_increments",
2641            "line_search_backtracks",
2642            "max_line_search_backtracks_per_increment",
2643            "tangent_rebuild_count",
2644            "iteration_spike_count",
2645            "convergence_stall_count",
2646            "backtrack_burst_count",
2647        ],
2648    )?;
2649    let Value::Object(mut object) = value else {
2650        unreachable!("integer-preserving FEA result serialization returns an object")
2651    };
2652    object
2653        .properties
2654        .insert("run_id".to_string(), Value::String(run_id.clone()));
2655    object.properties.insert(
2656        FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
2657        Value::String(run_id),
2658    );
2659    copy_study_context_property(&args[0], &mut object);
2660    Ok(Value::Object(object))
2661}
2662
2663fn create_field_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2664    if args.len() != 2 {
2665        return Err(builtin_error(
2666            FIELD_NAME,
2667            &ERROR_INPUT,
2668            "fea.field requires results/run input and field id",
2669        ));
2670    }
2671    let field_id = scalar_string(&args[1], FIELD_NAME, &ERROR_INPUT)?;
2672    let results = results_data_from_value(FIELD_NAME, &args[0])?;
2673    let field = find_field(results.fields.into_iter(), &field_id).ok_or_else(|| {
2674        builtin_error(
2675            FIELD_NAME,
2676            &ERROR_INPUT,
2677            format!("FEA field `{field_id}` was not found in results"),
2678        )
2679    })?;
2680    let descriptor = find_descriptor(results.field_descriptors.iter(), &field_id)
2681        .cloned()
2682        .unwrap_or_else(|| AnalysisFieldDescriptor::from_field(&field));
2683    let mut object = field_to_object(&field, &descriptor)?;
2684    copy_study_context_property(&args[0], &mut object);
2685    copy_run_id_context_property(&args[0], &mut object);
2686    Ok(Value::Object(object))
2687}
2688
2689fn create_plot_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2690    #[cfg(feature = "plot-core")]
2691    {
2692        let request = plot_request_from_args(&args)?;
2693        reject_requested_device_field(&request)?;
2694        let mut figures = generate_plot_figures(&request.study, &request.run_id, &request.options)?;
2695        let figure = select_generated_figure(&mut figures, request.field_id.as_deref())?;
2696        let handle = import_generated_figure(figure)?;
2697        Ok(Value::Num(f64::from(handle)))
2698    }
2699    #[cfg(not(feature = "plot-core"))]
2700    {
2701        let _ = args;
2702        Err(builtin_error(
2703            PLOT_NAME,
2704            &ERROR_OPERATION,
2705            "fea.plot requires the plot-core runtime feature",
2706        ))
2707    }
2708}
2709
2710#[cfg(feature = "plot-core")]
2711fn reject_requested_device_field(request: &FeaPlotRequest) -> BuiltinResult<()> {
2712    let Some(field_id) = request.field_id.as_deref() else {
2713        return Ok(());
2714    };
2715    let results = analysis_results_by_run_id_op(
2716        &request.run_id,
2717        AnalysisResultsQuery::default(),
2718        OperationContext::new(None, None),
2719    )
2720    .map(|envelope| envelope.data)
2721    .map_err(|err| operation_error(PLOT_NAME, &ERROR_OPERATION, err))?;
2722    if find_field(results.fields, field_id)
2723        .is_some_and(|field| matches!(field.values, AnalysisFieldValues::DeviceRef(_)))
2724    {
2725        return Err(builtin_error(
2726            PLOT_NAME,
2727            &ERROR_INPUT,
2728            format!(
2729                "FEA field `{field_id}` is device-backed and cannot be plotted without explicit host materialization"
2730            ),
2731        ));
2732    }
2733    Ok(())
2734}
2735
2736fn create_compare_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2737    if args.len() != 2 {
2738        return Err(builtin_error(
2739            COMPARE_NAME,
2740            &ERROR_INPUT,
2741            "fea.compare requires baseline and candidate run ids",
2742        ));
2743    }
2744    let baseline_run_id = scalar_string(&args[0], COMPARE_NAME, &ERROR_INPUT)?;
2745    let candidate_run_id = scalar_string(&args[1], COMPARE_NAME, &ERROR_INPUT)?;
2746    operation_result_to_object_preserving_integers(
2747        COMPARE_NAME,
2748        &ERROR_OPERATION,
2749        &ERROR_INTERNAL,
2750        FEA_COMPARE_CLASS,
2751        analysis_results_compare_op(
2752            AnalysisResultsCompareQuery {
2753                baseline_run_id,
2754                candidate_run_id,
2755            },
2756            OperationContext::new(None, None),
2757        ),
2758        Some(FEA_PAYLOAD_JSON_PROPERTY),
2759        &[
2760            "quality_reason_count_delta",
2761            "failed_increment_delta",
2762            "max_iteration_delta",
2763            "nonlinear_spike_count_delta",
2764            "nonlinear_stall_count_delta",
2765        ],
2766        &[],
2767    )
2768}
2769
2770fn create_trends_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
2771    let mut window_size = AnalysisTrendsQuery::default().window_size;
2772    let mut window_size_seen = false;
2773    for pair in expect_name_value_tail(TRENDS_NAME, args.as_slice())? {
2774        match pair.key.as_str() {
2775            "windowsize" => {
2776                if window_size_seen {
2777                    return Err(builtin_error(
2778                        TRENDS_NAME,
2779                        &ERROR_INPUT,
2780                        "duplicate fea.trends option `windowsize`",
2781                    ));
2782                }
2783                window_size_seen = true;
2784                window_size = usize_from_value(TRENDS_NAME, pair.value)?;
2785                if window_size == 0 {
2786                    return Err(builtin_error(
2787                        TRENDS_NAME,
2788                        &ERROR_INPUT,
2789                        "fea.trends WindowSize must be positive",
2790                    ));
2791                }
2792            }
2793            other => {
2794                return Err(builtin_error(
2795                    TRENDS_NAME,
2796                    &ERROR_INPUT,
2797                    format!("unsupported fea.trends option `{other}`"),
2798                ));
2799            }
2800        }
2801    }
2802    operation_result_to_object(
2803        TRENDS_NAME,
2804        &ERROR_OPERATION,
2805        &ERROR_INTERNAL,
2806        FEA_TRENDS_CLASS,
2807        analysis_trends_op(
2808            AnalysisTrendsQuery { window_size },
2809            OperationContext::new(None, None),
2810        ),
2811        Some(FEA_PAYLOAD_JSON_PROPERTY),
2812    )
2813}
2814
2815fn build_model_from_parts(
2816    builtin: &'static str,
2817    geometry: &GeometryAsset,
2818    model_id: String,
2819    profile: AnalysisCreateModelProfile,
2820    defaults: ModelDefaultsMode,
2821    frame: Option<ReferenceFrame>,
2822    materials: Vec<MaterialModel>,
2823    material_assignments: Vec<MaterialAssignment>,
2824    boundary_conditions: Vec<BoundaryCondition>,
2825    loads: Vec<LoadCase>,
2826    steps: Vec<AnalysisStep>,
2827    domains: Vec<DomainPayload>,
2828    interfaces: Vec<AnalysisInterface>,
2829) -> BuiltinResult<AnalysisModel> {
2830    let mut model = match defaults {
2831        ModelDefaultsMode::ProfileScaffold => analysis_create_model_op(
2832            geometry,
2833            AnalysisCreateModelIntentSpec {
2834                model_id: model_id.clone(),
2835                profile,
2836                prep_context: None,
2837            },
2838            OperationContext::new(None, None),
2839        )
2840        .map(|envelope| envelope.data)
2841        .map_err(|err| operation_error(builtin, &ERROR_OPERATION, err))?,
2842        ModelDefaultsMode::None => empty_model(model_id, geometry),
2843    };
2844
2845    if let Some(frame) = frame {
2846        model.frame = frame;
2847    }
2848    if !materials.is_empty() {
2849        model.materials = materials;
2850    }
2851    if !material_assignments.is_empty() {
2852        model.material_assignments = material_assignments
2853            .into_iter()
2854            .map(|mut assignment| {
2855                assignment.region_id =
2856                    resolve_region_selector(builtin, &assignment.region_id, geometry)?;
2857                Ok(assignment)
2858            })
2859            .collect::<BuiltinResult<Vec<_>>>()?;
2860    }
2861    if !boundary_conditions.is_empty() {
2862        model.boundary_conditions = boundary_conditions
2863            .into_iter()
2864            .map(|mut bc| {
2865                bc.region_id = resolve_region_selector(builtin, &bc.region_id, geometry)?;
2866                Ok(bc)
2867            })
2868            .collect::<BuiltinResult<Vec<_>>>()?;
2869    }
2870    if !loads.is_empty() {
2871        model.loads = loads
2872            .into_iter()
2873            .map(|mut load| {
2874                load.region_id = resolve_region_selector(builtin, &load.region_id, geometry)?;
2875                Ok(load)
2876            })
2877            .collect::<BuiltinResult<Vec<_>>>()?;
2878    }
2879    if !steps.is_empty() {
2880        model.steps = steps;
2881    }
2882    for domain in domains {
2883        match domain.kind.as_str() {
2884            "thermo_mechanical" => {
2885                let mut domain: ThermoMechanicalDomain =
2886                    json_deserialize(builtin, domain.data, "thermo_mechanical domain")?;
2887                for entry in &mut domain.region_temperature_deltas {
2888                    entry.region_id = resolve_region_selector(builtin, &entry.region_id, geometry)?;
2889                }
2890                if let Some(source) = &mut domain.field_source {
2891                    for region_id in &mut source.expected_region_ids {
2892                        *region_id = resolve_region_selector(builtin, region_id, geometry)?;
2893                    }
2894                }
2895                model.thermo_mechanical = Some(domain);
2896            }
2897            "electro_thermal" => {
2898                let mut domain: ElectroThermalDomain =
2899                    json_deserialize(builtin, domain.data, "electro_thermal domain")?;
2900                for entry in &mut domain.region_conductivity_scales {
2901                    entry.region_id = resolve_region_selector(builtin, &entry.region_id, geometry)?;
2902                }
2903                model.electro_thermal = Some(domain);
2904            }
2905            "electromagnetic" => {
2906                model.electromagnetic = Some(json_deserialize(
2907                    builtin,
2908                    domain.data,
2909                    "electromagnetic domain",
2910                )?);
2911            }
2912            "cfd" => {
2913                model.cfd = Some(json_deserialize(builtin, domain.data, "cfd domain")?);
2914            }
2915            other => {
2916                return Err(builtin_error(
2917                    builtin,
2918                    &ERROR_INPUT,
2919                    format!("unsupported domain payload `{other}`"),
2920                ));
2921            }
2922        }
2923    }
2924    if !interfaces.is_empty() {
2925        model.interfaces = interfaces
2926            .into_iter()
2927            .map(|mut interface| {
2928                interface.primary_region_id =
2929                    resolve_region_selector(builtin, &interface.primary_region_id, geometry)?;
2930                interface.secondary_region_id =
2931                    resolve_region_selector(builtin, &interface.secondary_region_id, geometry)?;
2932                Ok(interface)
2933            })
2934            .collect::<BuiltinResult<Vec<_>>>()?;
2935    }
2936    Ok(model)
2937}
2938
2939fn empty_model(model_id: String, geometry: &GeometryAsset) -> AnalysisModel {
2940    AnalysisModel {
2941        model_id: AnalysisModelId(model_id),
2942        geometry_id: geometry.geometry_id.clone(),
2943        geometry_revision: geometry.revision,
2944        units: geometry.units,
2945        frame: ReferenceFrame::Global,
2946        materials: Vec::new(),
2947        material_assignments: Vec::new(),
2948        structural: None,
2949        thermo_mechanical: None,
2950        electro_thermal: None,
2951        electromagnetic: None,
2952        cfd: None,
2953        interfaces: Vec::new(),
2954        boundary_conditions: Vec::new(),
2955        loads: Vec::new(),
2956        steps: Vec::new(),
2957    }
2958}
2959
2960fn resolve_region_selector(
2961    builtin: &'static str,
2962    selector: &str,
2963    geometry: &GeometryAsset,
2964) -> BuiltinResult<String> {
2965    if let Some(id) = selector
2966        .strip_prefix("id:")
2967        .or_else(|| selector.strip_prefix("region:"))
2968    {
2969        return require_region_id(builtin, id, geometry);
2970    }
2971    if let Some(tag) = selector.strip_prefix("tag:") {
2972        return geometry
2973            .regions
2974            .iter()
2975            .find(|region| region.tag.as_deref() == Some(tag))
2976            .map(|region| region.region_id.clone())
2977            .ok_or_else(|| {
2978                builtin_error(
2979                    builtin,
2980                    &ERROR_INPUT,
2981                    format!("region tag `{tag}` was not found in geometry"),
2982                )
2983            });
2984    }
2985    if let Some(name) = selector.strip_prefix("name:") {
2986        return geometry
2987            .regions
2988            .iter()
2989            .find(|region| region.name == name)
2990            .map(|region| region.region_id.clone())
2991            .ok_or_else(|| {
2992                builtin_error(
2993                    builtin,
2994                    &ERROR_INPUT,
2995                    format!("region name `{name}` was not found in geometry"),
2996                )
2997            });
2998    }
2999    require_region_id(builtin, selector, geometry)
3000}
3001
3002fn require_region_id(
3003    builtin: &'static str,
3004    region_id: &str,
3005    geometry: &GeometryAsset,
3006) -> BuiltinResult<String> {
3007    geometry
3008        .regions
3009        .iter()
3010        .find(|region| region.region_id == region_id)
3011        .map(|region| region.region_id.clone())
3012        .ok_or_else(|| {
3013            builtin_error(
3014                builtin,
3015                &ERROR_INPUT,
3016                format!("region id `{region_id}` was not found in geometry"),
3017            )
3018        })
3019}
3020
3021fn material_to_object(material: MaterialModel) -> BuiltinResult<Value> {
3022    serializable_to_object(
3023        MATERIAL_NAME,
3024        &ERROR_INTERNAL,
3025        FEA_MATERIAL_CLASS,
3026        &material,
3027        Some(FEA_PAYLOAD_JSON_PROPERTY),
3028    )
3029}
3030
3031fn material_assignment_to_object(assignment: MaterialAssignment) -> BuiltinResult<Value> {
3032    serializable_to_object(
3033        MATERIAL_ASSIGNMENT_NAME,
3034        &ERROR_INTERNAL,
3035        FEA_MATERIAL_ASSIGNMENT_CLASS,
3036        &assignment,
3037        Some(FEA_PAYLOAD_JSON_PROPERTY),
3038    )
3039}
3040
3041fn boundary_condition_to_object(bc: BoundaryCondition) -> BuiltinResult<Value> {
3042    serializable_to_object(
3043        BOUNDARY_CONDITION_NAME,
3044        &ERROR_INTERNAL,
3045        FEA_BOUNDARY_CONDITION_CLASS,
3046        &bc,
3047        Some(FEA_PAYLOAD_JSON_PROPERTY),
3048    )
3049}
3050
3051fn load_case_to_object(load: LoadCase) -> BuiltinResult<Value> {
3052    serializable_to_object(
3053        LOAD_CASE_NAME,
3054        &ERROR_INTERNAL,
3055        FEA_LOAD_CASE_CLASS,
3056        &load,
3057        Some(FEA_PAYLOAD_JSON_PROPERTY),
3058    )
3059}
3060
3061fn step_to_object(step: AnalysisStep) -> BuiltinResult<Value> {
3062    serializable_to_object(
3063        STEP_NAME,
3064        &ERROR_INTERNAL,
3065        FEA_STEP_CLASS,
3066        &step,
3067        Some(FEA_PAYLOAD_JSON_PROPERTY),
3068    )
3069}
3070
3071fn domain_to_object(domain: DomainPayload) -> BuiltinResult<Value> {
3072    serializable_to_object_preserving_integers(
3073        DOMAIN_NAME,
3074        &ERROR_INTERNAL,
3075        FEA_DOMAIN_CLASS,
3076        &domain,
3077        Some(FEA_PAYLOAD_JSON_PROPERTY),
3078        &[],
3079        &["revision"],
3080    )
3081}
3082
3083fn interface_to_object(interface: AnalysisInterface) -> BuiltinResult<Value> {
3084    serializable_to_object(
3085        INTERFACE_NAME,
3086        &ERROR_INTERNAL,
3087        FEA_INTERFACE_CLASS,
3088        &interface,
3089        Some(FEA_PAYLOAD_JSON_PROPERTY),
3090    )
3091}
3092
3093fn run_options_to_object(payload: RunOptionsPayload) -> BuiltinResult<Value> {
3094    serializable_to_object(
3095        RUN_OPTIONS_NAME,
3096        &ERROR_INTERNAL,
3097        FEA_RUN_OPTIONS_CLASS,
3098        &payload,
3099        Some(FEA_PAYLOAD_JSON_PROPERTY),
3100    )
3101}
3102
3103fn model_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<AnalysisModel> {
3104    object_payload(builtin, value, FEA_MODEL_CLASS)
3105}
3106
3107fn study_vec_from_value(
3108    builtin: &'static str,
3109    value: &Value,
3110) -> BuiltinResult<Vec<AnalysisStudySpec>> {
3111    object_vec_from_value_with_property(
3112        builtin,
3113        value,
3114        FEA_STUDY_CLASS,
3115        FEA_STUDY_SPEC_JSON_PROPERTY,
3116    )
3117}
3118
3119fn material_vec_from_value(
3120    builtin: &'static str,
3121    value: &Value,
3122) -> BuiltinResult<Vec<MaterialModel>> {
3123    object_vec_from_value(builtin, value, FEA_MATERIAL_CLASS)
3124}
3125
3126fn material_assignment_vec_from_value(
3127    builtin: &'static str,
3128    value: &Value,
3129) -> BuiltinResult<Vec<MaterialAssignment>> {
3130    object_vec_from_value(builtin, value, FEA_MATERIAL_ASSIGNMENT_CLASS)
3131}
3132
3133fn boundary_condition_vec_from_value(
3134    builtin: &'static str,
3135    value: &Value,
3136) -> BuiltinResult<Vec<BoundaryCondition>> {
3137    object_vec_from_value(builtin, value, FEA_BOUNDARY_CONDITION_CLASS)
3138}
3139
3140fn load_case_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<LoadCase>> {
3141    object_vec_from_value(builtin, value, FEA_LOAD_CASE_CLASS)
3142}
3143
3144fn step_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<AnalysisStep>> {
3145    object_vec_from_value(builtin, value, FEA_STEP_CLASS)
3146}
3147
3148fn domain_vec_from_value(
3149    builtin: &'static str,
3150    value: &Value,
3151) -> BuiltinResult<Vec<DomainPayload>> {
3152    object_vec_from_value(builtin, value, FEA_DOMAIN_CLASS)
3153}
3154
3155fn interface_vec_from_value(
3156    builtin: &'static str,
3157    value: &Value,
3158) -> BuiltinResult<Vec<AnalysisInterface>> {
3159    object_vec_from_value(builtin, value, FEA_INTERFACE_CLASS)
3160}
3161
3162fn object_vec_from_value<T: DeserializeOwned>(
3163    builtin: &'static str,
3164    value: &Value,
3165    expected_class: &'static str,
3166) -> BuiltinResult<Vec<T>> {
3167    object_vec_from_value_with_property(builtin, value, expected_class, FEA_PAYLOAD_JSON_PROPERTY)
3168}
3169
3170fn object_vec_from_value_with_property<T: DeserializeOwned>(
3171    builtin: &'static str,
3172    value: &Value,
3173    expected_class: &'static str,
3174    payload_property: &'static str,
3175) -> BuiltinResult<Vec<T>> {
3176    match value {
3177        Value::Cell(cell) => cell
3178            .data
3179            .iter()
3180            .map(|item| {
3181                object_payload_with_property(builtin, item, expected_class, payload_property)
3182            })
3183            .collect(),
3184        Value::Object(_) => Ok(vec![object_payload_with_property(
3185            builtin,
3186            value,
3187            expected_class,
3188            payload_property,
3189        )?]),
3190        other => Err(builtin_error(
3191            builtin,
3192            &ERROR_INPUT,
3193            format!("expected {expected_class} object or cell array; got {other:?}"),
3194        )),
3195    }
3196}
3197
3198fn object_payload<T: DeserializeOwned>(
3199    builtin: &'static str,
3200    value: &Value,
3201    expected_class: &'static str,
3202) -> BuiltinResult<T> {
3203    object_payload_with_property(builtin, value, expected_class, FEA_PAYLOAD_JSON_PROPERTY)
3204}
3205
3206fn object_payload_with_property<T: DeserializeOwned>(
3207    builtin: &'static str,
3208    value: &Value,
3209    expected_class: &'static str,
3210    payload_property: &'static str,
3211) -> BuiltinResult<T> {
3212    let Value::Object(object) = value else {
3213        return Err(builtin_error(
3214            builtin,
3215            &ERROR_INPUT,
3216            format!("expected {expected_class} object"),
3217        ));
3218    };
3219    if object.class_name != expected_class {
3220        return Err(builtin_error(
3221            builtin,
3222            &ERROR_INPUT,
3223            format!("expected {expected_class}, got {}", object.class_name),
3224        ));
3225    }
3226    object_json_property(builtin, object, payload_property, &ERROR_INPUT)
3227}
3228
3229fn run_options_payload_from_value(
3230    builtin: &'static str,
3231    value: &Value,
3232) -> BuiltinResult<RunOptionsPayload> {
3233    object_payload(builtin, value, FEA_RUN_OPTIONS_CLASS)
3234}
3235
3236fn resolved_run_options_from_payload(
3237    builtin: &'static str,
3238    payload: RunOptionsPayload,
3239    expected_kind: AnalysisRunKind,
3240) -> BuiltinResult<ResolvedRunOptions> {
3241    if payload.run_kind != expected_kind {
3242        return Err(builtin_error(
3243            builtin,
3244            &ERROR_INPUT,
3245            format!(
3246                "run options kind {:?} does not match selected study solver {:?}",
3247                payload.run_kind, expected_kind
3248            ),
3249        ));
3250    }
3251    let mut resolved = ResolvedRunOptions::default();
3252    match payload.run_kind {
3253        AnalysisRunKind::LinearStatic => {
3254            resolved.linear_static = Some(json_deserialize(
3255                builtin,
3256                payload.options,
3257                "linear_static run options",
3258            )?);
3259        }
3260        AnalysisRunKind::Modal => {
3261            resolved.modal = Some(json_deserialize(
3262                builtin,
3263                payload.options,
3264                "modal run options",
3265            )?);
3266        }
3267        AnalysisRunKind::Acoustic => {
3268            resolved.acoustic = Some(json_deserialize(
3269                builtin,
3270                payload.options,
3271                "acoustic run options",
3272            )?);
3273        }
3274        AnalysisRunKind::Thermal => {
3275            resolved.thermal = Some(json_deserialize(
3276                builtin,
3277                payload.options,
3278                "thermal run options",
3279            )?);
3280        }
3281        AnalysisRunKind::Transient => {
3282            resolved.transient = Some(json_deserialize(
3283                builtin,
3284                payload.options,
3285                "transient run options",
3286            )?);
3287        }
3288        AnalysisRunKind::Cfd => {
3289            resolved.cfd = Some(json_deserialize(
3290                builtin,
3291                payload.options,
3292                "cfd run options",
3293            )?);
3294        }
3295        AnalysisRunKind::Cht => {
3296            resolved.cht = Some(json_deserialize(
3297                builtin,
3298                payload.options,
3299                "cht run options",
3300            )?);
3301        }
3302        AnalysisRunKind::Fsi => {
3303            resolved.fsi = Some(json_deserialize(
3304                builtin,
3305                payload.options,
3306                "fsi run options",
3307            )?);
3308        }
3309        AnalysisRunKind::Nonlinear => {
3310            resolved.nonlinear = Some(json_deserialize(
3311                builtin,
3312                payload.options,
3313                "nonlinear run options",
3314            )?);
3315        }
3316        AnalysisRunKind::Electromagnetic => {
3317            resolved.electromagnetic = Some(json_deserialize(
3318                builtin,
3319                payload.options,
3320                "electromagnetic run options",
3321            )?);
3322        }
3323    }
3324    Ok(resolved)
3325}
3326
3327fn run_options_json_for_kind(
3328    builtin: &'static str,
3329    run_kind: AnalysisRunKind,
3330    fields: serde_json::Map<String, serde_json::Value>,
3331) -> BuiltinResult<serde_json::Value> {
3332    match run_kind {
3333        AnalysisRunKind::LinearStatic => typed_json_with_overrides::<AnalysisRunOptions>(
3334            builtin,
3335            AnalysisRunOptions::default(),
3336            fields,
3337            "linear_static run options",
3338        ),
3339        AnalysisRunKind::Modal => typed_json_with_overrides::<AnalysisModalRunOptions>(
3340            builtin,
3341            AnalysisModalRunOptions::default(),
3342            fields,
3343            "modal run options",
3344        ),
3345        AnalysisRunKind::Acoustic => typed_json_with_overrides::<AnalysisAcousticRunOptions>(
3346            builtin,
3347            AnalysisAcousticRunOptions::default(),
3348            fields,
3349            "acoustic run options",
3350        ),
3351        AnalysisRunKind::Thermal => typed_json_with_overrides::<AnalysisThermalRunOptions>(
3352            builtin,
3353            AnalysisThermalRunOptions::default(),
3354            fields,
3355            "thermal run options",
3356        ),
3357        AnalysisRunKind::Transient => typed_json_with_overrides::<AnalysisTransientRunOptions>(
3358            builtin,
3359            AnalysisTransientRunOptions::default(),
3360            fields,
3361            "transient run options",
3362        ),
3363        AnalysisRunKind::Cfd => typed_json_with_overrides::<AnalysisCfdRunOptions>(
3364            builtin,
3365            AnalysisCfdRunOptions::default(),
3366            fields,
3367            "cfd run options",
3368        ),
3369        AnalysisRunKind::Cht => typed_json_with_overrides::<AnalysisChtRunOptions>(
3370            builtin,
3371            AnalysisChtRunOptions::default(),
3372            fields,
3373            "cht run options",
3374        ),
3375        AnalysisRunKind::Fsi => typed_json_with_overrides::<AnalysisFsiRunOptions>(
3376            builtin,
3377            AnalysisFsiRunOptions::default(),
3378            fields,
3379            "fsi run options",
3380        ),
3381        AnalysisRunKind::Nonlinear => typed_json_with_overrides::<AnalysisNonlinearRunOptions>(
3382            builtin,
3383            AnalysisNonlinearRunOptions::default(),
3384            fields,
3385            "nonlinear run options",
3386        ),
3387        AnalysisRunKind::Electromagnetic => {
3388            typed_json_with_overrides::<AnalysisElectromagneticRunOptions>(
3389                builtin,
3390                AnalysisElectromagneticRunOptions::default(),
3391                fields,
3392                "electromagnetic run options",
3393            )
3394        }
3395    }
3396}
3397
3398fn results_query_from_args(args: &[Value]) -> BuiltinResult<AnalysisResultsQuery> {
3399    let mut query = AnalysisResultsQuery::default();
3400    let mut seen = HashSet::new();
3401    for pair in expect_name_value_tail(RESULTS_NAME, args)? {
3402        let canonical = match pair.key.as_str() {
3403            "includefields" | "fields" => "includefields",
3404            "includefieldvalues" | "fieldvalues" => "includefieldvalues",
3405            other => other,
3406        };
3407        if !seen.insert(canonical.to_string()) {
3408            return Err(builtin_error(
3409                RESULTS_NAME,
3410                &ERROR_INPUT,
3411                format!("duplicate fea.results option `{canonical}`"),
3412            ));
3413        }
3414        match pair.key.as_str() {
3415            "includefields" | "fields" => {
3416                query.include_fields = string_vec_from_value(RESULTS_NAME, pair.value)?;
3417            }
3418            "includefieldvalues" | "fieldvalues" => {
3419                query.include_field_values = exact_bool_from_value(RESULTS_NAME, pair.value)?;
3420            }
3421            "includediagnostics" => {
3422                query.include_diagnostics = exact_bool_from_value(RESULTS_NAME, pair.value)?;
3423            }
3424            "diagnosticcodes" => {
3425                query.diagnostic_codes = string_vec_from_value(RESULTS_NAME, pair.value)?;
3426            }
3427            "includemodalresults" => {
3428                query.include_modal_results = exact_bool_from_value(RESULTS_NAME, pair.value)?;
3429            }
3430            "modeindices" => {
3431                query.mode_indices = one_based_usize_vec_from_value(RESULTS_NAME, pair.value)?;
3432            }
3433            "includetransientresults" => {
3434                query.include_transient_results = exact_bool_from_value(RESULTS_NAME, pair.value)?;
3435            }
3436            "transientsnapshotindices" => {
3437                query.transient_snapshot_indices =
3438                    one_based_usize_vec_from_value(RESULTS_NAME, pair.value)?;
3439            }
3440            "includenonlinearresults" => {
3441                query.include_nonlinear_results = exact_bool_from_value(RESULTS_NAME, pair.value)?;
3442            }
3443            "includeelectromagneticresults" => {
3444                query.include_electromagnetic_results =
3445                    exact_bool_from_value(RESULTS_NAME, pair.value)?;
3446            }
3447            other => {
3448                return Err(builtin_error(
3449                    RESULTS_NAME,
3450                    &ERROR_INPUT,
3451                    format!("unsupported fea.results option `{other}`"),
3452                ));
3453            }
3454        }
3455    }
3456    Ok(query)
3457}
3458
3459fn run_id_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<String> {
3460    match value {
3461        Value::Object(object) if object.class_name == FEA_RUN_RESULT_CLASS => {
3462            run_id_from_object(object).ok_or_else(|| {
3463                builtin_error(
3464                    builtin,
3465                    &ERROR_INPUT,
3466                    "fea.RunResult does not contain a run_id; sweep results expose run_entries",
3467                )
3468            })
3469        }
3470        Value::String(_) | Value::CharArray(_) | Value::StringArray(_) => {
3471            scalar_string(value, builtin, &ERROR_INPUT)
3472        }
3473        other => Err(builtin_error(
3474            builtin,
3475            &ERROR_INPUT,
3476            format!("expected run id string or fea.RunResult; got {other:?}"),
3477        )),
3478    }
3479}
3480
3481fn run_id_from_object(object: &ObjectInstance) -> Option<String> {
3482    object
3483        .properties
3484        .get(FEA_RUN_ID_CONTEXT_PROPERTY)
3485        .or_else(|| object.properties.get("run_id"))
3486        .or_else(|| object.properties.get("runId"))
3487        .and_then(|value| match value {
3488            Value::String(run_id) => Some(run_id.clone()),
3489            _ => None,
3490        })
3491}
3492
3493fn results_data_from_value(
3494    builtin: &'static str,
3495    value: &Value,
3496) -> BuiltinResult<crate::analysis::AnalysisResultsData> {
3497    match value {
3498        Value::Object(object) if object.class_name == FEA_RESULTS_CLASS => {
3499            object_json_property(builtin, object, FEA_PAYLOAD_JSON_PROPERTY, &ERROR_INPUT)
3500        }
3501        _ => {
3502            let run_id = run_id_from_value(builtin, value)?;
3503            analysis_results_by_run_id_op(
3504                &run_id,
3505                AnalysisResultsQuery::default(),
3506                OperationContext::new(None, None),
3507            )
3508            .map(|envelope| envelope.data)
3509            .map_err(|err| operation_error(builtin, &ERROR_OPERATION, err))
3510        }
3511    }
3512}
3513
3514fn run_study_result_to_object(spec: &AnalysisStudySpec) -> BuiltinResult<Value> {
3515    let envelope = analysis_run_study_op(spec, OperationContext::new(None, None))
3516        .map_err(|err| operation_error(RUN_NAME, &ERROR_OPERATION, err))?;
3517    let mut object = serializable_to_object_value(
3518        RUN_NAME,
3519        &ERROR_INTERNAL,
3520        FEA_RUN_RESULT_CLASS,
3521        &envelope.data,
3522        Some(FEA_PAYLOAD_JSON_PROPERTY),
3523    )?;
3524    object.properties.insert(
3525        FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
3526        Value::String(envelope.data.run_id.clone()),
3527    );
3528    object.properties.insert(
3529        "run_id".to_string(),
3530        Value::String(envelope.data.run_id.clone()),
3531    );
3532    object.properties.insert(
3533        "runId".to_string(),
3534        Value::String(envelope.data.run_id.clone()),
3535    );
3536    insert_study_context(&mut object, spec)?;
3537    Ok(Value::Object(object))
3538}
3539
3540fn insert_study_context(
3541    object: &mut ObjectInstance,
3542    spec: &AnalysisStudySpec,
3543) -> BuiltinResult<()> {
3544    let json = serde_json::to_string(spec).map_err(|err| {
3545        builtin_error_with_source(RUN_NAME, &ERROR_INTERNAL, err.to_string(), err)
3546    })?;
3547    object.properties.insert(
3548        FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
3549        Value::String(json),
3550    );
3551    Ok(())
3552}
3553
3554fn copy_study_context_property(source: &Value, target: &mut ObjectInstance) {
3555    if let Some(json) = study_context_json_from_value(source) {
3556        target.properties.insert(
3557            FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
3558            Value::String(json),
3559        );
3560    }
3561}
3562
3563fn copy_run_id_context_property(source: &Value, target: &mut ObjectInstance) {
3564    if let Some(run_id) = run_id_context_from_value(source) {
3565        target.properties.insert(
3566            FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
3567            Value::String(run_id.clone()),
3568        );
3569        target
3570            .properties
3571            .entry("run_id".to_string())
3572            .or_insert(Value::String(run_id.clone()));
3573        target
3574            .properties
3575            .entry("runId".to_string())
3576            .or_insert(Value::String(run_id));
3577    }
3578}
3579
3580fn study_context_json_from_value(value: &Value) -> Option<String> {
3581    let Value::Object(object) = value else {
3582        return None;
3583    };
3584    if object.class_name == FEA_STUDY_CLASS {
3585        if let Some(Value::String(json)) = object.properties.get(FEA_STUDY_SPEC_JSON_PROPERTY) {
3586            return Some(json.clone());
3587        }
3588    }
3589    object
3590        .properties
3591        .get(FEA_STUDY_CONTEXT_JSON_PROPERTY)
3592        .and_then(|value| match value {
3593            Value::String(json) => Some(json.clone()),
3594            _ => None,
3595        })
3596}
3597
3598fn study_context_from_value(
3599    builtin: &'static str,
3600    value: &Value,
3601) -> BuiltinResult<AnalysisStudySpec> {
3602    let Some(json) = study_context_json_from_value(value) else {
3603        return Err(builtin_error(
3604            builtin,
3605            &ERROR_INPUT,
3606            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)"),
3607        ));
3608    };
3609    serde_json::from_str(&json)
3610        .map_err(|err| builtin_error_with_source(builtin, &ERROR_INPUT, err.to_string(), err))
3611}
3612
3613fn run_id_context_from_value(value: &Value) -> Option<String> {
3614    let Value::Object(object) = value else {
3615        return None;
3616    };
3617    run_id_from_object(object)
3618}
3619
3620fn field_to_object(
3621    field: &AnalysisField,
3622    descriptor: &AnalysisFieldDescriptor,
3623) -> BuiltinResult<ObjectInstance> {
3624    ensure_fea_classes_registered();
3625    let mut object = ObjectInstance::new(FEA_FIELD_CLASS.to_string());
3626    object.properties.insert(
3627        "field_id".to_string(),
3628        Value::String(field.field_id.clone()),
3629    );
3630    object
3631        .properties
3632        .insert("id".to_string(), Value::String(field.field_id.clone()));
3633    object.properties.insert(
3634        "shape".to_string(),
3635        usize_slice_tensor(&field.shape, 1, field.shape.len())?,
3636    );
3637    object
3638        .properties
3639        .insert("values".to_string(), field_values_value(field)?);
3640    object.properties.insert(
3641        "unit".to_string(),
3642        Value::String(descriptor.unit.clone().unwrap_or_default()),
3643    );
3644    object.properties.insert(
3645        "location".to_string(),
3646        Value::String(format!("{:?}", descriptor.location).to_ascii_lowercase()),
3647    );
3648    object.properties.insert(
3649        "kind".to_string(),
3650        Value::String(format!("{:?}", descriptor.kind).to_ascii_lowercase()),
3651    );
3652    object.properties.insert(
3653        "family".to_string(),
3654        Value::String(descriptor.family.clone()),
3655    );
3656    object.properties.insert(
3657        "quantity".to_string(),
3658        Value::String(descriptor.quantity.clone()),
3659    );
3660    object.properties.insert(
3661        "topology_id".to_string(),
3662        descriptor
3663            .topology_id
3664            .as_ref()
3665            .map(|value| Value::String(value.clone()))
3666            .unwrap_or_else(empty_double_value),
3667    );
3668    object.properties.insert(
3669        "element_kind".to_string(),
3670        descriptor
3671            .element_kind
3672            .as_ref()
3673            .map(|value| Value::String(value.clone()))
3674            .unwrap_or_else(empty_double_value),
3675    );
3676    object.properties.insert(
3677        "component_count".to_string(),
3678        descriptor
3679            .component_count
3680            .map(|value| Value::Int(IntValue::U64(value as u64)))
3681            .unwrap_or_else(empty_double_value),
3682    );
3683    object.properties.insert(
3684        "element_count".to_string(),
3685        Value::Int(IntValue::U64(descriptor.element_count as u64)),
3686    );
3687    object.properties.insert(
3688        "entity_count".to_string(),
3689        Value::Int(IntValue::U64(descriptor.entity_count as u64)),
3690    );
3691    object.properties.insert(
3692        "value_count".to_string(),
3693        Value::Int(IntValue::U64(descriptor.value_count as u64)),
3694    );
3695    object.properties.insert(
3696        "storage".to_string(),
3697        Value::String(format!("{:?}", descriptor.storage).to_ascii_lowercase()),
3698    );
3699    object.properties.insert(
3700        "descriptor".to_string(),
3701        serializable_to_value_preserving_integers(
3702            FIELD_NAME,
3703            &ERROR_INTERNAL,
3704            descriptor,
3705            &[],
3706            &["shape", "element_count", "component_count", "size_bytes"],
3707        )?,
3708    );
3709    let json = serde_json::to_string(field).map_err(|err| {
3710        builtin_error_with_source(FIELD_NAME, &ERROR_INTERNAL, err.to_string(), err)
3711    })?;
3712    object
3713        .properties
3714        .insert(FEA_PAYLOAD_JSON_PROPERTY.to_string(), Value::String(json));
3715    Ok(object)
3716}
3717
3718fn field_values_value(field: &AnalysisField) -> BuiltinResult<Value> {
3719    match &field.values {
3720        AnalysisFieldValues::HostF64(values) => Tensor::new(values.clone(), field.shape.clone())
3721            .map(Value::Tensor)
3722            .map_err(|err| {
3723                builtin_error(
3724                    FIELD_NAME,
3725                    &ERROR_INTERNAL,
3726                    format!("fea.field: failed to build values tensor: {err}"),
3727                )
3728            }),
3729        AnalysisFieldValues::DeviceRef(device) => serializable_to_value_preserving_integers(
3730            FIELD_NAME,
3731            &ERROR_INTERNAL,
3732            device,
3733            &[],
3734            &["element_count"],
3735        ),
3736    }
3737}
3738
3739fn usize_slice_tensor(values: &[usize], rows: usize, cols: usize) -> BuiltinResult<Value> {
3740    let values = values
3741        .iter()
3742        .map(|value| u64::try_from(*value))
3743        .collect::<Result<Vec<_>, _>>()
3744        .map_err(|_| {
3745            builtin_error(
3746                FIELD_NAME,
3747                &ERROR_INTERNAL,
3748                "FEA field shape exceeds uint64",
3749            )
3750        })?;
3751    Tensor::new_integer(IntegerStorage::U64(values), vec![rows, cols])
3752        .map(Value::Tensor)
3753        .map_err(|err| {
3754            builtin_error(
3755                FIELD_NAME,
3756                &ERROR_INTERNAL,
3757                format!("fea.field: failed to build metadata tensor: {err}"),
3758            )
3759        })
3760}
3761
3762fn empty_double_value() -> Value {
3763    Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor shape is valid"))
3764}
3765
3766fn find_field<I>(fields: I, requested: &str) -> Option<AnalysisField>
3767where
3768    I: IntoIterator<Item = AnalysisField>,
3769{
3770    let mut suffix_matches = Vec::new();
3771    for field in fields {
3772        if field.field_id == requested {
3773            return Some(field);
3774        }
3775        if field_id_matches(&field.field_id, requested) {
3776            suffix_matches.push(field);
3777        }
3778    }
3779    if suffix_matches.len() == 1 {
3780        suffix_matches.pop()
3781    } else {
3782        None
3783    }
3784}
3785
3786fn find_descriptor<'a, I>(descriptors: I, requested: &str) -> Option<&'a AnalysisFieldDescriptor>
3787where
3788    I: IntoIterator<Item = &'a AnalysisFieldDescriptor>,
3789{
3790    let mut suffix_matches = Vec::new();
3791    for descriptor in descriptors {
3792        if descriptor.field_id == requested {
3793            return Some(descriptor);
3794        }
3795        if field_id_matches(&descriptor.field_id, requested) {
3796            suffix_matches.push(descriptor);
3797        }
3798    }
3799    if suffix_matches.len() == 1 {
3800        suffix_matches.pop()
3801    } else {
3802        None
3803    }
3804}
3805
3806fn field_id_matches(candidate: &str, requested: &str) -> bool {
3807    candidate == requested
3808        || candidate
3809            .strip_suffix(requested)
3810            .is_some_and(|prefix| prefix.ends_with('.'))
3811        || candidate
3812            .rsplit_once('.')
3813            .is_some_and(|(_, tail)| tail == requested)
3814}
3815
3816struct FeaPlotRequest {
3817    study: AnalysisStudySpec,
3818    run_id: String,
3819    field_id: Option<String>,
3820    options: FeaPlotOptions,
3821}
3822
3823#[derive(Debug, Clone, PartialEq, Eq)]
3824struct FeaPlotOptions {
3825    field_id: Option<String>,
3826    mesh_source: crate::analysis::AnalysisFigureMeshSource,
3827    show_solver_mesh_edges: bool,
3828    apply_deformation_overlay: bool,
3829}
3830
3831impl Default for FeaPlotOptions {
3832    fn default() -> Self {
3833        Self {
3834            field_id: None,
3835            mesh_source: crate::analysis::AnalysisFigureMeshSource::Auto,
3836            show_solver_mesh_edges: false,
3837            apply_deformation_overlay: true,
3838        }
3839    }
3840}
3841
3842fn plot_request_from_args(args: &[Value]) -> BuiltinResult<FeaPlotRequest> {
3843    if args.is_empty() {
3844        return Err(builtin_error(
3845            PLOT_NAME,
3846            &ERROR_INPUT,
3847            "fea.plot requires a run, results, field, or study/run pair",
3848        ));
3849    }
3850
3851    let (core, options) = split_plot_options(args)?;
3852    match core {
3853        [single] => plot_request_from_context_value(single, options),
3854        [first, second] if is_fea_study(first) => {
3855            let study = study_context_from_value(PLOT_NAME, first)?;
3856            let run_id = run_id_from_value(PLOT_NAME, second)?;
3857            Ok(FeaPlotRequest {
3858                study,
3859                run_id,
3860                field_id: options.field_id.clone(),
3861                options,
3862            })
3863        }
3864        [first, second] => {
3865            let mut request = plot_request_from_context_value(first, options)?;
3866            request.field_id = Some(scalar_string(second, PLOT_NAME, &ERROR_INPUT)?);
3867            if request.options.field_id.is_some() {
3868                request.field_id = request.options.field_id.clone();
3869            }
3870            Ok(request)
3871        }
3872        [first, second, third] if is_fea_study(first) => {
3873            let study = study_context_from_value(PLOT_NAME, first)?;
3874            let run_id = run_id_from_value(PLOT_NAME, second)?;
3875            let field_id = match options.field_id.clone() {
3876                Some(field_id) => Some(field_id),
3877                None => Some(scalar_string(third, PLOT_NAME, &ERROR_INPUT)?),
3878            };
3879            Ok(FeaPlotRequest {
3880                study,
3881                run_id,
3882                field_id,
3883                options,
3884            })
3885        }
3886        _ => Err(builtin_error(
3887            PLOT_NAME,
3888            &ERROR_INPUT,
3889            "fea.plot supports plot(run, field), plot(results, field), plot(field), or plot(study, runId, field)",
3890        )),
3891    }
3892}
3893
3894fn split_plot_options(args: &[Value]) -> BuiltinResult<(&[Value], FeaPlotOptions)> {
3895    let mut options = FeaPlotOptions::default();
3896    let mut end = args.len();
3897    while end >= 2 && is_plot_option_name(&args[end - 2]) {
3898        let key = scalar_string(&args[end - 2], PLOT_NAME, &ERROR_INPUT)?.to_ascii_lowercase();
3899        match key.as_str() {
3900            "field" | "fieldid" | "field_id" => {
3901                options.field_id = Some(scalar_string(&args[end - 1], PLOT_NAME, &ERROR_INPUT)?);
3902            }
3903            "mesh" => {
3904                options.show_solver_mesh_edges =
3905                    plot_mesh_option_shows_solver_edges(&args[end - 1])?;
3906            }
3907            "overlay" => {
3908                options.mesh_source = plot_overlay_option_mesh_source(&args[end - 1])?;
3909            }
3910            "deformed" => {
3911                options.apply_deformation_overlay = bool_from_value(PLOT_NAME, &args[end - 1])?;
3912            }
3913            _ => unreachable!("is_plot_option_name only accepts supported plot option names"),
3914        }
3915        end -= 2;
3916    }
3917    Ok((&args[..end], options))
3918}
3919
3920fn is_plot_option_name(value: &Value) -> bool {
3921    scalar_string(value, PLOT_NAME, &ERROR_INPUT)
3922        .map(|name| {
3923            matches!(
3924                name.to_ascii_lowercase().as_str(),
3925                "field" | "fieldid" | "field_id" | "mesh" | "overlay" | "deformed"
3926            )
3927        })
3928        .unwrap_or(false)
3929}
3930
3931fn plot_mesh_option_shows_solver_edges(value: &Value) -> BuiltinResult<bool> {
3932    let mesh = scalar_string(value, PLOT_NAME, &ERROR_INPUT)?;
3933    match mesh.to_ascii_lowercase().as_str() {
3934        "solver" | "solver_edges" | "solveredges" | "edges" => Ok(true),
3935        "cad" | "geometry" | "surface" | "none" => Ok(false),
3936        other => Err(builtin_error(
3937            PLOT_NAME,
3938            &ERROR_INPUT,
3939            format!(
3940                "unsupported fea.plot mesh option `{other}`; expected solver, solver_edges, cad, geometry, surface, or none"
3941            ),
3942        )),
3943    }
3944}
3945
3946fn plot_overlay_option_mesh_source(
3947    value: &Value,
3948) -> BuiltinResult<crate::analysis::AnalysisFigureMeshSource> {
3949    let overlay = scalar_string(value, PLOT_NAME, &ERROR_INPUT)?;
3950    match overlay.to_ascii_lowercase().as_str() {
3951        "auto" => Ok(crate::analysis::AnalysisFigureMeshSource::Auto),
3952        "solver" | "mesh" | "boundary" | "solver_boundary" | "solverboundary" => {
3953            Ok(crate::analysis::AnalysisFigureMeshSource::Solver)
3954        }
3955        "cad" | "cad_reference" | "reference" | "geometry" | "surface" => {
3956            Ok(crate::analysis::AnalysisFigureMeshSource::CadReference)
3957        }
3958        other => Err(builtin_error(
3959            PLOT_NAME,
3960            &ERROR_INPUT,
3961            format!("unsupported fea.plot overlay option `{other}`; expected auto, solver, or cad"),
3962        )),
3963    }
3964}
3965
3966fn is_fea_study(value: &Value) -> bool {
3967    matches!(value, Value::Object(object) if object.class_name == FEA_STUDY_CLASS)
3968}
3969
3970fn plot_request_from_context_value(
3971    value: &Value,
3972    options: FeaPlotOptions,
3973) -> BuiltinResult<FeaPlotRequest> {
3974    let study = study_context_from_value(PLOT_NAME, value)?;
3975    let run_id = run_id_context_from_value(value)
3976        .or_else(|| run_id_from_value(PLOT_NAME, value).ok())
3977        .ok_or_else(|| {
3978            builtin_error(
3979                PLOT_NAME,
3980                &ERROR_INPUT,
3981                "fea.plot requires a run_id; use a fea.RunResult from fea.run or pass fea.plot(study, runId, field)",
3982            )
3983        })?;
3984    let field_id = options.field_id.clone().or_else(|| match value {
3985        Value::Object(object) if object.class_name == FEA_FIELD_CLASS => object
3986            .properties
3987            .get("field_id")
3988            .and_then(|value| match value {
3989                Value::String(field_id) => Some(field_id.clone()),
3990                _ => None,
3991            }),
3992        _ => None,
3993    });
3994    Ok(FeaPlotRequest {
3995        study,
3996        run_id,
3997        field_id,
3998        options,
3999    })
4000}
4001
4002#[cfg(feature = "plot-core")]
4003fn generate_plot_figures(
4004    study: &AnalysisStudySpec,
4005    run_id: &str,
4006    options: &FeaPlotOptions,
4007) -> BuiltinResult<Vec<crate::analysis::AnalysisGeneratedFigure>> {
4008    crate::analysis::analysis_generate_study_run_figures(
4009        study,
4010        run_id,
4011        crate::analysis::AnalysisFigureGenerationOptions {
4012            include_comparison: false,
4013            include_trends: false,
4014            max_mesh_result_figures: 8,
4015            mesh_source: options.mesh_source,
4016            show_solver_mesh_edges: options.show_solver_mesh_edges,
4017            apply_deformation_overlay: options.apply_deformation_overlay,
4018            ..crate::analysis::AnalysisFigureGenerationOptions::default()
4019        },
4020    )
4021    .map_err(|err| builtin_error(PLOT_NAME, &ERROR_OPERATION, err))
4022}
4023
4024#[cfg(feature = "plot-core")]
4025fn select_generated_figure(
4026    figures: &mut Vec<crate::analysis::AnalysisGeneratedFigure>,
4027    field_id: Option<&str>,
4028) -> BuiltinResult<crate::analysis::AnalysisGeneratedFigure> {
4029    if figures.is_empty() {
4030        return Err(builtin_error(
4031            PLOT_NAME,
4032            &ERROR_OPERATION,
4033            "fea.plot could not generate a renderable FEA figure for this run",
4034        ));
4035    }
4036    let Some(field_id) = field_id else {
4037        if let Some(index) = default_generated_figure_index(figures) {
4038            return Ok(figures.remove(index));
4039        }
4040        return Ok(figures.remove(0));
4041    };
4042    if let Some(index) = figures.iter().position(|figure| {
4043        figure
4044            .field_ids
4045            .iter()
4046            .any(|candidate| field_id_matches(candidate, field_id))
4047    }) {
4048        return Ok(figures.remove(index));
4049    }
4050    let available = figures
4051        .iter()
4052        .flat_map(|figure| figure.field_ids.iter())
4053        .cloned()
4054        .collect::<Vec<_>>()
4055        .join(", ");
4056    Err(builtin_error(
4057        PLOT_NAME,
4058        &ERROR_INPUT,
4059        format!("FEA field `{field_id}` did not produce a mesh figure; available figure fields: {available}"),
4060    ))
4061}
4062
4063#[cfg(feature = "plot-core")]
4064fn default_generated_figure_index(
4065    figures: &[crate::analysis::AnalysisGeneratedFigure],
4066) -> Option<usize> {
4067    let mut best: Option<(usize, u8)> = None;
4068    for (index, figure) in figures.iter().enumerate() {
4069        let score = default_generated_figure_score(figure);
4070        if best
4071            .map(|(_, best_score)| score > best_score)
4072            .unwrap_or(true)
4073        {
4074            best = Some((index, score));
4075        }
4076    }
4077    best.map(|(index, _)| index)
4078}
4079
4080#[cfg(feature = "plot-core")]
4081fn default_generated_figure_score(figure: &crate::analysis::AnalysisGeneratedFigure) -> u8 {
4082    let kind_score = match figure.kind {
4083        crate::analysis::AnalysisGeneratedFigureKind::MeshResult => 40,
4084        crate::analysis::AnalysisGeneratedFigureKind::Modal
4085        | crate::analysis::AnalysisGeneratedFigureKind::Electromagnetic => 35,
4086        crate::analysis::AnalysisGeneratedFigureKind::Summary
4087        | crate::analysis::AnalysisGeneratedFigureKind::Convergence => 20,
4088        crate::analysis::AnalysisGeneratedFigureKind::Comparison
4089        | crate::analysis::AnalysisGeneratedFigureKind::Trend => 15,
4090    };
4091    figure
4092        .field_ids
4093        .iter()
4094        .map(|field_id| default_field_figure_score(field_id))
4095        .max()
4096        .unwrap_or(kind_score)
4097        .max(kind_score)
4098}
4099
4100#[cfg(feature = "plot-core")]
4101fn default_field_figure_score(field_id: &str) -> u8 {
4102    let normalized = field_id.to_ascii_lowercase();
4103    if normalized.contains("residual")
4104        || normalized.contains("iteration")
4105        || normalized.contains("orthogonality")
4106        || normalized.contains("condition")
4107    {
4108        return 25;
4109    }
4110    if normalized.contains("von_mises") || normalized.contains("stress") {
4111        return 95;
4112    }
4113    if normalized.contains("temperature")
4114        || normalized.contains("heat_flux")
4115        || normalized.contains("velocity")
4116        || normalized.contains("pressure")
4117        || normalized.contains("magnetic_flux_density")
4118        || normalized.contains("electric_field")
4119        || normalized.contains("sound_pressure")
4120        || normalized.contains("coupling")
4121    {
4122        return 90;
4123    }
4124    if normalized.contains("mode_shape") || normalized.contains("displacement") {
4125        return 85;
4126    }
4127    if normalized.starts_with("structural.")
4128        || normalized.starts_with("modal.")
4129        || normalized.starts_with("thermal.")
4130        || normalized.starts_with("transient.")
4131        || normalized.starts_with("nonlinear.")
4132        || normalized.starts_with("em.")
4133        || normalized.starts_with("electro_thermal.")
4134        || normalized.starts_with("thermo_mechanical.")
4135        || normalized.starts_with("acoustic.")
4136        || normalized.starts_with("cfd.")
4137        || normalized.starts_with("fluid.")
4138        || normalized.starts_with("cht.")
4139        || normalized.starts_with("fsi.")
4140    {
4141        return 70;
4142    }
4143    40
4144}
4145
4146#[cfg(feature = "plot-core")]
4147fn import_generated_figure(figure: crate::analysis::AnalysisGeneratedFigure) -> BuiltinResult<u32> {
4148    Ok(crate::builtins::plotting::import_runtime_figure(
4149        figure.figure,
4150    ))
4151}
4152
4153struct NameValuePair<'a> {
4154    name: &'a Value,
4155    key: String,
4156    value: &'a Value,
4157}
4158
4159fn expect_name_value_tail<'a>(
4160    builtin: &'static str,
4161    args: &'a [Value],
4162) -> BuiltinResult<Vec<NameValuePair<'a>>> {
4163    if !args.len().is_multiple_of(2) {
4164        return Err(builtin_error(
4165            builtin,
4166            &ERROR_INPUT,
4167            format!("{builtin} options must be Name, Value pairs"),
4168        ));
4169    }
4170    args.chunks(2)
4171        .map(|pair| {
4172            let key = option_key(&pair[0], builtin)?;
4173            Ok(NameValuePair {
4174                name: &pair[0],
4175                key,
4176                value: &pair[1],
4177            })
4178        })
4179        .collect()
4180}
4181
4182fn json_fields_from_name_values(
4183    builtin: &'static str,
4184    args: &[Value],
4185) -> BuiltinResult<serde_json::Map<String, serde_json::Value>> {
4186    let mut fields = serde_json::Map::new();
4187    for pair in expect_name_value_tail(builtin, args)? {
4188        let raw = scalar_string(pair.name, builtin, &ERROR_INPUT)?;
4189        let key = canonical_field_name(&raw);
4190        if fields
4191            .insert(key.clone(), value_to_json(builtin, pair.value)?)
4192            .is_some()
4193        {
4194            return Err(builtin_error(
4195                builtin,
4196                &ERROR_INPUT,
4197                format!("duplicate {builtin} option `{key}`"),
4198            ));
4199        }
4200    }
4201    Ok(fields)
4202}
4203
4204fn option_key(value: &Value, builtin: &'static str) -> BuiltinResult<String> {
4205    Ok(normalize_token(&scalar_string(
4206        value,
4207        builtin,
4208        &ERROR_INPUT,
4209    )?))
4210}
4211
4212fn normalize_token(text: &str) -> String {
4213    text.chars()
4214        .filter(|ch| ch.is_ascii_alphanumeric())
4215        .flat_map(|ch| ch.to_lowercase())
4216        .collect()
4217}
4218
4219fn canonical_field_name(text: &str) -> String {
4220    let mut out = String::new();
4221    let mut previous_lower_or_digit = false;
4222    for ch in text.chars() {
4223        if ch == '-' || ch == ' ' {
4224            if !out.ends_with('_') && !out.is_empty() {
4225                out.push('_');
4226            }
4227            previous_lower_or_digit = false;
4228            continue;
4229        }
4230        if ch == '_' {
4231            if !out.ends_with('_') && !out.is_empty() {
4232                out.push('_');
4233            }
4234            previous_lower_or_digit = false;
4235            continue;
4236        }
4237        if ch.is_ascii_uppercase() {
4238            if previous_lower_or_digit && !out.ends_with('_') {
4239                out.push('_');
4240            }
4241            out.push(ch.to_ascii_lowercase());
4242            previous_lower_or_digit = false;
4243        } else if ch.is_ascii_alphanumeric() {
4244            out.push(ch.to_ascii_lowercase());
4245            previous_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
4246        }
4247    }
4248    match normalize_token(&out).as_str() {
4249        "youngsmoduluspa" => "youngs_modulus_pa".to_string(),
4250        "poissonratio" => "poisson_ratio".to_string(),
4251        "density" | "densitykgperm3" => "density_kg_per_m3".to_string(),
4252        "magnitude" | "magnitudepa" => "magnitude_pa".to_string(),
4253        "current" | "currenta" => "current_a".to_string(),
4254        "phase" | "phaserad" => "phase_rad".to_string(),
4255        "specificimpedancepasperm" => "specific_impedance_pa_s_per_m".to_string(),
4256        "temperaturek" => "temperature_k".to_string(),
4257        "heatfluxwperm2" => "heat_flux_w_per_m2".to_string(),
4258        "ambienttemperaturek" => "ambient_temperature_k".to_string(),
4259        "coefficientwperm2k" => "coefficient_w_per_m2k".to_string(),
4260        "velocitympers" => "velocity_m_per_s".to_string(),
4261        "pressurepa" => "pressure_pa".to_string(),
4262        "amplitudescale" => "amplitude_scale".to_string(),
4263        "conductivitywpermk" => "conductivity_w_per_mk".to_string(),
4264        "specificheatjperkgk" => "specific_heat_j_per_kgk".to_string(),
4265        "conductivitysperm" => "conductivity_s_per_m".to_string(),
4266        "speedofsoundmpers" => "speed_of_sound_m_per_s".to_string(),
4267        "volumetricwperm3" => "volumetric_w_per_m3".to_string(),
4268        "inletvelocitympers" => "inlet_velocity_m_per_s".to_string(),
4269        "thermalconductancewperm2k" => "thermal_conductance_w_per_m2k".to_string(),
4270        "contactresistancem2kperw" => "contact_resistance_m2k_per_w".to_string(),
4271        "deterministicmode" => "deterministic_mode".to_string(),
4272        "precisionmode" => "precision_mode".to_string(),
4273        "preconditionermode" => "preconditioner_mode".to_string(),
4274        "qualitypolicy" => "quality_policy".to_string(),
4275        "prepcalibrationprofile" => "prep_calibration_profile".to_string(),
4276        "prepartifactid" => "prep_artifact_id".to_string(),
4277        "sweepfrequencyhz" => "sweep_frequency_hz".to_string(),
4278        "sweepenabled" => "sweep_enabled".to_string(),
4279        _ => out.trim_matches('_').to_string(),
4280    }
4281}
4282
4283fn value_to_json(builtin: &'static str, value: &Value) -> BuiltinResult<serde_json::Value> {
4284    match value {
4285        Value::Num(n) => json_number(builtin, *n),
4286        Value::Int(i) => Ok(int_value_to_json(i)),
4287        Value::Bool(b) => Ok(serde_json::Value::Bool(*b)),
4288        Value::String(s) => Ok(serde_json::Value::String(s.clone())),
4289        Value::CharArray(chars) if chars.rows == 1 => {
4290            Ok(serde_json::Value::String(chars.data.iter().collect()))
4291        }
4292        Value::StringArray(array) if array.data.len() == 1 => {
4293            Ok(serde_json::Value::String(array.data[0].clone()))
4294        }
4295        Value::StringArray(array) => Ok(serde_json::Value::Array(
4296            array
4297                .data
4298                .iter()
4299                .cloned()
4300                .map(serde_json::Value::String)
4301                .collect(),
4302        )),
4303        Value::Tensor(tensor) if tensor_utils::is_scalar_tensor(tensor) => numeric_scalar_to_json(
4304            builtin,
4305            tensor
4306                .numeric_value_at(0)
4307                .expect("validated scalar tensor storage"),
4308        ),
4309        Value::Tensor(tensor) => Ok(serde_json::Value::Array(
4310            (0..tensor.len())
4311                .map(|index| {
4312                    numeric_scalar_to_json(
4313                        builtin,
4314                        tensor
4315                            .numeric_value_at(index)
4316                            .expect("validated tensor storage"),
4317                    )
4318                })
4319                .collect::<BuiltinResult<Vec<_>>>()?,
4320        )),
4321        Value::Cell(cell) => Ok(serde_json::Value::Array(
4322            cell.data
4323                .iter()
4324                .map(|item| value_to_json(builtin, item))
4325                .collect::<BuiltinResult<Vec<_>>>()?,
4326        )),
4327        Value::Struct(fields) => {
4328            let mut object = serde_json::Map::new();
4329            for (key, value) in &fields.fields {
4330                object.insert(canonical_field_name(key), value_to_json(builtin, value)?);
4331            }
4332            Ok(serde_json::Value::Object(object))
4333        }
4334        Value::Object(object) => {
4335            if let Some(Value::String(json)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY) {
4336                serde_json::from_str(json).map_err(|err| {
4337                    builtin_error_with_source(builtin, &ERROR_INPUT, err.to_string(), err)
4338                })
4339            } else {
4340                let mut object_json = serde_json::Map::new();
4341                for (key, value) in &object.properties {
4342                    if key.starts_with("__runmat_") {
4343                        continue;
4344                    }
4345                    object_json.insert(canonical_field_name(key), value_to_json(builtin, value)?);
4346                }
4347                Ok(serde_json::Value::Object(object_json))
4348            }
4349        }
4350        other => Err(builtin_error(
4351            builtin,
4352            &ERROR_INPUT,
4353            format!("cannot convert value to FEA JSON payload: {other:?}"),
4354        )),
4355    }
4356}
4357
4358fn numeric_scalar_to_json(
4359    builtin: &'static str,
4360    value: NumericScalar,
4361) -> BuiltinResult<serde_json::Value> {
4362    match value {
4363        NumericScalar::F64(value) => json_number(builtin, value),
4364        NumericScalar::F32(value) => json_number(builtin, f64::from(value)),
4365        value => Ok(int_value_to_json(
4366            &value
4367                .into_int_value()
4368                .expect("non-floating numeric scalar is integer"),
4369        )),
4370    }
4371}
4372
4373fn json_number(builtin: &'static str, value: f64) -> BuiltinResult<serde_json::Value> {
4374    serde_json::Number::from_f64(value)
4375        .map(serde_json::Value::Number)
4376        .ok_or_else(|| {
4377            builtin_error(
4378                builtin,
4379                &ERROR_INPUT,
4380                "FEA numeric option values must be finite JSON numbers",
4381            )
4382        })
4383}
4384
4385fn typed_json_with_overrides<T: Serialize + DeserializeOwned>(
4386    builtin: &'static str,
4387    default: T,
4388    fields: serde_json::Map<String, serde_json::Value>,
4389    label: &str,
4390) -> BuiltinResult<serde_json::Value> {
4391    let base = serde_json::to_value(default)
4392        .map_err(|err| builtin_error(builtin, &ERROR_INTERNAL, err.to_string()))?;
4393    let merged = json_with_overrides(builtin, base, fields, label)?;
4394    let typed: T = json_deserialize(builtin, merged, label)?;
4395    serde_json::to_value(typed)
4396        .map_err(|err| builtin_error_with_source(builtin, &ERROR_INTERNAL, err.to_string(), err))
4397}
4398
4399fn json_with_overrides(
4400    builtin: &'static str,
4401    mut base: serde_json::Value,
4402    fields: serde_json::Map<String, serde_json::Value>,
4403    label: &str,
4404) -> BuiltinResult<serde_json::Value> {
4405    let Some(object) = base.as_object_mut() else {
4406        return Err(builtin_error(
4407            builtin,
4408            &ERROR_INTERNAL,
4409            format!("{label} default payload is not an object"),
4410        ));
4411    };
4412    for (key, value) in fields {
4413        if !object.contains_key(&key) {
4414            return Err(builtin_error(
4415                builtin,
4416                &ERROR_INPUT,
4417                format!("unsupported {label} option `{key}`"),
4418            ));
4419        }
4420        object.insert(key, value);
4421    }
4422    Ok(base)
4423}
4424
4425fn json_deserialize<T: DeserializeOwned>(
4426    builtin: &'static str,
4427    value: serde_json::Value,
4428    label: &str,
4429) -> BuiltinResult<T> {
4430    serde_json::from_value(value)
4431        .map_err(|err| builtin_error(builtin, &ERROR_INPUT, format!("invalid {label}: {err}")))
4432}
4433
4434fn typed_domain_data<T: DeserializeOwned + Serialize>(
4435    builtin: &'static str,
4436    label: &str,
4437    value: serde_json::Value,
4438) -> BuiltinResult<serde_json::Value> {
4439    let typed: T = json_deserialize(builtin, value, label)?;
4440    serde_json::to_value(typed)
4441        .map_err(|err| builtin_error_with_source(builtin, &ERROR_INTERNAL, err.to_string(), err))
4442}
4443
4444fn json_to_string(value: serde_json::Value) -> BuiltinResult<String> {
4445    serde_json::from_value(value).map_err(|err| {
4446        builtin_error(
4447            MATERIAL_NAME,
4448            &ERROR_INPUT,
4449            format!("invalid string option: {err}"),
4450        )
4451    })
4452}
4453
4454fn remove_required_f64(
4455    fields: &mut serde_json::Map<String, serde_json::Value>,
4456    builtin: &'static str,
4457    key: &str,
4458) -> BuiltinResult<f64> {
4459    let Some(value) = fields.remove(key) else {
4460        return Err(builtin_error(
4461            builtin,
4462            &ERROR_INPUT,
4463            format!("missing required option `{key}`"),
4464        ));
4465    };
4466    serde_json::from_value(value).map_err(|err| {
4467        builtin_error(
4468            builtin,
4469            &ERROR_INPUT,
4470            format!("invalid numeric option `{key}`: {err}"),
4471        )
4472    })
4473}
4474
4475fn remove_optional_f64(
4476    fields: &mut serde_json::Map<String, serde_json::Value>,
4477    builtin: &'static str,
4478    key: &str,
4479) -> BuiltinResult<Option<f64>> {
4480    fields
4481        .remove(key)
4482        .map(|value| {
4483            serde_json::from_value(value).map_err(|err| {
4484                builtin_error(
4485                    builtin,
4486                    &ERROR_INPUT,
4487                    format!("invalid numeric option `{key}`: {err}"),
4488                )
4489            })
4490        })
4491        .transpose()
4492}
4493
4494fn remove_required_vector3(
4495    fields: &mut serde_json::Map<String, serde_json::Value>,
4496    builtin: &'static str,
4497    key: &str,
4498) -> BuiltinResult<[f64; 3]> {
4499    let Some(value) = fields.remove(key) else {
4500        return Err(builtin_error(
4501            builtin,
4502            &ERROR_INPUT,
4503            format!("missing required vector option `{key}`"),
4504        ));
4505    };
4506    let values: Vec<f64> = serde_json::from_value(value).map_err(|err| {
4507        builtin_error(
4508            builtin,
4509            &ERROR_INPUT,
4510            format!("invalid vector option `{key}`: {err}"),
4511        )
4512    })?;
4513    if values.len() != 3 {
4514        return Err(builtin_error(
4515            builtin,
4516            &ERROR_INPUT,
4517            format!("vector option `{key}` must contain exactly 3 values"),
4518        ));
4519    }
4520    Ok([values[0], values[1], values[2]])
4521}
4522
4523fn move_known_fields(
4524    source: &mut serde_json::Map<String, serde_json::Value>,
4525    target: &mut serde_json::Map<String, serde_json::Value>,
4526    keys: &[&str],
4527) -> bool {
4528    let mut moved = false;
4529    for key in keys {
4530        if let Some(value) = source.remove(*key) {
4531            target.insert((*key).to_string(), value);
4532            moved = true;
4533        }
4534    }
4535    moved
4536}
4537
4538fn reject_unknown_fields(
4539    builtin: &'static str,
4540    fields: serde_json::Map<String, serde_json::Value>,
4541) -> BuiltinResult<()> {
4542    if fields.is_empty() {
4543        return Ok(());
4544    }
4545    let keys = fields.keys().cloned().collect::<Vec<_>>().join(", ");
4546    Err(builtin_error(
4547        builtin,
4548        &ERROR_INPUT,
4549        format!("unsupported option field(s): {keys}"),
4550    ))
4551}
4552
4553fn logical_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<bool> {
4554    match value {
4555        Value::Bool(value) => Ok(*value),
4556        Value::LogicalArray(array) if array.data.len() == 1 => Ok(array.data[0] != 0),
4557        other => Err(builtin_error(
4558            builtin,
4559            &ERROR_INPUT,
4560            format!("expected logical scalar; got {other:?}"),
4561        )),
4562    }
4563}
4564
4565fn exact_bool_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<bool> {
4566    if let Ok(value) = logical_from_value(builtin, value) {
4567        return Ok(value);
4568    }
4569    if let Some(integer) = tensor_utils::scalar_integer_value(value) {
4570        return match integer.try_to_usize() {
4571            Some(0) => Ok(false),
4572            Some(1) => Ok(true),
4573            _ => Err(builtin_error(
4574                builtin,
4575                &ERROR_INPUT,
4576                "numeric logical option must be exactly zero or one",
4577            )),
4578        };
4579    }
4580    match ordinary_double_scalar(value) {
4581        Some(0.0) => Ok(false),
4582        Some(1.0) => Ok(true),
4583        _ => Err(builtin_error(
4584            builtin,
4585            &ERROR_INPUT,
4586            "logical option must be a logical scalar or exact numeric zero or one",
4587        )),
4588    }
4589}
4590
4591fn bool_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<bool> {
4592    bool::try_from(value).map_err(|err| builtin_error(builtin, &ERROR_INPUT, err))
4593}
4594
4595fn usize_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<usize> {
4596    if let Some(int) = tensor_utils::scalar_integer_value(value) {
4597        return int.try_to_usize().ok_or_else(|| {
4598            builtin_error(
4599                builtin,
4600                &ERROR_INPUT,
4601                "expected non-negative integer value outside the platform range",
4602            )
4603        });
4604    }
4605    match ordinary_double_scalar(value) {
4606        Some(n) if n.is_finite() && n >= 0.0 && n.fract() == 0.0 => {
4607            if n > usize::MAX as f64 || (usize::BITS == 64 && n == usize::MAX as f64) {
4608                return Err(builtin_error(
4609                    builtin,
4610                    &ERROR_INPUT,
4611                    "expected non-negative integer value outside the platform range",
4612                ));
4613            }
4614            Ok(n as usize)
4615        }
4616        _ => Err(builtin_error(
4617            builtin,
4618            &ERROR_INPUT,
4619            format!("expected non-negative integer value; got {value:?}"),
4620        )),
4621    }
4622}
4623
4624fn ordinary_double_scalar(value: &Value) -> Option<f64> {
4625    match value {
4626        Value::Num(value) => Some(*value),
4627        Value::Tensor(tensor)
4628            if tensor.len() == 1 && tensor.numeric_dtype() == runmat_value::NumericDType::F64 =>
4629        {
4630            Some(tensor_utils::tensor_value_f64(tensor, 0))
4631        }
4632        _ => None,
4633    }
4634}
4635
4636fn string_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<String>> {
4637    match value {
4638        Value::Cell(cell) => cell
4639            .data
4640            .iter()
4641            .map(|item| scalar_string(item, builtin, &ERROR_INPUT))
4642            .collect(),
4643        Value::StringArray(array) => Ok(array.data.clone()),
4644        Value::String(_) | Value::CharArray(_) => {
4645            Ok(vec![scalar_string(value, builtin, &ERROR_INPUT)?])
4646        }
4647        other => Err(builtin_error(
4648            builtin,
4649            &ERROR_INPUT,
4650            format!("expected string, string array, or cell array of strings; got {other:?}"),
4651        )),
4652    }
4653}
4654
4655fn usize_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<usize>> {
4656    match value {
4657        Value::Tensor(tensor) => {
4658            if tensor
4659                .shape
4660                .iter()
4661                .filter(|&&dimension| dimension > 1)
4662                .count()
4663                > 1
4664            {
4665                return Err(builtin_error(
4666                    builtin,
4667                    &ERROR_INPUT,
4668                    "expected a numeric scalar or vector of indices, not a matrix",
4669                ));
4670            }
4671            if let Some(storage) = tensor.integer_storage() {
4672                return storage
4673                    .exact_values()
4674                    .into_iter()
4675                    .map(|value| usize_from_value(builtin, &Value::Int(value)))
4676                    .collect();
4677            }
4678            if tensor.numeric_dtype() != runmat_value::NumericDType::F64 {
4679                return Err(builtin_error(
4680                    builtin,
4681                    &ERROR_INPUT,
4682                    "floating index selectors must use ordinary double storage",
4683                ));
4684            }
4685            tensor_utils::tensor_values_f64(tensor)
4686                .into_iter()
4687                .map(|value| usize_from_value(builtin, &Value::Num(value)))
4688                .collect()
4689        }
4690        Value::Int(_) | Value::Num(_) => Ok(vec![usize_from_value(builtin, value)?]),
4691        other => Err(builtin_error(
4692            builtin,
4693            &ERROR_INPUT,
4694            format!("expected numeric scalar or vector of indices; got {other:?}"),
4695        )),
4696    }
4697}
4698
4699fn one_based_usize_vec_from_value(
4700    builtin: &'static str,
4701    value: &Value,
4702) -> BuiltinResult<Vec<usize>> {
4703    usize_vec_from_value(builtin, value)?
4704        .into_iter()
4705        .map(|value| {
4706            value.checked_sub(1).ok_or_else(|| {
4707                builtin_error(
4708                    builtin,
4709                    &ERROR_INPUT,
4710                    "result indices are one-based and must be positive",
4711                )
4712            })
4713        })
4714        .collect()
4715}
4716
4717fn parse_model_defaults_mode(text: &str) -> BuiltinResult<ModelDefaultsMode> {
4718    match normalize_token(text).as_str() {
4719        "profilescaffold" | "scaffold" | "profile" => Ok(ModelDefaultsMode::ProfileScaffold),
4720        "none" | "empty" => Ok(ModelDefaultsMode::None),
4721        other => Err(builtin_error(
4722            MODEL_NAME,
4723            &ERROR_INPUT,
4724            format!("unsupported model defaults mode `{other}`"),
4725        )),
4726    }
4727}
4728
4729fn resolved_document_to_object(document: FeaResolvedDocument) -> BuiltinResult<Value> {
4730    match document {
4731        FeaResolvedDocument::Study(spec) => study_to_object(*spec),
4732        FeaResolvedDocument::Sweep(spec) => sweep_to_object(spec),
4733    }
4734}
4735
4736fn study_to_object(spec: AnalysisStudySpec) -> BuiltinResult<Value> {
4737    let mut object = serializable_to_object(
4738        STUDY_NAME,
4739        &ERROR_INTERNAL,
4740        FEA_STUDY_CLASS,
4741        &spec,
4742        Some(FEA_STUDY_SPEC_JSON_PROPERTY),
4743    )?;
4744    if let Value::Object(ref mut object) = object {
4745        object
4746            .properties
4747            .insert("id".to_string(), Value::String(spec.study_id));
4748    }
4749    Ok(object)
4750}
4751
4752fn sweep_to_object(spec: AnalysisStudySweepSpec) -> BuiltinResult<Value> {
4753    let mut object = serializable_to_object(
4754        SWEEP_NAME,
4755        &ERROR_INTERNAL,
4756        FEA_SWEEP_CLASS,
4757        &spec,
4758        Some(FEA_SWEEP_SPEC_JSON_PROPERTY),
4759    )?;
4760    if let Value::Object(ref mut object) = object {
4761        object
4762            .properties
4763            .insert("id".to_string(), Value::String(spec.sweep_id));
4764    }
4765    Ok(object)
4766}
4767
4768fn operation_result_to_object<T: Serialize>(
4769    builtin: &'static str,
4770    operation_error_descriptor: &'static BuiltinErrorDescriptor,
4771    internal_error_descriptor: &'static BuiltinErrorDescriptor,
4772    class_name: &'static str,
4773    result: Result<OperationEnvelope<T>, OperationErrorEnvelope>,
4774    hidden_json_property: Option<&'static str>,
4775) -> BuiltinResult<Value> {
4776    let envelope =
4777        result.map_err(|err| operation_error(builtin, operation_error_descriptor, err))?;
4778    serializable_to_object(
4779        builtin,
4780        internal_error_descriptor,
4781        class_name,
4782        &envelope.data,
4783        hidden_json_property,
4784    )
4785}
4786
4787fn operation_result_to_object_preserving_integers<T: Serialize>(
4788    builtin: &'static str,
4789    operation_error_descriptor: &'static BuiltinErrorDescriptor,
4790    internal_error_descriptor: &'static BuiltinErrorDescriptor,
4791    class_name: &'static str,
4792    result: Result<OperationEnvelope<T>, OperationErrorEnvelope>,
4793    hidden_json_property: Option<&'static str>,
4794    signed_fields: &[&str],
4795    unsigned_fields: &[&str],
4796) -> BuiltinResult<Value> {
4797    let envelope =
4798        result.map_err(|err| operation_error(builtin, operation_error_descriptor, err))?;
4799    serializable_to_object_preserving_integers(
4800        builtin,
4801        internal_error_descriptor,
4802        class_name,
4803        &envelope.data,
4804        hidden_json_property,
4805        signed_fields,
4806        unsigned_fields,
4807    )
4808}
4809
4810fn sweep_plan_result_to_object(
4811    result: Result<OperationEnvelope<AnalysisStudySweepPlanData>, OperationErrorEnvelope>,
4812) -> BuiltinResult<Value> {
4813    let mut envelope = result
4814        .map_err(|error| operation_error(PLAN_NAME, &ERROR_OPERATION, public_sweep_error(error)))?;
4815    one_base_failure_entries(PLAN_NAME, &mut envelope.data.failure_entries)?;
4816    serializable_to_object_preserving_integers(
4817        PLAN_NAME,
4818        &ERROR_INTERNAL,
4819        FEA_PLAN_CLASS,
4820        &envelope.data,
4821        None,
4822        &[],
4823        &[
4824            "study_count",
4825            "planned_count",
4826            "failed_count",
4827            "study_index",
4828        ],
4829    )
4830}
4831
4832fn sweep_run_result_to_object(
4833    result: Result<OperationEnvelope<AnalysisStudySweepData>, OperationErrorEnvelope>,
4834) -> BuiltinResult<Value> {
4835    let mut envelope = result
4836        .map_err(|error| operation_error(RUN_NAME, &ERROR_OPERATION, public_sweep_error(error)))?;
4837    one_base_failure_entries(RUN_NAME, &mut envelope.data.failure_entries)?;
4838    serializable_to_object_preserving_integers(
4839        RUN_NAME,
4840        &ERROR_INTERNAL,
4841        FEA_RUN_RESULT_CLASS,
4842        &envelope.data,
4843        Some(FEA_PAYLOAD_JSON_PROPERTY),
4844        &[],
4845        &[
4846            "study_count",
4847            "success_count",
4848            "failed_count",
4849            "study_index",
4850        ],
4851    )
4852}
4853
4854fn one_base_failure_entries(
4855    builtin: &'static str,
4856    entries: &mut [AnalysisStudySweepFailureEntry],
4857) -> BuiltinResult<()> {
4858    for entry in entries {
4859        entry.study_index = entry.study_index.checked_add(1).ok_or_else(|| {
4860            builtin_error(
4861                builtin,
4862                &ERROR_INTERNAL,
4863                "study index cannot be represented at the one-based public boundary",
4864            )
4865        })?;
4866    }
4867    Ok(())
4868}
4869
4870fn public_sweep_error(mut error: OperationErrorEnvelope) -> OperationErrorEnvelope {
4871    let Some(index) = error
4872        .context
4873        .get("study_index")
4874        .and_then(|value| value.parse::<usize>().ok())
4875    else {
4876        return error;
4877    };
4878    let Some(public_index) = index.checked_add(1) else {
4879        return error;
4880    };
4881    error
4882        .context
4883        .insert("study_index".to_string(), public_index.to_string());
4884    error.message = error.message.replacen(
4885        &format!("at index {index} "),
4886        &format!("at index {public_index} "),
4887        1,
4888    );
4889    error
4890}
4891
4892fn serializable_to_object<T: Serialize>(
4893    builtin: &'static str,
4894    error: &'static BuiltinErrorDescriptor,
4895    class_name: &'static str,
4896    value: &T,
4897    hidden_json_property: Option<&'static str>,
4898) -> BuiltinResult<Value> {
4899    serializable_to_object_value(builtin, error, class_name, value, hidden_json_property)
4900        .map(Value::Object)
4901}
4902
4903fn serializable_to_object_preserving_integers<T: Serialize>(
4904    builtin: &'static str,
4905    error: &'static BuiltinErrorDescriptor,
4906    class_name: &'static str,
4907    value: &T,
4908    hidden_json_property: Option<&'static str>,
4909    signed_fields: &[&str],
4910    unsigned_fields: &[&str],
4911) -> BuiltinResult<Value> {
4912    let json = serde_json::to_value(value)
4913        .map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))?;
4914    let object =
4915        serializable_to_object_value(builtin, error, class_name, value, hidden_json_property)?;
4916    let mut wrapped = Value::Object(object);
4917    promote_named_integer_fields(
4918        builtin,
4919        error,
4920        &mut wrapped,
4921        &json,
4922        signed_fields,
4923        unsigned_fields,
4924    )?;
4925    Ok(wrapped)
4926}
4927
4928fn promote_named_integer_fields(
4929    builtin: &'static str,
4930    error: &'static BuiltinErrorDescriptor,
4931    value: &mut Value,
4932    json: &serde_json::Value,
4933    signed_fields: &[&str],
4934    unsigned_fields: &[&str],
4935) -> BuiltinResult<()> {
4936    match (value, json) {
4937        (Value::Object(object), serde_json::Value::Object(map)) => {
4938            for (name, child) in map {
4939                let Some(target) = object.properties.get_mut(name) else {
4940                    continue;
4941                };
4942                if signed_fields.contains(&name.as_str()) {
4943                    if let Some(exact) = exact_integer_json_value(builtin, error, child, true)? {
4944                        *target = exact;
4945                    }
4946                } else if unsigned_fields.contains(&name.as_str()) {
4947                    if let Some(exact) = exact_integer_json_value(builtin, error, child, false)? {
4948                        *target = exact;
4949                    }
4950                } else {
4951                    promote_named_integer_fields(
4952                        builtin,
4953                        error,
4954                        target,
4955                        child,
4956                        signed_fields,
4957                        unsigned_fields,
4958                    )?;
4959                }
4960            }
4961        }
4962        (Value::Struct(value), serde_json::Value::Object(map)) => {
4963            for (name, child) in map {
4964                let Some(target) = value.fields.get_mut(name) else {
4965                    continue;
4966                };
4967                if signed_fields.contains(&name.as_str()) {
4968                    if let Some(exact) = exact_integer_json_value(builtin, error, child, true)? {
4969                        *target = exact;
4970                    }
4971                } else if unsigned_fields.contains(&name.as_str()) {
4972                    if let Some(exact) = exact_integer_json_value(builtin, error, child, false)? {
4973                        *target = exact;
4974                    }
4975                } else {
4976                    promote_named_integer_fields(
4977                        builtin,
4978                        error,
4979                        target,
4980                        child,
4981                        signed_fields,
4982                        unsigned_fields,
4983                    )?;
4984                }
4985            }
4986        }
4987        (Value::Cell(cell), serde_json::Value::Array(items)) => {
4988            for (target, child) in cell.data.iter_mut().zip(items) {
4989                promote_named_integer_fields(
4990                    builtin,
4991                    error,
4992                    target,
4993                    child,
4994                    signed_fields,
4995                    unsigned_fields,
4996                )?;
4997            }
4998        }
4999        _ => {}
5000    }
5001    Ok(())
5002}
5003
5004fn exact_integer_json_value(
5005    builtin: &'static str,
5006    error: &'static BuiltinErrorDescriptor,
5007    json: &serde_json::Value,
5008    signed: bool,
5009) -> BuiltinResult<Option<Value>> {
5010    match json {
5011        serde_json::Value::Null => Ok(None),
5012        serde_json::Value::Number(number) if signed => number
5013            .as_i64()
5014            .map(|value| Some(Value::Int(IntValue::I64(value))))
5015            .ok_or_else(|| {
5016                builtin_error(builtin, error, "signed structural integer is out of range")
5017            }),
5018        serde_json::Value::Number(number) => number
5019            .as_u64()
5020            .map(|value| Some(Value::Int(IntValue::U64(value))))
5021            .ok_or_else(|| {
5022                builtin_error(
5023                    builtin,
5024                    error,
5025                    "unsigned structural integer is out of range",
5026                )
5027            }),
5028        serde_json::Value::Array(items) => {
5029            if signed {
5030                let values = items
5031                    .iter()
5032                    .map(|item| {
5033                        item.as_i64().ok_or_else(|| {
5034                            builtin_error(
5035                                builtin,
5036                                error,
5037                                "signed structural integer array is out of range",
5038                            )
5039                        })
5040                    })
5041                    .collect::<BuiltinResult<Vec<_>>>()?;
5042                Tensor::new_integer(IntegerStorage::I64(values), vec![1, items.len()])
5043                    .map(Value::Tensor)
5044                    .map(Some)
5045                    .map_err(|message| builtin_error(builtin, error, message))
5046            } else {
5047                let values = items
5048                    .iter()
5049                    .map(|item| {
5050                        item.as_u64().ok_or_else(|| {
5051                            builtin_error(
5052                                builtin,
5053                                error,
5054                                "unsigned structural integer array is out of range",
5055                            )
5056                        })
5057                    })
5058                    .collect::<BuiltinResult<Vec<_>>>()?;
5059                Tensor::new_integer(IntegerStorage::U64(values), vec![1, items.len()])
5060                    .map(Value::Tensor)
5061                    .map(Some)
5062                    .map_err(|message| builtin_error(builtin, error, message))
5063            }
5064        }
5065        _ => Err(builtin_error(
5066            builtin,
5067            error,
5068            "structural integer field has a noninteger representation",
5069        )),
5070    }
5071}
5072
5073fn serializable_to_value_preserving_integers<T: Serialize>(
5074    builtin: &'static str,
5075    error: &'static BuiltinErrorDescriptor,
5076    value: &T,
5077    signed_fields: &[&str],
5078    unsigned_fields: &[&str],
5079) -> BuiltinResult<Value> {
5080    let json = serde_json::to_value(value)
5081        .map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))?;
5082    let mut converted = value_from_json_preserving_integer_kinds(builtin, error, &json)?;
5083    promote_named_integer_fields(
5084        builtin,
5085        error,
5086        &mut converted,
5087        &json,
5088        signed_fields,
5089        unsigned_fields,
5090    )?;
5091    Ok(converted)
5092}
5093
5094fn value_from_json_preserving_integer_kinds(
5095    builtin: &'static str,
5096    error: &'static BuiltinErrorDescriptor,
5097    json: &serde_json::Value,
5098) -> BuiltinResult<Value> {
5099    let mut converted = value_from_json(json)
5100        .map_err(|err| builtin_error_with_source(builtin, error, err.message().to_string(), err))?;
5101    promote_json_integer_kinds(builtin, error, &mut converted, json)?;
5102    Ok(converted)
5103}
5104
5105fn promote_json_integer_kinds(
5106    builtin: &'static str,
5107    error: &'static BuiltinErrorDescriptor,
5108    value: &mut Value,
5109    json: &serde_json::Value,
5110) -> BuiltinResult<()> {
5111    match (value, json) {
5112        (target, serde_json::Value::Number(number)) if number.is_u64() => {
5113            *target = Value::Int(IntValue::U64(
5114                number.as_u64().expect("checked unsigned number"),
5115            ));
5116        }
5117        (target, serde_json::Value::Number(number)) if number.is_i64() => {
5118            *target = Value::Int(IntValue::I64(
5119                number.as_i64().expect("checked signed number"),
5120            ));
5121        }
5122        (Value::Tensor(tensor), serde_json::Value::Array(_)) => {
5123            if let Some(storage) = exact_json_integer_array(json, &tensor.shape) {
5124                *tensor = Tensor::new_integer(storage, tensor.shape.clone())
5125                    .map_err(|message| builtin_error(builtin, error, message))?;
5126            }
5127        }
5128        (Value::Struct(structure), serde_json::Value::Object(map)) => {
5129            for (name, child) in map {
5130                if let Some(target) = structure.fields.get_mut(name) {
5131                    promote_json_integer_kinds(builtin, error, target, child)?;
5132                }
5133            }
5134        }
5135        (Value::Object(object), serde_json::Value::Object(map)) => {
5136            for (name, child) in map {
5137                if let Some(target) = object.properties.get_mut(name) {
5138                    promote_json_integer_kinds(builtin, error, target, child)?;
5139                }
5140            }
5141        }
5142        (Value::Cell(cell), serde_json::Value::Array(items)) => {
5143            for (target, child) in cell.data.iter_mut().zip(items) {
5144                promote_json_integer_kinds(builtin, error, target, child)?;
5145            }
5146        }
5147        _ => {}
5148    }
5149    Ok(())
5150}
5151
5152fn exact_json_integer_array(json: &serde_json::Value, shape: &[usize]) -> Option<IntegerStorage> {
5153    fn collect<'a>(
5154        json: &'a serde_json::Value,
5155        numbers: &mut Vec<&'a serde_json::Number>,
5156    ) -> Option<()> {
5157        match json {
5158            serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => {
5159                numbers.push(number);
5160                Some(())
5161            }
5162            serde_json::Value::Array(items) => {
5163                for item in items {
5164                    collect(item, numbers)?;
5165                }
5166                Some(())
5167            }
5168            _ => None,
5169        }
5170    }
5171
5172    let mut numbers = Vec::new();
5173    collect(json, &mut numbers)?;
5174    if numbers.is_empty() || numbers.len() != shape.iter().product::<usize>() {
5175        return None;
5176    }
5177    let row_major_index = |column_major_index: usize| {
5178        let mut row_major_index = 0;
5179        let mut column_stride = 1;
5180        for (dimension_index, &dimension) in shape.iter().enumerate() {
5181            let coordinate = (column_major_index / column_stride) % dimension;
5182            let row_stride = shape[dimension_index + 1..].iter().product::<usize>();
5183            row_major_index += coordinate * row_stride;
5184            column_stride *= dimension;
5185        }
5186        row_major_index
5187    };
5188    if numbers.iter().all(|number| number.is_u64()) {
5189        return Some(IntegerStorage::U64(
5190            (0..numbers.len())
5191                .map(|index| {
5192                    numbers[row_major_index(index)]
5193                        .as_u64()
5194                        .expect("checked unsigned number")
5195                })
5196                .collect(),
5197        ));
5198    }
5199    if numbers.iter().all(|number| number.is_i64()) {
5200        return Some(IntegerStorage::I64(
5201            (0..numbers.len())
5202                .map(|index| {
5203                    numbers[row_major_index(index)]
5204                        .as_i64()
5205                        .expect("checked signed number")
5206                })
5207                .collect(),
5208        ));
5209    }
5210    None
5211}
5212
5213fn serializable_to_object_value<T: Serialize>(
5214    builtin: &'static str,
5215    error: &'static BuiltinErrorDescriptor,
5216    class_name: &'static str,
5217    value: &T,
5218    hidden_json_property: Option<&'static str>,
5219) -> BuiltinResult<ObjectInstance> {
5220    ensure_fea_classes_registered();
5221    let json = serde_json::to_value(value)
5222        .map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))?;
5223    let converted = value_from_json_preserving_integer_kinds(builtin, error, &json)?;
5224    let mut object = ObjectInstance::new(class_name.to_string());
5225    if let Value::Struct(fields) = converted {
5226        object.properties = fields.fields.into_iter().collect();
5227    } else {
5228        object.properties.insert("value".to_string(), converted);
5229    }
5230    if let Some(property) = hidden_json_property {
5231        object
5232            .properties
5233            .insert(property.to_string(), Value::String(json.to_string()));
5234    }
5235    Ok(object)
5236}
5237
5238fn geometry_asset_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<GeometryAsset> {
5239    let Value::Object(object) = value else {
5240        return Err(builtin_error(
5241            builtin,
5242            &ERROR_INPUT,
5243            format!("{builtin} geometry must be {GEOMETRY_ASSET_CLASS}"),
5244        ));
5245    };
5246    if object.class_name != GEOMETRY_ASSET_CLASS {
5247        return Err(builtin_error(
5248            builtin,
5249            &ERROR_INPUT,
5250            format!(
5251                "{builtin} geometry must be {GEOMETRY_ASSET_CLASS}, got {}",
5252                object.class_name
5253            ),
5254        ));
5255    }
5256    object_json_property(builtin, object, GEOMETRY_ASSET_JSON_PROPERTY, &ERROR_INPUT)
5257}
5258
5259fn geometry_asset_from_value_with_builtin(
5260    value: &Value,
5261    builtin: &'static str,
5262) -> BuiltinResult<GeometryAsset> {
5263    geometry_asset_from_value(builtin, value)
5264}
5265
5266fn object_json_property<T: DeserializeOwned>(
5267    builtin: &'static str,
5268    object: &ObjectInstance,
5269    property: &'static str,
5270    error: &'static BuiltinErrorDescriptor,
5271) -> BuiltinResult<T> {
5272    let Some(Value::String(json)) = object.properties.get(property) else {
5273        return Err(builtin_error(
5274            builtin,
5275            error,
5276            format!(
5277                "{} is missing required runtime payload property `{property}`",
5278                object.class_name
5279            ),
5280        ));
5281    };
5282    serde_json::from_str(json)
5283        .map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))
5284}
5285
5286fn scalar_string(
5287    value: &Value,
5288    builtin: &'static str,
5289    error: &'static BuiltinErrorDescriptor,
5290) -> BuiltinResult<String> {
5291    match value {
5292        Value::String(value) => Ok(value.clone()),
5293        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
5294        Value::CharArray(chars) if chars.rows == 1 => Ok(chars.data.iter().collect()),
5295        _ => Err(builtin_error(
5296            builtin,
5297            error,
5298            format!("{builtin} expected a text scalar"),
5299        )),
5300    }
5301}
5302
5303fn parse_scalar_enum<T: DeserializeOwned>(text: &str, label: &str) -> BuiltinResult<T> {
5304    parse_scalar_enum_for_builtin(STUDY_NAME, text, label)
5305}
5306
5307fn parse_scalar_enum_for_builtin<T: DeserializeOwned>(
5308    builtin: &'static str,
5309    text: &str,
5310    label: &str,
5311) -> BuiltinResult<T> {
5312    serde_yaml::from_str::<T>(&text.to_ascii_lowercase()).map_err(|err| {
5313        builtin_error(
5314            builtin,
5315            &ERROR_INPUT,
5316            format!("invalid {label} value `{text}`: {err}"),
5317        )
5318    })
5319}
5320
5321fn resolve_study_profile_and_run_kind(
5322    options: &StudyConstructorOptions,
5323) -> BuiltinResult<(AnalysisCreateModelProfile, AnalysisRunKind)> {
5324    let profile = options.profile.ok_or_else(|| {
5325        builtin_error(
5326            STUDY_NAME,
5327            &ERROR_INPUT,
5328            "fea.study requires Profile; choose a physics profile from fea.capabilities().physicsProfiles",
5329        )
5330    })?;
5331    let run_kind = profile.derived_run_kind();
5332    if let Some(explicit_run_kind) = options.run_kind {
5333        if explicit_run_kind != run_kind {
5334            return Err(builtin_error(
5335                STUDY_NAME,
5336                &ERROR_INPUT,
5337                format!(
5338                    "explicit solver {} does not match Profile {}; omit RunKind or choose a matching Profile",
5339                    explicit_run_kind.as_snake_case(),
5340                    profile.as_snake_case()
5341                ),
5342            ));
5343        }
5344    }
5345    Ok((profile, run_kind))
5346}
5347
5348fn ensure_fea_classes_registered() {
5349    static REGISTER: OnceLock<()> = OnceLock::new();
5350    REGISTER.get_or_init(|| {
5351        let workflow_methods = workflow_methods();
5352        for class_name in [FEA_STUDY_CLASS, FEA_SWEEP_CLASS] {
5353            crate::class_registry::register_class(crate::class_registry::RuntimeClass {
5354                name: class_name.to_string(),
5355                parent: None,
5356                properties: HashMap::new(),
5357                methods: workflow_methods.clone(),
5358            });
5359        }
5360        crate::class_registry::register_class(crate::class_registry::RuntimeClass {
5361            name: FEA_RUN_RESULT_CLASS.to_string(),
5362            parent: None,
5363            properties: HashMap::new(),
5364            methods: run_result_methods(),
5365        });
5366        crate::class_registry::register_class(crate::class_registry::RuntimeClass {
5367            name: FEA_RESULTS_CLASS.to_string(),
5368            parent: None,
5369            properties: HashMap::new(),
5370            methods: results_methods(),
5371        });
5372        for class_name in [FEA_VALIDATION_CLASS, FEA_PLAN_CLASS, FEA_RUN_RESULT_CLASS] {
5373            if class_name == FEA_RUN_RESULT_CLASS {
5374                continue;
5375            }
5376            crate::class_registry::register_class(crate::class_registry::RuntimeClass {
5377                name: class_name.to_string(),
5378                parent: None,
5379                properties: HashMap::new(),
5380                methods: HashMap::new(),
5381            });
5382        }
5383        for class_name in [
5384            FEA_MODEL_CLASS,
5385            FEA_MATERIAL_CLASS,
5386            FEA_MATERIAL_ASSIGNMENT_CLASS,
5387            FEA_BOUNDARY_CONDITION_CLASS,
5388            FEA_LOAD_CASE_CLASS,
5389            FEA_STEP_CLASS,
5390            FEA_DOMAIN_CLASS,
5391            FEA_INTERFACE_CLASS,
5392            FEA_RUN_OPTIONS_CLASS,
5393            FEA_FIELD_CLASS,
5394            FEA_COMPARE_CLASS,
5395            FEA_TRENDS_CLASS,
5396        ] {
5397            crate::class_registry::register_class(crate::class_registry::RuntimeClass {
5398                name: class_name.to_string(),
5399                parent: None,
5400                properties: HashMap::new(),
5401                methods: if class_name == FEA_FIELD_CLASS {
5402                    field_methods()
5403                } else {
5404                    HashMap::new()
5405                },
5406            });
5407        }
5408    });
5409}
5410
5411fn workflow_methods() -> HashMap<String, crate::class_registry::RuntimeMethod> {
5412    [
5413        ("validate", VALIDATE_NAME),
5414        ("plan", PLAN_NAME),
5415        ("run", RUN_NAME),
5416    ]
5417    .into_iter()
5418    .map(|(name, function_name)| {
5419        (
5420            name.to_string(),
5421            crate::class_registry::RuntimeMethod {
5422                name: name.to_string(),
5423                is_static: false,
5424                is_abstract: false,
5425                is_sealed: false,
5426                access: MemberAccess::Public,
5427                function_name: function_name.to_string(),
5428                implicit_class_argument: None,
5429            },
5430        )
5431    })
5432    .collect()
5433}
5434
5435fn run_result_methods() -> HashMap<String, crate::class_registry::RuntimeMethod> {
5436    [
5437        ("results", RESULTS_NAME),
5438        ("field", FIELD_NAME),
5439        ("plot", PLOT_NAME),
5440    ]
5441    .into_iter()
5442    .map(|(name, function_name)| {
5443        (
5444            name.to_string(),
5445            crate::class_registry::RuntimeMethod {
5446                name: name.to_string(),
5447                is_static: false,
5448                is_abstract: false,
5449                is_sealed: false,
5450                access: MemberAccess::Public,
5451                function_name: function_name.to_string(),
5452                implicit_class_argument: None,
5453            },
5454        )
5455    })
5456    .collect()
5457}
5458
5459fn results_methods() -> HashMap<String, crate::class_registry::RuntimeMethod> {
5460    [("field", FIELD_NAME), ("plot", PLOT_NAME)]
5461        .into_iter()
5462        .map(|(name, function_name)| {
5463            (
5464                name.to_string(),
5465                crate::class_registry::RuntimeMethod {
5466                    name: name.to_string(),
5467                    is_static: false,
5468                    is_abstract: false,
5469                    is_sealed: false,
5470                    access: MemberAccess::Public,
5471                    function_name: function_name.to_string(),
5472                    implicit_class_argument: None,
5473                },
5474            )
5475        })
5476        .collect()
5477}
5478
5479fn field_methods() -> HashMap<String, crate::class_registry::RuntimeMethod> {
5480    [("plot", PLOT_NAME)]
5481        .into_iter()
5482        .map(|(name, function_name)| {
5483            (
5484                name.to_string(),
5485                crate::class_registry::RuntimeMethod {
5486                    name: name.to_string(),
5487                    is_static: false,
5488                    is_abstract: false,
5489                    is_sealed: false,
5490                    access: MemberAccess::Public,
5491                    function_name: function_name.to_string(),
5492                    implicit_class_argument: None,
5493                },
5494            )
5495        })
5496        .collect()
5497}
5498
5499fn operation_error(
5500    builtin: &'static str,
5501    error: &'static BuiltinErrorDescriptor,
5502    source: OperationErrorEnvelope,
5503) -> RuntimeError {
5504    let message = format!(
5505        "{}: {}: {}",
5506        error.message, source.error_code, source.message
5507    );
5508    build_runtime_error(message)
5509        .with_builtin(builtin)
5510        .with_identifier(
5511            error
5512                .identifier
5513                .unwrap_or(ERROR_OPERATION.identifier.expect("descriptor identifier")),
5514        )
5515        .build()
5516}
5517
5518fn builtin_error(
5519    builtin: &'static str,
5520    error: &'static BuiltinErrorDescriptor,
5521    message: impl Into<String>,
5522) -> RuntimeError {
5523    build_runtime_error(format!("{}: {}", error.message, message.into()))
5524        .with_builtin(builtin)
5525        .with_identifier(
5526            error
5527                .identifier
5528                .unwrap_or(ERROR_INTERNAL.identifier.expect("descriptor identifier")),
5529        )
5530        .build()
5531}
5532
5533fn builtin_error_with_source<E>(
5534    builtin: &'static str,
5535    error: &'static BuiltinErrorDescriptor,
5536    message: impl Into<String>,
5537    source: E,
5538) -> RuntimeError
5539where
5540    E: std::error::Error + Send + Sync + 'static,
5541{
5542    build_runtime_error(format!("{}: {}", error.message, message.into()))
5543        .with_builtin(builtin)
5544        .with_identifier(
5545            error
5546                .identifier
5547                .unwrap_or(ERROR_INTERNAL.identifier.expect("descriptor identifier")),
5548        )
5549        .with_source(source)
5550        .build()
5551}
5552
5553fn sanitize_id(id: &str) -> String {
5554    id.chars()
5555        .map(|ch| {
5556            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
5557                ch
5558            } else {
5559                '_'
5560            }
5561        })
5562        .collect()
5563}
5564
5565#[cfg(test)]
5566mod tests {
5567    use super::*;
5568    use futures::executor::block_on;
5569    use runmat_value::{CellArray, StructValue};
5570
5571    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";
5572    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";
5573
5574    fn cell(values: Vec<Value>) -> Value {
5575        let cols = values.len().max(1);
5576        Value::Cell(CellArray::new(values, 1, cols).expect("cell should build"))
5577    }
5578
5579    fn force_vector() -> Value {
5580        Value::Tensor(Tensor::new_2d(vec![0.0, -1000.0, 0.0], 1, 3).expect("tensor should build"))
5581    }
5582
5583    fn moment_vector() -> Value {
5584        Value::Tensor(Tensor::new_2d(vec![10.0, 20.0, 30.0], 1, 3).expect("tensor should build"))
5585    }
5586
5587    fn boundary_payload(value: Value) -> BoundaryCondition {
5588        let Value::Object(object) = value else {
5589            panic!("expected boundary condition object");
5590        };
5591        let Some(Value::String(payload)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY) else {
5592            panic!("expected boundary condition JSON payload");
5593        };
5594        serde_json::from_str(payload).expect("boundary condition payload should decode")
5595    }
5596
5597    fn object_payload<T: DeserializeOwned>(value: &Value) -> T {
5598        let Value::Object(object) = value else {
5599            panic!("expected FEA object");
5600        };
5601        let Some(Value::String(payload)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY) else {
5602            panic!("expected FEA JSON payload");
5603        };
5604        serde_json::from_str(payload).expect("FEA payload should decode")
5605    }
5606
5607    fn boundary_args(kind: &str, fields: Vec<(&str, Value)>) -> Vec<Value> {
5608        let mut args = vec![
5609            Value::String("bc".into()),
5610            Value::String("region".into()),
5611            Value::String(kind.into()),
5612        ];
5613        for (name, value) in fields {
5614            args.push(Value::String(name.into()));
5615            args.push(value);
5616        }
5617        args
5618    }
5619
5620    #[test]
5621    fn fea_usize_parsers_preserve_typed_bounds_and_reject_invalid_values() {
5622        use runmat_value::{IntValue, IntegerStorage};
5623
5624        assert_eq!(
5625            usize_from_value(INTERFACE_NAME, &Value::Int(IntValue::U16(7))).unwrap(),
5626            7
5627        );
5628        assert!(usize_from_value(INTERFACE_NAME, &Value::Int(IntValue::I8(-1))).is_err());
5629        assert!(usize_from_value(INTERFACE_NAME, &Value::Num(1.5)).is_err());
5630        assert!(usize_vec_from_value(
5631            INTERFACE_NAME,
5632            &Value::Tensor(Tensor::new_2d(vec![1.0, -1.0], 1, 2).unwrap())
5633        )
5634        .is_err());
5635        let typed_indices =
5636            Tensor::new_integer(IntegerStorage::U16(vec![2, 4]), vec![1, 2]).unwrap();
5637        assert_eq!(
5638            usize_vec_from_value(INTERFACE_NAME, &Value::Tensor(typed_indices)).unwrap(),
5639            vec![2, 4]
5640        );
5641
5642        let maximum = usize_from_value(INTERFACE_NAME, &Value::Int(IntValue::U64(u64::MAX)));
5643        if usize::BITS == 64 {
5644            assert_eq!(maximum.unwrap(), usize::MAX);
5645        } else {
5646            assert!(maximum.is_err());
5647        }
5648    }
5649
5650    #[test]
5651    fn fea_public_object_mirror_preserves_recursive_integer_kinds() {
5652        #[derive(Serialize)]
5653        struct IntegerMirror {
5654            signed: i64,
5655            unsigned: u64,
5656            matrix: Vec<Vec<u64>>,
5657            floating: f64,
5658            floating_vector: Vec<f64>,
5659        }
5660
5661        let object = serializable_to_object_value(
5662            RUN_OPTIONS_NAME,
5663            &ERROR_INTERNAL,
5664            FEA_RUN_OPTIONS_CLASS,
5665            &IntegerMirror {
5666                signed: -9,
5667                unsigned: u64::MAX,
5668                matrix: vec![vec![1, 2], vec![3, 4]],
5669                floating: 1.0,
5670                floating_vector: vec![2.0, 3.0],
5671            },
5672            None,
5673        )
5674        .expect("integer-preserving public mirror");
5675        assert!(matches!(
5676            object.properties.get("signed"),
5677            Some(Value::Int(IntValue::I64(-9)))
5678        ));
5679        assert!(matches!(
5680            object.properties.get("unsigned"),
5681            Some(Value::Int(IntValue::U64(u64::MAX)))
5682        ));
5683        let Some(Value::Tensor(matrix)) = object.properties.get("matrix") else {
5684            panic!("exact integer matrix");
5685        };
5686        assert_eq!(matrix.shape, vec![2, 2]);
5687        assert_eq!(
5688            matrix
5689                .integer_storage()
5690                .expect("integer storage")
5691                .exact_values(),
5692            vec![
5693                IntValue::U64(1),
5694                IntValue::U64(3),
5695                IntValue::U64(2),
5696                IntValue::U64(4),
5697            ]
5698        );
5699        assert!(matches!(
5700            object.properties.get("floating"),
5701            Some(Value::Num(1.0))
5702        ));
5703        assert!(matches!(
5704            object.properties.get("floating_vector"),
5705            Some(Value::Tensor(values)) if values.integer_storage().is_none()
5706        ));
5707
5708        #[derive(Serialize)]
5709        struct EmptyIntegerMirror {
5710            available_mode_indices: Vec<usize>,
5711            iteration_counts: Vec<usize>,
5712        }
5713        let empty = serializable_to_object_preserving_integers(
5714            RESULTS_NAME,
5715            &ERROR_INTERNAL,
5716            FEA_RESULTS_CLASS,
5717            &EmptyIntegerMirror {
5718                available_mode_indices: Vec::new(),
5719                iteration_counts: Vec::new(),
5720            },
5721            None,
5722            &[],
5723            &["available_mode_indices", "iteration_counts"],
5724        )
5725        .expect("schema-aware empty integer vectors");
5726        let Value::Object(empty) = empty else {
5727            panic!("results object");
5728        };
5729        for name in ["available_mode_indices", "iteration_counts"] {
5730            let Some(Value::Tensor(values)) = empty.properties.get(name) else {
5731                panic!("empty exact vector {name}");
5732            };
5733            assert!(values.integer_storage().is_some());
5734            assert!(values.is_empty());
5735        }
5736    }
5737
5738    #[test]
5739    fn fea_execution_controls_enforce_exact_structural_boundaries() {
5740        let options = block_on(fea_run_options_builtin(vec![
5741            Value::String("modal".into()),
5742            Value::String("ModeCount".into()),
5743            Value::Num(3.0),
5744            Value::String("ResidualWarnThreshold".into()),
5745            Value::Int(IntValue::U64(1)),
5746        ]))
5747        .expect("ordinary integral double count and typed floating control");
5748        let Value::Object(options) = options else {
5749            panic!("run options object");
5750        };
5751        let Some(Value::Struct(payload)) = options.properties.get("options") else {
5752            panic!("run options payload");
5753        };
5754        assert!(matches!(
5755            payload.fields.get("mode_count"),
5756            Some(Value::Int(IntValue::U64(3)))
5757        ));
5758        assert!(matches!(
5759            payload.fields.get("residual_warn_threshold"),
5760            Some(Value::Num(1.0))
5761        ));
5762
5763        for (solver, exact_field, floating_field) in [
5764            ("modal", "ModeCount", "ResidualWarnThreshold"),
5765            ("acoustic", "ModeCount", "ResidualWarnThreshold"),
5766            ("thermal", "StepCount", "TimeStepS"),
5767            ("transient", "MaxStepRetries", "Tolerance"),
5768            ("cfd", "MaxLinearIters", "ResidualWarnThreshold"),
5769            ("cht", "StepCount", "ResidualWarnThreshold"),
5770            ("fsi", "MaxLinearIters", "Tolerance"),
5771            ("nonlinear", "TangentRefreshInterval", "Tolerance"),
5772            (
5773                "electromagnetic",
5774                "HarmonicMaxIterations",
5775                "HarmonicTolerance",
5776            ),
5777        ] {
5778            let value = block_on(fea_run_options_builtin(vec![
5779                Value::String(solver.into()),
5780                Value::String(exact_field.into()),
5781                Value::Int(IntValue::U32(3)),
5782                Value::String(floating_field.into()),
5783                Value::Int(IntValue::I16(1)),
5784            ]))
5785            .unwrap_or_else(|error| panic!("{solver} typed run options: {error}"));
5786            let Value::Object(object) = value else {
5787                panic!("{solver} run options object");
5788            };
5789            let Some(Value::Struct(payload)) = object.properties.get("options") else {
5790                panic!("{solver} run options payload");
5791            };
5792            let exact_field = canonical_field_name(exact_field);
5793            let floating_field = canonical_field_name(floating_field);
5794            assert!(matches!(
5795                payload.fields.get(&exact_field),
5796                Some(Value::Int(IntValue::U64(3)))
5797            ));
5798            assert!(matches!(
5799                payload.fields.get(&floating_field),
5800                Some(Value::Num(1.0))
5801            ));
5802        }
5803
5804        let prep_context = block_on(fea_run_options_builtin(vec![
5805            Value::String("modal".into()),
5806            Value::String("PrepContext".into()),
5807            Value::String("internal".into()),
5808        ]))
5809        .expect_err("internal prep context must not be public");
5810        assert_eq!(prep_context.identifier(), Some("RunMat:fea:InvalidInput"));
5811
5812        let extra_step = block_on(fea_step_builtin(vec![
5813            Value::String("step".into()),
5814            Value::String("modal".into()),
5815            Value::Num(1.0),
5816        ]))
5817        .expect_err("step has exact arity");
5818        assert_eq!(extra_step.identifier(), Some("RunMat:fea:InvalidInput"));
5819
5820        let zero_window = block_on(fea_trends_builtin(vec![
5821            Value::String("WindowSize".into()),
5822            Value::Int(IntValue::U8(0)),
5823        ]))
5824        .expect_err("trend window must be positive");
5825        assert_eq!(zero_window.identifier(), Some("RunMat:fea:InvalidInput"));
5826
5827        let scalar_double = Tensor::new(vec![4.0], vec![1, 1]).expect("double scalar tensor");
5828        assert_eq!(
5829            usize_from_value(RUN_OPTIONS_NAME, &Value::Tensor(scalar_double.clone())).unwrap(),
5830            4
5831        );
5832        assert!(exact_bool_from_value(
5833            RESULTS_NAME,
5834            &Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).expect("double flag"))
5835        )
5836        .unwrap());
5837
5838        let scalar_single =
5839            Tensor::new_with_dtype(vec![4.0], vec![1, 1], runmat_value::NumericDType::F32)
5840                .expect("single scalar tensor");
5841        assert!(usize_from_value(RUN_OPTIONS_NAME, &Value::Tensor(scalar_single)).is_err());
5842    }
5843
5844    #[test]
5845    fn fea_result_selectors_are_exact_one_based_vectors_and_flags_are_zero_one() {
5846        let wide = 9_007_199_254_740_993_u64;
5847        let selectors = Tensor::new_integer(IntegerStorage::U64(vec![wide]), vec![1, 1])
5848            .expect("wide selector vector");
5849        let decoded = one_based_usize_vec_from_value(RESULTS_NAME, &Value::Tensor(selectors));
5850        if usize::BITS == 64 {
5851            assert_eq!(decoded.unwrap(), vec![(wide - 1) as usize]);
5852        } else {
5853            assert_eq!(
5854                decoded.unwrap_err().identifier(),
5855                Some("RunMat:fea:InvalidInput")
5856            );
5857        }
5858
5859        let selectors = Tensor::new_integer(IntegerStorage::U32(vec![3, 1, 3]), vec![3, 1])
5860            .expect("selector vector");
5861        assert_eq!(
5862            one_based_usize_vec_from_value(RESULTS_NAME, &Value::Tensor(selectors)).unwrap(),
5863            vec![2, 0, 2]
5864        );
5865        let matrix = Tensor::new_integer(IntegerStorage::U8(vec![1, 2, 3, 4]), vec![2, 2])
5866            .expect("selector matrix");
5867        assert!(one_based_usize_vec_from_value(RESULTS_NAME, &Value::Tensor(matrix)).is_err());
5868        assert!(!exact_bool_from_value(RESULTS_NAME, &Value::Int(IntValue::I8(0))).unwrap());
5869        assert!(exact_bool_from_value(RESULTS_NAME, &Value::Int(IntValue::U64(1))).unwrap());
5870        assert!(exact_bool_from_value(RESULTS_NAME, &Value::Int(IntValue::I16(2))).is_err());
5871
5872        let single_selectors =
5873            Tensor::new_with_dtype(vec![1.0, 2.0], vec![1, 2], runmat_value::NumericDType::F32)
5874                .expect("single selectors");
5875        assert!(
5876            one_based_usize_vec_from_value(RESULTS_NAME, &Value::Tensor(single_selectors)).is_err()
5877        );
5878    }
5879
5880    #[test]
5881    fn fea_sweep_failures_cross_to_one_based_public_indices() {
5882        let mut entries = vec![AnalysisStudySweepFailureEntry {
5883            study_id: "bad-study".into(),
5884            study_index: 0,
5885            error_code: "RM.TEST".into(),
5886            message: "failed".into(),
5887        }];
5888        one_base_failure_entries(RUN_NAME, &mut entries).expect("public index translation");
5889        assert_eq!(entries[0].study_index, 1);
5890
5891        let mut context = std::collections::BTreeMap::new();
5892        context.insert("study_index".into(), "0".into());
5893        let error = public_sweep_error(OperationErrorEnvelope {
5894            error_code: "RM.TEST".into(),
5895            error_type: crate::operations::OperationErrorType::Validation,
5896            message: "study sweep failed at index 0 for study_id bad-study".into(),
5897            operation: "test".into(),
5898            op_version: "1".into(),
5899            retryable: false,
5900            severity: crate::operations::OperationErrorSeverity::Error,
5901            context,
5902            trace_id: None,
5903            request_id: None,
5904            timestamp: "test".into(),
5905        });
5906        assert_eq!(
5907            error.context.get("study_index").map(String::as_str),
5908            Some("1")
5909        );
5910        assert!(error.message.contains("at index 1 "));
5911    }
5912
5913    #[test]
5914    fn fea_json_preserves_native_integer_scalars_and_tensors() {
5915        let maximum = runmat_value::IntValue::U64(u64::MAX);
5916        assert_eq!(
5917            value_to_json(INTERFACE_NAME, &Value::Int(maximum.clone()))
5918                .expect("scalar json")
5919                .to_string(),
5920            maximum.decimal_string()
5921        );
5922
5923        let scalar = Tensor::new_integer(
5924            runmat_value::IntegerStorage::U64(vec![u64::MAX]),
5925            vec![1, 1],
5926        )
5927        .expect("scalar tensor");
5928        assert_eq!(
5929            value_to_json(INTERFACE_NAME, &Value::Tensor(scalar))
5930                .expect("scalar tensor json")
5931                .to_string(),
5932            u64::MAX.to_string()
5933        );
5934
5935        let tensor = Tensor::new_integer(
5936            runmat_value::IntegerStorage::U64(vec![42, u64::MAX]),
5937            vec![1, 2],
5938        )
5939        .expect("tensor");
5940        assert_eq!(
5941            value_to_json(INTERFACE_NAME, &Value::Tensor(tensor))
5942                .expect("tensor json")
5943                .to_string(),
5944            "[42,18446744073709551615]"
5945        );
5946    }
5947
5948    #[test]
5949    fn fea_numeric_constructors_cross_all_integer_classes_once_into_binary64() {
5950        for integer in [
5951            IntValue::I8(1),
5952            IntValue::I16(1),
5953            IntValue::I32(1),
5954            IntValue::I64(1),
5955            IntValue::U8(1),
5956            IntValue::U16(1),
5957            IntValue::U32(1),
5958            IntValue::U64(u64::MAX),
5959        ] {
5960            let expected = boundary_integer_to_f64(&integer);
5961            let domain = block_on(fea_domain_builtin(vec![
5962                Value::String("electromagnetic".into()),
5963                Value::String("AppliedCurrentA".into()),
5964                Value::Int(integer.clone()),
5965            ]))
5966            .expect("domain integer field");
5967            let domain: DomainPayload = object_payload(&domain);
5968            let domain: runmat_analysis_core::ElectromagneticDomain =
5969                json_deserialize(DOMAIN_NAME, domain.data, "electromagnetic domain")
5970                    .expect("typed domain storage boundary");
5971            assert_eq!(domain.applied_current_a, expected);
5972
5973            let interface = block_on(fea_interface_builtin(vec![
5974                Value::String("contact".into()),
5975                Value::String("left".into()),
5976                Value::String("right".into()),
5977                Value::String("FrictionCoefficient".into()),
5978                Value::Int(integer.clone()),
5979            ]))
5980            .expect("interface integer field");
5981            let interface: AnalysisInterface = object_payload(&interface);
5982            let AnalysisInterfaceKind::Contact(contact) = interface.kind else {
5983                panic!("expected contact interface");
5984            };
5985            assert_eq!(contact.friction_coefficient, expected);
5986
5987            let load = block_on(fea_load_case_builtin(vec![
5988                Value::String("pressure".into()),
5989                Value::String("face".into()),
5990                Value::String("pressure".into()),
5991                Value::String("MagnitudePa".into()),
5992                Value::Int(integer.clone()),
5993            ]))
5994            .expect("load integer field");
5995            let load: LoadCase = object_payload(&load);
5996            assert!(
5997                matches!(load.kind, LoadKind::Pressure { magnitude_pa } if magnitude_pa == expected)
5998            );
5999
6000            let material = block_on(fea_material_builtin(vec![
6001                Value::String("material".into()),
6002                Value::String("YoungsModulusPa".into()),
6003                Value::Int(integer),
6004                Value::String("PoissonRatio".into()),
6005                Value::Int(IntValue::U8(0)),
6006            ]))
6007            .expect("material integer field");
6008            let material: MaterialModel = object_payload(&material);
6009            assert_eq!(material.mechanical.youngs_modulus_pa, expected);
6010        }
6011    }
6012
6013    #[test]
6014    fn fea_domain_revision_and_field_metadata_remain_exact_in_public_objects() {
6015        let mut field_source = StructValue::new();
6016        field_source.insert("source_id", Value::String("temperature".into()));
6017        field_source.insert("revision", Value::Int(IntValue::U32(u32::MAX)));
6018        let domain = block_on(fea_domain_builtin(vec![
6019            Value::String("thermoMechanical".into()),
6020            Value::String("FieldSource".into()),
6021            Value::Struct(field_source),
6022        ]))
6023        .expect("domain source revision");
6024        let Value::Object(domain) = domain else {
6025            panic!("expected domain object");
6026        };
6027        let Value::Struct(data) = domain.properties.get("data").expect("domain data") else {
6028            panic!("expected domain data struct");
6029        };
6030        let Value::Struct(source) = data.fields.get("field_source").expect("field source") else {
6031            panic!("expected field source struct");
6032        };
6033        assert_eq!(
6034            source.fields.get("revision"),
6035            Some(&Value::Int(IntValue::U64(u64::from(u32::MAX))))
6036        );
6037
6038        for revision in [
6039            Value::Int(IntValue::I8(-1)),
6040            Value::Int(IntValue::U64(u64::MAX)),
6041            Value::Num(1.0),
6042        ] {
6043            let mut field_source = StructValue::new();
6044            field_source.insert("source_id", Value::String("temperature".into()));
6045            field_source.insert("revision", revision);
6046            let error = block_on(fea_domain_builtin(vec![
6047                Value::String("thermoMechanical".into()),
6048                Value::String("FieldSource".into()),
6049                Value::Struct(field_source),
6050            ]))
6051            .expect_err("invalid structural revision must reject");
6052            assert_eq!(error.identifier(), Some("RunMat:fea:InvalidInput"));
6053            assert_eq!(error.context.builtin.as_deref(), Some(DOMAIN_NAME));
6054        }
6055
6056        let field = AnalysisField {
6057            field_id: "stress".into(),
6058            shape: vec![u32::MAX as usize, 0],
6059            values: AnalysisFieldValues::HostF64(Vec::new()),
6060        };
6061        let descriptor = AnalysisFieldDescriptor::from_field(&field);
6062        let object = field_to_object(&field, &descriptor).expect("field object");
6063        let Value::Tensor(shape) = object.properties.get("shape").expect("shape") else {
6064            panic!("expected integer shape tensor");
6065        };
6066        assert_eq!(shape.numeric_dtype(), runmat_value::NumericDType::U64);
6067        assert_eq!(
6068            shape.integer_storage(),
6069            Some(&IntegerStorage::U64(vec![u64::from(u32::MAX), 0]))
6070        );
6071        assert_eq!(
6072            object.properties.get("element_count"),
6073            Some(&Value::Int(IntValue::U64(0)))
6074        );
6075
6076        let device_field = AnalysisField {
6077            field_id: "device".into(),
6078            shape: vec![u32::MAX as usize],
6079            values: AnalysisFieldValues::DeviceRef(runmat_analysis_core::DeviceFieldRef {
6080                backend: "wgpu".into(),
6081                token: "buffer".into(),
6082                element_count: u32::MAX as usize,
6083            }),
6084        };
6085        let Value::Struct(device) = field_values_value(&device_field).expect("device metadata")
6086        else {
6087            panic!("expected device field metadata");
6088        };
6089        assert_eq!(
6090            device.fields.get("element_count"),
6091            Some(&Value::Int(IntValue::U64(u64::from(u32::MAX))))
6092        );
6093    }
6094
6095    #[test]
6096    fn fea_numeric_constructors_reject_resident_fields_without_provider_access() {
6097        let resident = || {
6098            Value::GpuTensor(runmat_accelerate_api::GpuTensorHandle {
6099                shape: vec![1, 1],
6100                device_id: u32::MAX,
6101                buffer_id: u64::MAX - 1,
6102                descriptor: Default::default(),
6103            })
6104        };
6105        let cases = [
6106            block_on(fea_domain_builtin(vec![
6107                Value::String("electromagnetic".into()),
6108                Value::String("AppliedCurrentA".into()),
6109                resident(),
6110            ])),
6111            block_on(fea_interface_builtin(vec![
6112                Value::String("contact".into()),
6113                Value::String("left".into()),
6114                Value::String("right".into()),
6115                Value::String("FrictionCoefficient".into()),
6116                resident(),
6117            ])),
6118            block_on(fea_load_case_builtin(vec![
6119                Value::String("pressure".into()),
6120                Value::String("face".into()),
6121                Value::String("pressure".into()),
6122                Value::String("MagnitudePa".into()),
6123                resident(),
6124            ])),
6125            block_on(fea_material_builtin(vec![
6126                Value::String("material".into()),
6127                Value::String("YoungsModulusPa".into()),
6128                resident(),
6129                Value::String("PoissonRatio".into()),
6130                Value::Num(0.3),
6131            ])),
6132        ];
6133        for result in cases {
6134            let error = result.expect_err("resident FEA constructor field must reject");
6135            assert_eq!(error.identifier(), Some("RunMat:fea:InvalidInput"));
6136            assert!(error.message().contains("cannot convert value"));
6137        }
6138    }
6139
6140    #[test]
6141    fn fea_structural_serializer_preserves_plan_counts_and_compare_deltas() {
6142        #[derive(Serialize)]
6143        struct FailureEntry {
6144            study_index: usize,
6145        }
6146        #[derive(Serialize)]
6147        struct StructuralPayload {
6148            study_count: usize,
6149            failure_entries: Vec<FailureEntry>,
6150            quality_reason_count_delta: i64,
6151            optional_delta: Option<i64>,
6152        }
6153        let value = serializable_to_object_preserving_integers(
6154            PLAN_NAME,
6155            &ERROR_INTERNAL,
6156            FEA_PLAN_CLASS,
6157            &StructuralPayload {
6158                study_count: 3,
6159                failure_entries: vec![FailureEntry { study_index: 2 }],
6160                quality_reason_count_delta: -4,
6161                optional_delta: None,
6162            },
6163            None,
6164            &["quality_reason_count_delta", "optional_delta"],
6165            &["study_count", "study_index"],
6166        )
6167        .expect("structural serializer");
6168        let Value::Object(object) = value else {
6169            panic!("expected structural object");
6170        };
6171        assert_eq!(
6172            object.properties.get("study_count"),
6173            Some(&Value::Int(IntValue::U64(3)))
6174        );
6175        assert_eq!(
6176            object.properties.get("quality_reason_count_delta"),
6177            Some(&Value::Int(IntValue::I64(-4)))
6178        );
6179        assert!(matches!(
6180            object.properties.get("optional_delta"),
6181            Some(Value::Tensor(tensor)) if tensor.is_empty()
6182        ));
6183        let Some(Value::Cell(entries)) = object.properties.get("failure_entries") else {
6184            panic!("expected failure entry cell");
6185        };
6186        let Some(Value::Struct(entry)) = entries.data.first() else {
6187            panic!("expected failure entry struct");
6188        };
6189        assert_eq!(
6190            entry.fields.get("study_index"),
6191            Some(&Value::Int(IntValue::U64(2)))
6192        );
6193    }
6194
6195    #[test]
6196    fn fea_constructor_aliases_arity_and_error_attribution_are_stable() {
6197        for (alias, expected) in [
6198            ("ConductivityWPerMk", "conductivity_w_per_mk"),
6199            ("SpecificHeatJPerKgK", "specific_heat_j_per_kgk"),
6200            ("ConductivitySPerM", "conductivity_s_per_m"),
6201            ("SpeedOfSoundMPerS", "speed_of_sound_m_per_s"),
6202            ("VolumetricWPerM3", "volumetric_w_per_m3"),
6203            ("InletVelocityMPerS", "inlet_velocity_m_per_s"),
6204            ("ThermalConductanceWPerM2K", "thermal_conductance_w_per_m2k"),
6205            ("ContactResistanceM2KPerW", "contact_resistance_m2k_per_w"),
6206        ] {
6207            assert_eq!(canonical_field_name(alias), expected, "{alias}");
6208        }
6209
6210        let field_error = create_field_object_from_args(vec![
6211            Value::Num(1.0),
6212            Value::String("stress".into()),
6213            Value::Num(2.0),
6214        ])
6215        .expect_err("surplus fea.field argument");
6216        assert_eq!(field_error.context.builtin.as_deref(), Some(FIELD_NAME));
6217
6218        let compare_error = create_compare_object_from_args(vec![
6219            Value::String("base".into()),
6220            Value::String("candidate".into()),
6221            Value::Num(2.0),
6222        ])
6223        .expect_err("surplus fea.compare argument");
6224        assert_eq!(compare_error.context.builtin.as_deref(), Some(COMPARE_NAME));
6225
6226        let model_error = block_on(fea_model_builtin(vec![
6227            Value::String("model".into()),
6228            Value::Num(1.0),
6229        ]))
6230        .expect_err("invalid model geometry");
6231        assert_eq!(model_error.context.builtin.as_deref(), Some(MODEL_NAME));
6232
6233        let duplicate_error = block_on(fea_material_builtin(vec![
6234            Value::String("material".into()),
6235            Value::String("YoungsModulusPa".into()),
6236            Value::Num(1.0),
6237            Value::String("youngs_modulus_pa".into()),
6238            Value::Num(2.0),
6239            Value::String("PoissonRatio".into()),
6240            Value::Num(0.3),
6241        ]))
6242        .expect_err("duplicate normalized field");
6243        assert_eq!(
6244            duplicate_error.context.builtin.as_deref(),
6245            Some(MATERIAL_NAME)
6246        );
6247        assert!(duplicate_error.message().contains("duplicate"));
6248    }
6249
6250    #[test]
6251    fn fea_study_requires_geometry_asset() {
6252        let err = block_on(fea_study_builtin(vec![
6253            Value::String("demo".to_string()),
6254            Value::Num(1.0),
6255        ]))
6256        .expect_err("invalid geometry should fail");
6257        assert_eq!(err.identifier(), Some("RunMat:fea:InvalidInput"));
6258    }
6259
6260    #[test]
6261    fn fea_study_requires_profile() {
6262        let tmp = tempfile::tempdir().expect("tempdir should be created");
6263        let geometry_path = tmp.path().join("part.step");
6264        std::fs::write(&geometry_path, SIMPLE_STEP).expect("geometry fixture should write");
6265        let geometry = block_on(crate::builtins::geometry::geometry_load_builtin(
6266            geometry_path.to_string_lossy().to_string(),
6267        ))
6268        .expect("geometry should load");
6269
6270        let err = block_on(fea_study_builtin(vec![
6271            Value::String("missing_profile".to_string()),
6272            geometry,
6273        ]))
6274        .expect_err("missing profile should fail");
6275        assert_eq!(err.identifier(), Some("RunMat:fea:InvalidInput"));
6276        assert!(err.message().contains("fea.study requires Profile"));
6277    }
6278
6279    #[test]
6280    fn fea_model_requires_profile() {
6281        let tmp = tempfile::tempdir().expect("tempdir should be created");
6282        let geometry_path = tmp.path().join("part.step");
6283        std::fs::write(&geometry_path, SIMPLE_STEP).expect("geometry fixture should write");
6284        let geometry = block_on(crate::builtins::geometry::geometry_load_builtin(
6285            geometry_path.to_string_lossy().to_string(),
6286        ))
6287        .expect("geometry should load");
6288
6289        let err = block_on(fea_model_builtin(vec![
6290            Value::String("missing_profile_model".to_string()),
6291            geometry,
6292        ]))
6293        .expect_err("missing profile should fail");
6294        assert_eq!(err.identifier(), Some("RunMat:fea:InvalidInput"));
6295        assert!(err.message().contains("fea.model requires Profile"));
6296    }
6297
6298    #[test]
6299    fn fea_load_validate_and_plan_document_workflow() {
6300        let tmp = tempfile::tempdir().expect("tempdir should be created");
6301        std::fs::write(tmp.path().join("part.stl"), TRIANGLE_STL)
6302            .expect("geometry fixture should write");
6303        let fea_path = tmp.path().join("bracket.fea");
6304        std::fs::write(
6305            &fea_path,
6306            r#"
6307version: 1
6308kind: study
6309id: bracket_static
6310geometry:
6311  path: part.stl
6312  units: meter
6313model:
6314  profile: linear_static_structural
6315run:
6316  backend: cpu
6317"#,
6318        )
6319        .expect("FEA fixture should write");
6320
6321        let study = block_on(fea_load_builtin(fea_path.to_string_lossy().to_string()))
6322            .expect("FEA document should load");
6323        let Value::Object(study_object) = study.clone() else {
6324            panic!("expected loaded FEA study object");
6325        };
6326        assert_eq!(study_object.class_name, FEA_STUDY_CLASS);
6327        assert!(study_object
6328            .properties
6329            .contains_key(FEA_STUDY_SPEC_JSON_PROPERTY));
6330
6331        let validation =
6332            block_on(fea_validate_builtin(study.clone())).expect("FEA study should validate");
6333        let Value::Object(validation_object) = validation else {
6334            panic!("expected validation object");
6335        };
6336        assert_eq!(validation_object.class_name, FEA_VALIDATION_CLASS);
6337        assert_eq!(
6338            validation_object.properties.get("valid"),
6339            Some(&Value::Bool(true))
6340        );
6341
6342        let plan = block_on(fea_plan_builtin(study)).expect("FEA study should plan");
6343        let Value::Object(plan_object) = plan else {
6344            panic!("expected plan object");
6345        };
6346        assert_eq!(plan_object.class_name, FEA_PLAN_CLASS);
6347        assert!(plan_object.properties.contains_key("operation_sequence"));
6348    }
6349
6350    #[test]
6351    fn fea_load_case_accepts_moment_and_torque_alias() {
6352        for kind in ["moment", "torque"] {
6353            let load = block_on(fea_load_case_builtin(vec![
6354                Value::String(format!("tip_{kind}")),
6355                Value::String("tip_node".to_string()),
6356                Value::String(kind.to_string()),
6357                Value::String("Vector".to_string()),
6358                moment_vector(),
6359            ]))
6360            .expect("moment load should build");
6361            assert_object_class(&load, FEA_LOAD_CASE_CLASS);
6362
6363            let Value::Object(object) = load else {
6364                panic!("expected load object");
6365            };
6366            let Some(Value::String(payload)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY)
6367            else {
6368                panic!("expected load JSON payload");
6369            };
6370            let decoded: LoadCase =
6371                serde_json::from_str(payload).expect("load payload should decode");
6372            assert_eq!(decoded.load_id, format!("tip_{kind}"));
6373            assert_eq!(decoded.region_id, "tip_node");
6374            assert!(matches!(
6375                decoded.kind,
6376                LoadKind::Moment {
6377                    mx: 10.0,
6378                    my: 20.0,
6379                    mz: 30.0
6380                }
6381            ));
6382        }
6383    }
6384
6385    #[test]
6386    fn fea_load_case_doc_keywords_include_moment_and_torque() {
6387        let doc = runmat_builtins::builtin_docs()
6388            .into_iter()
6389            .find(|doc| doc.name == "fea.loadCase")
6390            .expect("fea.loadCase doc metadata should be registered");
6391        let keywords = doc
6392            .keywords
6393            .expect("fea.loadCase should advertise keywords");
6394        let keyword_set = keywords
6395            .split(',')
6396            .map(str::trim)
6397            .collect::<std::collections::BTreeSet<_>>();
6398
6399        assert!(keyword_set.contains("moment"));
6400        assert!(keyword_set.contains("torque"));
6401    }
6402
6403    #[test]
6404    fn fea_boundary_condition_accepts_prescribed_rotation() {
6405        let boundary = block_on(fea_boundary_condition_builtin(vec![
6406            Value::String("tip_rotation".to_string()),
6407            Value::String("tip_node".to_string()),
6408            Value::String("prescribedRotation".to_string()),
6409            Value::String("rx".to_string()),
6410            Value::Num(0.1),
6411            Value::String("ry".to_string()),
6412            Value::Num(0.2),
6413            Value::String("rz".to_string()),
6414            Value::Num(0.3),
6415        ]))
6416        .expect("prescribed rotation boundary condition should build");
6417        assert_object_class(&boundary, FEA_BOUNDARY_CONDITION_CLASS);
6418
6419        let Value::Object(object) = boundary else {
6420            panic!("expected boundary condition object");
6421        };
6422        let Some(Value::String(payload)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY) else {
6423            panic!("expected boundary condition JSON payload");
6424        };
6425        let decoded: BoundaryCondition =
6426            serde_json::from_str(payload).expect("boundary condition payload should decode");
6427        assert_eq!(decoded.bc_id, "tip_rotation");
6428        assert_eq!(decoded.region_id, "tip_node");
6429        assert!(matches!(
6430            decoded.kind,
6431            BoundaryConditionKind::PrescribedRotation {
6432                rx: 0.1,
6433                ry: 0.2,
6434                rz: 0.3
6435            }
6436        ));
6437    }
6438
6439    #[test]
6440    fn fea_boundary_condition_accepts_integer_fields_for_all_numeric_kinds() {
6441        let rotation = boundary_payload(
6442            block_on(fea_boundary_condition_builtin(boundary_args(
6443                "prescribedRotation",
6444                vec![
6445                    ("rx", Value::Int(IntValue::I8(1))),
6446                    ("ry", Value::Int(IntValue::U16(2))),
6447                    ("rz", Value::Int(IntValue::I32(3))),
6448                ],
6449            )))
6450            .unwrap(),
6451        );
6452        assert!(matches!(
6453            rotation.kind,
6454            BoundaryConditionKind::PrescribedRotation {
6455                rx: 1.0,
6456                ry: 2.0,
6457                rz: 3.0
6458            }
6459        ));
6460
6461        let cases = [
6462            (
6463                "acousticImpedance",
6464                "specificImpedancePaSPerM",
6465                IntValue::U32(4),
6466            ),
6467            (
6468                "thermalPrescribedTemperature",
6469                "temperatureK",
6470                IntValue::I64(5),
6471            ),
6472            ("thermalHeatFlux", "heatFluxWPerM2", IntValue::U64(6)),
6473            ("cfdInletVelocity", "velocityMPerS", IntValue::I16(7)),
6474            ("cfdOutletPressure", "pressurePa", IntValue::U8(8)),
6475        ];
6476        for (kind, field, value) in cases {
6477            boundary_payload(
6478                block_on(fea_boundary_condition_builtin(boundary_args(
6479                    kind,
6480                    vec![(field, Value::Int(value))],
6481                )))
6482                .unwrap(),
6483            );
6484        }
6485
6486        let convection = boundary_payload(
6487            block_on(fea_boundary_condition_builtin(boundary_args(
6488                "thermalConvection",
6489                vec![
6490                    ("ambientTemperatureK", Value::Int(IntValue::U8(9))),
6491                    ("coefficientWPerM2K", Value::Int(IntValue::I16(10))),
6492                ],
6493            )))
6494            .unwrap(),
6495        );
6496        assert!(matches!(
6497            convection.kind,
6498            BoundaryConditionKind::ThermalConvection {
6499                ambient_temperature_k: 9.0,
6500                coefficient_w_per_m2k: 10.0
6501            }
6502        ));
6503    }
6504
6505    #[test]
6506    fn fea_boundary_condition_converts_every_integer_class_at_binary64_boundary() {
6507        for value in [
6508            IntValue::I8(1),
6509            IntValue::I16(1),
6510            IntValue::I32(1),
6511            IntValue::I64(1),
6512            IntValue::U8(1),
6513            IntValue::U16(1),
6514            IntValue::U32(1),
6515            IntValue::U64(u64::MAX),
6516        ] {
6517            let expected = boundary_integer_to_f64(&value);
6518            let boundary = boundary_payload(
6519                block_on(fea_boundary_condition_builtin(boundary_args(
6520                    "thermalHeatFlux",
6521                    vec![("heatFluxWPerM2", Value::Int(value))],
6522                )))
6523                .unwrap(),
6524            );
6525            let BoundaryConditionKind::ThermalHeatFlux { heat_flux_w_per_m2 } = boundary.kind
6526            else {
6527                panic!("expected thermal heat-flux boundary");
6528            };
6529            assert_eq!(heat_flux_w_per_m2, expected);
6530        }
6531    }
6532
6533    #[test]
6534    fn fea_boundary_condition_rejects_nonscalar_numeric_fields() {
6535        let values =
6536            Tensor::new_integer(runmat_value::IntegerStorage::U8(vec![1, 2]), vec![1, 2]).unwrap();
6537        let err = block_on(fea_boundary_condition_builtin(boundary_args(
6538            "thermalHeatFlux",
6539            vec![("heatFluxWPerM2", Value::Tensor(values))],
6540        )))
6541        .expect_err("nonscalar field must fail");
6542        assert_eq!(err.identifier(), Some("RunMat:fea:InvalidInput"));
6543        assert_eq!(
6544            err.context.builtin.as_deref(),
6545            Some(BOUNDARY_CONDITION_NAME)
6546        );
6547    }
6548
6549    #[test]
6550    fn fea_boundary_condition_declares_seven_integer_forms() {
6551        assert_eq!(FEA_BOUNDARY_CONDITION_INTEGER_CAPABILITIES.len(), 7);
6552        assert!(FEA_BOUNDARY_CONDITION_INTEGER_CAPABILITIES
6553            .iter()
6554            .flat_map(|capability| capability.inputs)
6555            .all(|input| input.classes.len() == 8));
6556    }
6557
6558    #[test]
6559    fn typed_constructors_build_full_study_and_sweep_objects() {
6560        let tmp = tempfile::tempdir().expect("tempdir should be created");
6561        let geometry_path = tmp.path().join("part.step");
6562        std::fs::write(&geometry_path, SIMPLE_STEP).expect("geometry fixture should write");
6563
6564        let geometry = block_on(crate::builtins::geometry::geometry_load_builtin(
6565            geometry_path.to_string_lossy().to_string(),
6566        ))
6567        .expect("geometry should load");
6568        let asset = geometry_asset_from_value(MODEL_NAME, &geometry)
6569            .expect("geometry payload should decode");
6570        let region_id = asset
6571            .regions
6572            .first()
6573            .expect("fixture should import a region")
6574            .region_id
6575            .clone();
6576
6577        let material = block_on(fea_material_builtin(vec![
6578            Value::String("steel".to_string()),
6579            Value::String("YoungsModulusPa".to_string()),
6580            Value::Num(200e9),
6581            Value::String("PoissonRatio".to_string()),
6582            Value::Num(0.30),
6583        ]))
6584        .expect("material should build");
6585        assert_object_class(&material, FEA_MATERIAL_CLASS);
6586
6587        let assignment = block_on(fea_material_assignment_builtin(vec![
6588            Value::String(region_id.clone()),
6589            Value::String("steel".to_string()),
6590        ]))
6591        .expect("material assignment should build");
6592        assert_object_class(&assignment, FEA_MATERIAL_ASSIGNMENT_CLASS);
6593
6594        let fixed = block_on(fea_boundary_condition_builtin(vec![
6595            Value::String("fixed_base".to_string()),
6596            Value::String(region_id.clone()),
6597            Value::String("fixed".to_string()),
6598        ]))
6599        .expect("boundary condition should build");
6600        assert_object_class(&fixed, FEA_BOUNDARY_CONDITION_CLASS);
6601
6602        let load = block_on(fea_load_case_builtin(vec![
6603            Value::String("tip_force".to_string()),
6604            Value::String(region_id.clone()),
6605            Value::String("force".to_string()),
6606            Value::String("Vector".to_string()),
6607            force_vector(),
6608        ]))
6609        .expect("load case should build");
6610        assert_object_class(&load, FEA_LOAD_CASE_CLASS);
6611
6612        let step = block_on(fea_step_builtin(vec![
6613            Value::String("static_step".to_string()),
6614            Value::String("static".to_string()),
6615        ]))
6616        .expect("analysis step should build");
6617        assert_object_class(&step, FEA_STEP_CLASS);
6618
6619        let selector = format!("id:{region_id}");
6620        let mut regional_delta = StructValue::new();
6621        regional_delta.insert("region_id", Value::String(selector.clone()));
6622        regional_delta.insert("temperature_delta_k", Value::Int(IntValue::I8(5)));
6623        let mut field_source = StructValue::new();
6624        field_source.insert("source_id", Value::String("temperature-map".into()));
6625        field_source.insert("revision", Value::Int(IntValue::U32(7)));
6626        field_source.insert("expected_region_ids", cell(vec![Value::String(selector)]));
6627        let domain = block_on(fea_domain_builtin(vec![
6628            Value::String("thermoMechanical".into()),
6629            Value::String("RegionTemperatureDeltas".into()),
6630            cell(vec![Value::Struct(regional_delta)]),
6631            Value::String("FieldSource".into()),
6632            Value::Struct(field_source),
6633        ]))
6634        .expect("thermo-mechanical domain should build");
6635
6636        let model = block_on(fea_model_builtin(vec![
6637            Value::String("bracket_static_model".to_string()),
6638            geometry.clone(),
6639            Value::String("Defaults".to_string()),
6640            Value::String("none".to_string()),
6641            Value::String("Profile".to_string()),
6642            Value::String("linear_static_structural".to_string()),
6643            Value::String("Materials".to_string()),
6644            cell(vec![material]),
6645            Value::String("MaterialAssignments".to_string()),
6646            cell(vec![assignment]),
6647            Value::String("BoundaryConditions".to_string()),
6648            cell(vec![fixed]),
6649            Value::String("Loads".to_string()),
6650            cell(vec![load]),
6651            Value::String("Steps".to_string()),
6652            cell(vec![step]),
6653            Value::String("Domains".to_string()),
6654            cell(vec![domain]),
6655        ]))
6656        .expect("model should build");
6657        assert_object_class(&model, FEA_MODEL_CLASS);
6658        let decoded_model: AnalysisModel = object_payload(&model);
6659        let thermo = decoded_model
6660            .thermo_mechanical
6661            .expect("thermo-mechanical domain");
6662        assert_eq!(thermo.region_temperature_deltas[0].region_id, region_id);
6663        assert_eq!(
6664            thermo
6665                .field_source
6666                .expect("field source")
6667                .expected_region_ids,
6668            vec![region_id.clone()]
6669        );
6670
6671        let run_options = block_on(fea_run_options_builtin(vec![
6672            Value::String("linear_static".to_string()),
6673            Value::String("DeterministicMode".to_string()),
6674            Value::Bool(true),
6675            Value::String("PrecisionMode".to_string()),
6676            Value::String("fp64".to_string()),
6677            Value::String("QualityPolicy".to_string()),
6678            Value::String("balanced".to_string()),
6679        ]))
6680        .expect("run options should build");
6681        assert_object_class(&run_options, FEA_RUN_OPTIONS_CLASS);
6682
6683        let study = block_on(fea_study_builtin(vec![
6684            Value::String("bracket_static".to_string()),
6685            geometry,
6686            Value::String("Profile".to_string()),
6687            Value::String("linear_static_structural".to_string()),
6688            Value::String("Backend".to_string()),
6689            Value::String("cpu".to_string()),
6690            Value::String("Model".to_string()),
6691            model,
6692            Value::String("RunOptions".to_string()),
6693            run_options,
6694        ]))
6695        .expect("study should build");
6696        assert_object_class(&study, FEA_STUDY_CLASS);
6697
6698        let sweep = block_on(fea_sweep_builtin(vec![
6699            Value::String("bracket_sweep".to_string()),
6700            cell(vec![study]),
6701            Value::String("FailFast".to_string()),
6702            Value::Bool(false),
6703        ]))
6704        .expect("sweep should build");
6705        assert_object_class(&sweep, FEA_SWEEP_CLASS);
6706    }
6707
6708    #[test]
6709    fn fea_results_field_exposes_values_metadata_and_plot_context() {
6710        let (run_value, _study) = synthetic_plot_run_value();
6711
6712        let results = block_on(fea_results_builtin(vec![run_value])).expect("results should load");
6713        let Value::Object(results_object) = results.clone() else {
6714            panic!("expected results object");
6715        };
6716        assert_eq!(results_object.class_name, FEA_RESULTS_CLASS);
6717        assert_eq!(
6718            results_object.properties.get("run_id"),
6719            Some(&Value::String("synthetic_plot_run".to_string()))
6720        );
6721        assert!(results_object
6722            .properties
6723            .contains_key(FEA_STUDY_CONTEXT_JSON_PROPERTY));
6724
6725        let field = block_on(fea_field_builtin(vec![
6726            results,
6727            Value::String("von_mises".to_string()),
6728        ]))
6729        .expect("field should resolve by unique suffix");
6730        let Value::Object(field_object) = field else {
6731            panic!("expected field object");
6732        };
6733        assert_eq!(field_object.class_name, FEA_FIELD_CLASS);
6734        assert_eq!(
6735            field_object.properties.get("field_id"),
6736            Some(&Value::String("structural.von_mises".to_string()))
6737        );
6738        assert_eq!(
6739            field_object.properties.get("unit"),
6740            Some(&Value::String("Pa".to_string()))
6741        );
6742        assert_eq!(
6743            field_object.properties.get("location"),
6744            Some(&Value::String("element".to_string()))
6745        );
6746        assert_eq!(
6747            field_object.properties.get("topology_id"),
6748            Some(&Value::String("analysis_mesh".to_string()))
6749        );
6750        assert_eq!(
6751            field_object.properties.get("element_kind"),
6752            Some(&Value::String("tetrahedron4".to_string()))
6753        );
6754        assert_eq!(
6755            field_object.properties.get("entity_count"),
6756            Some(&Value::Int(runmat_value::IntValue::U64(1)))
6757        );
6758        assert_eq!(
6759            field_object.properties.get("value_count"),
6760            Some(&Value::Int(runmat_value::IntValue::U64(1)))
6761        );
6762        assert_eq!(
6763            field_object.properties.get("element_count"),
6764            Some(&Value::Int(runmat_value::IntValue::U64(1)))
6765        );
6766        let Some(Value::Tensor(values)) = field_object.properties.get("values") else {
6767            panic!("expected values tensor");
6768        };
6769        assert_eq!(values.shape, vec![1]);
6770        assert_eq!(values.materialize_f64(), vec![42.0]);
6771        assert!(field_object
6772            .properties
6773            .contains_key(FEA_STUDY_CONTEXT_JSON_PROPERTY));
6774        assert_eq!(
6775            field_object.properties.get(FEA_RUN_ID_CONTEXT_PROPERTY),
6776            Some(&Value::String("synthetic_plot_run".to_string()))
6777        );
6778    }
6779
6780    #[cfg(feature = "plot-core")]
6781    #[test]
6782    fn fea_plot_returns_figure_handle_for_contextual_run_results_and_fields() {
6783        let (run_value, _study) = synthetic_plot_run_value();
6784
6785        let run_handle = block_on(fea_plot_builtin(vec![
6786            run_value.clone(),
6787            Value::String("von_mises".to_string()),
6788        ]))
6789        .expect("run plot should create a figure");
6790        assert!(matches!(run_handle, Value::Num(handle) if handle >= 1.0));
6791
6792        let results = block_on(fea_results_builtin(vec![run_value])).expect("results should load");
6793        let field = block_on(fea_field_builtin(vec![
6794            results,
6795            Value::String("structural.von_mises".to_string()),
6796        ]))
6797        .expect("field should resolve");
6798        let field_handle =
6799            block_on(fea_plot_builtin(vec![field])).expect("field plot should create a figure");
6800        assert!(matches!(field_handle, Value::Num(handle) if handle >= 1.0));
6801    }
6802
6803    #[test]
6804    fn fea_plot_request_accepts_solver_mesh_edge_option() {
6805        let (run_value, _study) = synthetic_plot_run_value();
6806
6807        let request = plot_request_from_args(&[
6808            run_value,
6809            Value::String("von_mises".to_string()),
6810            Value::String("mesh".to_string()),
6811            Value::String("solver".to_string()),
6812            Value::String("deformed".to_string()),
6813            Value::Bool(false),
6814            Value::String("overlay".to_string()),
6815            Value::String("cad".to_string()),
6816        ])
6817        .expect("plot request should parse mesh, deformation, and overlay options");
6818
6819        assert_eq!(request.field_id.as_deref(), Some("von_mises"));
6820        assert!(request.options.show_solver_mesh_edges);
6821        assert!(!request.options.apply_deformation_overlay);
6822        assert_eq!(
6823            request.options.mesh_source,
6824            crate::analysis::AnalysisFigureMeshSource::CadReference
6825        );
6826    }
6827
6828    #[cfg(feature = "plot-core")]
6829    #[test]
6830    fn fea_plot_default_prefers_von_mises_scalar_figure() {
6831        let mut figures = vec![
6832            generated_test_figure("deformation", vec!["structural.displacement"]),
6833            generated_test_figure("stress", vec!["structural.von_mises"]),
6834            generated_test_figure("residual", vec!["structural.residual_norm"]),
6835        ];
6836
6837        let selected =
6838            select_generated_figure(&mut figures, None).expect("default figure should select");
6839
6840        assert_eq!(selected.title, "stress");
6841    }
6842
6843    #[cfg(feature = "plot-core")]
6844    #[test]
6845    fn fea_plot_default_selects_representative_non_structural_figures() {
6846        let cases = [
6847            (
6848                vec![
6849                    generated_test_figure("thermal residual", vec!["thermal.residual_norm"]),
6850                    generated_test_figure("temperature", vec!["thermal.temperature.0"]),
6851                ],
6852                "temperature",
6853            ),
6854            (
6855                vec![
6856                    generated_test_figure("flow residual", vec!["cfd.residual_momentum"]),
6857                    generated_test_figure("velocity", vec!["fluid.velocity"]),
6858                ],
6859                "velocity",
6860            ),
6861            (
6862                vec![
6863                    generated_test_figure("acoustic phase", vec!["acoustic.phase"]),
6864                    generated_test_figure("pressure", vec!["acoustic.pressure"]),
6865                ],
6866                "pressure",
6867            ),
6868            (
6869                vec![
6870                    generated_test_figure(
6871                        "coupling residual",
6872                        vec!["thermo_mechanical.coupling_residual.0"],
6873                    ),
6874                    generated_test_figure(
6875                        "thermal stress",
6876                        vec!["thermo_mechanical.thermal_stress.0"],
6877                    ),
6878                ],
6879                "thermal stress",
6880            ),
6881        ];
6882
6883        for (mut figures, expected_title) in cases {
6884            let selected =
6885                select_generated_figure(&mut figures, None).expect("default figure should select");
6886            assert_eq!(selected.title, expected_title);
6887        }
6888    }
6889
6890    #[cfg(feature = "plot-core")]
6891    fn generated_test_figure(
6892        title: &str,
6893        field_ids: Vec<&str>,
6894    ) -> crate::analysis::AnalysisGeneratedFigure {
6895        crate::analysis::AnalysisGeneratedFigure {
6896            kind: crate::analysis::AnalysisGeneratedFigureKind::MeshResult,
6897            title: title.to_string(),
6898            field_ids: field_ids.into_iter().map(str::to_string).collect(),
6899            topology_ids: Vec::new(),
6900            warnings: Vec::new(),
6901            figure: runmat_plot::plots::Figure::new(),
6902        }
6903    }
6904
6905    #[test]
6906    fn fea_usize_parser_reads_typed_integer_storage_exactly_and_rejects_float_boundary() {
6907        let wide = if usize::BITS == 64 {
6908            9_007_199_254_740_993
6909        } else {
6910            u32::MAX as u64
6911        };
6912        let typed = Tensor::new_integer(runmat_value::IntegerStorage::U64(vec![wide]), vec![1, 1])
6913            .expect("typed integer");
6914
6915        assert_eq!(
6916            usize_from_value(STUDY_NAME, &Value::Tensor(typed)).expect("typed integer"),
6917            wide as usize
6918        );
6919
6920        let boundary = if usize::BITS == 64 {
6921            usize::MAX as f64
6922        } else {
6923            (usize::MAX as f64) + 1.0
6924        };
6925        assert!(usize_from_value(STUDY_NAME, &Value::Num(boundary)).is_err());
6926    }
6927
6928    fn synthetic_plot_run_value() -> (Value, Value) {
6929        crate::analysis::storage::configure_artifact_store(
6930            crate::analysis::storage::AnalysisArtifactStoreConfig::InMemory,
6931        )
6932        .expect("artifact store should configure");
6933
6934        let tmp = tempfile::tempdir().expect("tempdir should be created");
6935        std::fs::write(tmp.path().join("part.stl"), TRIANGLE_STL)
6936            .expect("geometry fixture should write");
6937        let fea_path = tmp.path().join("plot.fea");
6938        std::fs::write(
6939            &fea_path,
6940            r#"
6941version: 1
6942kind: study
6943id: synthetic_plot
6944geometry:
6945  path: part.stl
6946  units: meter
6947model:
6948  profile: linear_static_structural
6949run:
6950  backend: cpu
6951"#,
6952        )
6953        .expect("FEA fixture should write");
6954        let study = block_on(fea_load_builtin(fea_path.to_string_lossy().to_string()))
6955            .expect("study should load");
6956        let Value::Object(study_object) = &study else {
6957            panic!("expected study object");
6958        };
6959        let study_json = match study_object.properties.get(FEA_STUDY_SPEC_JSON_PROPERTY) {
6960            Some(Value::String(json)) => json.clone(),
6961            _ => panic!("expected study spec payload"),
6962        };
6963
6964        let run = crate::analysis::AnalysisRunResult {
6965            run_id: "synthetic_plot_run".to_string(),
6966            run: runmat_analysis_fea::FeaRunResult {
6967                backend: ComputeBackend::Cpu,
6968                solver_backend: "synthetic".to_string(),
6969                solver_device_apply_k_ratio: 0.0,
6970                solver_method: "synthetic".to_string(),
6971                preconditioner: "none".to_string(),
6972                solver_host_sync_count: 0,
6973                diagnostics: Vec::new(),
6974                fields: vec![AnalysisField::host_f64(
6975                    "structural.von_mises",
6976                    vec![1],
6977                    vec![42.0],
6978                )],
6979            },
6980            render_topology: Some(crate::analysis::AnalysisRenderTopology {
6981                schema_version: "analysis_render_topology/v1".to_string(),
6982                source: crate::analysis::AnalysisRenderTopologySource::AnalysisMesh,
6983                meshes: vec![crate::analysis::AnalysisRenderMesh {
6984                    mesh_id: "synthetic_plot_boundary".to_string(),
6985                    vertices: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
6986                    triangles: vec![[0, 1, 2]],
6987                    regions: Vec::new(),
6988                    vertex_volume_node_indices: vec![Some(0), Some(1), Some(2)],
6989                    triangle_volume_element_indices: vec![Some(0)],
6990                }],
6991            }),
6992            modal_results: None,
6993            thermal_results: None,
6994            transient_results: None,
6995            nonlinear_results: None,
6996            electromagnetic_results: None,
6997            model_validity: crate::analysis::QualityGate::Pass,
6998            solver_convergence: crate::analysis::QualityGate::Pass,
6999            result_quality: crate::analysis::QualityGate::Pass,
7000            run_status: crate::analysis::RunStatus::Publishable,
7001            publishable: true,
7002            quality_reasons: Vec::new(),
7003            provenance: crate::analysis::RunProvenance {
7004                backend: ComputeBackend::Cpu,
7005                solver_backend: "synthetic".to_string(),
7006                solver_device_apply_k_ratio: 0.0,
7007                solver_host_sync_count: 0,
7008                precision_mode: "fp64".to_string(),
7009                deterministic_mode: true,
7010                solver_method: "synthetic".to_string(),
7011                preconditioner: "none".to_string(),
7012                quality_policy: "balanced".to_string(),
7013                fallback_events: Vec::new(),
7014            },
7015        };
7016        crate::analysis::storage::persist_run_result(&run).expect("run should persist");
7017
7018        let mut object = ObjectInstance::new(FEA_RUN_RESULT_CLASS.to_string());
7019        object.properties.insert(
7020            "run_id".to_string(),
7021            Value::String("synthetic_plot_run".to_string()),
7022        );
7023        object.properties.insert(
7024            FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
7025            Value::String("synthetic_plot_run".to_string()),
7026        );
7027        object.properties.insert(
7028            FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
7029            Value::String(study_json),
7030        );
7031        (Value::Object(object), study)
7032    }
7033
7034    fn persist_synthetic_indexed_results() -> String {
7035        let indexed_fields = |prefix: &str| {
7036            vec![
7037                AnalysisField::host_f64(format!("{prefix}.0"), vec![1], vec![10.0]),
7038                AnalysisField::host_f64(format!("{prefix}.1"), vec![1], vec![20.0]),
7039            ]
7040        };
7041        let run_id = "synthetic_indexed_results".to_string();
7042        let run = crate::analysis::AnalysisRunResult {
7043            run_id: run_id.clone(),
7044            run: runmat_analysis_fea::FeaRunResult {
7045                backend: ComputeBackend::Cpu,
7046                solver_backend: "synthetic".to_string(),
7047                solver_device_apply_k_ratio: 0.0,
7048                solver_method: "synthetic".to_string(),
7049                preconditioner: "none".to_string(),
7050                solver_host_sync_count: 0,
7051                diagnostics: Vec::new(),
7052                fields: Vec::new(),
7053            },
7054            render_topology: None,
7055            modal_results: Some(crate::analysis::ModalResultsData {
7056                modal_payload_version: "modal_results/v1".to_string(),
7057                eigenvalues_hz: vec![10.0, 20.0],
7058                mode_shapes: indexed_fields("mode_shape"),
7059                residual_norms: vec![0.1, 0.2],
7060                mode_units: crate::analysis::ModalFrequencyUnits::Hz,
7061                frequency_basis: crate::analysis::ModalFrequencyBasis::NativeEigenSolve,
7062            }),
7063            thermal_results: None,
7064            transient_results: Some(crate::analysis::TransientResultsData {
7065                transient_payload_version: "transient_results/v1".to_string(),
7066                time_points_s: vec![0.0, 1.0],
7067                displacement_snapshots: indexed_fields("displacement"),
7068                rotation_snapshots: Vec::new(),
7069                velocity_snapshots: indexed_fields("velocity"),
7070                angular_velocity_snapshots: Vec::new(),
7071                acceleration_snapshots: indexed_fields("acceleration"),
7072                angular_acceleration_snapshots: Vec::new(),
7073                von_mises_snapshots: indexed_fields("von_mises"),
7074                kinetic_energy_snapshots: indexed_fields("kinetic_energy"),
7075                strain_energy_snapshots: indexed_fields("strain_energy"),
7076                residual_norm_snapshots: indexed_fields("residual_norm"),
7077                thermo_mechanical_temperature_snapshots: Vec::new(),
7078                thermo_mechanical_thermal_strain_snapshots: Vec::new(),
7079                thermo_mechanical_thermal_stress_snapshots: Vec::new(),
7080                thermo_mechanical_displacement_snapshots: Vec::new(),
7081                thermo_mechanical_von_mises_snapshots: Vec::new(),
7082                thermo_mechanical_coupling_residual_snapshots: Vec::new(),
7083                electro_thermal_temperature_snapshots: Vec::new(),
7084                electro_thermal_thermal_residual_snapshots: Vec::new(),
7085                residual_norms: vec![0.25],
7086                integration_method: crate::analysis::TransientIntegrationMethod::ImplicitEuler,
7087            }),
7088            nonlinear_results: None,
7089            electromagnetic_results: None,
7090            model_validity: crate::analysis::QualityGate::Pass,
7091            solver_convergence: crate::analysis::QualityGate::Pass,
7092            result_quality: crate::analysis::QualityGate::Pass,
7093            run_status: crate::analysis::RunStatus::Publishable,
7094            publishable: true,
7095            quality_reasons: Vec::new(),
7096            provenance: crate::analysis::RunProvenance {
7097                backend: ComputeBackend::Cpu,
7098                solver_backend: "synthetic".to_string(),
7099                solver_device_apply_k_ratio: 0.0,
7100                solver_host_sync_count: 0,
7101                precision_mode: "fp64".to_string(),
7102                deterministic_mode: true,
7103                solver_method: "synthetic".to_string(),
7104                preconditioner: "none".to_string(),
7105                quality_policy: "balanced".to_string(),
7106                fallback_events: Vec::new(),
7107            },
7108        };
7109        crate::analysis::storage::persist_run_result(&run).expect("indexed run should persist");
7110        run_id
7111    }
7112
7113    #[test]
7114    fn fea_results_translates_successful_selectors_and_public_indices_once() {
7115        let run_id = persist_synthetic_indexed_results();
7116        let selected = block_on(fea_results_builtin(vec![
7117            Value::String(run_id.clone()),
7118            Value::String("ModeIndices".to_string()),
7119            Value::Int(IntValue::U8(2)),
7120            Value::String("TransientSnapshotIndices".to_string()),
7121            Value::Tensor(Tensor::new(vec![2.0], vec![1, 1]).expect("double selector")),
7122        ]))
7123        .expect("one-based selectors should resolve the second stored entries");
7124        let Value::Object(selected) = selected else {
7125            panic!("results object");
7126        };
7127        let Some(Value::Struct(modal)) = selected.properties.get("modal_results") else {
7128            panic!("modal results");
7129        };
7130        let Some(Value::Tensor(eigenvalues)) = modal.fields.get("eigenvalues_hz") else {
7131            panic!("modal eigenvalues");
7132        };
7133        assert_eq!(eigenvalues.materialize_f64(), vec![20.0]);
7134        let Some(Value::Struct(transient)) = selected.properties.get("transient_results") else {
7135            panic!("transient results");
7136        };
7137        let Some(Value::Tensor(time_points)) = transient.fields.get("time_points_s") else {
7138            panic!("transient time points");
7139        };
7140        assert_eq!(time_points.materialize_f64(), vec![1.0]);
7141
7142        let full = block_on(fea_results_builtin(vec![Value::String(run_id)]))
7143            .expect("full indexed results");
7144        let Value::Object(full) = full else {
7145            panic!("results object");
7146        };
7147        let Some(Value::Struct(summary)) = full.properties.get("summary") else {
7148            panic!("results summary");
7149        };
7150        let Some(Value::Tensor(indices)) = summary.fields.get("available_mode_indices") else {
7151            panic!("available mode indices");
7152        };
7153        assert_eq!(
7154            indices
7155                .integer_storage()
7156                .expect("exact public indices")
7157                .exact_values(),
7158            vec![IntValue::U64(1), IntValue::U64(2)]
7159        );
7160    }
7161
7162    fn assert_object_class(value: &Value, expected: &str) {
7163        let Value::Object(object) = value else {
7164            panic!("expected object value");
7165        };
7166        assert_eq!(object.class_name, expected);
7167        assert!(
7168            object.properties.contains_key(FEA_PAYLOAD_JSON_PROPERTY)
7169                || object.properties.contains_key(FEA_STUDY_SPEC_JSON_PROPERTY)
7170                || object.properties.contains_key(FEA_SWEEP_SPEC_JSON_PROPERTY)
7171        );
7172    }
7173}