use runmat_analysis_core::{
AnalysisField, AnalysisFieldValues, AnalysisInterface, AnalysisInterfaceKind, AnalysisModel,
AnalysisModelId, AnalysisStep, AnalysisStepKind, BoundaryCondition, BoundaryConditionKind,
EvidenceConfidence, LoadCase, LoadKind, MaterialAcousticModel, MaterialAssignment,
MaterialElectricalModel, MaterialMechanicalModel, MaterialModel, MaterialPlasticModel,
MaterialThermalModel, ReferenceFrame,
};
use runmat_analysis_fea::ComputeBackend;
use runmat_builtins::{
Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
ClassDef, MethodDef, ObjectInstance, Tensor, Value,
};
use runmat_geometry_core::GeometryAsset;
use runmat_macros::runtime_builtin;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::OnceLock;
use crate::analysis::{
analysis_create_model_op, analysis_plan_study_op, analysis_plan_study_sweep_op,
analysis_results_by_run_id_op, analysis_results_compare_op, analysis_run_study_op,
analysis_run_study_sweep_op, analysis_trends_op, analysis_validate_study_op,
analysis_validate_study_sweep_op, load_fea_document_from_path_async,
AnalysisAcousticRunOptions, AnalysisCfdRunOptions, AnalysisChtRunOptions,
AnalysisCreateModelIntentSpec, AnalysisCreateModelProfile, AnalysisElectromagneticRunOptions,
AnalysisFieldDescriptor, AnalysisFsiRunOptions, AnalysisModalRunOptions,
AnalysisNonlinearRunOptions, AnalysisResultsCompareQuery, AnalysisResultsQuery,
AnalysisRunKind, AnalysisRunOptions, AnalysisStudySpec, AnalysisStudySweepSpec,
AnalysisThermalRunOptions, AnalysisTransientRunOptions, AnalysisTrendsQuery,
FeaResolvedDocument,
};
use crate::builtins::geometry::{GEOMETRY_ASSET_CLASS, GEOMETRY_ASSET_JSON_PROPERTY};
use crate::builtins::io::json::jsondecode::value_from_json;
use crate::operations::{OperationContext, OperationEnvelope, OperationErrorEnvelope};
use crate::{build_runtime_error, BuiltinResult, RuntimeError};
const FEA_STUDY_CLASS: &str = "fea.Study";
const FEA_SWEEP_CLASS: &str = "fea.Sweep";
const FEA_VALIDATION_CLASS: &str = "fea.Validation";
const FEA_PLAN_CLASS: &str = "fea.Plan";
const FEA_RUN_RESULT_CLASS: &str = "fea.RunResult";
const FEA_MODEL_CLASS: &str = "fea.Model";
const FEA_MATERIAL_CLASS: &str = "fea.Material";
const FEA_MATERIAL_ASSIGNMENT_CLASS: &str = "fea.MaterialAssignment";
const FEA_BOUNDARY_CONDITION_CLASS: &str = "fea.BoundaryCondition";
const FEA_LOAD_CASE_CLASS: &str = "fea.LoadCase";
const FEA_STEP_CLASS: &str = "fea.Step";
const FEA_DOMAIN_CLASS: &str = "fea.Domain";
const FEA_INTERFACE_CLASS: &str = "fea.Interface";
const FEA_RUN_OPTIONS_CLASS: &str = "fea.RunOptions";
const FEA_RESULTS_CLASS: &str = "fea.Results";
const FEA_FIELD_CLASS: &str = "fea.Field";
const FEA_COMPARE_CLASS: &str = "fea.Compare";
const FEA_TRENDS_CLASS: &str = "fea.Trends";
const FEA_STUDY_SPEC_JSON_PROPERTY: &str = "__runmat_fea_study_spec_json";
const FEA_SWEEP_SPEC_JSON_PROPERTY: &str = "__runmat_fea_sweep_spec_json";
const FEA_PAYLOAD_JSON_PROPERTY: &str = "__runmat_fea_payload_json";
const FEA_STUDY_CONTEXT_JSON_PROPERTY: &str = "__runmat_fea_study_context_json";
const FEA_RUN_ID_CONTEXT_PROPERTY: &str = "__runmat_fea_run_id";
const LOAD_NAME: &str = "fea.load";
const STUDY_NAME: &str = "fea.study";
const SWEEP_NAME: &str = "fea.sweep";
const MODEL_NAME: &str = "fea.model";
const MATERIAL_NAME: &str = "fea.material";
const MATERIAL_ASSIGNMENT_NAME: &str = "fea.materialAssignment";
const BOUNDARY_CONDITION_NAME: &str = "fea.boundaryCondition";
const LOAD_CASE_NAME: &str = "fea.loadCase";
const STEP_NAME: &str = "fea.step";
const DOMAIN_NAME: &str = "fea.domain";
const INTERFACE_NAME: &str = "fea.interface";
const RUN_OPTIONS_NAME: &str = "fea.runOptions";
const VALIDATE_NAME: &str = "fea.validate";
const PLAN_NAME: &str = "fea.plan";
const RUN_NAME: &str = "fea.run";
const RESULTS_NAME: &str = "fea.results";
const FIELD_NAME: &str = "fea.field";
const PLOT_NAME: &str = "fea.plot";
const COMPARE_NAME: &str = "fea.compare";
const TRENDS_NAME: &str = "fea.trends";
const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
name: "result",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Required,
default: None,
description: "FEA object or operation result.",
}];
const IN_PATH: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
name: "path",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Path to a .fea file.",
}];
const IN_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
name: "study",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Required,
default: None,
description: "A .fea path, fea.Study object, or fea.Sweep object.",
}];
const IN_STUDY_ARGS: [BuiltinParamDescriptor; 3] = [
BuiltinParamDescriptor {
name: "id",
ty: BuiltinParamType::StringScalar,
arity: BuiltinParamArity::Required,
default: None,
description: "Study id.",
},
BuiltinParamDescriptor {
name: "geometry",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Required,
default: None,
description: "geometry.Asset returned by geometry.load.",
},
BuiltinParamDescriptor {
name: "Name, Value",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Variadic,
default: None,
description: "Profile, Backend, ModelId, and model setup options.",
},
];
const IN_VARIADIC_ARGS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
name: "args",
ty: BuiltinParamType::Any,
arity: BuiltinParamArity::Variadic,
default: None,
description: "Constructor or query arguments.",
}];
const LOAD_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "doc = fea.load(path)",
inputs: &IN_PATH,
outputs: &OUT_ANY,
}];
const STUDY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "study = fea.study(id, geometry, Name, Value, ...)",
inputs: &IN_STUDY_ARGS,
outputs: &OUT_ANY,
}];
const VALIDATE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "result = fea.validate(study)",
inputs: &IN_INPUT,
outputs: &OUT_ANY,
}];
const PLAN_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "plan = fea.plan(study)",
inputs: &IN_INPUT,
outputs: &OUT_ANY,
}];
const RUN_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "run = fea.run(study)",
inputs: &IN_INPUT,
outputs: &OUT_ANY,
}];
const SWEEP_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "sweep = fea.sweep(id, studies, Name, Value, ...)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const MODEL_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "model = fea.model(id, geometry, Name, Value, ...)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const MATERIAL_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "material = fea.material(id, Name, Value, ...)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const COMPONENT_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "component = fea.component(args, ...)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const RESULTS_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "results = fea.results(runOrRunId, Name, Value, ...)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const FIELD_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "field = fea.field(resultsOrRun, fieldId)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const PLOT_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "figure = fea.plot(runOrResultsOrField, fieldId)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const COMPARE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "comparison = fea.compare(baselineRunId, candidateRunId)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const TRENDS_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
label: "trends = fea.trends(Name, Value, ...)",
inputs: &IN_VARIADIC_ARGS,
outputs: &OUT_ANY,
}];
const ERROR_LOAD: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.FEA.BUILTIN.LOAD_FAILED",
identifier: Some("RunMat:fea:LoadFailed"),
when: "A .fea document cannot be read, parsed, or resolved.",
message: "fea: failed to load FEA document",
};
const ERROR_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.FEA.BUILTIN.INVALID_INPUT",
identifier: Some("RunMat:fea:InvalidInput"),
when: "A builtin receives an unsupported argument pattern or object type.",
message: "fea: invalid input",
};
const ERROR_OPERATION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.FEA.BUILTIN.OPERATION_FAILED",
identifier: Some("RunMat:fea:OperationFailed"),
when: "The validation, planning, or run operation fails.",
message: "fea: operation failed",
};
const ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
code: "RM.FEA.BUILTIN.INTERNAL",
identifier: Some("RunMat:fea:Internal"),
when: "An FEA object or operation result cannot be converted to a RunMat value.",
message: "fea: internal error",
};
const ERRORS: [BuiltinErrorDescriptor; 4] =
[ERROR_LOAD, ERROR_INPUT, ERROR_OPERATION, ERROR_INTERNAL];
pub const FEA_LOAD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &LOAD_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_STUDY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &STUDY_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_VALIDATE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &VALIDATE_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_PLAN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &PLAN_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_RUN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &RUN_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_SWEEP_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &SWEEP_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_MODEL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &MODEL_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_MATERIAL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &MATERIAL_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_COMPONENT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &COMPONENT_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_RESULTS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &RESULTS_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_FIELD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &FIELD_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_PLOT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &PLOT_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_COMPARE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &COMPARE_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
pub const FEA_TRENDS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
signatures: &TRENDS_SIGNATURES,
output_mode: BuiltinOutputMode::Fixed,
completion_policy: BuiltinCompletionPolicy::Public,
errors: &ERRORS,
};
#[runtime_builtin(
name = "fea.load",
category = "fea",
summary = "Load a .fea study or sweep document.",
keywords = "fea,study,sweep,load,yaml",
descriptor(crate::builtins::fea::FEA_LOAD_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_load_builtin(path: String) -> BuiltinResult<Value> {
load_document_object(PathBuf::from(path)).await
}
#[runtime_builtin(
name = "fea.study",
category = "fea",
summary = "Create a typed FEA study from geometry, model data, and run settings.",
keywords = "fea,study,geometry,run",
descriptor(crate::builtins::fea::FEA_STUDY_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_study_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() == 1 {
let path = scalar_string(&args[0], STUDY_NAME, &ERROR_INPUT)?;
return load_document_object(PathBuf::from(path)).await;
}
create_study_object_from_args(args)
}
#[runtime_builtin(
name = "fea.sweep",
category = "fea",
summary = "Create a FEA study sweep from study objects.",
keywords = "fea,sweep,study,run",
descriptor(crate::builtins::fea::FEA_SWEEP_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_sweep_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_sweep_object_from_args(args)
}
#[runtime_builtin(
name = "fea.model",
category = "fea",
summary = "Create a typed FEA model object from geometry and model components.",
keywords = "fea,model,materials,boundary,loads,domains",
descriptor(crate::builtins::fea::FEA_MODEL_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_model_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_model_object_from_args(args)
}
#[runtime_builtin(
name = "fea.material",
category = "fea",
summary = "Create a typed FEA material object.",
keywords = "fea,material,mechanical,thermal,electrical,plastic",
descriptor(crate::builtins::fea::FEA_MATERIAL_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_material_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_material_object_from_args(args)
}
#[runtime_builtin(
name = "fea.materialAssignment",
category = "fea",
summary = "Create a typed FEA material assignment.",
keywords = "fea,material,assignment,region",
descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_material_assignment_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_material_assignment_object_from_args(args)
}
#[runtime_builtin(
name = "fea.boundaryCondition",
category = "fea",
summary = "Create a typed FEA boundary condition.",
keywords = "fea,boundary,condition,region,prescribed,rotation",
descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_boundary_condition_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_boundary_condition_object_from_args(args)
}
#[runtime_builtin(
name = "fea.loadCase",
category = "fea",
summary = "Create a typed FEA load case.",
keywords = "fea,load,force,moment,torque,pressure,current",
descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_load_case_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_load_case_object_from_args(args)
}
#[runtime_builtin(
name = "fea.step",
category = "fea",
summary = "Create a typed FEA analysis step.",
keywords = "fea,step,static,modal,transient",
descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_step_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_step_object_from_args(args)
}
#[runtime_builtin(
name = "fea.domain",
category = "fea",
summary = "Create a typed FEA physics domain object.",
keywords = "fea,domain,thermal,electromagnetic,cfd",
descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_domain_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_domain_object_from_args(args)
}
#[runtime_builtin(
name = "fea.interface",
category = "fea",
summary = "Create a typed FEA interface object.",
keywords = "fea,interface,contact,region",
descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_interface_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_interface_object_from_args(args)
}
#[runtime_builtin(
name = "fea.runOptions",
category = "fea",
summary = "Create typed FEA run options for a solver.",
keywords = "fea,run,options,solver,quality",
descriptor(crate::builtins::fea::FEA_COMPONENT_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_run_options_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_run_options_object_from_args(args)
}
#[runtime_builtin(
name = "fea.validate",
category = "fea",
summary = "Validate a FEA study or sweep without planning or solving.",
keywords = "fea,validate,study,sweep",
descriptor(crate::builtins::fea::FEA_VALIDATE_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_validate_builtin(input: Value) -> BuiltinResult<Value> {
match resolve_document_input(input, VALIDATE_NAME).await? {
FeaResolvedDocument::Study(spec) => operation_result_to_object(
VALIDATE_NAME,
&ERROR_OPERATION,
&ERROR_INTERNAL,
FEA_VALIDATION_CLASS,
analysis_validate_study_op(&spec, OperationContext::new(None, None)),
None,
),
FeaResolvedDocument::Sweep(spec) => operation_result_to_object(
VALIDATE_NAME,
&ERROR_OPERATION,
&ERROR_INTERNAL,
FEA_VALIDATION_CLASS,
analysis_validate_study_sweep_op(&spec, OperationContext::new(None, None)),
None,
),
}
}
#[runtime_builtin(
name = "fea.plan",
category = "fea",
summary = "Plan a FEA study or sweep without solving it.",
keywords = "fea,plan,study,sweep",
descriptor(crate::builtins::fea::FEA_PLAN_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_plan_builtin(input: Value) -> BuiltinResult<Value> {
match resolve_document_input(input, PLAN_NAME).await? {
FeaResolvedDocument::Study(spec) => operation_result_to_object(
PLAN_NAME,
&ERROR_OPERATION,
&ERROR_INTERNAL,
FEA_PLAN_CLASS,
analysis_plan_study_op(&spec, OperationContext::new(None, None)),
None,
),
FeaResolvedDocument::Sweep(spec) => operation_result_to_object(
PLAN_NAME,
&ERROR_OPERATION,
&ERROR_INTERNAL,
FEA_PLAN_CLASS,
analysis_plan_study_sweep_op(&spec, OperationContext::new(None, None)),
None,
),
}
}
#[runtime_builtin(
name = "fea.run",
category = "fea",
summary = "Run a FEA study or sweep.",
keywords = "fea,run,study,sweep,solve",
descriptor(crate::builtins::fea::FEA_RUN_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_run_builtin(input: Value) -> BuiltinResult<Value> {
match resolve_document_input(input, RUN_NAME).await? {
FeaResolvedDocument::Study(spec) => run_study_result_to_object(&spec),
FeaResolvedDocument::Sweep(spec) => operation_result_to_object(
RUN_NAME,
&ERROR_OPERATION,
&ERROR_INTERNAL,
FEA_RUN_RESULT_CLASS,
analysis_run_study_sweep_op(&spec, OperationContext::new(None, None)),
Some(FEA_PAYLOAD_JSON_PROPERTY),
),
}
}
#[runtime_builtin(
name = "fea.results",
category = "fea",
summary = "Load or project FEA run results for post-processing.",
keywords = "fea,results,run_id,fields,diagnostics",
descriptor(crate::builtins::fea::FEA_RESULTS_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_results_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_results_object_from_args(args)
}
#[runtime_builtin(
name = "fea.field",
category = "fea",
summary = "Extract a field from FEA results or a run result.",
keywords = "fea,field,displacement,von_mises,post",
descriptor(crate::builtins::fea::FEA_FIELD_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_field_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_field_object_from_args(args)
}
#[runtime_builtin(
name = "fea.plot",
category = "fea",
summary = "Create a RunMat figure for an FEA result field on its geometry mesh.",
keywords = "fea,plot,visualize,mesh,von_mises,stress,field",
descriptor(crate::builtins::fea::FEA_PLOT_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_plot_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_plot_from_args(args)
}
#[runtime_builtin(
name = "fea.compare",
category = "fea",
summary = "Compare two persisted FEA runs by run id.",
keywords = "fea,compare,run_id,quality",
descriptor(crate::builtins::fea::FEA_COMPARE_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_compare_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_compare_object_from_args(args)
}
#[runtime_builtin(
name = "fea.trends",
category = "fea",
summary = "Summarize recent persisted FEA run trends.",
keywords = "fea,trends,history,quality",
descriptor(crate::builtins::fea::FEA_TRENDS_DESCRIPTOR),
builtin_path = "crate::builtins::fea"
)]
pub async fn fea_trends_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
create_trends_object_from_args(args)
}
async fn load_document_object(path: PathBuf) -> BuiltinResult<Value> {
let document = load_fea_document_from_path_async(&path)
.await
.map_err(|err| builtin_error(LOAD_NAME, &ERROR_LOAD, err))?;
resolved_document_to_object(document)
}
async fn resolve_document_input(
input: Value,
builtin: &'static str,
) -> BuiltinResult<FeaResolvedDocument> {
match input {
Value::Object(object) if object.class_name == FEA_STUDY_CLASS => {
let spec: AnalysisStudySpec =
object_json_property(builtin, &object, FEA_STUDY_SPEC_JSON_PROPERTY, &ERROR_INPUT)?;
Ok(FeaResolvedDocument::Study(Box::new(spec)))
}
Value::Object(object) if object.class_name == FEA_SWEEP_CLASS => {
let spec: AnalysisStudySweepSpec =
object_json_property(builtin, &object, FEA_SWEEP_SPEC_JSON_PROPERTY, &ERROR_INPUT)?;
Ok(FeaResolvedDocument::Sweep(spec))
}
Value::String(path) => load_fea_document_from_path_async(&PathBuf::from(path))
.await
.map_err(|err| builtin_error(builtin, &ERROR_LOAD, err)),
Value::CharArray(chars) if chars.rows == 1 => {
let path: String = chars.data.iter().collect();
load_fea_document_from_path_async(&PathBuf::from(path))
.await
.map_err(|err| builtin_error(builtin, &ERROR_LOAD, err))
}
other => Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("expected .fea path, {FEA_STUDY_CLASS}, or {FEA_SWEEP_CLASS}; got {other:?}"),
)),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RunOptionsPayload {
run_kind: AnalysisRunKind,
options: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DomainPayload {
kind: String,
data: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ModelDefaultsMode {
ProfileScaffold,
None,
}
impl Default for ModelDefaultsMode {
fn default() -> Self {
Self::ProfileScaffold
}
}
#[derive(Debug, Default)]
struct StudyConstructorOptions {
run_kind: Option<AnalysisRunKind>,
profile: Option<AnalysisCreateModelProfile>,
backend: Option<ComputeBackend>,
model_id: Option<String>,
model: Option<AnalysisModel>,
frame: Option<ReferenceFrame>,
model_defaults: ModelDefaultsMode,
materials: Vec<MaterialModel>,
material_assignments: Vec<MaterialAssignment>,
boundary_conditions: Vec<BoundaryCondition>,
loads: Vec<LoadCase>,
steps: Vec<AnalysisStep>,
domains: Vec<DomainPayload>,
interfaces: Vec<AnalysisInterface>,
run_options: Option<RunOptionsPayload>,
}
fn create_study_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 2 {
return Err(builtin_error(
STUDY_NAME,
&ERROR_INPUT,
"fea.study requires id and geometry arguments",
));
}
let study_id = scalar_string(&args[0], STUDY_NAME, &ERROR_INPUT)?;
let geometry = geometry_asset_from_value(&args[1])?;
let options = StudyConstructorOptions::parse(&args[2..])?;
let (profile, run_kind) = resolve_study_profile_and_run_kind(&options)?;
let model_id = options.model_id.clone().unwrap_or_else(|| {
options
.model
.as_ref()
.map(|model| model.model_id.0.clone())
.unwrap_or_else(|| format!("{}_model", sanitize_id(&study_id)))
});
let model = match options.model {
Some(model) => Some(model),
None if options.has_model_components() => Some(build_model_from_parts(
STUDY_NAME,
&geometry,
model_id.clone(),
profile,
options.model_defaults,
options.frame,
options.materials,
options.material_assignments,
options.boundary_conditions,
options.loads,
options.steps,
options.domains,
options.interfaces,
)?),
None => None,
};
let run_options = options
.run_options
.map(|payload| resolved_run_options_from_payload(STUDY_NAME, payload, run_kind))
.transpose()?
.unwrap_or_default();
let spec = AnalysisStudySpec {
study_id,
geometry,
create_model_intent: AnalysisCreateModelIntentSpec {
model_id,
profile,
prep_context: None,
},
model,
run_kind,
backend: options.backend.unwrap_or(ComputeBackend::Cpu),
linear_static_run_options: run_options.linear_static,
modal_run_options: run_options.modal,
acoustic_run_options: run_options.acoustic,
thermal_run_options: run_options.thermal,
transient_run_options: run_options.transient,
cfd_run_options: run_options.cfd,
cht_run_options: run_options.cht,
fsi_run_options: run_options.fsi,
nonlinear_run_options: run_options.nonlinear,
electromagnetic_run_options: run_options.electromagnetic,
};
study_to_object(spec)
}
impl StudyConstructorOptions {
fn parse(args: &[Value]) -> BuiltinResult<Self> {
if !args.len().is_multiple_of(2) {
return Err(builtin_error(
STUDY_NAME,
&ERROR_INPUT,
"fea.study options must be Name, Value pairs",
));
}
let mut options = Self::default();
for pair in args.chunks(2) {
let key = option_key(&pair[0], STUDY_NAME)?;
match key.as_str() {
"runkind" | "kind" => {
let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
options.run_kind = Some(parse_scalar_enum(&text, "RunKind")?);
}
"profile" => {
let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
options.profile = Some(parse_scalar_enum(&text, "Profile")?);
}
"backend" => {
let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
options.backend = Some(parse_scalar_enum(&text, "Backend")?);
}
"modelid" => {
options.model_id = Some(scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?);
}
"model" => {
options.model = Some(model_from_value(STUDY_NAME, &pair[1])?);
}
"frame" => {
let text = scalar_string(&pair[1], STUDY_NAME, &ERROR_INPUT)?;
options.frame = Some(parse_scalar_enum(&text, "Frame")?);
}
"defaults" => {
options.model_defaults = parse_model_defaults_mode(&scalar_string(
&pair[1],
STUDY_NAME,
&ERROR_INPUT,
)?)?;
}
"materials" => options.materials = material_vec_from_value(STUDY_NAME, &pair[1])?,
"materialassignments" | "assignments" => {
options.material_assignments =
material_assignment_vec_from_value(STUDY_NAME, &pair[1])?;
}
"boundaryconditions" | "bcs" => {
options.boundary_conditions =
boundary_condition_vec_from_value(STUDY_NAME, &pair[1])?;
}
"loads" | "loadcases" => {
options.loads = load_case_vec_from_value(STUDY_NAME, &pair[1])?;
}
"steps" => options.steps = step_vec_from_value(STUDY_NAME, &pair[1])?,
"domains" => options.domains = domain_vec_from_value(STUDY_NAME, &pair[1])?,
"interfaces" => {
options.interfaces = interface_vec_from_value(STUDY_NAME, &pair[1])?;
}
"runoptions" | "options" => {
options.run_options =
Some(run_options_payload_from_value(STUDY_NAME, &pair[1])?);
}
other => {
return Err(builtin_error(
STUDY_NAME,
&ERROR_INPUT,
format!("unsupported fea.study option `{other}`"),
));
}
}
}
Ok(options)
}
fn has_model_components(&self) -> bool {
self.frame.is_some()
|| !self.materials.is_empty()
|| !self.material_assignments.is_empty()
|| !self.boundary_conditions.is_empty()
|| !self.loads.is_empty()
|| !self.steps.is_empty()
|| !self.domains.is_empty()
|| !self.interfaces.is_empty()
}
}
#[derive(Debug, Default)]
struct ModelConstructorOptions {
profile: Option<AnalysisCreateModelProfile>,
frame: Option<ReferenceFrame>,
defaults: ModelDefaultsMode,
materials: Vec<MaterialModel>,
material_assignments: Vec<MaterialAssignment>,
boundary_conditions: Vec<BoundaryCondition>,
loads: Vec<LoadCase>,
steps: Vec<AnalysisStep>,
domains: Vec<DomainPayload>,
interfaces: Vec<AnalysisInterface>,
}
#[derive(Debug, Default)]
struct ResolvedRunOptions {
linear_static: Option<AnalysisRunOptions>,
modal: Option<AnalysisModalRunOptions>,
acoustic: Option<AnalysisAcousticRunOptions>,
thermal: Option<AnalysisThermalRunOptions>,
transient: Option<AnalysisTransientRunOptions>,
cfd: Option<AnalysisCfdRunOptions>,
cht: Option<AnalysisChtRunOptions>,
fsi: Option<AnalysisFsiRunOptions>,
nonlinear: Option<AnalysisNonlinearRunOptions>,
electromagnetic: Option<AnalysisElectromagneticRunOptions>,
}
fn create_sweep_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 2 {
return Err(builtin_error(
SWEEP_NAME,
&ERROR_INPUT,
"fea.sweep requires id and studies arguments",
));
}
let sweep_id = scalar_string(&args[0], SWEEP_NAME, &ERROR_INPUT)?;
let studies = study_vec_from_value(SWEEP_NAME, &args[1])?;
let mut fail_fast = true;
for pair in expect_name_value_tail(SWEEP_NAME, &args[2..])? {
match pair.key.as_str() {
"failfast" => fail_fast = bool_from_value(SWEEP_NAME, pair.value)?,
other => {
return Err(builtin_error(
SWEEP_NAME,
&ERROR_INPUT,
format!("unsupported fea.sweep option `{other}`"),
));
}
}
}
sweep_to_object(AnalysisStudySweepSpec {
sweep_id,
studies,
fail_fast,
})
}
fn create_model_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 2 {
return Err(builtin_error(
MODEL_NAME,
&ERROR_INPUT,
"fea.model requires id and geometry arguments",
));
}
let model_id = scalar_string(&args[0], MODEL_NAME, &ERROR_INPUT)?;
let geometry = geometry_asset_from_value(&args[1])?;
let options = parse_model_constructor_options(MODEL_NAME, &args[2..])?;
let profile = options
.profile
.unwrap_or(AnalysisCreateModelProfile::LinearStaticStructural);
let model = build_model_from_parts(
MODEL_NAME,
&geometry,
model_id,
profile,
options.defaults,
options.frame,
options.materials,
options.material_assignments,
options.boundary_conditions,
options.loads,
options.steps,
options.domains,
options.interfaces,
)?;
serializable_to_object(
MODEL_NAME,
&ERROR_INTERNAL,
FEA_MODEL_CLASS,
&model,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn parse_model_constructor_options(
builtin: &'static str,
args: &[Value],
) -> BuiltinResult<ModelConstructorOptions> {
let mut options = ModelConstructorOptions::default();
for pair in expect_name_value_tail(builtin, args)? {
match pair.key.as_str() {
"profile" => {
let text = scalar_string(pair.value, builtin, &ERROR_INPUT)?;
options.profile = Some(parse_scalar_enum(&text, "Profile")?);
}
"frame" => {
let text = scalar_string(pair.value, builtin, &ERROR_INPUT)?;
options.frame = Some(parse_scalar_enum(&text, "Frame")?);
}
"defaults" => {
options.defaults =
parse_model_defaults_mode(&scalar_string(pair.value, builtin, &ERROR_INPUT)?)?;
}
"materials" => options.materials = material_vec_from_value(builtin, pair.value)?,
"materialassignments" | "assignments" => {
options.material_assignments =
material_assignment_vec_from_value(builtin, pair.value)?;
}
"boundaryconditions" | "bcs" => {
options.boundary_conditions =
boundary_condition_vec_from_value(builtin, pair.value)?;
}
"loads" | "loadcases" => options.loads = load_case_vec_from_value(builtin, pair.value)?,
"steps" => options.steps = step_vec_from_value(builtin, pair.value)?,
"domains" => options.domains = domain_vec_from_value(builtin, pair.value)?,
"interfaces" => options.interfaces = interface_vec_from_value(builtin, pair.value)?,
other => {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("unsupported {builtin} option `{other}`"),
));
}
}
}
Ok(options)
}
fn create_material_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.is_empty() {
return Err(builtin_error(
MATERIAL_NAME,
&ERROR_INPUT,
"fea.material requires a material id",
));
}
let material_id = scalar_string(&args[0], MATERIAL_NAME, &ERROR_INPUT)?;
let mut fields = json_fields_from_name_values(MATERIAL_NAME, &args[1..])?;
let name = fields
.remove("name")
.map(json_to_string)
.transpose()?
.unwrap_or_else(|| material_id.clone());
let mechanical = if let Some(value) = fields.remove("mechanical") {
json_deserialize(MATERIAL_NAME, value, "mechanical material model")?
} else {
let youngs = remove_required_f64(&mut fields, MATERIAL_NAME, "youngs_modulus_pa")?;
let poisson = remove_required_f64(&mut fields, MATERIAL_NAME, "poisson_ratio")?;
let density = remove_optional_f64(&mut fields, "density_kg_per_m3")?.unwrap_or(7850.0);
MaterialMechanicalModel {
youngs_modulus_pa: youngs,
poisson_ratio: poisson,
density_kg_per_m3: density,
}
};
let thermal = if let Some(value) = fields.remove("thermal") {
json_deserialize(MATERIAL_NAME, value, "thermal material model")?
} else {
let mut thermal = serde_json::to_value(MaterialThermalModel::default())
.map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
move_known_fields(
&mut fields,
thermal.as_object_mut().expect("thermal model is object"),
&[
"reference_temperature_k",
"modulus_temp_coeff_per_k",
"conductivity_w_per_mk",
"specific_heat_j_per_kgk",
"expansion_coefficient_per_k",
],
);
json_deserialize(MATERIAL_NAME, thermal, "thermal material model")?
};
let electrical = if let Some(value) = fields.remove("electrical") {
Some(json_deserialize(
MATERIAL_NAME,
value,
"electrical material model",
)?)
} else {
let mut electrical = serde_json::to_value(MaterialElectricalModel::default())
.map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
let moved = move_known_fields(
&mut fields,
electrical
.as_object_mut()
.expect("electrical material model is object"),
&[
"reference_temperature_k",
"conductivity_s_per_m",
"resistive_heating_coefficient",
"relative_permittivity",
"relative_permeability",
"conductivity_frequency_response",
],
);
if moved {
Some(json_deserialize(
MATERIAL_NAME,
electrical,
"electrical material model",
)?)
} else {
None
}
};
let acoustic = if let Some(value) = fields.remove("acoustic") {
Some(json_deserialize(
MATERIAL_NAME,
value,
"acoustic material model",
)?)
} else {
let mut acoustic = serde_json::to_value(MaterialAcousticModel::default())
.map_err(|err| builtin_error(MATERIAL_NAME, &ERROR_INTERNAL, err.to_string()))?;
let moved = move_known_fields(
&mut fields,
acoustic
.as_object_mut()
.expect("acoustic material model is object"),
&[
"density_kg_per_m3",
"speed_of_sound_m_per_s",
"damping_ratio",
],
);
if moved {
Some(json_deserialize(
MATERIAL_NAME,
acoustic,
"acoustic material model",
)?)
} else {
None
}
};
let plastic = if let Some(value) = fields.remove("plastic") {
Some(json_deserialize(
MATERIAL_NAME,
value,
"plastic material model",
)?)
} else if fields.contains_key("yield_strain")
|| fields.contains_key("hardening_modulus_ratio")
|| fields.contains_key("saturation_exponent")
{
Some(MaterialPlasticModel {
yield_strain: remove_required_f64(&mut fields, MATERIAL_NAME, "yield_strain")?,
hardening_modulus_ratio: remove_required_f64(
&mut fields,
MATERIAL_NAME,
"hardening_modulus_ratio",
)?,
saturation_exponent: remove_required_f64(
&mut fields,
MATERIAL_NAME,
"saturation_exponent",
)?,
})
} else {
None
};
reject_unknown_fields(MATERIAL_NAME, fields)?;
material_to_object(MaterialModel {
material_id,
name,
mechanical,
thermal,
acoustic,
electrical,
plastic,
})
}
fn create_material_assignment_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 2 {
return Err(builtin_error(
MATERIAL_ASSIGNMENT_NAME,
&ERROR_INPUT,
"fea.materialAssignment requires region and material arguments",
));
}
let region_id = scalar_string(&args[0], MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
let assigned_material_id = scalar_string(&args[1], MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
let mut expected_material_id = assigned_material_id.clone();
let mut confidence = EvidenceConfidence::Verified;
for pair in expect_name_value_tail(MATERIAL_ASSIGNMENT_NAME, &args[2..])? {
match pair.key.as_str() {
"expectedmaterial" | "expectedmaterialid" => {
expected_material_id =
scalar_string(pair.value, MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
}
"confidence" => {
let text = scalar_string(pair.value, MATERIAL_ASSIGNMENT_NAME, &ERROR_INPUT)?;
confidence = parse_scalar_enum(&text, "Confidence")?;
}
other => {
return Err(builtin_error(
MATERIAL_ASSIGNMENT_NAME,
&ERROR_INPUT,
format!("unsupported fea.materialAssignment option `{other}`"),
));
}
}
}
material_assignment_to_object(MaterialAssignment {
region_id,
expected_material_id,
assigned_material_id,
confidence,
})
}
fn create_boundary_condition_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 3 {
return Err(builtin_error(
BOUNDARY_CONDITION_NAME,
&ERROR_INPUT,
"fea.boundaryCondition requires id, region, and kind arguments",
));
}
let bc_id = scalar_string(&args[0], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
let region_id = scalar_string(&args[1], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
let kind_text = scalar_string(&args[2], BOUNDARY_CONDITION_NAME, &ERROR_INPUT)?;
let mut fields = json_fields_from_name_values(BOUNDARY_CONDITION_NAME, &args[3..])?;
let kind = match normalize_token(&kind_text).as_str() {
"prescribedrotation" => BoundaryConditionKind::PrescribedRotation {
rx: remove_required_f64(&mut fields, BOUNDARY_CONDITION_NAME, "rx")?,
ry: remove_required_f64(&mut fields, BOUNDARY_CONDITION_NAME, "ry")?,
rz: remove_required_f64(&mut fields, BOUNDARY_CONDITION_NAME, "rz")?,
},
"acousticimpedance" => BoundaryConditionKind::AcousticImpedance {
specific_impedance_pa_s_per_m: remove_required_f64(
&mut fields,
BOUNDARY_CONDITION_NAME,
"specific_impedance_pa_s_per_m",
)?,
},
"thermalprescribedtemperature" => BoundaryConditionKind::ThermalPrescribedTemperature {
temperature_k: remove_required_f64(
&mut fields,
BOUNDARY_CONDITION_NAME,
"temperature_k",
)?,
},
"thermalheatflux" => BoundaryConditionKind::ThermalHeatFlux {
heat_flux_w_per_m2: remove_required_f64(
&mut fields,
BOUNDARY_CONDITION_NAME,
"heat_flux_w_per_m2",
)?,
},
"thermalconvection" => BoundaryConditionKind::ThermalConvection {
ambient_temperature_k: remove_required_f64(
&mut fields,
BOUNDARY_CONDITION_NAME,
"ambient_temperature_k",
)?,
coefficient_w_per_m2k: remove_required_f64(
&mut fields,
BOUNDARY_CONDITION_NAME,
"coefficient_w_per_m2k",
)?,
},
_ => parse_scalar_enum::<BoundaryConditionKind>(&kind_text, "BoundaryConditionKind")?,
};
reject_unknown_fields(BOUNDARY_CONDITION_NAME, fields)?;
boundary_condition_to_object(BoundaryCondition {
bc_id,
region_id,
kind,
})
}
fn create_load_case_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 3 {
return Err(builtin_error(
LOAD_CASE_NAME,
&ERROR_INPUT,
"fea.loadCase requires id, region, and kind arguments",
));
}
let load_id = scalar_string(&args[0], LOAD_CASE_NAME, &ERROR_INPUT)?;
let region_id = scalar_string(&args[1], LOAD_CASE_NAME, &ERROR_INPUT)?;
let kind_text = scalar_string(&args[2], LOAD_CASE_NAME, &ERROR_INPUT)?;
let mut fields = json_fields_from_name_values(LOAD_CASE_NAME, &args[3..])?;
let kind = match normalize_token(&kind_text).as_str() {
"force" => {
let [fx, fy, fz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
LoadKind::Force { fx, fy, fz }
}
"moment" | "torque" => {
let [mx, my, mz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
LoadKind::Moment { mx, my, mz }
}
"pressure" => LoadKind::Pressure {
magnitude_pa: remove_required_f64(&mut fields, LOAD_CASE_NAME, "magnitude_pa")?,
},
"bodyforce" => {
let [gx, gy, gz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
LoadKind::BodyForce { gx, gy, gz }
}
"currentdensity" => {
let [jx, jy, jz] = remove_required_vector3(&mut fields, LOAD_CASE_NAME, "vector")?;
LoadKind::CurrentDensity {
jx,
jy,
jz,
phase_rad: remove_optional_f64(&mut fields, "phase_rad")?.unwrap_or_default(),
amplitude_scale: remove_optional_f64(&mut fields, "amplitude_scale")?
.unwrap_or(1.0),
}
}
"coilcurrent" => LoadKind::CoilCurrent {
current_a: remove_required_f64(&mut fields, LOAD_CASE_NAME, "current_a")?,
phase_rad: remove_optional_f64(&mut fields, "phase_rad")?.unwrap_or_default(),
amplitude_scale: remove_optional_f64(&mut fields, "amplitude_scale")?.unwrap_or(1.0),
},
"heatsource" => LoadKind::HeatSource {
volumetric_w_per_m3: remove_required_f64(
&mut fields,
LOAD_CASE_NAME,
"volumetric_w_per_m3",
)?,
},
other => {
return Err(builtin_error(
LOAD_CASE_NAME,
&ERROR_INPUT,
format!("unsupported load kind `{other}`"),
));
}
};
reject_unknown_fields(LOAD_CASE_NAME, fields)?;
load_case_to_object(LoadCase {
load_id,
region_id,
kind,
})
}
fn create_step_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 2 {
return Err(builtin_error(
STEP_NAME,
&ERROR_INPUT,
"fea.step requires id and kind arguments",
));
}
let step_id = scalar_string(&args[0], STEP_NAME, &ERROR_INPUT)?;
let kind_text = scalar_string(&args[1], STEP_NAME, &ERROR_INPUT)?;
let kind = parse_scalar_enum::<AnalysisStepKind>(&kind_text, "AnalysisStepKind")?;
step_to_object(AnalysisStep { step_id, kind })
}
fn create_domain_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.is_empty() {
return Err(builtin_error(
DOMAIN_NAME,
&ERROR_INPUT,
"fea.domain requires a domain kind",
));
}
let kind_text = scalar_string(&args[0], DOMAIN_NAME, &ERROR_INPUT)?;
let kind = normalize_token(&kind_text);
let fields = json_fields_from_name_values(DOMAIN_NAME, &args[1..])?;
let payload = match kind.as_str() {
"thermomechanical" => DomainPayload {
kind: "thermo_mechanical".to_string(),
data: json_with_overrides(
DOMAIN_NAME,
serde_json::json!({
"enabled": true,
"reference_temperature_k": 293.15,
"applied_temperature_delta_k": 0.0,
"field_artifact_id": null,
"field_source": null,
"region_temperature_deltas": [],
"time_profile": []
}),
fields,
"thermo_mechanical domain",
)?,
},
"electrothermal" => DomainPayload {
kind: "electro_thermal".to_string(),
data: json_with_overrides(
DOMAIN_NAME,
serde_json::json!({
"enabled": true,
"reference_temperature_k": 293.15,
"applied_voltage_v": 0.0,
"region_conductivity_scales": [],
"time_profile": []
}),
fields,
"electro_thermal domain",
)?,
},
"electromagnetic" => DomainPayload {
kind: "electromagnetic".to_string(),
data: json_with_overrides(
DOMAIN_NAME,
serde_json::json!({
"enabled": true,
"reference_frequency_hz": 0.0,
"applied_current_a": 0.0
}),
fields,
"electromagnetic domain",
)?,
},
"cfd" => DomainPayload {
kind: "cfd".to_string(),
data: json_with_overrides(
DOMAIN_NAME,
serde_json::json!({
"enabled": true,
"solve_family": "steady_state",
"reference_density_kg_per_m3": 1.225,
"dynamic_viscosity_pa_s": 1.8e-5,
"inlet_velocity_m_per_s": 0.0,
"turbulence_intensity": 0.0,
"time_profile": []
}),
fields,
"cfd domain",
)?,
},
other => {
return Err(builtin_error(
DOMAIN_NAME,
&ERROR_INPUT,
format!("unsupported FEA domain kind `{other}`"),
));
}
};
domain_to_object(payload)
}
fn create_interface_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 3 {
return Err(builtin_error(
INTERFACE_NAME,
&ERROR_INPUT,
"fea.interface requires id, primary region, and secondary region arguments",
));
}
let interface_id = scalar_string(&args[0], INTERFACE_NAME, &ERROR_INPUT)?;
let primary_region_id = scalar_string(&args[1], INTERFACE_NAME, &ERROR_INPUT)?;
let secondary_region_id = scalar_string(&args[2], INTERFACE_NAME, &ERROR_INPUT)?;
let mut kind = "contact".to_string();
let mut fields = serde_json::Map::new();
for pair in expect_name_value_tail(INTERFACE_NAME, &args[3..])? {
if pair.key == "kind" {
kind = scalar_string(pair.value, INTERFACE_NAME, &ERROR_INPUT)?;
} else {
fields.insert(
canonical_field_name(&scalar_string(pair.name, INTERFACE_NAME, &ERROR_INPUT)?),
value_to_json(INTERFACE_NAME, pair.value)?,
);
}
}
let kind = match normalize_token(&kind).as_str() {
"contact" => AnalysisInterfaceKind::Contact(json_deserialize(
INTERFACE_NAME,
json_with_overrides(
INTERFACE_NAME,
serde_json::json!({
"penalty_stiffness_scale": 1.0,
"max_penetration_ratio": 0.0,
"friction_coefficient": 0.0
}),
fields,
"contact interface",
)?,
"contact interface",
)?),
"fluid_structure" | "fluidstructure" | "fsi" => {
AnalysisInterfaceKind::FluidStructure(json_deserialize(
INTERFACE_NAME,
json_with_overrides(
INTERFACE_NAME,
serde_json::json!({
"normal_stiffness_pa_per_m": 1.0e9,
"damping_ratio": 0.0,
"relaxation_factor": 0.5
}),
fields,
"fluid-structure interface",
)?,
"fluid-structure interface",
)?)
}
"conjugate_heat_transfer" | "conjugateheattransfer" | "cht" => {
AnalysisInterfaceKind::ConjugateHeatTransfer(json_deserialize(
INTERFACE_NAME,
json_with_overrides(
INTERFACE_NAME,
serde_json::json!({
"thermal_conductance_w_per_m2k": 500.0,
"contact_resistance_m2k_per_w": 0.0,
"relaxation_factor": 0.5
}),
fields,
"conjugate heat-transfer interface",
)?,
"conjugate heat-transfer interface",
)?)
}
other => {
return Err(builtin_error(
INTERFACE_NAME,
&ERROR_INPUT,
format!("unsupported interface kind `{other}`"),
));
}
};
interface_to_object(AnalysisInterface {
interface_id,
primary_region_id,
secondary_region_id,
kind,
})
}
fn create_run_options_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.is_empty() {
return Err(builtin_error(
RUN_OPTIONS_NAME,
&ERROR_INPUT,
"fea.runOptions requires a solver",
));
}
let kind_text = scalar_string(&args[0], RUN_OPTIONS_NAME, &ERROR_INPUT)?;
let run_kind = parse_scalar_enum::<AnalysisRunKind>(&kind_text, "solver")?;
let fields = json_fields_from_name_values(RUN_OPTIONS_NAME, &args[1..])?;
let data = run_options_json_for_kind(RUN_OPTIONS_NAME, run_kind, fields)?;
run_options_to_object(RunOptionsPayload {
run_kind,
options: data,
})
}
fn create_results_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.is_empty() {
return Err(builtin_error(
RESULTS_NAME,
&ERROR_INPUT,
"fea.results requires a run id or fea.RunResult",
));
}
if let Value::Object(object) = &args[0] {
if object.class_name == FEA_RESULTS_CLASS && args.len() == 1 {
return Ok(args[0].clone());
}
}
let run_id = run_id_from_value(RESULTS_NAME, &args[0])?;
let query = results_query_from_args(&args[1..])?;
let envelope = analysis_results_by_run_id_op(&run_id, query, OperationContext::new(None, None))
.map_err(|err| operation_error(RESULTS_NAME, &ERROR_OPERATION, err))?;
let mut object = serializable_to_object_value(
RESULTS_NAME,
&ERROR_INTERNAL,
FEA_RESULTS_CLASS,
&envelope.data,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)?;
object
.properties
.insert("run_id".to_string(), Value::String(run_id.clone()));
object.properties.insert(
FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
Value::String(run_id),
);
copy_study_context_property(&args[0], &mut object);
Ok(Value::Object(object))
}
fn create_field_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 2 {
return Err(builtin_error(
FIELD_NAME,
&ERROR_INPUT,
"fea.field requires results/run input and field id",
));
}
let field_id = scalar_string(&args[1], FIELD_NAME, &ERROR_INPUT)?;
let results = results_data_from_value(FIELD_NAME, &args[0])?;
let field = find_field(results.fields.into_iter(), &field_id).ok_or_else(|| {
builtin_error(
FIELD_NAME,
&ERROR_INPUT,
format!("FEA field `{field_id}` was not found in results"),
)
})?;
let descriptor = find_descriptor(results.field_descriptors.iter(), &field_id)
.cloned()
.unwrap_or_else(|| AnalysisFieldDescriptor::from_field(&field));
let mut object = field_to_object(&field, &descriptor)?;
copy_study_context_property(&args[0], &mut object);
copy_run_id_context_property(&args[0], &mut object);
Ok(Value::Object(object))
}
fn create_plot_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
#[cfg(feature = "plot-core")]
{
let request = plot_request_from_args(&args)?;
let mut figures = generate_plot_figures(&request.study, &request.run_id)?;
let figure = select_generated_figure(&mut figures, request.field_id.as_deref())?;
let handle = import_generated_figure(figure)?;
Ok(Value::Num(f64::from(handle)))
}
#[cfg(not(feature = "plot-core"))]
{
let _ = args;
Err(builtin_error(
PLOT_NAME,
&ERROR_OPERATION,
"fea.plot requires the plot-core runtime feature",
))
}
}
fn create_compare_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
if args.len() < 2 {
return Err(builtin_error(
COMPARE_NAME,
&ERROR_INPUT,
"fea.compare requires baseline and candidate run ids",
));
}
let baseline_run_id = scalar_string(&args[0], COMPARE_NAME, &ERROR_INPUT)?;
let candidate_run_id = scalar_string(&args[1], COMPARE_NAME, &ERROR_INPUT)?;
operation_result_to_object(
COMPARE_NAME,
&ERROR_OPERATION,
&ERROR_INTERNAL,
FEA_COMPARE_CLASS,
analysis_results_compare_op(
AnalysisResultsCompareQuery {
baseline_run_id,
candidate_run_id,
},
OperationContext::new(None, None),
),
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn create_trends_object_from_args(args: Vec<Value>) -> BuiltinResult<Value> {
let mut window_size = AnalysisTrendsQuery::default().window_size;
for pair in expect_name_value_tail(TRENDS_NAME, args.as_slice())? {
match pair.key.as_str() {
"windowsize" => window_size = usize_from_value(TRENDS_NAME, pair.value)?,
other => {
return Err(builtin_error(
TRENDS_NAME,
&ERROR_INPUT,
format!("unsupported fea.trends option `{other}`"),
));
}
}
}
operation_result_to_object(
TRENDS_NAME,
&ERROR_OPERATION,
&ERROR_INTERNAL,
FEA_TRENDS_CLASS,
analysis_trends_op(
AnalysisTrendsQuery { window_size },
OperationContext::new(None, None),
),
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn build_model_from_parts(
builtin: &'static str,
geometry: &GeometryAsset,
model_id: String,
profile: AnalysisCreateModelProfile,
defaults: ModelDefaultsMode,
frame: Option<ReferenceFrame>,
materials: Vec<MaterialModel>,
material_assignments: Vec<MaterialAssignment>,
boundary_conditions: Vec<BoundaryCondition>,
loads: Vec<LoadCase>,
steps: Vec<AnalysisStep>,
domains: Vec<DomainPayload>,
interfaces: Vec<AnalysisInterface>,
) -> BuiltinResult<AnalysisModel> {
let mut model = match defaults {
ModelDefaultsMode::ProfileScaffold => analysis_create_model_op(
geometry,
AnalysisCreateModelIntentSpec {
model_id: model_id.clone(),
profile,
prep_context: None,
},
OperationContext::new(None, None),
)
.map(|envelope| envelope.data)
.map_err(|err| operation_error(builtin, &ERROR_OPERATION, err))?,
ModelDefaultsMode::None => empty_model(model_id, geometry),
};
if let Some(frame) = frame {
model.frame = frame;
}
if !materials.is_empty() {
model.materials = materials;
}
if !material_assignments.is_empty() {
model.material_assignments = material_assignments
.into_iter()
.map(|mut assignment| {
assignment.region_id = resolve_region_selector(&assignment.region_id, geometry)?;
Ok(assignment)
})
.collect::<BuiltinResult<Vec<_>>>()?;
}
if !boundary_conditions.is_empty() {
model.boundary_conditions = boundary_conditions
.into_iter()
.map(|mut bc| {
bc.region_id = resolve_region_selector(&bc.region_id, geometry)?;
Ok(bc)
})
.collect::<BuiltinResult<Vec<_>>>()?;
}
if !loads.is_empty() {
model.loads = loads
.into_iter()
.map(|mut load| {
load.region_id = resolve_region_selector(&load.region_id, geometry)?;
Ok(load)
})
.collect::<BuiltinResult<Vec<_>>>()?;
}
if !steps.is_empty() {
model.steps = steps;
}
for domain in domains {
match domain.kind.as_str() {
"thermo_mechanical" => {
model.thermo_mechanical = Some(json_deserialize(
builtin,
domain.data,
"thermo_mechanical domain",
)?);
}
"electro_thermal" => {
model.electro_thermal = Some(json_deserialize(
builtin,
domain.data,
"electro_thermal domain",
)?);
}
"electromagnetic" => {
model.electromagnetic = Some(json_deserialize(
builtin,
domain.data,
"electromagnetic domain",
)?);
}
"cfd" => {
model.cfd = Some(json_deserialize(builtin, domain.data, "cfd domain")?);
}
other => {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("unsupported domain payload `{other}`"),
));
}
}
}
if !interfaces.is_empty() {
model.interfaces = interfaces
.into_iter()
.map(|mut interface| {
interface.primary_region_id =
resolve_region_selector(&interface.primary_region_id, geometry)?;
interface.secondary_region_id =
resolve_region_selector(&interface.secondary_region_id, geometry)?;
Ok(interface)
})
.collect::<BuiltinResult<Vec<_>>>()?;
}
Ok(model)
}
fn empty_model(model_id: String, geometry: &GeometryAsset) -> AnalysisModel {
AnalysisModel {
model_id: AnalysisModelId(model_id),
geometry_id: geometry.geometry_id.clone(),
geometry_revision: geometry.revision,
units: geometry.units,
frame: ReferenceFrame::Global,
materials: Vec::new(),
material_assignments: Vec::new(),
structural: None,
thermo_mechanical: None,
electro_thermal: None,
electromagnetic: None,
cfd: None,
interfaces: Vec::new(),
boundary_conditions: Vec::new(),
loads: Vec::new(),
steps: Vec::new(),
}
}
fn resolve_region_selector(selector: &str, geometry: &GeometryAsset) -> BuiltinResult<String> {
if let Some(id) = selector
.strip_prefix("id:")
.or_else(|| selector.strip_prefix("region:"))
{
return require_region_id(id, geometry);
}
if let Some(tag) = selector.strip_prefix("tag:") {
return geometry
.regions
.iter()
.find(|region| region.tag.as_deref() == Some(tag))
.map(|region| region.region_id.clone())
.ok_or_else(|| {
builtin_error(
MODEL_NAME,
&ERROR_INPUT,
format!("region tag `{tag}` was not found in geometry"),
)
});
}
if let Some(name) = selector.strip_prefix("name:") {
return geometry
.regions
.iter()
.find(|region| region.name == name)
.map(|region| region.region_id.clone())
.ok_or_else(|| {
builtin_error(
MODEL_NAME,
&ERROR_INPUT,
format!("region name `{name}` was not found in geometry"),
)
});
}
require_region_id(selector, geometry)
}
fn require_region_id(region_id: &str, geometry: &GeometryAsset) -> BuiltinResult<String> {
geometry
.regions
.iter()
.find(|region| region.region_id == region_id)
.map(|region| region.region_id.clone())
.ok_or_else(|| {
builtin_error(
MODEL_NAME,
&ERROR_INPUT,
format!("region id `{region_id}` was not found in geometry"),
)
})
}
fn material_to_object(material: MaterialModel) -> BuiltinResult<Value> {
serializable_to_object(
MATERIAL_NAME,
&ERROR_INTERNAL,
FEA_MATERIAL_CLASS,
&material,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn material_assignment_to_object(assignment: MaterialAssignment) -> BuiltinResult<Value> {
serializable_to_object(
MATERIAL_ASSIGNMENT_NAME,
&ERROR_INTERNAL,
FEA_MATERIAL_ASSIGNMENT_CLASS,
&assignment,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn boundary_condition_to_object(bc: BoundaryCondition) -> BuiltinResult<Value> {
serializable_to_object(
BOUNDARY_CONDITION_NAME,
&ERROR_INTERNAL,
FEA_BOUNDARY_CONDITION_CLASS,
&bc,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn load_case_to_object(load: LoadCase) -> BuiltinResult<Value> {
serializable_to_object(
LOAD_CASE_NAME,
&ERROR_INTERNAL,
FEA_LOAD_CASE_CLASS,
&load,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn step_to_object(step: AnalysisStep) -> BuiltinResult<Value> {
serializable_to_object(
STEP_NAME,
&ERROR_INTERNAL,
FEA_STEP_CLASS,
&step,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn domain_to_object(domain: DomainPayload) -> BuiltinResult<Value> {
serializable_to_object(
DOMAIN_NAME,
&ERROR_INTERNAL,
FEA_DOMAIN_CLASS,
&domain,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn interface_to_object(interface: AnalysisInterface) -> BuiltinResult<Value> {
serializable_to_object(
INTERFACE_NAME,
&ERROR_INTERNAL,
FEA_INTERFACE_CLASS,
&interface,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn run_options_to_object(payload: RunOptionsPayload) -> BuiltinResult<Value> {
serializable_to_object(
RUN_OPTIONS_NAME,
&ERROR_INTERNAL,
FEA_RUN_OPTIONS_CLASS,
&payload,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)
}
fn model_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<AnalysisModel> {
object_payload(builtin, value, FEA_MODEL_CLASS)
}
fn study_vec_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<Vec<AnalysisStudySpec>> {
object_vec_from_value_with_property(
builtin,
value,
FEA_STUDY_CLASS,
FEA_STUDY_SPEC_JSON_PROPERTY,
)
}
fn material_vec_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<Vec<MaterialModel>> {
object_vec_from_value(builtin, value, FEA_MATERIAL_CLASS)
}
fn material_assignment_vec_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<Vec<MaterialAssignment>> {
object_vec_from_value(builtin, value, FEA_MATERIAL_ASSIGNMENT_CLASS)
}
fn boundary_condition_vec_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<Vec<BoundaryCondition>> {
object_vec_from_value(builtin, value, FEA_BOUNDARY_CONDITION_CLASS)
}
fn load_case_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<LoadCase>> {
object_vec_from_value(builtin, value, FEA_LOAD_CASE_CLASS)
}
fn step_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<AnalysisStep>> {
object_vec_from_value(builtin, value, FEA_STEP_CLASS)
}
fn domain_vec_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<Vec<DomainPayload>> {
object_vec_from_value(builtin, value, FEA_DOMAIN_CLASS)
}
fn interface_vec_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<Vec<AnalysisInterface>> {
object_vec_from_value(builtin, value, FEA_INTERFACE_CLASS)
}
fn object_vec_from_value<T: DeserializeOwned>(
builtin: &'static str,
value: &Value,
expected_class: &'static str,
) -> BuiltinResult<Vec<T>> {
object_vec_from_value_with_property(builtin, value, expected_class, FEA_PAYLOAD_JSON_PROPERTY)
}
fn object_vec_from_value_with_property<T: DeserializeOwned>(
builtin: &'static str,
value: &Value,
expected_class: &'static str,
payload_property: &'static str,
) -> BuiltinResult<Vec<T>> {
match value {
Value::Cell(cell) => cell
.data
.iter()
.map(|item| {
object_payload_with_property(builtin, item, expected_class, payload_property)
})
.collect(),
Value::Object(_) => Ok(vec![object_payload_with_property(
builtin,
value,
expected_class,
payload_property,
)?]),
other => Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("expected {expected_class} object or cell array; got {other:?}"),
)),
}
}
fn object_payload<T: DeserializeOwned>(
builtin: &'static str,
value: &Value,
expected_class: &'static str,
) -> BuiltinResult<T> {
object_payload_with_property(builtin, value, expected_class, FEA_PAYLOAD_JSON_PROPERTY)
}
fn object_payload_with_property<T: DeserializeOwned>(
builtin: &'static str,
value: &Value,
expected_class: &'static str,
payload_property: &'static str,
) -> BuiltinResult<T> {
let Value::Object(object) = value else {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("expected {expected_class} object"),
));
};
if object.class_name != expected_class {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("expected {expected_class}, got {}", object.class_name),
));
}
object_json_property(builtin, object, payload_property, &ERROR_INPUT)
}
fn run_options_payload_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<RunOptionsPayload> {
object_payload(builtin, value, FEA_RUN_OPTIONS_CLASS)
}
fn resolved_run_options_from_payload(
builtin: &'static str,
payload: RunOptionsPayload,
expected_kind: AnalysisRunKind,
) -> BuiltinResult<ResolvedRunOptions> {
if payload.run_kind != expected_kind {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!(
"run options kind {:?} does not match selected study solver {:?}",
payload.run_kind, expected_kind
),
));
}
let mut resolved = ResolvedRunOptions::default();
match payload.run_kind {
AnalysisRunKind::LinearStatic => {
resolved.linear_static = Some(json_deserialize(
builtin,
payload.options,
"linear_static run options",
)?);
}
AnalysisRunKind::Modal => {
resolved.modal = Some(json_deserialize(
builtin,
payload.options,
"modal run options",
)?);
}
AnalysisRunKind::Acoustic => {
resolved.acoustic = Some(json_deserialize(
builtin,
payload.options,
"acoustic run options",
)?);
}
AnalysisRunKind::Thermal => {
resolved.thermal = Some(json_deserialize(
builtin,
payload.options,
"thermal run options",
)?);
}
AnalysisRunKind::Transient => {
resolved.transient = Some(json_deserialize(
builtin,
payload.options,
"transient run options",
)?);
}
AnalysisRunKind::Cfd => {
resolved.cfd = Some(json_deserialize(
builtin,
payload.options,
"cfd run options",
)?);
}
AnalysisRunKind::Cht => {
resolved.cht = Some(json_deserialize(
builtin,
payload.options,
"cht run options",
)?);
}
AnalysisRunKind::Fsi => {
resolved.fsi = Some(json_deserialize(
builtin,
payload.options,
"fsi run options",
)?);
}
AnalysisRunKind::Nonlinear => {
resolved.nonlinear = Some(json_deserialize(
builtin,
payload.options,
"nonlinear run options",
)?);
}
AnalysisRunKind::Electromagnetic => {
resolved.electromagnetic = Some(json_deserialize(
builtin,
payload.options,
"electromagnetic run options",
)?);
}
}
Ok(resolved)
}
fn run_options_json_for_kind(
builtin: &'static str,
run_kind: AnalysisRunKind,
fields: serde_json::Map<String, serde_json::Value>,
) -> BuiltinResult<serde_json::Value> {
match run_kind {
AnalysisRunKind::LinearStatic => typed_json_with_overrides::<AnalysisRunOptions>(
builtin,
AnalysisRunOptions::default(),
fields,
"linear_static run options",
),
AnalysisRunKind::Modal => typed_json_with_overrides::<AnalysisModalRunOptions>(
builtin,
AnalysisModalRunOptions::default(),
fields,
"modal run options",
),
AnalysisRunKind::Acoustic => typed_json_with_overrides::<AnalysisAcousticRunOptions>(
builtin,
AnalysisAcousticRunOptions::default(),
fields,
"acoustic run options",
),
AnalysisRunKind::Thermal => typed_json_with_overrides::<AnalysisThermalRunOptions>(
builtin,
AnalysisThermalRunOptions::default(),
fields,
"thermal run options",
),
AnalysisRunKind::Transient => typed_json_with_overrides::<AnalysisTransientRunOptions>(
builtin,
AnalysisTransientRunOptions::default(),
fields,
"transient run options",
),
AnalysisRunKind::Cfd => typed_json_with_overrides::<AnalysisCfdRunOptions>(
builtin,
AnalysisCfdRunOptions::default(),
fields,
"cfd run options",
),
AnalysisRunKind::Cht => typed_json_with_overrides::<AnalysisChtRunOptions>(
builtin,
AnalysisChtRunOptions::default(),
fields,
"cht run options",
),
AnalysisRunKind::Fsi => typed_json_with_overrides::<AnalysisFsiRunOptions>(
builtin,
AnalysisFsiRunOptions::default(),
fields,
"fsi run options",
),
AnalysisRunKind::Nonlinear => typed_json_with_overrides::<AnalysisNonlinearRunOptions>(
builtin,
AnalysisNonlinearRunOptions::default(),
fields,
"nonlinear run options",
),
AnalysisRunKind::Electromagnetic => {
typed_json_with_overrides::<AnalysisElectromagneticRunOptions>(
builtin,
AnalysisElectromagneticRunOptions::default(),
fields,
"electromagnetic run options",
)
}
}
}
fn results_query_from_args(args: &[Value]) -> BuiltinResult<AnalysisResultsQuery> {
let mut query = AnalysisResultsQuery::default();
for pair in expect_name_value_tail(RESULTS_NAME, args)? {
match pair.key.as_str() {
"includefields" | "fields" => {
query.include_fields = string_vec_from_value(RESULTS_NAME, pair.value)?;
}
"includefieldvalues" | "fieldvalues" => {
query.include_field_values = bool_from_value(RESULTS_NAME, pair.value)?;
}
"includediagnostics" => {
query.include_diagnostics = bool_from_value(RESULTS_NAME, pair.value)?;
}
"diagnosticcodes" => {
query.diagnostic_codes = string_vec_from_value(RESULTS_NAME, pair.value)?;
}
"includemodalresults" => {
query.include_modal_results = bool_from_value(RESULTS_NAME, pair.value)?;
}
"modeindices" => {
query.mode_indices = usize_vec_from_value(RESULTS_NAME, pair.value)?;
}
"includetransientresults" => {
query.include_transient_results = bool_from_value(RESULTS_NAME, pair.value)?;
}
"transientsnapshotindices" => {
query.transient_snapshot_indices = usize_vec_from_value(RESULTS_NAME, pair.value)?;
}
"includenonlinearresults" => {
query.include_nonlinear_results = bool_from_value(RESULTS_NAME, pair.value)?;
}
"includeelectromagneticresults" => {
query.include_electromagnetic_results = bool_from_value(RESULTS_NAME, pair.value)?;
}
other => {
return Err(builtin_error(
RESULTS_NAME,
&ERROR_INPUT,
format!("unsupported fea.results option `{other}`"),
));
}
}
}
Ok(query)
}
fn run_id_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<String> {
match value {
Value::Object(object) if object.class_name == FEA_RUN_RESULT_CLASS => {
run_id_from_object(object).ok_or_else(|| {
builtin_error(
builtin,
&ERROR_INPUT,
"fea.RunResult does not contain a run_id; sweep results expose run_entries",
)
})
}
Value::String(_) | Value::CharArray(_) | Value::StringArray(_) => {
scalar_string(value, builtin, &ERROR_INPUT)
}
other => Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("expected run id string or fea.RunResult; got {other:?}"),
)),
}
}
fn run_id_from_object(object: &ObjectInstance) -> Option<String> {
object
.properties
.get(FEA_RUN_ID_CONTEXT_PROPERTY)
.or_else(|| object.properties.get("run_id"))
.or_else(|| object.properties.get("runId"))
.and_then(|value| match value {
Value::String(run_id) => Some(run_id.clone()),
_ => None,
})
}
fn results_data_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<crate::analysis::AnalysisResultsData> {
match value {
Value::Object(object) if object.class_name == FEA_RESULTS_CLASS => {
object_json_property(builtin, object, FEA_PAYLOAD_JSON_PROPERTY, &ERROR_INPUT)
}
_ => {
let run_id = run_id_from_value(builtin, value)?;
analysis_results_by_run_id_op(
&run_id,
AnalysisResultsQuery::default(),
OperationContext::new(None, None),
)
.map(|envelope| envelope.data)
.map_err(|err| operation_error(builtin, &ERROR_OPERATION, err))
}
}
}
fn run_study_result_to_object(spec: &AnalysisStudySpec) -> BuiltinResult<Value> {
let envelope = analysis_run_study_op(spec, OperationContext::new(None, None))
.map_err(|err| operation_error(RUN_NAME, &ERROR_OPERATION, err))?;
let mut object = serializable_to_object_value(
RUN_NAME,
&ERROR_INTERNAL,
FEA_RUN_RESULT_CLASS,
&envelope.data,
Some(FEA_PAYLOAD_JSON_PROPERTY),
)?;
object.properties.insert(
FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
Value::String(envelope.data.run_id.clone()),
);
object.properties.insert(
"run_id".to_string(),
Value::String(envelope.data.run_id.clone()),
);
object.properties.insert(
"runId".to_string(),
Value::String(envelope.data.run_id.clone()),
);
insert_study_context(&mut object, spec)?;
Ok(Value::Object(object))
}
fn insert_study_context(
object: &mut ObjectInstance,
spec: &AnalysisStudySpec,
) -> BuiltinResult<()> {
let json = serde_json::to_string(spec).map_err(|err| {
builtin_error_with_source(RUN_NAME, &ERROR_INTERNAL, err.to_string(), err)
})?;
object.properties.insert(
FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
Value::String(json),
);
Ok(())
}
fn copy_study_context_property(source: &Value, target: &mut ObjectInstance) {
if let Some(json) = study_context_json_from_value(source) {
target.properties.insert(
FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
Value::String(json),
);
}
}
fn copy_run_id_context_property(source: &Value, target: &mut ObjectInstance) {
if let Some(run_id) = run_id_context_from_value(source) {
target.properties.insert(
FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
Value::String(run_id.clone()),
);
target
.properties
.entry("run_id".to_string())
.or_insert(Value::String(run_id.clone()));
target
.properties
.entry("runId".to_string())
.or_insert(Value::String(run_id));
}
}
fn study_context_json_from_value(value: &Value) -> Option<String> {
let Value::Object(object) = value else {
return None;
};
if object.class_name == FEA_STUDY_CLASS {
if let Some(Value::String(json)) = object.properties.get(FEA_STUDY_SPEC_JSON_PROPERTY) {
return Some(json.clone());
}
}
object
.properties
.get(FEA_STUDY_CONTEXT_JSON_PROPERTY)
.and_then(|value| match value {
Value::String(json) => Some(json.clone()),
_ => None,
})
}
fn study_context_from_value(
builtin: &'static str,
value: &Value,
) -> BuiltinResult<AnalysisStudySpec> {
let Some(json) = study_context_json_from_value(value) else {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
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)"),
));
};
serde_json::from_str(&json)
.map_err(|err| builtin_error_with_source(builtin, &ERROR_INPUT, err.to_string(), err))
}
fn run_id_context_from_value(value: &Value) -> Option<String> {
let Value::Object(object) = value else {
return None;
};
run_id_from_object(object)
}
fn field_to_object(
field: &AnalysisField,
descriptor: &AnalysisFieldDescriptor,
) -> BuiltinResult<ObjectInstance> {
ensure_fea_classes_registered();
let mut object = ObjectInstance::new(FEA_FIELD_CLASS.to_string());
object.properties.insert(
"field_id".to_string(),
Value::String(field.field_id.clone()),
);
object
.properties
.insert("id".to_string(), Value::String(field.field_id.clone()));
object.properties.insert(
"shape".to_string(),
usize_slice_tensor(&field.shape, 1, field.shape.len())?,
);
object
.properties
.insert("values".to_string(), field_values_value(field)?);
object.properties.insert(
"unit".to_string(),
Value::String(descriptor.unit.clone().unwrap_or_default()),
);
object.properties.insert(
"location".to_string(),
Value::String(format!("{:?}", descriptor.location).to_ascii_lowercase()),
);
object.properties.insert(
"kind".to_string(),
Value::String(format!("{:?}", descriptor.kind).to_ascii_lowercase()),
);
object.properties.insert(
"family".to_string(),
Value::String(descriptor.family.clone()),
);
object.properties.insert(
"quantity".to_string(),
Value::String(descriptor.quantity.clone()),
);
object.properties.insert(
"component_count".to_string(),
descriptor
.component_count
.map(|value| Value::Num(value as f64))
.unwrap_or_else(empty_double_value),
);
object.properties.insert(
"element_count".to_string(),
Value::Num(descriptor.element_count as f64),
);
object.properties.insert(
"storage".to_string(),
Value::String(format!("{:?}", descriptor.storage).to_ascii_lowercase()),
);
object.properties.insert(
"descriptor".to_string(),
serializable_to_value(FIELD_NAME, &ERROR_INTERNAL, descriptor)?,
);
let json = serde_json::to_string(field).map_err(|err| {
builtin_error_with_source(FIELD_NAME, &ERROR_INTERNAL, err.to_string(), err)
})?;
object
.properties
.insert(FEA_PAYLOAD_JSON_PROPERTY.to_string(), Value::String(json));
Ok(object)
}
fn field_values_value(field: &AnalysisField) -> BuiltinResult<Value> {
match &field.values {
AnalysisFieldValues::HostF64(values) => Tensor::new(values.clone(), field.shape.clone())
.map(Value::Tensor)
.map_err(|err| {
builtin_error(
FIELD_NAME,
&ERROR_INTERNAL,
format!("fea.field: failed to build values tensor: {err}"),
)
}),
AnalysisFieldValues::DeviceRef(device) => {
serializable_to_value(FIELD_NAME, &ERROR_INTERNAL, device)
}
}
}
fn usize_slice_tensor(values: &[usize], rows: usize, cols: usize) -> BuiltinResult<Value> {
Tensor::new_2d(
values.iter().map(|value| *value as f64).collect(),
rows,
cols,
)
.map(Value::Tensor)
.map_err(|err| {
builtin_error(
FIELD_NAME,
&ERROR_INTERNAL,
format!("fea.field: failed to build metadata tensor: {err}"),
)
})
}
fn empty_double_value() -> Value {
Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).expect("empty tensor shape is valid"))
}
fn find_field<I>(fields: I, requested: &str) -> Option<AnalysisField>
where
I: IntoIterator<Item = AnalysisField>,
{
let mut suffix_matches = Vec::new();
for field in fields {
if field.field_id == requested {
return Some(field);
}
if field_id_matches(&field.field_id, requested) {
suffix_matches.push(field);
}
}
if suffix_matches.len() == 1 {
suffix_matches.pop()
} else {
None
}
}
fn find_descriptor<'a, I>(descriptors: I, requested: &str) -> Option<&'a AnalysisFieldDescriptor>
where
I: IntoIterator<Item = &'a AnalysisFieldDescriptor>,
{
let mut suffix_matches = Vec::new();
for descriptor in descriptors {
if descriptor.field_id == requested {
return Some(descriptor);
}
if field_id_matches(&descriptor.field_id, requested) {
suffix_matches.push(descriptor);
}
}
if suffix_matches.len() == 1 {
suffix_matches.pop()
} else {
None
}
}
fn field_id_matches(candidate: &str, requested: &str) -> bool {
candidate == requested
|| candidate
.strip_suffix(requested)
.is_some_and(|prefix| prefix.ends_with('.'))
|| candidate
.rsplit_once('.')
.is_some_and(|(_, tail)| tail == requested)
}
struct FeaPlotRequest {
study: AnalysisStudySpec,
run_id: String,
field_id: Option<String>,
}
fn plot_request_from_args(args: &[Value]) -> BuiltinResult<FeaPlotRequest> {
if args.is_empty() {
return Err(builtin_error(
PLOT_NAME,
&ERROR_INPUT,
"fea.plot requires a run, results, field, or study/run pair",
));
}
let (core, field_from_name_value) = split_plot_field_name_value(args)?;
match core {
[single] => plot_request_from_context_value(single, field_from_name_value),
[first, second] if is_fea_study(first) => {
let study = study_context_from_value(PLOT_NAME, first)?;
let run_id = run_id_from_value(PLOT_NAME, second)?;
Ok(FeaPlotRequest {
study,
run_id,
field_id: field_from_name_value,
})
}
[first, second] => {
let mut request = plot_request_from_context_value(first, None)?;
request.field_id = Some(scalar_string(second, PLOT_NAME, &ERROR_INPUT)?);
if field_from_name_value.is_some() {
request.field_id = field_from_name_value;
}
Ok(request)
}
[first, second, third] if is_fea_study(first) => {
let study = study_context_from_value(PLOT_NAME, first)?;
let run_id = run_id_from_value(PLOT_NAME, second)?;
let field_id = match field_from_name_value {
Some(field_id) => Some(field_id),
None => Some(scalar_string(third, PLOT_NAME, &ERROR_INPUT)?),
};
Ok(FeaPlotRequest {
study,
run_id,
field_id,
})
}
_ => Err(builtin_error(
PLOT_NAME,
&ERROR_INPUT,
"fea.plot supports plot(run, field), plot(results, field), plot(field), or plot(study, runId, field)",
)),
}
}
fn split_plot_field_name_value(args: &[Value]) -> BuiltinResult<(&[Value], Option<String>)> {
if args.len() >= 3 && is_field_option_name(&args[args.len() - 2]) {
let field_id = scalar_string(&args[args.len() - 1], PLOT_NAME, &ERROR_INPUT)?;
Ok((&args[..args.len() - 2], Some(field_id)))
} else {
Ok((args, None))
}
}
fn is_field_option_name(value: &Value) -> bool {
scalar_string(value, PLOT_NAME, &ERROR_INPUT)
.map(|name| {
matches!(
name.to_ascii_lowercase().as_str(),
"field" | "fieldid" | "field_id"
)
})
.unwrap_or(false)
}
fn is_fea_study(value: &Value) -> bool {
matches!(value, Value::Object(object) if object.class_name == FEA_STUDY_CLASS)
}
fn plot_request_from_context_value(
value: &Value,
field_override: Option<String>,
) -> BuiltinResult<FeaPlotRequest> {
let study = study_context_from_value(PLOT_NAME, value)?;
let run_id = run_id_context_from_value(value)
.or_else(|| run_id_from_value(PLOT_NAME, value).ok())
.ok_or_else(|| {
builtin_error(
PLOT_NAME,
&ERROR_INPUT,
"fea.plot requires a run_id; use a fea.RunResult from fea.run or pass fea.plot(study, runId, field)",
)
})?;
let field_id = field_override.or_else(|| match value {
Value::Object(object) if object.class_name == FEA_FIELD_CLASS => object
.properties
.get("field_id")
.and_then(|value| match value {
Value::String(field_id) => Some(field_id.clone()),
_ => None,
}),
_ => None,
});
Ok(FeaPlotRequest {
study,
run_id,
field_id,
})
}
#[cfg(feature = "plot-core")]
fn generate_plot_figures(
study: &AnalysisStudySpec,
run_id: &str,
) -> BuiltinResult<Vec<crate::analysis::AnalysisGeneratedFigure>> {
crate::analysis::analysis_generate_study_run_figures(
study,
run_id,
crate::analysis::AnalysisFigureGenerationOptions {
include_comparison: false,
include_trends: false,
max_mesh_result_figures: 8,
..crate::analysis::AnalysisFigureGenerationOptions::default()
},
)
.map_err(|err| builtin_error(PLOT_NAME, &ERROR_OPERATION, err))
}
#[cfg(feature = "plot-core")]
fn select_generated_figure(
figures: &mut Vec<crate::analysis::AnalysisGeneratedFigure>,
field_id: Option<&str>,
) -> BuiltinResult<crate::analysis::AnalysisGeneratedFigure> {
if figures.is_empty() {
return Err(builtin_error(
PLOT_NAME,
&ERROR_OPERATION,
"fea.plot could not generate a renderable FEA figure for this run",
));
}
let Some(field_id) = field_id else {
return Ok(figures.remove(0));
};
if let Some(index) = figures.iter().position(|figure| {
figure
.field_ids
.iter()
.any(|candidate| field_id_matches(candidate, field_id))
}) {
return Ok(figures.remove(index));
}
let available = figures
.iter()
.flat_map(|figure| figure.field_ids.iter())
.cloned()
.collect::<Vec<_>>()
.join(", ");
Err(builtin_error(
PLOT_NAME,
&ERROR_INPUT,
format!("FEA field `{field_id}` did not produce a mesh figure; available figure fields: {available}"),
))
}
#[cfg(feature = "plot-core")]
fn import_generated_figure(figure: crate::analysis::AnalysisGeneratedFigure) -> BuiltinResult<u32> {
Ok(crate::builtins::plotting::import_runtime_figure(
figure.figure,
))
}
struct NameValuePair<'a> {
name: &'a Value,
key: String,
value: &'a Value,
}
fn expect_name_value_tail<'a>(
builtin: &'static str,
args: &'a [Value],
) -> BuiltinResult<Vec<NameValuePair<'a>>> {
if !args.len().is_multiple_of(2) {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("{builtin} options must be Name, Value pairs"),
));
}
args.chunks(2)
.map(|pair| {
let key = option_key(&pair[0], builtin)?;
Ok(NameValuePair {
name: &pair[0],
key,
value: &pair[1],
})
})
.collect()
}
fn json_fields_from_name_values(
builtin: &'static str,
args: &[Value],
) -> BuiltinResult<serde_json::Map<String, serde_json::Value>> {
let mut fields = serde_json::Map::new();
for pair in expect_name_value_tail(builtin, args)? {
let raw = scalar_string(pair.name, builtin, &ERROR_INPUT)?;
fields.insert(
canonical_field_name(&raw),
value_to_json(builtin, pair.value)?,
);
}
Ok(fields)
}
fn option_key(value: &Value, builtin: &'static str) -> BuiltinResult<String> {
Ok(normalize_token(&scalar_string(
value,
builtin,
&ERROR_INPUT,
)?))
}
fn normalize_token(text: &str) -> String {
text.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.flat_map(|ch| ch.to_lowercase())
.collect()
}
fn canonical_field_name(text: &str) -> String {
let mut out = String::new();
let mut previous_lower_or_digit = false;
for ch in text.chars() {
if ch == '-' || ch == ' ' {
if !out.ends_with('_') && !out.is_empty() {
out.push('_');
}
previous_lower_or_digit = false;
continue;
}
if ch == '_' {
if !out.ends_with('_') && !out.is_empty() {
out.push('_');
}
previous_lower_or_digit = false;
continue;
}
if ch.is_ascii_uppercase() {
if previous_lower_or_digit && !out.ends_with('_') {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
previous_lower_or_digit = false;
} else if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
previous_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
}
}
match normalize_token(&out).as_str() {
"youngsmoduluspa" => "youngs_modulus_pa".to_string(),
"poissonratio" => "poisson_ratio".to_string(),
"density" | "densitykgperm3" => "density_kg_per_m3".to_string(),
"magnitude" | "magnitudepa" => "magnitude_pa".to_string(),
"current" | "currenta" => "current_a".to_string(),
"phase" | "phaserad" => "phase_rad".to_string(),
"amplitudescale" => "amplitude_scale".to_string(),
"deterministicmode" => "deterministic_mode".to_string(),
"precisionmode" => "precision_mode".to_string(),
"preconditionermode" => "preconditioner_mode".to_string(),
"qualitypolicy" => "quality_policy".to_string(),
"prepcalibrationprofile" => "prep_calibration_profile".to_string(),
"prepartifactid" => "prep_artifact_id".to_string(),
"sweepfrequencyhz" => "sweep_frequency_hz".to_string(),
"sweepenabled" => "sweep_enabled".to_string(),
_ => out.trim_matches('_').to_string(),
}
}
fn value_to_json(builtin: &'static str, value: &Value) -> BuiltinResult<serde_json::Value> {
match value {
Value::Num(n) => json_number(builtin, *n),
Value::Int(i) => Ok(serde_json::Value::Number(i.to_i64().into())),
Value::Bool(b) => Ok(serde_json::Value::Bool(*b)),
Value::String(s) => Ok(serde_json::Value::String(s.clone())),
Value::CharArray(chars) if chars.rows == 1 => {
Ok(serde_json::Value::String(chars.data.iter().collect()))
}
Value::StringArray(array) if array.data.len() == 1 => {
Ok(serde_json::Value::String(array.data[0].clone()))
}
Value::StringArray(array) => Ok(serde_json::Value::Array(
array
.data
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
)),
Value::Tensor(tensor) if tensor.data.len() == 1 => json_number(builtin, tensor.data[0]),
Value::Tensor(tensor) => Ok(serde_json::Value::Array(
tensor
.data
.iter()
.map(|value| json_number(builtin, *value))
.collect::<BuiltinResult<Vec<_>>>()?,
)),
Value::Cell(cell) => Ok(serde_json::Value::Array(
cell.data
.iter()
.map(|item| value_to_json(builtin, item))
.collect::<BuiltinResult<Vec<_>>>()?,
)),
Value::Struct(fields) => {
let mut object = serde_json::Map::new();
for (key, value) in &fields.fields {
object.insert(canonical_field_name(key), value_to_json(builtin, value)?);
}
Ok(serde_json::Value::Object(object))
}
Value::Object(object) => {
if let Some(Value::String(json)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY) {
serde_json::from_str(json).map_err(|err| {
builtin_error_with_source(builtin, &ERROR_INPUT, err.to_string(), err)
})
} else {
let mut object_json = serde_json::Map::new();
for (key, value) in &object.properties {
if key.starts_with("__runmat_") {
continue;
}
object_json.insert(canonical_field_name(key), value_to_json(builtin, value)?);
}
Ok(serde_json::Value::Object(object_json))
}
}
other => Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("cannot convert value to FEA JSON payload: {other:?}"),
)),
}
}
fn json_number(builtin: &'static str, value: f64) -> BuiltinResult<serde_json::Value> {
serde_json::Number::from_f64(value)
.map(serde_json::Value::Number)
.ok_or_else(|| {
builtin_error(
builtin,
&ERROR_INPUT,
"FEA numeric option values must be finite JSON numbers",
)
})
}
fn typed_json_with_overrides<T: Serialize + DeserializeOwned>(
builtin: &'static str,
default: T,
fields: serde_json::Map<String, serde_json::Value>,
label: &str,
) -> BuiltinResult<serde_json::Value> {
let base = serde_json::to_value(default)
.map_err(|err| builtin_error(builtin, &ERROR_INTERNAL, err.to_string()))?;
json_with_overrides(builtin, base, fields, label)
}
fn json_with_overrides(
builtin: &'static str,
mut base: serde_json::Value,
fields: serde_json::Map<String, serde_json::Value>,
label: &str,
) -> BuiltinResult<serde_json::Value> {
let Some(object) = base.as_object_mut() else {
return Err(builtin_error(
builtin,
&ERROR_INTERNAL,
format!("{label} default payload is not an object"),
));
};
for (key, value) in fields {
if !object.contains_key(&key) {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("unsupported {label} option `{key}`"),
));
}
object.insert(key, value);
}
Ok(base)
}
fn json_deserialize<T: DeserializeOwned>(
builtin: &'static str,
value: serde_json::Value,
label: &str,
) -> BuiltinResult<T> {
serde_json::from_value(value)
.map_err(|err| builtin_error(builtin, &ERROR_INPUT, format!("invalid {label}: {err}")))
}
fn json_to_string(value: serde_json::Value) -> BuiltinResult<String> {
serde_json::from_value(value).map_err(|err| {
builtin_error(
MATERIAL_NAME,
&ERROR_INPUT,
format!("invalid string option: {err}"),
)
})
}
fn remove_required_f64(
fields: &mut serde_json::Map<String, serde_json::Value>,
builtin: &'static str,
key: &str,
) -> BuiltinResult<f64> {
let Some(value) = fields.remove(key) else {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("missing required option `{key}`"),
));
};
serde_json::from_value(value).map_err(|err| {
builtin_error(
builtin,
&ERROR_INPUT,
format!("invalid numeric option `{key}`: {err}"),
)
})
}
fn remove_optional_f64(
fields: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
) -> BuiltinResult<Option<f64>> {
fields
.remove(key)
.map(|value| {
serde_json::from_value(value).map_err(|err| {
builtin_error(
LOAD_CASE_NAME,
&ERROR_INPUT,
format!("invalid numeric option `{key}`: {err}"),
)
})
})
.transpose()
}
fn remove_required_vector3(
fields: &mut serde_json::Map<String, serde_json::Value>,
builtin: &'static str,
key: &str,
) -> BuiltinResult<[f64; 3]> {
let Some(value) = fields.remove(key) else {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("missing required vector option `{key}`"),
));
};
let values: Vec<f64> = serde_json::from_value(value).map_err(|err| {
builtin_error(
builtin,
&ERROR_INPUT,
format!("invalid vector option `{key}`: {err}"),
)
})?;
if values.len() != 3 {
return Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("vector option `{key}` must contain exactly 3 values"),
));
}
Ok([values[0], values[1], values[2]])
}
fn move_known_fields(
source: &mut serde_json::Map<String, serde_json::Value>,
target: &mut serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> bool {
let mut moved = false;
for key in keys {
if let Some(value) = source.remove(*key) {
target.insert((*key).to_string(), value);
moved = true;
}
}
moved
}
fn reject_unknown_fields(
builtin: &'static str,
fields: serde_json::Map<String, serde_json::Value>,
) -> BuiltinResult<()> {
if fields.is_empty() {
return Ok(());
}
let keys = fields.keys().cloned().collect::<Vec<_>>().join(", ");
Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("unsupported option field(s): {keys}"),
))
}
fn bool_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<bool> {
bool::try_from(value).map_err(|err| builtin_error(builtin, &ERROR_INPUT, err))
}
fn usize_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<usize> {
match value {
Value::Int(int) => Ok(int.to_i64().max(0) as usize),
Value::Num(n) if *n >= 0.0 => Ok(*n as usize),
other => Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("expected non-negative integer value; got {other:?}"),
)),
}
}
fn string_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<String>> {
match value {
Value::Cell(cell) => cell
.data
.iter()
.map(|item| scalar_string(item, builtin, &ERROR_INPUT))
.collect(),
Value::StringArray(array) => Ok(array.data.clone()),
Value::String(_) | Value::CharArray(_) => {
Ok(vec![scalar_string(value, builtin, &ERROR_INPUT)?])
}
other => Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("expected string, string array, or cell array of strings; got {other:?}"),
)),
}
}
fn usize_vec_from_value(builtin: &'static str, value: &Value) -> BuiltinResult<Vec<usize>> {
match value {
Value::Tensor(Tensor { data, .. }) => {
Ok(data.iter().map(|value| *value as usize).collect())
}
Value::Cell(cell) => cell
.data
.iter()
.map(|item| usize_from_value(builtin, item))
.collect(),
Value::Int(_) | Value::Num(_) => Ok(vec![usize_from_value(builtin, value)?]),
other => Err(builtin_error(
builtin,
&ERROR_INPUT,
format!("expected numeric vector or cell array of indices; got {other:?}"),
)),
}
}
fn parse_model_defaults_mode(text: &str) -> BuiltinResult<ModelDefaultsMode> {
match normalize_token(text).as_str() {
"profilescaffold" | "scaffold" | "profile" => Ok(ModelDefaultsMode::ProfileScaffold),
"none" | "empty" => Ok(ModelDefaultsMode::None),
other => Err(builtin_error(
MODEL_NAME,
&ERROR_INPUT,
format!("unsupported model defaults mode `{other}`"),
)),
}
}
fn resolved_document_to_object(document: FeaResolvedDocument) -> BuiltinResult<Value> {
match document {
FeaResolvedDocument::Study(spec) => study_to_object(*spec),
FeaResolvedDocument::Sweep(spec) => sweep_to_object(spec),
}
}
fn study_to_object(spec: AnalysisStudySpec) -> BuiltinResult<Value> {
let mut object = serializable_to_object(
STUDY_NAME,
&ERROR_INTERNAL,
FEA_STUDY_CLASS,
&spec,
Some(FEA_STUDY_SPEC_JSON_PROPERTY),
)?;
if let Value::Object(ref mut object) = object {
object
.properties
.insert("id".to_string(), Value::String(spec.study_id));
}
Ok(object)
}
fn sweep_to_object(spec: AnalysisStudySweepSpec) -> BuiltinResult<Value> {
let mut object = serializable_to_object(
LOAD_NAME,
&ERROR_INTERNAL,
FEA_SWEEP_CLASS,
&spec,
Some(FEA_SWEEP_SPEC_JSON_PROPERTY),
)?;
if let Value::Object(ref mut object) = object {
object
.properties
.insert("id".to_string(), Value::String(spec.sweep_id));
}
Ok(object)
}
fn operation_result_to_object<T: Serialize>(
builtin: &'static str,
operation_error_descriptor: &'static BuiltinErrorDescriptor,
internal_error_descriptor: &'static BuiltinErrorDescriptor,
class_name: &'static str,
result: Result<OperationEnvelope<T>, OperationErrorEnvelope>,
hidden_json_property: Option<&'static str>,
) -> BuiltinResult<Value> {
let envelope =
result.map_err(|err| operation_error(builtin, operation_error_descriptor, err))?;
serializable_to_object(
builtin,
internal_error_descriptor,
class_name,
&envelope.data,
hidden_json_property,
)
}
fn serializable_to_object<T: Serialize>(
builtin: &'static str,
error: &'static BuiltinErrorDescriptor,
class_name: &'static str,
value: &T,
hidden_json_property: Option<&'static str>,
) -> BuiltinResult<Value> {
serializable_to_object_value(builtin, error, class_name, value, hidden_json_property)
.map(Value::Object)
}
fn serializable_to_value<T: Serialize>(
builtin: &'static str,
error: &'static BuiltinErrorDescriptor,
value: &T,
) -> BuiltinResult<Value> {
let json = serde_json::to_value(value)
.map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))?;
value_from_json(&json)
.map_err(|err| builtin_error_with_source(builtin, error, err.message().to_string(), err))
}
fn serializable_to_object_value<T: Serialize>(
builtin: &'static str,
error: &'static BuiltinErrorDescriptor,
class_name: &'static str,
value: &T,
hidden_json_property: Option<&'static str>,
) -> BuiltinResult<ObjectInstance> {
ensure_fea_classes_registered();
let json = serde_json::to_value(value)
.map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))?;
let converted = value_from_json(&json)
.map_err(|err| builtin_error_with_source(builtin, error, err.message().to_string(), err))?;
let mut object = ObjectInstance::new(class_name.to_string());
if let Value::Struct(fields) = converted {
object.properties = fields.fields.into_iter().collect();
} else {
object.properties.insert("value".to_string(), converted);
}
if let Some(property) = hidden_json_property {
object
.properties
.insert(property.to_string(), Value::String(json.to_string()));
}
Ok(object)
}
fn geometry_asset_from_value(value: &Value) -> BuiltinResult<GeometryAsset> {
let Value::Object(object) = value else {
return Err(builtin_error(
STUDY_NAME,
&ERROR_INPUT,
format!("fea.study geometry must be {GEOMETRY_ASSET_CLASS}"),
));
};
if object.class_name != GEOMETRY_ASSET_CLASS {
return Err(builtin_error(
STUDY_NAME,
&ERROR_INPUT,
format!(
"fea.study geometry must be {GEOMETRY_ASSET_CLASS}, got {}",
object.class_name
),
));
}
object_json_property(
STUDY_NAME,
object,
GEOMETRY_ASSET_JSON_PROPERTY,
&ERROR_INPUT,
)
}
fn object_json_property<T: DeserializeOwned>(
builtin: &'static str,
object: &ObjectInstance,
property: &'static str,
error: &'static BuiltinErrorDescriptor,
) -> BuiltinResult<T> {
let Some(Value::String(json)) = object.properties.get(property) else {
return Err(builtin_error(
builtin,
error,
format!(
"{} is missing required runtime payload property `{property}`",
object.class_name
),
));
};
serde_json::from_str(json)
.map_err(|err| builtin_error_with_source(builtin, error, err.to_string(), err))
}
fn scalar_string(
value: &Value,
builtin: &'static str,
error: &'static BuiltinErrorDescriptor,
) -> BuiltinResult<String> {
String::try_from(value).map_err(|err| builtin_error(builtin, error, err))
}
fn parse_scalar_enum<T: DeserializeOwned>(text: &str, label: &str) -> BuiltinResult<T> {
serde_yaml::from_str::<T>(&text.to_ascii_lowercase()).map_err(|err| {
builtin_error(
STUDY_NAME,
&ERROR_INPUT,
format!("invalid {label} value `{text}`: {err}"),
)
})
}
fn default_profile_for_run_kind(run_kind: AnalysisRunKind) -> AnalysisCreateModelProfile {
match run_kind {
AnalysisRunKind::LinearStatic => AnalysisCreateModelProfile::LinearStaticStructural,
AnalysisRunKind::Modal => AnalysisCreateModelProfile::ModalStructural,
AnalysisRunKind::Acoustic => AnalysisCreateModelProfile::AcousticHarmonic,
AnalysisRunKind::Thermal => AnalysisCreateModelProfile::ThermalStandalone,
AnalysisRunKind::Transient => AnalysisCreateModelProfile::TransientStructural,
AnalysisRunKind::Cfd => AnalysisCreateModelProfile::CfdSteadyState,
AnalysisRunKind::Cht => AnalysisCreateModelProfile::ChtCoupled,
AnalysisRunKind::Fsi => AnalysisCreateModelProfile::FsiCoupled,
AnalysisRunKind::Nonlinear => AnalysisCreateModelProfile::NonlinearStructural,
AnalysisRunKind::Electromagnetic => AnalysisCreateModelProfile::ElectromagneticStatic,
}
}
fn resolve_study_profile_and_run_kind(
options: &StudyConstructorOptions,
) -> BuiltinResult<(AnalysisCreateModelProfile, AnalysisRunKind)> {
let profile = match (options.profile, options.run_kind) {
(Some(profile), _) => profile,
(None, Some(run_kind)) => default_profile_for_run_kind(run_kind),
(None, None) => AnalysisCreateModelProfile::LinearStaticStructural,
};
let run_kind = profile.derived_run_kind();
if let Some(explicit_run_kind) = options.run_kind {
if explicit_run_kind != run_kind {
return Err(builtin_error(
STUDY_NAME,
&ERROR_INPUT,
format!(
"explicit solver {:?} does not match Profile {:?}; omit RunKind or choose a matching Profile",
explicit_run_kind, profile
),
));
}
}
Ok((profile, run_kind))
}
fn ensure_fea_classes_registered() {
static REGISTER: OnceLock<()> = OnceLock::new();
REGISTER.get_or_init(|| {
let workflow_methods = workflow_methods();
for class_name in [FEA_STUDY_CLASS, FEA_SWEEP_CLASS] {
runmat_builtins::register_class(ClassDef {
name: class_name.to_string(),
parent: None,
properties: HashMap::new(),
methods: workflow_methods.clone(),
});
}
runmat_builtins::register_class(ClassDef {
name: FEA_RUN_RESULT_CLASS.to_string(),
parent: None,
properties: HashMap::new(),
methods: run_result_methods(),
});
runmat_builtins::register_class(ClassDef {
name: FEA_RESULTS_CLASS.to_string(),
parent: None,
properties: HashMap::new(),
methods: results_methods(),
});
for class_name in [FEA_VALIDATION_CLASS, FEA_PLAN_CLASS, FEA_RUN_RESULT_CLASS] {
if class_name == FEA_RUN_RESULT_CLASS {
continue;
}
runmat_builtins::register_class(ClassDef {
name: class_name.to_string(),
parent: None,
properties: HashMap::new(),
methods: HashMap::new(),
});
}
for class_name in [
FEA_MODEL_CLASS,
FEA_MATERIAL_CLASS,
FEA_MATERIAL_ASSIGNMENT_CLASS,
FEA_BOUNDARY_CONDITION_CLASS,
FEA_LOAD_CASE_CLASS,
FEA_STEP_CLASS,
FEA_DOMAIN_CLASS,
FEA_INTERFACE_CLASS,
FEA_RUN_OPTIONS_CLASS,
FEA_FIELD_CLASS,
FEA_COMPARE_CLASS,
FEA_TRENDS_CLASS,
] {
runmat_builtins::register_class(ClassDef {
name: class_name.to_string(),
parent: None,
properties: HashMap::new(),
methods: if class_name == FEA_FIELD_CLASS {
field_methods()
} else {
HashMap::new()
},
});
}
});
}
fn workflow_methods() -> HashMap<String, MethodDef> {
[
("validate", VALIDATE_NAME),
("plan", PLAN_NAME),
("run", RUN_NAME),
]
.into_iter()
.map(|(name, function_name)| {
(
name.to_string(),
MethodDef {
name: name.to_string(),
is_static: false,
is_abstract: false,
is_sealed: false,
access: Access::Public,
function_name: function_name.to_string(),
implicit_class_argument: None,
},
)
})
.collect()
}
fn run_result_methods() -> HashMap<String, MethodDef> {
[
("results", RESULTS_NAME),
("field", FIELD_NAME),
("plot", PLOT_NAME),
]
.into_iter()
.map(|(name, function_name)| {
(
name.to_string(),
MethodDef {
name: name.to_string(),
is_static: false,
is_abstract: false,
is_sealed: false,
access: Access::Public,
function_name: function_name.to_string(),
implicit_class_argument: None,
},
)
})
.collect()
}
fn results_methods() -> HashMap<String, MethodDef> {
[("field", FIELD_NAME), ("plot", PLOT_NAME)]
.into_iter()
.map(|(name, function_name)| {
(
name.to_string(),
MethodDef {
name: name.to_string(),
is_static: false,
is_abstract: false,
is_sealed: false,
access: Access::Public,
function_name: function_name.to_string(),
implicit_class_argument: None,
},
)
})
.collect()
}
fn field_methods() -> HashMap<String, MethodDef> {
[("plot", PLOT_NAME)]
.into_iter()
.map(|(name, function_name)| {
(
name.to_string(),
MethodDef {
name: name.to_string(),
is_static: false,
is_abstract: false,
is_sealed: false,
access: Access::Public,
function_name: function_name.to_string(),
implicit_class_argument: None,
},
)
})
.collect()
}
fn operation_error(
builtin: &'static str,
error: &'static BuiltinErrorDescriptor,
source: OperationErrorEnvelope,
) -> RuntimeError {
let message = format!(
"{}: {}: {}",
error.message, source.error_code, source.message
);
build_runtime_error(message)
.with_builtin(builtin)
.with_identifier(
error
.identifier
.unwrap_or(ERROR_OPERATION.identifier.expect("descriptor identifier")),
)
.build()
}
fn builtin_error(
builtin: &'static str,
error: &'static BuiltinErrorDescriptor,
message: impl Into<String>,
) -> RuntimeError {
build_runtime_error(format!("{}: {}", error.message, message.into()))
.with_builtin(builtin)
.with_identifier(
error
.identifier
.unwrap_or(ERROR_INTERNAL.identifier.expect("descriptor identifier")),
)
.build()
}
fn builtin_error_with_source<E>(
builtin: &'static str,
error: &'static BuiltinErrorDescriptor,
message: impl Into<String>,
source: E,
) -> RuntimeError
where
E: std::error::Error + Send + Sync + 'static,
{
build_runtime_error(format!("{}: {}", error.message, message.into()))
.with_builtin(builtin)
.with_identifier(
error
.identifier
.unwrap_or(ERROR_INTERNAL.identifier.expect("descriptor identifier")),
)
.with_source(source)
.build()
}
fn sanitize_id(id: &str) -> String {
id.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
ch
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use futures::executor::block_on;
use runmat_builtins::CellArray;
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";
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";
fn cell(values: Vec<Value>) -> Value {
let cols = values.len().max(1);
Value::Cell(CellArray::new(values, 1, cols).expect("cell should build"))
}
fn force_vector() -> Value {
Value::Tensor(Tensor::new_2d(vec![0.0, -1000.0, 0.0], 1, 3).expect("tensor should build"))
}
fn moment_vector() -> Value {
Value::Tensor(Tensor::new_2d(vec![10.0, 20.0, 30.0], 1, 3).expect("tensor should build"))
}
#[test]
fn fea_study_requires_geometry_asset() {
let err = block_on(fea_study_builtin(vec![
Value::String("demo".to_string()),
Value::Num(1.0),
]))
.expect_err("invalid geometry should fail");
assert_eq!(err.identifier(), Some("RunMat:fea:InvalidInput"));
}
#[test]
fn fea_load_validate_and_plan_document_workflow() {
let tmp = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(tmp.path().join("part.stl"), TRIANGLE_STL)
.expect("geometry fixture should write");
let fea_path = tmp.path().join("bracket.fea");
std::fs::write(
&fea_path,
r#"
version: 1
kind: study
id: bracket_static
geometry:
path: part.stl
units: meter
model:
profile: linear_static_structural
run:
backend: cpu
"#,
)
.expect("FEA fixture should write");
let study = block_on(fea_load_builtin(fea_path.to_string_lossy().to_string()))
.expect("FEA document should load");
let Value::Object(study_object) = study.clone() else {
panic!("expected loaded FEA study object");
};
assert_eq!(study_object.class_name, FEA_STUDY_CLASS);
assert!(study_object
.properties
.contains_key(FEA_STUDY_SPEC_JSON_PROPERTY));
let validation =
block_on(fea_validate_builtin(study.clone())).expect("FEA study should validate");
let Value::Object(validation_object) = validation else {
panic!("expected validation object");
};
assert_eq!(validation_object.class_name, FEA_VALIDATION_CLASS);
assert_eq!(
validation_object.properties.get("valid"),
Some(&Value::Bool(true))
);
let plan = block_on(fea_plan_builtin(study)).expect("FEA study should plan");
let Value::Object(plan_object) = plan else {
panic!("expected plan object");
};
assert_eq!(plan_object.class_name, FEA_PLAN_CLASS);
assert!(plan_object.properties.contains_key("operation_sequence"));
}
#[test]
fn fea_load_case_accepts_moment_and_torque_alias() {
for kind in ["moment", "torque"] {
let load = block_on(fea_load_case_builtin(vec![
Value::String(format!("tip_{kind}")),
Value::String("tip_node".to_string()),
Value::String(kind.to_string()),
Value::String("Vector".to_string()),
moment_vector(),
]))
.expect("moment load should build");
assert_object_class(&load, FEA_LOAD_CASE_CLASS);
let Value::Object(object) = load else {
panic!("expected load object");
};
let Some(Value::String(payload)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY)
else {
panic!("expected load JSON payload");
};
let decoded: LoadCase =
serde_json::from_str(payload).expect("load payload should decode");
assert_eq!(decoded.load_id, format!("tip_{kind}"));
assert_eq!(decoded.region_id, "tip_node");
assert!(matches!(
decoded.kind,
LoadKind::Moment {
mx: 10.0,
my: 20.0,
mz: 30.0
}
));
}
}
#[test]
fn fea_load_case_doc_keywords_include_moment_and_torque() {
let doc = runmat_builtins::builtin_docs()
.into_iter()
.find(|doc| doc.name == "fea.loadCase")
.expect("fea.loadCase doc metadata should be registered");
let keywords = doc
.keywords
.expect("fea.loadCase should advertise keywords");
let keyword_set = keywords
.split(',')
.map(str::trim)
.collect::<std::collections::BTreeSet<_>>();
assert!(keyword_set.contains("moment"));
assert!(keyword_set.contains("torque"));
}
#[test]
fn fea_boundary_condition_accepts_prescribed_rotation() {
let boundary = block_on(fea_boundary_condition_builtin(vec![
Value::String("tip_rotation".to_string()),
Value::String("tip_node".to_string()),
Value::String("prescribedRotation".to_string()),
Value::String("rx".to_string()),
Value::Num(0.1),
Value::String("ry".to_string()),
Value::Num(0.2),
Value::String("rz".to_string()),
Value::Num(0.3),
]))
.expect("prescribed rotation boundary condition should build");
assert_object_class(&boundary, FEA_BOUNDARY_CONDITION_CLASS);
let Value::Object(object) = boundary else {
panic!("expected boundary condition object");
};
let Some(Value::String(payload)) = object.properties.get(FEA_PAYLOAD_JSON_PROPERTY) else {
panic!("expected boundary condition JSON payload");
};
let decoded: BoundaryCondition =
serde_json::from_str(payload).expect("boundary condition payload should decode");
assert_eq!(decoded.bc_id, "tip_rotation");
assert_eq!(decoded.region_id, "tip_node");
assert!(matches!(
decoded.kind,
BoundaryConditionKind::PrescribedRotation {
rx: 0.1,
ry: 0.2,
rz: 0.3
}
));
}
#[test]
fn typed_constructors_build_full_study_and_sweep_objects() {
let tmp = tempfile::tempdir().expect("tempdir should be created");
let geometry_path = tmp.path().join("part.step");
std::fs::write(&geometry_path, SIMPLE_STEP).expect("geometry fixture should write");
let geometry = block_on(crate::builtins::geometry::geometry_load_builtin(
geometry_path.to_string_lossy().to_string(),
))
.expect("geometry should load");
let asset = geometry_asset_from_value(&geometry).expect("geometry payload should decode");
let region_id = asset
.regions
.first()
.expect("fixture should import a region")
.region_id
.clone();
let material = block_on(fea_material_builtin(vec![
Value::String("steel".to_string()),
Value::String("YoungsModulusPa".to_string()),
Value::Num(200e9),
Value::String("PoissonRatio".to_string()),
Value::Num(0.30),
]))
.expect("material should build");
assert_object_class(&material, FEA_MATERIAL_CLASS);
let assignment = block_on(fea_material_assignment_builtin(vec![
Value::String(region_id.clone()),
Value::String("steel".to_string()),
]))
.expect("material assignment should build");
assert_object_class(&assignment, FEA_MATERIAL_ASSIGNMENT_CLASS);
let fixed = block_on(fea_boundary_condition_builtin(vec![
Value::String("fixed_base".to_string()),
Value::String(region_id.clone()),
Value::String("fixed".to_string()),
]))
.expect("boundary condition should build");
assert_object_class(&fixed, FEA_BOUNDARY_CONDITION_CLASS);
let load = block_on(fea_load_case_builtin(vec![
Value::String("tip_force".to_string()),
Value::String(region_id),
Value::String("force".to_string()),
Value::String("Vector".to_string()),
force_vector(),
]))
.expect("load case should build");
assert_object_class(&load, FEA_LOAD_CASE_CLASS);
let step = block_on(fea_step_builtin(vec![
Value::String("static_step".to_string()),
Value::String("static".to_string()),
]))
.expect("analysis step should build");
assert_object_class(&step, FEA_STEP_CLASS);
let model = block_on(fea_model_builtin(vec![
Value::String("bracket_static_model".to_string()),
geometry.clone(),
Value::String("Defaults".to_string()),
Value::String("none".to_string()),
Value::String("Profile".to_string()),
Value::String("linear_static_structural".to_string()),
Value::String("Materials".to_string()),
cell(vec![material]),
Value::String("MaterialAssignments".to_string()),
cell(vec![assignment]),
Value::String("BoundaryConditions".to_string()),
cell(vec![fixed]),
Value::String("Loads".to_string()),
cell(vec![load]),
Value::String("Steps".to_string()),
cell(vec![step]),
]))
.expect("model should build");
assert_object_class(&model, FEA_MODEL_CLASS);
let run_options = block_on(fea_run_options_builtin(vec![
Value::String("linear_static".to_string()),
Value::String("DeterministicMode".to_string()),
Value::Bool(true),
Value::String("PrecisionMode".to_string()),
Value::String("fp64".to_string()),
Value::String("QualityPolicy".to_string()),
Value::String("balanced".to_string()),
]))
.expect("run options should build");
assert_object_class(&run_options, FEA_RUN_OPTIONS_CLASS);
let study = block_on(fea_study_builtin(vec![
Value::String("bracket_static".to_string()),
geometry,
Value::String("Profile".to_string()),
Value::String("linear_static_structural".to_string()),
Value::String("Backend".to_string()),
Value::String("cpu".to_string()),
Value::String("Model".to_string()),
model,
Value::String("RunOptions".to_string()),
run_options,
]))
.expect("study should build");
assert_object_class(&study, FEA_STUDY_CLASS);
let sweep = block_on(fea_sweep_builtin(vec![
Value::String("bracket_sweep".to_string()),
cell(vec![study]),
Value::String("FailFast".to_string()),
Value::Bool(false),
]))
.expect("sweep should build");
assert_object_class(&sweep, FEA_SWEEP_CLASS);
}
#[test]
fn fea_results_field_exposes_values_metadata_and_plot_context() {
let (run_value, _study) = synthetic_plot_run_value();
let results = block_on(fea_results_builtin(vec![run_value])).expect("results should load");
let Value::Object(results_object) = results.clone() else {
panic!("expected results object");
};
assert_eq!(results_object.class_name, FEA_RESULTS_CLASS);
assert_eq!(
results_object.properties.get("run_id"),
Some(&Value::String("synthetic_plot_run".to_string()))
);
assert!(results_object
.properties
.contains_key(FEA_STUDY_CONTEXT_JSON_PROPERTY));
let field = block_on(fea_field_builtin(vec![
results,
Value::String("von_mises".to_string()),
]))
.expect("field should resolve by unique suffix");
let Value::Object(field_object) = field else {
panic!("expected field object");
};
assert_eq!(field_object.class_name, FEA_FIELD_CLASS);
assert_eq!(
field_object.properties.get("field_id"),
Some(&Value::String("structural.von_mises".to_string()))
);
assert_eq!(
field_object.properties.get("unit"),
Some(&Value::String("Pa".to_string()))
);
assert_eq!(
field_object.properties.get("location"),
Some(&Value::String("element".to_string()))
);
let Some(Value::Tensor(values)) = field_object.properties.get("values") else {
panic!("expected values tensor");
};
assert_eq!(values.shape, vec![1]);
assert_eq!(values.data, vec![42.0]);
assert!(field_object
.properties
.contains_key(FEA_STUDY_CONTEXT_JSON_PROPERTY));
assert_eq!(
field_object.properties.get(FEA_RUN_ID_CONTEXT_PROPERTY),
Some(&Value::String("synthetic_plot_run".to_string()))
);
}
#[cfg(feature = "plot-core")]
#[test]
fn fea_plot_returns_figure_handle_for_contextual_run_results_and_fields() {
let (run_value, _study) = synthetic_plot_run_value();
let run_handle = block_on(fea_plot_builtin(vec![
run_value.clone(),
Value::String("von_mises".to_string()),
]))
.expect("run plot should create a figure");
assert!(matches!(run_handle, Value::Num(handle) if handle >= 1.0));
let results = block_on(fea_results_builtin(vec![run_value])).expect("results should load");
let field = block_on(fea_field_builtin(vec![
results,
Value::String("structural.von_mises".to_string()),
]))
.expect("field should resolve");
let field_handle =
block_on(fea_plot_builtin(vec![field])).expect("field plot should create a figure");
assert!(matches!(field_handle, Value::Num(handle) if handle >= 1.0));
}
fn synthetic_plot_run_value() -> (Value, Value) {
crate::analysis::storage::configure_artifact_store(
crate::analysis::storage::AnalysisArtifactStoreConfig::InMemory,
)
.expect("artifact store should configure");
let tmp = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(tmp.path().join("part.stl"), TRIANGLE_STL)
.expect("geometry fixture should write");
let fea_path = tmp.path().join("plot.fea");
std::fs::write(
&fea_path,
r#"
version: 1
kind: study
id: synthetic_plot
geometry:
path: part.stl
units: meter
model:
profile: linear_static_structural
run:
backend: cpu
"#,
)
.expect("FEA fixture should write");
let study = block_on(fea_load_builtin(fea_path.to_string_lossy().to_string()))
.expect("study should load");
let Value::Object(study_object) = &study else {
panic!("expected study object");
};
let study_json = match study_object.properties.get(FEA_STUDY_SPEC_JSON_PROPERTY) {
Some(Value::String(json)) => json.clone(),
_ => panic!("expected study spec payload"),
};
let run = crate::analysis::AnalysisRunResult {
run_id: "synthetic_plot_run".to_string(),
run: runmat_analysis_fea::FeaRunResult {
backend: ComputeBackend::Cpu,
solver_backend: "synthetic".to_string(),
solver_device_apply_k_ratio: 0.0,
solver_method: "synthetic".to_string(),
preconditioner: "none".to_string(),
solver_host_sync_count: 0,
diagnostics: Vec::new(),
fields: vec![AnalysisField::host_f64(
"structural.von_mises",
vec![1],
vec![42.0],
)],
},
render_topology: None,
modal_results: None,
thermal_results: None,
transient_results: None,
nonlinear_results: None,
electromagnetic_results: None,
model_validity: crate::analysis::QualityGate::Pass,
solver_convergence: crate::analysis::QualityGate::Pass,
result_quality: crate::analysis::QualityGate::Pass,
run_status: crate::analysis::RunStatus::Publishable,
publishable: true,
quality_reasons: Vec::new(),
provenance: crate::analysis::RunProvenance {
backend: ComputeBackend::Cpu,
solver_backend: "synthetic".to_string(),
solver_device_apply_k_ratio: 0.0,
solver_host_sync_count: 0,
precision_mode: "fp64".to_string(),
deterministic_mode: true,
solver_method: "synthetic".to_string(),
preconditioner: "none".to_string(),
quality_policy: "balanced".to_string(),
fallback_events: Vec::new(),
},
};
crate::analysis::storage::persist_run_result(&run).expect("run should persist");
let mut object = ObjectInstance::new(FEA_RUN_RESULT_CLASS.to_string());
object.properties.insert(
"run_id".to_string(),
Value::String("synthetic_plot_run".to_string()),
);
object.properties.insert(
FEA_RUN_ID_CONTEXT_PROPERTY.to_string(),
Value::String("synthetic_plot_run".to_string()),
);
object.properties.insert(
FEA_STUDY_CONTEXT_JSON_PROPERTY.to_string(),
Value::String(study_json),
);
(Value::Object(object), study)
}
fn assert_object_class(value: &Value, expected: &str) {
let Value::Object(object) = value else {
panic!("expected object value");
};
assert_eq!(object.class_name, expected);
assert!(
object.properties.contains_key(FEA_PAYLOAD_JSON_PROPERTY)
|| object.properties.contains_key(FEA_STUDY_SPEC_JSON_PROPERTY)
|| object.properties.contains_key(FEA_SWEEP_SPEC_JSON_PROPERTY)
);
}
}