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}