1use std::cell::RefCell;
2use std::collections::{BTreeMap, HashMap, HashSet};
3use std::io::ErrorKind;
4use std::path::PathBuf;
5use std::sync::{Arc, OnceLock, RwLock};
6use std::time::Instant;
7
8use chrono::Utc;
9use runmat_analysis_core::{
10 validate_model_against_geometry, AnalysisField, AnalysisInterfaceKind, AnalysisModel,
11 AnalysisModelId, AnalysisStep, AnalysisStepKind, AnalysisValidationError, BoundaryCondition,
12 BoundaryConditionKind, EvidenceConfidence, LoadCase, LoadKind, MaterialAssignment,
13 MaterialMechanicalModel, MaterialModel, MaterialThermalModel, ReferenceFrame,
14};
15use runmat_analysis_fea::solve::backend::kind::LinearAlgebraBackendKind;
16use runmat_analysis_fea::solve::preconditioner::SpdPreconditionerKind;
17use runmat_analysis_fea::{
18 fea_acoustic_frequency_response_field_id, fea_cht_energy_residual_field_id,
19 fea_cht_fluid_temperature_field_id, fea_cht_interface_heat_flux_field_id,
20 fea_cht_interface_temperature_jump_field_id, fea_cht_solid_temperature_field_id,
21 fea_fsi_coupling_iteration_count_field_id, fea_fsi_fluid_pressure_field_id,
22 fea_fsi_fluid_velocity_field_id, fea_fsi_interface_displacement_field_id,
23 fea_fsi_interface_pressure_field_id, fea_fsi_interface_residual_field_id,
24 fea_fsi_interface_traction_field_id, fea_fsi_structural_displacement_field_id,
25 run_electromagnetic_with_options, run_linear_static_with_options, run_modal_with_options,
26 run_nonlinear_with_options, run_thermal_with_options, run_transient_with_options,
27 ComputeBackend, ElectromagneticSolveOptions, FeaProgressHandler, FeaRunError, FeaRunResult,
28 LinearStaticSolveOptions, ModalSolveOptions, ThermalSolveOptions,
29 FEA_FIELD_ACOUSTIC_PARTICLE_VELOCITY, FEA_FIELD_ACOUSTIC_PHASE,
30 FEA_FIELD_ACOUSTIC_PRESSURE_IMAG, FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE,
31 FEA_FIELD_ACOUSTIC_PRESSURE_REAL, FEA_FIELD_ACOUSTIC_SOUND_PRESSURE_LEVEL_DB,
32 FEA_FIELD_CFD_PRESSURE, FEA_FIELD_CFD_RESIDUAL_CONTINUITY, FEA_FIELD_CFD_RESIDUAL_MOMENTUM,
33 FEA_FIELD_CFD_REYNOLDS_NUMBER, FEA_FIELD_CFD_VELOCITY, FEA_FIELD_CFD_VORTICITY,
34 FEA_FIELD_CFD_WALL_SHEAR_STRESS, FEA_FIELD_CHT_FLUID_PRESSURE, FEA_FIELD_CHT_FLUID_VELOCITY,
35 FEA_FIELD_STRUCTURAL_DISPLACEMENT, FEA_FIELD_STRUCTURAL_NODAL_VON_MISES,
36 FEA_FIELD_STRUCTURAL_REACTION_FORCE, FEA_FIELD_STRUCTURAL_REACTION_MOMENT,
37 FEA_FIELD_STRUCTURAL_ROTATION, FEA_FIELD_STRUCTURAL_STRAIN,
38 FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY, FEA_FIELD_STRUCTURAL_STRESS,
39 FEA_FIELD_STRUCTURAL_VON_MISES,
40};
41use runmat_geometry_core::{EntityKind, GeometryAsset, MaterialEvidenceConfidence, UnitSystem};
42use runmat_meshing::{
43 generate_analysis_mesh, generate_analysis_mesh_with_sizing, ElementFamilyHint,
44 MeshConnectivityClass,
45};
46use runmat_meshing_core::{
47 build_refinement_markers_from_samples, plan_refinement_indicators, AdaptiveConvergenceStatus,
48 AdaptiveIterationSummary, AnalysisFieldTopologyDescriptor, AnalysisFieldTopologyLocation,
49 AnalysisMeshArtifact, AnalysisMeshValidationOptions, MeshSizingField, MeshTargetSize,
50 RefinementIndicatorAvailability, RefinementIndicatorSample, RefinementMarkerOptions,
51 RefinementStrategy, SizingFieldUpdate, SourceEntityKind, VolumeMeshingOptions,
52 TETRAHEDRON4_FIELD_ELEMENT_KIND,
53};
54use runmat_meshing_evidence::{
55 build_mesh_authoring_summary, build_mesh_evidence_artifact,
56 build_mesh_evidence_artifact_with_validation_evidence, MeshValidationEvidence,
57};
58use serde::{Deserialize, Serialize};
59use sha2::{Digest, Sha256};
60
61use crate::operations::{
62 operation_error, OperationContext, OperationEnvelope, OperationErrorEnvelope,
63 OperationErrorSeverity, OperationErrorSpec, OperationErrorType,
64};
65use policy::{
66 breach_rate_greater_than, breach_rate_less_than, electromagnetic_sweep_thresholds_for_policy,
67 electromagnetic_thresholds_for_policy, thermo_field_quality_thresholds_for_policy,
68 thermo_gradient_thresholds_for_policy, thermo_thresholds_for_policy,
69 ElectromagneticQualityThresholds, EM_ASSIGNMENT_COVERAGE_MIN_BALANCED,
70 EM_BOUNDARY_ANCHOR_MIN_BALANCED, EM_BOUNDARY_ENERGY_MIN_BALANCED,
71 EM_BOUNDARY_LOCALIZATION_MIN_BALANCED, EM_BOUNDARY_PENALTY_CONTRIBUTION_MAX_BALANCED,
72 EM_CONDITIONING_MAX_BALANCED, EM_CONDUCTIVITY_SPREAD_THRESHOLD_BALANCED,
73 EM_ENERGY_IMBALANCE_MAX_BALANCED, EM_FLUX_DIVERGENCE_MAX_BALANCED,
74 EM_GROUND_EFFECTIVENESS_MIN_BALANCED, EM_HETEROGENEITY_THRESHOLD_BALANCED,
75 EM_IMAG_RESIDUAL_MAX_BALANCED, EM_INSULATION_LEAKAGE_MAX_BALANCED,
76 EM_REAL_RESIDUAL_MAX_BALANCED, EM_REGION_CONTRAST_MAX_BALANCED, EM_RESONANCE_Q_MIN_BALANCED,
77 EM_SOURCE_INTERFERENCE_MAX_BALANCED, EM_SOURCE_MATERIAL_ALIGNMENT_MIN_BALANCED,
78 EM_SOURCE_OVERLAP_MAX_BALANCED, EM_SOURCE_REALIZATION_MIN_BALANCED,
79 EM_SOURCE_REGION_COVERAGE_MIN_BALANCED, EM_SOURCE_REGION_ENERGY_CONSISTENCY_MIN_BALANCED,
80 EM_SWEEP_COUNT_MIN_BALANCED, THERMO_HETEROGENEITY_THRESHOLD_BALANCED,
81 THERMO_SPREAD_THRESHOLD_BALANCED,
82};
83
84mod contracts;
85mod fea_document;
86mod fea_document_authoring;
87#[cfg(feature = "plot-core")]
88mod figures;
89mod policy;
90mod promotion;
91pub mod storage;
92mod study_authoring;
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct FeaRuntimeConfig {
96 pub artifact_root: Option<PathBuf>,
97 pub study_artifact_root: Option<PathBuf>,
98 pub thermo_field_artifact_root: Option<PathBuf>,
99}
100
101fn fea_runtime_config() -> &'static RwLock<FeaRuntimeConfig> {
102 static CONFIG: OnceLock<RwLock<FeaRuntimeConfig>> = OnceLock::new();
103 CONFIG.get_or_init(|| RwLock::new(FeaRuntimeConfig::default()))
104}
105
106fn current_fea_runtime_config() -> FeaRuntimeConfig {
107 fea_runtime_config()
108 .read()
109 .map(|guard| guard.clone())
110 .unwrap_or_default()
111}
112
113pub fn default_fea_artifact_root() -> PathBuf {
114 PathBuf::from("artifacts")
115}
116
117pub fn configure_fea_runtime(config: FeaRuntimeConfig) -> Result<(), String> {
118 let mut guard = fea_runtime_config()
119 .write()
120 .map_err(|_| "FEA runtime config lock poisoned".to_string())?;
121 *guard = config;
122 Ok(())
123}
124
125thread_local! {
126 static FEA_PROGRESS_HANDLER: RefCell<Option<FeaProgressHandler>> = const { RefCell::new(None) };
127}
128
129pub struct FeaProgressHandlerGuard {
130 previous: Option<FeaProgressHandler>,
131}
132
133impl Drop for FeaProgressHandlerGuard {
134 fn drop(&mut self) {
135 FEA_PROGRESS_HANDLER.with(|slot| {
136 slot.replace(self.previous.take());
137 });
138 }
139}
140
141pub fn replace_fea_progress_handler(
142 handler: Option<FeaProgressHandler>,
143) -> FeaProgressHandlerGuard {
144 let previous = FEA_PROGRESS_HANDLER.with(|slot| slot.replace(handler));
145 FeaProgressHandlerGuard { previous }
146}
147
148fn install_fea_solver_context() -> runmat_analysis_fea::FeaProgressContextGuard {
149 let host_handler = FEA_PROGRESS_HANDLER.with(|slot| slot.borrow().clone());
150 let handler = Some(Arc::new(move |event: FeaProgressEvent| {
151 tracing::info!(
152 target: "runmat_analysis",
153 operation = %event.operation,
154 phase = ?event.phase,
155 status = ?event.status,
156 current = event.current,
157 total = event.total,
158 fraction = event.fraction,
159 "{}", event.message
160 );
161 if let Some(host_handler) = host_handler.as_ref() {
162 host_handler(event);
163 }
164 }) as FeaProgressHandler);
165 runmat_analysis_fea::replace_fea_progress_context(
166 handler,
167 Some(Arc::new(crate::interrupt::is_cancelled)),
168 )
169}
170
171pub use contracts::{
172 analysis_runtime_physics_profile_catalog, AnalysisAcousticRunOptions, AnalysisCfdRunOptions,
173 AnalysisChtRunOptions, AnalysisCreateModelIntentSpec, AnalysisCreateModelPrepContext,
174 AnalysisCreateModelProfile, AnalysisDiagnosticsArtifactPayload, AnalysisDocumentCheckResult,
175 AnalysisDocumentKind, AnalysisDocumentRunResult, AnalysisElectromagneticRunOptions,
176 AnalysisFieldDescriptor, AnalysisFieldDescriptorsArtifactPayload, AnalysisFieldKind,
177 AnalysisFieldLocation, AnalysisFieldPageResult, AnalysisFieldPagingDescriptor,
178 AnalysisFieldRequestOptions, AnalysisFieldStorage, AnalysisFieldStorageRef,
179 AnalysisFsiRunOptions, AnalysisModalRunOptions, AnalysisNonlinearRunOptions,
180 AnalysisObjectArtifactMetadata, AnalysisRenderMesh, AnalysisRenderRegion,
181 AnalysisRenderTopology, AnalysisRenderTopologySource, AnalysisRenderTriangleRange,
182 AnalysisResultsCompareData, AnalysisResultsCompareQuery, AnalysisResultsData,
183 AnalysisResultsQuery, AnalysisResultsSummary, AnalysisRunDatasetFieldPagingPolicy,
184 AnalysisRunDatasetPayload, AnalysisRunDatasetStudyRef, AnalysisRunKind, AnalysisRunOptions,
185 AnalysisRunPrepContext, AnalysisRunResult, AnalysisRuntimeCapabilities,
186 AnalysisRuntimePhysicsProfileCatalogEntry, AnalysisRuntimePhysicsProfileDefaultOutput,
187 AnalysisStudyAuthoringData, AnalysisStudyAuthoringEvidence, AnalysisStudyAuthoringIntent,
188 AnalysisStudyDiagramObservation, AnalysisStudyIssue, AnalysisStudyPlanData,
189 AnalysisStudyRunData, AnalysisStudySpec, AnalysisStudySweepData,
190 AnalysisStudySweepFailureEntry, AnalysisStudySweepPlanData, AnalysisStudySweepPlanEntry,
191 AnalysisStudySweepRunEntry, AnalysisStudySweepSpec, AnalysisStudySweepValidateData,
192 AnalysisStudySweepValidateEntry, AnalysisStudyValidateResult, AnalysisThermalRunOptions,
193 AnalysisTransientRunOptions, AnalysisTrendKindSummary, AnalysisTrendsData, AnalysisTrendsQuery,
194 AnalysisValidateResult, ContactInterfaceOptions, ElectroRegionConductivityScale,
195 ElectroThermalCouplingOptions, ElectroTimeProfilePoint, ElectromagneticResultsData,
196 ModalFrequencyBasis, ModalFrequencyUnits, ModalResultsData, NonlinearMethod,
197 NonlinearResultsData, PlasticityConstitutiveOptions, PrecisionMode, PreconditionerMode,
198 PrepCalibrationProfile, QualityGate, QualityPolicy, QualityReason, QualityReasonCode,
199 RunProvenance, RunStatus, ThermalResultsData, ThermoFieldInterpolationMode, ThermoFieldSource,
200 ThermoMechanicalCouplingOptions, ThermoRegionTemperatureDelta, ThermoTimeProfilePoint,
201 TransientIntegrationMethod, TransientResultsData, ANALYSIS_ARTIFACT_MANIFEST_KIND,
202 ANALYSIS_DATASET_ARTIFACT_KIND, ANALYSIS_DIAGNOSTICS_ARTIFACT_KIND,
203 ANALYSIS_DIAGNOSTICS_SCHEMA_VERSION, ANALYSIS_FIELD_DEFAULT_MATERIALIZE_LIMIT,
204 ANALYSIS_FIELD_DEFAULT_PAGE_SIZE, ANALYSIS_FIELD_DESCRIPTORS_ARTIFACT_KIND,
205 ANALYSIS_FIELD_DESCRIPTORS_SCHEMA_VERSION, ANALYSIS_OBJECT_ARTIFACT_METADATA_SCHEMA_VERSION,
206 ANALYSIS_RUN_DATASET_KIND, ANALYSIS_RUN_DATASET_SCHEMA_VERSION,
207};
208pub use fea_document::{
209 is_fea_file_path, load_fea_document_from_path_async, parse_and_resolve_fea_document,
210 FeaResolvedDocument,
211};
212pub use fea_document_authoring::{
213 apply_fea_study_document_operation, apply_fea_study_document_operation_typed,
214 summarize_fea_study_document, FeaStudyDocumentOperation, FeaStudyDocumentOperationOutput,
215 FEA_STUDY_DOCUMENT_OPERATION_NAMES,
216};
217#[cfg(feature = "plot-core")]
218pub use figures::{
219 analysis_generate_study_run_figures, AnalysisFigureGenerationOptions, AnalysisFigureMeshSource,
220 AnalysisGeneratedFigure, AnalysisGeneratedFigureKind,
221};
222pub use runmat_analysis_fea::{FeaProgressEvent, FeaProgressPhase, FeaProgressStatus};
223pub use study_authoring::analysis_author_study_op;
224
225const ANALYSIS_CREATE_MODEL_OPERATION: &str = "fea.create_model";
226const ANALYSIS_CREATE_MODEL_OP_VERSION: &str = "fea.create_model/v1";
227const ANALYSIS_AUTHOR_STUDY_OPERATION: &str = "fea.author_study";
228const ANALYSIS_AUTHOR_STUDY_OP_VERSION: &str = "fea.author_study/v1";
229const ANALYSIS_VALIDATE_STUDY_OPERATION: &str = "fea.validate_study";
230const ANALYSIS_VALIDATE_STUDY_OP_VERSION: &str = "fea.validate_study/v1";
231const ANALYSIS_PLAN_STUDY_OPERATION: &str = "fea.plan_study";
232const ANALYSIS_PLAN_STUDY_OP_VERSION: &str = "fea.plan_study/v1";
233const ANALYSIS_PLAN_STUDY_SWEEP_OPERATION: &str = "fea.plan_study_sweep";
234const ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION: &str = "fea.plan_study_sweep/v1";
235const ANALYSIS_RUN_STUDY_OPERATION: &str = "fea.run_study";
236const ANALYSIS_RUN_STUDY_OP_VERSION: &str = "fea.run_study/v1";
237const ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION: &str = "fea.validate_study_sweep";
238const ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION: &str = "fea.validate_study_sweep/v1";
239const ANALYSIS_RUN_STUDY_SWEEP_OPERATION: &str = "fea.run_study_sweep";
240const ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION: &str = "fea.run_study_sweep/v1";
241const ANALYSIS_VALIDATE_OPERATION: &str = "fea.validate";
242const ANALYSIS_VALIDATE_OP_VERSION: &str = "fea.validate/v1";
243const ANALYSIS_RUN_OPERATION: &str = "fea.run_linear_static";
244const ANALYSIS_RUN_OP_VERSION: &str = "fea.run_linear_static/v1";
245const ANALYSIS_RUN_MODAL_OPERATION: &str = "fea.run_modal";
246const ANALYSIS_RUN_MODAL_OP_VERSION: &str = "fea.run_modal/v1";
247const ANALYSIS_RUN_ACOUSTIC_OPERATION: &str = "fea.run_acoustic";
248const ANALYSIS_RUN_ACOUSTIC_OP_VERSION: &str = "fea.run_acoustic/v1";
249const ANALYSIS_RUN_TRANSIENT_OPERATION: &str = "fea.run_transient";
250const ANALYSIS_RUN_TRANSIENT_OP_VERSION: &str = "fea.run_transient/v1";
251const ANALYSIS_RUN_THERMAL_OPERATION: &str = "fea.run_thermal";
252const ANALYSIS_RUN_THERMAL_OP_VERSION: &str = "fea.run_thermal/v1";
253const ANALYSIS_RUN_NONLINEAR_OPERATION: &str = "fea.run_nonlinear";
254const ANALYSIS_RUN_NONLINEAR_OP_VERSION: &str = "fea.run_nonlinear/v1";
255const ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION: &str = "fea.run_electromagnetic";
256const ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION: &str = "fea.run_electromagnetic/v1";
257const ANALYSIS_RUN_CFD_OPERATION: &str = "fea.run_cfd";
258const ANALYSIS_RUN_CFD_OP_VERSION: &str = "fea.run_cfd/v1";
259const ANALYSIS_RUN_CHT_OPERATION: &str = "fea.run_cht";
260const ANALYSIS_RUN_CHT_OP_VERSION: &str = "fea.run_cht/v1";
261const ANALYSIS_RUN_FSI_OPERATION: &str = "fea.run_fsi";
262const ANALYSIS_RUN_FSI_OP_VERSION: &str = "fea.run_fsi/v1";
263const ANALYSIS_RESULTS_OPERATION: &str = "fea.results";
264const ANALYSIS_RESULTS_OP_VERSION: &str = "fea.results/v1";
265const ANALYSIS_RESULTS_COMPARE_OPERATION: &str = "fea.results_compare";
266const ANALYSIS_RESULTS_COMPARE_OP_VERSION: &str = "fea.results_compare/v1";
267const ANALYSIS_TRENDS_OPERATION: &str = "fea.trends";
268const ANALYSIS_TRENDS_OP_VERSION: &str = "fea.trends/v1";
269const TRANSIENT_RESIDUAL_WARN_THRESHOLD: f64 = 1.0e-4;
270
271fn map_fea_run_error(
272 operation: &str,
273 op_version: &str,
274 default_error_code: &'static str,
275 cancel_error_code: &'static str,
276 model: &AnalysisModel,
277 context: &OperationContext,
278 err: FeaRunError,
279) -> OperationErrorEnvelope {
280 match err {
281 FeaRunError::Cancelled => operation_error(
282 operation,
283 op_version,
284 context,
285 OperationErrorSpec {
286 error_code: cancel_error_code,
287 error_type: OperationErrorType::Cancelled,
288 retryable: false,
289 severity: OperationErrorSeverity::Warning,
290 },
291 "FEA run cancelled by user",
292 BTreeMap::from([
293 ("analysis_model_id".to_string(), model.model_id.0.clone()),
294 ("geometry_id".to_string(), model.geometry_id.clone()),
295 ]),
296 ),
297 FeaRunError::InvalidModel(message) => operation_error(
298 operation,
299 op_version,
300 context,
301 OperationErrorSpec {
302 error_code: default_error_code,
303 error_type: OperationErrorType::Validation,
304 retryable: false,
305 severity: OperationErrorSeverity::Error,
306 },
307 message,
308 BTreeMap::from([
309 ("analysis_model_id".to_string(), model.model_id.0.clone()),
310 ("geometry_id".to_string(), model.geometry_id.clone()),
311 ]),
312 ),
313 FeaRunError::Assembly(message) => operation_error(
314 operation,
315 op_version,
316 context,
317 OperationErrorSpec {
318 error_code: "RM.FEA.RUN_LINEAR_STATIC.ASSEMBLY_FAILED",
319 error_type: OperationErrorType::Validation,
320 retryable: false,
321 severity: OperationErrorSeverity::Error,
322 },
323 message,
324 BTreeMap::from([
325 ("analysis_model_id".to_string(), model.model_id.0.clone()),
326 ("geometry_id".to_string(), model.geometry_id.clone()),
327 ]),
328 ),
329 }
330}
331
332fn reject_moment_loads_for_run_family(
333 model: &AnalysisModel,
334 operation: &'static str,
335 op_version: &'static str,
336 error_code: &'static str,
337 family: &'static str,
338 context: &OperationContext,
339) -> Result<(), OperationErrorEnvelope> {
340 if let Some(load) = model
341 .loads
342 .iter()
343 .find(|load| matches!(load.kind, LoadKind::Moment { .. } | LoadKind::Wrench { .. }))
344 {
345 let load_kind = match load.kind {
346 LoadKind::Moment { .. } => "moment",
347 LoadKind::Wrench { .. } => "wrench",
348 _ => "structural",
349 };
350 return Err(operation_error(
351 operation,
352 op_version,
353 context,
354 OperationErrorSpec {
355 error_code,
356 error_type: OperationErrorType::Validation,
357 retryable: false,
358 severity: OperationErrorSeverity::Error,
359 },
360 format!("{load_kind} loads are structural loads and cannot be used as {family} loads"),
361 BTreeMap::from([
362 ("analysis_model_id".to_string(), model.model_id.0.clone()),
363 ("load_id".to_string(), load.load_id.clone()),
364 ("region_id".to_string(), load.region_id.clone()),
365 ]),
366 ));
367 }
368 Ok(())
369}
370
371fn persist_fea_run_result_with_progress(
372 operation: &str,
373 op_version: &str,
374 artifact_error_code: &'static str,
375 context: &OperationContext,
376 result: &AnalysisRunResult,
377) -> Result<(), OperationErrorEnvelope> {
378 runmat_analysis_fea::emit_fea_progress_phase(
379 operation,
380 FeaProgressPhase::ArtifactPersistence,
381 FeaProgressStatus::Started,
382 "persisting FEA run artifact",
383 None,
384 None,
385 );
386 match storage::persist_run_result(result) {
387 Ok(_record) => {
388 runmat_analysis_fea::emit_fea_progress_phase(
389 operation,
390 FeaProgressPhase::ArtifactPersistence,
391 FeaProgressStatus::Completed,
392 "FEA run artifact persisted",
393 None,
394 None,
395 );
396 Ok(())
397 }
398 Err(err) => {
399 let message = format!("failed to persist FEA run artifact: {err}");
400 runmat_analysis_fea::emit_fea_progress_phase(
401 operation,
402 FeaProgressPhase::ArtifactPersistence,
403 FeaProgressStatus::Failed,
404 &message,
405 None,
406 None,
407 );
408 Err(operation_error(
409 operation,
410 op_version,
411 context,
412 OperationErrorSpec {
413 error_code: artifact_error_code,
414 error_type: OperationErrorType::Internal,
415 retryable: true,
416 severity: OperationErrorSeverity::Error,
417 },
418 message,
419 BTreeMap::from([("run_id".to_string(), result.run_id.clone())]),
420 ))
421 }
422 }
423}
424
425pub fn analysis_create_model_op(
426 geometry: &GeometryAsset,
427 intent: AnalysisCreateModelIntentSpec,
428 context: OperationContext,
429) -> Result<OperationEnvelope<AnalysisModel>, OperationErrorEnvelope> {
430 if intent.model_id.trim().is_empty() {
431 return Err(operation_error(
432 ANALYSIS_CREATE_MODEL_OPERATION,
433 ANALYSIS_CREATE_MODEL_OP_VERSION,
434 &context,
435 OperationErrorSpec {
436 error_code: "RM.FEA.CREATE_MODEL.INVALID_INTENT",
437 error_type: OperationErrorType::Input,
438 retryable: false,
439 severity: OperationErrorSeverity::Error,
440 },
441 "FEA model intent requires a non-empty model_id",
442 BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
443 ));
444 }
445
446 if geometry.meshes.is_empty() {
447 return Err(operation_error(
448 ANALYSIS_CREATE_MODEL_OPERATION,
449 ANALYSIS_CREATE_MODEL_OP_VERSION,
450 &context,
451 OperationErrorSpec {
452 error_code: "RM.FEA.CREATE_MODEL.GEOMETRY_EMPTY",
453 error_type: OperationErrorType::Validation,
454 retryable: false,
455 severity: OperationErrorSeverity::Error,
456 },
457 "geometry must contain at least one mesh to create an FEA model",
458 BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
459 ));
460 }
461
462 if geometry.units == UnitSystem::Unspecified {
463 return Err(operation_error(
464 ANALYSIS_CREATE_MODEL_OPERATION,
465 ANALYSIS_CREATE_MODEL_OP_VERSION,
466 &context,
467 OperationErrorSpec {
468 error_code: "RM.FEA.CREATE_MODEL.UNIT_UNSPECIFIED",
469 error_type: OperationErrorType::Validation,
470 retryable: false,
471 severity: OperationErrorSeverity::Error,
472 },
473 "geometry units must be specified before creating an FEA model",
474 BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
475 ));
476 }
477
478 let prep_mapped_region_ids = if let Some(prep) = intent.prep_context.as_ref() {
479 if prep.source_geometry_id != geometry.geometry_id
480 || prep.source_geometry_revision != geometry.revision
481 {
482 return Err(operation_error(
483 ANALYSIS_CREATE_MODEL_OPERATION,
484 ANALYSIS_CREATE_MODEL_OP_VERSION,
485 &context,
486 OperationErrorSpec {
487 error_code: "RM.FEA.CREATE_MODEL.PREP_MISMATCH",
488 error_type: OperationErrorType::Input,
489 retryable: false,
490 severity: OperationErrorSeverity::Error,
491 },
492 "FEA model prep context does not match geometry id/revision",
493 BTreeMap::from([
494 ("geometry_id".to_string(), geometry.geometry_id.clone()),
495 (
496 "geometry_revision".to_string(),
497 geometry.revision.to_string(),
498 ),
499 (
500 "prep_geometry_id".to_string(),
501 prep.source_geometry_id.clone(),
502 ),
503 (
504 "prep_geometry_revision".to_string(),
505 prep.source_geometry_revision.to_string(),
506 ),
507 ]),
508 ));
509 }
510
511 let mesh_id_set = geometry
512 .meshes
513 .iter()
514 .map(|mesh| mesh.mesh_id.as_str())
515 .collect::<HashSet<_>>();
516 let region_id_set = geometry
517 .regions
518 .iter()
519 .map(|region| region.region_id.as_str())
520 .collect::<HashSet<_>>();
521 for mapping in &prep.region_mappings {
522 if !region_id_set.is_empty() && !region_id_set.contains(mapping.region_id.as_str()) {
523 return Err(operation_error(
524 ANALYSIS_CREATE_MODEL_OPERATION,
525 ANALYSIS_CREATE_MODEL_OP_VERSION,
526 &context,
527 OperationErrorSpec {
528 error_code: "RM.FEA.CREATE_MODEL.PREP_REGION_NOT_FOUND",
529 error_type: OperationErrorType::Validation,
530 retryable: false,
531 severity: OperationErrorSeverity::Error,
532 },
533 format!(
534 "prep context region '{}' is not present in geometry regions",
535 mapping.region_id
536 ),
537 BTreeMap::from([("region_id".to_string(), mapping.region_id.clone())]),
538 ));
539 }
540 if mapping.source_mesh_ids.is_empty() || mapping.prepared_mesh_ids.is_empty() {
541 return Err(operation_error(
542 ANALYSIS_CREATE_MODEL_OPERATION,
543 ANALYSIS_CREATE_MODEL_OP_VERSION,
544 &context,
545 OperationErrorSpec {
546 error_code: "RM.FEA.CREATE_MODEL.PREP_INVALID_MAPPING",
547 error_type: OperationErrorType::Input,
548 retryable: false,
549 severity: OperationErrorSeverity::Error,
550 },
551 "prep context mapping requires non-empty source/prepared mesh ids",
552 BTreeMap::from([("region_id".to_string(), mapping.region_id.clone())]),
553 ));
554 }
555 for source_mesh_id in &mapping.source_mesh_ids {
556 if !mesh_id_set.contains(source_mesh_id.as_str()) {
557 return Err(operation_error(
558 ANALYSIS_CREATE_MODEL_OPERATION,
559 ANALYSIS_CREATE_MODEL_OP_VERSION,
560 &context,
561 OperationErrorSpec {
562 error_code: "RM.FEA.CREATE_MODEL.PREP_MESH_NOT_FOUND",
563 error_type: OperationErrorType::Validation,
564 retryable: false,
565 severity: OperationErrorSeverity::Error,
566 },
567 format!(
568 "prep context source mesh '{}' is not present in geometry",
569 source_mesh_id
570 ),
571 BTreeMap::from([("source_mesh_id".to_string(), source_mesh_id.clone())]),
572 ));
573 }
574 }
575 }
576
577 Some(
578 prep.region_mappings
579 .iter()
580 .map(|mapping| mapping.region_id.clone())
581 .collect::<HashSet<_>>(),
582 )
583 } else {
584 None
585 };
586
587 let fixed_region_id = select_fixed_region_id(geometry, prep_mapped_region_ids.as_ref())
588 .or_else(|| {
589 geometry
590 .regions
591 .first()
592 .map(|region| region.region_id.clone())
593 })
594 .unwrap_or_else(|| "region_default".to_string());
595 let load_region_id = select_load_region_id(geometry, prep_mapped_region_ids.as_ref())
596 .or_else(|| {
597 geometry
598 .regions
599 .last()
600 .map(|region| region.region_id.clone())
601 })
602 .unwrap_or_else(|| fixed_region_id.clone());
603
604 let mut inferred_materials = infer_material_models(geometry);
605 if matches!(intent.profile, AnalysisCreateModelProfile::AcousticHarmonic) {
606 for material in &mut inferred_materials {
607 material.acoustic = Some(runmat_analysis_core::MaterialAcousticModel::default());
608 }
609 }
610 if matches!(
611 intent.profile,
612 AnalysisCreateModelProfile::ElectromagneticStatic
613 | AnalysisCreateModelProfile::ElectroThermalCoupled
614 ) {
615 for material in &mut inferred_materials {
616 material.electrical = Some(runmat_analysis_core::MaterialElectricalModel::default());
617 }
618 }
619 let inferred_assignments = infer_material_assignments(
620 geometry,
621 &inferred_materials,
622 prep_mapped_region_ids.as_ref(),
623 );
624
625 let (default_bc, default_load, default_steps) = match intent.profile {
626 AnalysisCreateModelProfile::LinearStaticStructural => (
627 BoundaryCondition {
628 bc_id: "bc_default_fixed".to_string(),
629 region_id: fixed_region_id,
630 kind: BoundaryConditionKind::Fixed,
631 },
632 LoadCase {
633 load_id: "load_default_force".to_string(),
634 region_id: load_region_id,
635 kind: LoadKind::Force {
636 fx: 0.0,
637 fy: -1000.0,
638 fz: 0.0,
639 },
640 },
641 vec![AnalysisStep {
642 step_id: "step_default_static".to_string(),
643 kind: AnalysisStepKind::Static,
644 }],
645 ),
646 AnalysisCreateModelProfile::ThermoMechanicalCoupled => (
647 BoundaryCondition {
648 bc_id: "bc_default_fixed".to_string(),
649 region_id: fixed_region_id,
650 kind: BoundaryConditionKind::Fixed,
651 },
652 LoadCase {
653 load_id: "load_default_thermal_mech_force".to_string(),
654 region_id: load_region_id,
655 kind: LoadKind::Force {
656 fx: 0.0,
657 fy: -650.0,
658 fz: 0.0,
659 },
660 },
661 vec![AnalysisStep {
662 step_id: "step_default_thermo_mech".to_string(),
663 kind: AnalysisStepKind::Transient,
664 }],
665 ),
666 AnalysisCreateModelProfile::ElectroThermalCoupled => (
667 BoundaryCondition {
668 bc_id: "bc_default_electro_thermal_ground".to_string(),
669 region_id: fixed_region_id,
670 kind: BoundaryConditionKind::VectorPotentialGround,
671 },
672 LoadCase {
673 load_id: "load_default_electro_thermal_current".to_string(),
674 region_id: load_region_id,
675 kind: LoadKind::CoilCurrent {
676 current_a: 50.0,
677 phase_rad: 0.0,
678 amplitude_scale: 1.0,
679 },
680 },
681 vec![AnalysisStep {
682 step_id: "step_default_electro_thermal".to_string(),
683 kind: AnalysisStepKind::Transient,
684 }],
685 ),
686 AnalysisCreateModelProfile::ThermalStandalone => (
687 BoundaryCondition {
688 bc_id: "bc_default_fixed".to_string(),
689 region_id: fixed_region_id,
690 kind: BoundaryConditionKind::Fixed,
691 },
692 LoadCase {
693 load_id: "load_default_thermal_seed".to_string(),
694 region_id: load_region_id,
695 kind: LoadKind::BodyForce {
696 gx: 0.0,
697 gy: 0.0,
698 gz: 0.0,
699 },
700 },
701 vec![AnalysisStep {
702 step_id: "step_default_thermal".to_string(),
703 kind: AnalysisStepKind::Thermal,
704 }],
705 ),
706 AnalysisCreateModelProfile::ModalStructural => (
707 BoundaryCondition {
708 bc_id: "bc_default_fixed".to_string(),
709 region_id: fixed_region_id,
710 kind: BoundaryConditionKind::Fixed,
711 },
712 LoadCase {
713 load_id: "load_default_modal_seed".to_string(),
714 region_id: load_region_id,
715 kind: LoadKind::BodyForce {
716 gx: 0.0,
717 gy: 0.0,
718 gz: 0.0,
719 },
720 },
721 vec![AnalysisStep {
722 step_id: "step_default_modal".to_string(),
723 kind: AnalysisStepKind::Modal,
724 }],
725 ),
726 AnalysisCreateModelProfile::AcousticHarmonic => (
727 BoundaryCondition {
728 bc_id: "bc_default_acoustic_rigid_wall".to_string(),
729 region_id: fixed_region_id,
730 kind: BoundaryConditionKind::AcousticRigidWall,
731 },
732 LoadCase {
733 load_id: "load_default_acoustic_harmonic_seed".to_string(),
734 region_id: load_region_id,
735 kind: LoadKind::Pressure { magnitude_pa: 1.0 },
736 },
737 vec![AnalysisStep {
738 step_id: "step_default_acoustic_harmonic".to_string(),
739 kind: AnalysisStepKind::Modal,
740 }],
741 ),
742 AnalysisCreateModelProfile::TransientStructural => (
743 BoundaryCondition {
744 bc_id: "bc_default_fixed".to_string(),
745 region_id: fixed_region_id,
746 kind: BoundaryConditionKind::Fixed,
747 },
748 LoadCase {
749 load_id: "load_default_transient_force".to_string(),
750 region_id: load_region_id,
751 kind: LoadKind::Force {
752 fx: 0.0,
753 fy: -500.0,
754 fz: 0.0,
755 },
756 },
757 vec![AnalysisStep {
758 step_id: "step_default_transient".to_string(),
759 kind: AnalysisStepKind::Transient,
760 }],
761 ),
762 AnalysisCreateModelProfile::NonlinearStructural => (
763 BoundaryCondition {
764 bc_id: "bc_default_fixed".to_string(),
765 region_id: fixed_region_id,
766 kind: BoundaryConditionKind::Fixed,
767 },
768 LoadCase {
769 load_id: "load_default_nonlinear_force".to_string(),
770 region_id: load_region_id,
771 kind: LoadKind::Force {
772 fx: 0.0,
773 fy: -750.0,
774 fz: 0.0,
775 },
776 },
777 vec![AnalysisStep {
778 step_id: "step_default_nonlinear".to_string(),
779 kind: AnalysisStepKind::Nonlinear,
780 }],
781 ),
782 AnalysisCreateModelProfile::ElectromagneticStatic => (
783 BoundaryCondition {
784 bc_id: "bc_default_em_ground".to_string(),
785 region_id: fixed_region_id,
786 kind: BoundaryConditionKind::VectorPotentialGround,
787 },
788 LoadCase {
789 load_id: "load_default_em_coil_current".to_string(),
790 region_id: load_region_id,
791 kind: LoadKind::CoilCurrent {
792 current_a: 100.0,
793 phase_rad: 0.0,
794 amplitude_scale: 1.0,
795 },
796 },
797 vec![AnalysisStep {
798 step_id: "step_default_electromagnetic".to_string(),
799 kind: AnalysisStepKind::Electromagnetic,
800 }],
801 ),
802 AnalysisCreateModelProfile::CfdSteadyState => (
803 BoundaryCondition {
804 bc_id: "bc_default_fixed".to_string(),
805 region_id: fixed_region_id,
806 kind: BoundaryConditionKind::Fixed,
807 },
808 LoadCase {
809 load_id: "load_default_cfd_seed".to_string(),
810 region_id: load_region_id,
811 kind: LoadKind::BodyForce {
812 gx: 0.0,
813 gy: 0.0,
814 gz: 0.0,
815 },
816 },
817 vec![AnalysisStep {
818 step_id: "step_default_cfd".to_string(),
819 kind: AnalysisStepKind::Cfd,
820 }],
821 ),
822 AnalysisCreateModelProfile::CfdTransient => (
823 BoundaryCondition {
824 bc_id: "bc_default_fixed".to_string(),
825 region_id: fixed_region_id,
826 kind: BoundaryConditionKind::Fixed,
827 },
828 LoadCase {
829 load_id: "load_default_cfd_transient_seed".to_string(),
830 region_id: load_region_id,
831 kind: LoadKind::BodyForce {
832 gx: 0.0,
833 gy: 0.0,
834 gz: 0.0,
835 },
836 },
837 vec![AnalysisStep {
838 step_id: "step_default_cfd_transient".to_string(),
839 kind: AnalysisStepKind::Cfd,
840 }],
841 ),
842 AnalysisCreateModelProfile::ChtCoupled => (
843 BoundaryCondition {
844 bc_id: "bc_default_fixed".to_string(),
845 region_id: fixed_region_id,
846 kind: BoundaryConditionKind::Fixed,
847 },
848 LoadCase {
849 load_id: "load_default_cht_seed".to_string(),
850 region_id: load_region_id,
851 kind: LoadKind::BodyForce {
852 gx: 0.0,
853 gy: 0.0,
854 gz: 0.0,
855 },
856 },
857 vec![
858 AnalysisStep {
859 step_id: "step_default_cht_flow".to_string(),
860 kind: AnalysisStepKind::Cfd,
861 },
862 AnalysisStep {
863 step_id: "step_default_cht_thermal".to_string(),
864 kind: AnalysisStepKind::Thermal,
865 },
866 ],
867 ),
868 AnalysisCreateModelProfile::FsiCoupled => (
869 BoundaryCondition {
870 bc_id: "bc_default_fixed".to_string(),
871 region_id: fixed_region_id,
872 kind: BoundaryConditionKind::Fixed,
873 },
874 LoadCase {
875 load_id: "load_default_fsi_seed".to_string(),
876 region_id: load_region_id,
877 kind: LoadKind::Force {
878 fx: 0.0,
879 fy: -450.0,
880 fz: 0.0,
881 },
882 },
883 vec![
884 AnalysisStep {
885 step_id: "step_default_fsi_structure".to_string(),
886 kind: AnalysisStepKind::Transient,
887 },
888 AnalysisStep {
889 step_id: "step_default_fsi_flow".to_string(),
890 kind: AnalysisStepKind::Cfd,
891 },
892 ],
893 ),
894 };
895
896 let cfd = match intent.profile {
897 AnalysisCreateModelProfile::CfdSteadyState => Some(runmat_analysis_core::CfdDomain {
898 enabled: true,
899 solve_family: runmat_analysis_core::CfdSolveFamily::SteadyState,
900 reference_density_kg_per_m3: 1.225,
901 dynamic_viscosity_pa_s: 1.81e-5,
902 inlet_velocity_m_per_s: 5.0,
903 turbulence_intensity: 0.05,
904 time_profile: Vec::new(),
905 }),
906 AnalysisCreateModelProfile::CfdTransient => Some(runmat_analysis_core::CfdDomain {
907 enabled: true,
908 solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
909 reference_density_kg_per_m3: 1.225,
910 dynamic_viscosity_pa_s: 1.81e-5,
911 inlet_velocity_m_per_s: 5.0,
912 turbulence_intensity: 0.08,
913 time_profile: vec![
914 runmat_analysis_core::CfdTimeProfilePoint {
915 normalized_time: 0.0,
916 inlet_scale: 0.5,
917 },
918 runmat_analysis_core::CfdTimeProfilePoint {
919 normalized_time: 1.0,
920 inlet_scale: 1.0,
921 },
922 ],
923 }),
924 AnalysisCreateModelProfile::ChtCoupled => Some(runmat_analysis_core::CfdDomain {
925 enabled: true,
926 solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
927 reference_density_kg_per_m3: 1.225,
928 dynamic_viscosity_pa_s: 1.81e-5,
929 inlet_velocity_m_per_s: 4.5,
930 turbulence_intensity: 0.07,
931 time_profile: vec![
932 runmat_analysis_core::CfdTimeProfilePoint {
933 normalized_time: 0.0,
934 inlet_scale: 0.7,
935 },
936 runmat_analysis_core::CfdTimeProfilePoint {
937 normalized_time: 1.0,
938 inlet_scale: 1.0,
939 },
940 ],
941 }),
942 AnalysisCreateModelProfile::FsiCoupled => Some(runmat_analysis_core::CfdDomain {
943 enabled: true,
944 solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
945 reference_density_kg_per_m3: 1.225,
946 dynamic_viscosity_pa_s: 1.81e-5,
947 inlet_velocity_m_per_s: 4.0,
948 turbulence_intensity: 0.06,
949 time_profile: vec![
950 runmat_analysis_core::CfdTimeProfilePoint {
951 normalized_time: 0.0,
952 inlet_scale: 0.6,
953 },
954 runmat_analysis_core::CfdTimeProfilePoint {
955 normalized_time: 1.0,
956 inlet_scale: 1.0,
957 },
958 ],
959 }),
960 _ => None,
961 };
962 let electromagnetic = match intent.profile {
963 AnalysisCreateModelProfile::ElectromagneticStatic => {
964 Some(runmat_analysis_core::ElectromagneticDomain {
965 enabled: true,
966 reference_frequency_hz: 60.0,
967 applied_current_a: 100.0,
968 })
969 }
970 _ => None,
971 };
972 let thermo_mechanical = match intent.profile {
973 AnalysisCreateModelProfile::ChtCoupled => {
974 Some(runmat_analysis_core::ThermoMechanicalDomain {
975 enabled: true,
976 reference_temperature_k: 293.15,
977 applied_temperature_delta_k: 35.0,
978 field_artifact_id: None,
979 field_source: None,
980 region_temperature_deltas: Vec::new(),
981 time_profile: vec![
982 runmat_analysis_core::ThermoTimeProfilePoint {
983 normalized_time: 0.0,
984 scale: 0.6,
985 },
986 runmat_analysis_core::ThermoTimeProfilePoint {
987 normalized_time: 1.0,
988 scale: 1.0,
989 },
990 ],
991 })
992 }
993 _ => None,
994 };
995 let electro_thermal = match intent.profile {
996 AnalysisCreateModelProfile::ElectroThermalCoupled => {
997 Some(runmat_analysis_core::ElectroThermalDomain {
998 enabled: true,
999 reference_temperature_k: 293.15,
1000 applied_voltage_v: 24.0,
1001 region_conductivity_scales: Vec::new(),
1002 time_profile: vec![
1003 runmat_analysis_core::ElectroTimeProfilePoint {
1004 normalized_time: 0.0,
1005 current_scale: 0.5,
1006 },
1007 runmat_analysis_core::ElectroTimeProfilePoint {
1008 normalized_time: 1.0,
1009 current_scale: 1.0,
1010 },
1011 ],
1012 })
1013 }
1014 _ => None,
1015 };
1016
1017 let mut boundary_conditions = vec![default_bc];
1018 if matches!(
1019 intent.profile,
1020 AnalysisCreateModelProfile::CfdSteadyState
1021 | AnalysisCreateModelProfile::CfdTransient
1022 | AnalysisCreateModelProfile::ChtCoupled
1023 | AnalysisCreateModelProfile::FsiCoupled
1024 ) {
1025 let default_cfd_inlet_velocity = cfd
1026 .as_ref()
1027 .map(|domain| domain.inlet_velocity_m_per_s)
1028 .unwrap_or(0.0);
1029 boundary_conditions.extend([
1030 BoundaryCondition {
1031 bc_id: "bc_default_cfd_inlet".to_string(),
1032 region_id: "inlet".to_string(),
1033 kind: BoundaryConditionKind::CfdInletVelocity {
1034 velocity_m_per_s: default_cfd_inlet_velocity,
1035 },
1036 },
1037 BoundaryCondition {
1038 bc_id: "bc_default_cfd_outlet".to_string(),
1039 region_id: "outlet".to_string(),
1040 kind: BoundaryConditionKind::CfdOutletPressure { pressure_pa: 0.0 },
1041 },
1042 BoundaryCondition {
1043 bc_id: "bc_default_cfd_wall_upper".to_string(),
1044 region_id: "wall_upper".to_string(),
1045 kind: BoundaryConditionKind::CfdNoSlipWall,
1046 },
1047 BoundaryCondition {
1048 bc_id: "bc_default_cfd_wall_lower".to_string(),
1049 region_id: "wall_lower".to_string(),
1050 kind: BoundaryConditionKind::CfdNoSlipWall,
1051 },
1052 ]);
1053 }
1054
1055 let model = AnalysisModel {
1056 model_id: AnalysisModelId(intent.model_id),
1057 geometry_id: geometry.geometry_id.clone(),
1058 geometry_revision: geometry.revision,
1059 units: geometry.units,
1060 frame: ReferenceFrame::Global,
1061 materials: inferred_materials,
1062 material_assignments: inferred_assignments,
1063 structural: None,
1064 thermo_mechanical,
1065 electro_thermal,
1066 electromagnetic,
1067 cfd,
1068 interfaces: Vec::new(),
1069 boundary_conditions,
1070 loads: vec![default_load],
1071 steps: default_steps,
1072 };
1073
1074 validate_model_against_geometry(&model, geometry.units, &ReferenceFrame::Global).map_err(
1075 |error| {
1076 operation_error(
1077 ANALYSIS_CREATE_MODEL_OPERATION,
1078 ANALYSIS_CREATE_MODEL_OP_VERSION,
1079 &context,
1080 OperationErrorSpec {
1081 error_code: "RM.FEA.CREATE_MODEL.INVALID",
1082 error_type: OperationErrorType::Validation,
1083 retryable: false,
1084 severity: OperationErrorSeverity::Error,
1085 },
1086 format!("created FEA model failed validation: {error:?}"),
1087 BTreeMap::from([
1088 ("analysis_model_id".to_string(), model.model_id.0.clone()),
1089 ("geometry_id".to_string(), geometry.geometry_id.clone()),
1090 ]),
1091 )
1092 },
1093 )?;
1094
1095 Ok(OperationEnvelope::new(
1096 ANALYSIS_CREATE_MODEL_OPERATION,
1097 ANALYSIS_CREATE_MODEL_OP_VERSION,
1098 &context,
1099 model,
1100 ))
1101}
1102
1103pub fn analysis_validate_study_op(
1104 spec: &AnalysisStudySpec,
1105 context: OperationContext,
1106) -> Result<OperationEnvelope<AnalysisStudyValidateResult>, OperationErrorEnvelope> {
1107 let issue_codes = validate_study_issue_codes(spec);
1108 let issues: Vec<AnalysisStudyIssue> = issue_codes
1109 .iter()
1110 .map(|code| AnalysisStudyIssue {
1111 code: code.clone(),
1112 message: study_issue_message(code).to_string(),
1113 })
1114 .collect();
1115 let study_fingerprint = study_fingerprint(spec);
1116 let evidence_artifact_path = persist_study_evidence(
1117 &study_fingerprint,
1118 "validate",
1119 serde_json::json!({
1120 "schema_version": "fea_study_validate_artifact/v1",
1121 "study_id": spec.study_id.clone(),
1122 "study_fingerprint": study_fingerprint.clone(),
1123 "valid": issue_codes.is_empty(),
1124 "issue_codes": issue_codes.clone(),
1125 "issues": issues.clone(),
1126 "electromagnetic_run_options": spec.electromagnetic_run_options.clone(),
1127 }),
1128 )
1129 .map_err(|err| {
1130 operation_error(
1131 ANALYSIS_VALIDATE_STUDY_OPERATION,
1132 ANALYSIS_VALIDATE_STUDY_OP_VERSION,
1133 &context,
1134 OperationErrorSpec {
1135 error_code: "RM.FEA.VALIDATE_STUDY.ARTIFACT_STORE_FAILED",
1136 error_type: OperationErrorType::Internal,
1137 retryable: true,
1138 severity: OperationErrorSeverity::Error,
1139 },
1140 format!("failed to persist study validation evidence artifact: {err}"),
1141 BTreeMap::from([("study_id".to_string(), spec.study_id.clone())]),
1142 )
1143 })?;
1144 Ok(OperationEnvelope::new(
1145 ANALYSIS_VALIDATE_STUDY_OPERATION,
1146 ANALYSIS_VALIDATE_STUDY_OP_VERSION,
1147 &context,
1148 AnalysisStudyValidateResult {
1149 valid: issue_codes.is_empty(),
1150 issue_codes,
1151 issues,
1152 evidence_artifact_path,
1153 },
1154 ))
1155}
1156
1157pub fn analysis_plan_study_op(
1158 spec: &AnalysisStudySpec,
1159 context: OperationContext,
1160) -> Result<OperationEnvelope<AnalysisStudyPlanData>, OperationErrorEnvelope> {
1161 let issue_codes = validate_study_issue_codes(spec);
1162 if !issue_codes.is_empty() {
1163 return Err(operation_error(
1164 ANALYSIS_PLAN_STUDY_OPERATION,
1165 ANALYSIS_PLAN_STUDY_OP_VERSION,
1166 &context,
1167 OperationErrorSpec {
1168 error_code: "RM.FEA.PLAN_STUDY.INVALID_SPEC",
1169 error_type: OperationErrorType::Validation,
1170 retryable: false,
1171 severity: OperationErrorSeverity::Error,
1172 },
1173 "study spec is invalid; run fea.validate for issue details",
1174 BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1175 ));
1176 }
1177
1178 let study_fingerprint = study_fingerprint(spec);
1179 let run_operation = run_operation_for_kind(spec.run_kind).to_string();
1180 let run_op_version = run_operation_version_for_kind(spec.run_kind).to_string();
1181 let operation_sequence = study_operation_sequence(spec, &run_op_version);
1182 let evidence_artifact_path = persist_study_evidence(
1183 &study_fingerprint,
1184 "plan",
1185 serde_json::json!({
1186 "schema_version": "fea_study_plan_artifact/v1",
1187 "study_id": spec.study_id.clone(),
1188 "model_id": spec.create_model_intent.model_id.clone(),
1189 "run_kind": spec.run_kind,
1190 "backend": spec.backend,
1191 "run_options": study_run_options_json(spec),
1192 "study_fingerprint": study_fingerprint.clone(),
1193 "operation_sequence": operation_sequence.clone(),
1194 "run_operation": run_operation.clone(),
1195 "run_op_version": run_op_version.clone(),
1196 }),
1197 )
1198 .map_err(|err| {
1199 operation_error(
1200 ANALYSIS_PLAN_STUDY_OPERATION,
1201 ANALYSIS_PLAN_STUDY_OP_VERSION,
1202 &context,
1203 OperationErrorSpec {
1204 error_code: "RM.FEA.PLAN_STUDY.ARTIFACT_STORE_FAILED",
1205 error_type: OperationErrorType::Internal,
1206 retryable: true,
1207 severity: OperationErrorSeverity::Error,
1208 },
1209 format!("failed to persist study plan evidence artifact: {err}"),
1210 BTreeMap::from([("study_id".to_string(), spec.study_id.clone())]),
1211 )
1212 })?;
1213 Ok(OperationEnvelope::new(
1214 ANALYSIS_PLAN_STUDY_OPERATION,
1215 ANALYSIS_PLAN_STUDY_OP_VERSION,
1216 &context,
1217 AnalysisStudyPlanData {
1218 study_id: spec.study_id.clone(),
1219 model_id: spec.create_model_intent.model_id.clone(),
1220 run_kind: spec.run_kind,
1221 backend: spec.backend,
1222 electromagnetic_run_options: spec.electromagnetic_run_options.clone(),
1223 run_options: study_run_options_json(spec),
1224 operation_sequence,
1225 run_operation,
1226 run_op_version,
1227 study_fingerprint,
1228 evidence_artifact_path,
1229 },
1230 ))
1231}
1232
1233pub fn analysis_run_study_op(
1234 spec: &AnalysisStudySpec,
1235 context: OperationContext,
1236) -> Result<OperationEnvelope<AnalysisStudyRunData>, OperationErrorEnvelope> {
1237 let issue_codes = validate_study_issue_codes(spec);
1238 if !issue_codes.is_empty() {
1239 return Err(operation_error(
1240 ANALYSIS_RUN_STUDY_OPERATION,
1241 ANALYSIS_RUN_STUDY_OP_VERSION,
1242 &context,
1243 OperationErrorSpec {
1244 error_code: "RM.FEA.RUN_STUDY.INVALID_SPEC",
1245 error_type: OperationErrorType::Validation,
1246 retryable: false,
1247 severity: OperationErrorSeverity::Error,
1248 },
1249 "study spec is invalid; run fea.validate for issue details",
1250 BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1251 ));
1252 }
1253
1254 let study_fingerprint = study_fingerprint(spec);
1255 let run_operation = run_operation_for_kind(spec.run_kind).to_string();
1256 let run_op_version = run_operation_version_for_kind(spec.run_kind).to_string();
1257 let operation_sequence = study_operation_sequence(spec, &run_op_version);
1258 let analysis_mesh_artifact =
1259 generate_and_persist_study_analysis_mesh(spec, &study_fingerprint, &context)?;
1260 let analysis_mesh_artifact_path = analysis_mesh_artifact
1261 .as_ref()
1262 .map(|artifact| artifact.path.clone());
1263 let analysis_mesh_evidence_artifact_path = analysis_mesh_artifact
1264 .as_ref()
1265 .map(|artifact| artifact.evidence_path.clone());
1266
1267 let study_prep = crate::geometry::geometry_prep_for_analysis_op(
1268 &spec.geometry,
1269 crate::geometry::GeometryPrepForAnalysisSpec::default(),
1270 context.clone(),
1271 )?
1272 .data;
1273 let study_prep_artifact_id = study_prep.prep_artifact_id.clone();
1274 let mut create_model_intent = spec.create_model_intent.clone();
1275 create_model_intent.prep_context = Some(AnalysisCreateModelPrepContext {
1276 source_geometry_id: spec.geometry.geometry_id.clone(),
1277 source_geometry_revision: spec.geometry.revision,
1278 region_mappings: study_prep.prep.region_mappings.clone(),
1279 });
1280
1281 let model = match &spec.model {
1282 Some(model) => model.clone(),
1283 None => {
1284 analysis_create_model_op(&spec.geometry, create_model_intent.clone(), context.clone())?
1285 .data
1286 }
1287 };
1288 analysis_validate(
1289 &model,
1290 spec.geometry.units,
1291 &ReferenceFrame::Global,
1292 context.clone(),
1293 )?;
1294 let (
1295 run_envelope,
1296 resolved_run_options,
1297 resolved_electromagnetic_run_options,
1298 refined_analysis_mesh_artifact,
1299 ) = match spec.run_kind {
1300 AnalysisRunKind::LinearStatic => {
1301 let mut options = spec.linear_static_run_options.clone().unwrap_or_default();
1302 attach_prep_artifact_to_run_options(&mut options, &study_prep_artifact_id);
1303 attach_analysis_mesh_artifact_to_run_options(
1304 &mut options,
1305 analysis_mesh_artifact_path.as_deref(),
1306 );
1307 let initial_run = analysis_run_linear_static_with_options(
1308 &model,
1309 spec.backend,
1310 options.clone(),
1311 context.clone(),
1312 )?;
1313 let refinement_candidate_path = analysis_mesh_artifact
1314 .as_ref()
1315 .filter(|artifact| artifact.allow_refinement)
1316 .map(|artifact| artifact.path.as_str());
1317 let refined_analysis_mesh_artifact = generate_and_persist_refined_study_analysis_mesh(
1318 spec,
1319 &study_fingerprint,
1320 refinement_candidate_path,
1321 &context,
1322 )?;
1323 if let Some(refined_artifact) = refined_analysis_mesh_artifact.as_ref() {
1324 let mut refined_options = options.clone();
1325 refined_options.analysis_mesh_artifact_path = Some(refined_artifact.path.clone());
1326 let refined_run = analysis_run_linear_static_with_options(
1327 &model,
1328 spec.backend,
1329 refined_options.clone(),
1330 context.clone(),
1331 )?;
1332 Ok((
1333 refined_run,
1334 run_options_to_json(&refined_options),
1335 None,
1336 refined_analysis_mesh_artifact,
1337 ))
1338 } else {
1339 Ok((
1340 initial_run,
1341 run_options_to_json(&options),
1342 None,
1343 refined_analysis_mesh_artifact,
1344 ))
1345 }
1346 }
1347 AnalysisRunKind::Modal => {
1348 let mut options = spec.modal_run_options.clone().unwrap_or_default();
1349 attach_prep_artifact_to_modal_options(&mut options, &study_prep_artifact_id);
1350 let run = analysis_run_modal_with_options_op(
1351 &model,
1352 spec.backend,
1353 options.clone(),
1354 context.clone(),
1355 )?;
1356 Ok((run, run_options_to_json(&options), None, None))
1357 }
1358 AnalysisRunKind::Acoustic => {
1359 let mut options = spec.acoustic_run_options.clone().unwrap_or_default();
1360 attach_prep_artifact_to_acoustic_options(&mut options, &study_prep_artifact_id);
1361 let run = analysis_run_acoustic_with_options_op(
1362 &model,
1363 spec.backend,
1364 options.clone(),
1365 context.clone(),
1366 )?;
1367 Ok((run, run_options_to_json(&options), None, None))
1368 }
1369 AnalysisRunKind::Thermal => {
1370 let mut options = spec.thermal_run_options.clone().unwrap_or_default();
1371 attach_prep_artifact_to_thermal_options(&mut options, &study_prep_artifact_id);
1372 let run = analysis_run_thermal_with_options_op(
1373 &model,
1374 spec.backend,
1375 options.clone(),
1376 context.clone(),
1377 )?;
1378 Ok((run, run_options_to_json(&options), None, None))
1379 }
1380 AnalysisRunKind::Transient => {
1381 let mut options = spec.transient_run_options.clone().unwrap_or_default();
1382 attach_prep_artifact_to_transient_options(&mut options, &study_prep_artifact_id);
1383 let run = analysis_run_transient_with_options_op(
1384 &model,
1385 spec.backend,
1386 options.clone(),
1387 context.clone(),
1388 )?;
1389 Ok((run, run_options_to_json(&options), None, None))
1390 }
1391 AnalysisRunKind::Cfd => {
1392 let mut options = spec.cfd_run_options.clone().unwrap_or_default();
1393 attach_prep_artifact_to_cfd_options(&mut options, &study_prep_artifact_id);
1394 let run = analysis_run_cfd_with_options_op(
1395 &model,
1396 spec.backend,
1397 options.clone(),
1398 context.clone(),
1399 )?;
1400 Ok((run, run_options_to_json(&options), None, None))
1401 }
1402 AnalysisRunKind::Cht => {
1403 let mut options = spec.cht_run_options.clone().unwrap_or_default();
1404 attach_prep_artifact_to_cht_options(&mut options, &study_prep_artifact_id);
1405 let run = analysis_run_cht_with_options_op(
1406 &model,
1407 spec.backend,
1408 options.clone(),
1409 context.clone(),
1410 )?;
1411 Ok((run, run_options_to_json(&options), None, None))
1412 }
1413 AnalysisRunKind::Fsi => {
1414 let mut options = spec.fsi_run_options.clone().unwrap_or_default();
1415 attach_prep_artifact_to_fsi_options(&mut options, &study_prep_artifact_id);
1416 let run = analysis_run_fsi_with_options_op(
1417 &model,
1418 spec.backend,
1419 options.clone(),
1420 context.clone(),
1421 )?;
1422 Ok((run, run_options_to_json(&options), None, None))
1423 }
1424 AnalysisRunKind::Nonlinear => {
1425 let mut options = spec.nonlinear_run_options.clone().unwrap_or_default();
1426 attach_prep_artifact_to_nonlinear_options(&mut options, &study_prep_artifact_id);
1427 let run = analysis_run_nonlinear_with_options_op(
1428 &model,
1429 spec.backend,
1430 options.clone(),
1431 context.clone(),
1432 )?;
1433 Ok((run, run_options_to_json(&options), None, None))
1434 }
1435 AnalysisRunKind::Electromagnetic => {
1436 let mut options = spec.electromagnetic_run_options.clone().unwrap_or_default();
1437 attach_prep_artifact_to_electromagnetic_options(&mut options, &study_prep_artifact_id);
1438 let run = analysis_run_electromagnetic_with_options_op(
1439 &model,
1440 spec.backend,
1441 options.clone(),
1442 context.clone(),
1443 )?;
1444 Ok((run, run_options_to_json(&options), Some(options), None))
1445 }
1446 }?;
1447 let refined_analysis_mesh_artifact_path = refined_analysis_mesh_artifact
1448 .as_ref()
1449 .map(|artifact| artifact.path.clone());
1450 let refined_analysis_mesh_evidence_artifact_path = refined_analysis_mesh_artifact
1451 .as_ref()
1452 .map(|artifact| artifact.evidence_path.clone());
1453 let refinement_effect = refined_analysis_mesh_artifact
1454 .as_ref()
1455 .map(|artifact| artifact.refinement_effect.clone());
1456
1457 let evidence_artifact_path = persist_study_evidence(
1458 &study_fingerprint,
1459 "run",
1460 serde_json::json!({
1461 "schema_version": "fea_study_run_artifact/v1",
1462 "study_id": spec.study_id.clone(),
1463 "model_id": model.model_id.0.clone(),
1464 "model_profile": spec.create_model_intent.profile,
1465 "run_kind": spec.run_kind,
1466 "backend": spec.backend,
1467 "prep_artifact_id": study_prep_artifact_id.clone(),
1468 "analysis_mesh_artifact_path": analysis_mesh_artifact_path.clone(),
1469 "analysis_mesh_evidence_artifact_path": analysis_mesh_evidence_artifact_path.clone(),
1470 "refined_analysis_mesh_artifact_path": refined_analysis_mesh_artifact_path.clone(),
1471 "refined_analysis_mesh_evidence_artifact_path": refined_analysis_mesh_evidence_artifact_path.clone(),
1472 "refinement_effect": refinement_effect,
1473 "run_options": resolved_run_options.clone(),
1474 "resolved_electromagnetic_run_options": resolved_electromagnetic_run_options.clone(),
1475 "study_fingerprint": study_fingerprint.clone(),
1476 "operation_sequence": operation_sequence.clone(),
1477 "run_operation": run_operation.clone(),
1478 "run_op_version": run_op_version.clone(),
1479 "run_id": run_envelope.data.run_id.clone(),
1480 "run_status": run_envelope.data.run_status,
1481 "publishable": run_envelope.data.publishable,
1482 "solver_convergence": run_envelope.data.solver_convergence,
1483 "result_quality": run_envelope.data.result_quality,
1484 "quality_reasons": run_envelope.data.quality_reasons.clone(),
1485 "provenance": run_envelope.data.provenance.clone(),
1486 }),
1487 )
1488 .map_err(|err| {
1489 operation_error(
1490 ANALYSIS_RUN_STUDY_OPERATION,
1491 ANALYSIS_RUN_STUDY_OP_VERSION,
1492 &context,
1493 OperationErrorSpec {
1494 error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
1495 error_type: OperationErrorType::Internal,
1496 retryable: true,
1497 severity: OperationErrorSeverity::Error,
1498 },
1499 format!("failed to persist study run evidence artifact: {err}"),
1500 BTreeMap::from([
1501 ("study_id".to_string(), spec.study_id.clone()),
1502 ("run_id".to_string(), run_envelope.data.run_id.clone()),
1503 ]),
1504 )
1505 })?;
1506
1507 Ok(OperationEnvelope::new(
1508 ANALYSIS_RUN_STUDY_OPERATION,
1509 ANALYSIS_RUN_STUDY_OP_VERSION,
1510 &context,
1511 AnalysisStudyRunData {
1512 study_id: spec.study_id.clone(),
1513 model_id: model.model_id.0.clone(),
1514 model_profile: spec.create_model_intent.profile,
1515 run_kind: spec.run_kind,
1516 backend: spec.backend,
1517 electromagnetic_run_options: resolved_electromagnetic_run_options,
1518 prep_artifact_id: Some(study_prep_artifact_id),
1519 analysis_mesh_artifact_path,
1520 analysis_mesh_evidence_artifact_path,
1521 refined_analysis_mesh_artifact_path,
1522 refined_analysis_mesh_evidence_artifact_path,
1523 run_options: resolved_run_options,
1524 study_fingerprint,
1525 operation_sequence,
1526 run_operation,
1527 run_op_version,
1528 run_id: run_envelope.data.run_id,
1529 run_status: run_envelope.data.run_status,
1530 publishable: run_envelope.data.publishable,
1531 solver_convergence: run_envelope.data.solver_convergence,
1532 result_quality: run_envelope.data.result_quality,
1533 quality_reasons: run_envelope.data.quality_reasons,
1534 provenance: run_envelope.data.provenance,
1535 evidence_artifact_path,
1536 },
1537 ))
1538}
1539
1540pub fn analysis_plan_study_sweep_op(
1541 spec: &AnalysisStudySweepSpec,
1542 context: OperationContext,
1543) -> Result<OperationEnvelope<AnalysisStudySweepPlanData>, OperationErrorEnvelope> {
1544 let mut issue_codes = Vec::new();
1545 if spec.sweep_id.trim().is_empty() {
1546 issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1547 }
1548 if spec.studies.is_empty() {
1549 issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1550 }
1551 if !issue_codes.is_empty() {
1552 return Err(operation_error(
1553 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1554 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1555 &context,
1556 OperationErrorSpec {
1557 error_code: "RM.FEA.PLAN_STUDY_SWEEP.INVALID_SPEC",
1558 error_type: OperationErrorType::Validation,
1559 retryable: false,
1560 severity: OperationErrorSeverity::Error,
1561 },
1562 "study sweep spec is invalid",
1563 BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1564 ));
1565 }
1566
1567 let mut plan_entries = Vec::with_capacity(spec.studies.len());
1568 let mut failure_entries = Vec::new();
1569 for (index, study) in spec.studies.iter().enumerate() {
1570 let planned = match analysis_plan_study_op(study, context.clone()) {
1571 Ok(plan) => plan,
1572 Err(err) => {
1573 if spec.fail_fast {
1574 return Err(operation_error(
1575 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1576 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1577 &context,
1578 OperationErrorSpec {
1579 error_code: "RM.FEA.PLAN_STUDY_SWEEP.STUDY_FAILED",
1580 error_type: OperationErrorType::Validation,
1581 retryable: false,
1582 severity: OperationErrorSeverity::Error,
1583 },
1584 format!(
1585 "study sweep planning failed at index {} for study_id {}: {}",
1586 index, study.study_id, err.error_code
1587 ),
1588 BTreeMap::from([
1589 ("sweep_id".to_string(), spec.sweep_id.clone()),
1590 ("study_id".to_string(), study.study_id.clone()),
1591 ("study_index".to_string(), index.to_string()),
1592 ("cause_error_code".to_string(), err.error_code),
1593 ]),
1594 ));
1595 }
1596 failure_entries.push(AnalysisStudySweepFailureEntry {
1597 study_id: study.study_id.clone(),
1598 study_index: index,
1599 error_code: err.error_code,
1600 message: err.message,
1601 });
1602 continue;
1603 }
1604 };
1605 plan_entries.push(AnalysisStudySweepPlanEntry {
1606 study_id: planned.data.study_id,
1607 model_id: planned.data.model_id,
1608 run_kind: planned.data.run_kind,
1609 backend: planned.data.backend,
1610 electromagnetic_run_options: planned.data.electromagnetic_run_options,
1611 run_options: planned.data.run_options,
1612 operation_sequence: planned.data.operation_sequence,
1613 run_operation: planned.data.run_operation,
1614 run_op_version: planned.data.run_op_version,
1615 study_fingerprint: planned.data.study_fingerprint,
1616 });
1617 }
1618
1619 let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1620 let evidence_path = study_evidence_root()
1621 .join("sweeps")
1622 .join(sanitized_sweep_id)
1623 .join("plan.json");
1624 if let Some(parent) = evidence_path.parent() {
1625 fs_create_dir_all(parent).map_err(|err| {
1626 operation_error(
1627 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1628 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1629 &context,
1630 OperationErrorSpec {
1631 error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1632 error_type: OperationErrorType::Internal,
1633 retryable: true,
1634 severity: OperationErrorSeverity::Error,
1635 },
1636 format!("failed to create study sweep planning evidence directory: {err}"),
1637 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1638 )
1639 })?;
1640 }
1641 let payload = serde_json::json!({
1642 "schema_version": "fea_study_sweep_plan_artifact/v1",
1643 "sweep_id": spec.sweep_id.clone(),
1644 "study_count": spec.studies.len(),
1645 "planned_count": plan_entries.len(),
1646 "failed_count": failure_entries.len(),
1647 "failure_entries": failure_entries.clone(),
1648 "plan_entries": plan_entries.clone(),
1649 });
1650 let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1651 operation_error(
1652 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1653 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1654 &context,
1655 OperationErrorSpec {
1656 error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1657 error_type: OperationErrorType::Internal,
1658 retryable: true,
1659 severity: OperationErrorSeverity::Error,
1660 },
1661 format!("failed to encode study sweep planning evidence payload: {err}"),
1662 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1663 )
1664 })?;
1665 atomic_write_bytes(&evidence_path, &payload_bytes).map_err(|err| {
1666 operation_error(
1667 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1668 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1669 &context,
1670 OperationErrorSpec {
1671 error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1672 error_type: OperationErrorType::Internal,
1673 retryable: true,
1674 severity: OperationErrorSeverity::Error,
1675 },
1676 err,
1677 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1678 )
1679 })?;
1680
1681 Ok(OperationEnvelope::new(
1682 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1683 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1684 &context,
1685 AnalysisStudySweepPlanData {
1686 sweep_id: spec.sweep_id.clone(),
1687 study_count: spec.studies.len(),
1688 planned_count: plan_entries.len(),
1689 failed_count: failure_entries.len(),
1690 failure_entries,
1691 plan_entries,
1692 evidence_artifact_path: evidence_path.display().to_string(),
1693 },
1694 ))
1695}
1696
1697pub fn analysis_validate_study_sweep_op(
1698 spec: &AnalysisStudySweepSpec,
1699 context: OperationContext,
1700) -> Result<OperationEnvelope<AnalysisStudySweepValidateData>, OperationErrorEnvelope> {
1701 let mut issue_codes = Vec::new();
1702 if spec.sweep_id.trim().is_empty() {
1703 issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1704 }
1705 if spec.studies.is_empty() {
1706 issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1707 }
1708
1709 let study_entries: Vec<AnalysisStudySweepValidateEntry> = spec
1710 .studies
1711 .iter()
1712 .map(|study| {
1713 let study_issue_codes = validate_study_issue_codes(study);
1714 let issues = study_issue_codes
1715 .iter()
1716 .map(|code| AnalysisStudyIssue {
1717 code: code.clone(),
1718 message: study_issue_message(code).to_string(),
1719 })
1720 .collect::<Vec<_>>();
1721 AnalysisStudySweepValidateEntry {
1722 study_id: study.study_id.clone(),
1723 valid: study_issue_codes.is_empty(),
1724 issue_codes: study_issue_codes,
1725 issues,
1726 }
1727 })
1728 .collect();
1729
1730 let valid = issue_codes.is_empty() && study_entries.iter().all(|entry| entry.valid);
1731 let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1732 let evidence_path = study_evidence_root()
1733 .join("sweeps")
1734 .join(sanitized_sweep_id)
1735 .join("validate.json");
1736 if let Some(parent) = evidence_path.parent() {
1737 fs_create_dir_all(parent).map_err(|err| {
1738 operation_error(
1739 ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1740 ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1741 &context,
1742 OperationErrorSpec {
1743 error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1744 error_type: OperationErrorType::Internal,
1745 retryable: true,
1746 severity: OperationErrorSeverity::Error,
1747 },
1748 format!("failed to create study sweep validation evidence directory: {err}"),
1749 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1750 )
1751 })?;
1752 }
1753 let payload = serde_json::json!({
1754 "schema_version": "fea_study_sweep_validate_artifact/v1",
1755 "sweep_id": spec.sweep_id.clone(),
1756 "valid": valid,
1757 "issue_codes": issue_codes.clone(),
1758 "study_entries": study_entries,
1759 });
1760 let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1761 operation_error(
1762 ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1763 ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1764 &context,
1765 OperationErrorSpec {
1766 error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1767 error_type: OperationErrorType::Internal,
1768 retryable: true,
1769 severity: OperationErrorSeverity::Error,
1770 },
1771 format!("failed to encode study sweep validation evidence payload: {err}"),
1772 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1773 )
1774 })?;
1775 atomic_write_bytes(&evidence_path, &payload_bytes).map_err(|err| {
1776 operation_error(
1777 ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1778 ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1779 &context,
1780 OperationErrorSpec {
1781 error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1782 error_type: OperationErrorType::Internal,
1783 retryable: true,
1784 severity: OperationErrorSeverity::Error,
1785 },
1786 err,
1787 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1788 )
1789 })?;
1790
1791 Ok(OperationEnvelope::new(
1792 ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1793 ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1794 &context,
1795 AnalysisStudySweepValidateData {
1796 sweep_id: spec.sweep_id.clone(),
1797 valid,
1798 issue_codes,
1799 study_entries,
1800 evidence_artifact_path: evidence_path.display().to_string(),
1801 },
1802 ))
1803}
1804
1805pub fn analysis_run_study_sweep_op(
1806 spec: &AnalysisStudySweepSpec,
1807 context: OperationContext,
1808) -> Result<OperationEnvelope<AnalysisStudySweepData>, OperationErrorEnvelope> {
1809 let mut issue_codes = Vec::new();
1810 if spec.sweep_id.trim().is_empty() {
1811 issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1812 }
1813 if spec.studies.is_empty() {
1814 issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1815 }
1816 if !issue_codes.is_empty() {
1817 return Err(operation_error(
1818 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1819 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1820 &context,
1821 OperationErrorSpec {
1822 error_code: "RM.FEA.RUN_STUDY_SWEEP.INVALID_SPEC",
1823 error_type: OperationErrorType::Validation,
1824 retryable: false,
1825 severity: OperationErrorSeverity::Error,
1826 },
1827 "study sweep spec is invalid",
1828 BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1829 ));
1830 }
1831
1832 let mut run_entries = Vec::with_capacity(spec.studies.len());
1833 let mut failure_entries = Vec::new();
1834 for (index, study) in spec.studies.iter().enumerate() {
1835 let run = match analysis_run_study_op(study, context.clone()) {
1836 Ok(run) => run,
1837 Err(err) => {
1838 if spec.fail_fast {
1839 return Err(operation_error(
1840 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1841 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1842 &context,
1843 OperationErrorSpec {
1844 error_code: "RM.FEA.RUN_STUDY_SWEEP.STUDY_FAILED",
1845 error_type: OperationErrorType::Validation,
1846 retryable: false,
1847 severity: OperationErrorSeverity::Error,
1848 },
1849 format!(
1850 "study sweep failed at index {} for study_id {}: {}",
1851 index, study.study_id, err.error_code
1852 ),
1853 BTreeMap::from([
1854 ("sweep_id".to_string(), spec.sweep_id.clone()),
1855 ("study_id".to_string(), study.study_id.clone()),
1856 ("study_index".to_string(), index.to_string()),
1857 ("cause_error_code".to_string(), err.error_code),
1858 ]),
1859 ));
1860 }
1861 failure_entries.push(AnalysisStudySweepFailureEntry {
1862 study_id: study.study_id.clone(),
1863 study_index: index,
1864 error_code: err.error_code,
1865 message: err.message,
1866 });
1867 continue;
1868 }
1869 };
1870 run_entries.push(AnalysisStudySweepRunEntry {
1871 study_id: run.data.study_id,
1872 run_kind: run.data.run_kind,
1873 run_id: run.data.run_id,
1874 run_status: run.data.run_status,
1875 publishable: run.data.publishable,
1876 run_operation: run.data.run_operation,
1877 run_op_version: run.data.run_op_version,
1878 });
1879 }
1880
1881 let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1882 let evidence_root = study_evidence_root()
1883 .join("sweeps")
1884 .join(sanitized_sweep_id)
1885 .join("run.json");
1886 if let Some(parent) = evidence_root.parent() {
1887 fs_create_dir_all(parent).map_err(|err| {
1888 operation_error(
1889 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1890 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1891 &context,
1892 OperationErrorSpec {
1893 error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1894 error_type: OperationErrorType::Internal,
1895 retryable: true,
1896 severity: OperationErrorSeverity::Error,
1897 },
1898 format!("failed to create study sweep evidence directory: {err}"),
1899 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1900 )
1901 })?;
1902 }
1903 let payload = serde_json::json!({
1904 "schema_version": "fea_study_sweep_run_artifact/v1",
1905 "sweep_id": spec.sweep_id.clone(),
1906 "fail_fast": spec.fail_fast,
1907 "study_count": spec.studies.len(),
1908 "success_count": run_entries.len(),
1909 "failed_count": failure_entries.len(),
1910 "failure_entries": failure_entries.clone(),
1911 "run_entries": run_entries.clone(),
1912 });
1913 let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1914 operation_error(
1915 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1916 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1917 &context,
1918 OperationErrorSpec {
1919 error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1920 error_type: OperationErrorType::Internal,
1921 retryable: true,
1922 severity: OperationErrorSeverity::Error,
1923 },
1924 format!("failed to encode study sweep evidence payload: {err}"),
1925 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1926 )
1927 })?;
1928 atomic_write_bytes(&evidence_root, &payload_bytes).map_err(|err| {
1929 operation_error(
1930 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1931 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1932 &context,
1933 OperationErrorSpec {
1934 error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1935 error_type: OperationErrorType::Internal,
1936 retryable: true,
1937 severity: OperationErrorSeverity::Error,
1938 },
1939 err,
1940 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1941 )
1942 })?;
1943
1944 let evidence_artifact_path = evidence_root.display().to_string();
1945 Ok(OperationEnvelope::new(
1946 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1947 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1948 &context,
1949 AnalysisStudySweepData {
1950 sweep_id: spec.sweep_id.clone(),
1951 study_count: spec.studies.len(),
1952 success_count: run_entries.len(),
1953 failed_count: failure_entries.len(),
1954 failure_entries,
1955 run_entries,
1956 evidence_artifact_path,
1957 },
1958 ))
1959}
1960
1961pub fn analysis_validate(
1962 model: &AnalysisModel,
1963 geometry_units: UnitSystem,
1964 geometry_frame: &ReferenceFrame,
1965 context: OperationContext,
1966) -> Result<OperationEnvelope<AnalysisValidateResult>, OperationErrorEnvelope> {
1967 validate_model_against_geometry(model, geometry_units, geometry_frame)
1968 .map_err(|err| map_validate_error(err, model, &context))?;
1969
1970 Ok(OperationEnvelope::new(
1971 ANALYSIS_VALIDATE_OPERATION,
1972 ANALYSIS_VALIDATE_OP_VERSION,
1973 &context,
1974 AnalysisValidateResult { valid: true },
1975 ))
1976}
1977
1978pub fn analysis_run_linear_static_op(
1979 model: &AnalysisModel,
1980 backend: ComputeBackend,
1981 context: OperationContext,
1982) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1983 analysis_run_linear_static_with_options(model, backend, AnalysisRunOptions::default(), context)
1984}
1985
1986pub fn analysis_run_modal_op(
1987 model: &AnalysisModel,
1988 backend: ComputeBackend,
1989 context: OperationContext,
1990) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1991 analysis_run_modal_with_options_op(model, backend, AnalysisModalRunOptions::default(), context)
1992}
1993
1994pub fn analysis_run_acoustic_op(
1995 model: &AnalysisModel,
1996 backend: ComputeBackend,
1997 context: OperationContext,
1998) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1999 analysis_run_acoustic_with_options_op(
2000 model,
2001 backend,
2002 AnalysisAcousticRunOptions::default(),
2003 context,
2004 )
2005}
2006
2007pub fn analysis_run_modal_with_options_op(
2008 model: &AnalysisModel,
2009 backend: ComputeBackend,
2010 options: AnalysisModalRunOptions,
2011 context: OperationContext,
2012) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
2013 let _solver_context = install_fea_solver_context();
2014 let has_modal_step = model
2015 .steps
2016 .iter()
2017 .any(|step| step.kind == AnalysisStepKind::Modal);
2018 if !has_modal_step {
2019 return Err(operation_error(
2020 ANALYSIS_RUN_MODAL_OPERATION,
2021 ANALYSIS_RUN_MODAL_OP_VERSION,
2022 &context,
2023 OperationErrorSpec {
2024 error_code: "RM.FEA.RUN_MODAL.INVALID_MODEL",
2025 error_type: OperationErrorType::Validation,
2026 retryable: false,
2027 severity: OperationErrorSeverity::Error,
2028 },
2029 "FEA model must include at least one modal step for fea.run_modal",
2030 BTreeMap::from([
2031 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2032 ("geometry_id".to_string(), model.geometry_id.clone()),
2033 ]),
2034 ));
2035 }
2036
2037 if options.mode_count == 0 {
2038 return Err(operation_error(
2039 ANALYSIS_RUN_MODAL_OPERATION,
2040 ANALYSIS_RUN_MODAL_OP_VERSION,
2041 &context,
2042 OperationErrorSpec {
2043 error_code: "RM.FEA.RUN_MODAL.INVALID_OPTIONS",
2044 error_type: OperationErrorType::Input,
2045 retryable: false,
2046 severity: OperationErrorSeverity::Error,
2047 },
2048 "fea.run_modal options require mode_count greater than zero",
2049 BTreeMap::from([("mode_count".to_string(), options.mode_count.to_string())]),
2050 ));
2051 }
2052
2053 let thermo_options = resolve_thermo_coupling_options(
2054 model,
2055 model_thermo_coupling_options(model),
2056 ANALYSIS_RUN_MODAL_OPERATION,
2057 ANALYSIS_RUN_MODAL_OP_VERSION,
2058 &context,
2059 )?;
2060 if let Some(thermo_options) = thermo_options.as_ref() {
2061 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
2062 return Err(operation_error(
2063 ANALYSIS_RUN_MODAL_OPERATION,
2064 ANALYSIS_RUN_MODAL_OP_VERSION,
2065 &context,
2066 OperationErrorSpec {
2067 error_code: "RM.FEA.RUN_MODAL.INVALID_OPTIONS",
2068 error_type: OperationErrorType::Input,
2069 retryable: false,
2070 severity: OperationErrorSeverity::Error,
2071 },
2072 detail,
2073 metadata,
2074 ));
2075 }
2076 }
2077 let electro_options = model_electro_coupling_options(model);
2078 if let Some(electro_options) = electro_options.as_ref() {
2079 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
2080 return Err(operation_error(
2081 ANALYSIS_RUN_MODAL_OPERATION,
2082 ANALYSIS_RUN_MODAL_OP_VERSION,
2083 &context,
2084 OperationErrorSpec {
2085 error_code: electro_thermal_invalid_options_error_code(
2086 ANALYSIS_RUN_MODAL_OPERATION,
2087 ),
2088 error_type: OperationErrorType::Input,
2089 retryable: false,
2090 severity: OperationErrorSeverity::Error,
2091 },
2092 detail,
2093 metadata,
2094 ));
2095 }
2096 }
2097
2098 let prep_context = resolve_run_prep_context(
2099 model,
2100 options.prep_artifact_id.as_deref(),
2101 options.prep_context.clone(),
2102 ANALYSIS_RUN_MODAL_OPERATION,
2103 ANALYSIS_RUN_MODAL_OP_VERSION,
2104 &context,
2105 )?;
2106
2107 let modal_run = run_modal_with_options(
2108 model,
2109 backend,
2110 ModalSolveOptions {
2111 mode_count: options.mode_count,
2112 prep_context: to_fea_prep_context(
2113 prep_context.as_ref(),
2114 options.prep_calibration_profile,
2115 ),
2116 thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
2117 electro_thermal_context: to_fea_electro_thermal_context(electro_options),
2118 },
2119 )
2120 .map_err(|err| {
2121 map_fea_run_error(
2122 ANALYSIS_RUN_MODAL_OPERATION,
2123 ANALYSIS_RUN_MODAL_OP_VERSION,
2124 "RM.FEA.RUN_MODAL.SOLVER_MODEL_INVALID",
2125 "RM.FEA.RUN_MODAL.CANCELLED",
2126 model,
2127 &context,
2128 err,
2129 )
2130 })?;
2131
2132 let mut run = modal_run.run;
2133 let mut fallback_events = Vec::new();
2134 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
2135 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
2136 fallback_events.push(
2137 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
2138 );
2139 }
2140 let solver_convergence = if run.diagnostics.iter().any(|item| {
2141 item.code == "FEA_MODAL_CONVERGENCE"
2142 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2143 }) {
2144 QualityGate::Pass
2145 } else {
2146 QualityGate::Warn
2147 };
2148 let result_quality = if modal_run.eigenvalues_hz.is_empty() || modal_run.mode_shapes.is_empty()
2149 {
2150 QualityGate::Fail
2151 } else if modal_run
2152 .residual_norms
2153 .iter()
2154 .copied()
2155 .fold(0.0_f64, f64::max)
2156 > options.residual_warn_threshold
2157 {
2158 QualityGate::Warn
2159 } else {
2160 QualityGate::Pass
2161 };
2162 let modal_orthogonality_warn = run.diagnostics.iter().any(|item| {
2163 item.code == "FEA_MODAL_ORTHOGONALITY"
2164 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2165 });
2166 let modal_separation_warn = run.diagnostics.iter().any(|item| {
2167 item.code == "FEA_MODAL_SEPARATION"
2168 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2169 });
2170
2171 let mut quality_reasons = Vec::new();
2172 if solver_convergence == QualityGate::Warn {
2173 quality_reasons.push(QualityReason {
2174 code: QualityReasonCode::SolverNotConverged,
2175 detail: "modal solver convergence gate is warning".to_string(),
2176 });
2177 }
2178 if result_quality == QualityGate::Warn {
2179 quality_reasons.push(QualityReason {
2180 code: QualityReasonCode::ModalResidualExceeded,
2181 detail: format!(
2182 "modal residual exceeds threshold {}",
2183 options.residual_warn_threshold
2184 ),
2185 });
2186 }
2187 if modal_orthogonality_warn {
2188 quality_reasons.push(QualityReason {
2189 code: QualityReasonCode::ModalOrthogonalityExceeded,
2190 detail: "modal M-orthogonality off-diagonal threshold exceeded".to_string(),
2191 });
2192 }
2193 if modal_separation_warn {
2194 quality_reasons.push(QualityReason {
2195 code: QualityReasonCode::ModalSeparationLow,
2196 detail: "modal frequency separation threshold is low".to_string(),
2197 });
2198 }
2199 if fallback_events
2200 .iter()
2201 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
2202 {
2203 quality_reasons.push(QualityReason {
2204 code: QualityReasonCode::SolverBackendFallback,
2205 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
2206 });
2207 }
2208 if fallback_events.iter().any(|event| {
2209 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
2210 }) {
2211 quality_reasons.push(QualityReason {
2212 code: QualityReasonCode::FieldPromotionFallback,
2213 detail: "field promotion fell back to host-backed values".to_string(),
2214 });
2215 }
2216
2217 let frequency_basis = ModalFrequencyBasis::NativeEigenSolve;
2218
2219 let publishable = match options.quality_policy {
2220 QualityPolicy::Strict => {
2221 solver_convergence == QualityGate::Pass
2222 && result_quality == QualityGate::Pass
2223 && quality_reasons.is_empty()
2224 }
2225 QualityPolicy::Balanced => {
2226 solver_convergence == QualityGate::Pass
2227 && result_quality == QualityGate::Pass
2228 && !quality_reasons.iter().any(|r| {
2229 matches!(
2230 r.code,
2231 QualityReasonCode::ModalOrthogonalityExceeded
2232 | QualityReasonCode::ModalSeparationLow
2233 )
2234 })
2235 }
2236 QualityPolicy::Exploratory => {
2237 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
2238 }
2239 };
2240 let run_status = if publishable {
2241 RunStatus::Publishable
2242 } else if result_quality == QualityGate::Fail {
2243 RunStatus::Rejected
2244 } else {
2245 RunStatus::Degraded
2246 };
2247 let solver_backend = run.solver_backend.clone();
2248 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
2249 let solver_host_sync_count = run.solver_host_sync_count;
2250 let solver_method = run.solver_method.clone();
2251 let selected_preconditioner = run.preconditioner.clone();
2252
2253 let result = AnalysisRunResult {
2254 run_id: storage::next_run_id(),
2255 run,
2256 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
2257 modal_results: Some(ModalResultsData {
2258 modal_payload_version: "modal_results/v1".to_string(),
2259 eigenvalues_hz: modal_run.eigenvalues_hz,
2260 mode_shapes: modal_run.mode_shapes,
2261 residual_norms: modal_run.residual_norms,
2262 mode_units: ModalFrequencyUnits::Hz,
2263 frequency_basis,
2264 }),
2265 thermal_results: None,
2266 transient_results: None,
2267 nonlinear_results: None,
2268 electromagnetic_results: None,
2269 model_validity: QualityGate::Pass,
2270 solver_convergence,
2271 result_quality,
2272 run_status,
2273 publishable,
2274 quality_reasons,
2275 provenance: RunProvenance {
2276 backend,
2277 solver_backend,
2278 solver_device_apply_k_ratio,
2279 solver_host_sync_count,
2280 precision_mode: contracts::format_precision_mode(options.precision_mode),
2281 deterministic_mode: options.deterministic_mode,
2282 solver_method,
2283 preconditioner: selected_preconditioner,
2284 quality_policy: contracts::format_quality_policy(options.quality_policy),
2285 fallback_events,
2286 },
2287 };
2288
2289 if let Some(nonlinear) = result.nonlinear_results.as_ref() {
2290 let event = format!(
2291 "fea.run_nonlinear outcome run_id={} model_id={} backend={:?} run_status={:?} publishable={} failed_increments={} max_iteration_count={} line_search_backtracks={} tangent_rebuild_count={} max_residual_norm={} max_increment_norm={} max_backtracks_per_increment={} quality_reason_count={}",
2292 result.run_id,
2293 model.model_id.0,
2294 backend,
2295 result.run_status,
2296 result.publishable,
2297 nonlinear.failed_increments,
2298 nonlinear
2299 .iteration_counts
2300 .iter()
2301 .copied()
2302 .max()
2303 .unwrap_or(0),
2304 nonlinear.line_search_backtracks,
2305 nonlinear.tangent_rebuild_count,
2306 nonlinear
2307 .residual_norms
2308 .iter()
2309 .copied()
2310 .reduce(f64::max)
2311 .unwrap_or(0.0),
2312 nonlinear
2313 .increment_norms
2314 .iter()
2315 .copied()
2316 .reduce(f64::max)
2317 .unwrap_or(0.0),
2318 nonlinear.max_line_search_backtracks_per_increment,
2319 result.quality_reasons.len()
2320 );
2321 if matches!(result.run_status, RunStatus::Degraded | RunStatus::Rejected) {
2322 tracing::warn!(target: "runmat_analysis", "{event}");
2323 } else {
2324 tracing::info!(target: "runmat_analysis", "{event}");
2325 }
2326 }
2327
2328 persist_fea_run_result_with_progress(
2329 ANALYSIS_RUN_MODAL_OPERATION,
2330 ANALYSIS_RUN_MODAL_OP_VERSION,
2331 "RM.FEA.RUN_MODAL.ARTIFACT_STORE_FAILED",
2332 &context,
2333 &result,
2334 )?;
2335
2336 Ok(OperationEnvelope::new(
2337 ANALYSIS_RUN_MODAL_OPERATION,
2338 ANALYSIS_RUN_MODAL_OP_VERSION,
2339 &context,
2340 result,
2341 ))
2342}
2343
2344pub fn analysis_run_acoustic_with_options_op(
2345 model: &AnalysisModel,
2346 backend: ComputeBackend,
2347 options: AnalysisAcousticRunOptions,
2348 context: OperationContext,
2349) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
2350 let _solver_context = install_fea_solver_context();
2351 let has_modal_step = model
2352 .steps
2353 .iter()
2354 .any(|step| step.kind == AnalysisStepKind::Modal);
2355 if !has_modal_step {
2356 return Err(operation_error(
2357 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2358 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2359 &context,
2360 OperationErrorSpec {
2361 error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_MODEL",
2362 error_type: OperationErrorType::Validation,
2363 retryable: false,
2364 severity: OperationErrorSeverity::Error,
2365 },
2366 "FEA model must include an acoustic harmonic step marker for fea.run_acoustic",
2367 BTreeMap::from([
2368 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2369 ("geometry_id".to_string(), model.geometry_id.clone()),
2370 ]),
2371 ));
2372 }
2373 if !model
2374 .materials
2375 .iter()
2376 .any(|material| material.acoustic.is_some())
2377 {
2378 return Err(operation_error(
2379 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2380 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2381 &context,
2382 OperationErrorSpec {
2383 error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_MATERIAL",
2384 error_type: OperationErrorType::Validation,
2385 retryable: false,
2386 severity: OperationErrorSeverity::Error,
2387 },
2388 "fea.run_acoustic requires at least one acoustic material with density and sound-speed data",
2389 BTreeMap::from([
2390 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2391 (
2392 "material_count".to_string(),
2393 model.materials.len().to_string(),
2394 ),
2395 ]),
2396 ));
2397 }
2398 reject_moment_loads_for_run_family(
2399 model,
2400 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2401 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2402 "RM.FEA.RUN_ACOUSTIC.INVALID_ACOUSTIC_SOURCE",
2403 "acoustic",
2404 &context,
2405 )?;
2406 if !model.loads.iter().any(|load| match &load.kind {
2407 LoadKind::Pressure { magnitude_pa } => magnitude_pa.is_finite() && magnitude_pa.abs() > 0.0,
2408 _ => false,
2409 }) {
2410 return Err(operation_error(
2411 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2412 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2413 &context,
2414 OperationErrorSpec {
2415 error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_SOURCE",
2416 error_type: OperationErrorType::Validation,
2417 retryable: false,
2418 severity: OperationErrorSeverity::Error,
2419 },
2420 "fea.run_acoustic requires a nonzero acoustic pressure source load",
2421 BTreeMap::from([
2422 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2423 ("load_count".to_string(), model.loads.len().to_string()),
2424 ]),
2425 ));
2426 }
2427 if !model.boundary_conditions.iter().any(|bc| {
2428 matches!(
2429 &bc.kind,
2430 BoundaryConditionKind::AcousticRigidWall
2431 | BoundaryConditionKind::AcousticRadiation
2432 | BoundaryConditionKind::AcousticImpedance { .. }
2433 )
2434 }) {
2435 return Err(operation_error(
2436 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2437 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2438 &context,
2439 OperationErrorSpec {
2440 error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_BOUNDARY",
2441 error_type: OperationErrorType::Validation,
2442 retryable: false,
2443 severity: OperationErrorSeverity::Error,
2444 },
2445 "fea.run_acoustic requires at least one acoustic boundary condition",
2446 BTreeMap::from([
2447 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2448 (
2449 "boundary_condition_count".to_string(),
2450 model.boundary_conditions.len().to_string(),
2451 ),
2452 ]),
2453 ));
2454 }
2455
2456 if options.mode_count == 0 {
2457 return Err(operation_error(
2458 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2459 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2460 &context,
2461 OperationErrorSpec {
2462 error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_OPTIONS",
2463 error_type: OperationErrorType::Input,
2464 retryable: false,
2465 severity: OperationErrorSeverity::Error,
2466 },
2467 "fea.run_acoustic options require mode_count greater than zero",
2468 BTreeMap::from([("mode_count".to_string(), options.mode_count.to_string())]),
2469 ));
2470 }
2471
2472 let thermo_options = resolve_thermo_coupling_options(
2473 model,
2474 model_thermo_coupling_options(model),
2475 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2476 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2477 &context,
2478 )?;
2479 if let Some(thermo_options) = thermo_options.as_ref() {
2480 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
2481 return Err(operation_error(
2482 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2483 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2484 &context,
2485 OperationErrorSpec {
2486 error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_OPTIONS",
2487 error_type: OperationErrorType::Input,
2488 retryable: false,
2489 severity: OperationErrorSeverity::Error,
2490 },
2491 detail,
2492 metadata,
2493 ));
2494 }
2495 }
2496 let electro_options = model_electro_coupling_options(model);
2497 if let Some(electro_options) = electro_options.as_ref() {
2498 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
2499 return Err(operation_error(
2500 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2501 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2502 &context,
2503 OperationErrorSpec {
2504 error_code: electro_thermal_invalid_options_error_code(
2505 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2506 ),
2507 error_type: OperationErrorType::Input,
2508 retryable: false,
2509 severity: OperationErrorSeverity::Error,
2510 },
2511 detail,
2512 metadata,
2513 ));
2514 }
2515 }
2516
2517 let prep_context = resolve_run_prep_context(
2518 model,
2519 options.prep_artifact_id.as_deref(),
2520 options.prep_context.clone(),
2521 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2522 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2523 &context,
2524 )?;
2525 let render_topology = render_topology_from_prep_context(prep_context.as_ref());
2526
2527 let solve_start = Instant::now();
2528 let mut run = solve_acoustic_harmonic(
2529 model,
2530 backend,
2531 options.mode_count,
2532 prep_context,
2533 options.residual_warn_threshold,
2534 );
2535 let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
2536 run.diagnostics
2537 .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
2538 code: "FEA_ACOUSTIC_COST".to_string(),
2539 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
2540 message: format!(
2541 "solve_ms={} mode_count={} residual_warn_threshold={}",
2542 solve_ms, options.mode_count, options.residual_warn_threshold,
2543 ),
2544 });
2545 let acoustic_residual_norm = diagnostic_metric(
2546 &run.diagnostics,
2547 "FEA_ACOUSTIC_HELMHOLTZ_RESIDUAL",
2548 "normalized_residual_norm",
2549 )
2550 .unwrap_or(f64::INFINITY);
2551 let mut fallback_events = Vec::new();
2552 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
2553 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
2554 fallback_events.push(
2555 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
2556 );
2557 }
2558 let solver_convergence = if acoustic_residual_norm <= options.residual_warn_threshold {
2559 QualityGate::Pass
2560 } else {
2561 QualityGate::Warn
2562 };
2563 let result_quality = if run.fields_are_empty() {
2564 QualityGate::Fail
2565 } else if acoustic_residual_norm > options.residual_warn_threshold {
2566 QualityGate::Warn
2567 } else {
2568 QualityGate::Pass
2569 };
2570
2571 let mut quality_reasons = Vec::new();
2572 if solver_convergence == QualityGate::Warn {
2573 quality_reasons.push(QualityReason {
2574 code: QualityReasonCode::SolverNotConverged,
2575 detail: "acoustic solver convergence gate is warning".to_string(),
2576 });
2577 }
2578 if result_quality == QualityGate::Warn {
2579 quality_reasons.push(QualityReason {
2580 code: QualityReasonCode::ModalResidualExceeded,
2581 detail: format!(
2582 "acoustic residual exceeds threshold {}",
2583 options.residual_warn_threshold
2584 ),
2585 });
2586 }
2587 if fallback_events
2588 .iter()
2589 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
2590 {
2591 quality_reasons.push(QualityReason {
2592 code: QualityReasonCode::SolverBackendFallback,
2593 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
2594 });
2595 }
2596 if fallback_events.iter().any(|event| {
2597 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
2598 }) {
2599 quality_reasons.push(QualityReason {
2600 code: QualityReasonCode::FieldPromotionFallback,
2601 detail: "field promotion fell back to host-backed values".to_string(),
2602 });
2603 }
2604
2605 let publishable = match options.quality_policy {
2606 QualityPolicy::Strict => {
2607 solver_convergence == QualityGate::Pass
2608 && result_quality == QualityGate::Pass
2609 && quality_reasons.is_empty()
2610 }
2611 QualityPolicy::Balanced => {
2612 solver_convergence == QualityGate::Pass
2613 && result_quality == QualityGate::Pass
2614 && quality_reasons.is_empty()
2615 }
2616 QualityPolicy::Exploratory => {
2617 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
2618 }
2619 };
2620 let run_status = if publishable {
2621 RunStatus::Publishable
2622 } else if result_quality == QualityGate::Fail {
2623 RunStatus::Rejected
2624 } else {
2625 RunStatus::Degraded
2626 };
2627 let solver_backend = run.solver_backend.clone();
2628 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
2629 let solver_host_sync_count = run.solver_host_sync_count;
2630 let solver_method = run.solver_method.clone();
2631 let selected_preconditioner = run.preconditioner.clone();
2632
2633 let result = AnalysisRunResult {
2634 run_id: storage::next_run_id(),
2635 run,
2636 render_topology,
2637 modal_results: None,
2638 thermal_results: None,
2639 transient_results: None,
2640 nonlinear_results: None,
2641 electromagnetic_results: None,
2642 model_validity: QualityGate::Pass,
2643 solver_convergence,
2644 result_quality,
2645 run_status,
2646 publishable,
2647 quality_reasons,
2648 provenance: RunProvenance {
2649 backend,
2650 solver_backend,
2651 solver_device_apply_k_ratio,
2652 solver_host_sync_count,
2653 precision_mode: contracts::format_precision_mode(options.precision_mode),
2654 deterministic_mode: options.deterministic_mode,
2655 solver_method,
2656 preconditioner: selected_preconditioner,
2657 quality_policy: contracts::format_quality_policy(options.quality_policy),
2658 fallback_events,
2659 },
2660 };
2661
2662 persist_fea_run_result_with_progress(
2663 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2664 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2665 "RM.FEA.RUN_ACOUSTIC.ARTIFACT_STORE_FAILED",
2666 &context,
2667 &result,
2668 )?;
2669
2670 Ok(OperationEnvelope::new(
2671 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2672 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2673 &context,
2674 result,
2675 ))
2676}
2677
2678fn solve_acoustic_harmonic(
2679 model: &AnalysisModel,
2680 backend: ComputeBackend,
2681 mode_count: usize,
2682 prep_context: Option<AnalysisRunPrepContext>,
2683 residual_warn_threshold: f64,
2684) -> FeaRunResult {
2685 let node_count = acoustic_node_count(model, prep_context.as_ref());
2686 let material_summary = acoustic_material_summary(model, mode_count);
2687 let boundary_summary = acoustic_boundary_summary(
2688 model,
2689 material_summary.characteristic_impedance_pa_s_per_m(),
2690 );
2691 let speed_of_sound_m_per_s = material_summary.speed_of_sound_m_per_s;
2692 let density_kg_per_m3 = material_summary.density_kg_per_m3;
2693 let drive_frequency_hz = acoustic_drive_frequency_hz(mode_count, node_count);
2694 let damping_ratio = material_summary.damping_ratio;
2695 let source_real = acoustic_source_vector(model, node_count);
2696 let source_imag = vec![0.0; node_count];
2697 let domain = acoustic_domain_topology(node_count, prep_context.as_ref());
2698 let system = acoustic_helmholtz_operator(
2699 domain,
2700 node_count,
2701 drive_frequency_hz,
2702 speed_of_sound_m_per_s,
2703 damping_ratio,
2704 &boundary_summary,
2705 );
2706 let (pressure_real, pressure_imag) =
2707 solve_complex_graph_operator(&system, &source_real, &source_imag);
2708 let normalized_residual_norm = acoustic_residual_norm(
2709 &system,
2710 &pressure_real,
2711 &pressure_imag,
2712 &source_real,
2713 &source_imag,
2714 );
2715 let pressure_magnitude = pressure_real
2716 .iter()
2717 .zip(pressure_imag.iter())
2718 .map(|(real, imag)| real.hypot(*imag))
2719 .collect::<Vec<_>>();
2720 let phase = pressure_real
2721 .iter()
2722 .zip(pressure_imag.iter())
2723 .map(|(real, imag)| imag.atan2(*real))
2724 .collect::<Vec<_>>();
2725 let sound_pressure_level_db = pressure_magnitude
2726 .iter()
2727 .map(|pressure| 20.0 * (pressure.max(2.0e-5) / 2.0e-5).log10())
2728 .collect::<Vec<_>>();
2729 let particle_velocity = recover_acoustic_particle_velocity(
2730 &pressure_real,
2731 domain,
2732 drive_frequency_hz,
2733 density_kg_per_m3,
2734 );
2735 let peak_pressure_pa = pressure_magnitude.iter().copied().fold(0.0_f64, f64::max);
2736 let sweep_frequencies_hz = acoustic_sweep_frequencies_hz(drive_frequency_hz);
2737 let mut frequency_response_fields = Vec::with_capacity(sweep_frequencies_hz.len());
2738 let mut sweep_peak_pressure_pa = 0.0_f64;
2739 let mut sweep_residual_norm = 0.0_f64;
2740 for frequency_hz in &sweep_frequencies_hz {
2741 let sweep_system = acoustic_helmholtz_operator(
2742 domain,
2743 node_count,
2744 *frequency_hz,
2745 speed_of_sound_m_per_s,
2746 damping_ratio,
2747 &boundary_summary,
2748 );
2749 let (sweep_real, sweep_imag) =
2750 solve_complex_graph_operator(&sweep_system, &source_real, &source_imag);
2751 let sweep_magnitude = sweep_real
2752 .iter()
2753 .zip(sweep_imag.iter())
2754 .map(|(real, imag)| real.hypot(*imag))
2755 .collect::<Vec<_>>();
2756 sweep_peak_pressure_pa =
2757 sweep_peak_pressure_pa.max(sweep_magnitude.iter().copied().fold(0.0_f64, f64::max));
2758 sweep_residual_norm = sweep_residual_norm.max(acoustic_residual_norm(
2759 &sweep_system,
2760 &sweep_real,
2761 &sweep_imag,
2762 &source_real,
2763 &source_imag,
2764 ));
2765 frequency_response_fields.push(AnalysisField::host_f64(
2766 fea_acoustic_frequency_response_field_id(*frequency_hz),
2767 vec![node_count],
2768 sweep_magnitude,
2769 ));
2770 }
2771 let sweep_frequency_min_hz = sweep_frequencies_hz
2772 .iter()
2773 .copied()
2774 .fold(f64::INFINITY, f64::min);
2775 let sweep_frequency_max_hz = sweep_frequencies_hz.iter().copied().fold(0.0_f64, f64::max);
2776 let sweep_bandwidth_hz = if sweep_frequency_min_hz.is_finite() {
2777 (sweep_frequency_max_hz - sweep_frequency_min_hz).max(0.0)
2778 } else {
2779 0.0
2780 };
2781 let known_answer = acoustic_known_answer_metrics(
2782 domain,
2783 &pressure_magnitude,
2784 drive_frequency_hz,
2785 speed_of_sound_m_per_s,
2786 );
2787 let mut fields = vec![
2788 AnalysisField::host_f64(
2789 FEA_FIELD_ACOUSTIC_PRESSURE_REAL,
2790 vec![node_count],
2791 pressure_real,
2792 ),
2793 AnalysisField::host_f64(
2794 FEA_FIELD_ACOUSTIC_PRESSURE_IMAG,
2795 vec![node_count],
2796 pressure_imag,
2797 ),
2798 AnalysisField::host_f64(
2799 FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE,
2800 vec![node_count],
2801 pressure_magnitude,
2802 ),
2803 AnalysisField::host_f64(FEA_FIELD_ACOUSTIC_PHASE, vec![node_count], phase),
2804 AnalysisField::host_f64(
2805 FEA_FIELD_ACOUSTIC_SOUND_PRESSURE_LEVEL_DB,
2806 vec![node_count],
2807 sound_pressure_level_db,
2808 ),
2809 AnalysisField::host_f64(
2810 FEA_FIELD_ACOUSTIC_PARTICLE_VELOCITY,
2811 vec![node_count, 3],
2812 particle_velocity,
2813 ),
2814 ];
2815 let acoustic_field_count = fields.len() + frequency_response_fields.len();
2816 fields.extend(frequency_response_fields);
2817 let severity = if normalized_residual_norm <= residual_warn_threshold {
2818 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2819 } else {
2820 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2821 };
2822 FeaRunResult {
2823 backend,
2824 solver_backend: "cpu_reference".to_string(),
2825 solver_device_apply_k_ratio: 0.0,
2826 solver_method: "acoustic_domain_graph_helmholtz_harmonic".to_string(),
2827 preconditioner: "none".to_string(),
2828 solver_host_sync_count: 0,
2829 diagnostics: vec![
2830 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2831 code: "FEA_ACOUSTIC_HELMHOLTZ_RESIDUAL".to_string(),
2832 severity,
2833 message: format!(
2834 "normalized_residual_norm={} equation_scale={} residual_warn_threshold={}",
2835 normalized_residual_norm,
2836 source_norm(&source_real, &source_imag).max(1.0),
2837 residual_warn_threshold,
2838 ),
2839 },
2840 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2841 code: "FEA_ACOUSTIC_DOMAIN_ASSEMBLY".to_string(),
2842 severity: if domain.edge_count > 0 && domain.active_dimension_count >= 2 {
2843 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2844 } else {
2845 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2846 },
2847 message: format!(
2848 "domain_node_count={} domain_edge_count={} domain_active_dimension_count={} domain_dim_x={} domain_dim_y={} domain_dim_z={} domain_spacing_x_m={} domain_spacing_y_m={} domain_spacing_z_m={} boundary_node_count={} average_node_degree={} source_node_count={} domain_volume_m3={}",
2849 domain.node_count,
2850 domain.edge_count,
2851 domain.active_dimension_count,
2852 domain.dims[0],
2853 domain.dims[1],
2854 domain.dims[2],
2855 domain.spacing[0],
2856 domain.spacing[1],
2857 domain.spacing[2],
2858 domain.boundary_node_count,
2859 domain.average_node_degree(),
2860 source_real
2861 .iter()
2862 .filter(|value| value.abs() > 1.0e-12)
2863 .count(),
2864 domain.volume_m3(),
2865 ),
2866 },
2867 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2868 code: "FEA_ACOUSTIC_HARMONIC_RESPONSE".to_string(),
2869 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
2870 message: format!(
2871 "drive_frequency_hz={} speed_of_sound_m_per_s={} density_kg_per_m3={} damping_ratio={} acoustic_node_count={} acoustic_field_count={} peak_pressure_pa={} acoustic_material_count={} acoustic_material_coverage_ratio={}",
2872 drive_frequency_hz,
2873 speed_of_sound_m_per_s,
2874 density_kg_per_m3,
2875 damping_ratio,
2876 node_count,
2877 acoustic_field_count,
2878 peak_pressure_pa,
2879 material_summary.explicit_material_count,
2880 material_summary.coverage_ratio,
2881 ),
2882 },
2883 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2884 code: "FEA_ACOUSTIC_BOUNDARY_MODEL".to_string(),
2885 severity: if boundary_summary.has_acoustic_boundary_data() {
2886 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2887 } else {
2888 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2889 },
2890 message: format!(
2891 "acoustic_boundary_count={} rigid_wall_count={} radiation_boundary_count={} impedance_boundary_count={} acoustic_boundary_coverage_ratio={} mean_specific_impedance_pa_s_per_m={} radiation_loss_factor={} impedance_loss_factor={}",
2892 boundary_summary.acoustic_boundary_count,
2893 boundary_summary.rigid_wall_count,
2894 boundary_summary.radiation_boundary_count,
2895 boundary_summary.impedance_boundary_count,
2896 boundary_summary.coverage_ratio,
2897 boundary_summary.mean_specific_impedance_pa_s_per_m,
2898 boundary_summary.radiation_loss_factor,
2899 boundary_summary.impedance_loss_factor,
2900 ),
2901 },
2902 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2903 code: "FEA_ACOUSTIC_FREQUENCY_RESPONSE".to_string(),
2904 severity: if sweep_residual_norm <= residual_warn_threshold {
2905 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2906 } else {
2907 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2908 },
2909 message: format!(
2910 "sweep_count={} sweep_frequency_min_hz={} sweep_frequency_max_hz={} sweep_bandwidth_hz={} sweep_peak_pressure_pa={} sweep_max_residual_norm={} response_coverage_ratio={}",
2911 sweep_frequencies_hz.len(),
2912 sweep_frequency_min_hz,
2913 sweep_frequency_max_hz,
2914 sweep_bandwidth_hz,
2915 sweep_peak_pressure_pa,
2916 sweep_residual_norm,
2917 if sweep_frequencies_hz.is_empty() {
2918 0.0
2919 } else {
2920 1.0
2921 }
2922 ),
2923 },
2924 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2925 code: "FEA_ACOUSTIC_KNOWN_ANSWER".to_string(),
2926 severity: if known_answer.known_answer_coverage_ratio >= 1.0
2927 && known_answer.tube_mode_alignment_error_ratio <= 0.5
2928 && known_answer.tube_pressure_variation_ratio > 1.0e-12
2929 && known_answer.cavity_mode_spacing_ratio.is_finite()
2930 && known_answer.cavity_mode_spacing_ratio > 0.0
2931 {
2932 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2933 } else {
2934 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2935 },
2936 message: format!(
2937 "tube_mode_alignment_error_ratio={} tube_pressure_variation_ratio={} cavity_mode_spacing_ratio={} cavity_reference_mode_count={} known_answer_coverage_ratio={}",
2938 known_answer.tube_mode_alignment_error_ratio,
2939 known_answer.tube_pressure_variation_ratio,
2940 known_answer.cavity_mode_spacing_ratio,
2941 known_answer.cavity_reference_mode_count,
2942 known_answer.known_answer_coverage_ratio,
2943 ),
2944 },
2945 ],
2946 fields,
2947 }
2948}
2949
2950#[derive(Debug, Clone, Copy)]
2951struct AcousticKnownAnswerMetrics {
2952 tube_mode_alignment_error_ratio: f64,
2953 tube_pressure_variation_ratio: f64,
2954 cavity_mode_spacing_ratio: f64,
2955 cavity_reference_mode_count: usize,
2956 known_answer_coverage_ratio: f64,
2957}
2958
2959fn acoustic_known_answer_metrics(
2960 topology: AcousticDomainTopology,
2961 pressure_magnitude: &[f64],
2962 drive_frequency_hz: f64,
2963 speed_of_sound_m_per_s: f64,
2964) -> AcousticKnownAnswerMetrics {
2965 let tube_length_m = topology.spacing[0] * topology.dims[0].saturating_sub(1).max(1) as f64;
2966 let fundamental_hz = speed_of_sound_m_per_s.max(1.0) / (2.0 * tube_length_m.max(1.0e-9));
2967 let nearest_mode = (drive_frequency_hz / fundamental_hz).round().max(1.0);
2968 let nearest_mode_frequency_hz = nearest_mode * fundamental_hz;
2969 let tube_mode_alignment_error_ratio = (drive_frequency_hz - nearest_mode_frequency_hz).abs()
2970 / drive_frequency_hz.abs().max(fundamental_hz);
2971 let (min_pressure, max_pressure) = pressure_magnitude
2972 .iter()
2973 .copied()
2974 .fold((f64::INFINITY, 0.0_f64), |(min_value, max_value), value| {
2975 (min_value.min(value), max_value.max(value))
2976 });
2977 let tube_pressure_variation_ratio = if min_pressure.is_finite() {
2978 (max_pressure - min_pressure).max(0.0) / max_pressure.max(1.0e-12)
2979 } else {
2980 0.0
2981 };
2982
2983 let lengths = [
2984 topology.spacing[0] * topology.dims[0].saturating_sub(1).max(1) as f64,
2985 topology.spacing[1] * topology.dims[1].saturating_sub(1).max(1) as f64,
2986 topology.spacing[2] * topology.dims[2].saturating_sub(1).max(1) as f64,
2987 ];
2988 let mut cavity_reference_modes = Vec::new();
2989 for nx in 0..=1 {
2990 for ny in 0..=1 {
2991 for nz in 0..=1 {
2992 if nx == 0 && ny == 0 && nz == 0 {
2993 continue;
2994 }
2995 let mode_sum = (nx as f64 / lengths[0].max(1.0e-9)).powi(2)
2996 + (ny as f64 / lengths[1].max(1.0e-9)).powi(2)
2997 + (nz as f64 / lengths[2].max(1.0e-9)).powi(2);
2998 cavity_reference_modes
2999 .push(0.5 * speed_of_sound_m_per_s.max(1.0) * mode_sum.sqrt());
3000 }
3001 }
3002 }
3003 cavity_reference_modes.sort_by(|a, b| a.total_cmp(b));
3004 let cavity_reference_mode_count = cavity_reference_modes.len();
3005 let cavity_mode_spacing_ratio = cavity_reference_modes
3006 .windows(2)
3007 .map(|window| (window[1] - window[0]).abs())
3008 .filter(|spacing| *spacing > 1.0e-9)
3009 .fold(f64::INFINITY, f64::min)
3010 / fundamental_hz.max(1.0e-12);
3011 let cavity_mode_spacing_ratio = if cavity_mode_spacing_ratio.is_finite() {
3012 cavity_mode_spacing_ratio
3013 } else {
3014 0.0
3015 };
3016
3017 AcousticKnownAnswerMetrics {
3018 tube_mode_alignment_error_ratio,
3019 tube_pressure_variation_ratio,
3020 cavity_mode_spacing_ratio,
3021 cavity_reference_mode_count,
3022 known_answer_coverage_ratio: if cavity_reference_mode_count > 0
3023 && !pressure_magnitude.is_empty()
3024 && drive_frequency_hz.is_finite()
3025 {
3026 1.0
3027 } else {
3028 0.0
3029 },
3030 }
3031}
3032
3033fn acoustic_sweep_frequencies_hz(drive_frequency_hz: f64) -> Vec<f64> {
3034 let mut frequencies = Vec::new();
3035 for scale in [0.75, 1.0, 1.25] {
3036 let frequency = (drive_frequency_hz * scale).clamp(50.0, 20_000.0);
3037 if !frequencies
3038 .iter()
3039 .any(|existing| f64::abs(*existing - frequency) <= 1.0e-9)
3040 {
3041 frequencies.push(frequency);
3042 }
3043 }
3044 frequencies
3045}
3046
3047fn acoustic_node_count(
3048 model: &AnalysisModel,
3049 prep_context: Option<&AnalysisRunPrepContext>,
3050) -> usize {
3051 prep_context
3052 .map(|prep| prep.prepared_node_count.max(3))
3053 .unwrap_or_else(|| model.loads.len().saturating_mul(3).max(3))
3054 .min(512)
3055}
3056
3057#[derive(Debug, Clone, Copy)]
3058struct AcousticMaterialSummary {
3059 density_kg_per_m3: f64,
3060 speed_of_sound_m_per_s: f64,
3061 damping_ratio: f64,
3062 explicit_material_count: usize,
3063 coverage_ratio: f64,
3064}
3065
3066impl AcousticMaterialSummary {
3067 fn characteristic_impedance_pa_s_per_m(self) -> f64 {
3068 (self.density_kg_per_m3 * self.speed_of_sound_m_per_s).max(1.0)
3069 }
3070}
3071
3072fn acoustic_material_summary(model: &AnalysisModel, mode_count: usize) -> AcousticMaterialSummary {
3073 let mut density_sum = 0.0;
3074 let mut speed_sum = 0.0;
3075 let mut damping_sum = 0.0;
3076 let mut explicit_material_count = 0usize;
3077 for material in &model.materials {
3078 let Some(acoustic) = &material.acoustic else {
3079 continue;
3080 };
3081 density_sum += acoustic.density_kg_per_m3.max(1.0e-9);
3082 speed_sum += acoustic.speed_of_sound_m_per_s.max(1.0);
3083 damping_sum += acoustic.damping_ratio.max(0.0);
3084 explicit_material_count += 1;
3085 }
3086 if explicit_material_count == 0 {
3087 let reference_temperature_k = acoustic_reference_temperature_k(model);
3088 let speed_of_sound_m_per_s = 331.3 * (reference_temperature_k / 273.15).sqrt();
3089 let density_kg_per_m3 = 1.225 * (293.15 / reference_temperature_k.max(1.0));
3090 return AcousticMaterialSummary {
3091 density_kg_per_m3,
3092 speed_of_sound_m_per_s,
3093 damping_ratio: 0.02 + 0.002 * mode_count.saturating_sub(1).min(12) as f64,
3094 explicit_material_count,
3095 coverage_ratio: 0.0,
3096 };
3097 }
3098 let inv_count = 1.0 / explicit_material_count as f64;
3099 AcousticMaterialSummary {
3100 density_kg_per_m3: density_sum * inv_count,
3101 speed_of_sound_m_per_s: speed_sum * inv_count,
3102 damping_ratio: damping_sum * inv_count,
3103 explicit_material_count,
3104 coverage_ratio: explicit_material_count as f64 / model.materials.len().max(1) as f64,
3105 }
3106}
3107
3108#[derive(Debug, Clone, Copy)]
3109struct AcousticBoundarySummary {
3110 acoustic_boundary_count: usize,
3111 rigid_wall_count: usize,
3112 radiation_boundary_count: usize,
3113 impedance_boundary_count: usize,
3114 coverage_ratio: f64,
3115 mean_specific_impedance_pa_s_per_m: f64,
3116 radiation_loss_factor: f64,
3117 impedance_loss_factor: f64,
3118}
3119
3120impl AcousticBoundarySummary {
3121 fn has_acoustic_boundary_data(self) -> bool {
3122 self.acoustic_boundary_count > 0
3123 }
3124}
3125
3126fn acoustic_boundary_summary(
3127 model: &AnalysisModel,
3128 characteristic_impedance_pa_s_per_m: f64,
3129) -> AcousticBoundarySummary {
3130 let mut rigid_wall_count = 0usize;
3131 let mut radiation_boundary_count = 0usize;
3132 let mut impedance_boundary_count = 0usize;
3133 let mut impedance_sum = 0.0;
3134 for bc in &model.boundary_conditions {
3135 match bc.kind {
3136 BoundaryConditionKind::AcousticRigidWall => rigid_wall_count += 1,
3137 BoundaryConditionKind::AcousticRadiation => radiation_boundary_count += 1,
3138 BoundaryConditionKind::AcousticImpedance {
3139 specific_impedance_pa_s_per_m,
3140 } => {
3141 impedance_boundary_count += 1;
3142 impedance_sum += specific_impedance_pa_s_per_m.max(1.0);
3143 }
3144 _ => {}
3145 }
3146 }
3147 let acoustic_boundary_count =
3148 rigid_wall_count + radiation_boundary_count + impedance_boundary_count;
3149 let mean_specific_impedance_pa_s_per_m = if impedance_boundary_count == 0 {
3150 characteristic_impedance_pa_s_per_m
3151 } else {
3152 impedance_sum / impedance_boundary_count as f64
3153 };
3154 let impedance_ratio =
3155 characteristic_impedance_pa_s_per_m / mean_specific_impedance_pa_s_per_m.max(1.0);
3156 AcousticBoundarySummary {
3157 acoustic_boundary_count,
3158 rigid_wall_count,
3159 radiation_boundary_count,
3160 impedance_boundary_count,
3161 coverage_ratio: acoustic_boundary_count as f64
3162 / model.boundary_conditions.len().max(1) as f64,
3163 mean_specific_impedance_pa_s_per_m,
3164 radiation_loss_factor: 0.05 * radiation_boundary_count as f64,
3165 impedance_loss_factor: 0.025
3166 * impedance_boundary_count as f64
3167 * impedance_ratio.clamp(0.1, 10.0),
3168 }
3169}
3170
3171fn acoustic_reference_temperature_k(model: &AnalysisModel) -> f64 {
3172 if model.materials.is_empty() {
3173 293.15
3174 } else {
3175 model
3176 .materials
3177 .iter()
3178 .map(|material| material.thermal.reference_temperature_k.max(1.0))
3179 .sum::<f64>()
3180 / model.materials.len() as f64
3181 }
3182}
3183
3184fn acoustic_drive_frequency_hz(mode_count: usize, node_count: usize) -> f64 {
3185 (125.0 * mode_count.max(1) as f64 * (node_count as f64).sqrt()).clamp(50.0, 20_000.0)
3186}
3187
3188#[derive(Debug, Clone, Copy)]
3189struct AcousticDomainTopology {
3190 node_count: usize,
3191 dims: [usize; 3],
3192 spacing: [f64; 3],
3193 edge_count: usize,
3194 boundary_node_count: usize,
3195 active_dimension_count: usize,
3196}
3197
3198impl AcousticDomainTopology {
3199 fn coords(self, index: usize) -> [usize; 3] {
3200 let x_dim = self.dims[0].max(1);
3201 let y_dim = self.dims[1].max(1);
3202 let plane = x_dim.saturating_mul(y_dim).max(1);
3203 let z = index / plane;
3204 let rem = index % plane;
3205 let y = rem / x_dim;
3206 let x = rem % x_dim;
3207 [x, y, z]
3208 }
3209
3210 fn index(self, coords: [usize; 3]) -> Option<usize> {
3211 if coords
3212 .iter()
3213 .zip(self.dims.iter())
3214 .any(|(coord, dim)| *coord >= *dim)
3215 {
3216 return None;
3217 }
3218 let index = coords[0]
3219 + coords[1].saturating_mul(self.dims[0])
3220 + coords[2].saturating_mul(self.dims[0].saturating_mul(self.dims[1]));
3221 (index < self.node_count).then_some(index)
3222 }
3223
3224 fn is_boundary_node(self, index: usize) -> bool {
3225 let coords = self.coords(index);
3226 coords
3227 .iter()
3228 .zip(self.dims.iter())
3229 .any(|(coord, dim)| *dim > 1 && (*coord == 0 || *coord + 1 == *dim))
3230 }
3231
3232 fn average_node_degree(self) -> f64 {
3233 if self.node_count == 0 {
3234 0.0
3235 } else {
3236 2.0 * self.edge_count as f64 / self.node_count as f64
3237 }
3238 }
3239
3240 fn volume_m3(self) -> f64 {
3241 self.spacing
3242 .iter()
3243 .zip(self.dims.iter())
3244 .map(|(spacing, dim)| spacing * dim.saturating_sub(1).max(1) as f64)
3245 .product::<f64>()
3246 .max(1.0e-12)
3247 }
3248}
3249
3250#[derive(Debug, Clone, Copy)]
3251struct AcousticGraphEdge {
3252 left: usize,
3253 right: usize,
3254 stiffness: f64,
3255}
3256
3257#[derive(Debug, Clone)]
3258struct AcousticDomainSystem {
3259 diag_real: Vec<f64>,
3260 diag_imag: Vec<f64>,
3261 edges: Vec<AcousticGraphEdge>,
3262}
3263
3264fn acoustic_domain_topology(
3265 node_count: usize,
3266 prep_context: Option<&AnalysisRunPrepContext>,
3267) -> AcousticDomainTopology {
3268 let n = node_count.max(1);
3269 let volume_hint = prep_context
3270 .map(|prep| {
3271 prep.topology_volume_core_ratio
3272 + prep.topology_tetrahedron_family_ratio
3273 + prep.topology_hex_family_ratio
3274 })
3275 .unwrap_or(0.0);
3276 let z_dim = if n >= 8 && volume_hint > 0.05 {
3277 (n as f64).cbrt().round().max(2.0) as usize
3278 } else if n >= 24 {
3279 2
3280 } else {
3281 1
3282 };
3283 let y_dim = if n >= 3 {
3284 ((n as f64 / z_dim as f64).sqrt().ceil() as usize).max(2)
3285 } else {
3286 1
3287 };
3288 let x_dim = n.div_ceil(y_dim * z_dim).max(1);
3289 let dims = [x_dim, y_dim, z_dim];
3290 let spacing = dims.map(|dim| {
3291 if dim <= 1 {
3292 1.0
3293 } else {
3294 1.0 / dim.saturating_sub(1) as f64
3295 }
3296 });
3297 let mut edge_count = 0usize;
3298 let mut boundary_node_count = 0usize;
3299 let topology = AcousticDomainTopology {
3300 node_count: n,
3301 dims,
3302 spacing,
3303 edge_count: 0,
3304 boundary_node_count: 0,
3305 active_dimension_count: dims.iter().filter(|dim| **dim > 1).count(),
3306 };
3307 for node in 0..n {
3308 if topology.is_boundary_node(node) {
3309 boundary_node_count += 1;
3310 }
3311 let coords = topology.coords(node);
3312 for axis in 0..3 {
3313 if coords[axis] + 1 >= topology.dims[axis] {
3314 continue;
3315 }
3316 let mut next = coords;
3317 next[axis] += 1;
3318 if topology.index(next).is_some() {
3319 edge_count += 1;
3320 }
3321 }
3322 }
3323 AcousticDomainTopology {
3324 edge_count,
3325 boundary_node_count,
3326 ..topology
3327 }
3328}
3329
3330fn acoustic_source_vector(model: &AnalysisModel, node_count: usize) -> Vec<f64> {
3331 let mut source = vec![0.0; node_count.max(1)];
3332 for (index, load) in model.loads.iter().enumerate() {
3333 let node = (index * 3 + load.region_id.len()) % source.len();
3334 let amplitude = match &load.kind {
3335 LoadKind::Pressure { magnitude_pa } => *magnitude_pa,
3336 _ => 0.0,
3337 };
3338 source[node] += amplitude;
3339 }
3340 source
3341}
3342
3343fn acoustic_helmholtz_operator(
3344 topology: AcousticDomainTopology,
3345 node_count: usize,
3346 drive_frequency_hz: f64,
3347 speed_of_sound_m_per_s: f64,
3348 damping_ratio: f64,
3349 boundary_summary: &AcousticBoundarySummary,
3350) -> AcousticDomainSystem {
3351 let n = node_count.max(1);
3352 let omega = 2.0 * std::f64::consts::PI * drive_frequency_hz.max(1.0);
3353 let wave_number = omega / speed_of_sound_m_per_s.max(1.0);
3354 let mut diag_real = vec![0.0; n];
3355 let mut diag_imag = vec![0.0; n];
3356 let mut edges = Vec::with_capacity(topology.edge_count);
3357 for node in 0..n {
3358 let coords = topology.coords(node);
3359 for axis in 0..3 {
3360 if coords[axis] + 1 >= topology.dims[axis] {
3361 continue;
3362 }
3363 let mut next = coords;
3364 next[axis] += 1;
3365 let Some(next_index) = topology.index(next) else {
3366 continue;
3367 };
3368 let stiffness = 1.0 / topology.spacing[axis].max(1.0e-9).powi(2);
3369 diag_real[node] += stiffness;
3370 diag_real[next_index] += stiffness;
3371 edges.push(AcousticGraphEdge {
3372 left: node,
3373 right: next_index,
3374 stiffness,
3375 });
3376 }
3377 }
3378 let mass_term =
3379 (wave_number * topology.volume_m3().cbrt()).powi(2) / topology.node_count.max(1) as f64;
3380 let damping = (2.0 * damping_ratio.max(0.0) * mass_term.max(1.0e-9)).max(1.0e-9);
3381 let boundary_loss = (boundary_summary.radiation_loss_factor
3382 + boundary_summary.impedance_loss_factor)
3383 * wave_number.abs().max(1.0e-9)
3384 / topology.boundary_node_count.max(1) as f64;
3385 for node in 0..n {
3386 diag_real[node] -= mass_term;
3387 diag_imag[node] += damping;
3388 if topology.is_boundary_node(node) {
3389 diag_imag[node] += boundary_loss;
3390 diag_real[node] += 0.02 * boundary_summary.rigid_wall_count as f64;
3391 }
3392 }
3393 AcousticDomainSystem {
3394 diag_real,
3395 diag_imag,
3396 edges,
3397 }
3398}
3399
3400fn solve_complex_graph_operator(
3401 system: &AcousticDomainSystem,
3402 source_real: &[f64],
3403 source_imag: &[f64],
3404) -> (Vec<f64>, Vec<f64>) {
3405 let n = system.diag_real.len().max(1);
3406 let mut matrix_real = vec![vec![0.0; n]; n];
3407 let mut matrix_imag = vec![vec![0.0; n]; n];
3408 for row in 0..n {
3409 matrix_real[row][row] = system.diag_real[row];
3410 matrix_imag[row][row] = system.diag_imag[row];
3411 }
3412 for edge in &system.edges {
3413 matrix_real[edge.left][edge.right] -= edge.stiffness;
3414 matrix_real[edge.right][edge.left] -= edge.stiffness;
3415 }
3416 let mut rhs_real = (0..n)
3417 .map(|index| source_real.get(index).copied().unwrap_or(0.0))
3418 .collect::<Vec<_>>();
3419 let mut rhs_imag = (0..n)
3420 .map(|index| source_imag.get(index).copied().unwrap_or(0.0))
3421 .collect::<Vec<_>>();
3422 solve_dense_complex_system(
3423 &mut matrix_real,
3424 &mut matrix_imag,
3425 &mut rhs_real,
3426 &mut rhs_imag,
3427 )
3428}
3429
3430fn acoustic_residual_norm(
3431 system: &AcousticDomainSystem,
3432 pressure_real: &[f64],
3433 pressure_imag: &[f64],
3434 source_real: &[f64],
3435 source_imag: &[f64],
3436) -> f64 {
3437 let mut residual_sq = 0.0_f64;
3438 for i in 0..system.diag_real.len() {
3439 let mut applied_real =
3440 system.diag_real[i] * pressure_real[i] - system.diag_imag[i] * pressure_imag[i];
3441 let mut applied_imag =
3442 system.diag_real[i] * pressure_imag[i] + system.diag_imag[i] * pressure_real[i];
3443 for edge in system
3444 .edges
3445 .iter()
3446 .filter(|edge| edge.left == i || edge.right == i)
3447 {
3448 let neighbor = if edge.left == i {
3449 edge.right
3450 } else {
3451 edge.left
3452 };
3453 applied_real -= edge.stiffness * pressure_real[neighbor];
3454 applied_imag -= edge.stiffness * pressure_imag[neighbor];
3455 }
3456 let real = applied_real - source_real.get(i).copied().unwrap_or(0.0);
3457 let imag = applied_imag - source_imag.get(i).copied().unwrap_or(0.0);
3458 residual_sq += real * real + imag * imag;
3459 }
3460 residual_sq.sqrt() / source_norm(source_real, source_imag).max(1.0)
3461}
3462
3463fn source_norm(source_real: &[f64], source_imag: &[f64]) -> f64 {
3464 source_real
3465 .iter()
3466 .zip(source_imag.iter().chain(std::iter::repeat(&0.0)))
3467 .map(|(real, imag)| real * real + imag * imag)
3468 .sum::<f64>()
3469 .sqrt()
3470}
3471
3472fn solve_dense_complex_system(
3473 matrix_real: &mut [Vec<f64>],
3474 matrix_imag: &mut [Vec<f64>],
3475 rhs_real: &mut [f64],
3476 rhs_imag: &mut [f64],
3477) -> (Vec<f64>, Vec<f64>) {
3478 let n = rhs_real.len();
3479 for pivot in 0..n {
3480 let mut pivot_row = pivot;
3481 let mut pivot_norm = complex_abs_sq(matrix_real[pivot][pivot], matrix_imag[pivot][pivot]);
3482 for candidate in pivot + 1..n {
3483 let candidate_norm =
3484 complex_abs_sq(matrix_real[candidate][pivot], matrix_imag[candidate][pivot]);
3485 if candidate_norm > pivot_norm {
3486 pivot_row = candidate;
3487 pivot_norm = candidate_norm;
3488 }
3489 }
3490 if pivot_row != pivot {
3491 matrix_real.swap(pivot, pivot_row);
3492 matrix_imag.swap(pivot, pivot_row);
3493 rhs_real.swap(pivot, pivot_row);
3494 rhs_imag.swap(pivot, pivot_row);
3495 }
3496 if pivot_norm <= 1.0e-24 {
3497 matrix_real[pivot][pivot] += 1.0e-9;
3498 }
3499 let (pivot_inv_real, pivot_inv_imag) =
3500 complex_recip(matrix_real[pivot][pivot], matrix_imag[pivot][pivot]);
3501 for row in pivot + 1..n {
3502 let (factor_real, factor_imag) = complex_mul(
3503 matrix_real[row][pivot],
3504 matrix_imag[row][pivot],
3505 pivot_inv_real,
3506 pivot_inv_imag,
3507 );
3508 matrix_real[row][pivot] = 0.0;
3509 matrix_imag[row][pivot] = 0.0;
3510 for col in pivot + 1..n {
3511 let (update_real, update_imag) = complex_mul(
3512 factor_real,
3513 factor_imag,
3514 matrix_real[pivot][col],
3515 matrix_imag[pivot][col],
3516 );
3517 matrix_real[row][col] -= update_real;
3518 matrix_imag[row][col] -= update_imag;
3519 }
3520 let (rhs_update_real, rhs_update_imag) =
3521 complex_mul(factor_real, factor_imag, rhs_real[pivot], rhs_imag[pivot]);
3522 rhs_real[row] -= rhs_update_real;
3523 rhs_imag[row] -= rhs_update_imag;
3524 }
3525 }
3526
3527 let mut solution_real = vec![0.0; n];
3528 let mut solution_imag = vec![0.0; n];
3529 for row in (0..n).rev() {
3530 let mut accum_real = rhs_real[row];
3531 let mut accum_imag = rhs_imag[row];
3532 for col in row + 1..n {
3533 let (update_real, update_imag) = complex_mul(
3534 matrix_real[row][col],
3535 matrix_imag[row][col],
3536 solution_real[col],
3537 solution_imag[col],
3538 );
3539 accum_real -= update_real;
3540 accum_imag -= update_imag;
3541 }
3542 let (inv_real, inv_imag) = complex_recip(matrix_real[row][row], matrix_imag[row][row]);
3543 (solution_real[row], solution_imag[row]) =
3544 complex_mul(accum_real, accum_imag, inv_real, inv_imag);
3545 }
3546 (solution_real, solution_imag)
3547}
3548
3549fn complex_abs_sq(real: f64, imag: f64) -> f64 {
3550 real * real + imag * imag
3551}
3552
3553fn complex_recip(real: f64, imag: f64) -> (f64, f64) {
3554 let denom = (real * real + imag * imag).max(1.0e-24);
3555 (real / denom, -imag / denom)
3556}
3557
3558fn complex_mul(a_real: f64, a_imag: f64, b_real: f64, b_imag: f64) -> (f64, f64) {
3559 (
3560 a_real * b_real - a_imag * b_imag,
3561 a_real * b_imag + a_imag * b_real,
3562 )
3563}
3564
3565fn recover_acoustic_particle_velocity(
3566 pressure_real: &[f64],
3567 topology: AcousticDomainTopology,
3568 drive_frequency_hz: f64,
3569 density_kg_per_m3: f64,
3570) -> Vec<f64> {
3571 let node_count = pressure_real.len().max(1);
3572 let omega = (2.0 * std::f64::consts::PI * drive_frequency_hz).max(1.0e-12);
3573 let impedance_scale = (density_kg_per_m3.max(1.0e-12) * omega).max(1.0e-12);
3574 let mut velocity = vec![0.0; node_count * 3];
3575 for node in 0..pressure_real.len() {
3576 for axis in 0..3 {
3577 velocity[node * 3 + axis] =
3578 -acoustic_axis_derivative(pressure_real, topology, node, axis) / impedance_scale;
3579 }
3580 }
3581 velocity
3582}
3583
3584fn acoustic_axis_derivative(
3585 pressure: &[f64],
3586 topology: AcousticDomainTopology,
3587 index: usize,
3588 axis: usize,
3589) -> f64 {
3590 if topology.dims[axis] <= 1 {
3591 return 0.0;
3592 }
3593 let coords = topology.coords(index);
3594 let mut prev_coords = coords;
3595 let prev_index = if coords[axis] > 0 {
3596 prev_coords[axis] -= 1;
3597 topology.index(prev_coords)
3598 } else {
3599 None
3600 };
3601 let mut next_coords = coords;
3602 let next_index = if coords[axis] + 1 < topology.dims[axis] {
3603 next_coords[axis] += 1;
3604 topology.index(next_coords)
3605 } else {
3606 None
3607 };
3608 let spacing = topology.spacing[axis].max(1.0e-12);
3609 match (prev_index, next_index) {
3610 (Some(prev), Some(next)) => (pressure[next] - pressure[prev]) / (2.0 * spacing),
3611 (Some(prev), None) => (pressure[index] - pressure[prev]) / spacing,
3612 (None, Some(next)) => (pressure[next] - pressure[index]) / spacing,
3613 (None, None) => 0.0,
3614 }
3615}
3616
3617fn cfd_reynolds_number(domain: &runmat_analysis_core::CfdDomain) -> f64 {
3618 cfd_reynolds_number_for_velocity(domain, domain.inlet_velocity_m_per_s)
3619}
3620
3621fn cfd_reynolds_number_for_velocity(
3622 domain: &runmat_analysis_core::CfdDomain,
3623 inlet_velocity_m_per_s: f64,
3624) -> f64 {
3625 domain.reference_density_kg_per_m3 * inlet_velocity_m_per_s.abs()
3626 / domain.dynamic_viscosity_pa_s
3627}
3628
3629fn cfd_profile_scale(domain: &runmat_analysis_core::CfdDomain, step_index: usize) -> f64 {
3630 domain
3631 .time_profile
3632 .get(step_index)
3633 .map(|point| point.inlet_scale)
3634 .filter(|scale| scale.is_finite() && *scale >= 0.0)
3635 .unwrap_or(1.0)
3636}
3637
3638fn cfd_node_count_from_model(
3639 model: &AnalysisModel,
3640 prep_context: Option<&AnalysisRunPrepContext>,
3641) -> usize {
3642 prep_context
3643 .map(|prep| prep.prepared_node_count.max(3))
3644 .unwrap_or_else(|| model.loads.len().saturating_mul(3).max(3))
3645 .min(512)
3646}
3647
3648#[derive(Clone, Debug)]
3649struct CfdDomainTopology {
3650 basis: CfdDomainTopologyBasis,
3651 geometry_source: CfdDomainGeometrySource,
3652 node_count: usize,
3653 control_volume_count: usize,
3654 control_volume_face_count: usize,
3655 control_volume_internal_face_count: usize,
3656 control_volume_boundary_face_count: usize,
3657 control_volume_connectivity_coverage_ratio: f64,
3658 domain_length_m: f64,
3659 hydraulic_diameter_m: f64,
3660 face_area_m2: f64,
3661 dx_m: f64,
3662 active_dimension_count: usize,
3663 element_geometry_node_count: usize,
3664 element_geometry_edge_count: usize,
3665 element_geometry_coverage_ratio: f64,
3666 element_topology_sample_element_count: usize,
3667 element_topology_sample_edge_count: usize,
3668 element_topology_sample_element_edges: [[u32; 3]; 4],
3669 element_topology_edge_nodes: Vec<[u32; 2]>,
3670 element_topology_element_edges: Vec<[u32; 3]>,
3671}
3672
3673impl CfdDomainTopology {
3674 fn from_model(model: &AnalysisModel, prep_context: Option<&AnalysisRunPrepContext>) -> Self {
3675 let node_count = cfd_node_count_from_model(model, prep_context);
3676 match prep_context {
3677 Some(prep) => Self::from_prep(node_count, prep),
3678 None => Self::implicit_channel(node_count),
3679 }
3680 }
3681
3682 fn implicit_channel(node_count: usize) -> Self {
3683 let node_count = node_count.max(2);
3684 let control_volume_count = node_count.saturating_sub(1).max(1);
3685 Self {
3686 basis: CfdDomainTopologyBasis::ImplicitChannel,
3687 geometry_source: CfdDomainGeometrySource::ImplicitChannel,
3688 node_count,
3689 control_volume_count,
3690 control_volume_face_count: control_volume_count.saturating_add(1),
3691 control_volume_internal_face_count: control_volume_count.saturating_sub(1),
3692 control_volume_boundary_face_count: 2,
3693 control_volume_connectivity_coverage_ratio: 0.0,
3694 domain_length_m: 1.0,
3695 hydraulic_diameter_m: 1.0,
3696 face_area_m2: 1.0,
3697 dx_m: 1.0 / control_volume_count as f64,
3698 active_dimension_count: 1,
3699 element_geometry_node_count: 0,
3700 element_geometry_edge_count: 0,
3701 element_geometry_coverage_ratio: 0.0,
3702 element_topology_sample_element_count: 0,
3703 element_topology_sample_edge_count: 0,
3704 element_topology_sample_element_edges: [[0; 3]; 4],
3705 element_topology_edge_nodes: Vec::new(),
3706 element_topology_element_edges: Vec::new(),
3707 }
3708 }
3709
3710 fn from_prep(node_count: usize, prep: &AnalysisRunPrepContext) -> Self {
3711 let has_control_volume_connectivity = prep.control_volume_cell_count > 0
3712 && prep.control_volume_face_count > 0
3713 && prep.control_volume_connectivity_coverage_ratio > 0.0;
3714 let control_volume_count = if has_control_volume_connectivity {
3715 prep.control_volume_cell_count.max(1)
3716 } else {
3717 node_count.max(2).saturating_sub(1).max(1)
3718 };
3719 let node_count = if has_control_volume_connectivity {
3720 control_volume_count.saturating_add(1).max(2)
3721 } else {
3722 node_count.max(2)
3723 };
3724 let fallback_length = finite_positive_or(prep.coordinate_characteristic_length_m, 1.0)
3725 * control_volume_count as f64;
3726 let domain_length_m = finite_positive_or(prep.coordinate_span_x_m, fallback_length);
3727 let transverse_y = finite_positive_or(prep.coordinate_span_y_m, 0.0);
3728 let transverse_z = finite_positive_or(prep.coordinate_span_z_m, 0.0);
3729 let hydraulic_diameter_m = if transverse_y > 0.0 && transverse_z > 0.0 {
3730 (2.0 * transverse_y * transverse_z / (transverse_y + transverse_z)).max(1.0e-12)
3731 } else {
3732 finite_positive_or(prep.coordinate_characteristic_length_m, 1.0)
3733 };
3734 let coordinate_face_area_m2 = hydraulic_diameter_m.max(1.0e-12).powi(2);
3735 let has_element_geometry = prep.element_geometry_coverage_ratio > 0.0
3736 && prep.mean_element_area_m2.is_finite()
3737 && prep.mean_element_area_m2 > 0.0;
3738 let face_area_m2 = if has_element_geometry {
3739 prep.mean_element_area_m2
3740 } else {
3741 coordinate_face_area_m2
3742 };
3743 let hydraulic_diameter_m = if has_element_geometry {
3744 (4.0 * face_area_m2 / std::f64::consts::PI)
3745 .sqrt()
3746 .max(1.0e-12)
3747 } else {
3748 hydraulic_diameter_m
3749 };
3750 Self {
3751 basis: CfdDomainTopologyBasis::PrepControlVolumeConnectivity,
3752 geometry_source: if has_element_geometry {
3753 CfdDomainGeometrySource::PrepElementGeometry
3754 } else {
3755 CfdDomainGeometrySource::CoordinateSpan
3756 },
3757 node_count,
3758 control_volume_count,
3759 control_volume_face_count: if has_control_volume_connectivity {
3760 prep.control_volume_face_count
3761 } else {
3762 control_volume_count.saturating_add(1)
3763 },
3764 control_volume_internal_face_count: if has_control_volume_connectivity {
3765 prep.control_volume_internal_face_count
3766 } else {
3767 control_volume_count.saturating_sub(1)
3768 },
3769 control_volume_boundary_face_count: if has_control_volume_connectivity {
3770 prep.control_volume_boundary_face_count
3771 } else {
3772 2
3773 },
3774 control_volume_connectivity_coverage_ratio: prep
3775 .control_volume_connectivity_coverage_ratio
3776 .clamp(0.0, 1.0),
3777 domain_length_m,
3778 hydraulic_diameter_m,
3779 face_area_m2,
3780 dx_m: domain_length_m / control_volume_count as f64,
3781 active_dimension_count: prep.coordinate_active_dimension_count.max(1),
3782 element_geometry_node_count: prep.element_geometry_node_count,
3783 element_geometry_edge_count: prep.element_geometry_edge_count,
3784 element_geometry_coverage_ratio: prep.element_geometry_coverage_ratio.clamp(0.0, 1.0),
3785 element_topology_sample_element_count: prep.element_topology_sample_element_count,
3786 element_topology_sample_edge_count: prep.element_topology_sample_edge_count,
3787 element_topology_sample_element_edges: prep.element_topology_sample_element_edges,
3788 element_topology_edge_nodes: prep.element_topology_edge_nodes.clone(),
3789 element_topology_element_edges: prep.element_topology_element_edges.clone(),
3790 }
3791 }
3792
3793 fn face_area_m2(&self) -> f64 {
3794 self.face_area_m2.max(1.0e-12)
3795 }
3796
3797 fn control_volume_volume_m3(&self) -> f64 {
3798 self.face_area_m2() * self.dx_m.max(1.0e-12)
3799 }
3800}
3801
3802#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3803enum CfdDomainTopologyBasis {
3804 PrepControlVolumeConnectivity,
3805 ImplicitChannel,
3806}
3807
3808impl CfdDomainTopologyBasis {
3809 fn as_str(self) -> &'static str {
3810 match self {
3811 Self::PrepControlVolumeConnectivity => "prep_control_volume_connectivity",
3812 Self::ImplicitChannel => "implicit_channel",
3813 }
3814 }
3815}
3816
3817#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3818enum CfdDomainGeometrySource {
3819 ImplicitChannel,
3820 CoordinateSpan,
3821 PrepElementGeometry,
3822}
3823
3824impl CfdDomainGeometrySource {
3825 fn as_str(self) -> &'static str {
3826 match self {
3827 Self::ImplicitChannel => "implicit_channel",
3828 Self::CoordinateSpan => "coordinate_span",
3829 Self::PrepElementGeometry => "prep_element_geometry",
3830 }
3831 }
3832}
3833
3834fn finite_positive_or(value: f64, fallback: f64) -> f64 {
3835 if value.is_finite() && value > 0.0 {
3836 value
3837 } else {
3838 fallback
3839 }
3840}
3841
3842#[derive(Clone, Debug)]
3843struct CfdBoundarySummary {
3844 inlet_boundary_count: usize,
3845 outlet_boundary_count: usize,
3846 no_slip_wall_boundary_count: usize,
3847 slip_wall_boundary_count: usize,
3848 symmetry_boundary_count: usize,
3849 authored_boundary_count: usize,
3850 boundary_coverage_ratio: f64,
3851 wall_boundary_coverage_ratio: f64,
3852 nominal_inlet_velocity_m_per_s: f64,
3853 outlet_pressure_pa: f64,
3854}
3855
3856impl CfdBoundarySummary {
3857 fn implicit_channel(domain: &runmat_analysis_core::CfdDomain, node_count: usize) -> Self {
3858 Self {
3859 inlet_boundary_count: 1,
3860 outlet_boundary_count: 1,
3861 no_slip_wall_boundary_count: node_count.saturating_sub(1).max(1),
3862 slip_wall_boundary_count: 0,
3863 symmetry_boundary_count: 0,
3864 authored_boundary_count: 0,
3865 boundary_coverage_ratio: 1.0,
3866 wall_boundary_coverage_ratio: 1.0,
3867 nominal_inlet_velocity_m_per_s: domain.inlet_velocity_m_per_s,
3868 outlet_pressure_pa: 0.0,
3869 }
3870 }
3871
3872 fn from_model(
3873 model: &AnalysisModel,
3874 domain: &runmat_analysis_core::CfdDomain,
3875 node_count: usize,
3876 ) -> Self {
3877 let mut inlet_velocity_sum = 0.0_f64;
3878 let mut outlet_pressure_sum = 0.0_f64;
3879 let mut summary = Self {
3880 inlet_boundary_count: 0,
3881 outlet_boundary_count: 0,
3882 no_slip_wall_boundary_count: 0,
3883 slip_wall_boundary_count: 0,
3884 symmetry_boundary_count: 0,
3885 authored_boundary_count: 0,
3886 boundary_coverage_ratio: 0.0,
3887 wall_boundary_coverage_ratio: 0.0,
3888 nominal_inlet_velocity_m_per_s: domain.inlet_velocity_m_per_s,
3889 outlet_pressure_pa: 0.0,
3890 };
3891
3892 for boundary in &model.boundary_conditions {
3893 match &boundary.kind {
3894 BoundaryConditionKind::CfdInletVelocity { velocity_m_per_s } => {
3895 summary.inlet_boundary_count += 1;
3896 summary.authored_boundary_count += 1;
3897 inlet_velocity_sum += *velocity_m_per_s;
3898 }
3899 BoundaryConditionKind::CfdOutletPressure { pressure_pa } => {
3900 summary.outlet_boundary_count += 1;
3901 summary.authored_boundary_count += 1;
3902 outlet_pressure_sum += *pressure_pa;
3903 }
3904 BoundaryConditionKind::CfdNoSlipWall => {
3905 summary.no_slip_wall_boundary_count += 1;
3906 summary.authored_boundary_count += 1;
3907 }
3908 BoundaryConditionKind::CfdSlipWall => {
3909 summary.slip_wall_boundary_count += 1;
3910 summary.authored_boundary_count += 1;
3911 }
3912 BoundaryConditionKind::CfdSymmetry => {
3913 summary.symmetry_boundary_count += 1;
3914 summary.authored_boundary_count += 1;
3915 }
3916 _ => {}
3917 }
3918 }
3919
3920 if summary.authored_boundary_count == 0 {
3921 return Self::implicit_channel(domain, node_count);
3922 }
3923
3924 if summary.inlet_boundary_count > 0 {
3925 summary.nominal_inlet_velocity_m_per_s =
3926 inlet_velocity_sum / summary.inlet_boundary_count as f64;
3927 }
3928 if summary.outlet_boundary_count > 0 {
3929 summary.outlet_pressure_pa = outlet_pressure_sum / summary.outlet_boundary_count as f64;
3930 }
3931
3932 let wall_like_count = summary.no_slip_wall_boundary_count
3933 + summary.slip_wall_boundary_count
3934 + summary.symmetry_boundary_count;
3935 let required_groups_present = usize::from(summary.inlet_boundary_count > 0)
3936 + usize::from(summary.outlet_boundary_count > 0)
3937 + usize::from(wall_like_count > 0);
3938 summary.boundary_coverage_ratio = required_groups_present as f64 / 3.0;
3939 summary.wall_boundary_coverage_ratio = if wall_like_count > 0 { 1.0 } else { 0.0 };
3940 summary
3941 }
3942
3943 fn wall_boundary_count(&self) -> usize {
3944 self.no_slip_wall_boundary_count + self.slip_wall_boundary_count
3945 }
3946}
3947
3948fn validate_authored_cfd_boundary_conditions(model: &AnalysisModel) -> Result<(), String> {
3949 let mut inlet_boundary_count = 0usize;
3950 let mut outlet_boundary_count = 0usize;
3951 let mut wall_like_boundary_count = 0usize;
3952 let mut authored_boundary_count = 0usize;
3953
3954 for boundary in &model.boundary_conditions {
3955 match &boundary.kind {
3956 BoundaryConditionKind::CfdInletVelocity { velocity_m_per_s } => {
3957 authored_boundary_count += 1;
3958 inlet_boundary_count += 1;
3959 if !velocity_m_per_s.is_finite() || *velocity_m_per_s < 0.0 {
3960 return Err(format!(
3961 "cfd inlet boundary {} requires finite non-negative velocity_m_per_s",
3962 boundary.bc_id
3963 ));
3964 }
3965 }
3966 BoundaryConditionKind::CfdOutletPressure { pressure_pa } => {
3967 authored_boundary_count += 1;
3968 outlet_boundary_count += 1;
3969 if !pressure_pa.is_finite() {
3970 return Err(format!(
3971 "cfd outlet boundary {} requires finite pressure_pa",
3972 boundary.bc_id
3973 ));
3974 }
3975 }
3976 BoundaryConditionKind::CfdNoSlipWall
3977 | BoundaryConditionKind::CfdSlipWall
3978 | BoundaryConditionKind::CfdSymmetry => {
3979 authored_boundary_count += 1;
3980 wall_like_boundary_count += 1;
3981 }
3982 _ => {}
3983 }
3984 }
3985
3986 if authored_boundary_count == 0 {
3987 return Ok(());
3988 }
3989 if inlet_boundary_count == 0 || outlet_boundary_count == 0 || wall_like_boundary_count == 0 {
3990 return Err(format!(
3991 "authored cfd boundaries require at least one inlet, outlet, and wall/symmetry boundary; got inlet={} outlet={} wall_like={}",
3992 inlet_boundary_count, outlet_boundary_count, wall_like_boundary_count,
3993 ));
3994 }
3995 Ok(())
3996}
3997
3998#[derive(Clone, Debug)]
3999struct CfdVelocityPressureSolution {
4000 topology: CfdDomainTopology,
4001 velocity: Vec<f64>,
4002 pressure: Vec<f64>,
4003 residual_momentum: Vec<f64>,
4004 residual_continuity: Vec<f64>,
4005 mass_balance_residual: f64,
4006 pressure_drop_pa: f64,
4007 control_volume_count: usize,
4008 inlet_boundary_count: usize,
4009 outlet_boundary_count: usize,
4010 wall_boundary_count: usize,
4011 no_slip_wall_boundary_count: usize,
4012 slip_wall_boundary_count: usize,
4013 symmetry_boundary_count: usize,
4014 authored_boundary_count: usize,
4015 boundary_coverage_ratio: f64,
4016 wall_boundary_coverage_ratio: f64,
4017 inlet_velocity_realization_ratio: f64,
4018 nominal_inlet_velocity_m_per_s: f64,
4019 outlet_pressure_pa: f64,
4020 pressure_correction_iteration_count: usize,
4021 pressure_correction_residual_ratio: f64,
4022 velocity_correction_residual_ratio: f64,
4023 transient_scale_min: f64,
4024 transient_scale_max: f64,
4025 transient_scale_variation: f64,
4026}
4027
4028#[derive(Clone, Debug)]
4029struct CfdKnownAnswerMetrics {
4030 pressure_drop_balance_ratio: f64,
4031 mass_flux_uniformity_ratio: f64,
4032 pressure_monotonic_cell_fraction: f64,
4033 known_answer_coverage_ratio: f64,
4034}
4035
4036fn recover_cfd_velocity_pressure(
4037 domain: &runmat_analysis_core::CfdDomain,
4038 topology: &CfdDomainTopology,
4039 step_index: usize,
4040) -> (Vec<f64>, Vec<f64>) {
4041 let boundary_summary = CfdBoundarySummary::implicit_channel(domain, topology.node_count);
4042 let solution = solve_cfd_velocity_pressure(
4043 domain,
4044 &boundary_summary,
4045 topology,
4046 step_index,
4047 1,
4048 32,
4049 1.0e-8,
4050 );
4051 (solution.velocity, solution.pressure)
4052}
4053
4054fn solve_cfd_velocity_pressure(
4055 domain: &runmat_analysis_core::CfdDomain,
4056 boundary_summary: &CfdBoundarySummary,
4057 topology: &CfdDomainTopology,
4058 step_index: usize,
4059 step_count: usize,
4060 max_linear_iters: usize,
4061 tolerance: f64,
4062) -> CfdVelocityPressureSolution {
4063 let node_count = topology.node_count.max(2);
4064 let profile_scale = cfd_profile_scale(domain, step_index);
4065 let nominal_inlet_velocity = boundary_summary.nominal_inlet_velocity_m_per_s;
4066 let inlet_velocity = nominal_inlet_velocity * profile_scale;
4067 let reynolds = cfd_reynolds_number_for_velocity(domain, nominal_inlet_velocity).max(1.0);
4068 let hydraulic_diameter_m = topology.hydraulic_diameter_m;
4069 let friction_factor = if reynolds <= 2300.0 {
4070 64.0 / reynolds
4071 } else {
4072 0.3164 / reynolds.powf(0.25)
4073 };
4074 let friction_gradient_pa_per_m = 0.5
4075 * domain.reference_density_kg_per_m3
4076 * inlet_velocity
4077 * inlet_velocity.abs()
4078 * friction_factor
4079 / hydraulic_diameter_m;
4080 let pressure_drop_pa = (friction_gradient_pa_per_m * topology.domain_length_m).max(0.0);
4081 let denom = node_count.saturating_sub(1).max(1) as f64;
4082 let mut axial_velocity = vec![inlet_velocity; node_count];
4083 let mut pressure = (0..node_count)
4084 .map(|node| {
4085 let xi = node as f64 / denom;
4086 boundary_summary.outlet_pressure_pa + 0.5 * pressure_drop_pa * (1.0 - xi)
4087 })
4088 .collect::<Vec<_>>();
4089 let target_pressure = (0..node_count)
4090 .map(|node| {
4091 let xi = node as f64 / denom;
4092 boundary_summary.outlet_pressure_pa + pressure_drop_pa * (1.0 - xi)
4093 })
4094 .collect::<Vec<_>>();
4095 let correction_iters = max_linear_iters.max(1);
4096 let correction_tolerance = tolerance.max(1.0e-12);
4097 let mut pressure_correction_residual_ratio = f64::INFINITY;
4098 let mut velocity_correction_residual_ratio = f64::INFINITY;
4099 let mut pressure_correction_iteration_count = 0usize;
4100 for iteration in 0..correction_iters {
4101 let previous_pressure = pressure.clone();
4102 let previous_velocity = axial_velocity.clone();
4103 for node in 0..node_count {
4104 pressure[node] = 0.35 * pressure[node] + 0.65 * target_pressure[node];
4105 }
4106 for node in 0..node_count {
4107 if node == 0 {
4108 axial_velocity[node] = inlet_velocity;
4109 continue;
4110 }
4111 if node + 1 == node_count {
4112 axial_velocity[node] = axial_velocity[node.saturating_sub(1)];
4113 continue;
4114 }
4115 let gradient = (pressure[node + 1] - pressure[node - 1]) / (2.0 * topology.dx_m);
4116 let pressure_driven_speed = ((-2.0 * gradient * hydraulic_diameter_m)
4117 / (domain.reference_density_kg_per_m3.max(1.0e-12) * friction_factor.max(1.0e-12)))
4118 .max(0.0)
4119 .sqrt();
4120 axial_velocity[node] = 0.50 * axial_velocity[node] + 0.50 * pressure_driven_speed;
4121 }
4122 axial_velocity[node_count - 1] = axial_velocity[node_count - 2];
4123
4124 let pressure_correction_norm = pressure
4125 .iter()
4126 .zip(previous_pressure.iter())
4127 .map(|(current, previous)| (current - previous) * (current - previous))
4128 .sum::<f64>()
4129 .sqrt();
4130 let pressure_scale = target_pressure
4131 .iter()
4132 .map(|value| value * value)
4133 .sum::<f64>()
4134 .sqrt()
4135 .max(1.0);
4136 pressure_correction_residual_ratio = pressure_correction_norm / pressure_scale;
4137 let velocity_correction_norm = axial_velocity
4138 .iter()
4139 .zip(previous_velocity.iter())
4140 .map(|(current, previous)| (current - previous) * (current - previous))
4141 .sum::<f64>()
4142 .sqrt();
4143 let velocity_scale = axial_velocity
4144 .iter()
4145 .map(|value| value * value)
4146 .sum::<f64>()
4147 .sqrt()
4148 .max(inlet_velocity.abs())
4149 .max(1.0e-12);
4150 velocity_correction_residual_ratio = velocity_correction_norm / velocity_scale;
4151 pressure_correction_iteration_count = iteration + 1;
4152 if pressure_correction_residual_ratio <= correction_tolerance
4153 && velocity_correction_residual_ratio <= correction_tolerance
4154 {
4155 break;
4156 }
4157 }
4158 pressure = target_pressure;
4159
4160 let mut velocity = Vec::with_capacity(node_count * 3);
4161 for (node, axial) in axial_velocity.iter().copied().enumerate() {
4162 let xi = node as f64 / denom;
4163 let recirculation = (2.0 * std::f64::consts::PI * xi).sin()
4164 * axial
4165 * domain.turbulence_intensity.clamp(0.0, 1.0)
4166 * 0.02;
4167 velocity.extend_from_slice(&[axial, recirculation, 0.0]);
4168 }
4169
4170 let (residual_momentum, residual_continuity) =
4171 cfd_residual_norms(&velocity, &pressure, domain, topology, step_count);
4172 let mass_balance_residual = residual_continuity.iter().copied().fold(0.0_f64, f64::max);
4173 let inlet_velocity_realization_ratio =
4174 inlet_velocity.abs() / nominal_inlet_velocity.abs().max(1.0e-12);
4175 let (transient_scale_min, transient_scale_max) = cfd_transient_scale_bounds(domain);
4176
4177 CfdVelocityPressureSolution {
4178 topology: topology.clone(),
4179 velocity,
4180 pressure,
4181 residual_momentum,
4182 residual_continuity,
4183 mass_balance_residual,
4184 pressure_drop_pa,
4185 control_volume_count: topology.control_volume_count,
4186 inlet_boundary_count: boundary_summary.inlet_boundary_count,
4187 outlet_boundary_count: boundary_summary.outlet_boundary_count,
4188 wall_boundary_count: boundary_summary.wall_boundary_count(),
4189 no_slip_wall_boundary_count: boundary_summary.no_slip_wall_boundary_count,
4190 slip_wall_boundary_count: boundary_summary.slip_wall_boundary_count,
4191 symmetry_boundary_count: boundary_summary.symmetry_boundary_count,
4192 authored_boundary_count: boundary_summary.authored_boundary_count,
4193 boundary_coverage_ratio: boundary_summary.boundary_coverage_ratio,
4194 wall_boundary_coverage_ratio: boundary_summary.wall_boundary_coverage_ratio,
4195 inlet_velocity_realization_ratio,
4196 nominal_inlet_velocity_m_per_s: nominal_inlet_velocity,
4197 outlet_pressure_pa: boundary_summary.outlet_pressure_pa,
4198 pressure_correction_iteration_count,
4199 pressure_correction_residual_ratio,
4200 velocity_correction_residual_ratio,
4201 transient_scale_min,
4202 transient_scale_max,
4203 transient_scale_variation: (transient_scale_max - transient_scale_min).abs(),
4204 }
4205}
4206
4207fn cfd_transient_scale_bounds(domain: &runmat_analysis_core::CfdDomain) -> (f64, f64) {
4208 if domain.time_profile.is_empty() {
4209 return (1.0, 1.0);
4210 }
4211 let (min, max) = domain
4212 .time_profile
4213 .iter()
4214 .filter_map(|point| point.inlet_scale.is_finite().then_some(point.inlet_scale))
4215 .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), scale| {
4216 (min.min(scale), max.max(scale))
4217 });
4218 if min.is_finite() && max.is_finite() {
4219 (min, max)
4220 } else {
4221 (1.0, 1.0)
4222 }
4223}
4224
4225fn cfd_known_answer_metrics(solution: &CfdVelocityPressureSolution) -> CfdKnownAnswerMetrics {
4226 let node_count = solution.pressure.len();
4227 let pressure_drop_observed = match (solution.pressure.first(), solution.pressure.last()) {
4228 (Some(first), Some(last)) => first - last,
4229 _ => 0.0,
4230 };
4231 let pressure_drop_balance_ratio = if solution.pressure_drop_pa.abs() > 1.0e-12 {
4232 pressure_drop_observed / solution.pressure_drop_pa
4233 } else if pressure_drop_observed.abs() <= 1.0e-12 {
4234 1.0
4235 } else {
4236 0.0
4237 };
4238
4239 let axial_values = (0..node_count)
4240 .map(|node| solution.velocity.get(node * 3).copied().unwrap_or(0.0))
4241 .collect::<Vec<_>>();
4242 let mean_axial = if axial_values.is_empty() {
4243 0.0
4244 } else {
4245 axial_values.iter().sum::<f64>() / axial_values.len() as f64
4246 };
4247 let max_axial_deviation = axial_values
4248 .iter()
4249 .map(|value| (value - mean_axial).abs())
4250 .fold(0.0_f64, f64::max);
4251 let mass_flux_uniformity_ratio = max_axial_deviation / mean_axial.abs().max(1.0e-12);
4252
4253 let pressure_edge_count = node_count.saturating_sub(1);
4254 let pressure_monotonic_cell_fraction = if pressure_edge_count == 0 {
4255 1.0
4256 } else {
4257 let tolerance = solution.pressure_drop_pa.abs().max(1.0) * 1.0e-12;
4258 let monotonic_edges = solution
4259 .pressure
4260 .windows(2)
4261 .filter(|pair| pair[0] + tolerance >= pair[1])
4262 .count();
4263 monotonic_edges as f64 / pressure_edge_count as f64
4264 };
4265
4266 let known_answer_coverage_ratio = if node_count >= 2
4267 && solution.control_volume_count == node_count - 1
4268 && solution.velocity.len() == node_count * 3
4269 && solution.pressure_drop_pa.is_finite()
4270 && solution.pressure.iter().all(|value| value.is_finite())
4271 && solution.velocity.iter().all(|value| value.is_finite())
4272 {
4273 1.0
4274 } else {
4275 0.0
4276 };
4277
4278 CfdKnownAnswerMetrics {
4279 pressure_drop_balance_ratio,
4280 mass_flux_uniformity_ratio,
4281 pressure_monotonic_cell_fraction,
4282 known_answer_coverage_ratio,
4283 }
4284}
4285
4286fn cfd_known_answer_diagnostic(
4287 metrics: &CfdKnownAnswerMetrics,
4288 topology: &CfdDomainTopology,
4289) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
4290 let severity = if (metrics.pressure_drop_balance_ratio - 1.0).abs() <= 1.0e-10
4291 && metrics.mass_flux_uniformity_ratio <= 1.0e-10
4292 && metrics.pressure_monotonic_cell_fraction >= 1.0
4293 && metrics.known_answer_coverage_ratio >= 1.0
4294 {
4295 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4296 } else {
4297 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4298 };
4299
4300 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4301 code: "FEA_CFD_KNOWN_ANSWER".to_string(),
4302 severity,
4303 message: format!(
4304 "basis=finite_volume_channel topology_basis={} pressure_drop_balance_ratio={} mass_flux_uniformity_ratio={} pressure_monotonic_cell_fraction={} known_answer_coverage_ratio={}",
4305 topology.basis.as_str(),
4306 metrics.pressure_drop_balance_ratio,
4307 metrics.mass_flux_uniformity_ratio,
4308 metrics.pressure_monotonic_cell_fraction,
4309 metrics.known_answer_coverage_ratio,
4310 ),
4311 }
4312}
4313
4314fn recover_cfd_vorticity(velocity: &[f64], node_count: usize, dx_m: f64) -> Vec<f64> {
4315 let mut vorticity = vec![0.0; node_count * 3];
4316 if node_count < 2 {
4317 return vorticity;
4318 }
4319 let dx_m = dx_m.max(1.0e-12);
4320
4321 for node in 0..node_count {
4322 let prev = node.saturating_sub(1);
4323 let next = (node + 1).min(node_count - 1);
4324 let prev_base = prev * 3;
4325 let next_base = next * 3;
4326 let dvx = velocity.get(next_base).copied().unwrap_or(0.0)
4327 - velocity.get(prev_base).copied().unwrap_or(0.0);
4328 let dvy = velocity.get(next_base + 1).copied().unwrap_or(0.0)
4329 - velocity.get(prev_base + 1).copied().unwrap_or(0.0);
4330 let base = node * 3;
4331 vorticity[base] = 0.0;
4332 vorticity[base + 1] = -dvx / (2.0 * dx_m);
4333 vorticity[base + 2] = dvy / (2.0 * dx_m);
4334 }
4335
4336 vorticity
4337}
4338
4339fn recover_cfd_wall_shear_stress(
4340 domain: &runmat_analysis_core::CfdDomain,
4341 velocity: &[f64],
4342 field_count: usize,
4343) -> Vec<f64> {
4344 let mut shear = vec![0.0; field_count * 3];
4345 let viscosity = domain.dynamic_viscosity_pa_s;
4346 for index in 0..field_count {
4347 let base = index * 3;
4348 shear[base] = viscosity * velocity.get(base).copied().unwrap_or(0.0);
4349 shear[base + 1] = viscosity * velocity.get(base + 1).copied().unwrap_or(0.0);
4350 }
4351 shear
4352}
4353
4354fn cfd_residual_norms(
4355 velocity: &[f64],
4356 pressure: &[f64],
4357 domain: &runmat_analysis_core::CfdDomain,
4358 topology: &CfdDomainTopology,
4359 step_count: usize,
4360) -> (Vec<f64>, Vec<f64>) {
4361 let node_count = pressure.len().max(1);
4362 let reynolds = cfd_reynolds_number(domain).max(1.0);
4363 let hydraulic_diameter_m = topology.hydraulic_diameter_m.max(1.0e-12);
4364 let friction_factor = if reynolds <= 2300.0 {
4365 64.0 / reynolds
4366 } else {
4367 0.3164 / reynolds.powf(0.25)
4368 };
4369 let mean_axial_velocity = (0..node_count)
4370 .map(|node| velocity.get(node * 3).copied().unwrap_or(0.0))
4371 .sum::<f64>()
4372 / node_count as f64;
4373 let friction_gradient_pa_per_m = 0.5
4374 * domain.reference_density_kg_per_m3.max(1.0e-12)
4375 * mean_axial_velocity
4376 * mean_axial_velocity.abs()
4377 * friction_factor
4378 / hydraulic_diameter_m;
4379 let dx = topology.dx_m.max(1.0e-12);
4380 let mut momentum_base = 0.0_f64;
4381 let mut continuity_base = 0.0_f64;
4382 for node in 0..node_count {
4383 let prev = node.saturating_sub(1);
4384 let next = (node + 1).min(node_count - 1);
4385 let velocity_prev = velocity.get(prev * 3).copied().unwrap_or(0.0);
4386 let velocity_next = velocity.get(next * 3).copied().unwrap_or(0.0);
4387 let pressure_prev = pressure.get(prev).copied().unwrap_or(0.0);
4388 let pressure_next = pressure.get(next).copied().unwrap_or(0.0);
4389 let stencil_width = if prev == next {
4390 1.0
4391 } else {
4392 (next - prev) as f64 * dx
4393 };
4394 let divergence = (velocity_next - velocity_prev) / stencil_width.max(1.0e-12);
4395 let pressure_gradient = (pressure_next - pressure_prev) / stencil_width.max(1.0e-12);
4396 continuity_base += divergence.abs();
4397 momentum_base += (pressure_gradient + friction_gradient_pa_per_m).abs();
4398 }
4399 let velocity_scale = mean_axial_velocity
4400 .abs()
4401 .max(domain.inlet_velocity_m_per_s)
4402 .max(1.0e-9);
4403 let pressure_scale = pressure
4404 .iter()
4405 .copied()
4406 .map(f64::abs)
4407 .fold(0.0_f64, f64::max)
4408 .max(1.0);
4409 let continuity_base = (continuity_base / (node_count as f64 * velocity_scale)).clamp(0.0, 1.0);
4410 let momentum_base = (momentum_base / (node_count as f64 * pressure_scale)).clamp(0.0, 1.0);
4411 let residual_count = step_count.max(1);
4412 (
4413 vec![momentum_base; residual_count],
4414 vec![continuity_base; residual_count],
4415 )
4416}
4417
4418fn build_cfd_run_fields(
4419 domain: &runmat_analysis_core::CfdDomain,
4420 solution: &CfdVelocityPressureSolution,
4421) -> Vec<AnalysisField> {
4422 let node_count = solution.pressure.len();
4423 let velocity = cell_centered_vector_from_nodal(&solution.velocity, node_count);
4424 let pressure = cell_centered_scalar_from_nodal(&solution.pressure);
4425 let cell_count = pressure.len().max(1);
4426 let vorticity = recover_cfd_vorticity(&velocity, cell_count, solution.topology.dx_m);
4427 let boundary_face_count = solution.wall_boundary_count.max(1);
4428 let wall_shear_stress = recover_cfd_wall_shear_stress(domain, &velocity, boundary_face_count);
4429 let residual_count = solution.residual_momentum.len();
4430
4431 vec![
4432 AnalysisField::host_f64(FEA_FIELD_CFD_VELOCITY, vec![cell_count, 3], velocity),
4433 AnalysisField::host_f64(FEA_FIELD_CFD_PRESSURE, vec![cell_count], pressure),
4434 AnalysisField::host_f64(FEA_FIELD_CFD_VORTICITY, vec![cell_count, 3], vorticity),
4435 AnalysisField::host_f64(
4436 FEA_FIELD_CFD_WALL_SHEAR_STRESS,
4437 vec![boundary_face_count, 3],
4438 wall_shear_stress,
4439 ),
4440 AnalysisField::host_f64(
4441 FEA_FIELD_CFD_RESIDUAL_MOMENTUM,
4442 vec![residual_count],
4443 solution.residual_momentum.clone(),
4444 ),
4445 AnalysisField::host_f64(
4446 FEA_FIELD_CFD_RESIDUAL_CONTINUITY,
4447 vec![residual_count],
4448 solution.residual_continuity.clone(),
4449 ),
4450 AnalysisField::host_f64(
4451 FEA_FIELD_CFD_REYNOLDS_NUMBER,
4452 vec![1],
4453 vec![cfd_reynolds_number(domain)],
4454 ),
4455 ]
4456}
4457
4458fn cfd_assembly_diagnostic(
4459 topology: &CfdDomainTopology,
4460 domain: &runmat_analysis_core::CfdDomain,
4461 time_step_s: f64,
4462 pressure_drop_pa: f64,
4463 mass_balance_residual: f64,
4464 residual_warn_threshold: f64,
4465) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
4466 let face_area_m2 = topology.face_area_m2();
4467 let control_volume_volume_m3 = topology.control_volume_volume_m3();
4468 let nominal_mass_flow_rate_kg_per_s =
4469 domain.reference_density_kg_per_m3 * domain.inlet_velocity_m_per_s * face_area_m2;
4470 let courant_number =
4471 domain.inlet_velocity_m_per_s.abs() * time_step_s.max(0.0) / topology.dx_m.max(1.0e-12);
4472 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4473 code: "FEA_CFD_ASSEMBLY".to_string(),
4474 severity: if mass_balance_residual <= residual_warn_threshold {
4475 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4476 } else {
4477 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4478 },
4479 message: format!(
4480 "basis=finite_volume_velocity_pressure topology_basis={} topology_geometry_source={} control_volume_count={} control_volume_face_count={} control_volume_internal_face_count={} control_volume_boundary_face_count={} control_volume_connectivity_coverage_ratio={} hydraulic_diameter_m={} domain_length_m={} dx_m={} face_area_m2={} control_volume_volume_m3={} nominal_mass_flow_rate_kg_per_s={} courant_number={} active_dimension_count={} element_geometry_node_count={} element_geometry_edge_count={} element_geometry_coverage_ratio={} element_topology_sample_element_count={} element_topology_sample_edge_count={} pressure_drop_pa={} mass_balance_residual={}",
4481 topology.basis.as_str(),
4482 topology.geometry_source.as_str(),
4483 topology.control_volume_count,
4484 topology.control_volume_face_count,
4485 topology.control_volume_internal_face_count,
4486 topology.control_volume_boundary_face_count,
4487 topology.control_volume_connectivity_coverage_ratio,
4488 topology.hydraulic_diameter_m,
4489 topology.domain_length_m,
4490 topology.dx_m,
4491 face_area_m2,
4492 control_volume_volume_m3,
4493 nominal_mass_flow_rate_kg_per_s,
4494 courant_number,
4495 topology.active_dimension_count,
4496 topology.element_geometry_node_count,
4497 topology.element_geometry_edge_count,
4498 topology.element_geometry_coverage_ratio,
4499 topology.element_topology_sample_element_count,
4500 topology.element_topology_sample_edge_count,
4501 pressure_drop_pa,
4502 mass_balance_residual,
4503 ),
4504 }
4505}
4506
4507fn pressure_drop_from_nodal_pressure(pressure: &[f64]) -> f64 {
4508 match (pressure.first(), pressure.last()) {
4509 (Some(first), Some(last)) => first - last,
4510 _ => 0.0,
4511 }
4512}
4513
4514fn cell_centered_vector_from_nodal(nodal: &[f64], node_count: usize) -> Vec<f64> {
4515 let cell_count = node_count.saturating_sub(1).max(1);
4516 let mut cell_values = Vec::with_capacity(cell_count * 3);
4517 for cell in 0..cell_count {
4518 let left = cell.min(node_count.saturating_sub(1));
4519 let right = (cell + 1).min(node_count.saturating_sub(1));
4520 for component in 0..3 {
4521 let left_value = nodal.get(left * 3 + component).copied().unwrap_or(0.0);
4522 let right_value = nodal
4523 .get(right * 3 + component)
4524 .copied()
4525 .unwrap_or(left_value);
4526 cell_values.push(0.5 * (left_value + right_value));
4527 }
4528 }
4529 cell_values
4530}
4531
4532fn cell_centered_scalar_from_nodal(nodal: &[f64]) -> Vec<f64> {
4533 let node_count = nodal.len();
4534 let cell_count = node_count.saturating_sub(1).max(1);
4535 let mut cell_values = Vec::with_capacity(cell_count);
4536 for cell in 0..cell_count {
4537 let left = cell.min(node_count.saturating_sub(1));
4538 let right = (cell + 1).min(node_count.saturating_sub(1));
4539 let left_value = nodal.get(left).copied().unwrap_or(0.0);
4540 let right_value = nodal.get(right).copied().unwrap_or(left_value);
4541 cell_values.push(0.5 * (left_value + right_value));
4542 }
4543 cell_values
4544}
4545
4546fn resample_scalar_profile(values: &[f64], target_count: usize) -> Vec<f64> {
4547 let target_count = target_count.max(1);
4548 if values.is_empty() {
4549 return vec![0.0; target_count];
4550 }
4551 if values.len() == target_count {
4552 return values.to_vec();
4553 }
4554 if target_count == 1 {
4555 return vec![values[0]];
4556 }
4557 let source_max = values.len().saturating_sub(1) as f64;
4558 let target_max = target_count.saturating_sub(1) as f64;
4559 (0..target_count)
4560 .map(|target_index| {
4561 let source_position = target_index as f64 * source_max / target_max.max(1.0);
4562 let left = source_position.floor() as usize;
4563 let right = source_position.ceil() as usize;
4564 if left == right {
4565 values.get(left).copied().unwrap_or(0.0)
4566 } else {
4567 let t = source_position - left as f64;
4568 let left_value = values.get(left).copied().unwrap_or(0.0);
4569 let right_value = values.get(right).copied().unwrap_or(left_value);
4570 left_value * (1.0 - t) + right_value * t
4571 }
4572 })
4573 .collect()
4574}
4575
4576fn fluid_interface_face_count(topology: &CfdDomainTopology) -> usize {
4577 if topology.control_volume_connectivity_coverage_ratio > 0.0
4578 && topology.control_volume_boundary_face_count > 0
4579 {
4580 topology.control_volume_boundary_face_count
4581 } else {
4582 topology.control_volume_count
4583 }
4584 .max(1)
4585}
4586
4587fn coupled_interface_graph_edge_target(
4588 topology: &CfdDomainTopology,
4589 interface_face_count: usize,
4590) -> usize {
4591 if interface_face_count < 2 {
4592 return 0;
4593 }
4594 let line_edge_count = interface_face_count - 1;
4595 let complete_graph_edge_count = interface_face_count * (interface_face_count - 1) / 2;
4596 if topology.control_volume_connectivity_coverage_ratio > 0.0 {
4597 topology
4598 .control_volume_internal_face_count
4599 .max(line_edge_count)
4600 .min(complete_graph_edge_count)
4601 } else {
4602 line_edge_count
4603 }
4604}
4605
4606fn coupled_interface_graph_edges_for_topology(
4607 topology: &CfdDomainTopology,
4608 interface_face_count: usize,
4609) -> Vec<(usize, usize)> {
4610 use std::collections::BTreeSet;
4611
4612 let target = coupled_interface_graph_edge_target(topology, interface_face_count);
4613 if target == 0 {
4614 return Vec::new();
4615 }
4616
4617 let mut seen = BTreeSet::<(usize, usize)>::new();
4618 let mut edges = Vec::with_capacity(target);
4619
4620 let full_edge_count = topology
4621 .element_topology_edge_nodes
4622 .len()
4623 .min(interface_face_count);
4624 if full_edge_count > 0 {
4625 for element_edges in &topology.element_topology_element_edges {
4626 let local_edges = element_edges
4627 .iter()
4628 .map(|edge| *edge as usize)
4629 .filter(|edge| *edge < full_edge_count)
4630 .collect::<Vec<_>>();
4631 if local_edges.len() < 2 {
4632 continue;
4633 }
4634 let pair_count = if local_edges.len() == 2 {
4635 1
4636 } else {
4637 local_edges.len()
4638 };
4639 for offset in 0..pair_count {
4640 let next = if offset + 1 < local_edges.len() {
4641 offset + 1
4642 } else {
4643 0
4644 };
4645 let left = local_edges[offset].min(local_edges[next]);
4646 let right = local_edges[offset].max(local_edges[next]);
4647 if left != right && seen.insert((left, right)) {
4648 edges.push((left, right));
4649 if edges.len() == target {
4650 return edges;
4651 }
4652 }
4653 }
4654 }
4655 }
4656
4657 let sample_edge_count = topology
4658 .element_topology_sample_edge_count
4659 .min(interface_face_count);
4660 if edges.is_empty() {
4661 for element_edges in topology
4662 .element_topology_sample_element_edges
4663 .iter()
4664 .take(topology.element_topology_sample_element_count.min(4))
4665 {
4666 let local_edges = element_edges
4667 .iter()
4668 .map(|edge| *edge as usize)
4669 .filter(|edge| *edge < sample_edge_count)
4670 .collect::<Vec<_>>();
4671 if local_edges.len() < 2 {
4672 continue;
4673 }
4674 let pair_count = if local_edges.len() == 2 {
4675 1
4676 } else {
4677 local_edges.len()
4678 };
4679 for offset in 0..pair_count {
4680 let next = if offset + 1 < local_edges.len() {
4681 offset + 1
4682 } else {
4683 0
4684 };
4685 let left = local_edges[offset].min(local_edges[next]);
4686 let right = local_edges[offset].max(local_edges[next]);
4687 if left != right && seen.insert((left, right)) {
4688 edges.push((left, right));
4689 if edges.len() == target {
4690 return edges;
4691 }
4692 }
4693 }
4694 }
4695 }
4696
4697 for (left, right) in coupled_interface_graph_edges(interface_face_count, target) {
4698 let edge = (left.min(right), left.max(right));
4699 if seen.insert(edge) {
4700 edges.push(edge);
4701 if edges.len() == target {
4702 break;
4703 }
4704 }
4705 }
4706 edges
4707}
4708
4709fn coupled_interface_connectivity_coverage_ratio(
4710 topology: &CfdDomainTopology,
4711 interface_face_count: usize,
4712 edge_count: usize,
4713) -> f64 {
4714 let target = coupled_interface_graph_edge_target(topology, interface_face_count);
4715 if target == 0 {
4716 return 1.0;
4717 }
4718 (edge_count as f64 / target as f64).clamp(0.0, 1.0)
4719}
4720
4721fn coupled_interface_mesh_backed_connectivity_ratio(
4722 topology: &CfdDomainTopology,
4723 edge_count: usize,
4724) -> f64 {
4725 if topology.basis == CfdDomainTopologyBasis::PrepControlVolumeConnectivity
4726 && topology.control_volume_connectivity_coverage_ratio > 0.0
4727 && edge_count > 0
4728 {
4729 1.0
4730 } else {
4731 0.0
4732 }
4733}
4734
4735fn solve_cfd_finite_volume_run(
4736 model: &AnalysisModel,
4737 domain: &runmat_analysis_core::CfdDomain,
4738 backend: ComputeBackend,
4739 options: &AnalysisCfdRunOptions,
4740 prep_context: Option<&AnalysisRunPrepContext>,
4741) -> FeaRunResult {
4742 let topology = CfdDomainTopology::from_model(model, prep_context);
4743 let node_count = topology.node_count;
4744 let step_count = options.step_count.max(1);
4745 let field_step = match domain.solve_family {
4746 runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
4747 runmat_analysis_core::CfdSolveFamily::Transient => step_count.saturating_sub(1),
4748 };
4749 let boundary_summary = CfdBoundarySummary::from_model(model, domain, node_count);
4750 let solution = solve_cfd_velocity_pressure(
4751 domain,
4752 &boundary_summary,
4753 &topology,
4754 field_step,
4755 step_count,
4756 options.max_linear_iters,
4757 options.tolerance,
4758 );
4759 let max_momentum_residual = solution
4760 .residual_momentum
4761 .iter()
4762 .copied()
4763 .fold(0.0_f64, f64::max);
4764 let max_continuity_residual = solution
4765 .residual_continuity
4766 .iter()
4767 .copied()
4768 .fold(0.0_f64, f64::max);
4769 let known_answer_metrics = cfd_known_answer_metrics(&solution);
4770 let fields = build_cfd_run_fields(domain, &solution);
4771 let residual_severity = if max_momentum_residual <= options.residual_warn_threshold
4772 && max_continuity_residual <= options.residual_warn_threshold
4773 {
4774 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4775 } else {
4776 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4777 };
4778 FeaRunResult {
4779 backend,
4780 solver_backend: "cpu_reference".to_string(),
4781 solver_device_apply_k_ratio: 0.0,
4782 solver_method: "cfd_velocity_pressure_finite_volume".to_string(),
4783 preconditioner: "finite_volume_pressure_balance".to_string(),
4784 solver_host_sync_count: 0,
4785 diagnostics: vec![
4786 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4787 code: "FEA_CFD_RESIDUAL".to_string(),
4788 severity: residual_severity,
4789 message: format!(
4790 "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
4791 max_momentum_residual,
4792 max_continuity_residual,
4793 options.residual_warn_threshold,
4794 node_count,
4795 step_count,
4796 ),
4797 },
4798 cfd_assembly_diagnostic(
4799 &solution.topology,
4800 domain,
4801 options.time_step_s,
4802 solution.pressure_drop_pa,
4803 solution.mass_balance_residual,
4804 options.residual_warn_threshold,
4805 ),
4806 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4807 code: "FEA_CFD_BOUNDARY_CONDITIONS".to_string(),
4808 severity: if solution.boundary_coverage_ratio >= 1.0
4809 && solution.wall_boundary_coverage_ratio >= 1.0
4810 && solution.inlet_velocity_realization_ratio.is_finite()
4811 {
4812 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4813 } else {
4814 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4815 },
4816 message: format!(
4817 "boundary_source={} authored_boundary_count={} inlet_boundary_count={} outlet_boundary_count={} wall_boundary_count={} no_slip_wall_boundary_count={} slip_wall_boundary_count={} symmetry_boundary_count={} boundary_coverage_ratio={} wall_boundary_coverage_ratio={} nominal_inlet_velocity_m_per_s={} outlet_pressure_pa={} inlet_velocity_realization_ratio={}",
4818 if solution.authored_boundary_count > 0 {
4819 "authored"
4820 } else {
4821 "implicit_channel"
4822 },
4823 solution.authored_boundary_count,
4824 solution.inlet_boundary_count,
4825 solution.outlet_boundary_count,
4826 solution.wall_boundary_count,
4827 solution.no_slip_wall_boundary_count,
4828 solution.slip_wall_boundary_count,
4829 solution.symmetry_boundary_count,
4830 solution.boundary_coverage_ratio,
4831 solution.wall_boundary_coverage_ratio,
4832 solution.nominal_inlet_velocity_m_per_s,
4833 solution.outlet_pressure_pa,
4834 solution.inlet_velocity_realization_ratio,
4835 ),
4836 },
4837 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4838 code: "FEA_CFD_PRESSURE_CORRECTION".to_string(),
4839 severity: if solution.pressure_correction_residual_ratio <= options.tolerance
4840 && solution.velocity_correction_residual_ratio <= options.tolerance
4841 {
4842 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4843 } else {
4844 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4845 },
4846 message: format!(
4847 "iteration_count={} max_linear_iters={} tolerance={} pressure_correction_residual_ratio={} velocity_correction_residual_ratio={}",
4848 solution.pressure_correction_iteration_count,
4849 options.max_linear_iters,
4850 options.tolerance,
4851 solution.pressure_correction_residual_ratio,
4852 solution.velocity_correction_residual_ratio,
4853 ),
4854 },
4855 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4856 code: "FEA_CFD_TRANSIENT_EVOLUTION".to_string(),
4857 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
4858 message: format!(
4859 "solve_family={} step_count={} time_step_s={} transient_profile_point_count={} transient_scale_min={} transient_scale_max={} transient_scale_variation={}",
4860 match domain.solve_family {
4861 runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
4862 runmat_analysis_core::CfdSolveFamily::Transient => "transient",
4863 },
4864 step_count,
4865 options.time_step_s,
4866 domain.time_profile.len(),
4867 solution.transient_scale_min,
4868 solution.transient_scale_max,
4869 solution.transient_scale_variation,
4870 ),
4871 },
4872 cfd_known_answer_diagnostic(&known_answer_metrics, &topology),
4873 ],
4874 fields,
4875 }
4876}
4877
4878fn field_scalar_magnitudes(field: &AnalysisField, fallback_len: usize) -> Vec<f64> {
4879 let Some(values) = field.as_host_f64() else {
4880 return vec![0.0; fallback_len.max(1)];
4881 };
4882 match field.shape.as_slice() {
4883 [count, components] if *components > 1 => {
4884 let mut magnitudes = Vec::with_capacity(*count);
4885 for index in 0..*count {
4886 let start = index * *components;
4887 let magnitude = values
4888 .get(start..start + *components)
4889 .unwrap_or(&[])
4890 .iter()
4891 .map(|value| value * value)
4892 .sum::<f64>()
4893 .sqrt();
4894 magnitudes.push(magnitude);
4895 }
4896 magnitudes
4897 }
4898 _ => values.to_vec(),
4899 }
4900}
4901
4902fn build_cht_run_fields(
4903 domain: &runmat_analysis_core::CfdDomain,
4904 topology: &CfdDomainTopology,
4905 thermal_run: &runmat_analysis_fea::FeaThermalRunResult,
4906 authored_interface_conductance_w_per_m2k: Option<f64>,
4907 max_linear_iters: usize,
4908 tolerance: f64,
4909) -> (Vec<AnalysisField>, ChtInterfaceClosure) {
4910 let node_count = topology.node_count;
4911 let (fluid_velocity, fluid_pressure) = recover_cfd_velocity_pressure(domain, topology, 0);
4912 let mean_axial_velocity = mean_cfd_axial_velocity(&fluid_velocity);
4913 let mut fields = vec![
4914 AnalysisField::host_f64(
4915 FEA_FIELD_CHT_FLUID_VELOCITY,
4916 vec![node_count, 3],
4917 fluid_velocity,
4918 ),
4919 AnalysisField::host_f64(
4920 FEA_FIELD_CHT_FLUID_PRESSURE,
4921 vec![node_count],
4922 fluid_pressure,
4923 ),
4924 ];
4925 let mut closure = ChtInterfaceClosure::default();
4926
4927 for (step_index, temperature) in thermal_run.temperature_snapshots.iter().enumerate() {
4928 let fallback_len = temperature.element_count().max(1);
4929 let base_temperature = temperature
4930 .as_host_f64()
4931 .map(|values| values.to_vec())
4932 .unwrap_or_else(|| vec![thermal_run.reference_temperature_k; fallback_len]);
4933 let mut heat_flux = thermal_run
4934 .heat_flux_snapshots
4935 .get(step_index)
4936 .map(|field| field_scalar_magnitudes(field, fallback_len))
4937 .unwrap_or_else(|| vec![0.0; fallback_len]);
4938 if heat_flux.is_empty() {
4939 heat_flux.push(0.0);
4940 }
4941 let target_interface_count = fluid_interface_face_count(topology);
4942 if heat_flux.len() != target_interface_count {
4943 heat_flux = resample_scalar_profile(&heat_flux, target_interface_count);
4944 }
4945 let interface_count = heat_flux.len();
4946 let max_heat_flux = heat_flux
4947 .iter()
4948 .copied()
4949 .map(f64::abs)
4950 .fold(0.0_f64, f64::max);
4951 let target_jump_k = 0.01_f64;
4952 let interface_conductance_w_per_m2k = authored_interface_conductance_w_per_m2k
4953 .unwrap_or_else(|| (max_heat_flux / target_jump_k).max(25.0))
4954 .max(1.0e-12);
4955 let advection_shift_k = cht_advection_shift_k(
4956 domain,
4957 mean_axial_velocity,
4958 &base_temperature,
4959 thermal_run.reference_temperature_k,
4960 );
4961
4962 let interface_solution = solve_cht_conjugate_interface(
4963 &base_temperature,
4964 &heat_flux,
4965 topology,
4966 interface_conductance_w_per_m2k,
4967 advection_shift_k,
4968 thermal_run.reference_temperature_k,
4969 max_linear_iters,
4970 tolerance,
4971 );
4972 let fluid_temperature = interface_solution.fluid_temperature;
4973 let solid_temperature = interface_solution.solid_temperature;
4974 let temperature_jump = interface_solution.temperature_jump;
4975 let coupled_heat_flux = interface_solution.coupled_heat_flux;
4976 fields.push(AnalysisField::host_f64(
4977 fea_cht_fluid_temperature_field_id(step_index),
4978 vec![base_temperature.len().max(1)],
4979 fluid_temperature,
4980 ));
4981 fields.push(AnalysisField::host_f64(
4982 fea_cht_solid_temperature_field_id(step_index),
4983 vec![base_temperature.len().max(1)],
4984 solid_temperature,
4985 ));
4986
4987 closure.interface_face_count = closure.interface_face_count.max(interface_count);
4988 closure.max_temperature_jump_k = closure.max_temperature_jump_k.max(
4989 temperature_jump
4990 .iter()
4991 .copied()
4992 .map(f64::abs)
4993 .fold(0.0_f64, f64::max),
4994 );
4995 closure.max_advection_temperature_shift_k = closure
4996 .max_advection_temperature_shift_k
4997 .max(advection_shift_k.abs());
4998 closure.interface_conductance_w_per_m2k = closure
4999 .interface_conductance_w_per_m2k
5000 .max(interface_conductance_w_per_m2k);
5001 closure.max_flux_temperature_law_residual_ratio = closure
5002 .max_flux_temperature_law_residual_ratio
5003 .max(interface_solution.flux_temperature_law_residual_ratio);
5004 closure.max_heat_flux_realization_residual_ratio = closure
5005 .max_heat_flux_realization_residual_ratio
5006 .max(interface_solution.heat_flux_realization_residual_ratio);
5007 closure.max_coupled_interface_iteration_count = closure
5008 .max_coupled_interface_iteration_count
5009 .max(interface_solution.iteration_count);
5010 closure.max_coupled_interface_residual_ratio = closure
5011 .max_coupled_interface_residual_ratio
5012 .max(interface_solution.coupled_interface_residual_ratio);
5013 closure.thermal_network_edge_count = closure
5014 .thermal_network_edge_count
5015 .max(interface_solution.thermal_network_edge_count);
5016 closure.thermal_network_node_count = closure
5017 .thermal_network_node_count
5018 .max(interface_solution.thermal_network_node_count);
5019 closure.interface_connectivity_coverage_ratio = closure
5020 .interface_connectivity_coverage_ratio
5021 .max(interface_solution.interface_connectivity_coverage_ratio);
5022 closure.mesh_backed_interface_connectivity_ratio = closure
5023 .mesh_backed_interface_connectivity_ratio
5024 .max(interface_solution.mesh_backed_interface_connectivity_ratio);
5025 closure.full_topology_edge_count = closure
5026 .full_topology_edge_count
5027 .max(interface_solution.full_topology_edge_count);
5028 closure.full_topology_element_count = closure
5029 .full_topology_element_count
5030 .max(interface_solution.full_topology_element_count);
5031 closure.max_thermal_network_residual_ratio = closure
5032 .max_thermal_network_residual_ratio
5033 .max(interface_solution.thermal_network_residual_ratio);
5034 let fluid_heat = coupled_heat_flux.iter().sum::<f64>();
5035 let solid_heat = -fluid_heat;
5036 let heat_balance_ratio =
5037 (fluid_heat + solid_heat).abs() / (fluid_heat.abs() + solid_heat.abs() + 1.0e-12);
5038 closure.heat_flux_balance_ratio = closure.heat_flux_balance_ratio.max(heat_balance_ratio);
5039 let thermal_scale_k = base_temperature
5040 .iter()
5041 .map(|value| (value - thermal_run.reference_temperature_k).abs())
5042 .fold(0.0_f64, f64::max)
5043 .max(1.0);
5044 let normalized_temperature_jump =
5045 closure.max_temperature_jump_k / thermal_scale_k.max(1.0e-12);
5046 closure.max_thermal_transport_residual_ratio =
5047 closure.max_thermal_transport_residual_ratio.max(
5048 interface_solution
5049 .flux_temperature_law_residual_ratio
5050 .max(heat_balance_ratio)
5051 .max(interface_solution.heat_flux_realization_residual_ratio)
5052 .max(interface_solution.coupled_interface_residual_ratio)
5053 .max(interface_solution.thermal_network_residual_ratio),
5054 );
5055 closure.interface_temperature_continuity_ratio = closure
5056 .interface_temperature_continuity_ratio
5057 .max((1.0 - normalized_temperature_jump).clamp(0.0, 1.0));
5058 let mean_heat_flux = if coupled_heat_flux.is_empty() {
5059 0.0
5060 } else {
5061 coupled_heat_flux.iter().sum::<f64>() / coupled_heat_flux.len() as f64
5062 };
5063 closure.mean_interface_heat_flux_w_per_m2 = closure
5064 .mean_interface_heat_flux_w_per_m2
5065 .max(mean_heat_flux.abs());
5066 fields.push(AnalysisField::host_f64(
5067 fea_cht_interface_heat_flux_field_id(step_index),
5068 vec![interface_count],
5069 coupled_heat_flux,
5070 ));
5071
5072 fields.push(AnalysisField::host_f64(
5073 fea_cht_interface_temperature_jump_field_id(step_index),
5074 vec![interface_count],
5075 temperature_jump,
5076 ));
5077 let energy_residual = heat_balance_ratio
5078 .max(interface_solution.flux_temperature_law_residual_ratio)
5079 .max(interface_solution.heat_flux_realization_residual_ratio)
5080 .max(interface_solution.thermal_network_residual_ratio);
5081 closure.max_energy_residual = closure.max_energy_residual.max(energy_residual);
5082 fields.push(AnalysisField::host_f64(
5083 fea_cht_energy_residual_field_id(step_index),
5084 vec![1],
5085 vec![energy_residual],
5086 ));
5087 }
5088
5089 (fields, closure)
5090}
5091
5092#[derive(Debug, Clone)]
5093struct ChtConjugateInterfaceSolution {
5094 fluid_temperature: Vec<f64>,
5095 solid_temperature: Vec<f64>,
5096 temperature_jump: Vec<f64>,
5097 coupled_heat_flux: Vec<f64>,
5098 iteration_count: usize,
5099 coupled_interface_residual_ratio: f64,
5100 flux_temperature_law_residual_ratio: f64,
5101 heat_flux_realization_residual_ratio: f64,
5102 thermal_network_edge_count: usize,
5103 thermal_network_node_count: usize,
5104 interface_connectivity_coverage_ratio: f64,
5105 mesh_backed_interface_connectivity_ratio: f64,
5106 full_topology_edge_count: usize,
5107 full_topology_element_count: usize,
5108 thermal_network_residual_ratio: f64,
5109}
5110
5111fn solve_cht_conjugate_interface(
5112 base_temperature: &[f64],
5113 heat_flux: &[f64],
5114 topology: &CfdDomainTopology,
5115 interface_conductance_w_per_m2k: f64,
5116 advection_shift_k: f64,
5117 reference_temperature_k: f64,
5118 _max_linear_iters: usize,
5119 _tolerance: f64,
5120) -> ChtConjugateInterfaceSolution {
5121 let temperature_count = base_temperature.len().max(1);
5122 let interface_count = heat_flux.len().max(1);
5123 let interface_denom = interface_count.saturating_sub(1).max(1) as f64;
5124 let conductance = interface_conductance_w_per_m2k.max(1.0e-12);
5125 let axial_conductance = (0.05 * conductance).max(1.0e-12);
5126 let anchor_conductance = conductance;
5127 let base_interface_temperature = resample_scalar_profile(base_temperature, interface_count);
5128 let thermal_network_edges =
5129 coupled_interface_graph_edges_for_topology(topology, interface_count);
5130 let interface_connectivity_coverage_ratio = coupled_interface_connectivity_coverage_ratio(
5131 topology,
5132 interface_count,
5133 thermal_network_edges.len(),
5134 );
5135 let mesh_backed_interface_connectivity_ratio =
5136 coupled_interface_mesh_backed_connectivity_ratio(topology, thermal_network_edges.len());
5137 let mut operator = vec![vec![0.0; interface_count]; interface_count];
5138 for (row, diagonal) in operator.iter_mut().enumerate() {
5139 diagonal[row] = anchor_conductance;
5140 }
5141 for (left, right) in &thermal_network_edges {
5142 operator[*left][*left] += axial_conductance;
5143 operator[*right][*right] += axial_conductance;
5144 operator[*left][*right] -= axial_conductance;
5145 operator[*right][*left] -= axial_conductance;
5146 }
5147 let mut center_rhs = Vec::with_capacity(interface_count);
5148
5149 for index in 0..interface_count {
5150 let base = base_interface_temperature
5151 .get(index)
5152 .copied()
5153 .unwrap_or(reference_temperature_k);
5154 let xi = index as f64 / interface_denom;
5155 let center_temperature = base + advection_shift_k * xi;
5156 center_rhs.push(anchor_conductance * center_temperature);
5157 }
5158
5159 let mut matrix = operator.clone();
5160 let mut rhs = center_rhs.clone();
5161 let interface_center_temperature = solve_dense_real_system(&mut matrix, &mut rhs);
5162 let center_reaction = apply_dense_real_operator(&operator, &interface_center_temperature);
5163 let temperature_scale = interface_center_temperature
5164 .iter()
5165 .map(|value| (*value - reference_temperature_k).abs())
5166 .fold(0.0_f64, f64::max)
5167 .max(1.0);
5168 let thermal_network_residual_ratio = center_reaction
5169 .iter()
5170 .zip(center_rhs.iter())
5171 .map(|(reaction, rhs)| (reaction - rhs).abs())
5172 .fold(0.0_f64, f64::max)
5173 / (anchor_conductance * temperature_scale).max(1.0e-12);
5174 let mut interface_fluid_temperature = Vec::with_capacity(interface_count);
5175 let mut interface_solid_temperature = Vec::with_capacity(interface_count);
5176 for (center, flux) in interface_center_temperature
5177 .iter()
5178 .zip(heat_flux.iter().chain(std::iter::repeat(&0.0)))
5179 {
5180 let jump = *flux / conductance;
5181 interface_fluid_temperature.push(*center + 0.5 * jump);
5182 interface_solid_temperature.push(*center - 0.5 * jump);
5183 }
5184 let fluid_temperature =
5185 resample_scalar_profile(&interface_fluid_temperature, temperature_count);
5186 let solid_temperature =
5187 resample_scalar_profile(&interface_solid_temperature, temperature_count);
5188 let iteration_count = usize::from(temperature_count > 0);
5189 let coupled_interface_residual_ratio = thermal_network_residual_ratio;
5190
5191 let mut temperature_jump = Vec::with_capacity(interface_count);
5192 let mut coupled_heat_flux = Vec::with_capacity(interface_count);
5193 let mut max_flux_temperature_law_residual = 0.0_f64;
5194 let mut max_heat_flux_realization_residual = 0.0_f64;
5195 let heat_flux_scale = heat_flux
5196 .iter()
5197 .copied()
5198 .map(f64::abs)
5199 .fold(0.0_f64, f64::max)
5200 .max(1.0e-12);
5201 for index in 0..interface_count {
5202 let jump = interface_fluid_temperature
5203 .get(index)
5204 .copied()
5205 .unwrap_or(reference_temperature_k)
5206 - interface_solid_temperature
5207 .get(index)
5208 .copied()
5209 .unwrap_or(reference_temperature_k);
5210 let coupled_flux = conductance * jump;
5211 let input_flux = heat_flux.get(index).copied().unwrap_or(0.0);
5212 max_flux_temperature_law_residual = max_flux_temperature_law_residual
5213 .max((conductance * jump - coupled_flux).abs() / heat_flux_scale);
5214 max_heat_flux_realization_residual = max_heat_flux_realization_residual
5215 .max((coupled_flux - input_flux).abs() / heat_flux_scale);
5216 temperature_jump.push(jump);
5217 coupled_heat_flux.push(coupled_flux);
5218 }
5219
5220 ChtConjugateInterfaceSolution {
5221 fluid_temperature,
5222 solid_temperature,
5223 temperature_jump,
5224 coupled_heat_flux,
5225 iteration_count,
5226 coupled_interface_residual_ratio,
5227 flux_temperature_law_residual_ratio: max_flux_temperature_law_residual,
5228 heat_flux_realization_residual_ratio: max_heat_flux_realization_residual,
5229 thermal_network_edge_count: thermal_network_edges.len(),
5230 thermal_network_node_count: interface_count,
5231 interface_connectivity_coverage_ratio,
5232 mesh_backed_interface_connectivity_ratio,
5233 full_topology_edge_count: topology.element_topology_edge_nodes.len(),
5234 full_topology_element_count: topology.element_topology_element_edges.len(),
5235 thermal_network_residual_ratio,
5236 }
5237}
5238
5239fn mean_cfd_axial_velocity(velocity: &[f64]) -> f64 {
5240 let node_count = velocity.len() / 3;
5241 if node_count == 0 {
5242 return 0.0;
5243 }
5244 (0..node_count)
5245 .map(|node| velocity.get(node * 3).copied().unwrap_or(0.0))
5246 .sum::<f64>()
5247 / node_count as f64
5248}
5249
5250fn cht_advection_shift_k(
5251 domain: &runmat_analysis_core::CfdDomain,
5252 mean_axial_velocity_m_per_s: f64,
5253 temperature: &[f64],
5254 reference_temperature_k: f64,
5255) -> f64 {
5256 let thermal_span_k = temperature
5257 .iter()
5258 .map(|value| (value - reference_temperature_k).abs())
5259 .fold(0.0_f64, f64::max)
5260 .max(1.0);
5261 let reynolds_ratio = (cfd_reynolds_number_for_velocity(domain, mean_axial_velocity_m_per_s)
5262 / 1.0e5)
5263 .clamp(0.0, 5.0);
5264 thermal_span_k * reynolds_ratio * domain.turbulence_intensity.clamp(0.0, 1.0) * 2.0e-3
5265}
5266
5267fn cht_interface_conductance_w_per_m2k(model: &AnalysisModel) -> Option<f64> {
5268 model
5269 .interfaces
5270 .iter()
5271 .find_map(|interface| match &interface.kind {
5272 AnalysisInterfaceKind::ConjugateHeatTransfer(interface)
5273 if interface.thermal_conductance_w_per_m2k.is_finite()
5274 && interface.thermal_conductance_w_per_m2k > 0.0 =>
5275 {
5276 Some(interface.thermal_conductance_w_per_m2k)
5277 }
5278 AnalysisInterfaceKind::ConjugateHeatTransfer(_)
5279 | AnalysisInterfaceKind::FluidStructure(_)
5280 | AnalysisInterfaceKind::Contact(_) => None,
5281 })
5282}
5283
5284#[derive(Debug, Clone, Copy, Default)]
5285struct ChtInterfaceClosure {
5286 interface_face_count: usize,
5287 max_temperature_jump_k: f64,
5288 max_energy_residual: f64,
5289 heat_flux_balance_ratio: f64,
5290 mean_interface_heat_flux_w_per_m2: f64,
5291 max_thermal_transport_residual_ratio: f64,
5292 interface_temperature_continuity_ratio: f64,
5293 max_advection_temperature_shift_k: f64,
5294 interface_conductance_w_per_m2k: f64,
5295 max_flux_temperature_law_residual_ratio: f64,
5296 max_heat_flux_realization_residual_ratio: f64,
5297 max_coupled_interface_iteration_count: usize,
5298 max_coupled_interface_residual_ratio: f64,
5299 thermal_network_edge_count: usize,
5300 thermal_network_node_count: usize,
5301 interface_connectivity_coverage_ratio: f64,
5302 mesh_backed_interface_connectivity_ratio: f64,
5303 full_topology_edge_count: usize,
5304 full_topology_element_count: usize,
5305 max_thermal_network_residual_ratio: f64,
5306}
5307
5308#[derive(Debug, Clone, Copy)]
5309struct ChtKnownAnswerMetrics {
5310 heated_channel_energy_residual_ratio: f64,
5311 conjugate_slab_flux_law_residual_ratio: f64,
5312 interface_temperature_continuity_ratio: f64,
5313 advection_shift_coverage_ratio: f64,
5314 coupled_interface_residual_ratio: f64,
5315 heat_flux_realization_residual_ratio: f64,
5316 interface_connectivity_coverage_ratio: f64,
5317 mesh_backed_interface_connectivity_ratio: f64,
5318 thermal_network_residual_ratio: f64,
5319 known_answer_coverage_ratio: f64,
5320}
5321
5322fn cht_known_answer_metrics(
5323 domain: &runmat_analysis_core::CfdDomain,
5324 closure: &ChtInterfaceClosure,
5325) -> ChtKnownAnswerMetrics {
5326 let reynolds = cfd_reynolds_number(domain);
5327 let advection_shift_coverage_ratio = if reynolds.is_finite()
5328 && reynolds > 0.0
5329 && closure.max_advection_temperature_shift_k.is_finite()
5330 && closure.max_advection_temperature_shift_k >= 0.0
5331 {
5332 1.0
5333 } else {
5334 0.0
5335 };
5336 let known_answer_coverage_ratio = if closure.interface_face_count > 0
5337 && closure.interface_conductance_w_per_m2k.is_finite()
5338 && closure.interface_conductance_w_per_m2k > 0.0
5339 && closure.max_energy_residual.is_finite()
5340 && closure.max_flux_temperature_law_residual_ratio.is_finite()
5341 && closure.max_heat_flux_realization_residual_ratio.is_finite()
5342 && closure.interface_temperature_continuity_ratio.is_finite()
5343 && closure.max_coupled_interface_residual_ratio.is_finite()
5344 && closure.thermal_network_node_count > 0
5345 && closure.interface_connectivity_coverage_ratio.is_finite()
5346 && closure.interface_connectivity_coverage_ratio >= 1.0
5347 && closure.mesh_backed_interface_connectivity_ratio.is_finite()
5348 && closure.max_thermal_network_residual_ratio.is_finite()
5349 && advection_shift_coverage_ratio >= 1.0
5350 {
5351 1.0
5352 } else {
5353 0.0
5354 };
5355
5356 ChtKnownAnswerMetrics {
5357 heated_channel_energy_residual_ratio: closure.max_energy_residual,
5358 conjugate_slab_flux_law_residual_ratio: closure.max_flux_temperature_law_residual_ratio,
5359 interface_temperature_continuity_ratio: closure.interface_temperature_continuity_ratio,
5360 advection_shift_coverage_ratio,
5361 coupled_interface_residual_ratio: closure.max_coupled_interface_residual_ratio,
5362 heat_flux_realization_residual_ratio: closure.max_heat_flux_realization_residual_ratio,
5363 interface_connectivity_coverage_ratio: closure.interface_connectivity_coverage_ratio,
5364 mesh_backed_interface_connectivity_ratio: closure.mesh_backed_interface_connectivity_ratio,
5365 thermal_network_residual_ratio: closure.max_thermal_network_residual_ratio,
5366 known_answer_coverage_ratio,
5367 }
5368}
5369
5370fn cht_known_answer_diagnostic(
5371 metrics: &ChtKnownAnswerMetrics,
5372 residual_threshold: f64,
5373) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
5374 let severity = if metrics.heated_channel_energy_residual_ratio <= residual_threshold
5375 && metrics.conjugate_slab_flux_law_residual_ratio <= residual_threshold
5376 && metrics.interface_temperature_continuity_ratio >= 0.999
5377 && metrics.advection_shift_coverage_ratio >= 1.0
5378 && metrics.coupled_interface_residual_ratio <= residual_threshold
5379 && metrics.heat_flux_realization_residual_ratio <= residual_threshold
5380 && metrics.interface_connectivity_coverage_ratio >= 1.0
5381 && metrics.thermal_network_residual_ratio <= residual_threshold
5382 && metrics.known_answer_coverage_ratio >= 1.0
5383 {
5384 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
5385 } else {
5386 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
5387 };
5388
5389 runmat_analysis_fea::diagnostics::FeaDiagnostic {
5390 code: "FEA_CHT_KNOWN_ANSWER".to_string(),
5391 severity,
5392 message: format!(
5393 "basis=heated_channel_conjugate_slab heated_channel_energy_residual_ratio={} conjugate_slab_flux_law_residual_ratio={} interface_temperature_continuity_ratio={} advection_shift_coverage_ratio={} coupled_interface_residual_ratio={} heat_flux_realization_residual_ratio={} interface_connectivity_coverage_ratio={} mesh_backed_interface_connectivity_ratio={} thermal_network_residual_ratio={} known_answer_coverage_ratio={}",
5394 metrics.heated_channel_energy_residual_ratio,
5395 metrics.conjugate_slab_flux_law_residual_ratio,
5396 metrics.interface_temperature_continuity_ratio,
5397 metrics.advection_shift_coverage_ratio,
5398 metrics.coupled_interface_residual_ratio,
5399 metrics.heat_flux_realization_residual_ratio,
5400 metrics.interface_connectivity_coverage_ratio,
5401 metrics.mesh_backed_interface_connectivity_ratio,
5402 metrics.thermal_network_residual_ratio,
5403 metrics.known_answer_coverage_ratio,
5404 ),
5405 }
5406}
5407
5408fn build_fsi_run_fields(
5409 domain: &runmat_analysis_core::CfdDomain,
5410 topology: &CfdDomainTopology,
5411 step_count: usize,
5412 structural_compliance_per_pa: f64,
5413 max_linear_iters: usize,
5414 tolerance: f64,
5415 residual_momentum: &[f64],
5416 residual_continuity: &[f64],
5417) -> (Vec<AnalysisField>, FsiInterfaceClosure) {
5418 let mut fields = Vec::new();
5419 let mut closure = FsiInterfaceClosure::default();
5420 let step_count = step_count.max(1);
5421 let node_count = topology.node_count;
5422
5423 for step_index in 0..step_count {
5424 let (fluid_velocity, fluid_pressure) =
5425 recover_cfd_velocity_pressure(domain, topology, step_index);
5426 let interface_step = solve_fsi_partitioned_interface(
5427 &fluid_pressure,
5428 topology,
5429 structural_compliance_per_pa,
5430 max_linear_iters,
5431 tolerance,
5432 );
5433 let displacement = interface_step.structural_displacement;
5434 closure.interface_node_count = closure.interface_node_count.max(fluid_pressure.len());
5435 closure.interface_face_count = closure
5436 .interface_face_count
5437 .max(interface_step.interface_pressure.len());
5438 closure.max_coupling_iteration_count = closure
5439 .max_coupling_iteration_count
5440 .max(interface_step.iteration_count);
5441 closure.max_pressure_feedback_residual_ratio = closure
5442 .max_pressure_feedback_residual_ratio
5443 .max(interface_step.pressure_feedback_residual_ratio);
5444 closure.max_two_way_interface_residual_ratio = closure
5445 .max_two_way_interface_residual_ratio
5446 .max(interface_step.two_way_interface_residual_ratio);
5447 closure.max_structural_traction_update_residual_ratio = closure
5448 .max_structural_traction_update_residual_ratio
5449 .max(interface_step.structural_traction_update_residual_ratio);
5450 closure.max_pressure_displacement_law_residual_ratio = closure
5451 .max_pressure_displacement_law_residual_ratio
5452 .max(interface_step.pressure_displacement_law_residual_ratio);
5453 closure.max_structural_solve_residual_ratio = closure
5454 .max_structural_solve_residual_ratio
5455 .max(interface_step.structural_solve_residual_ratio);
5456 closure.max_interface_work_energy_residual_ratio = closure
5457 .max_interface_work_energy_residual_ratio
5458 .max(interface_step.interface_work_energy_residual_ratio);
5459 closure.max_interface_work_j_per_m2 = closure
5460 .max_interface_work_j_per_m2
5461 .max(interface_step.interface_work_j_per_m2.abs());
5462 closure.max_structural_strain_energy_j_per_m2 = closure
5463 .max_structural_strain_energy_j_per_m2
5464 .max(interface_step.structural_strain_energy_j_per_m2.abs());
5465 closure.structural_coupling_edge_count = closure
5466 .structural_coupling_edge_count
5467 .max(interface_step.structural_coupling_edge_count);
5468 closure.interface_connectivity_coverage_ratio = closure
5469 .interface_connectivity_coverage_ratio
5470 .max(interface_step.interface_connectivity_coverage_ratio);
5471 closure.mesh_backed_interface_connectivity_ratio = closure
5472 .mesh_backed_interface_connectivity_ratio
5473 .max(interface_step.mesh_backed_interface_connectivity_ratio);
5474 closure.full_topology_edge_count = closure
5475 .full_topology_edge_count
5476 .max(interface_step.full_topology_edge_count);
5477 closure.full_topology_element_count = closure
5478 .full_topology_element_count
5479 .max(interface_step.full_topology_element_count);
5480 closure.interface_stiffness_pa_per_m = closure
5481 .interface_stiffness_pa_per_m
5482 .max(interface_step.interface_stiffness_pa_per_m);
5483 let fluid_normal_force = interface_step.interface_pressure.iter().sum::<f64>();
5484 let structural_normal_force = -fluid_normal_force;
5485 let force_balance_ratio = (fluid_normal_force + structural_normal_force).abs()
5486 / (fluid_normal_force.abs() + structural_normal_force.abs() + 1.0e-12);
5487 closure.force_balance_ratio = closure.force_balance_ratio.max(force_balance_ratio);
5488 let mean_pressure = if interface_step.interface_pressure.is_empty() {
5489 0.0
5490 } else {
5491 interface_step.interface_pressure.iter().sum::<f64>()
5492 / interface_step.interface_pressure.len() as f64
5493 };
5494 closure.mean_interface_pressure_pa =
5495 closure.mean_interface_pressure_pa.max(mean_pressure.abs());
5496 closure.max_traction_magnitude_pa = closure.max_traction_magnitude_pa.max(
5497 interface_step
5498 .interface_pressure
5499 .iter()
5500 .map(|pressure| pressure.abs())
5501 .fold(0.0_f64, f64::max),
5502 );
5503 let interface_displacement = interface_step.interface_displacement.clone();
5504 let displacement_transfer_residual = displacement
5505 .iter()
5506 .zip(interface_displacement.iter())
5507 .map(|(structural, interface)| (structural - interface).abs())
5508 .fold(0.0_f64, f64::max);
5509 closure.max_displacement_transfer_residual_m = closure
5510 .max_displacement_transfer_residual_m
5511 .max(displacement_transfer_residual);
5512 closure.max_interface_displacement_m = closure.max_interface_displacement_m.max(
5513 interface_displacement
5514 .chunks_exact(3)
5515 .map(|components| {
5516 (components[0] * components[0]
5517 + components[1] * components[1]
5518 + components[2] * components[2])
5519 .sqrt()
5520 })
5521 .fold(0.0_f64, f64::max),
5522 );
5523 fields.push(AnalysisField::host_f64(
5524 fea_fsi_fluid_velocity_field_id(step_index),
5525 vec![node_count, 3],
5526 fluid_velocity,
5527 ));
5528 fields.push(AnalysisField::host_f64(
5529 fea_fsi_fluid_pressure_field_id(step_index),
5530 vec![node_count],
5531 fluid_pressure.clone(),
5532 ));
5533
5534 fields.push(AnalysisField::host_f64(
5535 fea_fsi_structural_displacement_field_id(step_index),
5536 vec![node_count, 3],
5537 displacement.clone(),
5538 ));
5539 fields.push(AnalysisField::host_f64(
5540 fea_fsi_interface_displacement_field_id(step_index),
5541 vec![node_count, 3],
5542 interface_displacement,
5543 ));
5544
5545 fields.push(AnalysisField::host_f64(
5546 fea_fsi_interface_pressure_field_id(step_index),
5547 vec![interface_step.interface_pressure.len().max(1)],
5548 interface_step.interface_pressure.clone(),
5549 ));
5550 let mut traction = Vec::with_capacity(interface_step.interface_pressure.len() * 3);
5551 for pressure in &interface_step.interface_pressure {
5552 traction.extend_from_slice(&[-*pressure, 0.0, 0.0]);
5553 }
5554 fields.push(AnalysisField::host_f64(
5555 fea_fsi_interface_traction_field_id(step_index),
5556 vec![interface_step.interface_pressure.len().max(1), 3],
5557 traction,
5558 ));
5559
5560 let residual = residual_momentum
5561 .get(step_index)
5562 .copied()
5563 .unwrap_or(0.0)
5564 .max(residual_continuity.get(step_index).copied().unwrap_or(0.0))
5565 .max(interface_step.two_way_interface_residual_ratio);
5566 closure.max_interface_residual = closure.max_interface_residual.max(residual);
5567 fields.push(AnalysisField::host_f64(
5568 fea_fsi_interface_residual_field_id(step_index),
5569 vec![1],
5570 vec![residual],
5571 ));
5572 fields.push(AnalysisField::host_f64(
5573 fea_fsi_coupling_iteration_count_field_id(step_index),
5574 vec![1],
5575 vec![interface_step.iteration_count as f64],
5576 ));
5577 }
5578
5579 (fields, closure)
5580}
5581
5582#[derive(Debug, Clone)]
5583struct FsiPartitionedInterfaceStep {
5584 interface_pressure: Vec<f64>,
5585 structural_displacement: Vec<f64>,
5586 interface_displacement: Vec<f64>,
5587 iteration_count: usize,
5588 pressure_feedback_residual_ratio: f64,
5589 two_way_interface_residual_ratio: f64,
5590 structural_traction_update_residual_ratio: f64,
5591 pressure_displacement_law_residual_ratio: f64,
5592 structural_solve_residual_ratio: f64,
5593 interface_work_j_per_m2: f64,
5594 structural_strain_energy_j_per_m2: f64,
5595 interface_work_energy_residual_ratio: f64,
5596 structural_coupling_edge_count: usize,
5597 interface_connectivity_coverage_ratio: f64,
5598 mesh_backed_interface_connectivity_ratio: f64,
5599 full_topology_edge_count: usize,
5600 full_topology_element_count: usize,
5601 interface_stiffness_pa_per_m: f64,
5602}
5603
5604#[derive(Debug, Clone)]
5605struct FsiStructuralInterfaceResponse {
5606 displacement_x: Vec<f64>,
5607 reaction_pressure: Vec<f64>,
5608 residual_ratio: f64,
5609 coupling_edge_count: usize,
5610}
5611
5612fn solve_fsi_partitioned_interface(
5613 fluid_pressure: &[f64],
5614 topology: &CfdDomainTopology,
5615 structural_compliance_per_pa: f64,
5616 max_linear_iters: usize,
5617 tolerance: f64,
5618) -> FsiPartitionedInterfaceStep {
5619 let max_linear_iters = max_linear_iters.max(1);
5620 let tolerance = tolerance.max(1.0e-12);
5621 let interface_face_count = fluid_interface_face_count(topology);
5622 let face_pressure = resample_scalar_profile(
5623 &cell_centered_scalar_from_nodal(fluid_pressure),
5624 interface_face_count,
5625 );
5626 let pressure_scale = face_pressure
5627 .iter()
5628 .map(|pressure| pressure.abs())
5629 .fold(0.0_f64, f64::max)
5630 .max(1.0);
5631 let compliance = structural_compliance_per_pa.max(1.0e-18);
5632 let pressure_relaxation = 0.65_f64;
5633 let displacement_relaxation = 0.65_f64;
5634 let traction_relaxation = 0.65_f64;
5635 let interface_stiffness_pa_per_m = 1.0 / compliance;
5636 let feedback_stiffness_pa_per_m = 0.25 * interface_stiffness_pa_per_m;
5637 let structural_coupling_edges =
5638 coupled_interface_graph_edges_for_topology(topology, interface_face_count);
5639 let interface_connectivity_coverage_ratio = coupled_interface_connectivity_coverage_ratio(
5640 topology,
5641 interface_face_count,
5642 structural_coupling_edges.len(),
5643 );
5644 let mesh_backed_interface_connectivity_ratio =
5645 coupled_interface_mesh_backed_connectivity_ratio(topology, structural_coupling_edges.len());
5646 let mut interface_pressure = vec![0.0; face_pressure.len()];
5647 let mut structural_traction = vec![0.0; face_pressure.len()];
5648 let mut structural_face_displacement = vec![0.0; face_pressure.len() * 3];
5649 let mut interface_face_displacement = vec![0.0; face_pressure.len() * 3];
5650 let mut iteration_count = 0_usize;
5651 let mut pressure_feedback_residual_ratio = if face_pressure.is_empty() { 0.0 } else { 1.0 };
5652 let mut displacement_transfer_residual_ratio = 0.0_f64;
5653 let mut structural_traction_update_residual_ratio =
5654 if face_pressure.is_empty() { 0.0 } else { 1.0 };
5655 let mut structural_solve_residual_ratio = if face_pressure.is_empty() { 0.0 } else { 1.0 };
5656 let mut structural_coupling_edge_count = 0_usize;
5657
5658 for iteration in 1..=max_linear_iters {
5659 let target_pressure: Vec<f64> = face_pressure
5660 .iter()
5661 .enumerate()
5662 .map(|(face, pressure)| {
5663 let displacement = interface_face_displacement
5664 .get(face * 3)
5665 .copied()
5666 .unwrap_or(0.0);
5667 *pressure - feedback_stiffness_pa_per_m * displacement
5668 })
5669 .collect();
5670 for (interface, target) in interface_pressure.iter_mut().zip(target_pressure.iter()) {
5671 *interface += pressure_relaxation * (*target - *interface);
5672 }
5673 let structural_response = solve_fsi_structural_interface_response(
5674 &interface_pressure,
5675 interface_stiffness_pa_per_m,
5676 &structural_coupling_edges,
5677 );
5678 structural_solve_residual_ratio = structural_response.residual_ratio;
5679 structural_coupling_edge_count =
5680 structural_coupling_edge_count.max(structural_response.coupling_edge_count);
5681 for (face, displacement_x) in structural_response.displacement_x.iter().enumerate() {
5682 structural_face_displacement[face * 3] = *displacement_x;
5683 structural_face_displacement[face * 3 + 1] = 0.0;
5684 structural_face_displacement[face * 3 + 2] = 0.0;
5685 }
5686 let target_structural_traction = structural_response.reaction_pressure;
5687 for (traction, target) in structural_traction
5688 .iter_mut()
5689 .zip(target_structural_traction.iter())
5690 {
5691 *traction += traction_relaxation * (*target - *traction);
5692 }
5693 for (interface, structural) in interface_face_displacement
5694 .iter_mut()
5695 .zip(structural_face_displacement.iter())
5696 {
5697 *interface += displacement_relaxation * (*structural - *interface);
5698 }
5699 let max_feedback_residual = target_pressure
5700 .iter()
5701 .zip(interface_pressure.iter())
5702 .map(|(target, interface)| (target - interface).abs())
5703 .fold(0.0_f64, f64::max);
5704 pressure_feedback_residual_ratio = max_feedback_residual / pressure_scale;
5705 let displacement_scale = structural_face_displacement
5706 .iter()
5707 .copied()
5708 .map(f64::abs)
5709 .fold(0.0_f64, f64::max)
5710 .max(1.0e-18);
5711 displacement_transfer_residual_ratio = structural_face_displacement
5712 .iter()
5713 .zip(interface_face_displacement.iter())
5714 .map(|(structural, interface)| (structural - interface).abs())
5715 .fold(0.0_f64, f64::max)
5716 / displacement_scale;
5717 structural_traction_update_residual_ratio = target_structural_traction
5718 .iter()
5719 .zip(structural_traction.iter())
5720 .map(|(target, traction)| (target - traction).abs())
5721 .fold(0.0_f64, f64::max)
5722 / pressure_scale;
5723 iteration_count = iteration;
5724 if pressure_feedback_residual_ratio <= tolerance
5725 && displacement_transfer_residual_ratio <= tolerance
5726 && structural_traction_update_residual_ratio <= tolerance
5727 && structural_solve_residual_ratio <= tolerance
5728 {
5729 break;
5730 }
5731 }
5732 let structural_response = solve_fsi_structural_interface_response(
5733 &interface_pressure,
5734 interface_stiffness_pa_per_m,
5735 &structural_coupling_edges,
5736 );
5737 structural_solve_residual_ratio = structural_solve_residual_ratio
5738 .max(structural_response.residual_ratio)
5739 .min(1.0);
5740 structural_coupling_edge_count =
5741 structural_coupling_edge_count.max(structural_response.coupling_edge_count);
5742 let pressure_displacement_law_residual_ratio = structural_response
5743 .reaction_pressure
5744 .iter()
5745 .zip(interface_pressure.iter())
5746 .map(|(reaction, pressure)| (reaction - pressure).abs() / pressure.abs().max(1.0))
5747 .fold(0.0_f64, f64::max);
5748 let interface_work_j_per_m2 = interface_pressure
5749 .iter()
5750 .zip(structural_response.displacement_x.iter())
5751 .map(|(pressure, displacement)| pressure * displacement)
5752 .sum::<f64>();
5753 let structural_strain_energy_j_per_m2 = 0.5
5754 * structural_response
5755 .reaction_pressure
5756 .iter()
5757 .zip(structural_response.displacement_x.iter())
5758 .map(|(reaction, displacement)| reaction * displacement)
5759 .sum::<f64>();
5760 let interface_work_energy_residual_ratio =
5761 (interface_work_j_per_m2 - 2.0 * structural_strain_energy_j_per_m2).abs()
5762 / (interface_work_j_per_m2.abs()
5763 + (2.0 * structural_strain_energy_j_per_m2).abs()
5764 + 1.0e-12);
5765 let interface_face_displacement_x = interface_face_displacement
5766 .chunks_exact(3)
5767 .map(|components| components[0])
5768 .collect::<Vec<_>>();
5769 let structural_displacement =
5770 vector_field_from_x_profile(&structural_response.displacement_x, fluid_pressure.len());
5771 let interface_displacement =
5772 vector_field_from_x_profile(&interface_face_displacement_x, fluid_pressure.len());
5773
5774 FsiPartitionedInterfaceStep {
5775 interface_pressure,
5776 structural_displacement,
5777 interface_displacement,
5778 iteration_count,
5779 pressure_feedback_residual_ratio,
5780 two_way_interface_residual_ratio: pressure_feedback_residual_ratio
5781 .max(displacement_transfer_residual_ratio)
5782 .max(structural_traction_update_residual_ratio)
5783 .max(structural_solve_residual_ratio),
5784 structural_traction_update_residual_ratio,
5785 pressure_displacement_law_residual_ratio,
5786 structural_solve_residual_ratio,
5787 interface_work_j_per_m2,
5788 structural_strain_energy_j_per_m2,
5789 interface_work_energy_residual_ratio,
5790 structural_coupling_edge_count,
5791 interface_connectivity_coverage_ratio,
5792 mesh_backed_interface_connectivity_ratio,
5793 full_topology_edge_count: topology.element_topology_edge_nodes.len(),
5794 full_topology_element_count: topology.element_topology_element_edges.len(),
5795 interface_stiffness_pa_per_m,
5796 }
5797}
5798
5799fn solve_fsi_structural_interface_response(
5800 pressure_load: &[f64],
5801 interface_stiffness_pa_per_m: f64,
5802 coupling_edges: &[(usize, usize)],
5803) -> FsiStructuralInterfaceResponse {
5804 let face_count = pressure_load.len();
5805 if face_count == 0 {
5806 return FsiStructuralInterfaceResponse {
5807 displacement_x: Vec::new(),
5808 reaction_pressure: Vec::new(),
5809 residual_ratio: 0.0,
5810 coupling_edge_count: 0,
5811 };
5812 }
5813
5814 let diagonal_stiffness = interface_stiffness_pa_per_m.max(1.0e-9);
5815 let coupling_stiffness = 0.20 * diagonal_stiffness;
5816 let mut operator = vec![vec![0.0; face_count]; face_count];
5817 for (row, diagonal) in operator.iter_mut().enumerate() {
5818 diagonal[row] = diagonal_stiffness;
5819 }
5820 for (left, right) in coupling_edges {
5821 operator[*left][*left] += coupling_stiffness;
5822 operator[*right][*right] += coupling_stiffness;
5823 operator[*left][*right] -= coupling_stiffness;
5824 operator[*right][*left] -= coupling_stiffness;
5825 }
5826
5827 let mut matrix = operator.clone();
5828 let mut rhs = pressure_load.to_vec();
5829 let displacement_x = solve_dense_real_system(&mut matrix, &mut rhs);
5830 let reaction_pressure = apply_dense_real_operator(&operator, &displacement_x);
5831 let pressure_scale = pressure_load
5832 .iter()
5833 .copied()
5834 .map(f64::abs)
5835 .fold(0.0_f64, f64::max)
5836 .max(1.0);
5837 let residual_ratio = reaction_pressure
5838 .iter()
5839 .zip(pressure_load.iter())
5840 .map(|(reaction, pressure)| (reaction - pressure).abs())
5841 .fold(0.0_f64, f64::max)
5842 / pressure_scale;
5843
5844 FsiStructuralInterfaceResponse {
5845 displacement_x,
5846 reaction_pressure,
5847 residual_ratio,
5848 coupling_edge_count: coupling_edges.len(),
5849 }
5850}
5851
5852fn coupled_interface_graph_edges(face_count: usize, edge_target: usize) -> Vec<(usize, usize)> {
5853 if face_count < 2 || edge_target == 0 {
5854 return Vec::new();
5855 }
5856 let complete_graph_edge_count = face_count * (face_count - 1) / 2;
5857 let edge_target = edge_target.min(complete_graph_edge_count);
5858 let mut edges = Vec::with_capacity(edge_target);
5859 for span in 1..face_count {
5860 for left in 0..face_count - span {
5861 edges.push((left, left + span));
5862 if edges.len() == edge_target {
5863 return edges;
5864 }
5865 }
5866 }
5867 edges
5868}
5869
5870fn solve_dense_real_system(matrix: &mut [Vec<f64>], rhs: &mut [f64]) -> Vec<f64> {
5871 let n = rhs.len();
5872 for pivot in 0..n {
5873 let mut pivot_row = pivot;
5874 let mut pivot_abs = matrix[pivot][pivot].abs();
5875 for (candidate, row) in matrix.iter().enumerate().skip(pivot + 1) {
5876 let candidate_abs = row[pivot].abs();
5877 if candidate_abs > pivot_abs {
5878 pivot_row = candidate;
5879 pivot_abs = candidate_abs;
5880 }
5881 }
5882 if pivot_row != pivot {
5883 matrix.swap(pivot, pivot_row);
5884 rhs.swap(pivot, pivot_row);
5885 }
5886 if pivot_abs <= 1.0e-24 {
5887 matrix[pivot][pivot] += 1.0e-9;
5888 }
5889 let pivot_value = matrix[pivot][pivot];
5890 for row in pivot + 1..n {
5891 let factor = matrix[row][pivot] / pivot_value;
5892 matrix[row][pivot] = 0.0;
5893 for col in pivot + 1..n {
5894 matrix[row][col] -= factor * matrix[pivot][col];
5895 }
5896 rhs[row] -= factor * rhs[pivot];
5897 }
5898 }
5899
5900 let mut solution = vec![0.0; n];
5901 for row in (0..n).rev() {
5902 let mut accum = rhs[row];
5903 for (col, value) in solution.iter().enumerate().skip(row + 1) {
5904 accum -= matrix[row][col] * value;
5905 }
5906 solution[row] = accum
5907 / matrix[row][row]
5908 .abs()
5909 .max(1.0e-18)
5910 .copysign(matrix[row][row]);
5911 }
5912 solution
5913}
5914
5915fn apply_dense_real_operator(matrix: &[Vec<f64>], x: &[f64]) -> Vec<f64> {
5916 matrix
5917 .iter()
5918 .map(|row| {
5919 row.iter()
5920 .zip(x.iter())
5921 .map(|(coefficient, value)| coefficient * value)
5922 .sum::<f64>()
5923 })
5924 .collect()
5925}
5926
5927fn vector_field_from_x_profile(x: &[f64], target_count: usize) -> Vec<f64> {
5928 let profile = resample_scalar_profile(x, target_count);
5929 let mut field = Vec::with_capacity(profile.len() * 3);
5930 for displacement_x in profile {
5931 field.extend_from_slice(&[displacement_x, 0.0, 0.0]);
5932 }
5933 field
5934}
5935
5936#[derive(Debug, Clone, Copy, Default)]
5937struct FsiInterfaceClosure {
5938 interface_node_count: usize,
5939 interface_face_count: usize,
5940 max_interface_residual: f64,
5941 force_balance_ratio: f64,
5942 max_displacement_transfer_residual_m: f64,
5943 max_interface_displacement_m: f64,
5944 mean_interface_pressure_pa: f64,
5945 max_traction_magnitude_pa: f64,
5946 max_coupling_iteration_count: usize,
5947 max_pressure_feedback_residual_ratio: f64,
5948 max_two_way_interface_residual_ratio: f64,
5949 max_structural_traction_update_residual_ratio: f64,
5950 max_pressure_displacement_law_residual_ratio: f64,
5951 max_structural_solve_residual_ratio: f64,
5952 max_interface_work_j_per_m2: f64,
5953 max_structural_strain_energy_j_per_m2: f64,
5954 max_interface_work_energy_residual_ratio: f64,
5955 structural_coupling_edge_count: usize,
5956 interface_connectivity_coverage_ratio: f64,
5957 mesh_backed_interface_connectivity_ratio: f64,
5958 full_topology_edge_count: usize,
5959 full_topology_element_count: usize,
5960 interface_stiffness_pa_per_m: f64,
5961}
5962
5963#[derive(Debug, Clone, Copy)]
5964struct FsiKnownAnswerMetrics {
5965 pressure_loaded_wall_displacement_law_residual_ratio: f64,
5966 interface_traction_balance_residual_ratio: f64,
5967 interface_displacement_transfer_residual_m: f64,
5968 partitioned_pressure_feedback_residual_ratio: f64,
5969 two_way_interface_residual_ratio: f64,
5970 structural_traction_update_residual_ratio: f64,
5971 structural_solve_residual_ratio: f64,
5972 interface_work_energy_residual_ratio: f64,
5973 interface_connectivity_coverage_ratio: f64,
5974 mesh_backed_interface_connectivity_ratio: f64,
5975 known_answer_coverage_ratio: f64,
5976}
5977
5978fn fsi_known_answer_metrics(closure: &FsiInterfaceClosure) -> FsiKnownAnswerMetrics {
5979 let known_answer_coverage_ratio = if closure.interface_node_count > 0
5980 && closure.interface_face_count > 0
5981 && closure.mean_interface_pressure_pa.is_finite()
5982 && closure.mean_interface_pressure_pa > 0.0
5983 && closure.max_traction_magnitude_pa.is_finite()
5984 && closure.max_traction_magnitude_pa > 0.0
5985 && closure.max_interface_displacement_m.is_finite()
5986 && closure.max_interface_displacement_m > 0.0
5987 && closure.interface_stiffness_pa_per_m.is_finite()
5988 && closure.interface_stiffness_pa_per_m > 0.0
5989 && closure
5990 .max_pressure_displacement_law_residual_ratio
5991 .is_finite()
5992 && closure.force_balance_ratio.is_finite()
5993 && closure.max_pressure_feedback_residual_ratio.is_finite()
5994 && closure.max_two_way_interface_residual_ratio.is_finite()
5995 && closure
5996 .max_structural_traction_update_residual_ratio
5997 .is_finite()
5998 && closure.max_structural_solve_residual_ratio.is_finite()
5999 && closure.max_interface_work_j_per_m2.is_finite()
6000 && closure.max_interface_work_j_per_m2 > 0.0
6001 && closure.max_structural_strain_energy_j_per_m2.is_finite()
6002 && closure.max_structural_strain_energy_j_per_m2 > 0.0
6003 && closure.max_interface_work_energy_residual_ratio.is_finite()
6004 && closure.structural_coupling_edge_count > 0
6005 && closure.interface_connectivity_coverage_ratio.is_finite()
6006 && closure.interface_connectivity_coverage_ratio >= 1.0
6007 && closure.mesh_backed_interface_connectivity_ratio.is_finite()
6008 {
6009 1.0
6010 } else {
6011 0.0
6012 };
6013
6014 FsiKnownAnswerMetrics {
6015 pressure_loaded_wall_displacement_law_residual_ratio: closure
6016 .max_pressure_displacement_law_residual_ratio,
6017 interface_traction_balance_residual_ratio: closure.force_balance_ratio,
6018 interface_displacement_transfer_residual_m: closure.max_displacement_transfer_residual_m,
6019 partitioned_pressure_feedback_residual_ratio: closure.max_pressure_feedback_residual_ratio,
6020 two_way_interface_residual_ratio: closure.max_two_way_interface_residual_ratio,
6021 structural_traction_update_residual_ratio: closure
6022 .max_structural_traction_update_residual_ratio,
6023 structural_solve_residual_ratio: closure.max_structural_solve_residual_ratio,
6024 interface_work_energy_residual_ratio: closure.max_interface_work_energy_residual_ratio,
6025 interface_connectivity_coverage_ratio: closure.interface_connectivity_coverage_ratio,
6026 mesh_backed_interface_connectivity_ratio: closure.mesh_backed_interface_connectivity_ratio,
6027 known_answer_coverage_ratio,
6028 }
6029}
6030
6031fn fsi_known_answer_diagnostic(
6032 metrics: &FsiKnownAnswerMetrics,
6033 tolerance: f64,
6034) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
6035 let severity = if metrics.pressure_loaded_wall_displacement_law_residual_ratio <= tolerance
6036 && metrics.interface_traction_balance_residual_ratio <= 1.0e-9
6037 && metrics.interface_displacement_transfer_residual_m <= 1.0e-12
6038 && metrics.partitioned_pressure_feedback_residual_ratio <= tolerance
6039 && metrics.two_way_interface_residual_ratio <= tolerance
6040 && metrics.structural_traction_update_residual_ratio <= tolerance
6041 && metrics.structural_solve_residual_ratio <= tolerance
6042 && metrics.interface_work_energy_residual_ratio <= tolerance
6043 && metrics.interface_connectivity_coverage_ratio >= 1.0
6044 && metrics.known_answer_coverage_ratio >= 1.0
6045 {
6046 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
6047 } else {
6048 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
6049 };
6050
6051 runmat_analysis_fea::diagnostics::FeaDiagnostic {
6052 code: "FEA_FSI_KNOWN_ANSWER".to_string(),
6053 severity,
6054 message: format!(
6055 "basis=pressure_loaded_wall_partitioned pressure_loaded_wall_displacement_law_residual_ratio={} interface_traction_balance_residual_ratio={} interface_displacement_transfer_residual_m={} partitioned_pressure_feedback_residual_ratio={} two_way_interface_residual_ratio={} structural_traction_update_residual_ratio={} structural_solve_residual_ratio={} interface_work_energy_residual_ratio={} interface_connectivity_coverage_ratio={} mesh_backed_interface_connectivity_ratio={} known_answer_coverage_ratio={}",
6056 metrics.pressure_loaded_wall_displacement_law_residual_ratio,
6057 metrics.interface_traction_balance_residual_ratio,
6058 metrics.interface_displacement_transfer_residual_m,
6059 metrics.partitioned_pressure_feedback_residual_ratio,
6060 metrics.two_way_interface_residual_ratio,
6061 metrics.structural_traction_update_residual_ratio,
6062 metrics.structural_solve_residual_ratio,
6063 metrics.interface_work_energy_residual_ratio,
6064 metrics.interface_connectivity_coverage_ratio,
6065 metrics.mesh_backed_interface_connectivity_ratio,
6066 metrics.known_answer_coverage_ratio,
6067 ),
6068 }
6069}
6070
6071fn fsi_structural_compliance_per_pa(model: &AnalysisModel) -> f64 {
6072 if let Some(stiffness) = fsi_interface_normal_stiffness_pa_per_m(model) {
6073 return 1.0 / stiffness.max(1.0e-18);
6074 }
6075
6076 let mean_modulus = model
6077 .materials
6078 .iter()
6079 .filter_map(|material| {
6080 let modulus = material.mechanical.youngs_modulus_pa;
6081 (modulus.is_finite() && modulus > 0.0).then_some(modulus)
6082 })
6083 .fold((0.0_f64, 0_usize), |(sum, count), modulus| {
6084 (sum + modulus, count + 1)
6085 });
6086 let youngs_modulus = if mean_modulus.1 > 0 {
6087 mean_modulus.0 / mean_modulus.1 as f64
6088 } else {
6089 200.0e9
6090 };
6091 1.0 / youngs_modulus.max(1.0e6)
6092}
6093
6094fn fsi_interface_normal_stiffness_pa_per_m(model: &AnalysisModel) -> Option<f64> {
6095 model
6096 .interfaces
6097 .iter()
6098 .find_map(|interface| match &interface.kind {
6099 AnalysisInterfaceKind::FluidStructure(fluid_structure)
6100 if fluid_structure.normal_stiffness_pa_per_m.is_finite()
6101 && fluid_structure.normal_stiffness_pa_per_m > 0.0 =>
6102 {
6103 Some(fluid_structure.normal_stiffness_pa_per_m)
6104 }
6105 AnalysisInterfaceKind::FluidStructure(_)
6106 | AnalysisInterfaceKind::ConjugateHeatTransfer(_)
6107 | AnalysisInterfaceKind::Contact(_) => None,
6108 })
6109}
6110
6111pub fn analysis_run_transient_op(
6112 model: &AnalysisModel,
6113 backend: ComputeBackend,
6114 context: OperationContext,
6115) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6116 analysis_run_transient_with_options_op(
6117 model,
6118 backend,
6119 AnalysisTransientRunOptions::default(),
6120 context,
6121 )
6122}
6123
6124pub fn analysis_run_cfd_op(
6125 model: &AnalysisModel,
6126 backend: ComputeBackend,
6127 context: OperationContext,
6128) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6129 analysis_run_cfd_with_options_op(model, backend, AnalysisCfdRunOptions::default(), context)
6130}
6131
6132pub fn analysis_run_cfd_with_options_op(
6133 model: &AnalysisModel,
6134 backend: ComputeBackend,
6135 options: AnalysisCfdRunOptions,
6136 context: OperationContext,
6137) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6138 let _solver_context = install_fea_solver_context();
6139 let has_cfd_step = model
6140 .steps
6141 .iter()
6142 .any(|step| step.kind == AnalysisStepKind::Cfd);
6143 if !has_cfd_step {
6144 return Err(operation_error(
6145 ANALYSIS_RUN_CFD_OPERATION,
6146 ANALYSIS_RUN_CFD_OP_VERSION,
6147 &context,
6148 OperationErrorSpec {
6149 error_code: "RM.FEA.RUN_CFD.INVALID_MODEL",
6150 error_type: OperationErrorType::Validation,
6151 retryable: false,
6152 severity: OperationErrorSeverity::Error,
6153 },
6154 "FEA model must include at least one cfd step for fea.run_cfd",
6155 BTreeMap::from([
6156 ("analysis_model_id".to_string(), model.model_id.0.clone()),
6157 ("geometry_id".to_string(), model.geometry_id.clone()),
6158 ]),
6159 ));
6160 }
6161
6162 let Some(cfd_domain) = model.cfd.as_ref() else {
6163 return Err(operation_error(
6164 ANALYSIS_RUN_CFD_OPERATION,
6165 ANALYSIS_RUN_CFD_OP_VERSION,
6166 &context,
6167 OperationErrorSpec {
6168 error_code: "RM.FEA.RUN_CFD.INVALID_MODEL",
6169 error_type: OperationErrorType::Validation,
6170 retryable: false,
6171 severity: OperationErrorSeverity::Error,
6172 },
6173 "fea.run_cfd requires model.cfd to be configured",
6174 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6175 ));
6176 };
6177
6178 if !cfd_domain.enabled {
6179 return Err(operation_error(
6180 ANALYSIS_RUN_CFD_OPERATION,
6181 ANALYSIS_RUN_CFD_OP_VERSION,
6182 &context,
6183 OperationErrorSpec {
6184 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6185 error_type: OperationErrorType::Input,
6186 retryable: false,
6187 severity: OperationErrorSeverity::Error,
6188 },
6189 "fea.run_cfd requires cfd domain enabled=true",
6190 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6191 ));
6192 }
6193 if !cfd_domain.reference_density_kg_per_m3.is_finite()
6194 || cfd_domain.reference_density_kg_per_m3 <= 0.0
6195 {
6196 return Err(operation_error(
6197 ANALYSIS_RUN_CFD_OPERATION,
6198 ANALYSIS_RUN_CFD_OP_VERSION,
6199 &context,
6200 OperationErrorSpec {
6201 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6202 error_type: OperationErrorType::Input,
6203 retryable: false,
6204 severity: OperationErrorSeverity::Error,
6205 },
6206 "fea.run_cfd requires finite positive reference_density_kg_per_m3",
6207 BTreeMap::from([(
6208 "reference_density_kg_per_m3".to_string(),
6209 cfd_domain.reference_density_kg_per_m3.to_string(),
6210 )]),
6211 ));
6212 }
6213 if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
6214 return Err(operation_error(
6215 ANALYSIS_RUN_CFD_OPERATION,
6216 ANALYSIS_RUN_CFD_OP_VERSION,
6217 &context,
6218 OperationErrorSpec {
6219 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6220 error_type: OperationErrorType::Input,
6221 retryable: false,
6222 severity: OperationErrorSeverity::Error,
6223 },
6224 "fea.run_cfd requires finite positive dynamic_viscosity_pa_s",
6225 BTreeMap::from([(
6226 "dynamic_viscosity_pa_s".to_string(),
6227 cfd_domain.dynamic_viscosity_pa_s.to_string(),
6228 )]),
6229 ));
6230 }
6231 if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
6232 return Err(operation_error(
6233 ANALYSIS_RUN_CFD_OPERATION,
6234 ANALYSIS_RUN_CFD_OP_VERSION,
6235 &context,
6236 OperationErrorSpec {
6237 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6238 error_type: OperationErrorType::Input,
6239 retryable: false,
6240 severity: OperationErrorSeverity::Error,
6241 },
6242 "fea.run_cfd requires finite non-negative inlet_velocity_m_per_s",
6243 BTreeMap::from([(
6244 "inlet_velocity_m_per_s".to_string(),
6245 cfd_domain.inlet_velocity_m_per_s.to_string(),
6246 )]),
6247 ));
6248 }
6249 if !cfd_domain.turbulence_intensity.is_finite()
6250 || cfd_domain.turbulence_intensity < 0.0
6251 || cfd_domain.turbulence_intensity > 1.0
6252 {
6253 return Err(operation_error(
6254 ANALYSIS_RUN_CFD_OPERATION,
6255 ANALYSIS_RUN_CFD_OP_VERSION,
6256 &context,
6257 OperationErrorSpec {
6258 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6259 error_type: OperationErrorType::Input,
6260 retryable: false,
6261 severity: OperationErrorSeverity::Error,
6262 },
6263 "fea.run_cfd requires turbulence_intensity in [0, 1]",
6264 BTreeMap::from([(
6265 "turbulence_intensity".to_string(),
6266 cfd_domain.turbulence_intensity.to_string(),
6267 )]),
6268 ));
6269 }
6270 reject_moment_loads_for_run_family(
6271 model,
6272 ANALYSIS_RUN_CFD_OPERATION,
6273 ANALYSIS_RUN_CFD_OP_VERSION,
6274 "RM.FEA.RUN_CFD.INVALID_LOAD",
6275 "CFD",
6276 &context,
6277 )?;
6278 if let Err(detail) = validate_authored_cfd_boundary_conditions(model) {
6279 return Err(operation_error(
6280 ANALYSIS_RUN_CFD_OPERATION,
6281 ANALYSIS_RUN_CFD_OP_VERSION,
6282 &context,
6283 OperationErrorSpec {
6284 error_code: "RM.FEA.RUN_CFD.INVALID_BOUNDARY_CONDITIONS",
6285 error_type: OperationErrorType::Validation,
6286 retryable: false,
6287 severity: OperationErrorSeverity::Error,
6288 },
6289 detail.clone(),
6290 BTreeMap::from([
6291 ("analysis_model_id".to_string(), model.model_id.0.clone()),
6292 ("detail".to_string(), detail),
6293 ]),
6294 ));
6295 }
6296
6297 if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
6298 return Err(operation_error(
6299 ANALYSIS_RUN_CFD_OPERATION,
6300 ANALYSIS_RUN_CFD_OP_VERSION,
6301 &context,
6302 OperationErrorSpec {
6303 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6304 error_type: OperationErrorType::Input,
6305 retryable: false,
6306 severity: OperationErrorSeverity::Error,
6307 },
6308 "fea.run_cfd options require finite positive time_step_s",
6309 BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
6310 ));
6311 }
6312 if options.step_count == 0 {
6313 return Err(operation_error(
6314 ANALYSIS_RUN_CFD_OPERATION,
6315 ANALYSIS_RUN_CFD_OP_VERSION,
6316 &context,
6317 OperationErrorSpec {
6318 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6319 error_type: OperationErrorType::Input,
6320 retryable: false,
6321 severity: OperationErrorSeverity::Error,
6322 },
6323 "fea.run_cfd options require step_count greater than zero",
6324 BTreeMap::from([("step_count".to_string(), options.step_count.to_string())]),
6325 ));
6326 }
6327 if options.max_linear_iters == 0 {
6328 return Err(operation_error(
6329 ANALYSIS_RUN_CFD_OPERATION,
6330 ANALYSIS_RUN_CFD_OP_VERSION,
6331 &context,
6332 OperationErrorSpec {
6333 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6334 error_type: OperationErrorType::Input,
6335 retryable: false,
6336 severity: OperationErrorSeverity::Error,
6337 },
6338 "fea.run_cfd options require max_linear_iters greater than zero",
6339 BTreeMap::from([(
6340 "max_linear_iters".to_string(),
6341 options.max_linear_iters.to_string(),
6342 )]),
6343 ));
6344 }
6345 if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
6346 return Err(operation_error(
6347 ANALYSIS_RUN_CFD_OPERATION,
6348 ANALYSIS_RUN_CFD_OP_VERSION,
6349 &context,
6350 OperationErrorSpec {
6351 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6352 error_type: OperationErrorType::Input,
6353 retryable: false,
6354 severity: OperationErrorSeverity::Error,
6355 },
6356 "fea.run_cfd options require finite positive tolerance",
6357 BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
6358 ));
6359 }
6360 if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
6361 return Err(operation_error(
6362 ANALYSIS_RUN_CFD_OPERATION,
6363 ANALYSIS_RUN_CFD_OP_VERSION,
6364 &context,
6365 OperationErrorSpec {
6366 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6367 error_type: OperationErrorType::Input,
6368 retryable: false,
6369 severity: OperationErrorSeverity::Error,
6370 },
6371 "fea.run_cfd options require finite positive residual_warn_threshold",
6372 BTreeMap::from([(
6373 "residual_warn_threshold".to_string(),
6374 options.residual_warn_threshold.to_string(),
6375 )]),
6376 ));
6377 }
6378
6379 let prep_context = resolve_run_prep_context(
6380 model,
6381 options.prep_artifact_id.as_deref(),
6382 options.prep_context.clone(),
6383 ANALYSIS_RUN_CFD_OPERATION,
6384 ANALYSIS_RUN_CFD_OP_VERSION,
6385 &context,
6386 )?;
6387
6388 let solve_start = Instant::now();
6389 let mut run =
6390 solve_cfd_finite_volume_run(model, cfd_domain, backend, &options, prep_context.as_ref());
6391 let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
6392 run.diagnostics
6393 .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6394 code: "FEA_CFD_COST".to_string(),
6395 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6396 message: format!(
6397 "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
6398 solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
6399 ),
6400 });
6401 let mut fallback_events = Vec::new();
6402 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
6403 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
6404 fallback_events.push(
6405 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
6406 );
6407 }
6408
6409 let flow_topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
6410 let flow_boundary_summary = CfdBoundarySummary::from_model(model, cfd_domain, 2);
6411 let flow_inlet_velocity = flow_boundary_summary.nominal_inlet_velocity_m_per_s;
6412 let reynolds_number = cfd_reynolds_number_for_velocity(cfd_domain, flow_inlet_velocity);
6413 let solve_family = match cfd_domain.solve_family {
6414 runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
6415 runmat_analysis_core::CfdSolveFamily::Transient => "transient",
6416 };
6417 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6418 code: "FEA_CFD_FLOW".to_string(),
6419 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6420 message: format!(
6421 "density={} viscosity={} inlet_velocity={} turbulence_intensity={} reynolds_number={} solve_family={} profile_point_count={} topology_basis={} control_volume_count={} control_volume_face_count={} control_volume_internal_face_count={} control_volume_boundary_face_count={} control_volume_connectivity_coverage_ratio={} domain_length_m={} hydraulic_diameter_m={}",
6422 cfd_domain.reference_density_kg_per_m3,
6423 cfd_domain.dynamic_viscosity_pa_s,
6424 flow_inlet_velocity,
6425 cfd_domain.turbulence_intensity,
6426 reynolds_number,
6427 solve_family,
6428 cfd_domain.time_profile.len(),
6429 flow_topology.basis.as_str(),
6430 flow_topology.control_volume_count,
6431 flow_topology.control_volume_face_count,
6432 flow_topology.control_volume_internal_face_count,
6433 flow_topology.control_volume_boundary_face_count,
6434 flow_topology.control_volume_connectivity_coverage_ratio,
6435 flow_topology.domain_length_m,
6436 flow_topology.hydraulic_diameter_m,
6437 ),
6438 });
6439
6440 let max_momentum_residual = diagnostic_metric(
6441 &run.diagnostics,
6442 "FEA_CFD_RESIDUAL",
6443 "max_momentum_residual",
6444 )
6445 .unwrap_or(f64::INFINITY);
6446 let max_continuity_residual = diagnostic_metric(
6447 &run.diagnostics,
6448 "FEA_CFD_RESIDUAL",
6449 "max_continuity_residual",
6450 )
6451 .unwrap_or(f64::INFINITY);
6452 let solver_convergence = if max_momentum_residual <= options.residual_warn_threshold
6453 && max_continuity_residual <= options.residual_warn_threshold
6454 {
6455 QualityGate::Pass
6456 } else {
6457 QualityGate::Warn
6458 };
6459 let result_quality = if run.fields_are_empty()
6460 || !max_momentum_residual.is_finite()
6461 || !max_continuity_residual.is_finite()
6462 {
6463 QualityGate::Fail
6464 } else if max_momentum_residual > options.residual_warn_threshold
6465 || max_continuity_residual > options.residual_warn_threshold
6466 {
6467 QualityGate::Warn
6468 } else {
6469 QualityGate::Pass
6470 };
6471
6472 let mut quality_reasons = Vec::new();
6473 if solver_convergence == QualityGate::Warn {
6474 quality_reasons.push(QualityReason {
6475 code: QualityReasonCode::SolverNotConverged,
6476 detail: "cfd solver convergence gate is warning".to_string(),
6477 });
6478 }
6479 if result_quality == QualityGate::Warn {
6480 quality_reasons.push(QualityReason {
6481 code: QualityReasonCode::TransientResidualExceeded,
6482 detail: format!(
6483 "cfd residual exceeds threshold {}",
6484 options.residual_warn_threshold
6485 ),
6486 });
6487 }
6488 if fallback_events
6489 .iter()
6490 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
6491 {
6492 quality_reasons.push(QualityReason {
6493 code: QualityReasonCode::SolverBackendFallback,
6494 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
6495 });
6496 }
6497 if fallback_events.iter().any(|event| {
6498 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
6499 }) {
6500 quality_reasons.push(QualityReason {
6501 code: QualityReasonCode::FieldPromotionFallback,
6502 detail: "field promotion fell back to host-backed values".to_string(),
6503 });
6504 }
6505
6506 let publishable = match options.quality_policy {
6507 QualityPolicy::Strict => {
6508 solver_convergence == QualityGate::Pass
6509 && result_quality == QualityGate::Pass
6510 && quality_reasons.is_empty()
6511 }
6512 QualityPolicy::Balanced => {
6513 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
6514 }
6515 QualityPolicy::Exploratory => {
6516 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
6517 }
6518 };
6519 let run_status = if publishable {
6520 RunStatus::Publishable
6521 } else if result_quality == QualityGate::Fail {
6522 RunStatus::Rejected
6523 } else {
6524 RunStatus::Degraded
6525 };
6526 let solver_backend = run.solver_backend.clone();
6527 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
6528 let solver_host_sync_count = run.solver_host_sync_count;
6529 let solver_method = run.solver_method.clone();
6530 let selected_preconditioner = run.preconditioner.clone();
6531
6532 let result = AnalysisRunResult {
6533 run_id: storage::next_run_id(),
6534 run,
6535 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
6536 modal_results: None,
6537 thermal_results: None,
6538 transient_results: None,
6539 nonlinear_results: None,
6540 electromagnetic_results: None,
6541 model_validity: QualityGate::Pass,
6542 solver_convergence,
6543 result_quality,
6544 run_status,
6545 publishable,
6546 quality_reasons,
6547 provenance: RunProvenance {
6548 backend,
6549 solver_backend,
6550 solver_device_apply_k_ratio,
6551 solver_host_sync_count,
6552 precision_mode: contracts::format_precision_mode(options.precision_mode),
6553 deterministic_mode: options.deterministic_mode,
6554 solver_method,
6555 preconditioner: selected_preconditioner,
6556 quality_policy: contracts::format_quality_policy(options.quality_policy),
6557 fallback_events,
6558 },
6559 };
6560
6561 persist_fea_run_result_with_progress(
6562 ANALYSIS_RUN_CFD_OPERATION,
6563 ANALYSIS_RUN_CFD_OP_VERSION,
6564 "RM.FEA.RUN_CFD.ARTIFACT_STORE_FAILED",
6565 &context,
6566 &result,
6567 )?;
6568
6569 Ok(OperationEnvelope::new(
6570 ANALYSIS_RUN_CFD_OPERATION,
6571 ANALYSIS_RUN_CFD_OP_VERSION,
6572 &context,
6573 result,
6574 ))
6575}
6576
6577pub fn analysis_run_thermal_op(
6578 model: &AnalysisModel,
6579 backend: ComputeBackend,
6580 context: OperationContext,
6581) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6582 analysis_run_thermal_with_options_op(
6583 model,
6584 backend,
6585 AnalysisThermalRunOptions::default(),
6586 context,
6587 )
6588}
6589
6590pub fn analysis_run_cht_op(
6591 model: &AnalysisModel,
6592 backend: ComputeBackend,
6593 context: OperationContext,
6594) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6595 analysis_run_cht_with_options_op(model, backend, AnalysisChtRunOptions::default(), context)
6596}
6597
6598pub fn analysis_run_cht_with_options_op(
6599 model: &AnalysisModel,
6600 backend: ComputeBackend,
6601 options: AnalysisChtRunOptions,
6602 context: OperationContext,
6603) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6604 let _solver_context = install_fea_solver_context();
6605 let has_cfd_step = model
6606 .steps
6607 .iter()
6608 .any(|step| step.kind == AnalysisStepKind::Cfd);
6609 if !has_cfd_step {
6610 return Err(operation_error(
6611 ANALYSIS_RUN_CHT_OPERATION,
6612 ANALYSIS_RUN_CHT_OP_VERSION,
6613 &context,
6614 OperationErrorSpec {
6615 error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6616 error_type: OperationErrorType::Validation,
6617 retryable: false,
6618 severity: OperationErrorSeverity::Error,
6619 },
6620 "FEA model must include at least one cfd step for fea.run_cht",
6621 BTreeMap::from([
6622 ("analysis_model_id".to_string(), model.model_id.0.clone()),
6623 ("geometry_id".to_string(), model.geometry_id.clone()),
6624 ]),
6625 ));
6626 }
6627 let has_thermal_step = model
6628 .steps
6629 .iter()
6630 .any(|step| step.kind == AnalysisStepKind::Thermal);
6631 if !has_thermal_step {
6632 return Err(operation_error(
6633 ANALYSIS_RUN_CHT_OPERATION,
6634 ANALYSIS_RUN_CHT_OP_VERSION,
6635 &context,
6636 OperationErrorSpec {
6637 error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6638 error_type: OperationErrorType::Validation,
6639 retryable: false,
6640 severity: OperationErrorSeverity::Error,
6641 },
6642 "FEA model must include at least one thermal step for fea.run_cht",
6643 BTreeMap::from([
6644 ("analysis_model_id".to_string(), model.model_id.0.clone()),
6645 ("geometry_id".to_string(), model.geometry_id.clone()),
6646 ]),
6647 ));
6648 }
6649 let Some(cfd_domain) = model.cfd.as_ref() else {
6650 return Err(operation_error(
6651 ANALYSIS_RUN_CHT_OPERATION,
6652 ANALYSIS_RUN_CHT_OP_VERSION,
6653 &context,
6654 OperationErrorSpec {
6655 error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6656 error_type: OperationErrorType::Validation,
6657 retryable: false,
6658 severity: OperationErrorSeverity::Error,
6659 },
6660 "fea.run_cht requires model.cfd to be configured",
6661 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6662 ));
6663 };
6664 if !cfd_domain.enabled {
6665 return Err(operation_error(
6666 ANALYSIS_RUN_CHT_OPERATION,
6667 ANALYSIS_RUN_CHT_OP_VERSION,
6668 &context,
6669 OperationErrorSpec {
6670 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6671 error_type: OperationErrorType::Input,
6672 retryable: false,
6673 severity: OperationErrorSeverity::Error,
6674 },
6675 "fea.run_cht requires cfd domain enabled=true",
6676 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6677 ));
6678 }
6679 if !cfd_domain.reference_density_kg_per_m3.is_finite()
6680 || cfd_domain.reference_density_kg_per_m3 <= 0.0
6681 {
6682 return Err(operation_error(
6683 ANALYSIS_RUN_CHT_OPERATION,
6684 ANALYSIS_RUN_CHT_OP_VERSION,
6685 &context,
6686 OperationErrorSpec {
6687 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6688 error_type: OperationErrorType::Input,
6689 retryable: false,
6690 severity: OperationErrorSeverity::Error,
6691 },
6692 "fea.run_cht requires finite positive reference_density_kg_per_m3",
6693 BTreeMap::from([(
6694 "reference_density_kg_per_m3".to_string(),
6695 cfd_domain.reference_density_kg_per_m3.to_string(),
6696 )]),
6697 ));
6698 }
6699 if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
6700 return Err(operation_error(
6701 ANALYSIS_RUN_CHT_OPERATION,
6702 ANALYSIS_RUN_CHT_OP_VERSION,
6703 &context,
6704 OperationErrorSpec {
6705 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6706 error_type: OperationErrorType::Input,
6707 retryable: false,
6708 severity: OperationErrorSeverity::Error,
6709 },
6710 "fea.run_cht requires finite positive dynamic_viscosity_pa_s",
6711 BTreeMap::from([(
6712 "dynamic_viscosity_pa_s".to_string(),
6713 cfd_domain.dynamic_viscosity_pa_s.to_string(),
6714 )]),
6715 ));
6716 }
6717 if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
6718 return Err(operation_error(
6719 ANALYSIS_RUN_CHT_OPERATION,
6720 ANALYSIS_RUN_CHT_OP_VERSION,
6721 &context,
6722 OperationErrorSpec {
6723 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6724 error_type: OperationErrorType::Input,
6725 retryable: false,
6726 severity: OperationErrorSeverity::Error,
6727 },
6728 "fea.run_cht requires finite non-negative inlet_velocity_m_per_s",
6729 BTreeMap::from([(
6730 "inlet_velocity_m_per_s".to_string(),
6731 cfd_domain.inlet_velocity_m_per_s.to_string(),
6732 )]),
6733 ));
6734 }
6735 if !cfd_domain.turbulence_intensity.is_finite()
6736 || cfd_domain.turbulence_intensity < 0.0
6737 || cfd_domain.turbulence_intensity > 1.0
6738 {
6739 return Err(operation_error(
6740 ANALYSIS_RUN_CHT_OPERATION,
6741 ANALYSIS_RUN_CHT_OP_VERSION,
6742 &context,
6743 OperationErrorSpec {
6744 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6745 error_type: OperationErrorType::Input,
6746 retryable: false,
6747 severity: OperationErrorSeverity::Error,
6748 },
6749 "fea.run_cht requires turbulence_intensity in [0, 1]",
6750 BTreeMap::from([(
6751 "turbulence_intensity".to_string(),
6752 cfd_domain.turbulence_intensity.to_string(),
6753 )]),
6754 ));
6755 }
6756 reject_moment_loads_for_run_family(
6757 model,
6758 ANALYSIS_RUN_CHT_OPERATION,
6759 ANALYSIS_RUN_CHT_OP_VERSION,
6760 "RM.FEA.RUN_CHT.INVALID_LOAD",
6761 "CHT",
6762 &context,
6763 )?;
6764 if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
6765 return Err(operation_error(
6766 ANALYSIS_RUN_CHT_OPERATION,
6767 ANALYSIS_RUN_CHT_OP_VERSION,
6768 &context,
6769 OperationErrorSpec {
6770 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6771 error_type: OperationErrorType::Input,
6772 retryable: false,
6773 severity: OperationErrorSeverity::Error,
6774 },
6775 "fea.run_cht options require finite positive time_step_s",
6776 BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
6777 ));
6778 }
6779 if options.step_count == 0 || options.max_linear_iters == 0 {
6780 return Err(operation_error(
6781 ANALYSIS_RUN_CHT_OPERATION,
6782 ANALYSIS_RUN_CHT_OP_VERSION,
6783 &context,
6784 OperationErrorSpec {
6785 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6786 error_type: OperationErrorType::Input,
6787 retryable: false,
6788 severity: OperationErrorSeverity::Error,
6789 },
6790 "fea.run_cht options require step_count/max_linear_iters greater than zero",
6791 BTreeMap::new(),
6792 ));
6793 }
6794 if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
6795 return Err(operation_error(
6796 ANALYSIS_RUN_CHT_OPERATION,
6797 ANALYSIS_RUN_CHT_OP_VERSION,
6798 &context,
6799 OperationErrorSpec {
6800 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6801 error_type: OperationErrorType::Input,
6802 retryable: false,
6803 severity: OperationErrorSeverity::Error,
6804 },
6805 "fea.run_cht options require finite positive tolerance",
6806 BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
6807 ));
6808 }
6809 if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
6810 return Err(operation_error(
6811 ANALYSIS_RUN_CHT_OPERATION,
6812 ANALYSIS_RUN_CHT_OP_VERSION,
6813 &context,
6814 OperationErrorSpec {
6815 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6816 error_type: OperationErrorType::Input,
6817 retryable: false,
6818 severity: OperationErrorSeverity::Error,
6819 },
6820 "fea.run_cht options require finite positive residual_warn_threshold",
6821 BTreeMap::from([(
6822 "residual_warn_threshold".to_string(),
6823 options.residual_warn_threshold.to_string(),
6824 )]),
6825 ));
6826 }
6827
6828 let thermo_options = resolve_thermo_coupling_options(
6829 model,
6830 model_thermo_coupling_options(model),
6831 ANALYSIS_RUN_CHT_OPERATION,
6832 ANALYSIS_RUN_CHT_OP_VERSION,
6833 &context,
6834 )?;
6835 let Some(thermo_options) = thermo_options else {
6836 return Err(operation_error(
6837 ANALYSIS_RUN_CHT_OPERATION,
6838 ANALYSIS_RUN_CHT_OP_VERSION,
6839 &context,
6840 OperationErrorSpec {
6841 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6842 error_type: OperationErrorType::Input,
6843 retryable: false,
6844 severity: OperationErrorSeverity::Error,
6845 },
6846 "fea.run_cht requires model.thermo_mechanical to be configured",
6847 BTreeMap::new(),
6848 ));
6849 };
6850 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, &thermo_options) {
6851 return Err(operation_error(
6852 ANALYSIS_RUN_CHT_OPERATION,
6853 ANALYSIS_RUN_CHT_OP_VERSION,
6854 &context,
6855 OperationErrorSpec {
6856 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6857 error_type: OperationErrorType::Input,
6858 retryable: false,
6859 severity: OperationErrorSeverity::Error,
6860 },
6861 detail,
6862 metadata,
6863 ));
6864 }
6865 if let Err((detail, metadata)) = validate_coupled_flow_interfaces(model, "CHT") {
6866 return Err(operation_error(
6867 ANALYSIS_RUN_CHT_OPERATION,
6868 ANALYSIS_RUN_CHT_OP_VERSION,
6869 &context,
6870 OperationErrorSpec {
6871 error_code: "RM.FEA.RUN_CHT.INVALID_INTERFACE_MAPPING",
6872 error_type: OperationErrorType::Validation,
6873 retryable: false,
6874 severity: OperationErrorSeverity::Error,
6875 },
6876 detail,
6877 metadata,
6878 ));
6879 }
6880 let applied_temperature_delta_k = thermo_options.applied_temperature_delta_k;
6881
6882 let prep_context = resolve_run_prep_context(
6883 model,
6884 options.prep_artifact_id.as_deref(),
6885 options.prep_context.clone(),
6886 ANALYSIS_RUN_CHT_OPERATION,
6887 ANALYSIS_RUN_CHT_OP_VERSION,
6888 &context,
6889 )?;
6890
6891 let solve_start = Instant::now();
6892 let thermal_run = run_thermal_with_options(
6893 model,
6894 backend,
6895 ThermalSolveOptions {
6896 step_count: options.step_count,
6897 time_step_s: options.time_step_s,
6898 residual_target: options.residual_warn_threshold,
6899 prep_context: to_fea_prep_context(
6900 prep_context.as_ref(),
6901 options.prep_calibration_profile,
6902 ),
6903 thermo_mechanical_context: to_fea_thermo_mechanical_context(Some(
6904 thermo_options.clone(),
6905 )),
6906 },
6907 )
6908 .map_err(|err| {
6909 map_fea_run_error(
6910 ANALYSIS_RUN_CHT_OPERATION,
6911 ANALYSIS_RUN_CHT_OP_VERSION,
6912 "RM.FEA.RUN_CHT.SOLVER_MODEL_INVALID",
6913 "RM.FEA.RUN_CHT.CANCELLED",
6914 model,
6915 &context,
6916 err,
6917 )
6918 })?;
6919
6920 let topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
6921 let node_count = topology.node_count;
6922 let field_step = match cfd_domain.solve_family {
6923 runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
6924 runmat_analysis_core::CfdSolveFamily::Transient => options.step_count.saturating_sub(1),
6925 };
6926 let (fluid_velocity, fluid_pressure) =
6927 recover_cfd_velocity_pressure(cfd_domain, &topology, field_step);
6928 let (cfd_residual_momentum, cfd_residual_continuity) = cfd_residual_norms(
6929 &fluid_velocity,
6930 &fluid_pressure,
6931 cfd_domain,
6932 &topology,
6933 options.step_count,
6934 );
6935 let max_cfd_momentum_residual = cfd_residual_momentum
6936 .iter()
6937 .copied()
6938 .fold(0.0_f64, f64::max);
6939 let max_cfd_continuity_residual = cfd_residual_continuity
6940 .iter()
6941 .copied()
6942 .fold(0.0_f64, f64::max);
6943 let (cht_fields, cht_interface_closure) = build_cht_run_fields(
6944 cfd_domain,
6945 &topology,
6946 &thermal_run,
6947 cht_interface_conductance_w_per_m2k(model),
6948 options.max_linear_iters,
6949 options.tolerance,
6950 );
6951 let mut run = thermal_run.run.clone();
6952 run.solver_method = "cht_conjugate_projection".to_string();
6953 run.preconditioner = "thermal_cfd_projection".to_string();
6954 run.fields.extend(cht_fields);
6955 let reynolds_number = cfd_reynolds_number(cfd_domain);
6956 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6957 code: "FEA_CFD_FLOW".to_string(),
6958 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6959 message: format!(
6960 "density={} viscosity={} inlet_velocity={} turbulence_intensity={} reynolds_number={} solve_family={} profile_point_count={} topology_basis={} control_volume_count={} control_volume_face_count={} control_volume_internal_face_count={} control_volume_boundary_face_count={} control_volume_connectivity_coverage_ratio={} domain_length_m={} hydraulic_diameter_m={}",
6961 cfd_domain.reference_density_kg_per_m3,
6962 cfd_domain.dynamic_viscosity_pa_s,
6963 cfd_domain.inlet_velocity_m_per_s,
6964 cfd_domain.turbulence_intensity,
6965 reynolds_number,
6966 match cfd_domain.solve_family {
6967 runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
6968 runmat_analysis_core::CfdSolveFamily::Transient => "transient",
6969 },
6970 cfd_domain.time_profile.len(),
6971 topology.basis.as_str(),
6972 topology.control_volume_count,
6973 topology.control_volume_face_count,
6974 topology.control_volume_internal_face_count,
6975 topology.control_volume_boundary_face_count,
6976 topology.control_volume_connectivity_coverage_ratio,
6977 topology.domain_length_m,
6978 topology.hydraulic_diameter_m,
6979 ),
6980 });
6981 let cfd_residual_severity = if max_cfd_momentum_residual <= options.residual_warn_threshold
6982 && max_cfd_continuity_residual <= options.residual_warn_threshold
6983 {
6984 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
6985 } else {
6986 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
6987 };
6988 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6989 code: "FEA_CFD_RESIDUAL".to_string(),
6990 severity: cfd_residual_severity,
6991 message: format!(
6992 "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
6993 max_cfd_momentum_residual,
6994 max_cfd_continuity_residual,
6995 options.residual_warn_threshold,
6996 node_count,
6997 options.step_count,
6998 ),
6999 });
7000 run.diagnostics.push(cfd_assembly_diagnostic(
7001 &topology,
7002 cfd_domain,
7003 options.time_step_s,
7004 pressure_drop_from_nodal_pressure(&fluid_pressure),
7005 max_cfd_continuity_residual,
7006 options.residual_warn_threshold,
7007 ));
7008 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7009 code: "FEA_CHT_COUPLING".to_string(),
7010 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7011 message: format!(
7012 "reference_temperature_k={} applied_temperature_delta_k={} step_count={} time_step_s={} authored_interface_count={}",
7013 thermal_run.reference_temperature_k,
7014 applied_temperature_delta_k,
7015 options.step_count,
7016 options.time_step_s,
7017 model.interfaces.len(),
7018 ),
7019 });
7020 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7021 code: "FEA_CHT_INTERFACE_CLOSURE".to_string(),
7022 severity: if cht_interface_closure.heat_flux_balance_ratio <= 1.0e-9
7023 && cht_interface_closure.max_energy_residual <= options.residual_warn_threshold
7024 && cht_interface_closure.max_temperature_jump_k <= 0.1
7025 && cht_interface_closure.max_thermal_transport_residual_ratio
7026 <= options.residual_warn_threshold
7027 && cht_interface_closure.max_flux_temperature_law_residual_ratio
7028 <= options.residual_warn_threshold
7029 && cht_interface_closure.max_heat_flux_realization_residual_ratio
7030 <= options.residual_warn_threshold
7031 && cht_interface_closure.max_coupled_interface_residual_ratio
7032 <= options.residual_warn_threshold
7033 && cht_interface_closure.interface_connectivity_coverage_ratio >= 1.0
7034 && cht_interface_closure.max_thermal_network_residual_ratio
7035 <= options.residual_warn_threshold
7036 {
7037 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
7038 } else {
7039 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
7040 },
7041 message: format!(
7042 "interface_face_count={} max_temperature_jump_k={} max_energy_residual={} heat_flux_balance_ratio={} mean_interface_heat_flux_w_per_m2={} thermal_transport_residual_ratio={} interface_temperature_continuity_ratio={} max_advection_temperature_shift_k={} interface_conductance_w_per_m2k={} flux_temperature_law_residual_ratio={} heat_flux_realization_residual_ratio={} coupled_interface_iteration_count={} coupled_interface_residual_ratio={} thermal_network_node_count={} thermal_network_edge_count={} interface_connectivity_coverage_ratio={} mesh_backed_interface_connectivity_ratio={} full_topology_edge_count={} full_topology_element_count={} thermal_network_residual_ratio={}",
7043 cht_interface_closure.interface_face_count,
7044 cht_interface_closure.max_temperature_jump_k,
7045 cht_interface_closure.max_energy_residual,
7046 cht_interface_closure.heat_flux_balance_ratio,
7047 cht_interface_closure.mean_interface_heat_flux_w_per_m2,
7048 cht_interface_closure.max_thermal_transport_residual_ratio,
7049 cht_interface_closure.interface_temperature_continuity_ratio,
7050 cht_interface_closure.max_advection_temperature_shift_k,
7051 cht_interface_closure.interface_conductance_w_per_m2k,
7052 cht_interface_closure.max_flux_temperature_law_residual_ratio,
7053 cht_interface_closure.max_heat_flux_realization_residual_ratio,
7054 cht_interface_closure.max_coupled_interface_iteration_count,
7055 cht_interface_closure.max_coupled_interface_residual_ratio,
7056 cht_interface_closure.thermal_network_node_count,
7057 cht_interface_closure.thermal_network_edge_count,
7058 cht_interface_closure.interface_connectivity_coverage_ratio,
7059 cht_interface_closure.mesh_backed_interface_connectivity_ratio,
7060 cht_interface_closure.full_topology_edge_count,
7061 cht_interface_closure.full_topology_element_count,
7062 cht_interface_closure.max_thermal_network_residual_ratio,
7063 ),
7064 });
7065 let cht_known_answer = cht_known_answer_metrics(cfd_domain, &cht_interface_closure);
7066 run.diagnostics.push(cht_known_answer_diagnostic(
7067 &cht_known_answer,
7068 options.residual_warn_threshold,
7069 ));
7070 let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
7071 run.diagnostics
7072 .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7073 code: "FEA_CHT_COST".to_string(),
7074 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7075 message: format!(
7076 "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
7077 solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
7078 ),
7079 });
7080
7081 let mut fallback_events = Vec::new();
7082 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
7083 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
7084 fallback_events.push(
7085 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
7086 );
7087 }
7088
7089 let max_cht_transport_residual = cht_interface_closure
7090 .max_thermal_transport_residual_ratio
7091 .max(
7092 cht_interface_closure
7093 .max_energy_residual
7094 .max(cht_interface_closure.heat_flux_balance_ratio),
7095 );
7096 let solver_convergence = if max_cfd_momentum_residual <= options.residual_warn_threshold
7097 && max_cfd_continuity_residual <= options.residual_warn_threshold
7098 && max_cht_transport_residual <= options.residual_warn_threshold
7099 {
7100 QualityGate::Pass
7101 } else {
7102 QualityGate::Warn
7103 };
7104 let result_quality = if run.fields_are_empty()
7105 || thermal_run.temperature_snapshots.is_empty()
7106 || thermal_run.time_points_s.is_empty()
7107 || thermal_run.residual_norms.iter().any(|r| !r.is_finite())
7108 || !max_cfd_momentum_residual.is_finite()
7109 || !max_cfd_continuity_residual.is_finite()
7110 || !max_cht_transport_residual.is_finite()
7111 {
7112 QualityGate::Fail
7113 } else if max_cfd_momentum_residual > options.residual_warn_threshold
7114 || max_cfd_continuity_residual > options.residual_warn_threshold
7115 || max_cht_transport_residual > options.residual_warn_threshold
7116 {
7117 QualityGate::Warn
7118 } else {
7119 QualityGate::Pass
7120 };
7121
7122 let mut quality_reasons = Vec::new();
7123 if solver_convergence == QualityGate::Warn {
7124 quality_reasons.push(QualityReason {
7125 code: QualityReasonCode::SolverNotConverged,
7126 detail: "cht solver convergence gate is warning".to_string(),
7127 });
7128 }
7129 if max_cfd_momentum_residual > options.residual_warn_threshold
7130 || max_cfd_continuity_residual > options.residual_warn_threshold
7131 {
7132 quality_reasons.push(QualityReason {
7133 code: QualityReasonCode::SolverNotConverged,
7134 detail: format!(
7135 "cht cfd residual exceeds threshold {}",
7136 options.residual_warn_threshold,
7137 ),
7138 });
7139 }
7140 if max_cht_transport_residual > options.residual_warn_threshold {
7141 quality_reasons.push(QualityReason {
7142 code: QualityReasonCode::SolverNotConverged,
7143 detail: format!(
7144 "cht interface transport residual exceeds threshold {}",
7145 options.residual_warn_threshold
7146 ),
7147 });
7148 }
7149 if fallback_events
7150 .iter()
7151 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
7152 {
7153 quality_reasons.push(QualityReason {
7154 code: QualityReasonCode::SolverBackendFallback,
7155 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
7156 });
7157 }
7158 if fallback_events.iter().any(|event| {
7159 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
7160 }) {
7161 quality_reasons.push(QualityReason {
7162 code: QualityReasonCode::FieldPromotionFallback,
7163 detail: "field promotion fell back to host-backed values".to_string(),
7164 });
7165 }
7166
7167 let publishable = match options.quality_policy {
7168 QualityPolicy::Strict => {
7169 solver_convergence == QualityGate::Pass
7170 && result_quality == QualityGate::Pass
7171 && quality_reasons.is_empty()
7172 }
7173 QualityPolicy::Balanced => {
7174 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
7175 }
7176 QualityPolicy::Exploratory => {
7177 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
7178 }
7179 };
7180 let run_status = if publishable {
7181 RunStatus::Publishable
7182 } else if result_quality == QualityGate::Fail {
7183 RunStatus::Rejected
7184 } else {
7185 RunStatus::Degraded
7186 };
7187 let solver_backend = run.solver_backend.clone();
7188 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
7189 let solver_host_sync_count = run.solver_host_sync_count;
7190 let solver_method = run.solver_method.clone();
7191 let selected_preconditioner = run.preconditioner.clone();
7192
7193 let result = AnalysisRunResult {
7194 run_id: storage::next_run_id(),
7195 run,
7196 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
7197 modal_results: None,
7198 thermal_results: Some(ThermalResultsData {
7199 thermal_payload_version: "thermal_results/v1".to_string(),
7200 time_points_s: thermal_run.time_points_s,
7201 temperature_snapshots: thermal_run.temperature_snapshots,
7202 temperature_gradient_snapshots: thermal_run.temperature_gradient_snapshots,
7203 heat_flux_snapshots: thermal_run.heat_flux_snapshots,
7204 heat_source_snapshots: thermal_run.heat_source_snapshots,
7205 boundary_heat_flux_snapshots: thermal_run.boundary_heat_flux_snapshots,
7206 residual_norms: thermal_run.residual_norms,
7207 reference_temperature_k: thermal_run.reference_temperature_k,
7208 }),
7209 transient_results: None,
7210 nonlinear_results: None,
7211 electromagnetic_results: None,
7212 model_validity: QualityGate::Pass,
7213 solver_convergence,
7214 result_quality,
7215 run_status,
7216 publishable,
7217 quality_reasons,
7218 provenance: RunProvenance {
7219 backend,
7220 solver_backend,
7221 solver_device_apply_k_ratio,
7222 solver_host_sync_count,
7223 precision_mode: contracts::format_precision_mode(options.precision_mode),
7224 deterministic_mode: options.deterministic_mode,
7225 solver_method,
7226 preconditioner: selected_preconditioner,
7227 quality_policy: contracts::format_quality_policy(options.quality_policy),
7228 fallback_events,
7229 },
7230 };
7231
7232 persist_fea_run_result_with_progress(
7233 ANALYSIS_RUN_CHT_OPERATION,
7234 ANALYSIS_RUN_CHT_OP_VERSION,
7235 "RM.FEA.RUN_CHT.ARTIFACT_STORE_FAILED",
7236 &context,
7237 &result,
7238 )?;
7239
7240 Ok(OperationEnvelope::new(
7241 ANALYSIS_RUN_CHT_OPERATION,
7242 ANALYSIS_RUN_CHT_OP_VERSION,
7243 &context,
7244 result,
7245 ))
7246}
7247
7248pub fn analysis_run_fsi_op(
7249 model: &AnalysisModel,
7250 backend: ComputeBackend,
7251 context: OperationContext,
7252) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7253 analysis_run_fsi_with_options_op(model, backend, AnalysisFsiRunOptions::default(), context)
7254}
7255
7256pub fn analysis_run_fsi_with_options_op(
7257 model: &AnalysisModel,
7258 backend: ComputeBackend,
7259 options: AnalysisFsiRunOptions,
7260 context: OperationContext,
7261) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7262 let _solver_context = install_fea_solver_context();
7263 let has_cfd_step = model
7264 .steps
7265 .iter()
7266 .any(|step| step.kind == AnalysisStepKind::Cfd);
7267 if !has_cfd_step {
7268 return Err(operation_error(
7269 ANALYSIS_RUN_FSI_OPERATION,
7270 ANALYSIS_RUN_FSI_OP_VERSION,
7271 &context,
7272 OperationErrorSpec {
7273 error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7274 error_type: OperationErrorType::Validation,
7275 retryable: false,
7276 severity: OperationErrorSeverity::Error,
7277 },
7278 "FEA model must include at least one cfd step for fea.run_fsi",
7279 BTreeMap::from([
7280 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7281 ("geometry_id".to_string(), model.geometry_id.clone()),
7282 ]),
7283 ));
7284 }
7285 let has_transient_step = model
7286 .steps
7287 .iter()
7288 .any(|step| step.kind == AnalysisStepKind::Transient);
7289 if !has_transient_step {
7290 return Err(operation_error(
7291 ANALYSIS_RUN_FSI_OPERATION,
7292 ANALYSIS_RUN_FSI_OP_VERSION,
7293 &context,
7294 OperationErrorSpec {
7295 error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7296 error_type: OperationErrorType::Validation,
7297 retryable: false,
7298 severity: OperationErrorSeverity::Error,
7299 },
7300 "FEA model must include at least one transient step for fea.run_fsi",
7301 BTreeMap::from([
7302 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7303 ("geometry_id".to_string(), model.geometry_id.clone()),
7304 ]),
7305 ));
7306 }
7307 let Some(cfd_domain) = model.cfd.as_ref() else {
7308 return Err(operation_error(
7309 ANALYSIS_RUN_FSI_OPERATION,
7310 ANALYSIS_RUN_FSI_OP_VERSION,
7311 &context,
7312 OperationErrorSpec {
7313 error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7314 error_type: OperationErrorType::Validation,
7315 retryable: false,
7316 severity: OperationErrorSeverity::Error,
7317 },
7318 "fea.run_fsi requires model.cfd to be configured",
7319 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
7320 ));
7321 };
7322 if !cfd_domain.enabled {
7323 return Err(operation_error(
7324 ANALYSIS_RUN_FSI_OPERATION,
7325 ANALYSIS_RUN_FSI_OP_VERSION,
7326 &context,
7327 OperationErrorSpec {
7328 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7329 error_type: OperationErrorType::Input,
7330 retryable: false,
7331 severity: OperationErrorSeverity::Error,
7332 },
7333 "fea.run_fsi requires cfd domain enabled=true",
7334 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
7335 ));
7336 }
7337 if !cfd_domain.reference_density_kg_per_m3.is_finite()
7338 || cfd_domain.reference_density_kg_per_m3 <= 0.0
7339 {
7340 return Err(operation_error(
7341 ANALYSIS_RUN_FSI_OPERATION,
7342 ANALYSIS_RUN_FSI_OP_VERSION,
7343 &context,
7344 OperationErrorSpec {
7345 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7346 error_type: OperationErrorType::Input,
7347 retryable: false,
7348 severity: OperationErrorSeverity::Error,
7349 },
7350 "fea.run_fsi requires finite positive reference_density_kg_per_m3",
7351 BTreeMap::from([(
7352 "reference_density_kg_per_m3".to_string(),
7353 cfd_domain.reference_density_kg_per_m3.to_string(),
7354 )]),
7355 ));
7356 }
7357 if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
7358 return Err(operation_error(
7359 ANALYSIS_RUN_FSI_OPERATION,
7360 ANALYSIS_RUN_FSI_OP_VERSION,
7361 &context,
7362 OperationErrorSpec {
7363 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7364 error_type: OperationErrorType::Input,
7365 retryable: false,
7366 severity: OperationErrorSeverity::Error,
7367 },
7368 "fea.run_fsi requires finite positive dynamic_viscosity_pa_s",
7369 BTreeMap::from([(
7370 "dynamic_viscosity_pa_s".to_string(),
7371 cfd_domain.dynamic_viscosity_pa_s.to_string(),
7372 )]),
7373 ));
7374 }
7375 if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
7376 return Err(operation_error(
7377 ANALYSIS_RUN_FSI_OPERATION,
7378 ANALYSIS_RUN_FSI_OP_VERSION,
7379 &context,
7380 OperationErrorSpec {
7381 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7382 error_type: OperationErrorType::Input,
7383 retryable: false,
7384 severity: OperationErrorSeverity::Error,
7385 },
7386 "fea.run_fsi requires finite non-negative inlet_velocity_m_per_s",
7387 BTreeMap::from([(
7388 "inlet_velocity_m_per_s".to_string(),
7389 cfd_domain.inlet_velocity_m_per_s.to_string(),
7390 )]),
7391 ));
7392 }
7393 if !cfd_domain.turbulence_intensity.is_finite()
7394 || cfd_domain.turbulence_intensity < 0.0
7395 || cfd_domain.turbulence_intensity > 1.0
7396 {
7397 return Err(operation_error(
7398 ANALYSIS_RUN_FSI_OPERATION,
7399 ANALYSIS_RUN_FSI_OP_VERSION,
7400 &context,
7401 OperationErrorSpec {
7402 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7403 error_type: OperationErrorType::Input,
7404 retryable: false,
7405 severity: OperationErrorSeverity::Error,
7406 },
7407 "fea.run_fsi requires turbulence_intensity in [0, 1]",
7408 BTreeMap::from([(
7409 "turbulence_intensity".to_string(),
7410 cfd_domain.turbulence_intensity.to_string(),
7411 )]),
7412 ));
7413 }
7414 reject_moment_loads_for_run_family(
7415 model,
7416 ANALYSIS_RUN_FSI_OPERATION,
7417 ANALYSIS_RUN_FSI_OP_VERSION,
7418 "RM.FEA.RUN_FSI.INVALID_LOAD",
7419 "FSI",
7420 &context,
7421 )?;
7422 if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
7423 return Err(operation_error(
7424 ANALYSIS_RUN_FSI_OPERATION,
7425 ANALYSIS_RUN_FSI_OP_VERSION,
7426 &context,
7427 OperationErrorSpec {
7428 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7429 error_type: OperationErrorType::Input,
7430 retryable: false,
7431 severity: OperationErrorSeverity::Error,
7432 },
7433 "fea.run_fsi options require finite positive time_step_s",
7434 BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
7435 ));
7436 }
7437 if options.step_count == 0 || options.max_linear_iters == 0 {
7438 return Err(operation_error(
7439 ANALYSIS_RUN_FSI_OPERATION,
7440 ANALYSIS_RUN_FSI_OP_VERSION,
7441 &context,
7442 OperationErrorSpec {
7443 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7444 error_type: OperationErrorType::Input,
7445 retryable: false,
7446 severity: OperationErrorSeverity::Error,
7447 },
7448 "fea.run_fsi options require step_count/max_linear_iters greater than zero",
7449 BTreeMap::new(),
7450 ));
7451 }
7452 if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
7453 return Err(operation_error(
7454 ANALYSIS_RUN_FSI_OPERATION,
7455 ANALYSIS_RUN_FSI_OP_VERSION,
7456 &context,
7457 OperationErrorSpec {
7458 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7459 error_type: OperationErrorType::Input,
7460 retryable: false,
7461 severity: OperationErrorSeverity::Error,
7462 },
7463 "fea.run_fsi options require finite positive tolerance",
7464 BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
7465 ));
7466 }
7467 if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
7468 return Err(operation_error(
7469 ANALYSIS_RUN_FSI_OPERATION,
7470 ANALYSIS_RUN_FSI_OP_VERSION,
7471 &context,
7472 OperationErrorSpec {
7473 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7474 error_type: OperationErrorType::Input,
7475 retryable: false,
7476 severity: OperationErrorSeverity::Error,
7477 },
7478 "fea.run_fsi options require finite positive residual_warn_threshold",
7479 BTreeMap::from([(
7480 "residual_warn_threshold".to_string(),
7481 options.residual_warn_threshold.to_string(),
7482 )]),
7483 ));
7484 }
7485 if let Err((detail, metadata)) = validate_coupled_flow_interfaces(model, "FSI") {
7486 return Err(operation_error(
7487 ANALYSIS_RUN_FSI_OPERATION,
7488 ANALYSIS_RUN_FSI_OP_VERSION,
7489 &context,
7490 OperationErrorSpec {
7491 error_code: "RM.FEA.RUN_FSI.INVALID_INTERFACE_MAPPING",
7492 error_type: OperationErrorType::Validation,
7493 retryable: false,
7494 severity: OperationErrorSeverity::Error,
7495 },
7496 detail,
7497 metadata,
7498 ));
7499 }
7500
7501 let prep_context = resolve_run_prep_context(
7502 model,
7503 options.prep_artifact_id.as_deref(),
7504 options.prep_context.clone(),
7505 ANALYSIS_RUN_FSI_OPERATION,
7506 ANALYSIS_RUN_FSI_OP_VERSION,
7507 &context,
7508 )?;
7509
7510 let solve_start = Instant::now();
7511 let topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
7512 let node_count = topology.node_count;
7513 let field_step = match cfd_domain.solve_family {
7514 runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
7515 runmat_analysis_core::CfdSolveFamily::Transient => options.step_count.saturating_sub(1),
7516 };
7517 let (fluid_velocity, fluid_pressure) =
7518 recover_cfd_velocity_pressure(cfd_domain, &topology, field_step);
7519 let (cfd_residual_momentum, cfd_residual_continuity) = cfd_residual_norms(
7520 &fluid_velocity,
7521 &fluid_pressure,
7522 cfd_domain,
7523 &topology,
7524 options.step_count,
7525 );
7526 let max_cfd_momentum_residual = cfd_residual_momentum
7527 .iter()
7528 .copied()
7529 .fold(0.0_f64, f64::max);
7530 let max_cfd_continuity_residual = cfd_residual_continuity
7531 .iter()
7532 .copied()
7533 .fold(0.0_f64, f64::max);
7534 let structural_compliance_per_pa = fsi_structural_compliance_per_pa(model);
7535 let (fsi_fields, fsi_interface_closure) = build_fsi_run_fields(
7536 cfd_domain,
7537 &topology,
7538 options.step_count,
7539 structural_compliance_per_pa,
7540 options.max_linear_iters,
7541 options.tolerance,
7542 &cfd_residual_momentum,
7543 &cfd_residual_continuity,
7544 );
7545 let max_interface_residual = fsi_interface_closure.max_interface_residual;
7546 let mut run = FeaRunResult {
7547 backend,
7548 solver_backend: "cpu_reference".to_string(),
7549 solver_device_apply_k_ratio: 0.0,
7550 solver_method: "fsi_partitioned_projection".to_string(),
7551 preconditioner: "interface_relaxation".to_string(),
7552 solver_host_sync_count: 0,
7553 diagnostics: Vec::new(),
7554 fields: fsi_fields,
7555 };
7556 let reynolds_number = cfd_reynolds_number(cfd_domain);
7557 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7558 code: "FEA_CFD_FLOW".to_string(),
7559 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7560 message: format!(
7561 "density={} viscosity={} inlet_velocity={} turbulence_intensity={} reynolds_number={} solve_family={} profile_point_count={} topology_basis={} control_volume_count={} control_volume_face_count={} control_volume_internal_face_count={} control_volume_boundary_face_count={} control_volume_connectivity_coverage_ratio={} domain_length_m={} hydraulic_diameter_m={}",
7562 cfd_domain.reference_density_kg_per_m3,
7563 cfd_domain.dynamic_viscosity_pa_s,
7564 cfd_domain.inlet_velocity_m_per_s,
7565 cfd_domain.turbulence_intensity,
7566 reynolds_number,
7567 match cfd_domain.solve_family {
7568 runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
7569 runmat_analysis_core::CfdSolveFamily::Transient => "transient",
7570 },
7571 cfd_domain.time_profile.len(),
7572 topology.basis.as_str(),
7573 topology.control_volume_count,
7574 topology.control_volume_face_count,
7575 topology.control_volume_internal_face_count,
7576 topology.control_volume_boundary_face_count,
7577 topology.control_volume_connectivity_coverage_ratio,
7578 topology.domain_length_m,
7579 topology.hydraulic_diameter_m,
7580 ),
7581 });
7582 let residual_severity = if max_interface_residual <= options.residual_warn_threshold {
7583 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
7584 } else {
7585 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
7586 };
7587 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7588 code: "FEA_CFD_RESIDUAL".to_string(),
7589 severity: residual_severity,
7590 message: format!(
7591 "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
7592 max_cfd_momentum_residual,
7593 max_cfd_continuity_residual,
7594 options.residual_warn_threshold,
7595 node_count,
7596 options.step_count,
7597 ),
7598 });
7599 run.diagnostics.push(cfd_assembly_diagnostic(
7600 &topology,
7601 cfd_domain,
7602 options.time_step_s,
7603 pressure_drop_from_nodal_pressure(&fluid_pressure),
7604 max_cfd_continuity_residual,
7605 options.residual_warn_threshold,
7606 ));
7607 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7608 code: "FEA_FSI_INTERFACE_RESIDUAL".to_string(),
7609 severity: residual_severity,
7610 message: format!(
7611 "max_interface_residual={} structural_compliance_per_pa={} residual_warn_threshold={} interface_node_count={} interface_face_count={}",
7612 max_interface_residual,
7613 structural_compliance_per_pa,
7614 options.residual_warn_threshold,
7615 fsi_interface_closure.interface_node_count,
7616 fsi_interface_closure.interface_face_count,
7617 ),
7618 });
7619 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7620 code: "FEA_FSI_INTERFACE_CLOSURE".to_string(),
7621 severity: if fsi_interface_closure.force_balance_ratio <= 1.0e-9
7622 && fsi_interface_closure.max_displacement_transfer_residual_m <= 1.0e-12
7623 && fsi_interface_closure.max_pressure_feedback_residual_ratio <= options.tolerance
7624 && fsi_interface_closure.max_two_way_interface_residual_ratio <= options.tolerance
7625 && fsi_interface_closure.max_structural_traction_update_residual_ratio
7626 <= options.tolerance
7627 && fsi_interface_closure.max_pressure_displacement_law_residual_ratio
7628 <= options.tolerance
7629 && fsi_interface_closure.max_structural_solve_residual_ratio <= options.tolerance
7630 && fsi_interface_closure.max_interface_work_energy_residual_ratio <= options.tolerance
7631 && fsi_interface_closure.structural_coupling_edge_count > 0
7632 && fsi_interface_closure.interface_connectivity_coverage_ratio >= 1.0
7633 && fsi_interface_closure.max_interface_residual <= options.residual_warn_threshold
7634 {
7635 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
7636 } else {
7637 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
7638 },
7639 message: format!(
7640 "interface_node_count={} interface_face_count={} max_interface_residual={} force_balance_ratio={} max_displacement_transfer_residual_m={} max_interface_displacement_m={} mean_interface_pressure_pa={} max_traction_magnitude_pa={} max_coupling_iteration_count={} pressure_feedback_residual_ratio={} two_way_interface_residual_ratio={} structural_traction_update_residual_ratio={} pressure_displacement_law_residual_ratio={} structural_solve_residual_ratio={} interface_work_j_per_m2={} structural_strain_energy_j_per_m2={} interface_work_energy_residual_ratio={} structural_coupling_edge_count={} interface_connectivity_coverage_ratio={} mesh_backed_interface_connectivity_ratio={} full_topology_edge_count={} full_topology_element_count={} interface_stiffness_pa_per_m={}",
7641 fsi_interface_closure.interface_node_count,
7642 fsi_interface_closure.interface_face_count,
7643 fsi_interface_closure.max_interface_residual,
7644 fsi_interface_closure.force_balance_ratio,
7645 fsi_interface_closure.max_displacement_transfer_residual_m,
7646 fsi_interface_closure.max_interface_displacement_m,
7647 fsi_interface_closure.mean_interface_pressure_pa,
7648 fsi_interface_closure.max_traction_magnitude_pa,
7649 fsi_interface_closure.max_coupling_iteration_count,
7650 fsi_interface_closure.max_pressure_feedback_residual_ratio,
7651 fsi_interface_closure.max_two_way_interface_residual_ratio,
7652 fsi_interface_closure.max_structural_traction_update_residual_ratio,
7653 fsi_interface_closure.max_pressure_displacement_law_residual_ratio,
7654 fsi_interface_closure.max_structural_solve_residual_ratio,
7655 fsi_interface_closure.max_interface_work_j_per_m2,
7656 fsi_interface_closure.max_structural_strain_energy_j_per_m2,
7657 fsi_interface_closure.max_interface_work_energy_residual_ratio,
7658 fsi_interface_closure.structural_coupling_edge_count,
7659 fsi_interface_closure.interface_connectivity_coverage_ratio,
7660 fsi_interface_closure.mesh_backed_interface_connectivity_ratio,
7661 fsi_interface_closure.full_topology_edge_count,
7662 fsi_interface_closure.full_topology_element_count,
7663 fsi_interface_closure.interface_stiffness_pa_per_m,
7664 ),
7665 });
7666 let fsi_known_answer = fsi_known_answer_metrics(&fsi_interface_closure);
7667 run.diagnostics.push(fsi_known_answer_diagnostic(
7668 &fsi_known_answer,
7669 options.tolerance,
7670 ));
7671 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7672 code: "FEA_FSI_COUPLING".to_string(),
7673 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7674 message: format!(
7675 "step_count={} time_step_s={} structural_step_count={} cfd_profile_point_count={} authored_interface_count={} interface_node_count={} interface_face_count={} max_linear_iters={} tolerance={}",
7676 options.step_count,
7677 options.time_step_s,
7678 model
7679 .steps
7680 .iter()
7681 .filter(|step| step.kind == AnalysisStepKind::Transient)
7682 .count(),
7683 cfd_domain.time_profile.len(),
7684 model.interfaces.len(),
7685 fsi_interface_closure.interface_node_count,
7686 fsi_interface_closure.interface_face_count,
7687 options.max_linear_iters,
7688 options.tolerance,
7689 ),
7690 });
7691 let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
7692 run.diagnostics
7693 .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7694 code: "FEA_FSI_COST".to_string(),
7695 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7696 message: format!(
7697 "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
7698 solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
7699 ),
7700 });
7701
7702 let mut fallback_events = Vec::new();
7703 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
7704 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
7705 fallback_events.push(
7706 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
7707 );
7708 }
7709
7710 let solver_convergence = if max_interface_residual <= options.residual_warn_threshold {
7711 QualityGate::Pass
7712 } else {
7713 QualityGate::Warn
7714 };
7715 let result_quality = if run.fields_are_empty()
7716 || !max_cfd_momentum_residual.is_finite()
7717 || !max_cfd_continuity_residual.is_finite()
7718 || !max_interface_residual.is_finite()
7719 {
7720 QualityGate::Fail
7721 } else if max_interface_residual > options.residual_warn_threshold {
7722 QualityGate::Warn
7723 } else {
7724 QualityGate::Pass
7725 };
7726
7727 let mut quality_reasons = Vec::new();
7728 if solver_convergence == QualityGate::Warn {
7729 quality_reasons.push(QualityReason {
7730 code: QualityReasonCode::SolverNotConverged,
7731 detail: "fsi solver convergence gate is warning".to_string(),
7732 });
7733 }
7734 if max_interface_residual > options.residual_warn_threshold {
7735 quality_reasons.push(QualityReason {
7736 code: QualityReasonCode::SolverNotConverged,
7737 detail: format!(
7738 "fsi interface residual exceeds threshold {}",
7739 options.residual_warn_threshold
7740 ),
7741 });
7742 }
7743 if fallback_events
7744 .iter()
7745 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
7746 {
7747 quality_reasons.push(QualityReason {
7748 code: QualityReasonCode::SolverBackendFallback,
7749 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
7750 });
7751 }
7752 if fallback_events.iter().any(|event| {
7753 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
7754 }) {
7755 quality_reasons.push(QualityReason {
7756 code: QualityReasonCode::FieldPromotionFallback,
7757 detail: "field promotion fell back to host-backed values".to_string(),
7758 });
7759 }
7760
7761 let publishable = match options.quality_policy {
7762 QualityPolicy::Strict => {
7763 solver_convergence == QualityGate::Pass
7764 && result_quality == QualityGate::Pass
7765 && quality_reasons.is_empty()
7766 }
7767 QualityPolicy::Balanced => {
7768 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
7769 }
7770 QualityPolicy::Exploratory => {
7771 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
7772 }
7773 };
7774 let run_status = if publishable {
7775 RunStatus::Publishable
7776 } else if result_quality == QualityGate::Fail {
7777 RunStatus::Rejected
7778 } else {
7779 RunStatus::Degraded
7780 };
7781 let solver_backend = run.solver_backend.clone();
7782 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
7783 let solver_host_sync_count = run.solver_host_sync_count;
7784 let solver_method = run.solver_method.clone();
7785 let selected_preconditioner = run.preconditioner.clone();
7786
7787 let result = AnalysisRunResult {
7788 run_id: storage::next_run_id(),
7789 run,
7790 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
7791 modal_results: None,
7792 thermal_results: None,
7793 transient_results: None,
7794 nonlinear_results: None,
7795 electromagnetic_results: None,
7796 model_validity: QualityGate::Pass,
7797 solver_convergence,
7798 result_quality,
7799 run_status,
7800 publishable,
7801 quality_reasons,
7802 provenance: RunProvenance {
7803 backend,
7804 solver_backend,
7805 solver_device_apply_k_ratio,
7806 solver_host_sync_count,
7807 precision_mode: contracts::format_precision_mode(options.precision_mode),
7808 deterministic_mode: options.deterministic_mode,
7809 solver_method,
7810 preconditioner: selected_preconditioner,
7811 quality_policy: contracts::format_quality_policy(options.quality_policy),
7812 fallback_events,
7813 },
7814 };
7815
7816 persist_fea_run_result_with_progress(
7817 ANALYSIS_RUN_FSI_OPERATION,
7818 ANALYSIS_RUN_FSI_OP_VERSION,
7819 "RM.FEA.RUN_FSI.ARTIFACT_STORE_FAILED",
7820 &context,
7821 &result,
7822 )?;
7823
7824 Ok(OperationEnvelope::new(
7825 ANALYSIS_RUN_FSI_OPERATION,
7826 ANALYSIS_RUN_FSI_OP_VERSION,
7827 &context,
7828 result,
7829 ))
7830}
7831
7832pub fn analysis_run_thermal_with_options_op(
7833 model: &AnalysisModel,
7834 backend: ComputeBackend,
7835 options: AnalysisThermalRunOptions,
7836 context: OperationContext,
7837) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7838 let _solver_context = install_fea_solver_context();
7839 let has_thermal_step = model
7840 .steps
7841 .iter()
7842 .any(|step| step.kind == AnalysisStepKind::Thermal);
7843 if !has_thermal_step {
7844 return Err(operation_error(
7845 ANALYSIS_RUN_THERMAL_OPERATION,
7846 ANALYSIS_RUN_THERMAL_OP_VERSION,
7847 &context,
7848 OperationErrorSpec {
7849 error_code: "RM.FEA.RUN_THERMAL.INVALID_MODEL",
7850 error_type: OperationErrorType::Validation,
7851 retryable: false,
7852 severity: OperationErrorSeverity::Error,
7853 },
7854 "FEA model must include at least one thermal step for fea.run_thermal",
7855 BTreeMap::from([
7856 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7857 ("geometry_id".to_string(), model.geometry_id.clone()),
7858 ]),
7859 ));
7860 }
7861 validate_thermal_run_model(model, &context)?;
7862
7863 let thermo_options = resolve_thermo_coupling_options(
7864 model,
7865 model_thermo_coupling_options(model),
7866 ANALYSIS_RUN_THERMAL_OPERATION,
7867 ANALYSIS_RUN_THERMAL_OP_VERSION,
7868 &context,
7869 )?;
7870 let Some(thermo_options) = thermo_options else {
7871 return Err(operation_error(
7872 ANALYSIS_RUN_THERMAL_OPERATION,
7873 ANALYSIS_RUN_THERMAL_OP_VERSION,
7874 &context,
7875 OperationErrorSpec {
7876 error_code: "RM.FEA.RUN_THERMAL.INVALID_OPTIONS",
7877 error_type: OperationErrorType::Input,
7878 retryable: false,
7879 severity: OperationErrorSeverity::Error,
7880 },
7881 "fea.run_thermal requires model.thermo_mechanical to be configured",
7882 BTreeMap::new(),
7883 ));
7884 };
7885 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, &thermo_options) {
7886 return Err(operation_error(
7887 ANALYSIS_RUN_THERMAL_OPERATION,
7888 ANALYSIS_RUN_THERMAL_OP_VERSION,
7889 &context,
7890 OperationErrorSpec {
7891 error_code: "RM.FEA.RUN_THERMAL.INVALID_OPTIONS",
7892 error_type: OperationErrorType::Input,
7893 retryable: false,
7894 severity: OperationErrorSeverity::Error,
7895 },
7896 detail,
7897 metadata,
7898 ));
7899 }
7900
7901 let prep_context = resolve_run_prep_context(
7902 model,
7903 options.prep_artifact_id.as_deref(),
7904 options.prep_context.clone(),
7905 ANALYSIS_RUN_THERMAL_OPERATION,
7906 ANALYSIS_RUN_THERMAL_OP_VERSION,
7907 &context,
7908 )?;
7909
7910 let thermal_run = run_thermal_with_options(
7911 model,
7912 backend,
7913 ThermalSolveOptions {
7914 step_count: options.step_count,
7915 time_step_s: options.time_step_s,
7916 residual_target: options.residual_warn_threshold,
7917 prep_context: to_fea_prep_context(
7918 prep_context.as_ref(),
7919 options.prep_calibration_profile,
7920 ),
7921 thermo_mechanical_context: to_fea_thermo_mechanical_context(Some(thermo_options)),
7922 },
7923 )
7924 .map_err(|err| {
7925 map_fea_run_error(
7926 ANALYSIS_RUN_THERMAL_OPERATION,
7927 ANALYSIS_RUN_THERMAL_OP_VERSION,
7928 "RM.FEA.RUN_THERMAL.SOLVER_MODEL_INVALID",
7929 "RM.FEA.RUN_THERMAL.CANCELLED",
7930 model,
7931 &context,
7932 err,
7933 )
7934 })?;
7935
7936 let mut run = thermal_run.run;
7937 let mut fallback_events = Vec::new();
7938 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
7939 let solver_convergence = if diagnostic_metric(
7940 &run.diagnostics,
7941 "FEA_THERMAL_STABILITY",
7942 "max_residual_norm",
7943 )
7944 .unwrap_or(0.0)
7945 <= options.residual_warn_threshold
7946 {
7947 QualityGate::Pass
7948 } else {
7949 QualityGate::Warn
7950 };
7951 let result_quality = if thermal_run.temperature_snapshots.is_empty() {
7952 QualityGate::Fail
7953 } else {
7954 solver_convergence
7955 };
7956 let mut quality_reasons = Vec::new();
7957 if result_quality == QualityGate::Warn {
7958 quality_reasons.push(QualityReason {
7959 code: QualityReasonCode::ThermalResidualExceeded,
7960 detail: format!(
7961 "thermal residual exceeds threshold {}",
7962 options.residual_warn_threshold
7963 ),
7964 });
7965 }
7966 let thermal_conductivity_spread_ratio = diagnostic_metric(
7967 &run.diagnostics,
7968 "FEA_THERMAL_CONSTITUTIVE",
7969 "conductivity_spread_ratio",
7970 );
7971 let thermal_heat_capacity_spread_ratio = diagnostic_metric(
7972 &run.diagnostics,
7973 "FEA_THERMAL_CONSTITUTIVE",
7974 "heat_capacity_spread_ratio",
7975 );
7976 if thermal_conductivity_spread_ratio.unwrap_or(1.0) > 2.5
7977 || thermal_heat_capacity_spread_ratio.unwrap_or(1.0) > 2.5
7978 {
7979 quality_reasons.push(QualityReason {
7980 code: QualityReasonCode::ThermalConstitutiveSpreadHigh,
7981 detail: format!(
7982 "thermal constitutive spread exceeds limit: conductivity_spread_ratio={} heat_capacity_spread_ratio={}",
7983 thermal_conductivity_spread_ratio.unwrap_or(1.0),
7984 thermal_heat_capacity_spread_ratio.unwrap_or(1.0)
7985 ),
7986 });
7987 }
7988
7989 let publishable = match options.quality_policy {
7990 QualityPolicy::Strict => {
7991 solver_convergence == QualityGate::Pass
7992 && result_quality == QualityGate::Pass
7993 && quality_reasons.is_empty()
7994 }
7995 QualityPolicy::Balanced => {
7996 result_quality != QualityGate::Fail
7997 && !quality_reasons
7998 .iter()
7999 .any(|reason| reason.code == QualityReasonCode::ThermalConstitutiveSpreadHigh)
8000 }
8001 QualityPolicy::Exploratory => true,
8002 };
8003 let run_status = if publishable {
8004 RunStatus::Publishable
8005 } else if result_quality == QualityGate::Fail {
8006 RunStatus::Rejected
8007 } else {
8008 RunStatus::Degraded
8009 };
8010
8011 let solver_backend = run.solver_backend.clone();
8012 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
8013 let solver_host_sync_count = run.solver_host_sync_count;
8014 let solver_method = run.solver_method.clone();
8015 let selected_preconditioner = run.preconditioner.clone();
8016
8017 let result = AnalysisRunResult {
8018 run_id: storage::next_run_id(),
8019 run,
8020 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
8021 modal_results: None,
8022 thermal_results: Some(ThermalResultsData {
8023 thermal_payload_version: "thermal_results/v1".to_string(),
8024 time_points_s: thermal_run.time_points_s,
8025 temperature_snapshots: thermal_run.temperature_snapshots,
8026 temperature_gradient_snapshots: thermal_run.temperature_gradient_snapshots,
8027 heat_flux_snapshots: thermal_run.heat_flux_snapshots,
8028 heat_source_snapshots: thermal_run.heat_source_snapshots,
8029 boundary_heat_flux_snapshots: thermal_run.boundary_heat_flux_snapshots,
8030 residual_norms: thermal_run.residual_norms,
8031 reference_temperature_k: thermal_run.reference_temperature_k,
8032 }),
8033 transient_results: None,
8034 nonlinear_results: None,
8035 electromagnetic_results: None,
8036 model_validity: QualityGate::Pass,
8037 solver_convergence,
8038 result_quality,
8039 run_status,
8040 publishable,
8041 quality_reasons,
8042 provenance: RunProvenance {
8043 backend,
8044 solver_backend,
8045 solver_device_apply_k_ratio,
8046 solver_host_sync_count,
8047 precision_mode: contracts::format_precision_mode(options.precision_mode),
8048 deterministic_mode: options.deterministic_mode,
8049 solver_method,
8050 preconditioner: selected_preconditioner,
8051 quality_policy: contracts::format_quality_policy(options.quality_policy),
8052 fallback_events,
8053 },
8054 };
8055
8056 persist_fea_run_result_with_progress(
8057 ANALYSIS_RUN_THERMAL_OPERATION,
8058 ANALYSIS_RUN_THERMAL_OP_VERSION,
8059 "RM.FEA.RUN_THERMAL.ARTIFACT_STORE_FAILED",
8060 &context,
8061 &result,
8062 )?;
8063
8064 Ok(OperationEnvelope::new(
8065 ANALYSIS_RUN_THERMAL_OPERATION,
8066 ANALYSIS_RUN_THERMAL_OP_VERSION,
8067 &context,
8068 result,
8069 ))
8070}
8071
8072fn validate_thermal_run_model(
8073 model: &AnalysisModel,
8074 context: &OperationContext,
8075) -> Result<(), OperationErrorEnvelope> {
8076 if let Some(material) = model.materials.iter().find(|material| {
8077 !material.thermal.conductivity_w_per_mk.is_finite()
8078 || material.thermal.conductivity_w_per_mk <= 0.0
8079 || !material.thermal.specific_heat_j_per_kgk.is_finite()
8080 || material.thermal.specific_heat_j_per_kgk <= 0.0
8081 }) {
8082 return Err(operation_error(
8083 ANALYSIS_RUN_THERMAL_OPERATION,
8084 ANALYSIS_RUN_THERMAL_OP_VERSION,
8085 context,
8086 OperationErrorSpec {
8087 error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_MATERIAL",
8088 error_type: OperationErrorType::Validation,
8089 retryable: false,
8090 severity: OperationErrorSeverity::Error,
8091 },
8092 "fea.run_thermal requires finite positive thermal conductivity and specific heat",
8093 BTreeMap::from([
8094 ("analysis_model_id".to_string(), model.model_id.0.clone()),
8095 ("material_id".to_string(), material.material_id.clone()),
8096 (
8097 "conductivity_w_per_mk".to_string(),
8098 material.thermal.conductivity_w_per_mk.to_string(),
8099 ),
8100 (
8101 "specific_heat_j_per_kgk".to_string(),
8102 material.thermal.specific_heat_j_per_kgk.to_string(),
8103 ),
8104 ]),
8105 ));
8106 }
8107
8108 reject_moment_loads_for_run_family(
8109 model,
8110 ANALYSIS_RUN_THERMAL_OPERATION,
8111 ANALYSIS_RUN_THERMAL_OP_VERSION,
8112 "RM.FEA.RUN_THERMAL.INVALID_THERMAL_SOURCE",
8113 "thermal",
8114 context,
8115 )?;
8116
8117 if let Some(load) = model.loads.iter().find(|load| {
8118 matches!(
8119 &load.kind,
8120 LoadKind::HeatSource {
8121 volumetric_w_per_m3
8122 } if !volumetric_w_per_m3.is_finite()
8123 )
8124 }) {
8125 return Err(operation_error(
8126 ANALYSIS_RUN_THERMAL_OPERATION,
8127 ANALYSIS_RUN_THERMAL_OP_VERSION,
8128 context,
8129 OperationErrorSpec {
8130 error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_SOURCE",
8131 error_type: OperationErrorType::Validation,
8132 retryable: false,
8133 severity: OperationErrorSeverity::Error,
8134 },
8135 "fea.run_thermal requires finite thermal heat-source values",
8136 BTreeMap::from([
8137 ("analysis_model_id".to_string(), model.model_id.0.clone()),
8138 ("load_id".to_string(), load.load_id.clone()),
8139 ]),
8140 ));
8141 }
8142
8143 if let Some(boundary) = model.boundary_conditions.iter().find(|bc| match &bc.kind {
8144 BoundaryConditionKind::ThermalPrescribedTemperature { temperature_k } => {
8145 !temperature_k.is_finite() || *temperature_k <= 0.0
8146 }
8147 BoundaryConditionKind::ThermalHeatFlux { heat_flux_w_per_m2 } => {
8148 !heat_flux_w_per_m2.is_finite()
8149 }
8150 BoundaryConditionKind::ThermalConvection {
8151 ambient_temperature_k,
8152 coefficient_w_per_m2k,
8153 } => {
8154 !ambient_temperature_k.is_finite()
8155 || *ambient_temperature_k <= 0.0
8156 || !coefficient_w_per_m2k.is_finite()
8157 || *coefficient_w_per_m2k < 0.0
8158 }
8159 _ => false,
8160 }) {
8161 return Err(operation_error(
8162 ANALYSIS_RUN_THERMAL_OPERATION,
8163 ANALYSIS_RUN_THERMAL_OP_VERSION,
8164 context,
8165 OperationErrorSpec {
8166 error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_BOUNDARY",
8167 error_type: OperationErrorType::Validation,
8168 retryable: false,
8169 severity: OperationErrorSeverity::Error,
8170 },
8171 "fea.run_thermal requires finite physically valid thermal boundary condition values",
8172 BTreeMap::from([
8173 ("analysis_model_id".to_string(), model.model_id.0.clone()),
8174 ("boundary_condition_id".to_string(), boundary.bc_id.clone()),
8175 ]),
8176 ));
8177 }
8178
8179 Ok(())
8180}
8181
8182pub fn analysis_run_transient_with_options_op(
8183 model: &AnalysisModel,
8184 backend: ComputeBackend,
8185 options: AnalysisTransientRunOptions,
8186 context: OperationContext,
8187) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
8188 let _solver_context = install_fea_solver_context();
8189 let has_transient_step = model
8190 .steps
8191 .iter()
8192 .any(|step| step.kind == AnalysisStepKind::Transient);
8193 if !has_transient_step {
8194 return Err(operation_error(
8195 ANALYSIS_RUN_TRANSIENT_OPERATION,
8196 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8197 &context,
8198 OperationErrorSpec {
8199 error_code: "RM.FEA.RUN_TRANSIENT.INVALID_MODEL",
8200 error_type: OperationErrorType::Validation,
8201 retryable: false,
8202 severity: OperationErrorSeverity::Error,
8203 },
8204 "FEA model must include at least one transient step for fea.run_transient",
8205 BTreeMap::from([
8206 ("analysis_model_id".to_string(), model.model_id.0.clone()),
8207 ("geometry_id".to_string(), model.geometry_id.clone()),
8208 ]),
8209 ));
8210 }
8211
8212 let thermo_options = resolve_thermo_coupling_options(
8213 model,
8214 model_thermo_coupling_options(model),
8215 ANALYSIS_RUN_TRANSIENT_OPERATION,
8216 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8217 &context,
8218 )?;
8219 if let Some(thermo_options) = thermo_options.as_ref() {
8220 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
8221 return Err(operation_error(
8222 ANALYSIS_RUN_TRANSIENT_OPERATION,
8223 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8224 &context,
8225 OperationErrorSpec {
8226 error_code: "RM.FEA.RUN_TRANSIENT.INVALID_OPTIONS",
8227 error_type: OperationErrorType::Input,
8228 retryable: false,
8229 severity: OperationErrorSeverity::Error,
8230 },
8231 detail,
8232 metadata,
8233 ));
8234 }
8235 }
8236 let electro_options = model_electro_coupling_options(model);
8237 if let Some(electro_options) = electro_options.as_ref() {
8238 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
8239 return Err(operation_error(
8240 ANALYSIS_RUN_TRANSIENT_OPERATION,
8241 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8242 &context,
8243 OperationErrorSpec {
8244 error_code: electro_thermal_invalid_options_error_code(
8245 ANALYSIS_RUN_TRANSIENT_OPERATION,
8246 ),
8247 error_type: OperationErrorType::Input,
8248 retryable: false,
8249 severity: OperationErrorSeverity::Error,
8250 },
8251 detail,
8252 metadata,
8253 ));
8254 }
8255 }
8256
8257 let prep_context = resolve_run_prep_context(
8258 model,
8259 options.prep_artifact_id.as_deref(),
8260 options.prep_context.clone(),
8261 ANALYSIS_RUN_TRANSIENT_OPERATION,
8262 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8263 &context,
8264 )?;
8265 let transient_run = run_transient_with_options(
8266 model,
8267 backend,
8268 runmat_analysis_fea::solve::transient::TransientSolveOptions {
8269 time_step_s: options.time_step_s,
8270 min_time_step_s: options.min_time_step_s,
8271 max_time_step_s: options.max_time_step_s,
8272 step_count: options.step_count,
8273 max_linear_iters: options.max_linear_iters,
8274 tolerance: options.tolerance,
8275 residual_target: options.residual_target,
8276 adaptive_time_step: options.adaptive_time_step,
8277 max_step_retries: options.max_step_retries,
8278 adapt_min_scale: options.adapt_min_scale,
8279 adapt_max_scale: options.adapt_max_scale,
8280 adapt_growth_exponent: options.adapt_growth_exponent,
8281 adapt_retry_growth_cap: options.adapt_retry_growth_cap,
8282 adapt_nonconverged_shrink: options.adapt_nonconverged_shrink,
8283 dt_bucket_rel_tolerance: options.dt_bucket_rel_tolerance,
8284 progress_operation: ANALYSIS_RUN_TRANSIENT_OPERATION.to_string(),
8285 prep_context: to_fea_prep_context(
8286 prep_context.as_ref(),
8287 options.prep_calibration_profile,
8288 ),
8289 thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
8290 electro_thermal_context: to_fea_electro_thermal_context(electro_options),
8291 },
8292 )
8293 .map_err(|err| {
8294 map_fea_run_error(
8295 ANALYSIS_RUN_TRANSIENT_OPERATION,
8296 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8297 "RM.FEA.RUN_TRANSIENT.SOLVER_MODEL_INVALID",
8298 "RM.FEA.RUN_TRANSIENT.CANCELLED",
8299 model,
8300 &context,
8301 err,
8302 )
8303 })?;
8304
8305 let mut run = transient_run.run;
8306 let mut fallback_events = Vec::new();
8307 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
8308 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
8309 fallback_events.push(
8310 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
8311 );
8312 }
8313 let solver_convergence = if run.diagnostics.iter().any(|item| {
8314 item.code == "FEA_TRANSIENT_CONVERGENCE"
8315 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
8316 }) {
8317 QualityGate::Pass
8318 } else {
8319 QualityGate::Warn
8320 };
8321 let result_quality = if transient_run.displacement_snapshots.is_empty()
8322 || transient_run.time_points_s.is_empty()
8323 || transient_run
8324 .residual_norms
8325 .iter()
8326 .any(|residual| !residual.is_finite())
8327 {
8328 QualityGate::Fail
8329 } else if transient_run
8330 .residual_norms
8331 .iter()
8332 .copied()
8333 .fold(0.0_f64, f64::max)
8334 > TRANSIENT_RESIDUAL_WARN_THRESHOLD
8335 {
8336 QualityGate::Warn
8337 } else {
8338 QualityGate::Pass
8339 };
8340 let transient_stability_warn = run.diagnostics.iter().any(|item| {
8341 item.code == "FEA_TRANSIENT_STABILITY"
8342 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8343 }) || run.diagnostics.iter().any(|item| {
8344 item.code == "FEA_TRANSIENT_ENERGY"
8345 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8346 });
8347 let transient_step_failure_warn = run.diagnostics.iter().any(|item| {
8348 item.code == "FEA_TRANSIENT_STEP_FAILURE"
8349 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8350 });
8351 let thermo_transient_warn = run.diagnostics.iter().any(|item| {
8352 item.code == "FEA_TM_TRANSIENT"
8353 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8354 });
8355 let electro_transient_warn = run.diagnostics.iter().any(|item| {
8356 item.code == "FEA_ET_TRANSIENT"
8357 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8358 });
8359 let thermo_spatial_gradient_index = diagnostic_metric(
8360 &run.diagnostics,
8361 "FEA_TM_COUPLING",
8362 "spatial_gradient_index",
8363 );
8364 let thermo_spatial_coverage_ratio = diagnostic_metric(
8365 &run.diagnostics,
8366 "FEA_TM_COUPLING",
8367 "spatial_coverage_ratio",
8368 );
8369 let thermo_temporal_variation =
8370 diagnostic_metric(&run.diagnostics, "FEA_TM_TRANSIENT", "temporal_variation");
8371 let thermo_field_extrapolation_ratio = diagnostic_metric(
8372 &run.diagnostics,
8373 "FEA_TM_TRANSIENT",
8374 "field_extrapolation_ratio",
8375 );
8376 let (thermo_gradient_spatial_threshold, thermo_gradient_temporal_threshold) =
8377 thermo_gradient_thresholds_for_policy(options.quality_policy);
8378 let thermo_gradient_instability = thermo_spatial_gradient_index
8379 .map(|value| value > thermo_gradient_spatial_threshold)
8380 .unwrap_or(false)
8381 || thermo_temporal_variation
8382 .map(|value| value > thermo_gradient_temporal_threshold)
8383 .unwrap_or(false);
8384 let thermo_spread_ratio = diagnostic_metric(
8385 &run.diagnostics,
8386 "FEA_TM_COUPLING",
8387 "constitutive_material_spread_ratio",
8388 );
8389 let thermo_heterogeneity_index = diagnostic_metric(
8390 &run.diagnostics,
8391 "FEA_TM_COUPLING",
8392 "assignment_heterogeneity_index",
8393 );
8394 let (thermo_spread_threshold, thermo_heterogeneity_threshold) =
8395 thermo_thresholds_for_policy(options.quality_policy);
8396 let (thermo_field_coverage_min, thermo_field_extrapolation_max) =
8397 thermo_field_quality_thresholds_for_policy(options.quality_policy);
8398 let thermo_spread_breach = thermo_spread_ratio
8399 .map(|value| value > thermo_spread_threshold)
8400 .unwrap_or(false);
8401 let thermo_heterogeneity_breach = thermo_heterogeneity_index
8402 .map(|value| value > thermo_heterogeneity_threshold)
8403 .unwrap_or(false);
8404 let thermo_field_coverage_breach = thermo_spatial_coverage_ratio
8405 .map(|value| value < thermo_field_coverage_min)
8406 .unwrap_or(false);
8407 let thermo_field_extrapolation_breach = thermo_field_extrapolation_ratio
8408 .map(|value| value > thermo_field_extrapolation_max)
8409 .unwrap_or(false);
8410
8411 let mut quality_reasons = Vec::new();
8412 if solver_convergence == QualityGate::Warn {
8413 quality_reasons.push(QualityReason {
8414 code: QualityReasonCode::SolverNotConverged,
8415 detail: "transient solver convergence gate is warning".to_string(),
8416 });
8417 }
8418 if result_quality == QualityGate::Warn {
8419 quality_reasons.push(QualityReason {
8420 code: QualityReasonCode::TransientResidualExceeded,
8421 detail: format!(
8422 "transient residual exceeds threshold {}",
8423 TRANSIENT_RESIDUAL_WARN_THRESHOLD
8424 ),
8425 });
8426 }
8427 if transient_stability_warn {
8428 quality_reasons.push(QualityReason {
8429 code: QualityReasonCode::TransientStabilityExceeded,
8430 detail: "transient stability diagnostic exceeded threshold".to_string(),
8431 });
8432 }
8433 if transient_step_failure_warn {
8434 quality_reasons.push(QualityReason {
8435 code: QualityReasonCode::TransientStepFailure,
8436 detail: "transient step retry budget was exhausted".to_string(),
8437 });
8438 }
8439 if thermo_transient_warn {
8440 quality_reasons.push(QualityReason {
8441 code: QualityReasonCode::ThermoMechanicalTransientStress,
8442 detail: "thermo-mechanical transient coupling severity exceeded balanced threshold"
8443 .to_string(),
8444 });
8445 }
8446 if electro_transient_warn {
8447 quality_reasons.push(QualityReason {
8448 code: QualityReasonCode::ElectroThermalTransientStress,
8449 detail: "electro-thermal transient coupling severity exceeded balanced threshold"
8450 .to_string(),
8451 });
8452 }
8453 if thermo_spread_breach {
8454 quality_reasons.push(QualityReason {
8455 code: QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh,
8456 detail: format!(
8457 "thermo constitutive material spread ratio {} exceeds threshold {}",
8458 thermo_spread_ratio.unwrap_or(0.0),
8459 thermo_spread_threshold
8460 ),
8461 });
8462 }
8463 if thermo_heterogeneity_breach {
8464 quality_reasons.push(QualityReason {
8465 code: QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh,
8466 detail: format!(
8467 "thermo assignment heterogeneity index {} exceeds threshold {}",
8468 thermo_heterogeneity_index.unwrap_or(0.0),
8469 thermo_heterogeneity_threshold
8470 ),
8471 });
8472 }
8473 if thermo_gradient_instability {
8474 quality_reasons.push(QualityReason {
8475 code: QualityReasonCode::ThermoMechanicalGradientInstability,
8476 detail: format!(
8477 "thermo gradient instability spatial_gradient_index={} temporal_variation={} thresholds=({}, {})",
8478 thermo_spatial_gradient_index.unwrap_or(0.0),
8479 thermo_temporal_variation.unwrap_or(0.0),
8480 thermo_gradient_spatial_threshold,
8481 thermo_gradient_temporal_threshold,
8482 ),
8483 });
8484 }
8485 if thermo_field_coverage_breach {
8486 quality_reasons.push(QualityReason {
8487 code: QualityReasonCode::ThermoMechanicalFieldCoverageLow,
8488 detail: format!(
8489 "thermo field spatial coverage ratio {} is below minimum {}",
8490 thermo_spatial_coverage_ratio.unwrap_or(0.0),
8491 thermo_field_coverage_min
8492 ),
8493 });
8494 }
8495 if thermo_field_extrapolation_breach {
8496 quality_reasons.push(QualityReason {
8497 code: QualityReasonCode::ThermoMechanicalFieldExtrapolationHigh,
8498 detail: format!(
8499 "thermo field extrapolation ratio {} exceeds maximum {}",
8500 thermo_field_extrapolation_ratio.unwrap_or(0.0),
8501 thermo_field_extrapolation_max
8502 ),
8503 });
8504 }
8505 if fallback_events
8506 .iter()
8507 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
8508 {
8509 quality_reasons.push(QualityReason {
8510 code: QualityReasonCode::SolverBackendFallback,
8511 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
8512 });
8513 }
8514 if fallback_events.iter().any(|event| {
8515 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
8516 }) {
8517 quality_reasons.push(QualityReason {
8518 code: QualityReasonCode::FieldPromotionFallback,
8519 detail: "field promotion fell back to host-backed values".to_string(),
8520 });
8521 }
8522 let publishable = match options.quality_policy {
8523 QualityPolicy::Strict => {
8524 solver_convergence == QualityGate::Pass
8525 && result_quality == QualityGate::Pass
8526 && quality_reasons.is_empty()
8527 }
8528 QualityPolicy::Balanced => {
8529 solver_convergence == QualityGate::Pass
8530 && result_quality == QualityGate::Pass
8531 && !quality_reasons.iter().any(|r| {
8532 matches!(
8533 r.code,
8534 QualityReasonCode::TransientStabilityExceeded
8535 | QualityReasonCode::TransientStepFailure
8536 | QualityReasonCode::ThermoMechanicalTransientStress
8537 | QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh
8538 | QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh
8539 | QualityReasonCode::ThermoMechanicalGradientInstability
8540 )
8541 })
8542 }
8543 QualityPolicy::Exploratory => {
8544 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
8545 }
8546 };
8547 let run_status = if publishable {
8548 RunStatus::Publishable
8549 } else if result_quality == QualityGate::Fail {
8550 RunStatus::Rejected
8551 } else {
8552 RunStatus::Degraded
8553 };
8554
8555 let solver_backend = run.solver_backend.clone();
8556 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
8557 let solver_host_sync_count = run.solver_host_sync_count;
8558 let solver_method = run.solver_method.clone();
8559 let selected_preconditioner = run.preconditioner.clone();
8560
8561 let result = AnalysisRunResult {
8562 run_id: storage::next_run_id(),
8563 run,
8564 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
8565 modal_results: None,
8566 thermal_results: None,
8567 transient_results: Some(TransientResultsData {
8568 transient_payload_version: "transient_results/v1".to_string(),
8569 time_points_s: transient_run.time_points_s,
8570 displacement_snapshots: transient_run.displacement_snapshots,
8571 rotation_snapshots: transient_run.rotation_snapshots,
8572 velocity_snapshots: transient_run.velocity_snapshots,
8573 angular_velocity_snapshots: transient_run.angular_velocity_snapshots,
8574 acceleration_snapshots: transient_run.acceleration_snapshots,
8575 angular_acceleration_snapshots: transient_run.angular_acceleration_snapshots,
8576 von_mises_snapshots: transient_run.von_mises_snapshots,
8577 kinetic_energy_snapshots: transient_run.kinetic_energy_snapshots,
8578 strain_energy_snapshots: transient_run.strain_energy_snapshots,
8579 residual_norm_snapshots: transient_run.residual_norm_snapshots,
8580 thermo_mechanical_temperature_snapshots: transient_run
8581 .thermo_mechanical_temperature_snapshots,
8582 thermo_mechanical_thermal_strain_snapshots: transient_run
8583 .thermo_mechanical_thermal_strain_snapshots,
8584 thermo_mechanical_thermal_stress_snapshots: transient_run
8585 .thermo_mechanical_thermal_stress_snapshots,
8586 thermo_mechanical_displacement_snapshots: transient_run
8587 .thermo_mechanical_displacement_snapshots,
8588 thermo_mechanical_von_mises_snapshots: transient_run
8589 .thermo_mechanical_von_mises_snapshots,
8590 thermo_mechanical_coupling_residual_snapshots: transient_run
8591 .thermo_mechanical_coupling_residual_snapshots,
8592 electro_thermal_temperature_snapshots: transient_run
8593 .electro_thermal_temperature_snapshots,
8594 electro_thermal_thermal_residual_snapshots: transient_run
8595 .electro_thermal_thermal_residual_snapshots,
8596 residual_norms: transient_run.residual_norms,
8597 integration_method: TransientIntegrationMethod::ImplicitEuler,
8598 }),
8599 nonlinear_results: None,
8600 electromagnetic_results: None,
8601 model_validity: QualityGate::Pass,
8602 solver_convergence,
8603 result_quality,
8604 run_status,
8605 publishable,
8606 quality_reasons,
8607 provenance: RunProvenance {
8608 backend,
8609 solver_backend,
8610 solver_device_apply_k_ratio,
8611 solver_host_sync_count,
8612 precision_mode: contracts::format_precision_mode(options.precision_mode),
8613 deterministic_mode: options.deterministic_mode,
8614 solver_method,
8615 preconditioner: selected_preconditioner,
8616 quality_policy: contracts::format_quality_policy(options.quality_policy),
8617 fallback_events,
8618 },
8619 };
8620
8621 persist_fea_run_result_with_progress(
8622 ANALYSIS_RUN_TRANSIENT_OPERATION,
8623 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8624 "RM.FEA.RUN_TRANSIENT.ARTIFACT_STORE_FAILED",
8625 &context,
8626 &result,
8627 )?;
8628
8629 Ok(OperationEnvelope::new(
8630 ANALYSIS_RUN_TRANSIENT_OPERATION,
8631 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8632 &context,
8633 result,
8634 ))
8635}
8636
8637pub fn analysis_run_nonlinear_op(
8638 model: &AnalysisModel,
8639 backend: ComputeBackend,
8640 context: OperationContext,
8641) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
8642 analysis_run_nonlinear_with_options_op(
8643 model,
8644 backend,
8645 AnalysisNonlinearRunOptions::default(),
8646 context,
8647 )
8648}
8649
8650pub fn analysis_run_nonlinear_with_options_op(
8651 model: &AnalysisModel,
8652 backend: ComputeBackend,
8653 options: AnalysisNonlinearRunOptions,
8654 context: OperationContext,
8655) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
8656 let _solver_context = install_fea_solver_context();
8657 let has_nonlinear_step = model
8658 .steps
8659 .iter()
8660 .any(|step| step.kind == AnalysisStepKind::Nonlinear);
8661 if !has_nonlinear_step {
8662 return Err(operation_error(
8663 ANALYSIS_RUN_NONLINEAR_OPERATION,
8664 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8665 &context,
8666 OperationErrorSpec {
8667 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_MODEL",
8668 error_type: OperationErrorType::Validation,
8669 retryable: false,
8670 severity: OperationErrorSeverity::Error,
8671 },
8672 "FEA model must include at least one nonlinear step for fea.run_nonlinear",
8673 BTreeMap::from([
8674 ("analysis_model_id".to_string(), model.model_id.0.clone()),
8675 ("geometry_id".to_string(), model.geometry_id.clone()),
8676 ]),
8677 ));
8678 }
8679
8680 if options.increment_count == 0 {
8681 return Err(operation_error(
8682 ANALYSIS_RUN_NONLINEAR_OPERATION,
8683 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8684 &context,
8685 OperationErrorSpec {
8686 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8687 error_type: OperationErrorType::Input,
8688 retryable: false,
8689 severity: OperationErrorSeverity::Error,
8690 },
8691 "fea.run_nonlinear options require increment_count greater than zero",
8692 BTreeMap::from([(
8693 "increment_count".to_string(),
8694 options.increment_count.to_string(),
8695 )]),
8696 ));
8697 }
8698 if options.max_newton_iters == 0 {
8699 return Err(operation_error(
8700 ANALYSIS_RUN_NONLINEAR_OPERATION,
8701 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8702 &context,
8703 OperationErrorSpec {
8704 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8705 error_type: OperationErrorType::Input,
8706 retryable: false,
8707 severity: OperationErrorSeverity::Error,
8708 },
8709 "fea.run_nonlinear options require max_newton_iters greater than zero",
8710 BTreeMap::from([(
8711 "max_newton_iters".to_string(),
8712 options.max_newton_iters.to_string(),
8713 )]),
8714 ));
8715 }
8716 if options.tolerance <= 0.0 || !options.tolerance.is_finite() {
8717 return Err(operation_error(
8718 ANALYSIS_RUN_NONLINEAR_OPERATION,
8719 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8720 &context,
8721 OperationErrorSpec {
8722 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8723 error_type: OperationErrorType::Input,
8724 retryable: false,
8725 severity: OperationErrorSeverity::Error,
8726 },
8727 "fea.run_nonlinear options require finite positive tolerance",
8728 BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
8729 ));
8730 }
8731 if options.increment_norm_tolerance <= 0.0 || !options.increment_norm_tolerance.is_finite() {
8732 return Err(operation_error(
8733 ANALYSIS_RUN_NONLINEAR_OPERATION,
8734 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8735 &context,
8736 OperationErrorSpec {
8737 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8738 error_type: OperationErrorType::Input,
8739 retryable: false,
8740 severity: OperationErrorSeverity::Error,
8741 },
8742 "fea.run_nonlinear options require finite positive increment_norm_tolerance",
8743 BTreeMap::from([(
8744 "increment_norm_tolerance".to_string(),
8745 options.increment_norm_tolerance.to_string(),
8746 )]),
8747 ));
8748 }
8749 if options.residual_convergence_factor < 1.0 || !options.residual_convergence_factor.is_finite()
8750 {
8751 return Err(operation_error(
8752 ANALYSIS_RUN_NONLINEAR_OPERATION,
8753 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8754 &context,
8755 OperationErrorSpec {
8756 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8757 error_type: OperationErrorType::Input,
8758 retryable: false,
8759 severity: OperationErrorSeverity::Error,
8760 },
8761 "fea.run_nonlinear options require residual_convergence_factor >= 1.0",
8762 BTreeMap::from([(
8763 "residual_convergence_factor".to_string(),
8764 options.residual_convergence_factor.to_string(),
8765 )]),
8766 ));
8767 }
8768 if options.line_search_reduction <= 0.0
8769 || options.line_search_reduction >= 1.0
8770 || !options.line_search_reduction.is_finite()
8771 {
8772 return Err(operation_error(
8773 ANALYSIS_RUN_NONLINEAR_OPERATION,
8774 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8775 &context,
8776 OperationErrorSpec {
8777 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8778 error_type: OperationErrorType::Input,
8779 retryable: false,
8780 severity: OperationErrorSeverity::Error,
8781 },
8782 "fea.run_nonlinear options require line_search_reduction in (0, 1)",
8783 BTreeMap::from([(
8784 "line_search_reduction".to_string(),
8785 options.line_search_reduction.to_string(),
8786 )]),
8787 ));
8788 }
8789 if options.tangent_refresh_interval == 0 {
8790 return Err(operation_error(
8791 ANALYSIS_RUN_NONLINEAR_OPERATION,
8792 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8793 &context,
8794 OperationErrorSpec {
8795 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8796 error_type: OperationErrorType::Input,
8797 retryable: false,
8798 severity: OperationErrorSeverity::Error,
8799 },
8800 "fea.run_nonlinear options require tangent_refresh_interval greater than zero",
8801 BTreeMap::from([(
8802 "tangent_refresh_interval".to_string(),
8803 options.tangent_refresh_interval.to_string(),
8804 )]),
8805 ));
8806 }
8807
8808 let thermo_options = resolve_thermo_coupling_options(
8809 model,
8810 model_thermo_coupling_options(model),
8811 ANALYSIS_RUN_NONLINEAR_OPERATION,
8812 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8813 &context,
8814 )?;
8815 if let Some(thermo_options) = thermo_options.as_ref() {
8816 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
8817 return Err(operation_error(
8818 ANALYSIS_RUN_NONLINEAR_OPERATION,
8819 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8820 &context,
8821 OperationErrorSpec {
8822 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8823 error_type: OperationErrorType::Input,
8824 retryable: false,
8825 severity: OperationErrorSeverity::Error,
8826 },
8827 detail,
8828 metadata,
8829 ));
8830 }
8831 }
8832 let electro_options = model_electro_coupling_options(model);
8833 if let Some(electro_options) = electro_options.as_ref() {
8834 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
8835 return Err(operation_error(
8836 ANALYSIS_RUN_NONLINEAR_OPERATION,
8837 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8838 &context,
8839 OperationErrorSpec {
8840 error_code: electro_thermal_invalid_options_error_code(
8841 ANALYSIS_RUN_NONLINEAR_OPERATION,
8842 ),
8843 error_type: OperationErrorType::Input,
8844 retryable: false,
8845 severity: OperationErrorSeverity::Error,
8846 },
8847 detail,
8848 metadata,
8849 ));
8850 }
8851 }
8852 let plasticity_options = model_plasticity_constitutive_options(model);
8853 if let Some(plasticity_options) = plasticity_options.as_ref() {
8854 if let Err((detail, metadata)) =
8855 validate_plasticity_constitutive_options(plasticity_options)
8856 {
8857 return Err(operation_error(
8858 ANALYSIS_RUN_NONLINEAR_OPERATION,
8859 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8860 &context,
8861 OperationErrorSpec {
8862 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8863 error_type: OperationErrorType::Input,
8864 retryable: false,
8865 severity: OperationErrorSeverity::Error,
8866 },
8867 detail,
8868 metadata,
8869 ));
8870 }
8871 }
8872 let contact_options = model_contact_interface_options(model);
8873 if let Some(contact_options) = contact_options.as_ref() {
8874 if let Err((detail, metadata)) = validate_contact_interface_options(contact_options) {
8875 return Err(operation_error(
8876 ANALYSIS_RUN_NONLINEAR_OPERATION,
8877 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8878 &context,
8879 OperationErrorSpec {
8880 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8881 error_type: OperationErrorType::Input,
8882 retryable: false,
8883 severity: OperationErrorSeverity::Error,
8884 },
8885 detail,
8886 metadata,
8887 ));
8888 }
8889 }
8890
8891 let prep_context = resolve_run_prep_context(
8892 model,
8893 options.prep_artifact_id.as_deref(),
8894 options.prep_context.clone(),
8895 ANALYSIS_RUN_NONLINEAR_OPERATION,
8896 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8897 &context,
8898 )?;
8899 let nonlinear_run = run_nonlinear_with_options(
8900 model,
8901 backend,
8902 runmat_analysis_fea::solve::nonlinear::NonlinearSolveOptions {
8903 increment_count: options.increment_count,
8904 max_newton_iters: options.max_newton_iters,
8905 tolerance: options.tolerance,
8906 residual_convergence_factor: options.residual_convergence_factor,
8907 increment_norm_tolerance: options.increment_norm_tolerance,
8908 line_search: options.line_search,
8909 max_line_search_backtracks: options.max_line_search_backtracks,
8910 line_search_reduction: options.line_search_reduction,
8911 tangent_refresh_interval: options.tangent_refresh_interval,
8912 prep_context: to_fea_prep_context(
8913 prep_context.as_ref(),
8914 options.prep_calibration_profile,
8915 ),
8916 thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
8917 electro_thermal_context: to_fea_electro_thermal_context(electro_options),
8918 plasticity_context: to_fea_plasticity_constitutive_context(plasticity_options),
8919 contact_context: to_fea_contact_interface_context(contact_options),
8920 },
8921 )
8922 .map_err(|err| {
8923 map_fea_run_error(
8924 ANALYSIS_RUN_NONLINEAR_OPERATION,
8925 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8926 "RM.FEA.RUN_NONLINEAR.SOLVER_MODEL_INVALID",
8927 "RM.FEA.RUN_NONLINEAR.CANCELLED",
8928 model,
8929 &context,
8930 err,
8931 )
8932 })?;
8933
8934 let mut run = nonlinear_run.run;
8935 let mut fallback_events = Vec::new();
8936 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
8937 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
8938 fallback_events.push(
8939 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
8940 );
8941 }
8942
8943 let solver_convergence = if run.diagnostics.iter().any(|item| {
8944 item.code == "FEA_NONLINEAR_CONVERGENCE"
8945 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
8946 }) {
8947 QualityGate::Pass
8948 } else {
8949 QualityGate::Warn
8950 };
8951 let max_nonlinear_residual = nonlinear_run
8952 .residual_norms
8953 .iter()
8954 .copied()
8955 .reduce(f64::max)
8956 .unwrap_or(0.0);
8957 let max_nonlinear_increment_norm = nonlinear_run
8958 .increment_norms
8959 .iter()
8960 .copied()
8961 .reduce(f64::max)
8962 .unwrap_or(0.0);
8963 let result_quality = if nonlinear_run.load_factors.is_empty()
8964 || nonlinear_run.displacement_snapshots.is_empty()
8965 || nonlinear_run.residual_norms.iter().any(|r| !r.is_finite())
8966 || nonlinear_run.increment_norms.iter().any(|v| !v.is_finite())
8967 {
8968 QualityGate::Fail
8969 } else if max_nonlinear_residual > options.tolerance * options.residual_convergence_factor * 2.0
8970 || max_nonlinear_increment_norm > options.increment_norm_tolerance * 4.0
8971 {
8972 QualityGate::Warn
8973 } else {
8974 QualityGate::Pass
8975 };
8976 let nonlinear_increment_warn = run.diagnostics.iter().any(|item| {
8977 item.code == "FEA_NONLINEAR_CONVERGENCE"
8978 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8979 });
8980 let max_nonlinear_iteration_count = nonlinear_run
8981 .iteration_counts
8982 .iter()
8983 .copied()
8984 .max()
8985 .unwrap_or(0);
8986 let iteration_cap_hits = nonlinear_run
8987 .iteration_counts
8988 .iter()
8989 .filter(|&&count| count >= options.max_newton_iters.max(1))
8990 .count();
8991 let strict_increment_failure = nonlinear_run.failed_increments > 0;
8992 let strict_iteration_cap_exhausted = iteration_cap_hits > 0;
8993 let thermo_nonlinear_warn = run.diagnostics.iter().any(|item| {
8994 item.code == "FEA_TM_NONLINEAR"
8995 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8996 });
8997 let electro_nonlinear_warn = run.diagnostics.iter().any(|item| {
8998 item.code == "FEA_ET_NONLINEAR"
8999 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
9000 });
9001 let plastic_nonlinear_warn = run.diagnostics.iter().any(|item| {
9002 item.code == "FEA_PLASTIC_NONLINEAR"
9003 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
9004 });
9005 let contact_nonlinear_warn = run.diagnostics.iter().any(|item| {
9006 item.code == "FEA_CONTACT_NONLINEAR"
9007 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
9008 });
9009 let thermo_spatial_gradient_index = diagnostic_metric(
9010 &run.diagnostics,
9011 "FEA_TM_COUPLING",
9012 "spatial_gradient_index",
9013 );
9014 let thermo_spatial_coverage_ratio = diagnostic_metric(
9015 &run.diagnostics,
9016 "FEA_TM_COUPLING",
9017 "spatial_coverage_ratio",
9018 );
9019 let thermo_temporal_variation =
9020 diagnostic_metric(&run.diagnostics, "FEA_TM_NONLINEAR", "temporal_variation");
9021 let thermo_field_extrapolation_ratio = diagnostic_metric(
9022 &run.diagnostics,
9023 "FEA_TM_NONLINEAR",
9024 "field_extrapolation_ratio",
9025 );
9026 let (thermo_gradient_spatial_threshold, thermo_gradient_temporal_threshold) =
9027 thermo_gradient_thresholds_for_policy(options.quality_policy);
9028 let thermo_gradient_instability = thermo_spatial_gradient_index
9029 .map(|value| value > thermo_gradient_spatial_threshold)
9030 .unwrap_or(false)
9031 || thermo_temporal_variation
9032 .map(|value| value > thermo_gradient_temporal_threshold)
9033 .unwrap_or(false);
9034 let thermo_spread_ratio = diagnostic_metric(
9035 &run.diagnostics,
9036 "FEA_TM_COUPLING",
9037 "constitutive_material_spread_ratio",
9038 );
9039 let thermo_heterogeneity_index = diagnostic_metric(
9040 &run.diagnostics,
9041 "FEA_TM_COUPLING",
9042 "assignment_heterogeneity_index",
9043 );
9044 let (thermo_spread_threshold, thermo_heterogeneity_threshold) =
9045 thermo_thresholds_for_policy(options.quality_policy);
9046 let (thermo_field_coverage_min, thermo_field_extrapolation_max) =
9047 thermo_field_quality_thresholds_for_policy(options.quality_policy);
9048 let thermo_spread_breach = thermo_spread_ratio
9049 .map(|value| value > thermo_spread_threshold)
9050 .unwrap_or(false);
9051 let thermo_heterogeneity_breach = thermo_heterogeneity_index
9052 .map(|value| value > thermo_heterogeneity_threshold)
9053 .unwrap_or(false);
9054 let thermo_field_coverage_breach = thermo_spatial_coverage_ratio
9055 .map(|value| value < thermo_field_coverage_min)
9056 .unwrap_or(false);
9057 let thermo_field_extrapolation_breach = thermo_field_extrapolation_ratio
9058 .map(|value| value > thermo_field_extrapolation_max)
9059 .unwrap_or(false);
9060
9061 let mut quality_reasons = Vec::new();
9062 if solver_convergence == QualityGate::Warn {
9063 quality_reasons.push(QualityReason {
9064 code: QualityReasonCode::SolverNotConverged,
9065 detail: "nonlinear solver convergence gate is warning".to_string(),
9066 });
9067 }
9068 if result_quality == QualityGate::Warn {
9069 quality_reasons.push(QualityReason {
9070 code: QualityReasonCode::NonlinearResidualExceeded,
9071 detail: format!(
9072 "nonlinear residual/increment norm exceeds thresholds residual={} increment_norm={}",
9073 options.tolerance * options.residual_convergence_factor * 2.0,
9074 options.increment_norm_tolerance * 4.0
9075 ),
9076 });
9077 }
9078 if nonlinear_increment_warn || strict_increment_failure || strict_iteration_cap_exhausted {
9079 quality_reasons.push(QualityReason {
9080 code: QualityReasonCode::NonlinearIncrementFailure,
9081 detail: format!(
9082 "nonlinear increment convergence warnings failed_increments={} iteration_cap_hits={} max_iteration_count={}",
9083 nonlinear_run.failed_increments,
9084 iteration_cap_hits,
9085 max_nonlinear_iteration_count
9086 ),
9087 });
9088 }
9089 if thermo_nonlinear_warn {
9090 quality_reasons.push(QualityReason {
9091 code: QualityReasonCode::ThermoMechanicalNonlinearStress,
9092 detail: "thermo-mechanical nonlinear coupling severity exceeded balanced threshold"
9093 .to_string(),
9094 });
9095 }
9096 if electro_nonlinear_warn {
9097 quality_reasons.push(QualityReason {
9098 code: QualityReasonCode::ElectroThermalNonlinearStress,
9099 detail: "electro-thermal nonlinear coupling severity exceeded balanced threshold"
9100 .to_string(),
9101 });
9102 }
9103 if plastic_nonlinear_warn {
9104 quality_reasons.push(QualityReason {
9105 code: QualityReasonCode::PlasticityNonlinearStress,
9106 detail: "plasticity nonlinear severity exceeded balanced threshold".to_string(),
9107 });
9108 }
9109 if contact_nonlinear_warn {
9110 quality_reasons.push(QualityReason {
9111 code: QualityReasonCode::ContactNonlinearStress,
9112 detail: "contact nonlinear severity exceeded balanced threshold".to_string(),
9113 });
9114 }
9115 if thermo_spread_breach {
9116 quality_reasons.push(QualityReason {
9117 code: QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh,
9118 detail: format!(
9119 "thermo constitutive material spread ratio {} exceeds threshold {}",
9120 thermo_spread_ratio.unwrap_or(0.0),
9121 thermo_spread_threshold
9122 ),
9123 });
9124 }
9125 if thermo_heterogeneity_breach {
9126 quality_reasons.push(QualityReason {
9127 code: QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh,
9128 detail: format!(
9129 "thermo assignment heterogeneity index {} exceeds threshold {}",
9130 thermo_heterogeneity_index.unwrap_or(0.0),
9131 thermo_heterogeneity_threshold
9132 ),
9133 });
9134 }
9135 if thermo_gradient_instability {
9136 quality_reasons.push(QualityReason {
9137 code: QualityReasonCode::ThermoMechanicalGradientInstability,
9138 detail: format!(
9139 "thermo gradient instability spatial_gradient_index={} temporal_variation={} thresholds=({}, {})",
9140 thermo_spatial_gradient_index.unwrap_or(0.0),
9141 thermo_temporal_variation.unwrap_or(0.0),
9142 thermo_gradient_spatial_threshold,
9143 thermo_gradient_temporal_threshold,
9144 ),
9145 });
9146 }
9147 if thermo_field_coverage_breach {
9148 quality_reasons.push(QualityReason {
9149 code: QualityReasonCode::ThermoMechanicalFieldCoverageLow,
9150 detail: format!(
9151 "thermo field spatial coverage ratio {} is below minimum {}",
9152 thermo_spatial_coverage_ratio.unwrap_or(0.0),
9153 thermo_field_coverage_min
9154 ),
9155 });
9156 }
9157 if thermo_field_extrapolation_breach {
9158 quality_reasons.push(QualityReason {
9159 code: QualityReasonCode::ThermoMechanicalFieldExtrapolationHigh,
9160 detail: format!(
9161 "thermo field extrapolation ratio {} exceeds maximum {}",
9162 thermo_field_extrapolation_ratio.unwrap_or(0.0),
9163 thermo_field_extrapolation_max
9164 ),
9165 });
9166 }
9167 if fallback_events
9168 .iter()
9169 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
9170 {
9171 quality_reasons.push(QualityReason {
9172 code: QualityReasonCode::SolverBackendFallback,
9173 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
9174 });
9175 }
9176 if fallback_events.iter().any(|event| {
9177 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
9178 }) {
9179 quality_reasons.push(QualityReason {
9180 code: QualityReasonCode::FieldPromotionFallback,
9181 detail: "field promotion fell back to host-backed values".to_string(),
9182 });
9183 }
9184
9185 let publishable = match options.quality_policy {
9186 QualityPolicy::Strict => {
9187 solver_convergence == QualityGate::Pass
9188 && result_quality == QualityGate::Pass
9189 && !strict_increment_failure
9190 && !strict_iteration_cap_exhausted
9191 && quality_reasons.is_empty()
9192 }
9193 QualityPolicy::Balanced => {
9194 solver_convergence == QualityGate::Pass
9195 && result_quality == QualityGate::Pass
9196 && !quality_reasons.iter().any(|r| {
9197 matches!(
9198 r.code,
9199 QualityReasonCode::NonlinearResidualExceeded
9200 | QualityReasonCode::NonlinearIncrementFailure
9201 | QualityReasonCode::ThermoMechanicalNonlinearStress
9202 | QualityReasonCode::PlasticityNonlinearStress
9203 | QualityReasonCode::ContactNonlinearStress
9204 | QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh
9205 | QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh
9206 | QualityReasonCode::ThermoMechanicalGradientInstability
9207 )
9208 })
9209 }
9210 QualityPolicy::Exploratory => {
9211 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
9212 }
9213 };
9214 let run_status = if publishable {
9215 RunStatus::Publishable
9216 } else if result_quality == QualityGate::Fail {
9217 RunStatus::Rejected
9218 } else {
9219 RunStatus::Degraded
9220 };
9221
9222 let solver_backend = run.solver_backend.clone();
9223 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
9224 let solver_host_sync_count = run.solver_host_sync_count;
9225 let solver_method = run.solver_method.clone();
9226 let selected_preconditioner = run.preconditioner.clone();
9227
9228 let result = AnalysisRunResult {
9229 run_id: storage::next_run_id(),
9230 run,
9231 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
9232 modal_results: None,
9233 thermal_results: None,
9234 transient_results: None,
9235 nonlinear_results: Some(NonlinearResultsData {
9236 nonlinear_payload_version: "nonlinear_results/v1".to_string(),
9237 load_factors: nonlinear_run.load_factors,
9238 displacement_snapshots: nonlinear_run.displacement_snapshots,
9239 rotation_snapshots: nonlinear_run.rotation_snapshots,
9240 von_mises_snapshots: nonlinear_run.von_mises_snapshots,
9241 plastic_strain_snapshots: nonlinear_run.plastic_strain_snapshots,
9242 equivalent_plastic_strain_snapshots: nonlinear_run.equivalent_plastic_strain_snapshots,
9243 contact_pressure_snapshots: nonlinear_run.contact_pressure_snapshots,
9244 contact_gap_snapshots: nonlinear_run.contact_gap_snapshots,
9245 load_factor_snapshots: nonlinear_run.load_factor_snapshots,
9246 residual_norm_snapshots: nonlinear_run.residual_norm_snapshots,
9247 thermo_mechanical_temperature_snapshots: nonlinear_run
9248 .thermo_mechanical_temperature_snapshots,
9249 thermo_mechanical_thermal_strain_snapshots: nonlinear_run
9250 .thermo_mechanical_thermal_strain_snapshots,
9251 thermo_mechanical_thermal_stress_snapshots: nonlinear_run
9252 .thermo_mechanical_thermal_stress_snapshots,
9253 thermo_mechanical_displacement_snapshots: nonlinear_run
9254 .thermo_mechanical_displacement_snapshots,
9255 thermo_mechanical_von_mises_snapshots: nonlinear_run
9256 .thermo_mechanical_von_mises_snapshots,
9257 thermo_mechanical_coupling_residual_snapshots: nonlinear_run
9258 .thermo_mechanical_coupling_residual_snapshots,
9259 electro_thermal_temperature_snapshots: nonlinear_run
9260 .electro_thermal_temperature_snapshots,
9261 electro_thermal_thermal_residual_snapshots: nonlinear_run
9262 .electro_thermal_thermal_residual_snapshots,
9263 residual_norms: nonlinear_run.residual_norms,
9264 increment_norms: nonlinear_run.increment_norms,
9265 iteration_counts: nonlinear_run.iteration_counts,
9266 failed_increments: nonlinear_run.failed_increments,
9267 line_search_backtracks: nonlinear_run.line_search_backtracks,
9268 max_line_search_backtracks_per_increment: nonlinear_run
9269 .max_line_search_backtracks_per_increment,
9270 tangent_rebuild_count: nonlinear_run.tangent_rebuild_count,
9271 iteration_spike_count: nonlinear_run.iteration_spike_count,
9272 convergence_stall_count: nonlinear_run.convergence_stall_count,
9273 backtrack_burst_count: nonlinear_run.backtrack_burst_count,
9274 method: NonlinearMethod::IncrementalNewtonRaphson,
9275 }),
9276 electromagnetic_results: None,
9277 model_validity: QualityGate::Pass,
9278 solver_convergence,
9279 result_quality,
9280 run_status,
9281 publishable,
9282 quality_reasons,
9283 provenance: RunProvenance {
9284 backend,
9285 solver_backend,
9286 solver_device_apply_k_ratio,
9287 solver_host_sync_count,
9288 precision_mode: contracts::format_precision_mode(options.precision_mode),
9289 deterministic_mode: options.deterministic_mode,
9290 solver_method,
9291 preconditioner: selected_preconditioner,
9292 quality_policy: contracts::format_quality_policy(options.quality_policy),
9293 fallback_events,
9294 },
9295 };
9296
9297 persist_fea_run_result_with_progress(
9298 ANALYSIS_RUN_NONLINEAR_OPERATION,
9299 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
9300 "RM.FEA.RUN_NONLINEAR.ARTIFACT_STORE_FAILED",
9301 &context,
9302 &result,
9303 )?;
9304
9305 Ok(OperationEnvelope::new(
9306 ANALYSIS_RUN_NONLINEAR_OPERATION,
9307 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
9308 &context,
9309 result,
9310 ))
9311}
9312
9313pub fn analysis_run_linear_static_with_options(
9314 model: &AnalysisModel,
9315 backend: ComputeBackend,
9316 options: AnalysisRunOptions,
9317 context: OperationContext,
9318) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
9319 let _solver_context = install_fea_solver_context();
9320 let thermo_options = resolve_thermo_coupling_options(
9321 model,
9322 model_thermo_coupling_options(model),
9323 ANALYSIS_RUN_OPERATION,
9324 ANALYSIS_RUN_OP_VERSION,
9325 &context,
9326 )?;
9327 if let Some(thermo_options) = thermo_options.as_ref() {
9328 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
9329 return Err(operation_error(
9330 ANALYSIS_RUN_OPERATION,
9331 ANALYSIS_RUN_OP_VERSION,
9332 &context,
9333 OperationErrorSpec {
9334 error_code: "RM.FEA.RUN_LINEAR_STATIC.INVALID_OPTIONS",
9335 error_type: OperationErrorType::Input,
9336 retryable: false,
9337 severity: OperationErrorSeverity::Error,
9338 },
9339 detail,
9340 metadata,
9341 ));
9342 }
9343 }
9344 let electro_options = model_electro_coupling_options(model);
9345 if let Some(electro_options) = electro_options.as_ref() {
9346 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
9347 return Err(operation_error(
9348 ANALYSIS_RUN_OPERATION,
9349 ANALYSIS_RUN_OP_VERSION,
9350 &context,
9351 OperationErrorSpec {
9352 error_code: electro_thermal_invalid_options_error_code(ANALYSIS_RUN_OPERATION),
9353 error_type: OperationErrorType::Input,
9354 retryable: false,
9355 severity: OperationErrorSeverity::Error,
9356 },
9357 detail,
9358 metadata,
9359 ));
9360 }
9361 }
9362
9363 let requested_preconditioner = match options.preconditioner_mode {
9364 PreconditionerMode::Auto | PreconditionerMode::Jacobi => SpdPreconditionerKind::Jacobi,
9365 PreconditionerMode::Ilu => SpdPreconditionerKind::Ilu0,
9366 PreconditionerMode::Amg => SpdPreconditionerKind::Jacobi,
9367 };
9368 let runtime_tensor_available = runmat_accelerate_api::provider().is_some();
9369 let requested_solver_backend = match backend {
9370 ComputeBackend::Cpu => LinearAlgebraBackendKind::CpuReference,
9371 ComputeBackend::Gpu => {
9372 if runtime_tensor_available {
9373 LinearAlgebraBackendKind::RuntimeTensor
9374 } else {
9375 LinearAlgebraBackendKind::CpuReference
9376 }
9377 }
9378 };
9379 let prep_context = resolve_run_prep_context(
9380 model,
9381 options.prep_artifact_id.as_deref(),
9382 options.prep_context.clone(),
9383 ANALYSIS_RUN_OPERATION,
9384 ANALYSIS_RUN_OP_VERSION,
9385 &context,
9386 )?;
9387 let analysis_mesh = resolve_analysis_mesh_artifact(
9388 options.analysis_mesh_artifact_path.as_deref(),
9389 ANALYSIS_RUN_OPERATION,
9390 ANALYSIS_RUN_OP_VERSION,
9391 &context,
9392 )?;
9393 let analysis_mesh_validation_evidence = resolve_analysis_mesh_validation_evidence_status(
9394 options.analysis_mesh_artifact_path.as_deref(),
9395 ANALYSIS_RUN_OPERATION,
9396 ANALYSIS_RUN_OP_VERSION,
9397 &context,
9398 )?;
9399 let run = run_linear_static_with_options(
9400 model,
9401 backend,
9402 LinearStaticSolveOptions {
9403 preconditioner_kind: requested_preconditioner,
9404 algebra_backend_kind: requested_solver_backend,
9405 prep_context: to_fea_prep_context(
9406 prep_context.as_ref(),
9407 options.prep_calibration_profile,
9408 ),
9409 analysis_mesh_artifact_path: options.analysis_mesh_artifact_path.clone(),
9410 analysis_mesh: analysis_mesh.clone(),
9411 require_analysis_mesh_for_solid: true,
9412 thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
9413 electro_thermal_context: to_fea_electro_thermal_context(electro_options),
9414 },
9415 )
9416 .map_err(|err| {
9417 map_fea_run_error(
9418 ANALYSIS_RUN_OPERATION,
9419 ANALYSIS_RUN_OP_VERSION,
9420 "RM.FEA.RUN_LINEAR_STATIC.SOLVER_MODEL_INVALID",
9421 "RM.FEA.RUN_LINEAR_STATIC.CANCELLED",
9422 model,
9423 &context,
9424 err,
9425 )
9426 })?;
9427
9428 let mut run = run;
9429 append_solved_adaptive_mesh_summary(
9430 options.analysis_mesh_artifact_path.as_deref(),
9431 &run.fields,
9432 )
9433 .map_err(|err| {
9434 operation_error(
9435 ANALYSIS_RUN_OPERATION,
9436 ANALYSIS_RUN_OP_VERSION,
9437 &context,
9438 OperationErrorSpec {
9439 error_code: "RM.FEA.RUN_LINEAR_STATIC.ARTIFACT_STORE_FAILED",
9440 error_type: OperationErrorType::Internal,
9441 retryable: true,
9442 severity: OperationErrorSeverity::Error,
9443 },
9444 format!("failed to update analysis mesh adaptive summary: {err}"),
9445 BTreeMap::from([(
9446 "analysis_mesh_artifact_path".to_string(),
9447 options
9448 .analysis_mesh_artifact_path
9449 .clone()
9450 .unwrap_or_default(),
9451 )]),
9452 )
9453 })?;
9454 let mut fallback_events = Vec::new();
9455 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
9456
9457 match options.preconditioner_mode {
9458 PreconditionerMode::Auto | PreconditionerMode::Jacobi => {}
9459 PreconditionerMode::Ilu => {
9460 if run.preconditioner != "ilu0" {
9461 fallback_events.push(format!(
9462 "SOLVER_PRECONDITIONER_FALLBACK:requested=ilu0:using={}",
9463 run.preconditioner
9464 ));
9465 }
9466 }
9467 PreconditionerMode::Amg => {
9468 fallback_events
9469 .push("SOLVER_PRECONDITIONER_FALLBACK:requested=amg:using=jacobi".to_string());
9470 }
9471 }
9472
9473 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
9474 fallback_events.push(
9475 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
9476 );
9477 }
9478
9479 let solver_convergence = if run.diagnostics.iter().any(|item| {
9480 item.code == "FEA_CONVERGENCE"
9481 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
9482 }) {
9483 QualityGate::Pass
9484 } else {
9485 QualityGate::Warn
9486 };
9487
9488 let has_material_assignment_conflict = run.diagnostics.iter().any(|diag| {
9489 diag.code
9490 .starts_with("ANALYSIS_MATERIAL_ASSIGNMENT_CONFLICT_")
9491 });
9492 let field_topology_reasons =
9493 field_topology_quality_reasons(&run.fields, analysis_mesh.as_ref());
9494 let solid_mesh_reasons = solid_mesh_quality_reasons(model, analysis_mesh.as_ref());
9495 let mesh_validation_reasons =
9496 mesh_validation_evidence_quality_reasons(&analysis_mesh_validation_evidence);
9497 let missing_solid_mesh_reasons =
9498 missing_solid_analysis_mesh_reasons(model, analysis_mesh.as_ref());
9499 let solid_mesh_has_failure = solid_mesh_reasons.iter().any(|reason| {
9500 matches!(
9501 reason.code,
9502 QualityReasonCode::SolidMeshNoVolumeElements
9503 | QualityReasonCode::SolidMeshUnsupportedElementKind
9504 | QualityReasonCode::SolidMeshUnmappedLoadRegion
9505 | QualityReasonCode::SolidMeshUnmappedBoundaryConditionRegion
9506 | QualityReasonCode::SolidMeshMaterialCoverageIncomplete
9507 | QualityReasonCode::SolidMeshRenderTopologyIncomplete
9508 | QualityReasonCode::SolidMeshQualityMinJacobianFailed
9509 )
9510 }) || !mesh_validation_reasons.is_empty();
9511 let result_quality =
9512 if run.fields_are_empty() || solid_mesh_has_failure || !field_topology_reasons.is_empty() {
9513 QualityGate::Fail
9514 } else if !missing_solid_mesh_reasons.is_empty()
9515 || !solid_mesh_reasons.is_empty()
9516 || has_material_assignment_conflict
9517 {
9518 QualityGate::Warn
9519 } else {
9520 QualityGate::Pass
9521 };
9522
9523 let mut quality_reasons = Vec::new();
9524 if has_material_assignment_conflict {
9525 quality_reasons.push(QualityReason {
9526 code: QualityReasonCode::MaterialAssignmentConflict,
9527 detail: "material assignment confidence conflict detected".to_string(),
9528 });
9529 }
9530 quality_reasons.extend(solid_mesh_reasons);
9531 quality_reasons.extend(mesh_validation_reasons);
9532 quality_reasons.extend(field_topology_reasons);
9533 quality_reasons.extend(missing_solid_mesh_reasons);
9534 if solver_convergence == QualityGate::Warn {
9535 quality_reasons.push(QualityReason {
9536 code: QualityReasonCode::SolverNotConverged,
9537 detail: "solver convergence gate is warning".to_string(),
9538 });
9539 }
9540 if fallback_events
9541 .iter()
9542 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
9543 {
9544 quality_reasons.push(QualityReason {
9545 code: QualityReasonCode::SolverBackendFallback,
9546 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
9547 });
9548 }
9549 if fallback_events.iter().any(|event| {
9550 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
9551 }) {
9552 quality_reasons.push(QualityReason {
9553 code: QualityReasonCode::FieldPromotionFallback,
9554 detail: "field promotion fell back to host-backed values".to_string(),
9555 });
9556 }
9557 let publishable = match options.quality_policy {
9558 QualityPolicy::Strict => {
9559 solver_convergence == QualityGate::Pass
9560 && result_quality == QualityGate::Pass
9561 && quality_reasons.is_empty()
9562 }
9563 QualityPolicy::Balanced => {
9564 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
9565 }
9566 QualityPolicy::Exploratory => {
9567 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
9568 }
9569 };
9570 let run_status = if publishable {
9571 RunStatus::Publishable
9572 } else if result_quality == QualityGate::Fail {
9573 RunStatus::Rejected
9574 } else {
9575 RunStatus::Degraded
9576 };
9577 let solver_backend = run.solver_backend.clone();
9578 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
9579 let solver_host_sync_count = run.solver_host_sync_count;
9580 let solver_method = run.solver_method.clone();
9581 let preconditioner = run.preconditioner.clone();
9582
9583 let result = AnalysisRunResult {
9584 run_id: storage::next_run_id(),
9585 run,
9586 render_topology: render_topology_from_analysis_mesh(analysis_mesh.as_ref())
9587 .or_else(|| render_topology_from_prep_context(prep_context.as_ref())),
9588 modal_results: None,
9589 thermal_results: None,
9590 transient_results: None,
9591 nonlinear_results: None,
9592 electromagnetic_results: None,
9593 model_validity: QualityGate::Pass,
9594 solver_convergence,
9595 result_quality,
9596 run_status,
9597 publishable,
9598 quality_reasons,
9599 provenance: RunProvenance {
9600 backend,
9601 solver_backend,
9602 solver_device_apply_k_ratio,
9603 solver_host_sync_count,
9604 precision_mode: contracts::format_precision_mode(options.precision_mode),
9605 deterministic_mode: options.deterministic_mode,
9606 solver_method,
9607 preconditioner,
9608 quality_policy: contracts::format_quality_policy(options.quality_policy),
9609 fallback_events,
9610 },
9611 };
9612
9613 persist_fea_run_result_with_progress(
9614 ANALYSIS_RUN_OPERATION,
9615 ANALYSIS_RUN_OP_VERSION,
9616 "RM.FEA.RUN_LINEAR_STATIC.ARTIFACT_STORE_FAILED",
9617 &context,
9618 &result,
9619 )?;
9620
9621 Ok(OperationEnvelope::new(
9622 ANALYSIS_RUN_OPERATION,
9623 ANALYSIS_RUN_OP_VERSION,
9624 &context,
9625 result,
9626 ))
9627}
9628
9629fn field_topology_quality_reasons(
9630 fields: &[AnalysisField],
9631 analysis_mesh: Option<&AnalysisMeshArtifact>,
9632) -> Vec<QualityReason> {
9633 let Some(mesh) = analysis_mesh else {
9634 return Vec::new();
9635 };
9636 fields
9637 .iter()
9638 .filter_map(|field| primary_solver_mesh_field_expected_count(field, mesh).map(|expected| (field, expected)))
9639 .filter_map(|(field, expected)| {
9640 let descriptor = AnalysisFieldDescriptor::from_field(field);
9641 let actual = descriptor_entity_count(field, &descriptor);
9642 (actual != expected).then(|| QualityReason {
9643 code: QualityReasonCode::FieldTopologyMismatch,
9644 detail: format!(
9645 "field {} topology mismatch: topology_id={} location={:?} element_kind={} expected_entity_count={} actual_entity_count={}",
9646 field.field_id,
9647 descriptor.topology_id.as_deref().unwrap_or("none"),
9648 descriptor.location,
9649 descriptor.element_kind.as_deref().unwrap_or("none"),
9650 expected,
9651 actual
9652 ),
9653 })
9654 })
9655 .collect()
9656}
9657
9658#[derive(Debug, Clone, PartialEq)]
9659enum MeshValidationEvidenceStatus {
9660 NotRequested,
9661 Missing { detail: String },
9662 Present(Box<MeshValidationEvidence>),
9663}
9664
9665fn mesh_validation_evidence_quality_reasons(
9666 validation: &MeshValidationEvidenceStatus,
9667) -> Vec<QualityReason> {
9668 let validation = match validation {
9669 MeshValidationEvidenceStatus::NotRequested => return Vec::new(),
9670 MeshValidationEvidenceStatus::Missing { detail } => {
9671 return vec![QualityReason {
9672 code: QualityReasonCode::SolidMeshValidationEvidenceMissing,
9673 detail: format!("analysis mesh evidence is missing: {detail}"),
9674 }];
9675 }
9676 MeshValidationEvidenceStatus::Present(validation) => validation,
9677 };
9678 if validation.solve_ready {
9679 return Vec::new();
9680 }
9681 let code = validation
9682 .validation_error_code
9683 .as_deref()
9684 .unwrap_or("unknown");
9685 let message = validation
9686 .validation_error_message
9687 .as_deref()
9688 .unwrap_or("mesh evidence validation did not include a message");
9689 let recovery_detail = mesh_validation_recovery_detail(validation);
9690 vec![QualityReason {
9691 code: QualityReasonCode::SolidMeshValidationEvidenceFailed,
9692 detail: format!(
9693 "analysis mesh evidence is not solve-ready: validation_error_code={code}; {message}{recovery_detail}"
9694 ),
9695 }]
9696}
9697
9698fn mesh_validation_recovery_detail(validation: &MeshValidationEvidence) -> String {
9699 let mut details = Vec::<String>::new();
9700 if validation.unrecovered_tetrahedron_component_count > 0 {
9701 details.push(format!(
9702 "unrecovered_tetrahedron_component_count={}",
9703 validation.unrecovered_tetrahedron_component_count
9704 ));
9705 }
9706 if validation.unrepaired_exact_quality_total_count > 0
9707 || validation.unrepaired_exact_quality_general_cavity_count > 0
9708 || validation.unrepaired_exact_quality_boundary_adjacent_count > 0
9709 || validation.unrepaired_exact_quality_node_adjacent_count > 0
9710 || validation.unrepaired_exact_quality_interior_seed_count > 0
9711 || validation.unrepaired_exact_quality_edge_star_count > 0
9712 {
9713 details.push(format!(
9714 "unrepaired_exact_quality_total_count={}",
9715 validation.unrepaired_exact_quality_total_count
9716 ));
9717 details.push(format!(
9718 "unrepaired_exact_quality_general_cavity_count={}",
9719 validation.unrepaired_exact_quality_general_cavity_count
9720 ));
9721 details.push(format!(
9722 "unrepaired_exact_quality_boundary_adjacent_count={}",
9723 validation.unrepaired_exact_quality_boundary_adjacent_count
9724 ));
9725 details.push(format!(
9726 "unrepaired_exact_quality_node_adjacent_count={}",
9727 validation.unrepaired_exact_quality_node_adjacent_count
9728 ));
9729 details.push(format!(
9730 "unrepaired_exact_quality_interior_seed_count={}",
9731 validation.unrepaired_exact_quality_interior_seed_count
9732 ));
9733 details.push(format!(
9734 "unrepaired_exact_quality_edge_star_count={}",
9735 validation.unrepaired_exact_quality_edge_star_count
9736 ));
9737 }
9738 if details.is_empty() {
9739 String::new()
9740 } else {
9741 format!("; {}", details.join("; "))
9742 }
9743}
9744
9745fn solid_mesh_quality_reasons(
9746 model: &AnalysisModel,
9747 analysis_mesh: Option<&AnalysisMeshArtifact>,
9748) -> Vec<QualityReason> {
9749 let Some(mesh) = analysis_mesh else {
9750 return Vec::new();
9751 };
9752 let mut reasons = Vec::new();
9753 if mesh.volume_elements.is_empty() {
9754 reasons.push(QualityReason {
9755 code: QualityReasonCode::SolidMeshNoVolumeElements,
9756 detail: "analysis mesh has no volume elements".to_string(),
9757 });
9758 }
9759 if let Some(element) = mesh
9760 .volume_elements
9761 .iter()
9762 .find(|element| !element.kind.is_supported_for_solid_solve())
9763 {
9764 reasons.push(QualityReason {
9765 code: QualityReasonCode::SolidMeshUnsupportedElementKind,
9766 detail: format!(
9767 "analysis mesh contains unsupported solid element kind {:?} at element {}",
9768 element.kind, element.element_id
9769 ),
9770 });
9771 }
9772 if let Some(detail) = material_coverage_gap_detail(model, mesh) {
9773 reasons.push(QualityReason {
9774 code: QualityReasonCode::SolidMeshMaterialCoverageIncomplete,
9775 detail,
9776 });
9777 }
9778 if let Some(detail) = render_topology_gap_detail(mesh) {
9779 reasons.push(QualityReason {
9780 code: QualityReasonCode::SolidMeshRenderTopologyIncomplete,
9781 detail,
9782 });
9783 }
9784 reasons.extend(boundary_region_mapping_reasons(model, mesh));
9785 let thresholds = runmat_meshing_core::QualityThresholds::default();
9786 if !mesh.quality.min_scaled_jacobian.is_finite()
9787 || mesh.quality.min_scaled_jacobian < thresholds.min_scaled_jacobian
9788 {
9789 reasons.push(QualityReason {
9790 code: QualityReasonCode::SolidMeshQualityMinJacobianFailed,
9791 detail: format!(
9792 "analysis mesh min_scaled_jacobian={} is below threshold {}",
9793 mesh.quality.min_scaled_jacobian, thresholds.min_scaled_jacobian
9794 ),
9795 });
9796 }
9797 if !mesh.quality.max_aspect_ratio.is_finite()
9798 || mesh.quality.max_aspect_ratio > thresholds.max_aspect_ratio
9799 {
9800 reasons.push(QualityReason {
9801 code: QualityReasonCode::SolidMeshQualityAspectRatioWarn,
9802 detail: format!(
9803 "analysis mesh max_aspect_ratio={} exceeds warning threshold {}",
9804 mesh.quality.max_aspect_ratio, thresholds.max_aspect_ratio
9805 ),
9806 });
9807 }
9808 if let Some(thresholds) = boundary_projection_warning_thresholds_m(mesh) {
9809 if !mesh.quality.max_boundary_projection_error_m.is_finite()
9810 || !mesh.quality.mean_boundary_projection_error_m.is_finite()
9811 || mesh.quality.max_boundary_projection_error_m > thresholds.max_error_m
9812 || mesh.quality.mean_boundary_projection_error_m > thresholds.mean_error_m
9813 {
9814 reasons.push(QualityReason {
9815 code: QualityReasonCode::SolidMeshBoundaryProjectionWarn,
9816 detail: format!(
9817 "analysis mesh max_boundary_projection_error_m={} or mean_boundary_projection_error_m={} exceeds warning thresholds max={} mean={}; boundary faces are a carved-grid approximation of the source surface",
9818 mesh.quality.max_boundary_projection_error_m,
9819 mesh.quality.mean_boundary_projection_error_m,
9820 thresholds.max_error_m,
9821 thresholds.mean_error_m
9822 ),
9823 });
9824 }
9825 }
9826 reasons
9827}
9828
9829fn render_topology_gap_detail(mesh: &AnalysisMeshArtifact) -> Option<String> {
9830 if mesh.volume_elements.is_empty() {
9831 return None;
9832 }
9833 let mut renderable_boundary_face_count = 0_usize;
9834 for face in &mesh.boundary_faces {
9835 if face.kind != runmat_meshing_core::BoundaryElementKind::Tri3 || face.node_ids.len() != 3 {
9836 continue;
9837 }
9838 renderable_boundary_face_count += 1;
9839 }
9840 if renderable_boundary_face_count == 0 {
9841 return Some(format!(
9842 "analysis mesh has {} volume elements but no renderable Tri3 boundary faces for solver field visualization",
9843 mesh.volume_elements.len()
9844 ));
9845 }
9846 if let Err(err) = validate_analysis_mesh_solver_field_mapping(mesh) {
9847 return Some(format!(
9848 "analysis mesh render topology is incomplete: renderable_boundary_face_count={} field_mapping_error={}",
9849 renderable_boundary_face_count, err
9850 ));
9851 }
9852 None
9853}
9854
9855fn validate_analysis_mesh_solver_field_mapping(mesh: &AnalysisMeshArtifact) -> Result<(), String> {
9856 let element_values = vec![0.0_f64; mesh.volume_elements.len()];
9857 runmat_meshing::map_volume_scalar_field_to_boundary_faces(mesh, &element_values)
9858 .map_err(|err| err.to_string())?;
9859 let node_values = vec![[0.0_f64; 3]; mesh.nodes.len()];
9860 runmat_meshing::map_nodal_vector_field_to_boundary_nodes(mesh, &node_values)
9861 .map_err(|err| err.to_string())?;
9862 runmat_meshing::map_nodal_vector_field_to_boundary_faces(mesh, &node_values)
9863 .map_err(|err| err.to_string())?;
9864 Ok(())
9865}
9866
9867struct BoundaryProjectionWarningThresholds {
9868 max_error_m: f64,
9869 mean_error_m: f64,
9870}
9871
9872fn boundary_projection_warning_thresholds_m(
9873 mesh: &AnalysisMeshArtifact,
9874) -> Option<BoundaryProjectionWarningThresholds> {
9875 mesh.sizing
9876 .global_target_size_m
9877 .filter(|target_size_m| target_size_m.is_finite() && *target_size_m > 0.0)
9878 .map(|target_size_m| BoundaryProjectionWarningThresholds {
9879 max_error_m: target_size_m * 0.5,
9880 mean_error_m: target_size_m * 0.25,
9881 })
9882}
9883
9884fn missing_solid_analysis_mesh_reasons(
9885 model: &AnalysisModel,
9886 analysis_mesh: Option<&AnalysisMeshArtifact>,
9887) -> Vec<QualityReason> {
9888 if analysis_mesh.is_some() || model_has_explicit_structural_elements(model) {
9889 return Vec::new();
9890 }
9891 vec![QualityReason {
9892 code: QualityReasonCode::MissingSolidAnalysisMesh,
9893 detail: "linear static structural result requires a solver-ready solid analysis mesh"
9894 .to_string(),
9895 }]
9896}
9897
9898fn model_has_explicit_structural_elements(model: &AnalysisModel) -> bool {
9899 model
9900 .structural
9901 .as_ref()
9902 .is_some_and(|structural| !structural.elements.is_empty())
9903}
9904
9905fn boundary_region_mapping_reasons(
9906 model: &AnalysisModel,
9907 mesh: &AnalysisMeshArtifact,
9908) -> Vec<QualityReason> {
9909 let boundary_region_ids = mesh
9910 .boundary_faces
9911 .iter()
9912 .flat_map(|face| face.region_ids.iter().map(String::as_str))
9913 .collect::<HashSet<_>>();
9914 let mut reasons = Vec::new();
9915 for load in &model.loads {
9916 if !load_requires_boundary_region(&load.kind) {
9917 continue;
9918 }
9919 if !boundary_region_ids.contains(load.region_id.as_str()) {
9920 reasons.push(QualityReason {
9921 code: QualityReasonCode::SolidMeshUnmappedLoadRegion,
9922 detail: format!(
9923 "load `{}` references region `{}` but the analysis mesh has no matching boundary faces",
9924 load.load_id, load.region_id
9925 ),
9926 });
9927 }
9928 }
9929 for boundary_condition in &model.boundary_conditions {
9930 if !boundary_region_ids.contains(boundary_condition.region_id.as_str()) {
9931 reasons.push(QualityReason {
9932 code: QualityReasonCode::SolidMeshUnmappedBoundaryConditionRegion,
9933 detail: format!(
9934 "boundary condition `{}` references region `{}` but the analysis mesh has no matching boundary faces",
9935 boundary_condition.bc_id, boundary_condition.region_id
9936 ),
9937 });
9938 }
9939 }
9940 reasons
9941}
9942
9943fn load_requires_boundary_region(kind: &LoadKind) -> bool {
9944 matches!(
9945 kind,
9946 LoadKind::Force { .. }
9947 | LoadKind::Moment { .. }
9948 | LoadKind::Wrench { .. }
9949 | LoadKind::Pressure { .. }
9950 )
9951}
9952
9953fn material_coverage_gap_detail(
9954 model: &AnalysisModel,
9955 mesh: &AnalysisMeshArtifact,
9956) -> Option<String> {
9957 if mesh.volume_elements.is_empty() {
9958 return None;
9959 }
9960 if model.material_assignments.is_empty() {
9961 return (model.materials.len() != 1).then(|| {
9962 format!(
9963 "analysis mesh has {} material regions but model has {} materials and no material assignments",
9964 mesh.volume_elements
9965 .iter()
9966 .map(|element| element.material_region_id.as_str())
9967 .collect::<HashSet<_>>()
9968 .len(),
9969 model.materials.len()
9970 )
9971 });
9972 }
9973 if model.materials.len() == 1 {
9974 let material_id = model.materials[0].material_id.as_str();
9975 if model
9976 .material_assignments
9977 .iter()
9978 .all(|assignment| assignment.assigned_material_id == material_id)
9979 {
9980 return None;
9981 }
9982 }
9983 let assigned_region_ids = model
9984 .material_assignments
9985 .iter()
9986 .map(|assignment| assignment.region_id.as_str())
9987 .collect::<HashSet<_>>();
9988 let mut uncovered = mesh
9989 .volume_elements
9990 .iter()
9991 .map(|element| element.material_region_id.as_str())
9992 .filter(|region_id| !assigned_region_ids.contains(region_id))
9993 .collect::<Vec<_>>();
9994 uncovered.sort_unstable();
9995 uncovered.dedup();
9996 if uncovered.is_empty() {
9997 None
9998 } else {
9999 Some(format!(
10000 "analysis mesh volume material regions are not covered by model material assignments: {}",
10001 uncovered.join(",")
10002 ))
10003 }
10004}
10005
10006fn primary_solver_mesh_field_expected_count(
10007 field: &AnalysisField,
10008 mesh: &AnalysisMeshArtifact,
10009) -> Option<usize> {
10010 if matches!(
10011 field.field_id.as_str(),
10012 FEA_FIELD_STRUCTURAL_ROTATION
10013 | FEA_FIELD_STRUCTURAL_REACTION_FORCE
10014 | FEA_FIELD_STRUCTURAL_REACTION_MOMENT
10015 ) {
10016 return None;
10017 }
10018 if let Some(count) = mesh_field_topology_expected_count(field, mesh) {
10019 return Some(count);
10020 }
10021
10022 match field.field_id.as_str() {
10023 FEA_FIELD_STRUCTURAL_DISPLACEMENT | FEA_FIELD_STRUCTURAL_NODAL_VON_MISES => {
10024 Some(mesh.nodes.len())
10025 }
10026 FEA_FIELD_STRUCTURAL_STRAIN
10027 | FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY
10028 | FEA_FIELD_STRUCTURAL_STRESS
10029 | FEA_FIELD_STRUCTURAL_VON_MISES => Some(mesh.volume_elements.len()),
10030 _ => None,
10031 }
10032}
10033
10034fn mesh_field_topology_expected_count(
10035 field: &AnalysisField,
10036 mesh: &AnalysisMeshArtifact,
10037) -> Option<usize> {
10038 let descriptor = AnalysisFieldDescriptor::from_field(field);
10039 let topology_id = descriptor.topology_id.as_deref()?;
10040 let location = mesh_field_topology_location(descriptor.location)?;
10041 let element_kind = descriptor.element_kind.as_deref();
10042
10043 mesh.field_topology
10044 .iter()
10045 .find(|topology| {
10046 topology.topology_id == topology_id
10047 && topology.location == location
10048 && topology_element_kind_matches(topology, element_kind)
10049 })
10050 .map(|topology| topology.entity_count)
10051}
10052
10053fn mesh_field_topology_location(
10054 location: AnalysisFieldLocation,
10055) -> Option<AnalysisFieldTopologyLocation> {
10056 match location {
10057 AnalysisFieldLocation::Node => Some(AnalysisFieldTopologyLocation::Node),
10058 AnalysisFieldLocation::Element => Some(AnalysisFieldTopologyLocation::VolumeElement),
10059 AnalysisFieldLocation::BoundaryFace => Some(AnalysisFieldTopologyLocation::BoundaryFace),
10060 _ => None,
10061 }
10062}
10063
10064fn topology_element_kind_matches(
10065 topology: &AnalysisFieldTopologyDescriptor,
10066 element_kind: Option<&str>,
10067) -> bool {
10068 match (
10069 topology.element_kind.as_deref(),
10070 normalized_mesh_element_kind(element_kind),
10071 ) {
10072 (None, None) => true,
10073 (Some(left), Some(right)) => left == right,
10074 (None, Some(_)) => false,
10075 (Some(_), None) => false,
10076 }
10077}
10078
10079fn normalized_mesh_element_kind(element_kind: Option<&str>) -> Option<&str> {
10080 match element_kind {
10081 Some("tetrahedron4") => Some(TETRAHEDRON4_FIELD_ELEMENT_KIND),
10082 Some(other) => Some(other),
10083 None => None,
10084 }
10085}
10086
10087fn descriptor_entity_count(field: &AnalysisField, descriptor: &AnalysisFieldDescriptor) -> usize {
10088 if descriptor.entity_count > 0 {
10089 return descriptor.entity_count;
10090 }
10091 if matches!(
10092 descriptor.location,
10093 AnalysisFieldLocation::Global | AnalysisFieldLocation::Mode
10094 ) {
10095 return field.element_count();
10096 }
10097 if let Some(first_dim) = field.shape.first().copied() {
10098 if field.shape.len() > 1 || descriptor.component_count.is_some() {
10099 return first_dim;
10100 }
10101 }
10102 field.element_count()
10103}
10104
10105pub fn analysis_run_electromagnetic_op(
10106 model: &AnalysisModel,
10107 backend: ComputeBackend,
10108 context: OperationContext,
10109) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
10110 analysis_run_electromagnetic_with_options_op(
10111 model,
10112 backend,
10113 AnalysisElectromagneticRunOptions::default(),
10114 context,
10115 )
10116}
10117
10118pub fn analysis_run_electromagnetic_with_options_op(
10119 model: &AnalysisModel,
10120 backend: ComputeBackend,
10121 options: AnalysisElectromagneticRunOptions,
10122 context: OperationContext,
10123) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
10124 let _solver_context = install_fea_solver_context();
10125 let has_electromagnetic_step = model
10126 .steps
10127 .iter()
10128 .any(|step| step.kind == AnalysisStepKind::Electromagnetic);
10129 if !has_electromagnetic_step {
10130 return Err(operation_error(
10131 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10132 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10133 &context,
10134 OperationErrorSpec {
10135 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.REQUIRES_STEP",
10136 error_type: OperationErrorType::Validation,
10137 retryable: false,
10138 severity: OperationErrorSeverity::Error,
10139 },
10140 "FEA model must include at least one electromagnetic step for fea.run_electromagnetic",
10141 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
10142 ));
10143 }
10144
10145 let Some(em_domain) = model.electromagnetic.as_ref() else {
10146 return Err(operation_error(
10147 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10148 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10149 &context,
10150 OperationErrorSpec {
10151 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_MODEL",
10152 error_type: OperationErrorType::Validation,
10153 retryable: false,
10154 severity: OperationErrorSeverity::Error,
10155 },
10156 "fea.run_electromagnetic requires model.electromagnetic to be configured",
10157 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
10158 ));
10159 };
10160 if !em_domain.enabled {
10161 return Err(operation_error(
10162 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10163 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10164 &context,
10165 OperationErrorSpec {
10166 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10167 error_type: OperationErrorType::Input,
10168 retryable: false,
10169 severity: OperationErrorSeverity::Error,
10170 },
10171 "fea.run_electromagnetic requires electromagnetic domain enabled=true",
10172 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
10173 ));
10174 }
10175 if !em_domain.reference_frequency_hz.is_finite() || em_domain.reference_frequency_hz <= 0.0 {
10176 return Err(operation_error(
10177 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10178 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10179 &context,
10180 OperationErrorSpec {
10181 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10182 error_type: OperationErrorType::Input,
10183 retryable: false,
10184 severity: OperationErrorSeverity::Error,
10185 },
10186 "fea.run_electromagnetic requires finite positive reference_frequency_hz",
10187 BTreeMap::from([(
10188 "reference_frequency_hz".to_string(),
10189 em_domain.reference_frequency_hz.to_string(),
10190 )]),
10191 ));
10192 }
10193 if !em_domain.applied_current_a.is_finite() || em_domain.applied_current_a <= 0.0 {
10194 return Err(operation_error(
10195 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10196 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10197 &context,
10198 OperationErrorSpec {
10199 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10200 error_type: OperationErrorType::Input,
10201 retryable: false,
10202 severity: OperationErrorSeverity::Error,
10203 },
10204 "fea.run_electromagnetic requires finite positive applied_current_a",
10205 BTreeMap::from([(
10206 "applied_current_a".to_string(),
10207 em_domain.applied_current_a.to_string(),
10208 )]),
10209 ));
10210 }
10211 if !options.residual_target.is_finite() || options.residual_target <= 0.0 {
10212 return Err(operation_error(
10213 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10214 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10215 &context,
10216 OperationErrorSpec {
10217 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10218 error_type: OperationErrorType::Input,
10219 retryable: false,
10220 severity: OperationErrorSeverity::Error,
10221 },
10222 "fea.run_electromagnetic requires residual_target to be finite and positive",
10223 BTreeMap::from([(
10224 "residual_target".to_string(),
10225 options.residual_target.to_string(),
10226 )]),
10227 ));
10228 }
10229 if !options.harmonic_tolerance.is_finite() || options.harmonic_tolerance <= 0.0 {
10230 return Err(operation_error(
10231 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10232 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10233 &context,
10234 OperationErrorSpec {
10235 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10236 error_type: OperationErrorType::Input,
10237 retryable: false,
10238 severity: OperationErrorSeverity::Error,
10239 },
10240 "fea.run_electromagnetic requires harmonic_tolerance to be finite and positive",
10241 BTreeMap::from([(
10242 "harmonic_tolerance".to_string(),
10243 options.harmonic_tolerance.to_string(),
10244 )]),
10245 ));
10246 }
10247 if options.harmonic_max_iterations == 0 {
10248 return Err(operation_error(
10249 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10250 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10251 &context,
10252 OperationErrorSpec {
10253 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10254 error_type: OperationErrorType::Input,
10255 retryable: false,
10256 severity: OperationErrorSeverity::Error,
10257 },
10258 "fea.run_electromagnetic requires harmonic_max_iterations greater than zero",
10259 BTreeMap::from([(
10260 "harmonic_max_iterations".to_string(),
10261 options.harmonic_max_iterations.to_string(),
10262 )]),
10263 ));
10264 }
10265 validate_electromagnetic_run_model(model, &context)?;
10266
10267 let prep_context = resolve_run_prep_context(
10268 model,
10269 options.prep_artifact_id.as_deref(),
10270 options.prep_context.clone(),
10271 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10272 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10273 &context,
10274 )?;
10275
10276 let sweep_frequency_hz = normalize_em_sweep_frequency_hz(
10277 em_domain.reference_frequency_hz,
10278 options.sweep_enabled,
10279 &options.sweep_frequency_hz,
10280 )
10281 .ok_or_else(|| {
10282 operation_error(
10283 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10284 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10285 &context,
10286 OperationErrorSpec {
10287 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10288 error_type: OperationErrorType::Input,
10289 retryable: false,
10290 severity: OperationErrorSeverity::Error,
10291 },
10292 "fea.run_electromagnetic sweep_frequency_hz must contain finite positive values",
10293 BTreeMap::new(),
10294 )
10295 })?;
10296 let solve_options = ElectromagneticSolveOptions {
10297 prep_context: to_fea_prep_context(prep_context.as_ref(), options.prep_calibration_profile),
10298 residual_target: options.residual_target,
10299 harmonic_tolerance: options.harmonic_tolerance,
10300 harmonic_max_iterations: options.harmonic_max_iterations,
10301 };
10302 let mut sweep_runs = Vec::with_capacity(sweep_frequency_hz.len());
10303 let mut sweep_peak_flux_density = Vec::with_capacity(sweep_frequency_hz.len());
10304 let mut sweep_solve_quality = Vec::with_capacity(sweep_frequency_hz.len());
10305 for frequency_hz in &sweep_frequency_hz {
10306 let mut sweep_model = model.clone();
10307 if let Some(domain) = sweep_model.electromagnetic.as_mut() {
10308 domain.reference_frequency_hz = *frequency_hz;
10309 }
10310 let sweep_run =
10311 run_electromagnetic_with_options(&sweep_model, backend, solve_options.clone())
10312 .map_err(|err| {
10313 map_fea_run_error(
10314 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10315 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10316 "RM.FEA.RUN_ELECTROMAGNETIC.SOLVER_MODEL_INVALID",
10317 "RM.FEA.RUN_ELECTROMAGNETIC.CANCELLED",
10318 model,
10319 &context,
10320 err,
10321 )
10322 })?;
10323 sweep_peak_flux_density.push(peak_abs_field_value(
10324 &sweep_run.magnetic_flux_density_magnitude_field,
10325 ));
10326 sweep_solve_quality.push(sweep_run.solve_quality);
10327 sweep_runs.push(sweep_run);
10328 }
10329 let sweep_metrics = summarize_em_sweep(&sweep_frequency_hz, &sweep_peak_flux_density);
10330 let primary_index =
10331 nearest_frequency_index(&sweep_frequency_hz, em_domain.reference_frequency_hz).unwrap_or(0);
10332 let em_run = sweep_runs[primary_index].clone();
10333 let mut run = em_run.run.clone();
10334 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
10335 code: "FEA_EM_SWEEP".to_string(),
10336 severity: if sweep_metrics.sweep_count > 1 {
10337 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
10338 } else {
10339 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
10340 },
10341 message: format!(
10342 "sweep_count={} resonance_peak_frequency_hz={} resonance_peak_flux_density={} resonance_bandwidth_hz={} resonance_quality_factor={} resonance_flux_gain={}",
10343 sweep_metrics.sweep_count,
10344 sweep_metrics.resonance_peak_frequency_hz.unwrap_or(0.0),
10345 sweep_metrics.resonance_peak_flux_density.unwrap_or(0.0),
10346 sweep_metrics.resonance_bandwidth_hz.unwrap_or(0.0),
10347 sweep_metrics.resonance_quality_factor.unwrap_or(0.0),
10348 sweep_metrics.resonance_flux_gain.unwrap_or(0.0),
10349 ),
10350 });
10351 if sweep_metrics.sweep_count > 1 {
10352 run.diagnostics.push(em_sweep_known_answer_diagnostic(
10353 em_domain.reference_frequency_hz,
10354 &sweep_frequency_hz,
10355 &sweep_metrics,
10356 ));
10357 }
10358 let solver_convergence = if run.diagnostics.iter().any(|diag| {
10359 diag.code == "FEA_EM_STATIC"
10360 && diag.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
10361 }) {
10362 QualityGate::Pass
10363 } else {
10364 QualityGate::Warn
10365 };
10366 let mut result_quality = if em_run.solve_quality >= 0.85 {
10367 QualityGate::Pass
10368 } else if em_run.solve_quality >= 0.6 {
10369 QualityGate::Warn
10370 } else {
10371 QualityGate::Fail
10372 };
10373 let mut quality_reasons = Vec::new();
10374 let em_conductivity_spread_ratio = diagnostic_metric(
10375 &run.diagnostics,
10376 "FEA_EM_STATIC",
10377 "conductivity_spread_ratio",
10378 );
10379 let em_assignment_heterogeneity_index = diagnostic_metric(
10380 &run.diagnostics,
10381 "FEA_EM_STATIC",
10382 "electromagnetic_material_heterogeneity_index",
10383 );
10384 let em_assignment_coverage_ratio = diagnostic_metric(
10385 &run.diagnostics,
10386 "FEA_EM_STATIC",
10387 "assignment_coverage_ratio",
10388 );
10389 let em_assigned_coefficient_coverage_ratio = diagnostic_metric(
10390 &run.diagnostics,
10391 "FEA_EM_STATIC",
10392 "assigned_coefficient_coverage_ratio",
10393 );
10394 let em_region_contrast_index = diagnostic_metric(
10395 &run.diagnostics,
10396 "FEA_EM_STATIC",
10397 "region_coefficient_contrast_index",
10398 );
10399 let em_condition_number_estimate = diagnostic_metric(
10400 &run.diagnostics,
10401 "FEA_EM_STATIC",
10402 "condition_number_estimate",
10403 );
10404 let em_source_realization_ratio = diagnostic_metric(
10405 &run.diagnostics,
10406 "FEA_EM_SOURCE_ENERGY",
10407 "source_realization_ratio",
10408 );
10409 let em_source_region_coverage_ratio = diagnostic_metric(
10410 &run.diagnostics,
10411 "FEA_EM_SOURCE_ENERGY",
10412 "source_region_coverage_ratio",
10413 );
10414 let em_source_material_alignment_ratio = diagnostic_metric(
10415 &run.diagnostics,
10416 "FEA_EM_SOURCE_ENERGY",
10417 "source_material_alignment_ratio",
10418 );
10419 let em_source_overlap_ratio = diagnostic_metric(
10420 &run.diagnostics,
10421 "FEA_EM_SOURCE_ENERGY",
10422 "source_overlap_ratio",
10423 );
10424 let em_source_interference_index = diagnostic_metric(
10425 &run.diagnostics,
10426 "FEA_EM_SOURCE_ENERGY",
10427 "source_interference_index",
10428 );
10429 let em_boundary_anchor_ratio = diagnostic_metric(
10430 &run.diagnostics,
10431 "FEA_EM_SOURCE_ENERGY",
10432 "boundary_anchor_ratio",
10433 );
10434 let em_boundary_condition_localization_ratio = diagnostic_metric(
10435 &run.diagnostics,
10436 "FEA_EM_SOURCE_ENERGY",
10437 "boundary_condition_localization_ratio",
10438 );
10439 let em_ground_anchor_effectiveness_ratio = diagnostic_metric(
10440 &run.diagnostics,
10441 "FEA_EM_SOURCE_ENERGY",
10442 "ground_anchor_effectiveness_ratio",
10443 );
10444 let em_insulation_leakage_ratio = diagnostic_metric(
10445 &run.diagnostics,
10446 "FEA_EM_SOURCE_ENERGY",
10447 "insulation_leakage_ratio",
10448 );
10449 let em_flux_divergence_ratio =
10450 diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "flux_divergence_ratio");
10451 let em_energy_imbalance_ratio = diagnostic_metric(
10452 &run.diagnostics,
10453 "FEA_EM_SOURCE_ENERGY",
10454 "energy_imbalance_ratio",
10455 );
10456 let em_boundary_energy_ratio = diagnostic_metric(
10457 &run.diagnostics,
10458 "FEA_EM_SOURCE_ENERGY",
10459 "boundary_energy_ratio",
10460 );
10461 let em_boundary_penalty_conditioning_contribution = diagnostic_metric(
10462 &run.diagnostics,
10463 "FEA_EM_SOURCE_ENERGY",
10464 "boundary_penalty_conditioning_contribution",
10465 );
10466 let em_source_region_energy_consistency_ratio = diagnostic_metric(
10467 &run.diagnostics,
10468 "FEA_EM_SOURCE_ENERGY",
10469 "source_region_energy_consistency_ratio",
10470 );
10471 let em_real_residual_norm =
10472 diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "real_residual_norm");
10473 let em_imag_residual_norm =
10474 diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "imag_residual_norm");
10475 let em_sweep_count = diagnostic_metric(&run.diagnostics, "FEA_EM_SWEEP", "sweep_count");
10476 let em_resonance_quality_factor =
10477 diagnostic_metric(&run.diagnostics, "FEA_EM_SWEEP", "resonance_quality_factor");
10478 let ElectromagneticQualityThresholds {
10479 em_spread_threshold,
10480 em_heterogeneity_threshold,
10481 em_coverage_min_threshold,
10482 em_contrast_max_threshold,
10483 em_conditioning_max_threshold,
10484 em_source_realization_min_threshold,
10485 em_source_region_coverage_min_threshold,
10486 em_source_material_alignment_min_threshold,
10487 em_source_overlap_max_threshold,
10488 em_source_interference_max_threshold,
10489 em_boundary_anchor_min_threshold,
10490 em_boundary_localization_min_threshold,
10491 em_ground_effectiveness_min_threshold,
10492 em_insulation_leakage_max_threshold,
10493 em_divergence_max_threshold,
10494 em_energy_imbalance_max_threshold,
10495 em_boundary_energy_min_threshold,
10496 em_boundary_penalty_contribution_max_threshold,
10497 em_source_region_energy_consistency_min_threshold,
10498 em_real_residual_max_threshold,
10499 em_imag_residual_max_threshold,
10500 } = electromagnetic_thresholds_for_policy(options.quality_policy);
10501 let (em_sweep_count_min_threshold, em_resonance_q_min_threshold) =
10502 electromagnetic_sweep_thresholds_for_policy(options.quality_policy);
10503 let em_spread_breach = em_conductivity_spread_ratio
10504 .map(|value| value > em_spread_threshold)
10505 .unwrap_or(false);
10506 let em_heterogeneity_breach = em_assignment_heterogeneity_index
10507 .map(|value| value > em_heterogeneity_threshold)
10508 .unwrap_or(false);
10509 let em_coverage_breach = em_assignment_coverage_ratio
10510 .map(|value| value < em_coverage_min_threshold)
10511 .unwrap_or(false);
10512 let em_assigned_coefficient_breach = em_assigned_coefficient_coverage_ratio
10513 .map(|value| value < em_coverage_min_threshold)
10514 .unwrap_or(false);
10515 let em_contrast_breach = em_region_contrast_index
10516 .map(|value| value > em_contrast_max_threshold)
10517 .unwrap_or(false);
10518 let em_conditioning_breach = em_condition_number_estimate
10519 .map(|value| value > em_conditioning_max_threshold)
10520 .unwrap_or(false);
10521 let em_source_realization_breach = em_source_realization_ratio
10522 .map(|value| value < em_source_realization_min_threshold)
10523 .unwrap_or(false);
10524 let em_source_region_coverage_breach = em_source_region_coverage_ratio
10525 .map(|value| value < em_source_region_coverage_min_threshold)
10526 .unwrap_or(false);
10527 let em_source_material_alignment_breach = em_source_material_alignment_ratio
10528 .map(|value| value < em_source_material_alignment_min_threshold)
10529 .unwrap_or(false);
10530 let em_source_overlap_breach = em_source_overlap_ratio
10531 .map(|value| value > em_source_overlap_max_threshold)
10532 .unwrap_or(false);
10533 let em_source_interference_breach = em_source_interference_index
10534 .map(|value| value > em_source_interference_max_threshold)
10535 .unwrap_or(false);
10536 let em_boundary_anchor_breach = em_boundary_anchor_ratio
10537 .map(|value| value < em_boundary_anchor_min_threshold)
10538 .unwrap_or(false);
10539 let em_boundary_localization_breach = em_boundary_condition_localization_ratio
10540 .map(|value| value < em_boundary_localization_min_threshold)
10541 .unwrap_or(false);
10542 let em_ground_effectiveness_breach = em_ground_anchor_effectiveness_ratio
10543 .map(|value| value < em_ground_effectiveness_min_threshold)
10544 .unwrap_or(false);
10545 let em_insulation_leakage_breach = em_insulation_leakage_ratio
10546 .map(|value| value > em_insulation_leakage_max_threshold)
10547 .unwrap_or(false);
10548 let em_divergence_breach = em_flux_divergence_ratio
10549 .map(|value| value > em_divergence_max_threshold)
10550 .unwrap_or(false);
10551 let em_energy_imbalance_breach = em_energy_imbalance_ratio
10552 .map(|value| value > em_energy_imbalance_max_threshold)
10553 .unwrap_or(false);
10554 let em_boundary_energy_breach = em_boundary_energy_ratio
10555 .map(|value| value < em_boundary_energy_min_threshold)
10556 .unwrap_or(false);
10557 let em_boundary_penalty_contribution_breach = em_boundary_penalty_conditioning_contribution
10558 .map(|value| value > em_boundary_penalty_contribution_max_threshold)
10559 .unwrap_or(false);
10560 let em_source_region_energy_consistency_breach = em_source_region_energy_consistency_ratio
10561 .map(|value| value < em_source_region_energy_consistency_min_threshold)
10562 .unwrap_or(false);
10563 let em_real_residual_breach = em_real_residual_norm
10564 .map(|value| value > em_real_residual_max_threshold)
10565 .unwrap_or(false);
10566 let em_imag_residual_breach = em_imag_residual_norm
10567 .map(|value| value > em_imag_residual_max_threshold)
10568 .unwrap_or(false);
10569 let sweep_governance_active = options.sweep_enabled || !options.sweep_frequency_hz.is_empty();
10570 let em_sweep_coverage_breach = sweep_governance_active
10571 && em_sweep_count
10572 .map(|value| value < em_sweep_count_min_threshold)
10573 .unwrap_or(false);
10574 let em_resonance_sharpness_breach = sweep_governance_active
10575 && em_resonance_quality_factor
10576 .map(|value| value < em_resonance_q_min_threshold)
10577 .unwrap_or(false);
10578 if (em_spread_breach
10579 || em_heterogeneity_breach
10580 || em_coverage_breach
10581 || em_assigned_coefficient_breach
10582 || em_contrast_breach
10583 || em_conditioning_breach
10584 || em_source_realization_breach
10585 || em_source_region_coverage_breach
10586 || em_source_material_alignment_breach
10587 || em_source_overlap_breach
10588 || em_source_interference_breach
10589 || em_boundary_anchor_breach
10590 || em_boundary_localization_breach
10591 || em_ground_effectiveness_breach
10592 || em_insulation_leakage_breach
10593 || em_divergence_breach
10594 || em_energy_imbalance_breach
10595 || em_boundary_energy_breach
10596 || em_boundary_penalty_contribution_breach
10597 || em_source_region_energy_consistency_breach
10598 || em_real_residual_breach
10599 || em_imag_residual_breach
10600 || em_sweep_coverage_breach
10601 || em_resonance_sharpness_breach)
10602 && result_quality == QualityGate::Pass
10603 {
10604 result_quality = QualityGate::Warn;
10605 }
10606 if solver_convergence == QualityGate::Warn {
10607 quality_reasons.push(QualityReason {
10608 code: QualityReasonCode::SolverNotConverged,
10609 detail: "electromagnetic solver convergence gate is warning".to_string(),
10610 });
10611 }
10612 if result_quality != QualityGate::Pass {
10613 quality_reasons.push(QualityReason {
10614 code: QualityReasonCode::ElectromagneticSolveQualityLow,
10615 detail: "electromagnetic static solve quality below target".to_string(),
10616 });
10617 }
10618 if em_spread_breach {
10619 quality_reasons.push(QualityReason {
10620 code: QualityReasonCode::ElectromagneticConductivitySpreadHigh,
10621 detail: format!(
10622 "electromagnetic conductivity spread ratio {} exceeds threshold {}",
10623 em_conductivity_spread_ratio.unwrap_or(0.0),
10624 em_spread_threshold
10625 ),
10626 });
10627 }
10628 if em_heterogeneity_breach {
10629 quality_reasons.push(QualityReason {
10630 code: QualityReasonCode::ElectromagneticMaterialHeterogeneityHigh,
10631 detail: format!(
10632 "electromagnetic material heterogeneity index {} exceeds threshold {}",
10633 em_assignment_heterogeneity_index.unwrap_or(0.0),
10634 em_heterogeneity_threshold
10635 ),
10636 });
10637 }
10638 if em_coverage_breach {
10639 quality_reasons.push(QualityReason {
10640 code: QualityReasonCode::ElectromagneticAssignmentCoverageLow,
10641 detail: format!(
10642 "electromagnetic assignment coverage ratio {} is below threshold {}",
10643 em_assignment_coverage_ratio.unwrap_or(0.0),
10644 em_coverage_min_threshold
10645 ),
10646 });
10647 }
10648 if em_assigned_coefficient_breach {
10649 quality_reasons.push(QualityReason {
10650 code: QualityReasonCode::ElectromagneticAssignmentCoverageLow,
10651 detail: format!(
10652 "electromagnetic assigned coefficient coverage ratio {} is below threshold {}",
10653 em_assigned_coefficient_coverage_ratio.unwrap_or(0.0),
10654 em_coverage_min_threshold
10655 ),
10656 });
10657 }
10658 if em_contrast_breach {
10659 quality_reasons.push(QualityReason {
10660 code: QualityReasonCode::ElectromagneticRegionContrastHigh,
10661 detail: format!(
10662 "electromagnetic region coefficient contrast index {} exceeds threshold {}",
10663 em_region_contrast_index.unwrap_or(0.0),
10664 em_contrast_max_threshold
10665 ),
10666 });
10667 }
10668 if em_conditioning_breach {
10669 quality_reasons.push(QualityReason {
10670 code: QualityReasonCode::ElectromagneticConditioningHigh,
10671 detail: format!(
10672 "electromagnetic condition-number estimate {} exceeds threshold {}",
10673 em_condition_number_estimate.unwrap_or(0.0),
10674 em_conditioning_max_threshold
10675 ),
10676 });
10677 }
10678 if em_source_realization_breach {
10679 quality_reasons.push(QualityReason {
10680 code: QualityReasonCode::ElectromagneticSourceRealizationLow,
10681 detail: format!(
10682 "electromagnetic source realization ratio {} is below threshold {}",
10683 em_source_realization_ratio.unwrap_or(0.0),
10684 em_source_realization_min_threshold
10685 ),
10686 });
10687 }
10688 if em_source_region_coverage_breach {
10689 quality_reasons.push(QualityReason {
10690 code: QualityReasonCode::ElectromagneticSourceRegionCoverageLow,
10691 detail: format!(
10692 "electromagnetic source region coverage ratio {} is below threshold {}",
10693 em_source_region_coverage_ratio.unwrap_or(0.0),
10694 em_source_region_coverage_min_threshold
10695 ),
10696 });
10697 }
10698 if em_source_material_alignment_breach {
10699 quality_reasons.push(QualityReason {
10700 code: QualityReasonCode::ElectromagneticSourceMaterialAlignmentLow,
10701 detail: format!(
10702 "electromagnetic source material alignment ratio {} is below threshold {}",
10703 em_source_material_alignment_ratio.unwrap_or(0.0),
10704 em_source_material_alignment_min_threshold
10705 ),
10706 });
10707 }
10708 if em_source_overlap_breach {
10709 quality_reasons.push(QualityReason {
10710 code: QualityReasonCode::ElectromagneticSourceOverlapHigh,
10711 detail: format!(
10712 "electromagnetic source overlap ratio {} exceeds threshold {}",
10713 em_source_overlap_ratio.unwrap_or(0.0),
10714 em_source_overlap_max_threshold
10715 ),
10716 });
10717 }
10718 if em_source_interference_breach {
10719 quality_reasons.push(QualityReason {
10720 code: QualityReasonCode::ElectromagneticSourceInterferenceHigh,
10721 detail: format!(
10722 "electromagnetic source interference index {} exceeds threshold {}",
10723 em_source_interference_index.unwrap_or(0.0),
10724 em_source_interference_max_threshold
10725 ),
10726 });
10727 }
10728 if em_boundary_anchor_breach {
10729 quality_reasons.push(QualityReason {
10730 code: QualityReasonCode::ElectromagneticBoundaryAnchoringLow,
10731 detail: format!(
10732 "electromagnetic boundary anchor ratio {} is below threshold {}",
10733 em_boundary_anchor_ratio.unwrap_or(0.0),
10734 em_boundary_anchor_min_threshold
10735 ),
10736 });
10737 }
10738 if em_boundary_localization_breach {
10739 quality_reasons.push(QualityReason {
10740 code: QualityReasonCode::ElectromagneticBoundaryLocalizationLow,
10741 detail: format!(
10742 "electromagnetic boundary condition localization ratio {} is below threshold {}",
10743 em_boundary_condition_localization_ratio.unwrap_or(0.0),
10744 em_boundary_localization_min_threshold
10745 ),
10746 });
10747 }
10748 if em_ground_effectiveness_breach {
10749 quality_reasons.push(QualityReason {
10750 code: QualityReasonCode::ElectromagneticGroundAnchorEffectivenessLow,
10751 detail: format!(
10752 "electromagnetic ground anchor effectiveness ratio {} is below threshold {}",
10753 em_ground_anchor_effectiveness_ratio.unwrap_or(0.0),
10754 em_ground_effectiveness_min_threshold
10755 ),
10756 });
10757 }
10758 if em_insulation_leakage_breach {
10759 quality_reasons.push(QualityReason {
10760 code: QualityReasonCode::ElectromagneticInsulationLeakageHigh,
10761 detail: format!(
10762 "electromagnetic insulation leakage ratio {} exceeds threshold {}",
10763 em_insulation_leakage_ratio.unwrap_or(0.0),
10764 em_insulation_leakage_max_threshold
10765 ),
10766 });
10767 }
10768 if em_divergence_breach {
10769 quality_reasons.push(QualityReason {
10770 code: QualityReasonCode::ElectromagneticFluxDivergenceHigh,
10771 detail: format!(
10772 "electromagnetic flux divergence ratio {} exceeds threshold {}",
10773 em_flux_divergence_ratio.unwrap_or(0.0),
10774 em_divergence_max_threshold
10775 ),
10776 });
10777 }
10778 if em_energy_imbalance_breach {
10779 quality_reasons.push(QualityReason {
10780 code: QualityReasonCode::ElectromagneticEnergyImbalanceHigh,
10781 detail: format!(
10782 "electromagnetic energy imbalance ratio {} exceeds threshold {}",
10783 em_energy_imbalance_ratio.unwrap_or(0.0),
10784 em_energy_imbalance_max_threshold
10785 ),
10786 });
10787 }
10788 if em_boundary_energy_breach {
10789 quality_reasons.push(QualityReason {
10790 code: QualityReasonCode::ElectromagneticBoundaryEnergyLow,
10791 detail: format!(
10792 "electromagnetic boundary energy ratio {} is below threshold {}",
10793 em_boundary_energy_ratio.unwrap_or(0.0),
10794 em_boundary_energy_min_threshold
10795 ),
10796 });
10797 }
10798 if em_boundary_penalty_contribution_breach {
10799 quality_reasons.push(QualityReason {
10800 code: QualityReasonCode::ElectromagneticBoundaryPenaltyConditioningHigh,
10801 detail: format!(
10802 "electromagnetic boundary penalty conditioning contribution {} exceeds threshold {}",
10803 em_boundary_penalty_conditioning_contribution.unwrap_or(0.0),
10804 em_boundary_penalty_contribution_max_threshold
10805 ),
10806 });
10807 }
10808 if em_source_region_energy_consistency_breach {
10809 quality_reasons.push(QualityReason {
10810 code: QualityReasonCode::ElectromagneticSourceRegionEnergyConsistencyLow,
10811 detail: format!(
10812 "electromagnetic source-region energy consistency ratio {} is below threshold {}",
10813 em_source_region_energy_consistency_ratio.unwrap_or(0.0),
10814 em_source_region_energy_consistency_min_threshold
10815 ),
10816 });
10817 }
10818 if em_real_residual_breach {
10819 quality_reasons.push(QualityReason {
10820 code: QualityReasonCode::ElectromagneticRealResidualHigh,
10821 detail: format!(
10822 "electromagnetic real residual norm {} exceeds threshold {}",
10823 em_real_residual_norm.unwrap_or(0.0),
10824 em_real_residual_max_threshold
10825 ),
10826 });
10827 }
10828 if em_imag_residual_breach {
10829 quality_reasons.push(QualityReason {
10830 code: QualityReasonCode::ElectromagneticImagResidualHigh,
10831 detail: format!(
10832 "electromagnetic imaginary residual norm {} exceeds threshold {}",
10833 em_imag_residual_norm.unwrap_or(0.0),
10834 em_imag_residual_max_threshold
10835 ),
10836 });
10837 }
10838 if em_sweep_coverage_breach {
10839 quality_reasons.push(QualityReason {
10840 code: QualityReasonCode::ElectromagneticSweepCoverageLow,
10841 detail: format!(
10842 "electromagnetic sweep count {} is below threshold {}",
10843 em_sweep_count.unwrap_or(0.0),
10844 em_sweep_count_min_threshold
10845 ),
10846 });
10847 }
10848 if em_resonance_sharpness_breach {
10849 quality_reasons.push(QualityReason {
10850 code: QualityReasonCode::ElectromagneticResonanceSharpnessLow,
10851 detail: format!(
10852 "electromagnetic resonance quality factor {} is below threshold {}",
10853 em_resonance_quality_factor.unwrap_or(0.0),
10854 em_resonance_q_min_threshold
10855 ),
10856 });
10857 }
10858
10859 let publishable = match options.quality_policy {
10860 QualityPolicy::Strict => {
10861 solver_convergence == QualityGate::Pass
10862 && result_quality == QualityGate::Pass
10863 && quality_reasons.is_empty()
10864 }
10865 QualityPolicy::Balanced => {
10866 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
10867 }
10868 QualityPolicy::Exploratory => {
10869 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
10870 }
10871 };
10872 let run_status = if publishable {
10873 RunStatus::Publishable
10874 } else if result_quality == QualityGate::Fail {
10875 RunStatus::Rejected
10876 } else {
10877 RunStatus::Degraded
10878 };
10879 let solver_backend = run.solver_backend.clone();
10880 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
10881 let solver_host_sync_count = run.solver_host_sync_count;
10882 let solver_method = run.solver_method.clone();
10883 let preconditioner = run.preconditioner.clone();
10884
10885 let result = AnalysisRunResult {
10886 run_id: storage::next_run_id(),
10887 run,
10888 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
10889 modal_results: None,
10890 thermal_results: None,
10891 transient_results: None,
10892 nonlinear_results: None,
10893 electromagnetic_results: Some(ElectromagneticResultsData {
10894 electromagnetic_payload_version: "electromagnetic_results/v1".to_string(),
10895 reference_frequency_hz: em_run.reference_frequency_hz,
10896 applied_current_a: em_run.applied_current_a,
10897 vector_potential_real: em_run.vector_potential_real_field,
10898 vector_potential_imag: em_run.vector_potential_imag_field,
10899 magnetic_flux_density_real: em_run.magnetic_flux_density_real_field,
10900 magnetic_flux_density_imag: em_run.magnetic_flux_density_imag_field,
10901 magnetic_flux_density_magnitude: em_run.magnetic_flux_density_magnitude_field,
10902 magnetic_field_real: em_run.magnetic_field_real_field,
10903 magnetic_field_imag: em_run.magnetic_field_imag_field,
10904 current_density_real: em_run.current_density_real_field,
10905 current_density_imag: em_run.current_density_imag_field,
10906 electric_field_real: em_run.electric_field_real_field,
10907 electric_field_imag: em_run.electric_field_imag_field,
10908 power_loss_density: em_run.power_loss_density_field,
10909 energy_density: em_run.energy_density_field,
10910 residual_real: em_run.residual_real_field,
10911 residual_imag: em_run.residual_imag_field,
10912 electric_flux_density_real: em_run.electric_flux_density_real_field,
10913 electric_flux_density_imag: em_run.electric_flux_density_imag_field,
10914 poynting_vector_real: em_run.poynting_vector_real_field,
10915 poynting_vector_imag: em_run.poynting_vector_imag_field,
10916 sweep_frequency_hz,
10917 sweep_peak_flux_density,
10918 sweep_solve_quality,
10919 resonance_peak_frequency_hz: sweep_metrics.resonance_peak_frequency_hz,
10920 resonance_peak_flux_density: sweep_metrics.resonance_peak_flux_density,
10921 resonance_bandwidth_hz: sweep_metrics.resonance_bandwidth_hz,
10922 resonance_quality_factor: sweep_metrics.resonance_quality_factor,
10923 resonance_flux_gain: sweep_metrics.resonance_flux_gain,
10924 }),
10925 model_validity: QualityGate::Pass,
10926 solver_convergence,
10927 result_quality,
10928 run_status,
10929 publishable,
10930 quality_reasons,
10931 provenance: RunProvenance {
10932 backend,
10933 solver_backend,
10934 solver_device_apply_k_ratio,
10935 solver_host_sync_count,
10936 precision_mode: contracts::format_precision_mode(options.precision_mode),
10937 deterministic_mode: options.deterministic_mode,
10938 solver_method,
10939 preconditioner,
10940 quality_policy: contracts::format_quality_policy(options.quality_policy),
10941 fallback_events: Vec::new(),
10942 },
10943 };
10944
10945 persist_fea_run_result_with_progress(
10946 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10947 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10948 "RM.FEA.RUN_ELECTROMAGNETIC.ARTIFACT_STORE_FAILED",
10949 &context,
10950 &result,
10951 )?;
10952
10953 Ok(OperationEnvelope::new(
10954 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10955 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10956 &context,
10957 result,
10958 ))
10959}
10960
10961fn validate_electromagnetic_run_model(
10962 model: &AnalysisModel,
10963 context: &OperationContext,
10964) -> Result<(), OperationErrorEnvelope> {
10965 if let Some(material) = model.materials.iter().find(|material| {
10966 material.electrical.as_ref().is_some_and(|electrical| {
10967 !electrical.conductivity_s_per_m.is_finite()
10968 || electrical.conductivity_s_per_m <= 0.0
10969 || !electrical.relative_permittivity.is_finite()
10970 || electrical.relative_permittivity <= 0.0
10971 || !electrical.relative_permeability.is_finite()
10972 || electrical.relative_permeability <= 0.0
10973 || electrical
10974 .conductivity_frequency_response
10975 .iter()
10976 .any(|point| {
10977 !point.frequency_hz.is_finite()
10978 || point.frequency_hz <= 0.0
10979 || !point.conductivity_scale.is_finite()
10980 || point.conductivity_scale <= 0.0
10981 || point
10982 .dispersive_loss_scale
10983 .is_some_and(|value| !value.is_finite() || value < 0.0)
10984 || point
10985 .relative_permittivity_scale
10986 .is_some_and(|value| !value.is_finite() || value <= 0.0)
10987 || point
10988 .relative_permeability_scale
10989 .is_some_and(|value| !value.is_finite() || value <= 0.0)
10990 })
10991 })
10992 }) {
10993 return Err(operation_error(
10994 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10995 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10996 context,
10997 OperationErrorSpec {
10998 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_ELECTROMAGNETIC_MATERIAL",
10999 error_type: OperationErrorType::Validation,
11000 retryable: false,
11001 severity: OperationErrorSeverity::Error,
11002 },
11003 "fea.run_electromagnetic requires finite positive electrical material coefficients",
11004 BTreeMap::from([
11005 ("analysis_model_id".to_string(), model.model_id.0.clone()),
11006 ("material_id".to_string(), material.material_id.clone()),
11007 ]),
11008 ));
11009 }
11010
11011 let electrical_material_count = model
11012 .materials
11013 .iter()
11014 .filter(|material| material.electrical.is_some())
11015 .count();
11016 if electrical_material_count == 0 {
11017 return Err(operation_error(
11018 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11019 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11020 context,
11021 OperationErrorSpec {
11022 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_MATERIAL",
11023 error_type: OperationErrorType::Validation,
11024 retryable: false,
11025 severity: OperationErrorSeverity::Error,
11026 },
11027 "fea.run_electromagnetic requires at least one electrical material",
11028 BTreeMap::from([
11029 ("analysis_model_id".to_string(), model.model_id.0.clone()),
11030 (
11031 "material_count".to_string(),
11032 model.materials.len().to_string(),
11033 ),
11034 ]),
11035 ));
11036 }
11037 let electrical_material_by_id = model
11038 .materials
11039 .iter()
11040 .filter(|material| material.electrical.is_some())
11041 .map(|material| material.material_id.as_str())
11042 .collect::<std::collections::BTreeSet<_>>();
11043 if let Some(assignment) = model.material_assignments.iter().find(|assignment| {
11044 !electrical_material_by_id.contains(assignment.assigned_material_id.as_str())
11045 }) {
11046 return Err(operation_error(
11047 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11048 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11049 context,
11050 OperationErrorSpec {
11051 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_MATERIAL",
11052 error_type: OperationErrorType::Validation,
11053 retryable: false,
11054 severity: OperationErrorSeverity::Error,
11055 },
11056 "fea.run_electromagnetic requires every material assignment to reference an assigned electrical material",
11057 BTreeMap::from([
11058 ("analysis_model_id".to_string(), model.model_id.0.clone()),
11059 ("region_id".to_string(), assignment.region_id.clone()),
11060 (
11061 "assigned_material_id".to_string(),
11062 assignment.assigned_material_id.clone(),
11063 ),
11064 ]),
11065 ));
11066 }
11067
11068 reject_moment_loads_for_run_family(
11069 model,
11070 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11071 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11072 "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_ELECTROMAGNETIC_SOURCE",
11073 "electromagnetic",
11074 context,
11075 )?;
11076
11077 let has_electromagnetic_source = model.loads.iter().any(|load| match &load.kind {
11078 LoadKind::CurrentDensity {
11079 jx,
11080 jy,
11081 jz,
11082 phase_rad,
11083 amplitude_scale,
11084 } => {
11085 jx.is_finite()
11086 && jy.is_finite()
11087 && jz.is_finite()
11088 && phase_rad.is_finite()
11089 && amplitude_scale.is_finite()
11090 && *amplitude_scale > 0.0
11091 && (jx.abs() + jy.abs() + jz.abs()) > 0.0
11092 }
11093 LoadKind::CoilCurrent {
11094 current_a,
11095 phase_rad,
11096 amplitude_scale,
11097 } => {
11098 current_a.is_finite()
11099 && current_a.abs() > 0.0
11100 && phase_rad.is_finite()
11101 && amplitude_scale.is_finite()
11102 && *amplitude_scale > 0.0
11103 }
11104 _ => false,
11105 });
11106 if !has_electromagnetic_source {
11107 return Err(operation_error(
11108 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11109 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11110 context,
11111 OperationErrorSpec {
11112 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_SOURCE",
11113 error_type: OperationErrorType::Validation,
11114 retryable: false,
11115 severity: OperationErrorSeverity::Error,
11116 },
11117 "fea.run_electromagnetic requires a nonzero current-density or coil-current source",
11118 BTreeMap::from([
11119 ("analysis_model_id".to_string(), model.model_id.0.clone()),
11120 ("load_count".to_string(), model.loads.len().to_string()),
11121 ]),
11122 ));
11123 }
11124
11125 let has_electromagnetic_boundary = model.boundary_conditions.iter().any(|bc| {
11126 matches!(
11127 &bc.kind,
11128 BoundaryConditionKind::MagneticInsulation
11129 | BoundaryConditionKind::VectorPotentialGround
11130 )
11131 });
11132 if !has_electromagnetic_boundary {
11133 return Err(operation_error(
11134 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11135 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11136 context,
11137 OperationErrorSpec {
11138 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_BOUNDARY",
11139 error_type: OperationErrorType::Validation,
11140 retryable: false,
11141 severity: OperationErrorSeverity::Error,
11142 },
11143 "fea.run_electromagnetic requires magnetic insulation or vector-potential ground boundary data",
11144 BTreeMap::from([
11145 ("analysis_model_id".to_string(), model.model_id.0.clone()),
11146 (
11147 "boundary_condition_count".to_string(),
11148 model.boundary_conditions.len().to_string(),
11149 ),
11150 ]),
11151 ));
11152 }
11153
11154 Ok(())
11155}
11156
11157fn collect_analysis_result_fields(run_result: &AnalysisRunResult) -> Vec<AnalysisField> {
11158 let mut fields = Vec::new();
11159 let mut seen = HashSet::new();
11160
11161 for field in &run_result.run.fields {
11162 push_analysis_result_field(&mut fields, &mut seen, field);
11163 }
11164
11165 if let Some(modal) = run_result.modal_results.as_ref() {
11166 for field in &modal.mode_shapes {
11167 push_analysis_result_field(&mut fields, &mut seen, field);
11168 }
11169 }
11170
11171 if let Some(thermal) = run_result.thermal_results.as_ref() {
11172 for field in &thermal.temperature_snapshots {
11173 push_analysis_result_field(&mut fields, &mut seen, field);
11174 }
11175 for field in &thermal.temperature_gradient_snapshots {
11176 push_analysis_result_field(&mut fields, &mut seen, field);
11177 }
11178 for field in &thermal.heat_flux_snapshots {
11179 push_analysis_result_field(&mut fields, &mut seen, field);
11180 }
11181 for field in &thermal.heat_source_snapshots {
11182 push_analysis_result_field(&mut fields, &mut seen, field);
11183 }
11184 for field in &thermal.boundary_heat_flux_snapshots {
11185 push_analysis_result_field(&mut fields, &mut seen, field);
11186 }
11187 }
11188
11189 if let Some(transient) = run_result.transient_results.as_ref() {
11190 for field in &transient.displacement_snapshots {
11191 push_analysis_result_field(&mut fields, &mut seen, field);
11192 }
11193 for field in &transient.rotation_snapshots {
11194 push_analysis_result_field(&mut fields, &mut seen, field);
11195 }
11196 for field in &transient.velocity_snapshots {
11197 push_analysis_result_field(&mut fields, &mut seen, field);
11198 }
11199 for field in &transient.angular_velocity_snapshots {
11200 push_analysis_result_field(&mut fields, &mut seen, field);
11201 }
11202 for field in &transient.acceleration_snapshots {
11203 push_analysis_result_field(&mut fields, &mut seen, field);
11204 }
11205 for field in &transient.angular_acceleration_snapshots {
11206 push_analysis_result_field(&mut fields, &mut seen, field);
11207 }
11208 for field in &transient.von_mises_snapshots {
11209 push_analysis_result_field(&mut fields, &mut seen, field);
11210 }
11211 for field in &transient.kinetic_energy_snapshots {
11212 push_analysis_result_field(&mut fields, &mut seen, field);
11213 }
11214 for field in &transient.strain_energy_snapshots {
11215 push_analysis_result_field(&mut fields, &mut seen, field);
11216 }
11217 for field in &transient.residual_norm_snapshots {
11218 push_analysis_result_field(&mut fields, &mut seen, field);
11219 }
11220 for field in &transient.thermo_mechanical_temperature_snapshots {
11221 push_analysis_result_field(&mut fields, &mut seen, field);
11222 }
11223 for field in &transient.thermo_mechanical_thermal_strain_snapshots {
11224 push_analysis_result_field(&mut fields, &mut seen, field);
11225 }
11226 for field in &transient.thermo_mechanical_thermal_stress_snapshots {
11227 push_analysis_result_field(&mut fields, &mut seen, field);
11228 }
11229 for field in &transient.thermo_mechanical_displacement_snapshots {
11230 push_analysis_result_field(&mut fields, &mut seen, field);
11231 }
11232 for field in &transient.thermo_mechanical_von_mises_snapshots {
11233 push_analysis_result_field(&mut fields, &mut seen, field);
11234 }
11235 for field in &transient.thermo_mechanical_coupling_residual_snapshots {
11236 push_analysis_result_field(&mut fields, &mut seen, field);
11237 }
11238 for field in &transient.electro_thermal_temperature_snapshots {
11239 push_analysis_result_field(&mut fields, &mut seen, field);
11240 }
11241 for field in &transient.electro_thermal_thermal_residual_snapshots {
11242 push_analysis_result_field(&mut fields, &mut seen, field);
11243 }
11244 }
11245
11246 if let Some(nonlinear) = run_result.nonlinear_results.as_ref() {
11247 for field in &nonlinear.displacement_snapshots {
11248 push_analysis_result_field(&mut fields, &mut seen, field);
11249 }
11250 for field in &nonlinear.rotation_snapshots {
11251 push_analysis_result_field(&mut fields, &mut seen, field);
11252 }
11253 for field in &nonlinear.von_mises_snapshots {
11254 push_analysis_result_field(&mut fields, &mut seen, field);
11255 }
11256 for field in &nonlinear.plastic_strain_snapshots {
11257 push_analysis_result_field(&mut fields, &mut seen, field);
11258 }
11259 for field in &nonlinear.equivalent_plastic_strain_snapshots {
11260 push_analysis_result_field(&mut fields, &mut seen, field);
11261 }
11262 for field in &nonlinear.contact_pressure_snapshots {
11263 push_analysis_result_field(&mut fields, &mut seen, field);
11264 }
11265 for field in &nonlinear.contact_gap_snapshots {
11266 push_analysis_result_field(&mut fields, &mut seen, field);
11267 }
11268 for field in &nonlinear.load_factor_snapshots {
11269 push_analysis_result_field(&mut fields, &mut seen, field);
11270 }
11271 for field in &nonlinear.residual_norm_snapshots {
11272 push_analysis_result_field(&mut fields, &mut seen, field);
11273 }
11274 for field in &nonlinear.thermo_mechanical_temperature_snapshots {
11275 push_analysis_result_field(&mut fields, &mut seen, field);
11276 }
11277 for field in &nonlinear.thermo_mechanical_thermal_strain_snapshots {
11278 push_analysis_result_field(&mut fields, &mut seen, field);
11279 }
11280 for field in &nonlinear.thermo_mechanical_thermal_stress_snapshots {
11281 push_analysis_result_field(&mut fields, &mut seen, field);
11282 }
11283 for field in &nonlinear.thermo_mechanical_displacement_snapshots {
11284 push_analysis_result_field(&mut fields, &mut seen, field);
11285 }
11286 for field in &nonlinear.thermo_mechanical_von_mises_snapshots {
11287 push_analysis_result_field(&mut fields, &mut seen, field);
11288 }
11289 for field in &nonlinear.thermo_mechanical_coupling_residual_snapshots {
11290 push_analysis_result_field(&mut fields, &mut seen, field);
11291 }
11292 for field in &nonlinear.electro_thermal_temperature_snapshots {
11293 push_analysis_result_field(&mut fields, &mut seen, field);
11294 }
11295 for field in &nonlinear.electro_thermal_thermal_residual_snapshots {
11296 push_analysis_result_field(&mut fields, &mut seen, field);
11297 }
11298 }
11299
11300 if let Some(electromagnetic) = run_result.electromagnetic_results.as_ref() {
11301 for field in [
11302 &electromagnetic.vector_potential_real,
11303 &electromagnetic.vector_potential_imag,
11304 &electromagnetic.magnetic_flux_density_real,
11305 &electromagnetic.magnetic_flux_density_imag,
11306 &electromagnetic.magnetic_flux_density_magnitude,
11307 &electromagnetic.magnetic_field_real,
11308 &electromagnetic.magnetic_field_imag,
11309 &electromagnetic.current_density_real,
11310 &electromagnetic.current_density_imag,
11311 &electromagnetic.electric_field_real,
11312 &electromagnetic.electric_field_imag,
11313 &electromagnetic.power_loss_density,
11314 &electromagnetic.energy_density,
11315 &electromagnetic.residual_real,
11316 &electromagnetic.residual_imag,
11317 &electromagnetic.electric_flux_density_real,
11318 &electromagnetic.electric_flux_density_imag,
11319 &electromagnetic.poynting_vector_real,
11320 &electromagnetic.poynting_vector_imag,
11321 ] {
11322 push_analysis_result_field(&mut fields, &mut seen, field);
11323 }
11324 }
11325
11326 fields
11327}
11328
11329fn push_analysis_result_field(
11330 fields: &mut Vec<AnalysisField>,
11331 seen: &mut HashSet<String>,
11332 field: &AnalysisField,
11333) {
11334 if !seen.insert(field.field_id.clone()) {
11335 return;
11336 }
11337 fields.push(field.clone());
11338}
11339
11340fn filter_analysis_fields_by_indices(
11341 fields: &[AnalysisField],
11342 indices: &[usize],
11343) -> Vec<AnalysisField> {
11344 if fields.is_empty() {
11345 return Vec::new();
11346 }
11347 indices
11348 .iter()
11349 .filter_map(|index| fields.get(*index).cloned())
11350 .collect()
11351}
11352
11353pub(crate) fn analysis_run_field_ids(run_result: &AnalysisRunResult) -> Vec<String> {
11354 collect_analysis_result_fields(run_result)
11355 .into_iter()
11356 .map(|field| field.field_id)
11357 .collect()
11358}
11359
11360pub fn analysis_results_op(
11361 run_result: &AnalysisRunResult,
11362 query: AnalysisResultsQuery,
11363 context: OperationContext,
11364) -> Result<OperationEnvelope<AnalysisResultsData>, OperationErrorEnvelope> {
11365 let mut collected_fields = collect_analysis_result_fields(run_result);
11366
11367 if !query.include_fields.is_empty() {
11368 let mut filtered = Vec::new();
11369 for requested in &query.include_fields {
11370 let Some(field) = collected_fields
11371 .iter()
11372 .find(|field| &field.field_id == requested)
11373 else {
11374 return Err(operation_error(
11375 ANALYSIS_RESULTS_OPERATION,
11376 ANALYSIS_RESULTS_OP_VERSION,
11377 &context,
11378 OperationErrorSpec {
11379 error_code: "RM.FEA.RESULTS.FIELD_NOT_FOUND",
11380 error_type: OperationErrorType::Input,
11381 retryable: false,
11382 severity: OperationErrorSeverity::Error,
11383 },
11384 format!("requested FEA field '{requested}' was not produced by run"),
11385 BTreeMap::from([
11386 ("requested_field".to_string(), requested.clone()),
11387 (
11388 "available_fields".to_string(),
11389 collected_fields
11390 .iter()
11391 .map(|field| field.field_id.clone())
11392 .collect::<Vec<_>>()
11393 .join(","),
11394 ),
11395 ]),
11396 ));
11397 };
11398 filtered.push(field.clone());
11399 }
11400 collected_fields = filtered;
11401 }
11402 let field_descriptors = collected_fields
11403 .iter()
11404 .map(AnalysisFieldDescriptor::from_field)
11405 .collect::<Vec<_>>();
11406
11407 let (
11408 mode_count,
11409 available_mode_indices,
11410 min_frequency_hz,
11411 max_frequency_hz,
11412 max_modal_residual_norm,
11413 first_mode_converged,
11414 ) = if let Some(modal) = run_result.modal_results.as_ref() {
11415 let count = modal.eigenvalues_hz.len().min(modal.mode_shapes.len());
11416 let max_modal_residual_norm = modal.residual_norms.iter().copied().reduce(f64::max);
11417 let first_mode_converged = modal.residual_norms.first().copied().map(|v| v <= 1.0e-6);
11418 let (min_frequency_hz, max_frequency_hz) = if count == 0 {
11419 (None, None)
11420 } else {
11421 let mut min_value = f64::INFINITY;
11422 let mut max_value = f64::NEG_INFINITY;
11423 for value in modal.eigenvalues_hz.iter().copied().take(count) {
11424 min_value = min_value.min(value);
11425 max_value = max_value.max(value);
11426 }
11427 (Some(min_value), Some(max_value))
11428 };
11429 (
11430 count,
11431 (0..count).collect(),
11432 min_frequency_hz,
11433 max_frequency_hz,
11434 max_modal_residual_norm,
11435 first_mode_converged,
11436 )
11437 } else {
11438 (0, Vec::new(), None, None, None, None)
11439 };
11440
11441 let (
11442 snapshot_count,
11443 time_start_s,
11444 time_end_s,
11445 max_transient_residual_norm,
11446 final_step_converged,
11447 ) = if let Some(transient) = run_result.transient_results.as_ref() {
11448 let count = transient
11449 .time_points_s
11450 .len()
11451 .min(transient.displacement_snapshots.len());
11452 let max_residual = transient.residual_norms.iter().copied().reduce(f64::max);
11453 let final_step_converged = max_residual.map(|value| value <= 1.0e-6);
11454 if count == 0 {
11455 (0, None, None, max_residual, final_step_converged)
11456 } else {
11457 (
11458 count,
11459 transient.time_points_s.first().copied(),
11460 transient.time_points_s.get(count - 1).copied(),
11461 max_residual,
11462 final_step_converged,
11463 )
11464 }
11465 } else if let Some(thermal) = run_result.thermal_results.as_ref() {
11466 let count = thermal
11467 .time_points_s
11468 .len()
11469 .min(thermal.temperature_snapshots.len());
11470 let max_residual = thermal.residual_norms.iter().copied().reduce(f64::max);
11471 let final_step_converged = max_residual.map(|value| value <= 1.0e-6);
11472 if count == 0 {
11473 (0, None, None, max_residual, final_step_converged)
11474 } else {
11475 (
11476 count,
11477 thermal.time_points_s.first().copied(),
11478 thermal.time_points_s.get(count - 1).copied(),
11479 max_residual,
11480 final_step_converged,
11481 )
11482 }
11483 } else {
11484 (0, None, None, None, None)
11485 };
11486
11487 let (
11488 increment_count,
11489 failed_increment_count,
11490 max_nonlinear_residual_norm,
11491 max_nonlinear_increment_norm,
11492 max_nonlinear_iteration_count,
11493 final_increment_converged,
11494 nonlinear_line_search_backtracks,
11495 nonlinear_max_backtracks_per_increment,
11496 nonlinear_tangent_rebuild_count,
11497 nonlinear_iteration_spike_count,
11498 nonlinear_convergence_stall_count,
11499 nonlinear_backtrack_burst_count,
11500 ) = if let Some(nonlinear) = run_result.nonlinear_results.as_ref() {
11501 let count = nonlinear.load_factors.len();
11502 let max_residual = nonlinear.residual_norms.iter().copied().reduce(f64::max);
11503 let max_increment_norm = nonlinear.increment_norms.iter().copied().reduce(f64::max);
11504 let max_iteration_count = nonlinear.iteration_counts.iter().copied().max();
11505 let final_converged =
11506 max_residual.map(|value| value <= 1.0e-6 && nonlinear.failed_increments == 0);
11507 (
11508 count,
11509 Some(nonlinear.failed_increments),
11510 max_residual,
11511 max_increment_norm,
11512 max_iteration_count,
11513 final_converged,
11514 Some(nonlinear.line_search_backtracks),
11515 Some(nonlinear.max_line_search_backtracks_per_increment),
11516 Some(nonlinear.tangent_rebuild_count),
11517 Some(nonlinear.iteration_spike_count),
11518 Some(nonlinear.convergence_stall_count),
11519 Some(nonlinear.backtrack_burst_count),
11520 )
11521 } else {
11522 (
11523 0, None, None, None, None, None, None, None, None, None, None, None,
11524 )
11525 };
11526
11527 let prep_calibration_profile = diagnostic_metric_string(
11528 &run_result.run.diagnostics,
11529 "FEA_PREP_CALIBRATION",
11530 "profile",
11531 );
11532 let prep_calibration_fingerprint = diagnostic_metric_u64(
11533 &run_result.run.diagnostics,
11534 "FEA_PREP_CALIBRATION",
11535 "calibration_fingerprint",
11536 );
11537 let prep_acceptance_score = diagnostic_metric(
11538 &run_result.run.diagnostics,
11539 "FEA_PREP_ACCEPTANCE",
11540 "acceptance_score",
11541 );
11542 let prep_acceptance_passed = diagnostic_metric_bool(
11543 &run_result.run.diagnostics,
11544 "FEA_PREP_ACCEPTANCE",
11545 "accepted",
11546 );
11547 let prep_acceptance_fingerprint = diagnostic_metric_u64(
11548 &run_result.run.diagnostics,
11549 "FEA_PREP_ACCEPTANCE",
11550 "acceptance_fingerprint",
11551 );
11552 let thermo_coupling_enabled =
11553 diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_TM_COUPLING", "enabled");
11554 let thermo_coupling_fingerprint = diagnostic_metric_u64(
11555 &run_result.run.diagnostics,
11556 "FEA_TM_COUPLING",
11557 "coupling_fingerprint",
11558 );
11559 let thermo_constitutive_temperature_factor = diagnostic_metric(
11560 &run_result.run.diagnostics,
11561 "FEA_TM_COUPLING",
11562 "constitutive_temperature_factor",
11563 );
11564 let thermo_effective_modulus_scale = diagnostic_metric(
11565 &run_result.run.diagnostics,
11566 "FEA_TM_COUPLING",
11567 "effective_modulus_scale",
11568 );
11569 let thermo_constitutive_material_spread_ratio = diagnostic_metric(
11570 &run_result.run.diagnostics,
11571 "FEA_TM_COUPLING",
11572 "constitutive_material_spread_ratio",
11573 );
11574 let thermo_assignment_heterogeneity_index = diagnostic_metric(
11575 &run_result.run.diagnostics,
11576 "FEA_TM_COUPLING",
11577 "assignment_heterogeneity_index",
11578 );
11579 let thermo_region_delta_count = diagnostic_metric(
11580 &run_result.run.diagnostics,
11581 "FEA_TM_COUPLING",
11582 "region_delta_count",
11583 );
11584 let thermo_spatial_coverage_ratio = diagnostic_metric(
11585 &run_result.run.diagnostics,
11586 "FEA_TM_COUPLING",
11587 "spatial_coverage_ratio",
11588 );
11589 let thermo_field_extrapolation_ratio = diagnostic_metric(
11590 &run_result.run.diagnostics,
11591 "FEA_TM_TRANSIENT",
11592 "field_extrapolation_ratio",
11593 )
11594 .or_else(|| {
11595 diagnostic_metric(
11596 &run_result.run.diagnostics,
11597 "FEA_TM_NONLINEAR",
11598 "field_extrapolation_ratio",
11599 )
11600 });
11601 let thermo_field_clamp_ratio = diagnostic_metric(
11602 &run_result.run.diagnostics,
11603 "FEA_TM_TRANSIENT",
11604 "field_clamp_ratio",
11605 )
11606 .or_else(|| {
11607 diagnostic_metric(
11608 &run_result.run.diagnostics,
11609 "FEA_TM_NONLINEAR",
11610 "field_clamp_ratio",
11611 )
11612 });
11613 let thermo_transient_severity = diagnostic_metric(
11614 &run_result.run.diagnostics,
11615 "FEA_TM_TRANSIENT",
11616 "severity_peak",
11617 )
11618 .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_TM_TRANSIENT", "severity"));
11619 let thermo_nonlinear_severity = diagnostic_metric(
11620 &run_result.run.diagnostics,
11621 "FEA_TM_NONLINEAR",
11622 "severity_peak",
11623 )
11624 .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_TM_NONLINEAR", "severity"));
11625 let electro_thermal_coupling_enabled =
11626 diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_ET_COUPLING", "enabled");
11627 let electro_thermal_coupling_fingerprint = diagnostic_metric_u64(
11628 &run_result.run.diagnostics,
11629 "FEA_ET_COUPLING",
11630 "coupling_fingerprint",
11631 );
11632 let electro_joule_heating_scale = diagnostic_metric(
11633 &run_result.run.diagnostics,
11634 "FEA_ET_COUPLING",
11635 "joule_heating_scale",
11636 );
11637 let electro_conductivity_spread_ratio = diagnostic_metric(
11638 &run_result.run.diagnostics,
11639 "FEA_ET_COUPLING",
11640 "conductivity_spread_ratio",
11641 );
11642 let electro_transient_severity = diagnostic_metric(
11643 &run_result.run.diagnostics,
11644 "FEA_ET_TRANSIENT",
11645 "severity_peak",
11646 )
11647 .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_ET_TRANSIENT", "severity"));
11648 let electro_transient_time_scale_mean = diagnostic_metric(
11649 &run_result.run.diagnostics,
11650 "FEA_ET_TRANSIENT",
11651 "time_scale_mean",
11652 );
11653 let electro_nonlinear_severity = diagnostic_metric(
11654 &run_result.run.diagnostics,
11655 "FEA_ET_NONLINEAR",
11656 "severity_peak",
11657 )
11658 .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_ET_NONLINEAR", "severity"));
11659 let electro_nonlinear_time_scale_mean = diagnostic_metric(
11660 &run_result.run.diagnostics,
11661 "FEA_ET_NONLINEAR",
11662 "time_scale_mean",
11663 );
11664 let plastic_nonlinear_severity = diagnostic_metric(
11665 &run_result.run.diagnostics,
11666 "FEA_PLASTIC_NONLINEAR",
11667 "severity_peak",
11668 )
11669 .or_else(|| {
11670 diagnostic_metric(
11671 &run_result.run.diagnostics,
11672 "FEA_PLASTIC_NONLINEAR",
11673 "severity",
11674 )
11675 });
11676 let plastic_nonlinear_severity_mean = diagnostic_metric(
11677 &run_result.run.diagnostics,
11678 "FEA_PLASTIC_NONLINEAR",
11679 "severity_mean",
11680 );
11681 let plastic_load_realization_ratio = diagnostic_metric(
11682 &run_result.run.diagnostics,
11683 "FEA_PLASTIC_NONLINEAR",
11684 "load_realization_ratio",
11685 );
11686 let plastic_load_amplification_ratio = diagnostic_metric(
11687 &run_result.run.diagnostics,
11688 "FEA_PLASTIC_NONLINEAR",
11689 "load_amplification_ratio",
11690 );
11691 let contact_nonlinear_severity = diagnostic_metric(
11692 &run_result.run.diagnostics,
11693 "FEA_CONTACT_NONLINEAR",
11694 "severity_peak",
11695 )
11696 .or_else(|| {
11697 diagnostic_metric(
11698 &run_result.run.diagnostics,
11699 "FEA_CONTACT_NONLINEAR",
11700 "severity",
11701 )
11702 });
11703 let contact_nonlinear_severity_mean = diagnostic_metric(
11704 &run_result.run.diagnostics,
11705 "FEA_CONTACT_NONLINEAR",
11706 "severity_mean",
11707 );
11708 let contact_load_realization_ratio = diagnostic_metric(
11709 &run_result.run.diagnostics,
11710 "FEA_CONTACT_NONLINEAR",
11711 "load_realization_ratio",
11712 );
11713 let contact_load_amplification_ratio = diagnostic_metric(
11714 &run_result.run.diagnostics,
11715 "FEA_CONTACT_NONLINEAR",
11716 "load_amplification_ratio",
11717 );
11718 let thermal_max_residual_norm = diagnostic_metric(
11719 &run_result.run.diagnostics,
11720 "FEA_THERMAL_STABILITY",
11721 "max_residual_norm",
11722 );
11723 let thermal_min_temperature_k = diagnostic_metric(
11724 &run_result.run.diagnostics,
11725 "FEA_THERMAL_STABILITY",
11726 "min_temperature_k",
11727 );
11728 let thermal_max_temperature_k = diagnostic_metric(
11729 &run_result.run.diagnostics,
11730 "FEA_THERMAL_STABILITY",
11731 "max_temperature_k",
11732 );
11733 let thermal_conductivity_spread_ratio = diagnostic_metric(
11734 &run_result.run.diagnostics,
11735 "FEA_THERMAL_CONSTITUTIVE",
11736 "conductivity_spread_ratio",
11737 );
11738 let thermal_heat_capacity_spread_ratio = diagnostic_metric(
11739 &run_result.run.diagnostics,
11740 "FEA_THERMAL_CONSTITUTIVE",
11741 "heat_capacity_spread_ratio",
11742 );
11743 let thermal_spatial_gradient_index = diagnostic_metric(
11744 &run_result.run.diagnostics,
11745 "FEA_THERMAL_OUTCOME",
11746 "spatial_gradient_index",
11747 );
11748 let thermal_monotonic_response_fraction = diagnostic_metric(
11749 &run_result.run.diagnostics,
11750 "FEA_THERMAL_OUTCOME",
11751 "monotonic_response_fraction",
11752 );
11753 let thermal_response_realization_ratio = diagnostic_metric(
11754 &run_result.run.diagnostics,
11755 "FEA_THERMAL_OUTCOME",
11756 "thermal_response_realization_ratio",
11757 );
11758 let electromagnetic_enabled =
11759 diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_EM_STATIC", "enabled");
11760 let electromagnetic_formulation_coverage_ratio = diagnostic_metric(
11761 &run_result.run.diagnostics,
11762 "FEA_EM_FORMULATION",
11763 "formulation_coverage_ratio",
11764 );
11765 let electromagnetic_magnetostatic_curl_curl_coverage_ratio = diagnostic_metric(
11766 &run_result.run.diagnostics,
11767 "FEA_EM_FORMULATION",
11768 "magnetostatic_curl_curl_coverage_ratio",
11769 );
11770 let electromagnetic_magnetoquasistatic_eddy_current_coverage_ratio = diagnostic_metric(
11771 &run_result.run.diagnostics,
11772 "FEA_EM_FORMULATION",
11773 "magnetoquasistatic_eddy_current_coverage_ratio",
11774 );
11775 let electromagnetic_full_wave_displacement_current_coverage_ratio = diagnostic_metric(
11776 &run_result.run.diagnostics,
11777 "FEA_EM_FORMULATION",
11778 "full_wave_displacement_current_coverage_ratio",
11779 );
11780 let electromagnetic_displacement_to_conduction_ratio = diagnostic_metric(
11781 &run_result.run.diagnostics,
11782 "FEA_EM_FORMULATION",
11783 "displacement_to_conduction_ratio",
11784 );
11785 let electromagnetic_material_frequency_response_coverage_ratio = diagnostic_metric(
11786 &run_result.run.diagnostics,
11787 "FEA_EM_FORMULATION",
11788 "material_frequency_response_coverage_ratio",
11789 );
11790 let electromagnetic_reference_frequency_hz = diagnostic_metric(
11791 &run_result.run.diagnostics,
11792 "FEA_EM_STATIC",
11793 "reference_frequency_hz",
11794 );
11795 let electromagnetic_applied_current_a = diagnostic_metric(
11796 &run_result.run.diagnostics,
11797 "FEA_EM_STATIC",
11798 "applied_current_a",
11799 );
11800 let electromagnetic_solve_quality = diagnostic_metric(
11801 &run_result.run.diagnostics,
11802 "FEA_EM_STATIC",
11803 "solve_quality",
11804 );
11805 let electromagnetic_conductivity_spread_ratio = diagnostic_metric(
11806 &run_result.run.diagnostics,
11807 "FEA_EM_STATIC",
11808 "conductivity_spread_ratio",
11809 );
11810 let electromagnetic_relative_permittivity_spread_ratio = diagnostic_metric(
11811 &run_result.run.diagnostics,
11812 "FEA_EM_STATIC",
11813 "relative_permittivity_spread_ratio",
11814 );
11815 let electromagnetic_relative_permeability_spread_ratio = diagnostic_metric(
11816 &run_result.run.diagnostics,
11817 "FEA_EM_STATIC",
11818 "relative_permeability_spread_ratio",
11819 );
11820 let electromagnetic_material_heterogeneity_index = diagnostic_metric(
11821 &run_result.run.diagnostics,
11822 "FEA_EM_STATIC",
11823 "electromagnetic_material_heterogeneity_index",
11824 );
11825 let electromagnetic_assignment_coverage_ratio = diagnostic_metric(
11826 &run_result.run.diagnostics,
11827 "FEA_EM_STATIC",
11828 "assignment_coverage_ratio",
11829 );
11830 let electromagnetic_assigned_coefficient_coverage_ratio = diagnostic_metric(
11831 &run_result.run.diagnostics,
11832 "FEA_EM_STATIC",
11833 "assigned_coefficient_coverage_ratio",
11834 );
11835 let electromagnetic_region_coefficient_contrast_index = diagnostic_metric(
11836 &run_result.run.diagnostics,
11837 "FEA_EM_STATIC",
11838 "region_coefficient_contrast_index",
11839 );
11840 let electromagnetic_condition_number_estimate = diagnostic_metric(
11841 &run_result.run.diagnostics,
11842 "FEA_EM_STATIC",
11843 "condition_number_estimate",
11844 );
11845 let electromagnetic_source_realization_ratio = diagnostic_metric(
11846 &run_result.run.diagnostics,
11847 "FEA_EM_SOURCE_ENERGY",
11848 "source_realization_ratio",
11849 );
11850 let electromagnetic_source_region_coverage_ratio = diagnostic_metric(
11851 &run_result.run.diagnostics,
11852 "FEA_EM_SOURCE_ENERGY",
11853 "source_region_coverage_ratio",
11854 );
11855 let electromagnetic_source_material_alignment_ratio = diagnostic_metric(
11856 &run_result.run.diagnostics,
11857 "FEA_EM_SOURCE_ENERGY",
11858 "source_material_alignment_ratio",
11859 );
11860 let electromagnetic_source_localization_ratio = diagnostic_metric(
11861 &run_result.run.diagnostics,
11862 "FEA_EM_SOURCE_ENERGY",
11863 "source_localization_ratio",
11864 );
11865 let electromagnetic_source_overlap_ratio = diagnostic_metric(
11866 &run_result.run.diagnostics,
11867 "FEA_EM_SOURCE_ENERGY",
11868 "source_overlap_ratio",
11869 );
11870 let electromagnetic_source_interference_index = diagnostic_metric(
11871 &run_result.run.diagnostics,
11872 "FEA_EM_SOURCE_ENERGY",
11873 "source_interference_index",
11874 );
11875 let electromagnetic_boundary_anchor_ratio = diagnostic_metric(
11876 &run_result.run.diagnostics,
11877 "FEA_EM_SOURCE_ENERGY",
11878 "boundary_anchor_ratio",
11879 );
11880 let electromagnetic_boundary_condition_localization_ratio = diagnostic_metric(
11881 &run_result.run.diagnostics,
11882 "FEA_EM_SOURCE_ENERGY",
11883 "boundary_condition_localization_ratio",
11884 );
11885 let electromagnetic_ground_anchor_effectiveness_ratio = diagnostic_metric(
11886 &run_result.run.diagnostics,
11887 "FEA_EM_SOURCE_ENERGY",
11888 "ground_anchor_effectiveness_ratio",
11889 );
11890 let electromagnetic_insulation_leakage_ratio = diagnostic_metric(
11891 &run_result.run.diagnostics,
11892 "FEA_EM_SOURCE_ENERGY",
11893 "insulation_leakage_ratio",
11894 );
11895 let electromagnetic_flux_divergence_ratio = diagnostic_metric(
11896 &run_result.run.diagnostics,
11897 "FEA_EM_STATIC",
11898 "flux_divergence_ratio",
11899 );
11900 let electromagnetic_energy_imbalance_ratio = diagnostic_metric(
11901 &run_result.run.diagnostics,
11902 "FEA_EM_SOURCE_ENERGY",
11903 "energy_imbalance_ratio",
11904 );
11905 let electromagnetic_boundary_energy_ratio = diagnostic_metric(
11906 &run_result.run.diagnostics,
11907 "FEA_EM_SOURCE_ENERGY",
11908 "boundary_energy_ratio",
11909 );
11910 let electromagnetic_boundary_penalty_conditioning_contribution = diagnostic_metric(
11911 &run_result.run.diagnostics,
11912 "FEA_EM_SOURCE_ENERGY",
11913 "boundary_penalty_conditioning_contribution",
11914 );
11915 let electromagnetic_source_region_energy_consistency_ratio = diagnostic_metric(
11916 &run_result.run.diagnostics,
11917 "FEA_EM_SOURCE_ENERGY",
11918 "source_region_energy_consistency_ratio",
11919 );
11920 let electromagnetic_real_residual_norm = diagnostic_metric(
11921 &run_result.run.diagnostics,
11922 "FEA_EM_STATIC",
11923 "real_residual_norm",
11924 );
11925 let electromagnetic_imag_residual_norm = diagnostic_metric(
11926 &run_result.run.diagnostics,
11927 "FEA_EM_STATIC",
11928 "imag_residual_norm",
11929 );
11930 let electromagnetic_sweep_count =
11931 diagnostic_metric(&run_result.run.diagnostics, "FEA_EM_SWEEP", "sweep_count");
11932 let electromagnetic_resonance_peak_frequency_hz = diagnostic_metric(
11933 &run_result.run.diagnostics,
11934 "FEA_EM_SWEEP",
11935 "resonance_peak_frequency_hz",
11936 );
11937 let electromagnetic_resonance_peak_flux_density = diagnostic_metric(
11938 &run_result.run.diagnostics,
11939 "FEA_EM_SWEEP",
11940 "resonance_peak_flux_density",
11941 );
11942 let electromagnetic_resonance_bandwidth_hz = diagnostic_metric(
11943 &run_result.run.diagnostics,
11944 "FEA_EM_SWEEP",
11945 "resonance_bandwidth_hz",
11946 );
11947 let electromagnetic_resonance_quality_factor = diagnostic_metric(
11948 &run_result.run.diagnostics,
11949 "FEA_EM_SWEEP",
11950 "resonance_quality_factor",
11951 );
11952 let electromagnetic_resonance_flux_gain = diagnostic_metric(
11953 &run_result.run.diagnostics,
11954 "FEA_EM_SWEEP",
11955 "resonance_flux_gain",
11956 );
11957
11958 let summary = AnalysisResultsSummary {
11959 field_count: field_descriptors.len(),
11960 total_elements: field_descriptors
11961 .iter()
11962 .map(|field| field.element_count)
11963 .sum(),
11964 mode_count,
11965 available_mode_indices,
11966 min_frequency_hz,
11967 max_frequency_hz,
11968 max_modal_residual_norm,
11969 first_mode_converged,
11970 snapshot_count,
11971 time_start_s,
11972 time_end_s,
11973 max_transient_residual_norm,
11974 final_step_converged,
11975 increment_count,
11976 failed_increment_count,
11977 max_nonlinear_residual_norm,
11978 max_nonlinear_increment_norm,
11979 max_nonlinear_iteration_count,
11980 final_increment_converged,
11981 nonlinear_line_search_backtracks,
11982 nonlinear_max_backtracks_per_increment,
11983 nonlinear_tangent_rebuild_count,
11984 nonlinear_iteration_spike_count,
11985 nonlinear_convergence_stall_count,
11986 nonlinear_backtrack_burst_count,
11987 prep_calibration_profile,
11988 prep_calibration_fingerprint,
11989 prep_acceptance_score,
11990 prep_acceptance_passed,
11991 prep_acceptance_fingerprint,
11992 thermo_coupling_enabled,
11993 thermo_coupling_fingerprint,
11994 thermo_constitutive_temperature_factor,
11995 thermo_effective_modulus_scale,
11996 thermo_constitutive_material_spread_ratio,
11997 thermo_assignment_heterogeneity_index,
11998 thermo_region_delta_count,
11999 thermo_spatial_coverage_ratio,
12000 thermo_field_extrapolation_ratio,
12001 thermo_field_clamp_ratio,
12002 thermo_transient_severity,
12003 thermo_nonlinear_severity,
12004 electro_thermal_coupling_enabled,
12005 electro_thermal_coupling_fingerprint,
12006 electro_joule_heating_scale,
12007 electro_conductivity_spread_ratio,
12008 electro_transient_severity,
12009 electro_transient_time_scale_mean,
12010 electro_nonlinear_severity,
12011 electro_nonlinear_time_scale_mean,
12012 plastic_nonlinear_severity,
12013 plastic_nonlinear_severity_mean,
12014 plastic_load_realization_ratio,
12015 plastic_load_amplification_ratio,
12016 contact_nonlinear_severity,
12017 contact_nonlinear_severity_mean,
12018 contact_load_realization_ratio,
12019 contact_load_amplification_ratio,
12020 thermal_max_residual_norm,
12021 thermal_min_temperature_k,
12022 thermal_max_temperature_k,
12023 thermal_conductivity_spread_ratio,
12024 thermal_heat_capacity_spread_ratio,
12025 thermal_spatial_gradient_index,
12026 thermal_monotonic_response_fraction,
12027 thermal_response_realization_ratio,
12028 electromagnetic_enabled,
12029 electromagnetic_formulation_coverage_ratio,
12030 electromagnetic_magnetostatic_curl_curl_coverage_ratio,
12031 electromagnetic_magnetoquasistatic_eddy_current_coverage_ratio,
12032 electromagnetic_full_wave_displacement_current_coverage_ratio,
12033 electromagnetic_displacement_to_conduction_ratio,
12034 electromagnetic_material_frequency_response_coverage_ratio,
12035 electromagnetic_reference_frequency_hz,
12036 electromagnetic_applied_current_a,
12037 electromagnetic_solve_quality,
12038 electromagnetic_conductivity_spread_ratio,
12039 electromagnetic_relative_permittivity_spread_ratio,
12040 electromagnetic_relative_permeability_spread_ratio,
12041 electromagnetic_material_heterogeneity_index,
12042 electromagnetic_assignment_coverage_ratio,
12043 electromagnetic_assigned_coefficient_coverage_ratio,
12044 electromagnetic_region_coefficient_contrast_index,
12045 electromagnetic_condition_number_estimate,
12046 electromagnetic_source_realization_ratio,
12047 electromagnetic_source_region_coverage_ratio,
12048 electromagnetic_source_material_alignment_ratio,
12049 electromagnetic_source_localization_ratio,
12050 electromagnetic_source_overlap_ratio,
12051 electromagnetic_source_interference_index,
12052 electromagnetic_boundary_anchor_ratio,
12053 electromagnetic_boundary_condition_localization_ratio,
12054 electromagnetic_ground_anchor_effectiveness_ratio,
12055 electromagnetic_insulation_leakage_ratio,
12056 electromagnetic_flux_divergence_ratio,
12057 electromagnetic_energy_imbalance_ratio,
12058 electromagnetic_boundary_energy_ratio,
12059 electromagnetic_boundary_penalty_conditioning_contribution,
12060 electromagnetic_source_region_energy_consistency_ratio,
12061 electromagnetic_real_residual_norm,
12062 electromagnetic_imag_residual_norm,
12063 electromagnetic_sweep_count,
12064 electromagnetic_resonance_peak_frequency_hz,
12065 electromagnetic_resonance_peak_flux_density,
12066 electromagnetic_resonance_bandwidth_hz,
12067 electromagnetic_resonance_quality_factor,
12068 electromagnetic_resonance_flux_gain,
12069 };
12070
12071 let modal_results = if query.include_modal_results && query.include_field_values {
12072 if let Some(modal) = run_result.modal_results.as_ref() {
12073 if query.mode_indices.is_empty() {
12074 Some(modal.clone())
12075 } else {
12076 let mut eigenvalues_hz = Vec::with_capacity(query.mode_indices.len());
12077 let mut mode_shapes = Vec::with_capacity(query.mode_indices.len());
12078 let mut residual_norms = Vec::with_capacity(query.mode_indices.len());
12079 for &index in &query.mode_indices {
12080 let eigenvalue = modal.eigenvalues_hz.get(index).copied().ok_or_else(|| {
12081 operation_error(
12082 ANALYSIS_RESULTS_OPERATION,
12083 ANALYSIS_RESULTS_OP_VERSION,
12084 &context,
12085 OperationErrorSpec {
12086 error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
12087 error_type: OperationErrorType::Input,
12088 retryable: false,
12089 severity: OperationErrorSeverity::Error,
12090 },
12091 format!("requested modal mode index '{index}' was not produced by run"),
12092 BTreeMap::from([
12093 ("requested_mode_index".to_string(), index.to_string()),
12094 (
12095 "available_mode_count".to_string(),
12096 modal.eigenvalues_hz.len().to_string(),
12097 ),
12098 ]),
12099 )
12100 })?;
12101 let mode_shape = modal.mode_shapes.get(index).cloned().ok_or_else(|| {
12102 operation_error(
12103 ANALYSIS_RESULTS_OPERATION,
12104 ANALYSIS_RESULTS_OP_VERSION,
12105 &context,
12106 OperationErrorSpec {
12107 error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
12108 error_type: OperationErrorType::Input,
12109 retryable: false,
12110 severity: OperationErrorSeverity::Error,
12111 },
12112 format!(
12113 "requested modal mode index '{index}' is missing mode shape data"
12114 ),
12115 BTreeMap::from([
12116 ("requested_mode_index".to_string(), index.to_string()),
12117 (
12118 "available_shape_count".to_string(),
12119 modal.mode_shapes.len().to_string(),
12120 ),
12121 ]),
12122 )
12123 })?;
12124 let residual_norm =
12125 modal.residual_norms.get(index).copied().ok_or_else(|| {
12126 operation_error(
12127 ANALYSIS_RESULTS_OPERATION,
12128 ANALYSIS_RESULTS_OP_VERSION,
12129 &context,
12130 OperationErrorSpec {
12131 error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
12132 error_type: OperationErrorType::Input,
12133 retryable: false,
12134 severity: OperationErrorSeverity::Error,
12135 },
12136 format!(
12137 "requested modal mode index '{index}' is missing residual data"
12138 ),
12139 BTreeMap::from([
12140 ("requested_mode_index".to_string(), index.to_string()),
12141 (
12142 "available_residual_count".to_string(),
12143 modal.residual_norms.len().to_string(),
12144 ),
12145 ]),
12146 )
12147 })?;
12148 eigenvalues_hz.push(eigenvalue);
12149 mode_shapes.push(mode_shape);
12150 residual_norms.push(residual_norm);
12151 }
12152 Some(ModalResultsData {
12153 modal_payload_version: modal.modal_payload_version.clone(),
12154 eigenvalues_hz,
12155 mode_shapes,
12156 residual_norms,
12157 mode_units: modal.mode_units,
12158 frequency_basis: modal.frequency_basis,
12159 })
12160 }
12161 } else {
12162 None
12163 }
12164 } else {
12165 None
12166 };
12167
12168 let transient_results = if query.include_transient_results && query.include_field_values {
12169 if let Some(transient) = run_result.transient_results.as_ref() {
12170 if query.transient_snapshot_indices.is_empty() {
12171 Some(transient.clone())
12172 } else {
12173 let mut time_points_s = Vec::with_capacity(query.transient_snapshot_indices.len());
12174 let mut displacement_snapshots =
12175 Vec::with_capacity(query.transient_snapshot_indices.len());
12176 let rotation_snapshots = filter_analysis_fields_by_indices(
12177 &transient.rotation_snapshots,
12178 &query.transient_snapshot_indices,
12179 );
12180 let mut velocity_snapshots =
12181 Vec::with_capacity(query.transient_snapshot_indices.len());
12182 let angular_velocity_snapshots = filter_analysis_fields_by_indices(
12183 &transient.angular_velocity_snapshots,
12184 &query.transient_snapshot_indices,
12185 );
12186 let mut acceleration_snapshots =
12187 Vec::with_capacity(query.transient_snapshot_indices.len());
12188 let angular_acceleration_snapshots = filter_analysis_fields_by_indices(
12189 &transient.angular_acceleration_snapshots,
12190 &query.transient_snapshot_indices,
12191 );
12192 let mut von_mises_snapshots =
12193 Vec::with_capacity(query.transient_snapshot_indices.len());
12194 let mut kinetic_energy_snapshots =
12195 Vec::with_capacity(query.transient_snapshot_indices.len());
12196 let mut strain_energy_snapshots =
12197 Vec::with_capacity(query.transient_snapshot_indices.len());
12198 let mut residual_norm_snapshots =
12199 Vec::with_capacity(query.transient_snapshot_indices.len());
12200 let mut residual_norms = Vec::with_capacity(query.transient_snapshot_indices.len());
12201 let thermo_mechanical_temperature_snapshots = filter_analysis_fields_by_indices(
12202 &transient.thermo_mechanical_temperature_snapshots,
12203 &query.transient_snapshot_indices,
12204 );
12205 let thermo_mechanical_thermal_strain_snapshots = filter_analysis_fields_by_indices(
12206 &transient.thermo_mechanical_thermal_strain_snapshots,
12207 &query.transient_snapshot_indices,
12208 );
12209 let thermo_mechanical_thermal_stress_snapshots = filter_analysis_fields_by_indices(
12210 &transient.thermo_mechanical_thermal_stress_snapshots,
12211 &query.transient_snapshot_indices,
12212 );
12213 let thermo_mechanical_displacement_snapshots = filter_analysis_fields_by_indices(
12214 &transient.thermo_mechanical_displacement_snapshots,
12215 &query.transient_snapshot_indices,
12216 );
12217 let thermo_mechanical_von_mises_snapshots = filter_analysis_fields_by_indices(
12218 &transient.thermo_mechanical_von_mises_snapshots,
12219 &query.transient_snapshot_indices,
12220 );
12221 let thermo_mechanical_coupling_residual_snapshots =
12222 filter_analysis_fields_by_indices(
12223 &transient.thermo_mechanical_coupling_residual_snapshots,
12224 &query.transient_snapshot_indices,
12225 );
12226 let electro_thermal_temperature_snapshots = filter_analysis_fields_by_indices(
12227 &transient.electro_thermal_temperature_snapshots,
12228 &query.transient_snapshot_indices,
12229 );
12230 let electro_thermal_thermal_residual_snapshots = filter_analysis_fields_by_indices(
12231 &transient.electro_thermal_thermal_residual_snapshots,
12232 &query.transient_snapshot_indices,
12233 );
12234
12235 for &index in &query.transient_snapshot_indices {
12236 let time_point = transient.time_points_s.get(index).copied().ok_or_else(|| {
12237 operation_error(
12238 ANALYSIS_RESULTS_OPERATION,
12239 ANALYSIS_RESULTS_OP_VERSION,
12240 &context,
12241 OperationErrorSpec {
12242 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12243 error_type: OperationErrorType::Input,
12244 retryable: false,
12245 severity: OperationErrorSeverity::Error,
12246 },
12247 format!(
12248 "requested transient snapshot index '{index}' was not produced by run"
12249 ),
12250 BTreeMap::from([
12251 ("requested_snapshot_index".to_string(), index.to_string()),
12252 (
12253 "available_snapshot_count".to_string(),
12254 transient.time_points_s.len().to_string(),
12255 ),
12256 ]),
12257 )
12258 })?;
12259 let snapshot = transient
12260 .displacement_snapshots
12261 .get(index)
12262 .cloned()
12263 .ok_or_else(|| {
12264 operation_error(
12265 ANALYSIS_RESULTS_OPERATION,
12266 ANALYSIS_RESULTS_OP_VERSION,
12267 &context,
12268 OperationErrorSpec {
12269 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12270 error_type: OperationErrorType::Input,
12271 retryable: false,
12272 severity: OperationErrorSeverity::Error,
12273 },
12274 format!(
12275 "requested transient snapshot index '{index}' is missing displacement data"
12276 ),
12277 BTreeMap::from([
12278 ("requested_snapshot_index".to_string(), index.to_string()),
12279 (
12280 "available_displacement_snapshot_count".to_string(),
12281 transient.displacement_snapshots.len().to_string(),
12282 ),
12283 ]),
12284 )
12285 })?;
12286 let velocity = transient.velocity_snapshots.get(index).cloned().ok_or_else(|| {
12287 operation_error(
12288 ANALYSIS_RESULTS_OPERATION,
12289 ANALYSIS_RESULTS_OP_VERSION,
12290 &context,
12291 OperationErrorSpec {
12292 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12293 error_type: OperationErrorType::Input,
12294 retryable: false,
12295 severity: OperationErrorSeverity::Error,
12296 },
12297 format!(
12298 "requested transient snapshot index '{index}' is missing velocity data"
12299 ),
12300 BTreeMap::from([
12301 ("requested_snapshot_index".to_string(), index.to_string()),
12302 (
12303 "available_velocity_snapshot_count".to_string(),
12304 transient.velocity_snapshots.len().to_string(),
12305 ),
12306 ]),
12307 )
12308 })?;
12309 let acceleration =
12310 transient
12311 .acceleration_snapshots
12312 .get(index)
12313 .cloned()
12314 .ok_or_else(|| {
12315 operation_error(
12316 ANALYSIS_RESULTS_OPERATION,
12317 ANALYSIS_RESULTS_OP_VERSION,
12318 &context,
12319 OperationErrorSpec {
12320 error_code:
12321 "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12322 error_type: OperationErrorType::Input,
12323 retryable: false,
12324 severity: OperationErrorSeverity::Error,
12325 },
12326 format!(
12327 "requested transient snapshot index '{index}' is missing acceleration data"
12328 ),
12329 BTreeMap::from([
12330 ("requested_snapshot_index".to_string(), index.to_string()),
12331 (
12332 "available_acceleration_snapshot_count".to_string(),
12333 transient.acceleration_snapshots.len().to_string(),
12334 ),
12335 ]),
12336 )
12337 })?;
12338 let von_mises =
12339 transient
12340 .von_mises_snapshots
12341 .get(index)
12342 .cloned()
12343 .ok_or_else(|| {
12344 operation_error(
12345 ANALYSIS_RESULTS_OPERATION,
12346 ANALYSIS_RESULTS_OP_VERSION,
12347 &context,
12348 OperationErrorSpec {
12349 error_code:
12350 "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12351 error_type: OperationErrorType::Input,
12352 retryable: false,
12353 severity: OperationErrorSeverity::Error,
12354 },
12355 format!(
12356 "requested transient snapshot index '{index}' is missing von Mises data"
12357 ),
12358 BTreeMap::from([
12359 ("requested_snapshot_index".to_string(), index.to_string()),
12360 (
12361 "available_von_mises_snapshot_count".to_string(),
12362 transient.von_mises_snapshots.len().to_string(),
12363 ),
12364 ]),
12365 )
12366 })?;
12367 let kinetic_energy =
12368 transient
12369 .kinetic_energy_snapshots
12370 .get(index)
12371 .cloned()
12372 .ok_or_else(|| {
12373 operation_error(
12374 ANALYSIS_RESULTS_OPERATION,
12375 ANALYSIS_RESULTS_OP_VERSION,
12376 &context,
12377 OperationErrorSpec {
12378 error_code:
12379 "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12380 error_type: OperationErrorType::Input,
12381 retryable: false,
12382 severity: OperationErrorSeverity::Error,
12383 },
12384 format!(
12385 "requested transient snapshot index '{index}' is missing kinetic energy data"
12386 ),
12387 BTreeMap::from([
12388 ("requested_snapshot_index".to_string(), index.to_string()),
12389 (
12390 "available_kinetic_energy_snapshot_count".to_string(),
12391 transient.kinetic_energy_snapshots.len().to_string(),
12392 ),
12393 ]),
12394 )
12395 })?;
12396 let strain_energy =
12397 transient
12398 .strain_energy_snapshots
12399 .get(index)
12400 .cloned()
12401 .ok_or_else(|| {
12402 operation_error(
12403 ANALYSIS_RESULTS_OPERATION,
12404 ANALYSIS_RESULTS_OP_VERSION,
12405 &context,
12406 OperationErrorSpec {
12407 error_code:
12408 "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12409 error_type: OperationErrorType::Input,
12410 retryable: false,
12411 severity: OperationErrorSeverity::Error,
12412 },
12413 format!(
12414 "requested transient snapshot index '{index}' is missing strain energy data"
12415 ),
12416 BTreeMap::from([
12417 ("requested_snapshot_index".to_string(), index.to_string()),
12418 (
12419 "available_strain_energy_snapshot_count".to_string(),
12420 transient.strain_energy_snapshots.len().to_string(),
12421 ),
12422 ]),
12423 )
12424 })?;
12425 let residual_norm_snapshot = transient
12426 .residual_norm_snapshots
12427 .get(index)
12428 .cloned()
12429 .ok_or_else(|| {
12430 operation_error(
12431 ANALYSIS_RESULTS_OPERATION,
12432 ANALYSIS_RESULTS_OP_VERSION,
12433 &context,
12434 OperationErrorSpec {
12435 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12436 error_type: OperationErrorType::Input,
12437 retryable: false,
12438 severity: OperationErrorSeverity::Error,
12439 },
12440 format!(
12441 "requested transient snapshot index '{index}' is missing residual field data"
12442 ),
12443 BTreeMap::from([
12444 ("requested_snapshot_index".to_string(), index.to_string()),
12445 (
12446 "available_residual_snapshot_count".to_string(),
12447 transient.residual_norm_snapshots.len().to_string(),
12448 ),
12449 ]),
12450 )
12451 })?;
12452
12453 if index > 0 {
12454 let residual = transient.residual_norms.get(index - 1).copied().ok_or_else(|| {
12455 operation_error(
12456 ANALYSIS_RESULTS_OPERATION,
12457 ANALYSIS_RESULTS_OP_VERSION,
12458 &context,
12459 OperationErrorSpec {
12460 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12461 error_type: OperationErrorType::Input,
12462 retryable: false,
12463 severity: OperationErrorSeverity::Error,
12464 },
12465 format!(
12466 "requested transient snapshot index '{index}' is missing residual data"
12467 ),
12468 BTreeMap::from([
12469 ("requested_snapshot_index".to_string(), index.to_string()),
12470 (
12471 "available_residual_count".to_string(),
12472 transient.residual_norms.len().to_string(),
12473 ),
12474 ]),
12475 )
12476 })?;
12477 residual_norms.push(residual);
12478 }
12479
12480 time_points_s.push(time_point);
12481 displacement_snapshots.push(snapshot);
12482 velocity_snapshots.push(velocity);
12483 acceleration_snapshots.push(acceleration);
12484 von_mises_snapshots.push(von_mises);
12485 kinetic_energy_snapshots.push(kinetic_energy);
12486 strain_energy_snapshots.push(strain_energy);
12487 residual_norm_snapshots.push(residual_norm_snapshot);
12488 }
12489
12490 Some(TransientResultsData {
12491 transient_payload_version: transient.transient_payload_version.clone(),
12492 time_points_s,
12493 displacement_snapshots,
12494 rotation_snapshots,
12495 velocity_snapshots,
12496 angular_velocity_snapshots,
12497 acceleration_snapshots,
12498 angular_acceleration_snapshots,
12499 von_mises_snapshots,
12500 kinetic_energy_snapshots,
12501 strain_energy_snapshots,
12502 residual_norm_snapshots,
12503 thermo_mechanical_temperature_snapshots,
12504 thermo_mechanical_thermal_strain_snapshots,
12505 thermo_mechanical_thermal_stress_snapshots,
12506 thermo_mechanical_displacement_snapshots,
12507 thermo_mechanical_von_mises_snapshots,
12508 thermo_mechanical_coupling_residual_snapshots,
12509 electro_thermal_temperature_snapshots,
12510 electro_thermal_thermal_residual_snapshots,
12511 residual_norms,
12512 integration_method: transient.integration_method,
12513 })
12514 }
12515 } else {
12516 None
12517 }
12518 } else {
12519 None
12520 };
12521
12522 let thermal_results = if query.include_field_values {
12523 run_result.thermal_results.clone()
12524 } else {
12525 None
12526 };
12527
12528 let nonlinear_results = if query.include_nonlinear_results && query.include_field_values {
12529 run_result.nonlinear_results.clone()
12530 } else {
12531 None
12532 };
12533 let electromagnetic_results =
12534 if query.include_electromagnetic_results && query.include_field_values {
12535 run_result.electromagnetic_results.clone()
12536 } else {
12537 None
12538 };
12539 let fields = if query.include_field_values {
12540 collected_fields
12541 } else {
12542 Vec::new()
12543 };
12544
12545 let data = AnalysisResultsData {
12546 field_descriptors,
12547 fields,
12548 modal_results,
12549 thermal_results,
12550 transient_results,
12551 nonlinear_results,
12552 electromagnetic_results,
12553 diagnostics: if query.include_diagnostics {
12554 if query.diagnostic_codes.is_empty() {
12555 Some(run_result.run.diagnostics.clone())
12556 } else {
12557 Some(
12558 run_result
12559 .run
12560 .diagnostics
12561 .iter()
12562 .filter(|diag| query.diagnostic_codes.iter().any(|code| code == &diag.code))
12563 .cloned()
12564 .collect(),
12565 )
12566 }
12567 } else {
12568 None
12569 },
12570 run_status: run_result.run_status,
12571 publishable: run_result.publishable,
12572 quality_reasons: run_result.quality_reasons.clone(),
12573 provenance: run_result.provenance.clone(),
12574 summary,
12575 };
12576
12577 Ok(OperationEnvelope::new(
12578 ANALYSIS_RESULTS_OPERATION,
12579 ANALYSIS_RESULTS_OP_VERSION,
12580 &context,
12581 data,
12582 ))
12583}
12584
12585pub fn analysis_results_by_run_id_op(
12586 run_id: &str,
12587 query: AnalysisResultsQuery,
12588 context: OperationContext,
12589) -> Result<OperationEnvelope<AnalysisResultsData>, OperationErrorEnvelope> {
12590 let run_result = storage::load_run_result(run_id).map_err(|err| {
12591 operation_error(
12592 ANALYSIS_RESULTS_OPERATION,
12593 ANALYSIS_RESULTS_OP_VERSION,
12594 &context,
12595 OperationErrorSpec {
12596 error_code: "RM.FEA.RESULTS.ARTIFACT_STORE_FAILED",
12597 error_type: OperationErrorType::Internal,
12598 retryable: true,
12599 severity: OperationErrorSeverity::Error,
12600 },
12601 format!("failed to load FEA run artifact: {err}"),
12602 BTreeMap::from([("run_id".to_string(), run_id.to_string())]),
12603 )
12604 })?;
12605
12606 let Some(run_result) = run_result else {
12607 return Err(operation_error(
12608 ANALYSIS_RESULTS_OPERATION,
12609 ANALYSIS_RESULTS_OP_VERSION,
12610 &context,
12611 OperationErrorSpec {
12612 error_code: "RM.FEA.RESULTS.RUN_NOT_FOUND",
12613 error_type: OperationErrorType::Input,
12614 retryable: false,
12615 severity: OperationErrorSeverity::Error,
12616 },
12617 format!("FEA run_id '{run_id}' was not found"),
12618 BTreeMap::from([("run_id".to_string(), run_id.to_string())]),
12619 ));
12620 };
12621
12622 analysis_results_op(&run_result, query, context)
12623}
12624
12625pub fn analysis_results_compare_op(
12626 query: AnalysisResultsCompareQuery,
12627 context: OperationContext,
12628) -> Result<OperationEnvelope<AnalysisResultsCompareData>, OperationErrorEnvelope> {
12629 let baseline = storage::load_run_result(&query.baseline_run_id).map_err(|err| {
12630 operation_error(
12631 ANALYSIS_RESULTS_COMPARE_OPERATION,
12632 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12633 &context,
12634 OperationErrorSpec {
12635 error_code: "RM.FEA.RESULTS_COMPARE.ARTIFACT_STORE_FAILED",
12636 error_type: OperationErrorType::Internal,
12637 retryable: true,
12638 severity: OperationErrorSeverity::Error,
12639 },
12640 format!("failed to load baseline FEA run artifact: {err}"),
12641 BTreeMap::from([("run_id".to_string(), query.baseline_run_id.clone())]),
12642 )
12643 })?;
12644 let Some(baseline) = baseline else {
12645 return Err(operation_error(
12646 ANALYSIS_RESULTS_COMPARE_OPERATION,
12647 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12648 &context,
12649 OperationErrorSpec {
12650 error_code: "RM.FEA.RESULTS_COMPARE.RUN_NOT_FOUND",
12651 error_type: OperationErrorType::Input,
12652 retryable: false,
12653 severity: OperationErrorSeverity::Error,
12654 },
12655 format!(
12656 "FEA baseline run_id '{}' was not found",
12657 query.baseline_run_id
12658 ),
12659 BTreeMap::from([("run_id".to_string(), query.baseline_run_id.clone())]),
12660 ));
12661 };
12662
12663 let candidate = storage::load_run_result(&query.candidate_run_id).map_err(|err| {
12664 operation_error(
12665 ANALYSIS_RESULTS_COMPARE_OPERATION,
12666 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12667 &context,
12668 OperationErrorSpec {
12669 error_code: "RM.FEA.RESULTS_COMPARE.ARTIFACT_STORE_FAILED",
12670 error_type: OperationErrorType::Internal,
12671 retryable: true,
12672 severity: OperationErrorSeverity::Error,
12673 },
12674 format!("failed to load candidate FEA run artifact: {err}"),
12675 BTreeMap::from([("run_id".to_string(), query.candidate_run_id.clone())]),
12676 )
12677 })?;
12678 let Some(candidate) = candidate else {
12679 return Err(operation_error(
12680 ANALYSIS_RESULTS_COMPARE_OPERATION,
12681 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12682 &context,
12683 OperationErrorSpec {
12684 error_code: "RM.FEA.RESULTS_COMPARE.RUN_NOT_FOUND",
12685 error_type: OperationErrorType::Input,
12686 retryable: false,
12687 severity: OperationErrorSeverity::Error,
12688 },
12689 format!(
12690 "FEA candidate run_id '{}' was not found",
12691 query.candidate_run_id
12692 ),
12693 BTreeMap::from([("run_id".to_string(), query.candidate_run_id.clone())]),
12694 ));
12695 };
12696
12697 let baseline_solve_ms = run_solve_ms(&baseline);
12698 let candidate_solve_ms = run_solve_ms(&candidate);
12699 let failed_increment_delta = match (
12700 baseline.nonlinear_results.as_ref(),
12701 candidate.nonlinear_results.as_ref(),
12702 ) {
12703 (Some(a), Some(b)) => Some(b.failed_increments as i64 - a.failed_increments as i64),
12704 _ => None,
12705 };
12706 let max_iteration_delta = match (
12707 baseline.nonlinear_results.as_ref(),
12708 candidate.nonlinear_results.as_ref(),
12709 ) {
12710 (Some(a), Some(b)) => Some(
12711 b.iteration_counts.iter().copied().max().unwrap_or(0) as i64
12712 - a.iteration_counts.iter().copied().max().unwrap_or(0) as i64,
12713 ),
12714 _ => None,
12715 };
12716 let nonlinear_spike_count_delta = match (
12717 baseline.nonlinear_results.as_ref(),
12718 candidate.nonlinear_results.as_ref(),
12719 ) {
12720 (Some(a), Some(b)) => Some(b.iteration_spike_count as i64 - a.iteration_spike_count as i64),
12721 _ => None,
12722 };
12723 let nonlinear_stall_count_delta = match (
12724 baseline.nonlinear_results.as_ref(),
12725 candidate.nonlinear_results.as_ref(),
12726 ) {
12727 (Some(a), Some(b)) => {
12728 Some(b.convergence_stall_count as i64 - a.convergence_stall_count as i64)
12729 }
12730 _ => None,
12731 };
12732
12733 let data = AnalysisResultsCompareData {
12734 baseline_run_id: baseline.run_id,
12735 candidate_run_id: candidate.run_id,
12736 publishable_changed: baseline.publishable != candidate.publishable,
12737 run_status_changed: baseline.run_status != candidate.run_status,
12738 quality_reason_count_delta: candidate.quality_reasons.len() as i64
12739 - baseline.quality_reasons.len() as i64,
12740 failed_increment_delta,
12741 max_iteration_delta,
12742 nonlinear_spike_count_delta,
12743 nonlinear_stall_count_delta,
12744 solve_ms_delta: match (baseline_solve_ms, candidate_solve_ms) {
12745 (Some(a), Some(b)) => Some(b - a),
12746 _ => None,
12747 },
12748 };
12749
12750 Ok(OperationEnvelope::new(
12751 ANALYSIS_RESULTS_COMPARE_OPERATION,
12752 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12753 &context,
12754 data,
12755 ))
12756}
12757
12758pub fn analysis_trends_op(
12759 query: AnalysisTrendsQuery,
12760 context: OperationContext,
12761) -> Result<OperationEnvelope<AnalysisTrendsData>, OperationErrorEnvelope> {
12762 let runs = storage::list_run_results().map_err(|err| {
12763 operation_error(
12764 ANALYSIS_TRENDS_OPERATION,
12765 ANALYSIS_TRENDS_OP_VERSION,
12766 &context,
12767 OperationErrorSpec {
12768 error_code: "RM.FEA.TRENDS.ARTIFACT_STORE_FAILED",
12769 error_type: OperationErrorType::Internal,
12770 retryable: true,
12771 severity: OperationErrorSeverity::Error,
12772 },
12773 format!("failed to list FEA run artifacts: {err}"),
12774 BTreeMap::new(),
12775 )
12776 })?;
12777
12778 let mut grouped: HashMap<AnalysisRunKind, Vec<AnalysisRunResult>> = HashMap::new();
12779 for run in runs {
12780 grouped.entry(run_kind(&run)).or_default().push(run);
12781 }
12782
12783 let window = query.window_size.max(1);
12784 let mut summaries = Vec::new();
12785 for kind in [
12786 AnalysisRunKind::LinearStatic,
12787 AnalysisRunKind::Modal,
12788 AnalysisRunKind::Acoustic,
12789 AnalysisRunKind::Thermal,
12790 AnalysisRunKind::Transient,
12791 AnalysisRunKind::Cfd,
12792 AnalysisRunKind::Cht,
12793 AnalysisRunKind::Fsi,
12794 AnalysisRunKind::Nonlinear,
12795 AnalysisRunKind::Electromagnetic,
12796 ] {
12797 let Some(mut entries) = grouped.remove(&kind) else {
12798 continue;
12799 };
12800 entries.sort_by(|a, b| b.run_id.cmp(&a.run_id));
12801 if entries.len() > window {
12802 entries.truncate(window);
12803 }
12804 let sample_count = entries.len();
12805 if sample_count == 0 {
12806 continue;
12807 }
12808
12809 let mut solve_samples = entries
12810 .iter()
12811 .filter_map(run_solve_ms)
12812 .filter(|value| value.is_finite())
12813 .collect::<Vec<_>>();
12814 solve_samples.sort_by(|a, b| a.total_cmp(b));
12815 let median_solve_ms = percentile(&solve_samples, 0.5);
12816 let p95_solve_ms = percentile(&solve_samples, 0.95);
12817 let publishable_rate =
12818 entries.iter().filter(|run| run.publishable).count() as f64 / sample_count as f64;
12819
12820 let failed_increment_rate = if kind == AnalysisRunKind::Nonlinear {
12821 let failed = entries
12822 .iter()
12823 .filter_map(|run| run.nonlinear_results.as_ref())
12824 .filter(|nonlinear| nonlinear.failed_increments > 0)
12825 .count();
12826 Some(failed as f64 / sample_count as f64)
12827 } else {
12828 None
12829 };
12830 let mean_spike_count = if kind == AnalysisRunKind::Nonlinear {
12831 let values = entries
12832 .iter()
12833 .filter_map(|run| run.nonlinear_results.as_ref())
12834 .map(|nonlinear| nonlinear.iteration_spike_count as f64)
12835 .collect::<Vec<_>>();
12836 Some(mean(&values))
12837 } else {
12838 None
12839 };
12840 let mean_stall_count = if kind == AnalysisRunKind::Nonlinear {
12841 let values = entries
12842 .iter()
12843 .filter_map(|run| run.nonlinear_results.as_ref())
12844 .map(|nonlinear| nonlinear.convergence_stall_count as f64)
12845 .collect::<Vec<_>>();
12846 Some(mean(&values))
12847 } else {
12848 None
12849 };
12850 let prep_acceptance_rate = {
12851 let values = entries
12852 .iter()
12853 .filter_map(|run| {
12854 diagnostic_metric_bool(&run.run.diagnostics, "FEA_PREP_ACCEPTANCE", "accepted")
12855 })
12856 .collect::<Vec<_>>();
12857 if values.is_empty() {
12858 None
12859 } else {
12860 Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12861 }
12862 };
12863 let prep_calibration_fast_rate = calibration_profile_rate(&entries, "fast");
12864 let prep_calibration_balanced_rate = calibration_profile_rate(&entries, "balanced");
12865 let prep_calibration_conservative_rate = calibration_profile_rate(&entries, "conservative");
12866 let thermo_coupling_enabled_rate = {
12867 let values = entries
12868 .iter()
12869 .filter_map(|run| {
12870 diagnostic_metric_bool(&run.run.diagnostics, "FEA_TM_COUPLING", "enabled")
12871 })
12872 .collect::<Vec<_>>();
12873 if values.is_empty() {
12874 None
12875 } else {
12876 Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12877 }
12878 };
12879 let thermo_transient_warn_rate = if kind == AnalysisRunKind::Transient {
12880 diagnostic_warning_rate(&entries, "FEA_TM_TRANSIENT")
12881 } else {
12882 None
12883 };
12884 let thermo_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12885 diagnostic_warning_rate(&entries, "FEA_TM_NONLINEAR")
12886 } else {
12887 None
12888 };
12889 let thermo_spread_breach_rate = {
12890 let values = entries
12891 .iter()
12892 .filter_map(|run| {
12893 diagnostic_metric(
12894 &run.run.diagnostics,
12895 "FEA_TM_COUPLING",
12896 "constitutive_material_spread_ratio",
12897 )
12898 })
12899 .collect::<Vec<_>>();
12900 breach_rate_greater_than(&values, THERMO_SPREAD_THRESHOLD_BALANCED)
12901 };
12902 let thermo_heterogeneity_breach_rate = {
12903 let values = entries
12904 .iter()
12905 .filter_map(|run| {
12906 diagnostic_metric(
12907 &run.run.diagnostics,
12908 "FEA_TM_COUPLING",
12909 "assignment_heterogeneity_index",
12910 )
12911 })
12912 .collect::<Vec<_>>();
12913 breach_rate_greater_than(&values, THERMO_HETEROGENEITY_THRESHOLD_BALANCED)
12914 };
12915 let electro_thermal_coupling_enabled_rate = {
12916 let values = entries
12917 .iter()
12918 .filter_map(|run| {
12919 diagnostic_metric_bool(&run.run.diagnostics, "FEA_ET_COUPLING", "enabled")
12920 })
12921 .collect::<Vec<_>>();
12922 if values.is_empty() {
12923 None
12924 } else {
12925 Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12926 }
12927 };
12928 let electro_transient_warn_rate = if kind == AnalysisRunKind::Transient {
12929 diagnostic_warning_rate(&entries, "FEA_ET_TRANSIENT")
12930 } else {
12931 None
12932 };
12933 let electro_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12934 diagnostic_warning_rate(&entries, "FEA_ET_NONLINEAR")
12935 } else {
12936 None
12937 };
12938 let plastic_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12939 diagnostic_warning_rate(&entries, "FEA_PLASTIC_NONLINEAR")
12940 } else {
12941 None
12942 };
12943 let contact_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12944 diagnostic_warning_rate(&entries, "FEA_CONTACT_NONLINEAR")
12945 } else {
12946 None
12947 };
12948 let thermal_stability_warn_rate = if kind == AnalysisRunKind::Thermal {
12949 diagnostic_warning_rate(&entries, "FEA_THERMAL_STABILITY")
12950 } else {
12951 None
12952 };
12953 let thermal_constitutive_warn_rate = if kind == AnalysisRunKind::Thermal {
12954 diagnostic_warning_rate(&entries, "FEA_THERMAL_CONSTITUTIVE")
12955 } else {
12956 None
12957 };
12958 let thermal_spread_breach_rate = if kind == AnalysisRunKind::Thermal {
12959 let values = entries
12960 .iter()
12961 .filter_map(|run| {
12962 diagnostic_metric(
12963 &run.run.diagnostics,
12964 "FEA_THERMAL_CONSTITUTIVE",
12965 "conductivity_spread_ratio",
12966 )
12967 })
12968 .collect::<Vec<_>>();
12969 breach_rate_greater_than(&values, 2.5)
12970 } else {
12971 None
12972 };
12973 let electromagnetic_solve_warn_rate = if kind == AnalysisRunKind::Electromagnetic {
12974 Some(diagnostic_warning_rate(&entries, "FEA_EM_STATIC").unwrap_or(0.0))
12975 } else {
12976 None
12977 };
12978 let electromagnetic_spread_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
12979 let values = entries
12980 .iter()
12981 .filter_map(|run| {
12982 diagnostic_metric(
12983 &run.run.diagnostics,
12984 "FEA_EM_STATIC",
12985 "conductivity_spread_ratio",
12986 )
12987 })
12988 .collect::<Vec<_>>();
12989 breach_rate_greater_than(&values, EM_CONDUCTIVITY_SPREAD_THRESHOLD_BALANCED)
12990 } else {
12991 None
12992 };
12993 let electromagnetic_heterogeneity_breach_rate = if kind == AnalysisRunKind::Electromagnetic
12994 {
12995 let values = entries
12996 .iter()
12997 .filter_map(|run| {
12998 diagnostic_metric(
12999 &run.run.diagnostics,
13000 "FEA_EM_STATIC",
13001 "electromagnetic_material_heterogeneity_index",
13002 )
13003 })
13004 .collect::<Vec<_>>();
13005 breach_rate_greater_than(&values, EM_HETEROGENEITY_THRESHOLD_BALANCED)
13006 } else {
13007 None
13008 };
13009 let electromagnetic_coverage_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
13010 let values = entries
13011 .iter()
13012 .filter_map(|run| {
13013 diagnostic_metric(
13014 &run.run.diagnostics,
13015 "FEA_EM_STATIC",
13016 "assignment_coverage_ratio",
13017 )
13018 })
13019 .collect::<Vec<_>>();
13020 breach_rate_less_than(&values, EM_ASSIGNMENT_COVERAGE_MIN_BALANCED)
13021 } else {
13022 None
13023 };
13024 let electromagnetic_contrast_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
13025 let values = entries
13026 .iter()
13027 .filter_map(|run| {
13028 diagnostic_metric(
13029 &run.run.diagnostics,
13030 "FEA_EM_STATIC",
13031 "region_coefficient_contrast_index",
13032 )
13033 })
13034 .collect::<Vec<_>>();
13035 breach_rate_greater_than(&values, EM_REGION_CONTRAST_MAX_BALANCED)
13036 } else {
13037 None
13038 };
13039 let electromagnetic_conditioning_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
13040 let values = entries
13041 .iter()
13042 .filter_map(|run| {
13043 diagnostic_metric(
13044 &run.run.diagnostics,
13045 "FEA_EM_STATIC",
13046 "condition_number_estimate",
13047 )
13048 })
13049 .collect::<Vec<_>>();
13050 breach_rate_greater_than(&values, EM_CONDITIONING_MAX_BALANCED)
13051 } else {
13052 None
13053 };
13054 let electromagnetic_source_realization_breach_rate =
13055 if kind == AnalysisRunKind::Electromagnetic {
13056 let values = entries
13057 .iter()
13058 .filter_map(|run| {
13059 diagnostic_metric(
13060 &run.run.diagnostics,
13061 "FEA_EM_SOURCE_ENERGY",
13062 "source_realization_ratio",
13063 )
13064 })
13065 .collect::<Vec<_>>();
13066 breach_rate_less_than(&values, EM_SOURCE_REALIZATION_MIN_BALANCED)
13067 } else {
13068 None
13069 };
13070 let electromagnetic_source_region_coverage_breach_rate =
13071 if kind == AnalysisRunKind::Electromagnetic {
13072 let values = entries
13073 .iter()
13074 .filter_map(|run| {
13075 diagnostic_metric(
13076 &run.run.diagnostics,
13077 "FEA_EM_SOURCE_ENERGY",
13078 "source_region_coverage_ratio",
13079 )
13080 })
13081 .collect::<Vec<_>>();
13082 breach_rate_less_than(&values, EM_SOURCE_REGION_COVERAGE_MIN_BALANCED)
13083 } else {
13084 None
13085 };
13086 let electromagnetic_source_material_alignment_breach_rate =
13087 if kind == AnalysisRunKind::Electromagnetic {
13088 let values = entries
13089 .iter()
13090 .filter_map(|run| {
13091 diagnostic_metric(
13092 &run.run.diagnostics,
13093 "FEA_EM_SOURCE_ENERGY",
13094 "source_material_alignment_ratio",
13095 )
13096 })
13097 .collect::<Vec<_>>();
13098 breach_rate_less_than(&values, EM_SOURCE_MATERIAL_ALIGNMENT_MIN_BALANCED)
13099 } else {
13100 None
13101 };
13102 let electromagnetic_source_overlap_breach_rate = if kind == AnalysisRunKind::Electromagnetic
13103 {
13104 let values = entries
13105 .iter()
13106 .filter_map(|run| {
13107 diagnostic_metric(
13108 &run.run.diagnostics,
13109 "FEA_EM_SOURCE_ENERGY",
13110 "source_overlap_ratio",
13111 )
13112 })
13113 .collect::<Vec<_>>();
13114 breach_rate_greater_than(&values, EM_SOURCE_OVERLAP_MAX_BALANCED)
13115 } else {
13116 None
13117 };
13118 let electromagnetic_source_interference_breach_rate =
13119 if kind == AnalysisRunKind::Electromagnetic {
13120 let values = entries
13121 .iter()
13122 .filter_map(|run| {
13123 diagnostic_metric(
13124 &run.run.diagnostics,
13125 "FEA_EM_SOURCE_ENERGY",
13126 "source_interference_index",
13127 )
13128 })
13129 .collect::<Vec<_>>();
13130 breach_rate_greater_than(&values, EM_SOURCE_INTERFERENCE_MAX_BALANCED)
13131 } else {
13132 None
13133 };
13134 let electromagnetic_boundary_anchor_breach_rate =
13135 if kind == AnalysisRunKind::Electromagnetic {
13136 let values = entries
13137 .iter()
13138 .filter_map(|run| {
13139 diagnostic_metric(
13140 &run.run.diagnostics,
13141 "FEA_EM_SOURCE_ENERGY",
13142 "boundary_anchor_ratio",
13143 )
13144 })
13145 .collect::<Vec<_>>();
13146 breach_rate_less_than(&values, EM_BOUNDARY_ANCHOR_MIN_BALANCED)
13147 } else {
13148 None
13149 };
13150 let electromagnetic_boundary_localization_breach_rate =
13151 if kind == AnalysisRunKind::Electromagnetic {
13152 let values = entries
13153 .iter()
13154 .filter_map(|run| {
13155 diagnostic_metric(
13156 &run.run.diagnostics,
13157 "FEA_EM_SOURCE_ENERGY",
13158 "boundary_condition_localization_ratio",
13159 )
13160 })
13161 .collect::<Vec<_>>();
13162 breach_rate_less_than(&values, EM_BOUNDARY_LOCALIZATION_MIN_BALANCED)
13163 } else {
13164 None
13165 };
13166 let electromagnetic_ground_effectiveness_breach_rate =
13167 if kind == AnalysisRunKind::Electromagnetic {
13168 let values = entries
13169 .iter()
13170 .filter_map(|run| {
13171 diagnostic_metric(
13172 &run.run.diagnostics,
13173 "FEA_EM_SOURCE_ENERGY",
13174 "ground_anchor_effectiveness_ratio",
13175 )
13176 })
13177 .collect::<Vec<_>>();
13178 breach_rate_less_than(&values, EM_GROUND_EFFECTIVENESS_MIN_BALANCED)
13179 } else {
13180 None
13181 };
13182 let electromagnetic_insulation_leakage_breach_rate =
13183 if kind == AnalysisRunKind::Electromagnetic {
13184 let values = entries
13185 .iter()
13186 .filter_map(|run| {
13187 diagnostic_metric(
13188 &run.run.diagnostics,
13189 "FEA_EM_SOURCE_ENERGY",
13190 "insulation_leakage_ratio",
13191 )
13192 })
13193 .collect::<Vec<_>>();
13194 breach_rate_greater_than(&values, EM_INSULATION_LEAKAGE_MAX_BALANCED)
13195 } else {
13196 None
13197 };
13198 let electromagnetic_divergence_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
13199 let values = entries
13200 .iter()
13201 .filter_map(|run| {
13202 diagnostic_metric(
13203 &run.run.diagnostics,
13204 "FEA_EM_STATIC",
13205 "flux_divergence_ratio",
13206 )
13207 })
13208 .collect::<Vec<_>>();
13209 breach_rate_greater_than(&values, EM_FLUX_DIVERGENCE_MAX_BALANCED)
13210 } else {
13211 None
13212 };
13213 let electromagnetic_energy_imbalance_breach_rate =
13214 if kind == AnalysisRunKind::Electromagnetic {
13215 let values = entries
13216 .iter()
13217 .filter_map(|run| {
13218 diagnostic_metric(
13219 &run.run.diagnostics,
13220 "FEA_EM_SOURCE_ENERGY",
13221 "energy_imbalance_ratio",
13222 )
13223 })
13224 .collect::<Vec<_>>();
13225 breach_rate_greater_than(&values, EM_ENERGY_IMBALANCE_MAX_BALANCED)
13226 } else {
13227 None
13228 };
13229 let electromagnetic_boundary_energy_breach_rate =
13230 if kind == AnalysisRunKind::Electromagnetic {
13231 let values = entries
13232 .iter()
13233 .filter_map(|run| {
13234 diagnostic_metric(
13235 &run.run.diagnostics,
13236 "FEA_EM_SOURCE_ENERGY",
13237 "boundary_energy_ratio",
13238 )
13239 })
13240 .collect::<Vec<_>>();
13241 breach_rate_less_than(&values, EM_BOUNDARY_ENERGY_MIN_BALANCED)
13242 } else {
13243 None
13244 };
13245 let electromagnetic_boundary_penalty_contribution_breach_rate =
13246 if kind == AnalysisRunKind::Electromagnetic {
13247 let values = entries
13248 .iter()
13249 .filter_map(|run| {
13250 diagnostic_metric(
13251 &run.run.diagnostics,
13252 "FEA_EM_SOURCE_ENERGY",
13253 "boundary_penalty_conditioning_contribution",
13254 )
13255 })
13256 .collect::<Vec<_>>();
13257 breach_rate_greater_than(&values, EM_BOUNDARY_PENALTY_CONTRIBUTION_MAX_BALANCED)
13258 } else {
13259 None
13260 };
13261 let electromagnetic_source_region_energy_consistency_breach_rate =
13262 if kind == AnalysisRunKind::Electromagnetic {
13263 let values = entries
13264 .iter()
13265 .filter_map(|run| {
13266 diagnostic_metric(
13267 &run.run.diagnostics,
13268 "FEA_EM_SOURCE_ENERGY",
13269 "source_region_energy_consistency_ratio",
13270 )
13271 })
13272 .collect::<Vec<_>>();
13273 breach_rate_less_than(&values, EM_SOURCE_REGION_ENERGY_CONSISTENCY_MIN_BALANCED)
13274 } else {
13275 None
13276 };
13277 let electromagnetic_real_residual_breach_rate = if kind == AnalysisRunKind::Electromagnetic
13278 {
13279 let values = entries
13280 .iter()
13281 .filter_map(|run| {
13282 diagnostic_metric(&run.run.diagnostics, "FEA_EM_STATIC", "real_residual_norm")
13283 })
13284 .collect::<Vec<_>>();
13285 breach_rate_greater_than(&values, EM_REAL_RESIDUAL_MAX_BALANCED)
13286 } else {
13287 None
13288 };
13289 let electromagnetic_imag_residual_breach_rate = if kind == AnalysisRunKind::Electromagnetic
13290 {
13291 let values = entries
13292 .iter()
13293 .filter_map(|run| {
13294 diagnostic_metric(&run.run.diagnostics, "FEA_EM_STATIC", "imag_residual_norm")
13295 })
13296 .collect::<Vec<_>>();
13297 breach_rate_greater_than(&values, EM_IMAG_RESIDUAL_MAX_BALANCED)
13298 } else {
13299 None
13300 };
13301 let electromagnetic_sweep_coverage_breach_rate = if kind == AnalysisRunKind::Electromagnetic
13302 {
13303 let values = entries
13304 .iter()
13305 .filter_map(|run| {
13306 diagnostic_metric(&run.run.diagnostics, "FEA_EM_SWEEP", "sweep_count")
13307 })
13308 .collect::<Vec<_>>();
13309 breach_rate_less_than(&values, EM_SWEEP_COUNT_MIN_BALANCED)
13310 } else {
13311 None
13312 };
13313 let electromagnetic_resonance_sharpness_breach_rate =
13314 if kind == AnalysisRunKind::Electromagnetic {
13315 let values = entries
13316 .iter()
13317 .filter_map(|run| {
13318 diagnostic_metric(
13319 &run.run.diagnostics,
13320 "FEA_EM_SWEEP",
13321 "resonance_quality_factor",
13322 )
13323 })
13324 .collect::<Vec<_>>();
13325 breach_rate_less_than(&values, EM_RESONANCE_Q_MIN_BALANCED)
13326 } else {
13327 None
13328 };
13329
13330 summaries.push(AnalysisTrendKindSummary {
13331 run_kind: kind,
13332 sample_count,
13333 median_solve_ms,
13334 p95_solve_ms,
13335 publishable_rate,
13336 failed_increment_rate,
13337 mean_spike_count,
13338 mean_stall_count,
13339 prep_acceptance_rate,
13340 prep_calibration_fast_rate,
13341 prep_calibration_balanced_rate,
13342 prep_calibration_conservative_rate,
13343 thermo_coupling_enabled_rate,
13344 thermo_transient_warn_rate,
13345 thermo_nonlinear_warn_rate,
13346 thermo_spread_breach_rate,
13347 thermo_heterogeneity_breach_rate,
13348 electro_thermal_coupling_enabled_rate,
13349 electro_transient_warn_rate,
13350 electro_nonlinear_warn_rate,
13351 plastic_nonlinear_warn_rate,
13352 contact_nonlinear_warn_rate,
13353 thermal_stability_warn_rate,
13354 thermal_constitutive_warn_rate,
13355 thermal_spread_breach_rate,
13356 electromagnetic_solve_warn_rate,
13357 electromagnetic_spread_breach_rate,
13358 electromagnetic_heterogeneity_breach_rate,
13359 electromagnetic_coverage_breach_rate,
13360 electromagnetic_contrast_breach_rate,
13361 electromagnetic_conditioning_breach_rate,
13362 electromagnetic_source_realization_breach_rate,
13363 electromagnetic_source_region_coverage_breach_rate,
13364 electromagnetic_source_material_alignment_breach_rate,
13365 electromagnetic_source_overlap_breach_rate,
13366 electromagnetic_source_interference_breach_rate,
13367 electromagnetic_boundary_anchor_breach_rate,
13368 electromagnetic_boundary_localization_breach_rate,
13369 electromagnetic_ground_effectiveness_breach_rate,
13370 electromagnetic_insulation_leakage_breach_rate,
13371 electromagnetic_divergence_breach_rate,
13372 electromagnetic_energy_imbalance_breach_rate,
13373 electromagnetic_boundary_energy_breach_rate,
13374 electromagnetic_boundary_penalty_contribution_breach_rate,
13375 electromagnetic_source_region_energy_consistency_breach_rate,
13376 electromagnetic_real_residual_breach_rate,
13377 electromagnetic_imag_residual_breach_rate,
13378 electromagnetic_sweep_coverage_breach_rate,
13379 electromagnetic_resonance_sharpness_breach_rate,
13380 });
13381 }
13382
13383 Ok(OperationEnvelope::new(
13384 ANALYSIS_TRENDS_OPERATION,
13385 ANALYSIS_TRENDS_OP_VERSION,
13386 &context,
13387 AnalysisTrendsData {
13388 window_size: window,
13389 summaries,
13390 },
13391 ))
13392}
13393
13394fn run_kind(run: &AnalysisRunResult) -> AnalysisRunKind {
13395 if run
13396 .run
13397 .diagnostics
13398 .iter()
13399 .any(|diag| diag.code == "FEA_ACOUSTIC_HARMONIC_RESPONSE")
13400 {
13401 AnalysisRunKind::Acoustic
13402 } else if run.electromagnetic_results.is_some()
13403 || run
13404 .run
13405 .diagnostics
13406 .iter()
13407 .any(|diag| diag.code == "FEA_EM_STATIC")
13408 {
13409 AnalysisRunKind::Electromagnetic
13410 } else if run
13411 .run
13412 .diagnostics
13413 .iter()
13414 .any(|diag| diag.code == "FEA_CHT_COUPLING")
13415 {
13416 AnalysisRunKind::Cht
13417 } else if run
13418 .run
13419 .diagnostics
13420 .iter()
13421 .any(|diag| diag.code == "FEA_FSI_COUPLING")
13422 {
13423 AnalysisRunKind::Fsi
13424 } else if run
13425 .run
13426 .diagnostics
13427 .iter()
13428 .any(|diag| diag.code == "FEA_CFD_FLOW")
13429 {
13430 AnalysisRunKind::Cfd
13431 } else if run.nonlinear_results.is_some() {
13432 AnalysisRunKind::Nonlinear
13433 } else if run.thermal_results.is_some() {
13434 AnalysisRunKind::Thermal
13435 } else if run.transient_results.is_some() {
13436 AnalysisRunKind::Transient
13437 } else if run.modal_results.is_some() {
13438 AnalysisRunKind::Modal
13439 } else {
13440 AnalysisRunKind::LinearStatic
13441 }
13442}
13443
13444fn run_operation_version_for_kind(kind: AnalysisRunKind) -> &'static str {
13445 match kind {
13446 AnalysisRunKind::LinearStatic => ANALYSIS_RUN_OP_VERSION,
13447 AnalysisRunKind::Modal => ANALYSIS_RUN_MODAL_OP_VERSION,
13448 AnalysisRunKind::Acoustic => ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
13449 AnalysisRunKind::Thermal => ANALYSIS_RUN_THERMAL_OP_VERSION,
13450 AnalysisRunKind::Transient => ANALYSIS_RUN_TRANSIENT_OP_VERSION,
13451 AnalysisRunKind::Cfd => ANALYSIS_RUN_CFD_OP_VERSION,
13452 AnalysisRunKind::Cht => ANALYSIS_RUN_CHT_OP_VERSION,
13453 AnalysisRunKind::Fsi => ANALYSIS_RUN_FSI_OP_VERSION,
13454 AnalysisRunKind::Nonlinear => ANALYSIS_RUN_NONLINEAR_OP_VERSION,
13455 AnalysisRunKind::Electromagnetic => ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
13456 }
13457}
13458
13459fn run_operation_for_kind(kind: AnalysisRunKind) -> &'static str {
13460 match kind {
13461 AnalysisRunKind::LinearStatic => ANALYSIS_RUN_OPERATION,
13462 AnalysisRunKind::Modal => ANALYSIS_RUN_MODAL_OPERATION,
13463 AnalysisRunKind::Acoustic => ANALYSIS_RUN_ACOUSTIC_OPERATION,
13464 AnalysisRunKind::Thermal => ANALYSIS_RUN_THERMAL_OPERATION,
13465 AnalysisRunKind::Transient => ANALYSIS_RUN_TRANSIENT_OPERATION,
13466 AnalysisRunKind::Cfd => ANALYSIS_RUN_CFD_OPERATION,
13467 AnalysisRunKind::Cht => ANALYSIS_RUN_CHT_OPERATION,
13468 AnalysisRunKind::Fsi => ANALYSIS_RUN_FSI_OPERATION,
13469 AnalysisRunKind::Nonlinear => ANALYSIS_RUN_NONLINEAR_OPERATION,
13470 AnalysisRunKind::Electromagnetic => ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
13471 }
13472}
13473
13474fn sanitize_study_sweep_id(sweep_id: &str) -> String {
13475 sweep_id
13476 .chars()
13477 .map(|ch| {
13478 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
13479 ch
13480 } else {
13481 '_'
13482 }
13483 })
13484 .collect()
13485}
13486
13487fn validate_study_issue_codes(spec: &AnalysisStudySpec) -> Vec<String> {
13488 let mut issue_codes = Vec::new();
13489
13490 if spec.study_id.trim().is_empty() {
13491 issue_codes.push("RM.FEA.STUDY.ID_EMPTY".to_string());
13492 }
13493 if spec.create_model_intent.model_id.trim().is_empty() {
13494 issue_codes.push("RM.FEA.STUDY.MODEL_ID_EMPTY".to_string());
13495 }
13496 if spec.geometry.meshes.is_empty() {
13497 issue_codes.push("RM.FEA.STUDY.GEOMETRY_MESHES_EMPTY".to_string());
13498 }
13499 if spec.geometry.units == UnitSystem::Unspecified {
13500 issue_codes.push("RM.FEA.STUDY.GEOMETRY_UNITS_UNSPECIFIED".to_string());
13501 }
13502 if !profile_supports_run_kind(spec.create_model_intent.profile, spec.run_kind) {
13503 issue_codes.push("RM.FEA.STUDY.RUN_KIND_PROFILE_MISMATCH".to_string());
13504 }
13505 if let Some(model) = &spec.model {
13506 if model.geometry_id != spec.geometry.geometry_id
13507 || model.geometry_revision != spec.geometry.revision
13508 {
13509 issue_codes.push("RM.FEA.STUDY.MODEL_GEOMETRY_MISMATCH".to_string());
13510 }
13511 if validate_model_against_geometry(model, spec.geometry.units, &ReferenceFrame::Global)
13512 .is_err()
13513 {
13514 issue_codes.push("RM.FEA.STUDY.MODEL_INVALID".to_string());
13515 }
13516 }
13517 if spec.electromagnetic_run_options.is_some()
13518 && spec.run_kind != AnalysisRunKind::Electromagnetic
13519 {
13520 issue_codes.push("RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH".to_string());
13521 }
13522 if spec.linear_static_run_options.is_some() && spec.run_kind != AnalysisRunKind::LinearStatic
13523 || spec.modal_run_options.is_some() && spec.run_kind != AnalysisRunKind::Modal
13524 || spec.acoustic_run_options.is_some() && spec.run_kind != AnalysisRunKind::Acoustic
13525 || spec.thermal_run_options.is_some() && spec.run_kind != AnalysisRunKind::Thermal
13526 || spec.transient_run_options.is_some() && spec.run_kind != AnalysisRunKind::Transient
13527 || spec.cfd_run_options.is_some() && spec.run_kind != AnalysisRunKind::Cfd
13528 || spec.cht_run_options.is_some() && spec.run_kind != AnalysisRunKind::Cht
13529 || spec.fsi_run_options.is_some() && spec.run_kind != AnalysisRunKind::Fsi
13530 || spec.nonlinear_run_options.is_some() && spec.run_kind != AnalysisRunKind::Nonlinear
13531 {
13532 issue_codes.push("RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH".to_string());
13533 }
13534 if spec.run_kind == AnalysisRunKind::Electromagnetic {
13535 if let Some(options) = spec.electromagnetic_run_options.as_ref() {
13536 if !options.residual_target.is_finite() || options.residual_target <= 0.0 {
13537 issue_codes
13538 .push("RM.FEA.STUDY.ELECTROMAGNETIC_RESIDUAL_TARGET_INVALID".to_string());
13539 }
13540 if !options.harmonic_tolerance.is_finite() || options.harmonic_tolerance <= 0.0 {
13541 issue_codes
13542 .push("RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_TOLERANCE_INVALID".to_string());
13543 }
13544 if options.harmonic_max_iterations == 0 {
13545 issue_codes.push(
13546 "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_MAX_ITERATIONS_INVALID".to_string(),
13547 );
13548 }
13549 if options.sweep_enabled
13550 && !options
13551 .sweep_frequency_hz
13552 .iter()
13553 .all(|frequency_hz| frequency_hz.is_finite() && *frequency_hz > 0.0)
13554 {
13555 issue_codes
13556 .push("RM.FEA.STUDY.ELECTROMAGNETIC_SWEEP_FREQUENCY_INVALID".to_string());
13557 }
13558 }
13559 }
13560
13561 issue_codes
13562}
13563
13564fn study_issue_message(code: &str) -> &'static str {
13565 match code {
13566 "RM.FEA.STUDY.ID_EMPTY" => "study_id must be non-empty",
13567 "RM.FEA.STUDY.MODEL_ID_EMPTY" => "create_model_intent.model_id must be non-empty",
13568 "RM.FEA.STUDY.GEOMETRY_MESHES_EMPTY" => "geometry must contain at least one mesh",
13569 "RM.FEA.STUDY.GEOMETRY_UNITS_UNSPECIFIED" => {
13570 "geometry.units must be specified (not unspecified)"
13571 }
13572 "RM.FEA.STUDY.RUN_KIND_PROFILE_MISMATCH" => {
13573 "model.profile selects the solver; run kind must match the selected profile when supplied"
13574 }
13575 "RM.FEA.STUDY.MODEL_GEOMETRY_MISMATCH" => {
13576 "resolved model geometry id or revision does not match the study geometry"
13577 }
13578 "RM.FEA.STUDY.MODEL_INVALID" => "resolved model failed FEA validation",
13579 "RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH" => {
13580 "run options are only valid for the solver selected by model.profile"
13581 }
13582 "RM.FEA.STUDY.ELECTROMAGNETIC_RESIDUAL_TARGET_INVALID" => {
13583 "electromagnetic_run_options.residual_target must be finite and positive"
13584 }
13585 "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_TOLERANCE_INVALID" => {
13586 "electromagnetic_run_options.harmonic_tolerance must be finite and positive"
13587 }
13588 "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_MAX_ITERATIONS_INVALID" => {
13589 "electromagnetic_run_options.harmonic_max_iterations must be greater than zero"
13590 }
13591 "RM.FEA.STUDY.ELECTROMAGNETIC_SWEEP_FREQUENCY_INVALID" => {
13592 "electromagnetic_run_options.sweep_frequency_hz must contain finite positive values when sweep_enabled is true"
13593 }
13594 _ => "unrecognized study validation issue",
13595 }
13596}
13597
13598fn profile_supports_run_kind(
13599 profile: AnalysisCreateModelProfile,
13600 run_kind: AnalysisRunKind,
13601) -> bool {
13602 profile.derived_run_kind() == run_kind
13603}
13604
13605fn study_fingerprint(spec: &AnalysisStudySpec) -> String {
13606 let payload = serde_json::to_vec(spec).unwrap_or_else(|_| format!("{spec:?}").into_bytes());
13607 let mut hasher = Sha256::new();
13608 hasher.update(payload);
13609 format!("sha256:{:x}", hasher.finalize())
13610}
13611
13612fn study_operation_sequence(spec: &AnalysisStudySpec, run_op_version: &str) -> Vec<String> {
13613 let mut operation_sequence = Vec::with_capacity(3);
13614 if spec.model.is_none() {
13615 operation_sequence.push(ANALYSIS_CREATE_MODEL_OP_VERSION.to_string());
13616 }
13617 operation_sequence.push(ANALYSIS_VALIDATE_OP_VERSION.to_string());
13618 operation_sequence.push(run_op_version.to_string());
13619 operation_sequence
13620}
13621
13622fn study_run_options_json(spec: &AnalysisStudySpec) -> serde_json::Value {
13623 match spec.run_kind {
13624 AnalysisRunKind::LinearStatic => serde_json::to_value(&spec.linear_static_run_options),
13625 AnalysisRunKind::Modal => serde_json::to_value(&spec.modal_run_options),
13626 AnalysisRunKind::Acoustic => serde_json::to_value(&spec.acoustic_run_options),
13627 AnalysisRunKind::Thermal => serde_json::to_value(&spec.thermal_run_options),
13628 AnalysisRunKind::Transient => serde_json::to_value(&spec.transient_run_options),
13629 AnalysisRunKind::Cfd => serde_json::to_value(&spec.cfd_run_options),
13630 AnalysisRunKind::Cht => serde_json::to_value(&spec.cht_run_options),
13631 AnalysisRunKind::Fsi => serde_json::to_value(&spec.fsi_run_options),
13632 AnalysisRunKind::Nonlinear => serde_json::to_value(&spec.nonlinear_run_options),
13633 AnalysisRunKind::Electromagnetic => serde_json::to_value(&spec.electromagnetic_run_options),
13634 }
13635 .unwrap_or(serde_json::Value::Null)
13636}
13637
13638fn run_options_to_json<T: Serialize>(options: &T) -> serde_json::Value {
13639 serde_json::to_value(options).unwrap_or(serde_json::Value::Null)
13640}
13641
13642fn attach_prep_artifact_to_run_options(options: &mut AnalysisRunOptions, prep_artifact_id: &str) {
13643 if options.prep_artifact_id.is_none() {
13644 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13645 }
13646}
13647
13648fn attach_analysis_mesh_artifact_to_run_options(
13649 options: &mut AnalysisRunOptions,
13650 analysis_mesh_artifact_path: Option<&str>,
13651) {
13652 if options.analysis_mesh_artifact_path.is_none() {
13653 options.analysis_mesh_artifact_path = analysis_mesh_artifact_path.map(str::to_string);
13654 }
13655}
13656
13657fn attach_prep_artifact_to_modal_options(
13658 options: &mut AnalysisModalRunOptions,
13659 prep_artifact_id: &str,
13660) {
13661 if options.prep_artifact_id.is_none() {
13662 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13663 }
13664}
13665
13666fn attach_prep_artifact_to_acoustic_options(
13667 options: &mut AnalysisAcousticRunOptions,
13668 prep_artifact_id: &str,
13669) {
13670 if options.prep_artifact_id.is_none() {
13671 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13672 }
13673}
13674
13675fn attach_prep_artifact_to_thermal_options(
13676 options: &mut AnalysisThermalRunOptions,
13677 prep_artifact_id: &str,
13678) {
13679 if options.prep_artifact_id.is_none() {
13680 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13681 }
13682}
13683
13684fn attach_prep_artifact_to_transient_options(
13685 options: &mut AnalysisTransientRunOptions,
13686 prep_artifact_id: &str,
13687) {
13688 if options.prep_artifact_id.is_none() {
13689 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13690 }
13691}
13692
13693fn attach_prep_artifact_to_cfd_options(
13694 options: &mut AnalysisCfdRunOptions,
13695 prep_artifact_id: &str,
13696) {
13697 if options.prep_artifact_id.is_none() {
13698 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13699 }
13700}
13701
13702fn attach_prep_artifact_to_cht_options(
13703 options: &mut AnalysisChtRunOptions,
13704 prep_artifact_id: &str,
13705) {
13706 if options.prep_artifact_id.is_none() {
13707 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13708 }
13709}
13710
13711fn attach_prep_artifact_to_fsi_options(
13712 options: &mut AnalysisFsiRunOptions,
13713 prep_artifact_id: &str,
13714) {
13715 if options.prep_artifact_id.is_none() {
13716 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13717 }
13718}
13719
13720fn attach_prep_artifact_to_nonlinear_options(
13721 options: &mut AnalysisNonlinearRunOptions,
13722 prep_artifact_id: &str,
13723) {
13724 if options.prep_artifact_id.is_none() {
13725 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13726 }
13727}
13728
13729fn attach_prep_artifact_to_electromagnetic_options(
13730 options: &mut AnalysisElectromagneticRunOptions,
13731 prep_artifact_id: &str,
13732) {
13733 if options.prep_artifact_id.is_none() {
13734 options.prep_artifact_id = Some(prep_artifact_id.to_string());
13735 }
13736}
13737
13738#[derive(Debug, Clone)]
13739struct StudyAnalysisMeshArtifact {
13740 path: String,
13741 evidence_path: String,
13742 allow_refinement: bool,
13743}
13744
13745fn generate_and_persist_study_analysis_mesh(
13746 spec: &AnalysisStudySpec,
13747 study_fingerprint: &str,
13748 context: &OperationContext,
13749) -> Result<Option<StudyAnalysisMeshArtifact>, OperationErrorEnvelope> {
13750 if let Some(path) = spec.analysis_mesh_artifact_path.as_deref() {
13751 resolve_analysis_mesh_artifact(
13752 Some(path),
13753 ANALYSIS_RUN_STUDY_OPERATION,
13754 ANALYSIS_RUN_STUDY_OP_VERSION,
13755 context,
13756 )?;
13757 let evidence_path = match spec.analysis_mesh_evidence_artifact_path.clone() {
13758 Some(path) => path,
13759 None => analysis_mesh_evidence_path_from_artifact(path, context)?,
13760 };
13761 return Ok(Some(StudyAnalysisMeshArtifact {
13762 path: path.to_string(),
13763 evidence_path,
13764 allow_refinement: false,
13765 }));
13766 }
13767
13768 let Some(options) = spec.mesh_options.clone() else {
13769 return Ok(None);
13770 };
13771 let options = mesh_options_in_si_units(options, spec.geometry.units);
13772 let mut mesh =
13773 generate_study_analysis_mesh_with_initial_boundary_focus(spec, &options, context)?;
13774 attach_requested_boundary_regions_to_analysis_mesh(spec, &mut mesh);
13775 attach_single_material_assignment_to_analysis_mesh(spec, &mut mesh);
13776 attach_initial_adaptive_mesh_summary(spec, &options, &mut mesh);
13777 let validation_options =
13778 analysis_mesh_validation_options_for_generated_mesh(spec, &options, &mesh);
13779 runmat_meshing_core::validate_analysis_mesh_with_options(&mesh, validation_options.clone())
13780 .map_err(|err| {
13781 operation_error(
13782 ANALYSIS_RUN_STUDY_OPERATION,
13783 ANALYSIS_RUN_STUDY_OP_VERSION,
13784 context,
13785 OperationErrorSpec {
13786 error_code: "RM.FEA.RUN_STUDY.MESH_VALIDATION_FAILED",
13787 error_type: OperationErrorType::Validation,
13788 retryable: false,
13789 severity: OperationErrorSeverity::Error,
13790 },
13791 format!("generated analysis mesh failed validation: {err:?}"),
13792 BTreeMap::from([
13793 ("study_id".to_string(), spec.study_id.clone()),
13794 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
13795 ("mesh_id".to_string(), mesh.mesh_id.clone()),
13796 (
13797 "mesh_validation_code".to_string(),
13798 runmat_meshing_core::analysis_mesh_validation_error_code(&err).to_string(),
13799 ),
13800 ]),
13801 )
13802 })?;
13803 let mesh_evidence = build_mesh_evidence_artifact(&mesh, &validation_options);
13804 let mesh_authoring_summary = build_mesh_authoring_summary(&mesh_evidence);
13805 let evidence_path = persist_study_evidence(
13806 study_fingerprint,
13807 "mesh_evidence",
13808 serde_json::json!({
13809 "schema_version": "fea_study_mesh_evidence_artifact/v1",
13810 "study_id": spec.study_id.clone(),
13811 "geometry_id": spec.geometry.geometry_id.clone(),
13812 "geometry_revision": spec.geometry.revision,
13813 "analysis_profile": spec.create_model_intent.profile.as_snake_case(),
13814 "run_kind": spec.run_kind.as_snake_case(),
13815 "refinement_context": analysis_refinement_context(spec),
13816 "mesh_options": options,
13817 "mesh_validation_options": validation_options,
13818 "mesh_authoring_summary": mesh_authoring_summary,
13819 "mesh_evidence": mesh_evidence,
13820 }),
13821 )
13822 .map_err(|err| {
13823 operation_error(
13824 ANALYSIS_RUN_STUDY_OPERATION,
13825 ANALYSIS_RUN_STUDY_OP_VERSION,
13826 context,
13827 OperationErrorSpec {
13828 error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
13829 error_type: OperationErrorType::Internal,
13830 retryable: true,
13831 severity: OperationErrorSeverity::Error,
13832 },
13833 format!("failed to persist mesh evidence artifact: {err}"),
13834 BTreeMap::from([
13835 ("study_id".to_string(), spec.study_id.clone()),
13836 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
13837 ]),
13838 )
13839 })?;
13840
13841 let mesh_path = persist_study_evidence(
13842 study_fingerprint,
13843 "analysis_mesh",
13844 serde_json::json!({
13845 "schema_version": "fea_study_analysis_mesh_artifact/v1",
13846 "study_id": spec.study_id.clone(),
13847 "geometry_id": spec.geometry.geometry_id.clone(),
13848 "geometry_revision": spec.geometry.revision,
13849 "mesh_evidence_artifact_path": evidence_path.clone(),
13850 "analysis_profile": spec.create_model_intent.profile.as_snake_case(),
13851 "run_kind": spec.run_kind.as_snake_case(),
13852 "refinement_context": analysis_refinement_context(spec),
13853 "mesh_options": options,
13854 "mesh_validation_options": validation_options,
13855 "sizing_applications": sizing_application_summary(&mesh),
13856 "sizing_rejections": sizing_rejection_summary(&mesh),
13857 "mesh": mesh,
13858 }),
13859 )
13860 .map_err(|err| {
13861 operation_error(
13862 ANALYSIS_RUN_STUDY_OPERATION,
13863 ANALYSIS_RUN_STUDY_OP_VERSION,
13864 context,
13865 OperationErrorSpec {
13866 error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
13867 error_type: OperationErrorType::Internal,
13868 retryable: true,
13869 severity: OperationErrorSeverity::Error,
13870 },
13871 format!("failed to persist analysis mesh artifact: {err}"),
13872 BTreeMap::from([
13873 ("study_id".to_string(), spec.study_id.clone()),
13874 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
13875 ]),
13876 )
13877 })?;
13878 Ok(Some(StudyAnalysisMeshArtifact {
13879 path: mesh_path,
13880 evidence_path,
13881 allow_refinement: true,
13882 }))
13883}
13884
13885fn analysis_mesh_evidence_path_from_artifact(
13886 analysis_mesh_artifact_path: &str,
13887 context: &OperationContext,
13888) -> Result<String, OperationErrorEnvelope> {
13889 let bytes = fs_read(analysis_mesh_artifact_path).map_err(|err| {
13890 operation_error(
13891 ANALYSIS_RUN_STUDY_OPERATION,
13892 ANALYSIS_RUN_STUDY_OP_VERSION,
13893 context,
13894 OperationErrorSpec {
13895 error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_READ_FAILED",
13896 error_type: OperationErrorType::Input,
13897 retryable: false,
13898 severity: OperationErrorSeverity::Error,
13899 },
13900 format!("failed to read analysis mesh artifact: {err}"),
13901 BTreeMap::from([(
13902 "analysis_mesh_artifact_path".to_string(),
13903 analysis_mesh_artifact_path.to_string(),
13904 )]),
13905 )
13906 })?;
13907 let payload = serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|err| {
13908 operation_error(
13909 ANALYSIS_RUN_STUDY_OPERATION,
13910 ANALYSIS_RUN_STUDY_OP_VERSION,
13911 context,
13912 OperationErrorSpec {
13913 error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_PARSE_FAILED",
13914 error_type: OperationErrorType::Input,
13915 retryable: false,
13916 severity: OperationErrorSeverity::Error,
13917 },
13918 format!("failed to parse analysis mesh artifact: {err}"),
13919 BTreeMap::from([(
13920 "analysis_mesh_artifact_path".to_string(),
13921 analysis_mesh_artifact_path.to_string(),
13922 )]),
13923 )
13924 })?;
13925 payload
13926 .get("mesh_evidence_artifact_path")
13927 .and_then(serde_json::Value::as_str)
13928 .map(str::to_string)
13929 .ok_or_else(|| {
13930 operation_error(
13931 ANALYSIS_RUN_STUDY_OPERATION,
13932 ANALYSIS_RUN_STUDY_OP_VERSION,
13933 context,
13934 OperationErrorSpec {
13935 error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_EVIDENCE_MISSING",
13936 error_type: OperationErrorType::Input,
13937 retryable: false,
13938 severity: OperationErrorSeverity::Error,
13939 },
13940 "analysis mesh artifact does not reference a mesh evidence artifact",
13941 BTreeMap::from([(
13942 "analysis_mesh_artifact_path".to_string(),
13943 analysis_mesh_artifact_path.to_string(),
13944 )]),
13945 )
13946 })
13947}
13948
13949fn generate_study_analysis_mesh_with_initial_boundary_focus(
13950 spec: &AnalysisStudySpec,
13951 options: &VolumeMeshingOptions,
13952 context: &OperationContext,
13953) -> Result<AnalysisMeshArtifact, OperationErrorEnvelope> {
13954 let mut mesh = generate_analysis_mesh(&spec.geometry, options.clone()).map_err(|err| {
13955 analysis_mesh_generation_error(
13956 spec,
13957 context,
13958 "RM.FEA.RUN_STUDY.MESH_GENERATION_FAILED",
13959 format!("failed to generate analysis mesh: {err}"),
13960 )
13961 })?;
13962 attach_requested_boundary_regions_to_analysis_mesh(spec, &mut mesh);
13963 attach_single_material_assignment_to_analysis_mesh(spec, &mut mesh);
13964 let Some(sizing) = initial_boundary_focus_sizing_field(spec, options, &mesh) else {
13965 return Ok(mesh);
13966 };
13967 let focused_mesh = generate_analysis_mesh_with_sizing(&spec.geometry, options.clone(), &sizing)
13968 .map_err(|err| {
13969 analysis_mesh_generation_error(
13970 spec,
13971 context,
13972 "RM.FEA.RUN_STUDY.MESH_GENERATION_FAILED",
13973 format!("failed to generate analysis mesh with boundary focus sizing: {err}"),
13974 )
13975 })?;
13976 Ok(focused_mesh)
13977}
13978
13979fn analysis_mesh_generation_error(
13980 spec: &AnalysisStudySpec,
13981 context: &OperationContext,
13982 error_code: &'static str,
13983 message: String,
13984) -> OperationErrorEnvelope {
13985 operation_error(
13986 ANALYSIS_RUN_STUDY_OPERATION,
13987 ANALYSIS_RUN_STUDY_OP_VERSION,
13988 context,
13989 OperationErrorSpec {
13990 error_code,
13991 error_type: OperationErrorType::Validation,
13992 retryable: false,
13993 severity: OperationErrorSeverity::Error,
13994 },
13995 message,
13996 BTreeMap::from([
13997 ("study_id".to_string(), spec.study_id.clone()),
13998 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
13999 ]),
14000 )
14001}
14002
14003fn mesh_options_in_si_units(
14004 mut options: VolumeMeshingOptions,
14005 geometry_units: UnitSystem,
14006) -> VolumeMeshingOptions {
14007 let scale = geometry_unit_scale_to_meters(geometry_units);
14008 if let MeshTargetSize::LengthM(length) = options.target_size {
14009 options.target_size = MeshTargetSize::LengthM(length * scale);
14010 }
14011 if let Some(min_size_m) = options.min_size_m {
14012 options.min_size_m = Some(min_size_m * scale);
14013 }
14014 if let Some(max_size_m) = options.max_size_m {
14015 options.max_size_m = Some(max_size_m * scale);
14016 }
14017 options
14018}
14019
14020fn analysis_mesh_validation_options_for_study(
14021 spec: &AnalysisStudySpec,
14022 options: &VolumeMeshingOptions,
14023) -> AnalysisMeshValidationOptions {
14024 AnalysisMeshValidationOptions {
14025 quality: options.validation.quality,
14026 max_volume_element_count: Some(options.max_elements),
14027 max_volume_component_count: options.validation.max_volume_component_count,
14028 expected_bounds_m: geometry_surface_bounds_m(&spec.geometry),
14029 expected_volume_m3: geometry_enclosed_volume_m3(&spec.geometry),
14030 expected_boundary_area_m2: geometry_surface_area_m2(&spec.geometry),
14031 min_bounds_coverage_ratio: options.validation.min_bounds_coverage_ratio,
14032 min_volume_coverage_ratio: options.validation.min_volume_coverage_ratio,
14033 min_boundary_area_ratio: options.validation.min_boundary_area_ratio,
14034 min_boundary_face_recovery_ratio: options.validation.min_boundary_face_recovery_ratio,
14035 min_boundary_edge_recovery_ratio: options.validation.min_boundary_edge_recovery_ratio,
14036 required_boundary_region_ids: required_boundary_region_ids_for_study(spec),
14037 required_material_region_ids: required_material_region_ids_for_study(spec),
14038 ..AnalysisMeshValidationOptions::default()
14039 }
14040}
14041
14042fn analysis_mesh_validation_options_for_generated_mesh(
14043 spec: &AnalysisStudySpec,
14044 options: &VolumeMeshingOptions,
14045 mesh: &AnalysisMeshArtifact,
14046) -> AnalysisMeshValidationOptions {
14047 let mut validation = analysis_mesh_validation_options_for_study(spec, options);
14048 if !is_solid_mesh_backend(mesh) {
14049 validation.min_boundary_edge_recovery_ratio = 0.0;
14050 } else if validation.max_volume_component_count.is_none()
14051 && mesh.backend.volume_component_count > 0
14052 {
14053 validation.max_volume_component_count = Some(mesh.backend.volume_component_count);
14054 }
14055 if is_solid_mesh_backend(mesh) {
14056 validation.require_no_unrecovered_tetrahedron_components = true;
14057 validation.require_no_unrepaired_exact_quality = true;
14058 validation.require_boundary_source_edge_provenance =
14059 mesh.backend.plc_input_protected_edge_count > 0;
14060 validation.coverage_sample_points_m = solid_body_coverage_sample_points(mesh);
14061 validation.min_coverage_sample_ratio = 1.0;
14062 }
14063 validation
14064}
14065
14066fn analysis_mesh_validation_options_for_loaded_artifact(
14067 payload: &serde_json::Value,
14068 mesh: &AnalysisMeshArtifact,
14069) -> Result<AnalysisMeshValidationOptions, serde_json::Error> {
14070 if let Some(validation_value) = payload.get("mesh_validation_options") {
14071 return serde_json::from_value::<AnalysisMeshValidationOptions>(validation_value.clone());
14072 }
14073 let Some(options_value) = payload.get("mesh_options") else {
14074 return Ok(AnalysisMeshValidationOptions::default());
14075 };
14076 let options = serde_json::from_value::<VolumeMeshingOptions>(options_value.clone())?;
14077 let mut validation = AnalysisMeshValidationOptions {
14078 quality: options.validation.quality,
14079 max_volume_element_count: Some(options.max_elements),
14080 max_volume_component_count: options.validation.max_volume_component_count,
14081 min_bounds_coverage_ratio: options.validation.min_bounds_coverage_ratio,
14082 min_volume_coverage_ratio: options.validation.min_volume_coverage_ratio,
14083 min_boundary_area_ratio: options.validation.min_boundary_area_ratio,
14084 min_boundary_face_recovery_ratio: options.validation.min_boundary_face_recovery_ratio,
14085 min_boundary_edge_recovery_ratio: options.validation.min_boundary_edge_recovery_ratio,
14086 ..AnalysisMeshValidationOptions::default()
14087 };
14088 if !is_solid_mesh_backend(mesh) {
14089 validation.min_boundary_edge_recovery_ratio = 0.0;
14090 } else if validation.max_volume_component_count.is_none()
14091 && mesh.backend.volume_component_count > 0
14092 {
14093 validation.max_volume_component_count = Some(mesh.backend.volume_component_count);
14094 }
14095 if is_solid_mesh_backend(mesh) {
14096 validation.require_no_unrecovered_tetrahedron_components = true;
14097 validation.require_no_unrepaired_exact_quality = true;
14098 validation.require_boundary_source_edge_provenance =
14099 mesh.backend.plc_input_protected_edge_count > 0;
14100 validation.coverage_sample_points_m = solid_body_coverage_sample_points(mesh);
14101 validation.min_coverage_sample_ratio = 1.0;
14102 }
14103 Ok(validation)
14104}
14105
14106fn is_solid_mesh_backend(mesh: &AnalysisMeshArtifact) -> bool {
14107 mesh.backend.backend == "solid"
14108}
14109
14110fn solid_body_coverage_sample_points(mesh: &AnalysisMeshArtifact) -> Vec<[f64; 3]> {
14111 mesh.nodes
14112 .iter()
14113 .filter(|node| {
14114 node.coordinates_m.iter().all(|value| value.is_finite())
14115 && node
14116 .provenance
14117 .iter()
14118 .any(|provenance| provenance.source_entity_kind == SourceEntityKind::Body)
14119 })
14120 .map(|node| node.coordinates_m)
14121 .take(64)
14122 .collect()
14123}
14124
14125fn geometry_surface_bounds_m(geometry: &GeometryAsset) -> Option<[[f64; 3]; 2]> {
14126 let scale = geometry_unit_scale_to_meters(geometry.units);
14127 let mut bounds = None::<[[f64; 3]; 2]>;
14128 for vertex in geometry
14129 .surface_meshes
14130 .iter()
14131 .flat_map(|surface| surface.vertices.iter())
14132 {
14133 let point = [vertex[0] * scale, vertex[1] * scale, vertex[2] * scale];
14134 if point.iter().any(|coordinate| !coordinate.is_finite()) {
14135 continue;
14136 }
14137 match bounds.as_mut() {
14138 Some(bounds) => {
14139 for axis in 0..3 {
14140 bounds[0][axis] = bounds[0][axis].min(point[axis]);
14141 bounds[1][axis] = bounds[1][axis].max(point[axis]);
14142 }
14143 }
14144 None => bounds = Some([point, point]),
14145 }
14146 }
14147 bounds
14148}
14149
14150fn geometry_surface_area_m2(geometry: &GeometryAsset) -> Option<f64> {
14151 let scale = geometry_unit_scale_to_meters(geometry.units);
14152 let mut total_area = 0.0_f64;
14153 for surface in &geometry.surface_meshes {
14154 for triangle in &surface.triangles {
14155 let Some(vertices) = surface_triangle_vertices(surface, *triangle) else {
14156 continue;
14157 };
14158 let area = triangle_area(scale_triangle_vertices(vertices, scale));
14159 if area.is_finite() && area > 0.0 {
14160 total_area += area;
14161 }
14162 }
14163 }
14164 (total_area.is_finite() && total_area > 0.0).then_some(total_area)
14165}
14166
14167fn geometry_enclosed_volume_m3(geometry: &GeometryAsset) -> Option<f64> {
14168 let scale = geometry_unit_scale_to_meters(geometry.units);
14169 let mut signed_volume = 0.0_f64;
14170 for surface in &geometry.surface_meshes {
14171 for triangle in &surface.triangles {
14172 let Some(vertices) = surface_triangle_vertices(surface, *triangle) else {
14173 continue;
14174 };
14175 let contribution =
14176 signed_triangle_volume_from_origin(scale_triangle_vertices(vertices, scale));
14177 if contribution.is_finite() {
14178 signed_volume += contribution;
14179 }
14180 }
14181 }
14182 let volume = signed_volume.abs();
14183 (volume.is_finite() && volume > f64::EPSILON).then_some(volume)
14184}
14185
14186fn required_boundary_region_ids_for_study(spec: &AnalysisStudySpec) -> Vec<String> {
14187 let Some(model) = spec.model.as_ref() else {
14188 return Vec::new();
14189 };
14190 let mut region_ids = model
14191 .loads
14192 .iter()
14193 .filter(|load| load_requires_boundary_region(&load.kind))
14194 .map(|load| load.region_id.clone())
14195 .chain(
14196 model
14197 .boundary_conditions
14198 .iter()
14199 .map(|condition| condition.region_id.clone()),
14200 )
14201 .collect::<Vec<_>>();
14202 region_ids.sort();
14203 region_ids.dedup();
14204 region_ids
14205}
14206
14207fn required_material_region_ids_for_study(spec: &AnalysisStudySpec) -> Vec<String> {
14208 let Some(model) = spec.model.as_ref() else {
14209 return Vec::new();
14210 };
14211 let mut region_ids = model
14212 .material_assignments
14213 .iter()
14214 .map(|assignment| assignment.region_id.clone())
14215 .collect::<Vec<_>>();
14216 region_ids.sort();
14217 region_ids.dedup();
14218 region_ids
14219}
14220
14221fn geometry_unit_scale_to_meters(units: UnitSystem) -> f64 {
14222 match units {
14223 UnitSystem::Meter | UnitSystem::Unspecified => 1.0,
14224 UnitSystem::Millimeter => 0.001,
14225 UnitSystem::Inch => 0.0254,
14226 }
14227}
14228
14229fn attach_requested_boundary_regions_to_analysis_mesh(
14230 spec: &AnalysisStudySpec,
14231 mesh: &mut AnalysisMeshArtifact,
14232) {
14233 let Some(model) = spec.model.as_ref() else {
14234 return;
14235 };
14236 let mut requested_region_ids = model
14237 .loads
14238 .iter()
14239 .filter(|load| load_requires_boundary_region(&load.kind))
14240 .map(|load| load.region_id.clone())
14241 .chain(
14242 model
14243 .boundary_conditions
14244 .iter()
14245 .map(|condition| condition.region_id.clone()),
14246 )
14247 .collect::<Vec<_>>();
14248 requested_region_ids.sort();
14249 requested_region_ids.dedup();
14250
14251 for region_id in requested_region_ids {
14252 if analysis_mesh_has_boundary_region(mesh, ®ion_id) {
14253 continue;
14254 }
14255 let Some(source_centroid) = source_region_surface_centroid(&spec.geometry, ®ion_id)
14256 else {
14257 continue;
14258 };
14259 let Some(face_index) = nearest_analysis_boundary_face_index(mesh, source_centroid) else {
14260 continue;
14261 };
14262 let face = &mut mesh.boundary_faces[face_index];
14263 if !face
14264 .region_ids
14265 .iter()
14266 .any(|existing| existing == ®ion_id)
14267 {
14268 face.region_ids.push(region_id);
14269 face.region_ids.sort();
14270 face.region_ids.dedup();
14271 }
14272 }
14273}
14274
14275fn analysis_mesh_has_boundary_region(mesh: &AnalysisMeshArtifact, region_id: &str) -> bool {
14276 mesh.boundary_faces
14277 .iter()
14278 .any(|face| face.region_ids.iter().any(|existing| existing == region_id))
14279}
14280
14281fn attach_single_material_assignment_to_analysis_mesh(
14282 spec: &AnalysisStudySpec,
14283 mesh: &mut AnalysisMeshArtifact,
14284) {
14285 let Some(model) = spec.model.as_ref() else {
14286 return;
14287 };
14288 let mut assigned_region_ids = model
14289 .material_assignments
14290 .iter()
14291 .map(|assignment| assignment.region_id.clone())
14292 .collect::<Vec<_>>();
14293 assigned_region_ids.sort();
14294 assigned_region_ids.dedup();
14295 let [region_id] = assigned_region_ids.as_slice() else {
14296 return;
14297 };
14298 if mesh
14299 .volume_elements
14300 .iter()
14301 .any(|element| element.material_region_id == *region_id)
14302 {
14303 return;
14304 }
14305 for element in &mut mesh.volume_elements {
14306 element.material_region_id = region_id.clone();
14307 }
14308}
14309
14310fn source_region_surface_centroid(geometry: &GeometryAsset, region_id: &str) -> Option<[f64; 3]> {
14311 let mut weighted = [0.0_f64; 3];
14312 let mut total_area = 0.0_f64;
14313 for mapping in geometry
14314 .region_entity_mappings
14315 .iter()
14316 .filter(|mapping| mapping.region_id == region_id)
14317 .filter(|mapping| matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element))
14318 {
14319 let Some(surface) = geometry
14320 .surface_meshes
14321 .iter()
14322 .find(|surface| surface.mesh_id == mapping.mesh_id)
14323 else {
14324 continue;
14325 };
14326 for (triangle_index, triangle) in surface.triangles.iter().enumerate() {
14327 if !mapping.contains_entity(triangle_index as u64) {
14328 continue;
14329 }
14330 let Some(vertices) = surface_triangle_vertices(surface, *triangle) else {
14331 continue;
14332 };
14333 let area = triangle_area(vertices);
14334 if !area.is_finite() || area <= 0.0 {
14335 continue;
14336 }
14337 let centroid = triangle_centroid(vertices);
14338 for axis in 0..3 {
14339 weighted[axis] += centroid[axis] * area;
14340 }
14341 total_area += area;
14342 }
14343 }
14344 if total_area <= 0.0 || !total_area.is_finite() {
14345 return None;
14346 }
14347 Some([
14348 weighted[0] / total_area,
14349 weighted[1] / total_area,
14350 weighted[2] / total_area,
14351 ])
14352}
14353
14354fn surface_triangle_vertices(
14355 surface: &runmat_geometry_core::SurfaceMesh,
14356 triangle: [u32; 3],
14357) -> Option<[[f64; 3]; 3]> {
14358 Some([
14359 *surface.vertices.get(triangle[0] as usize)?,
14360 *surface.vertices.get(triangle[1] as usize)?,
14361 *surface.vertices.get(triangle[2] as usize)?,
14362 ])
14363}
14364
14365fn scale_triangle_vertices(vertices: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
14366 [
14367 [
14368 vertices[0][0] * scale,
14369 vertices[0][1] * scale,
14370 vertices[0][2] * scale,
14371 ],
14372 [
14373 vertices[1][0] * scale,
14374 vertices[1][1] * scale,
14375 vertices[1][2] * scale,
14376 ],
14377 [
14378 vertices[2][0] * scale,
14379 vertices[2][1] * scale,
14380 vertices[2][2] * scale,
14381 ],
14382 ]
14383}
14384
14385fn triangle_centroid(vertices: [[f64; 3]; 3]) -> [f64; 3] {
14386 [
14387 (vertices[0][0] + vertices[1][0] + vertices[2][0]) / 3.0,
14388 (vertices[0][1] + vertices[1][1] + vertices[2][1]) / 3.0,
14389 (vertices[0][2] + vertices[1][2] + vertices[2][2]) / 3.0,
14390 ]
14391}
14392
14393fn triangle_area(vertices: [[f64; 3]; 3]) -> f64 {
14394 let ab = [
14395 vertices[1][0] - vertices[0][0],
14396 vertices[1][1] - vertices[0][1],
14397 vertices[1][2] - vertices[0][2],
14398 ];
14399 let ac = [
14400 vertices[2][0] - vertices[0][0],
14401 vertices[2][1] - vertices[0][1],
14402 vertices[2][2] - vertices[0][2],
14403 ];
14404 let cross = [
14405 ab[1] * ac[2] - ab[2] * ac[1],
14406 ab[2] * ac[0] - ab[0] * ac[2],
14407 ab[0] * ac[1] - ab[1] * ac[0],
14408 ];
14409 0.5 * (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt()
14410}
14411
14412fn signed_triangle_volume_from_origin(vertices: [[f64; 3]; 3]) -> f64 {
14413 let cross = [
14414 vertices[1][1] * vertices[2][2] - vertices[1][2] * vertices[2][1],
14415 vertices[1][2] * vertices[2][0] - vertices[1][0] * vertices[2][2],
14416 vertices[1][0] * vertices[2][1] - vertices[1][1] * vertices[2][0],
14417 ];
14418 (vertices[0][0] * cross[0] + vertices[0][1] * cross[1] + vertices[0][2] * cross[2]) / 6.0
14419}
14420
14421fn nearest_analysis_boundary_face_index(
14422 mesh: &AnalysisMeshArtifact,
14423 point_m: [f64; 3],
14424) -> Option<usize> {
14425 mesh.boundary_faces
14426 .iter()
14427 .enumerate()
14428 .filter_map(|(index, face)| {
14429 let centroid = analysis_boundary_face_centroid(mesh, &face.node_ids)?;
14430 Some((index, vector_distance_m(centroid, point_m)))
14431 })
14432 .filter(|(_, distance)| distance.is_finite())
14433 .min_by(|left, right| left.1.total_cmp(&right.1))
14434 .map(|(index, _)| index)
14435}
14436
14437fn analysis_boundary_face_centroid(
14438 mesh: &AnalysisMeshArtifact,
14439 node_ids: &[u32],
14440) -> Option<[f64; 3]> {
14441 if node_ids.is_empty() {
14442 return None;
14443 }
14444 let mut centroid = [0.0_f64; 3];
14445 for node_id in node_ids {
14446 let node = mesh.nodes.iter().find(|node| node.node_id == *node_id)?;
14447 for (axis, value) in node.coordinates_m.iter().enumerate() {
14448 centroid[axis] += *value;
14449 }
14450 }
14451 for value in &mut centroid {
14452 *value /= node_ids.len() as f64;
14453 }
14454 Some(centroid)
14455}
14456
14457#[derive(Debug, Clone)]
14458struct RefinedAnalysisMeshArtifact {
14459 path: String,
14460 evidence_path: String,
14461 refinement_effect: serde_json::Value,
14462}
14463
14464fn generate_and_persist_refined_study_analysis_mesh(
14465 spec: &AnalysisStudySpec,
14466 study_fingerprint: &str,
14467 analysis_mesh_artifact_path: Option<&str>,
14468 context: &OperationContext,
14469) -> Result<Option<RefinedAnalysisMeshArtifact>, OperationErrorEnvelope> {
14470 let Some(path) = analysis_mesh_artifact_path else {
14471 return Ok(None);
14472 };
14473 let path_buf = PathBuf::from(path);
14474 let bytes = fs_read(path).map_err(|err| {
14475 operation_error(
14476 ANALYSIS_RUN_STUDY_OPERATION,
14477 ANALYSIS_RUN_STUDY_OP_VERSION,
14478 context,
14479 OperationErrorSpec {
14480 error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_READ_FAILED",
14481 error_type: OperationErrorType::Input,
14482 retryable: false,
14483 severity: OperationErrorSeverity::Error,
14484 },
14485 format!("failed to read analysis mesh artifact for refinement: {err}"),
14486 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
14487 )
14488 })?;
14489 let payload = serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|err| {
14490 operation_error(
14491 ANALYSIS_RUN_STUDY_OPERATION,
14492 ANALYSIS_RUN_STUDY_OP_VERSION,
14493 context,
14494 OperationErrorSpec {
14495 error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_PARSE_FAILED",
14496 error_type: OperationErrorType::Input,
14497 retryable: false,
14498 severity: OperationErrorSeverity::Error,
14499 },
14500 format!("failed to parse analysis mesh artifact for refinement: {err}"),
14501 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
14502 )
14503 })?;
14504 let options = payload
14505 .get("mesh_options")
14506 .cloned()
14507 .map(serde_json::from_value::<runmat_meshing_core::VolumeMeshingOptions>)
14508 .transpose()
14509 .map_err(|err| {
14510 operation_error(
14511 ANALYSIS_RUN_STUDY_OPERATION,
14512 ANALYSIS_RUN_STUDY_OP_VERSION,
14513 context,
14514 OperationErrorSpec {
14515 error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_PARSE_FAILED",
14516 error_type: OperationErrorType::Input,
14517 retryable: false,
14518 severity: OperationErrorSeverity::Error,
14519 },
14520 format!("failed to decode analysis mesh options for refinement: {err}"),
14521 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
14522 )
14523 })?;
14524 let Some(options) = options else {
14525 return Ok(None);
14526 };
14527 let mesh: AnalysisMeshArtifact =
14528 serde_json::from_value(payload["mesh"].clone()).map_err(|err| {
14529 operation_error(
14530 ANALYSIS_RUN_STUDY_OPERATION,
14531 ANALYSIS_RUN_STUDY_OP_VERSION,
14532 context,
14533 OperationErrorSpec {
14534 error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_PARSE_FAILED",
14535 error_type: OperationErrorType::Input,
14536 retryable: false,
14537 severity: OperationErrorSeverity::Error,
14538 },
14539 format!("failed to decode analysis mesh payload for refinement: {err}"),
14540 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
14541 )
14542 })?;
14543 let Some(latest_iteration) = mesh.adaptive_iterations.last() else {
14544 return Ok(None);
14545 };
14546 if latest_iteration.convergence_status != AdaptiveConvergenceStatus::Pending
14547 || mesh.sizing.samples.is_empty()
14548 || mesh.adaptive_iterations.len() >= options.refinement.max_iterations
14549 {
14550 return Ok(None);
14551 }
14552
14553 let mut refined_mesh =
14554 generate_analysis_mesh_with_sizing(&spec.geometry, options.clone(), &mesh.sizing).map_err(
14555 |err| {
14556 operation_error(
14557 ANALYSIS_RUN_STUDY_OPERATION,
14558 ANALYSIS_RUN_STUDY_OP_VERSION,
14559 context,
14560 OperationErrorSpec {
14561 error_code: "RM.FEA.RUN_STUDY.REFINED_MESH_GENERATION_FAILED",
14562 error_type: OperationErrorType::Validation,
14563 retryable: false,
14564 severity: OperationErrorSeverity::Error,
14565 },
14566 format!("failed to generate refined analysis mesh: {err}"),
14567 BTreeMap::from([
14568 ("study_id".to_string(), spec.study_id.clone()),
14569 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14570 ("analysis_mesh_artifact_path".to_string(), path.to_string()),
14571 ]),
14572 )
14573 },
14574 )?;
14575 refined_mesh.mesh_id = format!(
14576 "{}_refined_{}",
14577 refined_mesh.mesh_id,
14578 mesh.adaptive_iterations.len()
14579 );
14580 attach_requested_boundary_regions_to_analysis_mesh(spec, &mut refined_mesh);
14581 attach_single_material_assignment_to_analysis_mesh(spec, &mut refined_mesh);
14582 refined_mesh.adaptive_iterations = mesh.adaptive_iterations.clone();
14583 let refinement_effect = refinement_effect_summary(&mesh, &refined_mesh);
14584 let refinement_convergence = runmat_meshing_core::evaluate_adaptive_convergence(
14585 &options.refinement,
14586 runmat_meshing_core::AdaptiveConvergenceMetrics {
14587 completed_iterations: mesh.adaptive_iterations.len(),
14588 previous_node_count: Some(mesh.nodes.len()),
14589 current_node_count: Some(refined_mesh.nodes.len()),
14590 previous_element_count: Some(mesh.volume_elements.len()),
14591 current_element_count: Some(refined_mesh.volume_elements.len()),
14592 ..runmat_meshing_core::AdaptiveConvergenceMetrics::default()
14593 },
14594 );
14595 if refinement_convergence == AdaptiveConvergenceStatus::Converged
14596 && !refinement_effect_topology_changed(&refinement_effect)
14597 {
14598 mark_latest_adaptive_iteration_converged(
14599 &path_buf,
14600 payload,
14601 mesh,
14602 "adaptive refinement produced no topology growth",
14603 )
14604 .map_err(|err| {
14605 operation_error(
14606 ANALYSIS_RUN_STUDY_OPERATION,
14607 ANALYSIS_RUN_STUDY_OP_VERSION,
14608 context,
14609 OperationErrorSpec {
14610 error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
14611 error_type: OperationErrorType::Internal,
14612 retryable: true,
14613 severity: OperationErrorSeverity::Error,
14614 },
14615 format!("failed to persist adaptive convergence update: {err}"),
14616 BTreeMap::from([
14617 ("study_id".to_string(), spec.study_id.clone()),
14618 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14619 ("analysis_mesh_artifact_path".to_string(), path.to_string()),
14620 ]),
14621 )
14622 })?;
14623 return Ok(None);
14624 }
14625 let validation_options =
14626 analysis_mesh_validation_options_for_generated_mesh(spec, &options, &refined_mesh);
14627 runmat_meshing_core::validate_analysis_mesh_with_options(
14628 &refined_mesh,
14629 validation_options.clone(),
14630 )
14631 .map_err(|err| {
14632 operation_error(
14633 ANALYSIS_RUN_STUDY_OPERATION,
14634 ANALYSIS_RUN_STUDY_OP_VERSION,
14635 context,
14636 OperationErrorSpec {
14637 error_code: "RM.FEA.RUN_STUDY.REFINED_MESH_VALIDATION_FAILED",
14638 error_type: OperationErrorType::Validation,
14639 retryable: false,
14640 severity: OperationErrorSeverity::Error,
14641 },
14642 format!("refined analysis mesh failed validation: {err:?}"),
14643 BTreeMap::from([
14644 ("study_id".to_string(), spec.study_id.clone()),
14645 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14646 ("mesh_id".to_string(), refined_mesh.mesh_id.clone()),
14647 (
14648 "mesh_validation_code".to_string(),
14649 runmat_meshing_core::analysis_mesh_validation_error_code(&err).to_string(),
14650 ),
14651 ]),
14652 )
14653 })?;
14654
14655 let refined_mesh_evidence = build_mesh_evidence_artifact(&refined_mesh, &validation_options);
14656 let refined_evidence_path = persist_study_evidence(
14657 study_fingerprint,
14658 "mesh_evidence_refined",
14659 serde_json::json!({
14660 "schema_version": "fea_study_mesh_evidence_artifact/v1",
14661 "study_id": spec.study_id.clone(),
14662 "geometry_id": spec.geometry.geometry_id.clone(),
14663 "geometry_revision": spec.geometry.revision,
14664 "source_analysis_mesh_artifact_path": path,
14665 "analysis_profile": spec.create_model_intent.profile.as_snake_case(),
14666 "run_kind": spec.run_kind.as_snake_case(),
14667 "refinement_context": analysis_refinement_context(spec),
14668 "mesh_options": options,
14669 "mesh_validation_options": validation_options,
14670 "refinement_effect": refinement_effect.clone(),
14671 "mesh_evidence": refined_mesh_evidence,
14672 }),
14673 )
14674 .map_err(|err| {
14675 operation_error(
14676 ANALYSIS_RUN_STUDY_OPERATION,
14677 ANALYSIS_RUN_STUDY_OP_VERSION,
14678 context,
14679 OperationErrorSpec {
14680 error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
14681 error_type: OperationErrorType::Internal,
14682 retryable: true,
14683 severity: OperationErrorSeverity::Error,
14684 },
14685 format!("failed to persist refined mesh evidence artifact: {err}"),
14686 BTreeMap::from([
14687 ("study_id".to_string(), spec.study_id.clone()),
14688 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14689 ]),
14690 )
14691 })?;
14692
14693 persist_study_evidence(
14694 study_fingerprint,
14695 "analysis_mesh_refined",
14696 serde_json::json!({
14697 "schema_version": "fea_study_analysis_mesh_artifact/v1",
14698 "study_id": spec.study_id.clone(),
14699 "geometry_id": spec.geometry.geometry_id.clone(),
14700 "geometry_revision": spec.geometry.revision,
14701 "source_analysis_mesh_artifact_path": path,
14702 "mesh_evidence_artifact_path": refined_evidence_path.clone(),
14703 "analysis_profile": spec.create_model_intent.profile.as_snake_case(),
14704 "run_kind": spec.run_kind.as_snake_case(),
14705 "refinement_context": analysis_refinement_context(spec),
14706 "mesh_options": options,
14707 "mesh_validation_options": validation_options,
14708 "sizing_applications": sizing_application_summary(&refined_mesh),
14709 "sizing_rejections": sizing_rejection_summary(&refined_mesh),
14710 "refinement_effect": refinement_effect.clone(),
14711 "mesh": refined_mesh,
14712 }),
14713 )
14714 .map(|path| {
14715 Some(RefinedAnalysisMeshArtifact {
14716 path,
14717 evidence_path: refined_evidence_path,
14718 refinement_effect,
14719 })
14720 })
14721 .map_err(|err| {
14722 operation_error(
14723 ANALYSIS_RUN_STUDY_OPERATION,
14724 ANALYSIS_RUN_STUDY_OP_VERSION,
14725 context,
14726 OperationErrorSpec {
14727 error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
14728 error_type: OperationErrorType::Internal,
14729 retryable: true,
14730 severity: OperationErrorSeverity::Error,
14731 },
14732 format!("failed to persist refined analysis mesh artifact: {err}"),
14733 BTreeMap::from([
14734 ("study_id".to_string(), spec.study_id.clone()),
14735 ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14736 ]),
14737 )
14738 })
14739}
14740
14741fn refinement_effect_topology_changed(refinement_effect: &serde_json::Value) -> bool {
14742 refinement_effect
14743 .get("topology_changed")
14744 .and_then(serde_json::Value::as_bool)
14745 .unwrap_or(true)
14746}
14747
14748fn mark_latest_adaptive_iteration_converged(
14749 analysis_mesh_artifact_path: &PathBuf,
14750 mut payload: serde_json::Value,
14751 mut mesh: AnalysisMeshArtifact,
14752 detail: &str,
14753) -> Result<(), String> {
14754 let Some(latest_iteration) = mesh.adaptive_iterations.last_mut() else {
14755 return Ok(());
14756 };
14757 latest_iteration.convergence_status = AdaptiveConvergenceStatus::Converged;
14758 for indicator in &mut latest_iteration.indicators {
14759 if indicator.status == runmat_meshing_core::RefinementIndicatorStatus::Used {
14760 indicator.detail = Some(detail.to_string());
14761 }
14762 }
14763 payload["mesh"] = serde_json::to_value(&mesh)
14764 .map_err(|err| format!("failed to encode mesh payload: {err}"))?;
14765 let bytes = serde_json::to_vec_pretty(&payload)
14766 .map_err(|err| format!("failed to encode analysis mesh artifact: {err}"))?;
14767 atomic_write_bytes(analysis_mesh_artifact_path, &bytes)?;
14768 update_mesh_evidence_from_analysis_mesh_payload(&payload, &mesh)
14769}
14770
14771fn attach_initial_adaptive_mesh_summary(
14772 spec: &AnalysisStudySpec,
14773 options: &runmat_meshing_core::VolumeMeshingOptions,
14774 mesh: &mut AnalysisMeshArtifact,
14775) {
14776 let defaults = if matches!(options.refinement.strategy, RefinementStrategy::Uniform) {
14777 Vec::new()
14778 } else {
14779 default_refinement_indicators_for_context(
14780 spec.create_model_intent.profile.as_snake_case(),
14781 spec.run_kind.as_snake_case(),
14782 )
14783 };
14784 if defaults.is_empty()
14785 && options.refinement.indicators.namespaces.is_empty()
14786 && !matches!(options.refinement.strategy, RefinementStrategy::Uniform)
14787 {
14788 return;
14789 }
14790 let availability = defaults
14791 .iter()
14792 .cloned()
14793 .map(|key| RefinementIndicatorAvailability {
14794 key,
14795 applicable: true,
14796 field_available: false,
14797 })
14798 .collect::<Vec<_>>();
14799 let indicators =
14800 plan_refinement_indicators(&options.refinement, &defaults, &availability, false, false);
14801 let convergence_status = if matches!(options.refinement.strategy, RefinementStrategy::None) {
14802 AdaptiveConvergenceStatus::Disabled
14803 } else {
14804 AdaptiveConvergenceStatus::Pending
14805 };
14806 mesh.adaptive_iterations.push(AdaptiveIterationSummary {
14807 iteration_index: 0,
14808 node_count: mesh.nodes.len(),
14809 element_count: mesh.volume_elements.len(),
14810 convergence_status,
14811 indicators,
14812 markers: Vec::new(),
14813 sizing_update: SizingFieldUpdate::default(),
14814 });
14815}
14816
14817fn initial_boundary_focus_sizing_field(
14818 spec: &AnalysisStudySpec,
14819 options: &runmat_meshing_core::VolumeMeshingOptions,
14820 mesh: &AnalysisMeshArtifact,
14821) -> Option<MeshSizingField> {
14822 if matches!(options.refinement.strategy, RefinementStrategy::None)
14823 || options.refinement.max_iterations == 0
14824 {
14825 return None;
14826 }
14827 if runmat_meshing_core::select_volume_backend(options).selected
14828 != runmat_meshing_core::MeshBackendKind::Solid
14829 {
14830 return None;
14831 }
14832 let defaults = if matches!(options.refinement.strategy, RefinementStrategy::Uniform) {
14833 Vec::new()
14834 } else {
14835 default_refinement_indicators_for_context(
14836 spec.create_model_intent.profile.as_snake_case(),
14837 spec.run_kind.as_snake_case(),
14838 )
14839 };
14840 if defaults.is_empty() && options.refinement.indicators.namespaces.is_empty() {
14841 return None;
14842 }
14843 let context = serde_json::json!({
14844 "refinement_context": analysis_refinement_context(spec),
14845 });
14846 let boundary_load_region_ids =
14847 refinement_context_region_ids(&context, "boundary_load_region_ids");
14848 let boundary_constraint_region_ids =
14849 refinement_context_region_ids(&context, "boundary_constraint_region_ids");
14850 let load_focus_options = refinement_marker_options_for_focus(options.refinement.focus.loads);
14851 let constraint_focus_options =
14852 refinement_marker_options_for_focus(options.refinement.focus.constraints);
14853 let has_boundary_load_regions = load_focus_options.is_some()
14854 && has_boundary_faces_for_regions(mesh, boundary_load_region_ids.as_slice());
14855 let has_boundary_constraint_regions = constraint_focus_options.is_some()
14856 && has_boundary_faces_for_regions(mesh, boundary_constraint_region_ids.as_slice());
14857 if !has_boundary_load_regions && !has_boundary_constraint_regions {
14858 return None;
14859 }
14860 let availability = defaults
14861 .iter()
14862 .cloned()
14863 .map(|key| {
14864 let applicable = key.namespace != "structural"
14865 || (key.name != "load_regions" || load_focus_options.is_some())
14866 && (key.name != "constraint_regions" || constraint_focus_options.is_some());
14867 let field_available = key.namespace == "structural"
14868 && ((key.name == "load_regions" && has_boundary_load_regions)
14869 || (key.name == "constraint_regions" && has_boundary_constraint_regions));
14870 RefinementIndicatorAvailability {
14871 key,
14872 applicable,
14873 field_available,
14874 }
14875 })
14876 .collect::<Vec<_>>();
14877 let indicators =
14878 plan_refinement_indicators(&options.refinement, &defaults, &availability, false, false);
14879 let mut sizing_update = SizingFieldUpdate::default();
14880 if indicator_was_used(&indicators, "structural", "load_regions") {
14881 if let Some(marker_options) = load_focus_options {
14882 let samples =
14883 structural_boundary_region_samples(mesh, boundary_load_region_ids.as_slice());
14884 if let Ok((_, update)) = build_refinement_markers_from_samples(
14885 &samples,
14886 "structural.load_regions",
14887 marker_options,
14888 ) {
14889 merge_sizing_update(&mut sizing_update, update);
14890 }
14891 }
14892 }
14893 if indicator_was_used(&indicators, "structural", "constraint_regions") {
14894 if let Some(marker_options) = constraint_focus_options {
14895 let samples =
14896 structural_boundary_region_samples(mesh, boundary_constraint_region_ids.as_slice());
14897 if let Ok((_, update)) = build_refinement_markers_from_samples(
14898 &samples,
14899 "structural.constraint_regions",
14900 marker_options,
14901 ) {
14902 merge_sizing_update(&mut sizing_update, update);
14903 }
14904 }
14905 }
14906 if sizing_update.samples.is_empty()
14907 && sizing_update.min_size_m.is_none()
14908 && sizing_update.max_size_m.is_none()
14909 {
14910 return None;
14911 }
14912 let mut sizing = MeshSizingField::default();
14913 sizing_update.apply_to(&mut sizing);
14914 Some(sizing)
14915}
14916
14917fn default_refinement_indicators_for_context(
14918 profile: &str,
14919 run_kind: &str,
14920) -> Vec<runmat_meshing_core::RefinementIndicatorKey> {
14921 runmat_meshing_core::default_refinement_indicators_for_analysis(profile, run_kind)
14922}
14923
14924fn analysis_refinement_context(spec: &AnalysisStudySpec) -> serde_json::Value {
14925 let Some(model) = spec.model.as_ref() else {
14926 return serde_json::json!({
14927 "boundary_load_region_ids": [],
14928 "boundary_constraint_region_ids": [],
14929 });
14930 };
14931
14932 let mut load_region_ids = model
14933 .loads
14934 .iter()
14935 .filter(|load| load_requires_boundary_region(&load.kind))
14936 .map(|load| load.region_id.clone())
14937 .collect::<Vec<_>>();
14938 load_region_ids.sort();
14939 load_region_ids.dedup();
14940
14941 let mut constraint_region_ids = model
14942 .boundary_conditions
14943 .iter()
14944 .map(|boundary_condition| boundary_condition.region_id.clone())
14945 .collect::<Vec<_>>();
14946 constraint_region_ids.sort();
14947 constraint_region_ids.dedup();
14948
14949 serde_json::json!({
14950 "boundary_load_region_ids": load_region_ids,
14951 "boundary_constraint_region_ids": constraint_region_ids,
14952 })
14953}
14954
14955fn append_solved_adaptive_mesh_summary(
14956 analysis_mesh_artifact_path: Option<&str>,
14957 fields: &[AnalysisField],
14958) -> Result<(), String> {
14959 let Some(path) = analysis_mesh_artifact_path else {
14960 return Ok(());
14961 };
14962 let path_buf = PathBuf::from(path);
14963 let mut payload: serde_json::Value = serde_json::from_slice(
14964 &fs_read(&path_buf)
14965 .map_err(|err| format!("failed to read analysis mesh artifact: {err}"))?,
14966 )
14967 .map_err(|err| format!("failed to parse analysis mesh artifact: {err}"))?;
14968 let mut mesh: AnalysisMeshArtifact = serde_json::from_value(payload["mesh"].clone())
14969 .map_err(|err| format!("failed to decode analysis mesh payload: {err}"))?;
14970 let options = payload
14971 .get("mesh_options")
14972 .cloned()
14973 .map(serde_json::from_value::<runmat_meshing_core::VolumeMeshingOptions>)
14974 .transpose()
14975 .map_err(|err| format!("failed to decode analysis mesh options: {err}"))?;
14976 let Some(options) = options.as_ref() else {
14977 return Ok(());
14978 };
14979
14980 let profile_label = payload
14981 .get("analysis_profile")
14982 .and_then(serde_json::Value::as_str)
14983 .ok_or_else(|| {
14984 "analysis mesh artifact is missing analysis_profile; regenerate the mesh from the typed .fea study"
14985 .to_string()
14986 })?;
14987 let run_kind_label = payload
14988 .get("run_kind")
14989 .and_then(serde_json::Value::as_str)
14990 .ok_or_else(|| {
14991 "analysis mesh artifact is missing run_kind; regenerate the mesh from the typed .fea study"
14992 .to_string()
14993 })?;
14994 let defaults = if matches!(options.refinement.strategy, RefinementStrategy::Uniform) {
14995 Vec::new()
14996 } else {
14997 default_refinement_indicators_for_context(profile_label, run_kind_label)
14998 };
14999 if defaults.is_empty()
15000 && options.refinement.indicators.namespaces.is_empty()
15001 && !matches!(options.refinement.strategy, RefinementStrategy::Uniform)
15002 {
15003 return Ok(());
15004 }
15005
15006 let von_mises_values = analysis_field_values(fields, FEA_FIELD_STRUCTURAL_VON_MISES);
15007 let has_von_mises = von_mises_values
15008 .map(|values| values.len() == mesh.volume_elements.len())
15009 .unwrap_or(false);
15010 let strain_energy_density_values =
15011 analysis_field_values(fields, FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY);
15012 let has_strain_energy_density = strain_energy_density_values
15013 .map(|values| values.len() == mesh.volume_elements.len())
15014 .unwrap_or(false);
15015 let temperature_gradient_values = latest_prefixed_vector_field_magnitudes(
15016 fields,
15017 "thermal.temperature_gradient.",
15018 mesh.volume_elements.len(),
15019 );
15020 let has_temperature_gradient = temperature_gradient_values.is_some();
15021 let heat_flux_values = latest_prefixed_vector_field_magnitudes(
15022 fields,
15023 "thermal.heat_flux.",
15024 mesh.volume_elements.len(),
15025 );
15026 let has_heat_flux = heat_flux_values.is_some();
15027 let magnetic_flux_density_values = analysis_field_magnitudes(
15028 fields,
15029 "em.magnetic_flux_density_magnitude",
15030 mesh.volume_elements.len(),
15031 );
15032 let has_magnetic_flux_density = magnetic_flux_density_values.is_some();
15033 let electric_field_values =
15034 analysis_field_magnitudes(fields, "em.electric_field_real", mesh.volume_elements.len());
15035 let has_electric_field = electric_field_values.is_some();
15036 let current_density_values = analysis_field_magnitudes(
15037 fields,
15038 "em.current_density_real",
15039 mesh.volume_elements.len(),
15040 );
15041 let has_current_density = current_density_values.is_some();
15042 let electromagnetic_energy_density_values =
15043 analysis_field_magnitudes(fields, "em.energy_density", mesh.volume_elements.len());
15044 let has_electromagnetic_energy_density = electromagnetic_energy_density_values.is_some();
15045 let acoustic_pressure_values = analysis_field_magnitudes(
15046 fields,
15047 FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE,
15048 mesh.volume_elements.len(),
15049 );
15050 let has_acoustic_pressure = acoustic_pressure_values.is_some();
15051 let cfd_velocity_values =
15052 analysis_field_magnitudes(fields, FEA_FIELD_CFD_VELOCITY, mesh.volume_elements.len());
15053 let has_cfd_velocity = cfd_velocity_values.is_some();
15054 let cfd_pressure_values =
15055 analysis_field_magnitudes(fields, FEA_FIELD_CFD_PRESSURE, mesh.volume_elements.len());
15056 let has_cfd_pressure = cfd_pressure_values.is_some();
15057 let cfd_vorticity_values =
15058 analysis_field_magnitudes(fields, FEA_FIELD_CFD_VORTICITY, mesh.volume_elements.len());
15059 let has_cfd_vorticity = cfd_vorticity_values.is_some();
15060 let cfd_wall_shear_values = analysis_field_magnitudes(
15061 fields,
15062 FEA_FIELD_CFD_WALL_SHEAR_STRESS,
15063 mesh.volume_elements.len(),
15064 );
15065 let has_cfd_wall_shear = cfd_wall_shear_values.is_some();
15066 let cht_interface_heat_flux_values = latest_prefixed_vector_field_magnitudes(
15067 fields,
15068 "cht.interface_heat_flux.",
15069 mesh.volume_elements.len(),
15070 );
15071 let has_cht_interface_heat_flux = cht_interface_heat_flux_values.is_some();
15072 let cht_interface_temperature_jump_values = latest_prefixed_vector_field_magnitudes(
15073 fields,
15074 "cht.interface_temperature_jump.",
15075 mesh.volume_elements.len(),
15076 );
15077 let has_cht_interface_temperature_jump = cht_interface_temperature_jump_values.is_some();
15078 let cht_fluid_velocity_values = analysis_field_magnitudes(
15079 fields,
15080 FEA_FIELD_CHT_FLUID_VELOCITY,
15081 mesh.volume_elements.len(),
15082 );
15083 let has_cht_fluid_velocity = cht_fluid_velocity_values.is_some();
15084 let boundary_load_region_ids =
15085 refinement_context_region_ids(&payload, "boundary_load_region_ids");
15086 let boundary_constraint_region_ids =
15087 refinement_context_region_ids(&payload, "boundary_constraint_region_ids");
15088 let load_focus_options = refinement_marker_options_for_focus(options.refinement.focus.loads);
15089 let constraint_focus_options =
15090 refinement_marker_options_for_focus(options.refinement.focus.constraints);
15091 let has_boundary_load_regions = load_focus_options.is_some()
15092 && has_boundary_faces_for_regions(&mesh, boundary_load_region_ids.as_slice());
15093 let has_boundary_constraint_regions = constraint_focus_options.is_some()
15094 && has_boundary_faces_for_regions(&mesh, boundary_constraint_region_ids.as_slice());
15095 let availability = defaults
15096 .iter()
15097 .cloned()
15098 .map(|key| {
15099 let field_available = key.namespace == "structural"
15100 && ((key.name == "stress_gradient" && has_von_mises)
15101 || (key.name == "strain_energy_density" && has_strain_energy_density));
15102 let applicable = key.namespace != "structural"
15103 || (key.name != "load_regions" || load_focus_options.is_some())
15104 && (key.name != "constraint_regions" || constraint_focus_options.is_some());
15105 let field_available = field_available
15106 || (key.namespace == "structural"
15107 && ((key.name == "load_regions" && has_boundary_load_regions)
15108 || (key.name == "constraint_regions" && has_boundary_constraint_regions)))
15109 || (key.namespace == "thermal"
15110 && ((key.name == "temperature_gradient" && has_temperature_gradient)
15111 || (key.name == "heat_flux_gradient" && has_heat_flux)))
15112 || (key.namespace == "electromagnetic"
15113 && ((key.name == "flux_density_gradient" && has_magnetic_flux_density)
15114 || (key.name == "electric_field_gradient" && has_electric_field)
15115 || (key.name == "current_density_gradient" && has_current_density)
15116 || (key.name == "energy_density" && has_electromagnetic_energy_density)))
15117 || (key.namespace == "acoustic"
15118 && has_acoustic_pressure
15119 && matches!(
15120 key.name.as_str(),
15121 "pressure_gradient" | "pressure_curvature"
15122 ))
15123 || (key.namespace == "cfd"
15124 && ((key.name == "velocity_gradient" && has_cfd_velocity)
15125 || (key.name == "pressure_gradient" && has_cfd_pressure)
15126 || (key.name == "vorticity" && has_cfd_vorticity)
15127 || (key.name == "wall_shear" && has_cfd_wall_shear)))
15128 || (key.namespace == "cht"
15129 && ((key.name == "interface_heat_flux_jump" && has_cht_interface_heat_flux)
15130 || (key.name == "interface_temperature_jump"
15131 && has_cht_interface_temperature_jump)
15132 || (key.name == "fluid_boundary_layer" && has_cht_fluid_velocity)));
15133 RefinementIndicatorAvailability {
15134 key,
15135 applicable,
15136 field_available,
15137 }
15138 })
15139 .collect::<Vec<_>>();
15140 let element_budget_reached =
15141 options.max_elements > 0 && mesh.volume_elements.len() >= options.max_elements;
15142 let indicators = plan_refinement_indicators(
15143 &options.refinement,
15144 &defaults,
15145 &availability,
15146 element_budget_reached,
15147 false,
15148 );
15149
15150 let mut markers = Vec::new();
15151 let mut sizing_update = SizingFieldUpdate::default();
15152 if !element_budget_reached && matches!(options.refinement.strategy, RefinementStrategy::Uniform)
15153 {
15154 let samples = uniform_refinement_samples(&mesh);
15155 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15156 &samples,
15157 "mesh.uniform_refinement",
15158 RefinementMarkerOptions::default(),
15159 )
15160 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15161 markers.extend(new_markers);
15162 merge_sizing_update(&mut sizing_update, new_sizing_update);
15163 }
15164 let stress_gradient_used = indicator_was_used(&indicators, "structural", "stress_gradient");
15165 let strain_energy_density_used =
15166 indicator_was_used(&indicators, "structural", "strain_energy_density");
15167 let load_regions_used = indicator_was_used(&indicators, "structural", "load_regions");
15168 let constraint_regions_used =
15169 indicator_was_used(&indicators, "structural", "constraint_regions");
15170 let temperature_gradient_used =
15171 indicator_was_used(&indicators, "thermal", "temperature_gradient");
15172 let heat_flux_gradient_used = indicator_was_used(&indicators, "thermal", "heat_flux_gradient");
15173 let electromagnetic_flux_density_used =
15174 indicator_was_used(&indicators, "electromagnetic", "flux_density_gradient");
15175 let electromagnetic_electric_field_used =
15176 indicator_was_used(&indicators, "electromagnetic", "electric_field_gradient");
15177 let electromagnetic_current_density_used =
15178 indicator_was_used(&indicators, "electromagnetic", "current_density_gradient");
15179 let electromagnetic_energy_density_used =
15180 indicator_was_used(&indicators, "electromagnetic", "energy_density");
15181 let acoustic_pressure_gradient_used =
15182 indicator_was_used(&indicators, "acoustic", "pressure_gradient");
15183 let acoustic_pressure_curvature_used =
15184 indicator_was_used(&indicators, "acoustic", "pressure_curvature");
15185 let cfd_velocity_gradient_used = indicator_was_used(&indicators, "cfd", "velocity_gradient");
15186 let cfd_pressure_gradient_used = indicator_was_used(&indicators, "cfd", "pressure_gradient");
15187 let cfd_vorticity_used = indicator_was_used(&indicators, "cfd", "vorticity");
15188 let cfd_wall_shear_used = indicator_was_used(&indicators, "cfd", "wall_shear");
15189 let cht_interface_heat_flux_jump_used =
15190 indicator_was_used(&indicators, "cht", "interface_heat_flux_jump");
15191 let cht_interface_temperature_jump_used =
15192 indicator_was_used(&indicators, "cht", "interface_temperature_jump");
15193 let cht_fluid_boundary_layer_used =
15194 indicator_was_used(&indicators, "cht", "fluid_boundary_layer");
15195 if !element_budget_reached && stress_gradient_used {
15196 if let Some(values) = von_mises_values {
15197 let samples = structural_stress_gradient_samples(&mesh, values);
15198 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15199 &samples,
15200 "structural.stress_gradient",
15201 RefinementMarkerOptions::default(),
15202 )
15203 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15204 markers.extend(new_markers);
15205 merge_sizing_update(&mut sizing_update, new_sizing_update);
15206 }
15207 }
15208 if !element_budget_reached && load_regions_used {
15209 let samples =
15210 structural_boundary_region_samples(&mesh, boundary_load_region_ids.as_slice());
15211 if let Some(options) = load_focus_options {
15212 let (new_markers, new_sizing_update) =
15213 build_refinement_markers_from_samples(&samples, "structural.load_regions", options)
15214 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15215 markers.extend(new_markers);
15216 merge_sizing_update(&mut sizing_update, new_sizing_update);
15217 }
15218 }
15219 if !element_budget_reached && constraint_regions_used {
15220 let samples =
15221 structural_boundary_region_samples(&mesh, boundary_constraint_region_ids.as_slice());
15222 if let Some(options) = constraint_focus_options {
15223 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15224 &samples,
15225 "structural.constraint_regions",
15226 options,
15227 )
15228 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15229 markers.extend(new_markers);
15230 merge_sizing_update(&mut sizing_update, new_sizing_update);
15231 }
15232 }
15233 if !element_budget_reached && strain_energy_density_used {
15234 if let Some(values) = strain_energy_density_values {
15235 let samples = structural_strain_energy_density_samples(&mesh, values);
15236 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15237 &samples,
15238 "structural.strain_energy_density",
15239 RefinementMarkerOptions::default(),
15240 )
15241 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15242 markers.extend(new_markers);
15243 merge_sizing_update(&mut sizing_update, new_sizing_update);
15244 }
15245 }
15246 if !element_budget_reached && temperature_gradient_used {
15247 if let Some(values) = temperature_gradient_values.as_deref() {
15248 let samples = thermal_element_gradient_samples(&mesh, values);
15249 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15250 &samples,
15251 "thermal.temperature_gradient",
15252 RefinementMarkerOptions::default(),
15253 )
15254 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15255 markers.extend(new_markers);
15256 merge_sizing_update(&mut sizing_update, new_sizing_update);
15257 }
15258 }
15259 if !element_budget_reached && heat_flux_gradient_used {
15260 if let Some(values) = heat_flux_values.as_deref() {
15261 let samples = thermal_element_gradient_samples(&mesh, values);
15262 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15263 &samples,
15264 "thermal.heat_flux_gradient",
15265 RefinementMarkerOptions::default(),
15266 )
15267 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15268 markers.extend(new_markers);
15269 merge_sizing_update(&mut sizing_update, new_sizing_update);
15270 }
15271 }
15272 if !element_budget_reached && electromagnetic_flux_density_used {
15273 if let Some(values) = magnetic_flux_density_values.as_deref() {
15274 let samples = electromagnetic_element_gradient_samples(&mesh, values);
15275 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15276 &samples,
15277 "electromagnetic.flux_density_gradient",
15278 RefinementMarkerOptions::default(),
15279 )
15280 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15281 markers.extend(new_markers);
15282 merge_sizing_update(&mut sizing_update, new_sizing_update);
15283 }
15284 }
15285 if !element_budget_reached && electromagnetic_electric_field_used {
15286 if let Some(values) = electric_field_values.as_deref() {
15287 let samples = electromagnetic_element_gradient_samples(&mesh, values);
15288 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15289 &samples,
15290 "electromagnetic.electric_field_gradient",
15291 RefinementMarkerOptions::default(),
15292 )
15293 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15294 markers.extend(new_markers);
15295 merge_sizing_update(&mut sizing_update, new_sizing_update);
15296 }
15297 }
15298 if !element_budget_reached && electromagnetic_current_density_used {
15299 if let Some(values) = current_density_values.as_deref() {
15300 let samples = electromagnetic_element_gradient_samples(&mesh, values);
15301 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15302 &samples,
15303 "electromagnetic.current_density_gradient",
15304 RefinementMarkerOptions::default(),
15305 )
15306 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15307 markers.extend(new_markers);
15308 merge_sizing_update(&mut sizing_update, new_sizing_update);
15309 }
15310 }
15311 if !element_budget_reached && electromagnetic_energy_density_used {
15312 if let Some(values) = electromagnetic_energy_density_values.as_deref() {
15313 let samples = electromagnetic_element_gradient_samples(&mesh, values);
15314 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15315 &samples,
15316 "electromagnetic.energy_density",
15317 RefinementMarkerOptions::default(),
15318 )
15319 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15320 markers.extend(new_markers);
15321 merge_sizing_update(&mut sizing_update, new_sizing_update);
15322 }
15323 }
15324 if !element_budget_reached && acoustic_pressure_gradient_used {
15325 if let Some(values) = acoustic_pressure_values.as_deref() {
15326 let samples = acoustic_element_gradient_samples(&mesh, values);
15327 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15328 &samples,
15329 "acoustic.pressure_gradient",
15330 RefinementMarkerOptions::default(),
15331 )
15332 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15333 markers.extend(new_markers);
15334 merge_sizing_update(&mut sizing_update, new_sizing_update);
15335 }
15336 }
15337 if !element_budget_reached && acoustic_pressure_curvature_used {
15338 if let Some(values) = acoustic_pressure_values.as_deref() {
15339 let samples = acoustic_element_gradient_samples(&mesh, values);
15340 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15341 &samples,
15342 "acoustic.pressure_curvature",
15343 RefinementMarkerOptions::default(),
15344 )
15345 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15346 markers.extend(new_markers);
15347 merge_sizing_update(&mut sizing_update, new_sizing_update);
15348 }
15349 }
15350 if !element_budget_reached && cfd_velocity_gradient_used {
15351 if let Some(values) = cfd_velocity_values.as_deref() {
15352 let samples = cfd_element_gradient_samples(&mesh, values);
15353 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15354 &samples,
15355 "cfd.velocity_gradient",
15356 RefinementMarkerOptions::default(),
15357 )
15358 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15359 markers.extend(new_markers);
15360 merge_sizing_update(&mut sizing_update, new_sizing_update);
15361 }
15362 }
15363 if !element_budget_reached && cfd_pressure_gradient_used {
15364 if let Some(values) = cfd_pressure_values.as_deref() {
15365 let samples = cfd_element_gradient_samples(&mesh, values);
15366 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15367 &samples,
15368 "cfd.pressure_gradient",
15369 RefinementMarkerOptions::default(),
15370 )
15371 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15372 markers.extend(new_markers);
15373 merge_sizing_update(&mut sizing_update, new_sizing_update);
15374 }
15375 }
15376 if !element_budget_reached && cfd_vorticity_used {
15377 if let Some(values) = cfd_vorticity_values.as_deref() {
15378 let samples = cfd_element_gradient_samples(&mesh, values);
15379 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15380 &samples,
15381 "cfd.vorticity",
15382 RefinementMarkerOptions::default(),
15383 )
15384 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15385 markers.extend(new_markers);
15386 merge_sizing_update(&mut sizing_update, new_sizing_update);
15387 }
15388 }
15389 if !element_budget_reached && cfd_wall_shear_used {
15390 if let Some(values) = cfd_wall_shear_values.as_deref() {
15391 let samples = cfd_element_gradient_samples(&mesh, values);
15392 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15393 &samples,
15394 "cfd.wall_shear",
15395 RefinementMarkerOptions::default(),
15396 )
15397 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15398 markers.extend(new_markers);
15399 merge_sizing_update(&mut sizing_update, new_sizing_update);
15400 }
15401 }
15402 if !element_budget_reached && cht_interface_heat_flux_jump_used {
15403 if let Some(values) = cht_interface_heat_flux_values.as_deref() {
15404 let samples = cht_element_gradient_samples(&mesh, values);
15405 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15406 &samples,
15407 "cht.interface_heat_flux_jump",
15408 RefinementMarkerOptions::default(),
15409 )
15410 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15411 markers.extend(new_markers);
15412 merge_sizing_update(&mut sizing_update, new_sizing_update);
15413 }
15414 }
15415 if !element_budget_reached && cht_interface_temperature_jump_used {
15416 if let Some(values) = cht_interface_temperature_jump_values.as_deref() {
15417 let samples = cht_element_gradient_samples(&mesh, values);
15418 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15419 &samples,
15420 "cht.interface_temperature_jump",
15421 RefinementMarkerOptions::default(),
15422 )
15423 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15424 markers.extend(new_markers);
15425 merge_sizing_update(&mut sizing_update, new_sizing_update);
15426 }
15427 }
15428 if !element_budget_reached && cht_fluid_boundary_layer_used {
15429 if let Some(values) = cht_fluid_velocity_values.as_deref() {
15430 let samples = cht_element_gradient_samples(&mesh, values);
15431 let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15432 &samples,
15433 "cht.fluid_boundary_layer",
15434 RefinementMarkerOptions::default(),
15435 )
15436 .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15437 markers.extend(new_markers);
15438 merge_sizing_update(&mut sizing_update, new_sizing_update);
15439 }
15440 }
15441 let convergence_status = if matches!(options.refinement.strategy, RefinementStrategy::Uniform) {
15442 if element_budget_reached {
15443 AdaptiveConvergenceStatus::ElementBudgetReached
15444 } else if markers.is_empty() {
15445 AdaptiveConvergenceStatus::Converged
15446 } else {
15447 AdaptiveConvergenceStatus::Pending
15448 }
15449 } else {
15450 let marker_change = markers
15451 .iter()
15452 .map(|marker| marker.weight)
15453 .filter(|weight| weight.is_finite())
15454 .reduce(f64::max)
15455 .unwrap_or(0.0);
15456 runmat_meshing_core::evaluate_adaptive_convergence(
15457 &options.refinement,
15458 runmat_meshing_core::AdaptiveConvergenceMetrics {
15459 completed_iterations: mesh.adaptive_iterations.len(),
15460 element_budget_reached,
15461 field_change: (stress_gradient_used
15462 || load_regions_used
15463 || constraint_regions_used
15464 || temperature_gradient_used
15465 || heat_flux_gradient_used
15466 || electromagnetic_flux_density_used
15467 || electromagnetic_electric_field_used
15468 || electromagnetic_current_density_used
15469 || acoustic_pressure_gradient_used
15470 || acoustic_pressure_curvature_used
15471 || cfd_velocity_gradient_used
15472 || cfd_pressure_gradient_used
15473 || cfd_vorticity_used
15474 || cfd_wall_shear_used
15475 || cht_interface_heat_flux_jump_used
15476 || cht_interface_temperature_jump_used
15477 || cht_fluid_boundary_layer_used)
15478 .then_some(marker_change),
15479 energy_change: (strain_energy_density_used || electromagnetic_energy_density_used)
15480 .then_some(marker_change),
15481 residual: None,
15482 ..runmat_meshing_core::AdaptiveConvergenceMetrics::default()
15483 },
15484 )
15485 };
15486
15487 let mut updated_sizing = mesh.sizing.clone();
15488 sizing_update.clone().apply_to(&mut updated_sizing);
15489 mesh.sizing = updated_sizing;
15490 mesh.adaptive_iterations.push(AdaptiveIterationSummary {
15491 iteration_index: mesh.adaptive_iterations.len(),
15492 node_count: mesh.nodes.len(),
15493 element_count: mesh.volume_elements.len(),
15494 convergence_status,
15495 indicators,
15496 markers,
15497 sizing_update,
15498 });
15499 payload["mesh"] = serde_json::to_value(&mesh)
15500 .map_err(|err| format!("failed to encode mesh payload: {err}"))?;
15501 let bytes = serde_json::to_vec_pretty(&payload)
15502 .map_err(|err| format!("failed to encode analysis mesh artifact: {err}"))?;
15503 atomic_write_bytes(&path_buf, &bytes)?;
15504 update_mesh_evidence_from_analysis_mesh_payload(&payload, &mesh)?;
15505 Ok(())
15506}
15507
15508fn update_mesh_evidence_from_analysis_mesh_payload(
15509 analysis_mesh_payload: &serde_json::Value,
15510 mesh: &AnalysisMeshArtifact,
15511) -> Result<(), String> {
15512 let Some(evidence_path) = analysis_mesh_payload
15513 .get("mesh_evidence_artifact_path")
15514 .and_then(serde_json::Value::as_str)
15515 else {
15516 return Ok(());
15517 };
15518 let evidence_path = PathBuf::from(evidence_path);
15519 let mut evidence_payload: serde_json::Value = serde_json::from_slice(
15520 &fs_read(&evidence_path)
15521 .map_err(|err| format!("failed to read mesh evidence artifact: {err}"))?,
15522 )
15523 .map_err(|err| format!("failed to parse mesh evidence artifact: {err}"))?;
15524 let validation = evidence_payload
15525 .get("mesh_evidence")
15526 .and_then(|evidence| evidence.get("validation"))
15527 .cloned()
15528 .map(serde_json::from_value::<MeshValidationEvidence>)
15529 .transpose()
15530 .map_err(|err| format!("failed to decode mesh evidence validation policy: {err}"))?;
15531 let mesh_evidence = if let Some(validation) = validation {
15532 build_mesh_evidence_artifact_with_validation_evidence(mesh, validation)
15533 } else {
15534 let validation_options =
15535 analysis_mesh_validation_options_for_loaded_artifact(analysis_mesh_payload, mesh)
15536 .map_err(|err| {
15537 format!("failed to decode analysis mesh validation policy: {err}")
15538 })?;
15539 if let Some(validation_value) = analysis_mesh_payload.get("mesh_validation_options") {
15540 evidence_payload["mesh_validation_options"] = validation_value.clone();
15541 }
15542 build_mesh_evidence_artifact(mesh, &validation_options)
15543 };
15544 let mesh_authoring_summary = build_mesh_authoring_summary(&mesh_evidence);
15545 evidence_payload["mesh_authoring_summary"] = serde_json::to_value(mesh_authoring_summary)
15546 .map_err(|err| format!("failed to encode mesh authoring summary: {err}"))?;
15547 evidence_payload["mesh_evidence"] = serde_json::to_value(mesh_evidence)
15548 .map_err(|err| format!("failed to encode mesh evidence payload: {err}"))?;
15549 let bytes = serde_json::to_vec_pretty(&evidence_payload)
15550 .map_err(|err| format!("failed to encode mesh evidence artifact: {err}"))?;
15551 atomic_write_bytes(&evidence_path, &bytes)
15552}
15553
15554fn analysis_field_values<'a>(fields: &'a [AnalysisField], field_id: &str) -> Option<&'a [f64]> {
15555 fields
15556 .iter()
15557 .find(|field| field.field_id == field_id)
15558 .and_then(AnalysisField::as_host_f64)
15559}
15560
15561fn analysis_field_magnitudes(
15562 fields: &[AnalysisField],
15563 field_id: &str,
15564 entity_count: usize,
15565) -> Option<Vec<f64>> {
15566 let values = analysis_field_values(fields, field_id)?;
15567 vector_field_magnitudes(values, entity_count)
15568}
15569
15570fn latest_prefixed_vector_field_magnitudes(
15571 fields: &[AnalysisField],
15572 field_id_prefix: &str,
15573 entity_count: usize,
15574) -> Option<Vec<f64>> {
15575 fields
15576 .iter()
15577 .filter_map(|field| {
15578 let suffix = field.field_id.strip_prefix(field_id_prefix)?;
15579 let snapshot_index = suffix.parse::<usize>().ok()?;
15580 let values = field.as_host_f64()?;
15581 let magnitudes = vector_field_magnitudes(values, entity_count)?;
15582 Some((snapshot_index, magnitudes))
15583 })
15584 .max_by_key(|(snapshot_index, _)| *snapshot_index)
15585 .map(|(_, magnitudes)| magnitudes)
15586}
15587
15588fn vector_field_magnitudes(values: &[f64], entity_count: usize) -> Option<Vec<f64>> {
15589 if values.len() == entity_count {
15590 return Some(values.to_vec());
15591 }
15592 if entity_count == 0 || values.len() != entity_count * 3 {
15593 return None;
15594 }
15595 Some(
15596 values
15597 .chunks_exact(3)
15598 .map(|chunk| (chunk[0].powi(2) + chunk[1].powi(2) + chunk[2].powi(2)).sqrt())
15599 .collect(),
15600 )
15601}
15602
15603fn refinement_context_region_ids(payload: &serde_json::Value, key: &str) -> Vec<String> {
15604 payload
15605 .get("refinement_context")
15606 .and_then(|context| context.get(key))
15607 .and_then(serde_json::Value::as_array)
15608 .map(|values| {
15609 let mut ids = values
15610 .iter()
15611 .filter_map(serde_json::Value::as_str)
15612 .map(str::to_string)
15613 .collect::<Vec<_>>();
15614 ids.sort();
15615 ids.dedup();
15616 ids
15617 })
15618 .unwrap_or_default()
15619}
15620
15621fn has_boundary_faces_for_regions(mesh: &AnalysisMeshArtifact, region_ids: &[String]) -> bool {
15622 !region_ids.is_empty()
15623 && mesh.boundary_faces.iter().any(|face| {
15624 face.region_ids
15625 .iter()
15626 .any(|region_id| region_ids.iter().any(|target| target == region_id))
15627 })
15628}
15629
15630fn refinement_marker_options_for_focus(
15631 focus: runmat_meshing_core::RefinementFocusLevel,
15632) -> Option<RefinementMarkerOptions> {
15633 match focus {
15634 runmat_meshing_core::RefinementFocusLevel::Off => None,
15635 runmat_meshing_core::RefinementFocusLevel::Normal => {
15636 Some(RefinementMarkerOptions::default())
15637 }
15638 runmat_meshing_core::RefinementFocusLevel::Fine => Some(RefinementMarkerOptions {
15639 target_size_scale: 0.35,
15640 max_markers: 96,
15641 ..RefinementMarkerOptions::default()
15642 }),
15643 }
15644}
15645
15646fn indicator_was_used(
15647 indicators: &[runmat_meshing_core::RefinementIndicatorSummary],
15648 namespace: &str,
15649 name: &str,
15650) -> bool {
15651 indicators.iter().any(|indicator| {
15652 indicator.namespace == namespace
15653 && indicator.name == name
15654 && indicator.status == runmat_meshing_core::RefinementIndicatorStatus::Used
15655 })
15656}
15657
15658fn merge_sizing_update(target: &mut SizingFieldUpdate, update: SizingFieldUpdate) {
15659 target.samples.extend(update.samples);
15660 target.min_size_m = match (target.min_size_m, update.min_size_m) {
15661 (Some(left), Some(right)) => Some(left.min(right)),
15662 (Some(left), None) => Some(left),
15663 (None, Some(right)) => Some(right),
15664 (None, None) => None,
15665 };
15666 target.max_size_m = match (target.max_size_m, update.max_size_m) {
15667 (Some(left), Some(right)) => Some(left.max(right)),
15668 (Some(left), None) => Some(left),
15669 (None, Some(right)) => Some(right),
15670 (None, None) => None,
15671 };
15672}
15673
15674fn sizing_application_summary(mesh: &AnalysisMeshArtifact) -> serde_json::Value {
15675 let mut by_reason = BTreeMap::<String, usize>::new();
15676 let mut inserted_breakpoints_by_reason = BTreeMap::<String, usize>::new();
15677 let mut uninserted_by_reason = BTreeMap::<String, usize>::new();
15678 let mut inserted_breakpoint_count = 0_usize;
15679 for application in &mesh.sizing.applied_samples {
15680 let reason = application
15681 .reason
15682 .clone()
15683 .unwrap_or_else(|| "unspecified".to_string());
15684 *by_reason.entry(reason.clone()).or_default() += 1;
15685 if application.inserted_breakpoint_count > 0 {
15686 *inserted_breakpoints_by_reason.entry(reason).or_default() +=
15687 application.inserted_breakpoint_count;
15688 } else {
15689 *uninserted_by_reason.entry(reason).or_default() += 1;
15690 }
15691 inserted_breakpoint_count += application.inserted_breakpoint_count;
15692 }
15693 let accepted_requested = mesh
15694 .backend
15695 .tetrahedron_accepted_requested_refinement_point_count;
15696 let accepted_location = mesh
15697 .backend
15698 .tetrahedron_accepted_requested_refinement_location_count;
15699 let accepted_interpolated = mesh
15700 .backend
15701 .tetrahedron_accepted_requested_refinement_interpolated_point_count;
15702 let rejected_requested = mesh
15703 .backend
15704 .tetrahedron_rejected_requested_refinement_point_count;
15705 let dropped_requested = mesh
15706 .backend
15707 .tetrahedron_dropped_requested_refinement_point_count;
15708 let mut anisotropic_by_reason = BTreeMap::<String, usize>::new();
15709 let mut invalid_anisotropic_by_reason = BTreeMap::<String, usize>::new();
15710 for sample in &mesh.sizing.anisotropic_samples {
15711 let reason = sample
15712 .reason
15713 .clone()
15714 .unwrap_or_else(|| "unspecified".to_string());
15715 *anisotropic_by_reason.entry(reason.clone()).or_default() += 1;
15716 if !sample.is_valid_metric() {
15717 *invalid_anisotropic_by_reason.entry(reason).or_default() += 1;
15718 }
15719 }
15720 let invalid_anisotropic_count = invalid_anisotropic_by_reason.values().sum::<usize>();
15721 serde_json::json!({
15722 "total": mesh.sizing.applied_samples.len(),
15723 "inserted_breakpoint_count": inserted_breakpoint_count,
15724 "by_reason": by_reason,
15725 "inserted_breakpoints_by_reason": inserted_breakpoints_by_reason,
15726 "uninserted_by_reason": uninserted_by_reason,
15727 "anisotropic": {
15728 "total": mesh.sizing.anisotropic_samples.len(),
15729 "valid_count": mesh.sizing.anisotropic_samples.len().saturating_sub(invalid_anisotropic_count),
15730 "invalid_count": invalid_anisotropic_count,
15731 "by_reason": anisotropic_by_reason,
15732 "invalid_by_reason": invalid_anisotropic_by_reason,
15733 },
15734 "requested_tetrahedron_refinement": {
15735 "requested_count": mesh.backend.tetrahedron_requested_refinement_point_count,
15736 "accepted_location_count": accepted_location,
15737 "accepted_count": accepted_requested,
15738 "accepted_exact_count": accepted_requested.saturating_sub(accepted_interpolated),
15739 "accepted_interpolated_count": accepted_interpolated,
15740 "rejected_count": rejected_requested,
15741 "rejected_by_reason": mesh.backend.tetrahedron_requested_refinement_rejected_by_reason.clone(),
15742 "dropped_count": dropped_requested,
15743 "dropped_by_reason": mesh.backend.tetrahedron_requested_refinement_dropped_by_reason.clone(),
15744 "acceptance_ratio": if mesh.backend.tetrahedron_requested_refinement_point_count > 0 {
15745 Some(accepted_requested as f64 / mesh.backend.tetrahedron_requested_refinement_point_count as f64)
15746 } else {
15747 None
15748 },
15749 "rejection_ratio": if mesh.backend.tetrahedron_requested_refinement_point_count > 0 {
15750 Some(rejected_requested as f64 / mesh.backend.tetrahedron_requested_refinement_point_count as f64)
15751 } else {
15752 None
15753 },
15754 "drop_ratio": if mesh.backend.tetrahedron_requested_refinement_point_count > 0 {
15755 Some(dropped_requested as f64 / mesh.backend.tetrahedron_requested_refinement_point_count as f64)
15756 } else {
15757 None
15758 },
15759 "interpolated_ratio": if accepted_requested > 0 {
15760 Some(accepted_interpolated as f64 / accepted_requested as f64)
15761 } else {
15762 None
15763 },
15764 },
15765 })
15766}
15767
15768fn sizing_rejection_summary(mesh: &AnalysisMeshArtifact) -> serde_json::Value {
15769 let mut by_status = BTreeMap::<String, usize>::new();
15770 let mut by_reason = BTreeMap::<String, usize>::new();
15771 for rejection in &mesh.sizing.rejected_samples {
15772 *by_status.entry(rejection.status.clone()).or_default() += 1;
15773 let reason = rejection
15774 .reason
15775 .clone()
15776 .unwrap_or_else(|| "unspecified".to_string());
15777 *by_reason.entry(reason).or_default() += 1;
15778 }
15779 serde_json::json!({
15780 "total": mesh.sizing.rejected_samples.len(),
15781 "by_status": by_status,
15782 "by_reason": by_reason,
15783 })
15784}
15785
15786fn refinement_effect_summary(
15787 source_mesh: &AnalysisMeshArtifact,
15788 refined_mesh: &AnalysisMeshArtifact,
15789) -> serde_json::Value {
15790 let source_node_count = source_mesh.nodes.len();
15791 let source_element_count = source_mesh.volume_elements.len();
15792 let refined_node_count = refined_mesh.nodes.len();
15793 let refined_element_count = refined_mesh.volume_elements.len();
15794 serde_json::json!({
15795 "source_node_count": source_node_count,
15796 "source_element_count": source_element_count,
15797 "refined_node_count": refined_node_count,
15798 "refined_element_count": refined_element_count,
15799 "node_count_delta": refined_node_count as i64 - source_node_count as i64,
15800 "element_count_delta": refined_element_count as i64 - source_element_count as i64,
15801 "topology_changed": refined_node_count != source_node_count
15802 || refined_element_count != source_element_count,
15803 })
15804}
15805
15806fn structural_stress_gradient_samples(
15807 mesh: &AnalysisMeshArtifact,
15808 von_mises_values: &[f64],
15809) -> Vec<RefinementIndicatorSample> {
15810 structural_element_scalar_gradient_samples(mesh, von_mises_values)
15811}
15812
15813fn structural_strain_energy_density_samples(
15814 mesh: &AnalysisMeshArtifact,
15815 strain_energy_density_values: &[f64],
15816) -> Vec<RefinementIndicatorSample> {
15817 structural_element_scalar_gradient_samples(mesh, strain_energy_density_values)
15818}
15819
15820fn thermal_element_gradient_samples(
15821 mesh: &AnalysisMeshArtifact,
15822 element_values: &[f64],
15823) -> Vec<RefinementIndicatorSample> {
15824 structural_element_scalar_gradient_samples(mesh, element_values)
15825}
15826
15827fn electromagnetic_element_gradient_samples(
15828 mesh: &AnalysisMeshArtifact,
15829 element_values: &[f64],
15830) -> Vec<RefinementIndicatorSample> {
15831 structural_element_scalar_gradient_samples(mesh, element_values)
15832}
15833
15834fn acoustic_element_gradient_samples(
15835 mesh: &AnalysisMeshArtifact,
15836 element_values: &[f64],
15837) -> Vec<RefinementIndicatorSample> {
15838 structural_element_scalar_gradient_samples(mesh, element_values)
15839}
15840
15841fn cfd_element_gradient_samples(
15842 mesh: &AnalysisMeshArtifact,
15843 element_values: &[f64],
15844) -> Vec<RefinementIndicatorSample> {
15845 structural_element_scalar_gradient_samples(mesh, element_values)
15846}
15847
15848fn cht_element_gradient_samples(
15849 mesh: &AnalysisMeshArtifact,
15850 element_values: &[f64],
15851) -> Vec<RefinementIndicatorSample> {
15852 structural_element_scalar_gradient_samples(mesh, element_values)
15853}
15854
15855fn structural_boundary_region_samples(
15856 mesh: &AnalysisMeshArtifact,
15857 region_ids: &[String],
15858) -> Vec<RefinementIndicatorSample> {
15859 if region_ids.is_empty() {
15860 return Vec::new();
15861 }
15862 let region_ids = region_ids
15863 .iter()
15864 .map(String::as_str)
15865 .collect::<HashSet<_>>();
15866 let mut sampled_entity_ids = HashSet::new();
15867 let mut samples = Vec::new();
15868 for face in &mesh.boundary_faces {
15869 if !face
15870 .region_ids
15871 .iter()
15872 .any(|region_id| region_ids.contains(region_id.as_str()))
15873 {
15874 continue;
15875 }
15876 let Some(position_m) = element_centroid_m(mesh, &face.node_ids) else {
15877 continue;
15878 };
15879 let fallback_size = element_characteristic_size_m(mesh, &face.node_ids);
15880 if face.adjacent_volume_element_ids.is_empty() {
15881 if sampled_entity_ids.insert(face.face_id.clone()) {
15882 if let Some(current_size_m) = fallback_size {
15883 samples.push(RefinementIndicatorSample {
15884 entity_id: face.face_id.clone(),
15885 position_m,
15886 indicator_value: 1.0,
15887 current_size_m,
15888 });
15889 }
15890 }
15891 continue;
15892 }
15893 for element_id in &face.adjacent_volume_element_ids {
15894 if !sampled_entity_ids.insert(element_id.clone()) {
15895 continue;
15896 }
15897 let current_size_m = mesh
15898 .volume_elements
15899 .iter()
15900 .find(|element| element.element_id == *element_id)
15901 .and_then(|element| element_characteristic_size_m(mesh, &element.node_ids))
15902 .or(fallback_size);
15903 if let Some(current_size_m) = current_size_m {
15904 samples.push(RefinementIndicatorSample {
15905 entity_id: element_id.clone(),
15906 position_m,
15907 indicator_value: 1.0,
15908 current_size_m,
15909 });
15910 }
15911 }
15912 }
15913 samples
15914}
15915
15916fn structural_element_scalar_gradient_samples(
15917 mesh: &AnalysisMeshArtifact,
15918 values: &[f64],
15919) -> Vec<RefinementIndicatorSample> {
15920 if values.len() != mesh.volume_elements.len() {
15921 return Vec::new();
15922 }
15923 mesh.volume_elements
15924 .iter()
15925 .enumerate()
15926 .filter_map(|(index, element)| {
15927 let value = *values.get(index)?;
15928 let mut indicator = 0.0_f64;
15929 for (other_index, other) in mesh.volume_elements.iter().enumerate() {
15930 if index == other_index || shared_node_count(&element.node_ids, &other.node_ids) < 3
15931 {
15932 continue;
15933 }
15934 if let Some(other_value) = values.get(other_index) {
15935 indicator = indicator.max((value - *other_value).abs());
15936 }
15937 }
15938 if indicator <= 0.0 {
15939 indicator = value.abs();
15940 }
15941 Some(RefinementIndicatorSample {
15942 entity_id: element.element_id.clone(),
15943 position_m: element_centroid_m(mesh, &element.node_ids)?,
15944 indicator_value: indicator,
15945 current_size_m: element_characteristic_size_m(mesh, &element.node_ids)?,
15946 })
15947 })
15948 .collect()
15949}
15950
15951fn uniform_refinement_samples(mesh: &AnalysisMeshArtifact) -> Vec<RefinementIndicatorSample> {
15952 mesh.volume_elements
15953 .iter()
15954 .filter_map(|element| {
15955 Some(RefinementIndicatorSample {
15956 entity_id: element.element_id.clone(),
15957 position_m: element_centroid_m(mesh, &element.node_ids)?,
15958 indicator_value: 1.0,
15959 current_size_m: element_characteristic_size_m(mesh, &element.node_ids)?,
15960 })
15961 })
15962 .collect()
15963}
15964
15965fn shared_node_count(left: &[u32], right: &[u32]) -> usize {
15966 left.iter().filter(|node| right.contains(node)).count()
15967}
15968
15969fn element_centroid_m(mesh: &AnalysisMeshArtifact, node_ids: &[u32]) -> Option<[f64; 3]> {
15970 if node_ids.is_empty() {
15971 return None;
15972 }
15973 let mut centroid = [0.0_f64; 3];
15974 for node_id in node_ids {
15975 let node = mesh.nodes.iter().find(|node| node.node_id == *node_id)?;
15976 for (axis, value) in node.coordinates_m.iter().enumerate() {
15977 centroid[axis] += *value;
15978 }
15979 }
15980 for value in &mut centroid {
15981 *value /= node_ids.len() as f64;
15982 }
15983 Some(centroid)
15984}
15985
15986fn element_characteristic_size_m(mesh: &AnalysisMeshArtifact, node_ids: &[u32]) -> Option<f64> {
15987 let mut max_edge = 0.0_f64;
15988 for left_index in 0..node_ids.len() {
15989 for right_index in (left_index + 1)..node_ids.len() {
15990 let left = mesh
15991 .nodes
15992 .iter()
15993 .find(|node| node.node_id == node_ids[left_index])?
15994 .coordinates_m;
15995 let right = mesh
15996 .nodes
15997 .iter()
15998 .find(|node| node.node_id == node_ids[right_index])?
15999 .coordinates_m;
16000 max_edge = max_edge.max(vector_distance_m(left, right));
16001 }
16002 }
16003 (max_edge > 0.0).then_some(max_edge)
16004}
16005
16006fn vector_distance_m(left: [f64; 3], right: [f64; 3]) -> f64 {
16007 ((left[0] - right[0]).powi(2) + (left[1] - right[1]).powi(2) + (left[2] - right[2]).powi(2))
16008 .sqrt()
16009}
16010
16011fn resolve_analysis_mesh_artifact(
16012 analysis_mesh_artifact_path: Option<&str>,
16013 operation: &'static str,
16014 op_version: &'static str,
16015 context: &OperationContext,
16016) -> Result<Option<AnalysisMeshArtifact>, OperationErrorEnvelope> {
16017 let Some(path) = analysis_mesh_artifact_path else {
16018 return Ok(None);
16019 };
16020 let bytes = fs_read(path).map_err(|err| {
16021 operation_error(
16022 operation,
16023 op_version,
16024 context,
16025 OperationErrorSpec {
16026 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_READ_FAILED",
16027 error_type: OperationErrorType::Input,
16028 retryable: false,
16029 severity: OperationErrorSeverity::Error,
16030 },
16031 format!("failed to read analysis mesh artifact: {err}"),
16032 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16033 )
16034 })?;
16035 let payload = serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|err| {
16036 operation_error(
16037 operation,
16038 op_version,
16039 context,
16040 OperationErrorSpec {
16041 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_PARSE_FAILED",
16042 error_type: OperationErrorType::Input,
16043 retryable: false,
16044 severity: OperationErrorSeverity::Error,
16045 },
16046 format!("failed to parse analysis mesh artifact: {err}"),
16047 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16048 )
16049 })?;
16050 let mesh_value = payload
16051 .get("mesh")
16052 .cloned()
16053 .unwrap_or_else(|| payload.clone());
16054 let mesh = serde_json::from_value::<AnalysisMeshArtifact>(mesh_value).map_err(|err| {
16055 operation_error(
16056 operation,
16057 op_version,
16058 context,
16059 OperationErrorSpec {
16060 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_PARSE_FAILED",
16061 error_type: OperationErrorType::Input,
16062 retryable: false,
16063 severity: OperationErrorSeverity::Error,
16064 },
16065 format!("failed to decode analysis mesh artifact: {err}"),
16066 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16067 )
16068 })?;
16069 let validation_options = analysis_mesh_validation_options_for_loaded_artifact(&payload, &mesh)
16070 .map_err(|err| {
16071 operation_error(
16072 operation,
16073 op_version,
16074 context,
16075 OperationErrorSpec {
16076 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_PARSE_FAILED",
16077 error_type: OperationErrorType::Input,
16078 retryable: false,
16079 severity: OperationErrorSeverity::Error,
16080 },
16081 format!("failed to decode analysis mesh options: {err}"),
16082 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16083 )
16084 })?;
16085 runmat_meshing_core::validate_analysis_mesh_with_options(&mesh, validation_options).map_err(
16086 |err| {
16087 operation_error(
16088 operation,
16089 op_version,
16090 context,
16091 OperationErrorSpec {
16092 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_INVALID",
16093 error_type: OperationErrorType::Validation,
16094 retryable: false,
16095 severity: OperationErrorSeverity::Error,
16096 },
16097 format!("analysis mesh artifact failed validation: {err:?}"),
16098 BTreeMap::from([
16099 ("analysis_mesh_artifact_path".to_string(), path.to_string()),
16100 (
16101 "mesh_validation_code".to_string(),
16102 runmat_meshing_core::analysis_mesh_validation_error_code(&err).to_string(),
16103 ),
16104 ]),
16105 )
16106 },
16107 )?;
16108 Ok(Some(mesh))
16109}
16110
16111fn resolve_analysis_mesh_validation_evidence_status(
16112 analysis_mesh_artifact_path: Option<&str>,
16113 operation: &'static str,
16114 op_version: &'static str,
16115 context: &OperationContext,
16116) -> Result<MeshValidationEvidenceStatus, OperationErrorEnvelope> {
16117 let Some(path) = analysis_mesh_artifact_path else {
16118 return Ok(MeshValidationEvidenceStatus::NotRequested);
16119 };
16120 let bytes = fs_read(path).map_err(|err| {
16121 operation_error(
16122 operation,
16123 op_version,
16124 context,
16125 OperationErrorSpec {
16126 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_READ_FAILED",
16127 error_type: OperationErrorType::Input,
16128 retryable: false,
16129 severity: OperationErrorSeverity::Error,
16130 },
16131 format!("failed to read analysis mesh artifact: {err}"),
16132 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16133 )
16134 })?;
16135 let payload = serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|err| {
16136 operation_error(
16137 operation,
16138 op_version,
16139 context,
16140 OperationErrorSpec {
16141 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_PARSE_FAILED",
16142 error_type: OperationErrorType::Input,
16143 retryable: false,
16144 severity: OperationErrorSeverity::Error,
16145 },
16146 format!("failed to parse analysis mesh artifact: {err}"),
16147 BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16148 )
16149 })?;
16150 let Some(evidence_path) = payload
16151 .get("mesh_evidence_artifact_path")
16152 .and_then(serde_json::Value::as_str)
16153 else {
16154 return Ok(MeshValidationEvidenceStatus::Missing {
16155 detail: "analysis mesh artifact has no mesh_evidence_artifact_path".to_string(),
16156 });
16157 };
16158 let evidence_bytes = fs_read(evidence_path).map_err(|err| {
16159 operation_error(
16160 operation,
16161 op_version,
16162 context,
16163 OperationErrorSpec {
16164 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_EVIDENCE_READ_FAILED",
16165 error_type: OperationErrorType::Input,
16166 retryable: false,
16167 severity: OperationErrorSeverity::Error,
16168 },
16169 format!("failed to read analysis mesh evidence artifact: {err}"),
16170 BTreeMap::from([
16171 ("analysis_mesh_artifact_path".to_string(), path.to_string()),
16172 (
16173 "analysis_mesh_evidence_artifact_path".to_string(),
16174 evidence_path.to_string(),
16175 ),
16176 ]),
16177 )
16178 })?;
16179 let evidence_payload =
16180 serde_json::from_slice::<serde_json::Value>(&evidence_bytes).map_err(|err| {
16181 operation_error(
16182 operation,
16183 op_version,
16184 context,
16185 OperationErrorSpec {
16186 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_EVIDENCE_PARSE_FAILED",
16187 error_type: OperationErrorType::Input,
16188 retryable: false,
16189 severity: OperationErrorSeverity::Error,
16190 },
16191 format!("failed to parse analysis mesh evidence artifact: {err}"),
16192 BTreeMap::from([
16193 ("analysis_mesh_artifact_path".to_string(), path.to_string()),
16194 (
16195 "analysis_mesh_evidence_artifact_path".to_string(),
16196 evidence_path.to_string(),
16197 ),
16198 ]),
16199 )
16200 })?;
16201 let Some(validation_value) = evidence_payload
16202 .get("mesh_evidence")
16203 .and_then(|evidence| evidence.get("validation"))
16204 .cloned()
16205 else {
16206 return Ok(MeshValidationEvidenceStatus::Missing {
16207 detail: "mesh evidence artifact has no mesh_evidence.validation payload".to_string(),
16208 });
16209 };
16210 serde_json::from_value::<MeshValidationEvidence>(validation_value)
16211 .map(|validation| MeshValidationEvidenceStatus::Present(Box::new(validation)))
16212 .map_err(|err| {
16213 operation_error(
16214 operation,
16215 op_version,
16216 context,
16217 OperationErrorSpec {
16218 error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_EVIDENCE_PARSE_FAILED",
16219 error_type: OperationErrorType::Input,
16220 retryable: false,
16221 severity: OperationErrorSeverity::Error,
16222 },
16223 format!("failed to decode analysis mesh evidence validation payload: {err}"),
16224 BTreeMap::from([
16225 ("analysis_mesh_artifact_path".to_string(), path.to_string()),
16226 (
16227 "analysis_mesh_evidence_artifact_path".to_string(),
16228 evidence_path.to_string(),
16229 ),
16230 ]),
16231 )
16232 })
16233}
16234
16235fn study_evidence_root() -> PathBuf {
16236 let config = current_fea_runtime_config();
16237 config
16238 .study_artifact_root
16239 .or_else(|| {
16240 std::env::var("RUNMAT_FEA_STUDY_ARTIFACT_ROOT")
16241 .or_else(|_| std::env::var("RUNMAT_ANALYSIS_STUDY_ARTIFACT_ROOT"))
16242 .ok()
16243 .map(PathBuf::from)
16244 })
16245 .unwrap_or_else(|| {
16246 config
16247 .artifact_root
16248 .unwrap_or_else(default_fea_artifact_root)
16249 .join("studies")
16250 })
16251}
16252
16253fn thermo_field_artifact_root() -> PathBuf {
16254 let config = current_fea_runtime_config();
16255 config
16256 .thermo_field_artifact_root
16257 .or_else(|| {
16258 std::env::var("RUNMAT_THERMO_FIELD_ARTIFACT_ROOT")
16259 .ok()
16260 .map(PathBuf::from)
16261 })
16262 .unwrap_or_else(|| {
16263 config
16264 .artifact_root
16265 .unwrap_or_else(default_fea_artifact_root)
16266 .join("thermo-fields")
16267 })
16268}
16269
16270fn persist_study_evidence(
16271 study_fingerprint: &str,
16272 stage: &str,
16273 payload: serde_json::Value,
16274) -> Result<String, String> {
16275 let study_key = study_fingerprint.replace(':', "_");
16276 let root = study_evidence_root().join(study_key);
16277 fs_create_dir_all(&root)
16278 .map_err(|err| format!("failed to create study evidence directory: {err}"))?;
16279 let path = root.join(format!("{stage}.json"));
16280 let bytes = serde_json::to_vec_pretty(&payload)
16281 .map_err(|err| format!("failed to encode study evidence payload: {err}"))?;
16282 atomic_write_bytes(&path, &bytes)?;
16283 Ok(path.display().to_string())
16284}
16285
16286fn atomic_write_bytes(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
16287 let tmp = path.with_extension(format!(
16288 "tmp-{}-{}",
16289 std::process::id(),
16290 Utc::now().timestamp_nanos_opt().unwrap_or_default()
16291 ));
16292 fs_write(&tmp, bytes)
16293 .map_err(|err| format!("failed to write temporary study evidence file: {err}"))?;
16294 fs_rename(&tmp, path).map_err(|err| {
16295 let _ = fs_remove_file(&tmp);
16296 format!("failed to atomically persist study evidence file: {err}")
16297 })
16298}
16299
16300fn fs_create_dir_all(path: impl Into<PathBuf>) -> std::io::Result<()> {
16301 runmat_filesystem::create_dir_all(path.into())
16302}
16303
16304fn fs_read(path: impl Into<PathBuf>) -> std::io::Result<Vec<u8>> {
16305 runmat_filesystem::read(path.into())
16306}
16307
16308fn fs_write(path: impl Into<PathBuf>, bytes: &[u8]) -> std::io::Result<()> {
16309 runmat_filesystem::write(path.into(), bytes)
16310}
16311
16312fn fs_rename(from: impl Into<PathBuf>, to: impl Into<PathBuf>) -> std::io::Result<()> {
16313 runmat_filesystem::rename(from.into(), to.into())
16314}
16315
16316fn fs_remove_file(path: impl Into<PathBuf>) -> std::io::Result<()> {
16317 match runmat_filesystem::remove_file(path.into()) {
16318 Ok(()) => Ok(()),
16319 Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
16320 Err(err) => Err(err),
16321 }
16322}
16323
16324fn fs_exists(path: impl Into<PathBuf>) -> std::io::Result<bool> {
16325 match runmat_filesystem::metadata(path.into()) {
16326 Ok(_) => Ok(true),
16327 Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
16328 Err(err) => Err(err),
16329 }
16330}
16331
16332fn fs_read_to_string(path: impl Into<PathBuf>) -> std::io::Result<String> {
16333 runmat_filesystem::read_to_string(path.into())
16334}
16335
16336fn to_fea_prep_context(
16337 context: Option<&AnalysisRunPrepContext>,
16338 calibration_profile: Option<PrepCalibrationProfile>,
16339) -> Option<runmat_analysis_fea::FeaPrepContext> {
16340 context.map(|prep| runmat_analysis_fea::FeaPrepContext {
16341 prepared_mesh_count: prep.prepared_mesh_count,
16342 prepared_node_count: prep.prepared_node_count,
16343 prepared_element_count: prep.prepared_element_count,
16344 mapped_region_count: prep.mapped_region_count,
16345 min_scaled_jacobian: prep.min_scaled_jacobian,
16346 mean_aspect_ratio: prep.mean_aspect_ratio,
16347 inverted_element_count: prep.inverted_element_count,
16348 mapped_load_count: prep.mapped_load_count,
16349 mapped_bc_count: prep.mapped_bc_count,
16350 layout_seed: prep.layout_seed,
16351 topology_dof_multiplier: prep.topology_dof_multiplier,
16352 topology_bandwidth_estimate: prep.topology_bandwidth_estimate,
16353 mapped_region_participation_ratio: prep.mapped_region_participation_ratio,
16354 topology_surface_patch_ratio: prep.topology_surface_patch_ratio,
16355 topology_volume_core_ratio: prep.topology_volume_core_ratio,
16356 topology_mixed_family_ratio: prep.topology_mixed_family_ratio,
16357 topology_region_span_mean: prep.topology_region_span_mean,
16358 topology_region_block_count: prep.topology_region_block_count,
16359 topology_region_mesh_mean: prep.topology_region_mesh_mean,
16360 topology_region_mesh_variance: prep.topology_region_mesh_variance,
16361 topology_triangle_family_ratio: prep.topology_triangle_family_ratio,
16362 topology_quad_family_ratio: prep.topology_quad_family_ratio,
16363 topology_tetrahedron_family_ratio: prep.topology_tetrahedron_family_ratio,
16364 topology_hex_family_ratio: prep.topology_hex_family_ratio,
16365 coordinate_span_x_m: prep.coordinate_span_x_m,
16366 coordinate_span_y_m: prep.coordinate_span_y_m,
16367 coordinate_span_z_m: prep.coordinate_span_z_m,
16368 coordinate_active_dimension_count: prep.coordinate_active_dimension_count,
16369 coordinate_characteristic_length_m: prep.coordinate_characteristic_length_m,
16370 element_geometry_node_count: prep.element_geometry_node_count,
16371 element_geometry_edge_count: prep.element_geometry_edge_count,
16372 mean_element_edge_length_m: prep.mean_element_edge_length_m,
16373 mean_element_area_m2: prep.mean_element_area_m2,
16374 element_geometry_coverage_ratio: prep.element_geometry_coverage_ratio,
16375 reference_element_coordinates_m: prep.reference_element_coordinates_m,
16376 reference_element_area_m2: prep.reference_element_area_m2,
16377 element_topology_sample_element_count: prep.element_topology_sample_element_count,
16378 element_topology_sample_edge_count: prep.element_topology_sample_edge_count,
16379 element_topology_sample_edge_nodes: prep.element_topology_sample_edge_nodes,
16380 element_topology_sample_node_coordinates_m: prep.element_topology_sample_node_coordinates_m,
16381 element_topology_sample_element_edges: prep.element_topology_sample_element_edges,
16382 element_topology_sample_element_orientations: prep
16383 .element_topology_sample_element_orientations,
16384 element_topology_sample_element_areas_m2: prep.element_topology_sample_element_areas_m2,
16385 element_topology_node_coordinates_m: prep.element_topology_node_coordinates_m.clone(),
16386 element_topology_edge_nodes: prep.element_topology_edge_nodes.clone(),
16387 element_topology_element_edges: prep.element_topology_element_edges.clone(),
16388 element_topology_element_orientations: prep.element_topology_element_orientations.clone(),
16389 element_topology_element_areas_m2: prep.element_topology_element_areas_m2.clone(),
16390 calibration_profile_override: calibration_profile.and_then(map_calibration_profile),
16391 })
16392}
16393
16394fn render_topology_from_prep_context(
16395 context: Option<&AnalysisRunPrepContext>,
16396) -> Option<AnalysisRenderTopology> {
16397 let prep = context?;
16398 if prep.element_topology_node_coordinates_m.is_empty()
16399 || prep.element_topology_edge_nodes.is_empty()
16400 || prep.element_topology_element_edges.is_empty()
16401 {
16402 return None;
16403 }
16404
16405 let triangles = prep
16406 .element_topology_element_edges
16407 .iter()
16408 .filter_map(|element_edges| triangle_from_element_edges(element_edges, prep))
16409 .collect::<Vec<_>>();
16410 if triangles.is_empty() {
16411 return None;
16412 }
16413
16414 Some(AnalysisRenderTopology {
16415 schema_version: "analysis_render_topology/v1".to_string(),
16416 source: AnalysisRenderTopologySource::SolverPrep,
16417 meshes: vec![AnalysisRenderMesh {
16418 mesh_id: "solver_surface".to_string(),
16419 vertices: prep.element_topology_node_coordinates_m.clone(),
16420 triangles,
16421 regions: Vec::new(),
16422 vertex_volume_node_indices: (0..prep.element_topology_node_coordinates_m.len())
16423 .map(Some)
16424 .collect(),
16425 triangle_volume_element_indices: Vec::new(),
16426 }],
16427 })
16428}
16429
16430fn render_topology_from_analysis_mesh(
16431 mesh: Option<&AnalysisMeshArtifact>,
16432) -> Option<AnalysisRenderTopology> {
16433 let mesh = mesh?;
16434 if mesh.nodes.is_empty() || mesh.boundary_faces.is_empty() {
16435 return None;
16436 }
16437 if validate_analysis_mesh_solver_field_mapping(mesh).is_err() {
16438 return None;
16439 }
16440
16441 let node_index_by_id = mesh
16442 .nodes
16443 .iter()
16444 .enumerate()
16445 .map(|(index, node)| (node.node_id, index as u32))
16446 .collect::<HashMap<_, _>>();
16447 let volume_index_by_id = mesh
16448 .volume_elements
16449 .iter()
16450 .enumerate()
16451 .map(|(index, element)| (element.element_id.as_str(), index))
16452 .collect::<HashMap<_, _>>();
16453 let face_entries = mesh
16454 .boundary_faces
16455 .iter()
16456 .filter(|face| {
16457 face.kind == runmat_meshing_core::BoundaryElementKind::Tri3 && face.node_ids.len() == 3
16458 })
16459 .filter_map(|face| {
16460 let triangle = [
16461 *node_index_by_id.get(&face.node_ids[0])?,
16462 *node_index_by_id.get(&face.node_ids[1])?,
16463 *node_index_by_id.get(&face.node_ids[2])?,
16464 ];
16465 let volume_element_index = face
16466 .adjacent_volume_element_ids
16467 .iter()
16468 .find_map(|element_id| volume_index_by_id.get(element_id.as_str()).copied());
16469 Some((triangle, volume_element_index, face.region_ids.clone()))
16470 })
16471 .collect::<Vec<_>>();
16472 let mut triangles = Vec::with_capacity(face_entries.len());
16473 let mut triangle_volume_element_indices = Vec::with_capacity(face_entries.len());
16474 let mut region_triangles = BTreeMap::<String, Vec<u32>>::new();
16475 for (triangle_index, (triangle, volume_element_index, region_ids)) in
16476 face_entries.into_iter().enumerate()
16477 {
16478 triangles.push(triangle);
16479 triangle_volume_element_indices.push(volume_element_index);
16480 for region_id in region_ids {
16481 region_triangles
16482 .entry(region_id)
16483 .or_default()
16484 .push(triangle_index as u32);
16485 }
16486 }
16487 if triangles.is_empty() {
16488 return None;
16489 }
16490 let regions = render_regions_from_triangle_indices(region_triangles);
16491
16492 Some(AnalysisRenderTopology {
16493 schema_version: "analysis_render_topology/v1".to_string(),
16494 source: AnalysisRenderTopologySource::AnalysisMesh,
16495 meshes: vec![AnalysisRenderMesh {
16496 mesh_id: "analysis_mesh_boundary".to_string(),
16497 vertices: mesh.nodes.iter().map(|node| node.coordinates_m).collect(),
16498 triangles,
16499 regions,
16500 vertex_volume_node_indices: (0..mesh.nodes.len()).map(Some).collect(),
16501 triangle_volume_element_indices,
16502 }],
16503 })
16504}
16505
16506fn render_regions_from_triangle_indices(
16507 region_triangles: BTreeMap<String, Vec<u32>>,
16508) -> Vec<AnalysisRenderRegion> {
16509 region_triangles
16510 .into_iter()
16511 .filter_map(|(region_id, mut triangle_indices)| {
16512 triangle_indices.sort_unstable();
16513 triangle_indices.dedup();
16514 let mut ranges = Vec::<AnalysisRenderTriangleRange>::new();
16515 for triangle_index in triangle_indices {
16516 if let Some(last) = ranges.last_mut() {
16517 if last.start.saturating_add(last.count) == triangle_index {
16518 last.count = last.count.saturating_add(1);
16519 continue;
16520 }
16521 }
16522 ranges.push(AnalysisRenderTriangleRange {
16523 start: triangle_index,
16524 count: 1,
16525 });
16526 }
16527 if ranges.is_empty() {
16528 return None;
16529 }
16530 Some(AnalysisRenderRegion {
16531 region_id,
16532 label: None,
16533 tag: Some("boundary".to_string()),
16534 triangle_ranges: ranges,
16535 })
16536 })
16537 .collect()
16538}
16539
16540fn triangle_from_element_edges(
16541 element_edges: &[u32; 3],
16542 prep: &AnalysisRunPrepContext,
16543) -> Option<[u32; 3]> {
16544 let mut nodes = Vec::<u32>::with_capacity(3);
16545 for edge_index in element_edges {
16546 let edge = prep.element_topology_edge_nodes.get(*edge_index as usize)?;
16547 for node in edge {
16548 if !nodes.contains(node) {
16549 nodes.push(*node);
16550 }
16551 }
16552 }
16553 if nodes.len() == 3 {
16554 Some([nodes[0], nodes[1], nodes[2]])
16555 } else {
16556 None
16557 }
16558}
16559
16560fn map_calibration_profile(
16561 profile: PrepCalibrationProfile,
16562) -> Option<runmat_analysis_fea::FeaPrepCalibrationProfile> {
16563 match profile {
16564 PrepCalibrationProfile::Auto => None,
16565 PrepCalibrationProfile::Fast => Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Fast),
16566 PrepCalibrationProfile::Balanced => {
16567 Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Balanced)
16568 }
16569 PrepCalibrationProfile::Conservative => {
16570 Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Conservative)
16571 }
16572 }
16573}
16574
16575fn model_thermo_coupling_options(model: &AnalysisModel) -> Option<ThermoMechanicalCouplingOptions> {
16576 let domain = model.thermo_mechanical.as_ref()?;
16577 let expansion = if model.materials.is_empty() {
16578 1.2e-5
16579 } else {
16580 model
16581 .materials
16582 .iter()
16583 .map(|material| material.thermal.expansion_coefficient_per_k.max(0.0))
16584 .sum::<f64>()
16585 / model.materials.len() as f64
16586 };
16587
16588 Some(ThermoMechanicalCouplingOptions {
16589 enabled: domain.enabled,
16590 reference_temperature_k: domain.reference_temperature_k,
16591 applied_temperature_delta_k: domain.applied_temperature_delta_k,
16592 thermal_expansion_coefficient: expansion,
16593 field_artifact_id: domain.field_artifact_id.clone(),
16594 field_source: domain
16595 .field_source
16596 .as_ref()
16597 .map(|source| ThermoFieldSource {
16598 source_id: source.source_id.clone(),
16599 revision: source.revision,
16600 interpolation_mode: source.interpolation_mode.map(|mode| match mode {
16601 runmat_analysis_core::ThermoFieldInterpolationMode::Linear => {
16602 ThermoFieldInterpolationMode::Linear
16603 }
16604 runmat_analysis_core::ThermoFieldInterpolationMode::Step => {
16605 ThermoFieldInterpolationMode::Step
16606 }
16607 }),
16608 expected_region_ids: source.expected_region_ids.clone(),
16609 }),
16610 region_temperature_deltas: domain
16611 .region_temperature_deltas
16612 .iter()
16613 .map(|delta| ThermoRegionTemperatureDelta {
16614 region_id: delta.region_id.clone(),
16615 temperature_delta_k: delta.temperature_delta_k,
16616 })
16617 .collect(),
16618 time_profile: domain
16619 .time_profile
16620 .iter()
16621 .map(|point| ThermoTimeProfilePoint {
16622 normalized_time: point.normalized_time,
16623 scale: point.scale,
16624 })
16625 .collect(),
16626 })
16627}
16628
16629fn model_electro_coupling_options(model: &AnalysisModel) -> Option<ElectroThermalCouplingOptions> {
16630 let domain = model.electro_thermal.as_ref()?;
16631 let electrical_materials: Vec<_> = model
16632 .materials
16633 .iter()
16634 .filter_map(|material| material.electrical.as_ref())
16635 .collect();
16636 let base_conductivity = if electrical_materials.is_empty() {
16637 1.0
16638 } else {
16639 electrical_materials
16640 .iter()
16641 .map(|e| e.conductivity_s_per_m.max(1.0e-12))
16642 .sum::<f64>()
16643 / electrical_materials.len() as f64
16644 };
16645 let resistive_coeff = if electrical_materials.is_empty() {
16646 0.0
16647 } else {
16648 electrical_materials
16649 .iter()
16650 .map(|e| e.resistive_heating_coefficient.max(0.0))
16651 .sum::<f64>()
16652 / electrical_materials.len() as f64
16653 };
16654
16655 Some(ElectroThermalCouplingOptions {
16656 enabled: domain.enabled,
16657 reference_temperature_k: domain.reference_temperature_k,
16658 applied_voltage_v: domain.applied_voltage_v,
16659 base_electrical_conductivity_s_per_m: base_conductivity,
16660 resistive_heating_coefficient: resistive_coeff,
16661 region_conductivity_scales: domain
16662 .region_conductivity_scales
16663 .iter()
16664 .map(|scale| ElectroRegionConductivityScale {
16665 region_id: scale.region_id.clone(),
16666 conductivity_scale: scale.conductivity_scale,
16667 })
16668 .collect(),
16669 time_profile: domain
16670 .time_profile
16671 .iter()
16672 .map(|point| ElectroTimeProfilePoint {
16673 normalized_time: point.normalized_time,
16674 current_scale: point.current_scale,
16675 })
16676 .collect(),
16677 })
16678}
16679
16680fn model_plasticity_constitutive_options(
16681 model: &AnalysisModel,
16682) -> Option<PlasticityConstitutiveOptions> {
16683 let plastic = model
16684 .materials
16685 .iter()
16686 .find_map(|material| material.plastic.as_ref())?;
16687 Some(PlasticityConstitutiveOptions {
16688 enabled: true,
16689 yield_strain: plastic.yield_strain,
16690 hardening_modulus_ratio: plastic.hardening_modulus_ratio,
16691 saturation_exponent: plastic.saturation_exponent,
16692 })
16693}
16694
16695fn model_contact_interface_options(model: &AnalysisModel) -> Option<ContactInterfaceOptions> {
16696 model
16697 .interfaces
16698 .iter()
16699 .filter_map(|interface| match &interface.kind {
16700 AnalysisInterfaceKind::Contact(contact) => Some(ContactInterfaceOptions {
16701 enabled: true,
16702 penalty_stiffness_scale: contact.penalty_stiffness_scale,
16703 max_penetration_ratio: contact.max_penetration_ratio,
16704 friction_coefficient: contact.friction_coefficient,
16705 }),
16706 AnalysisInterfaceKind::FluidStructure(_)
16707 | AnalysisInterfaceKind::ConjugateHeatTransfer(_) => None,
16708 })
16709 .next()
16710}
16711
16712fn to_fea_thermo_mechanical_context(
16713 options: Option<ThermoMechanicalCouplingOptions>,
16714) -> Option<runmat_analysis_fea::FeaThermoMechanicalContext> {
16715 options.map(|tm| runmat_analysis_fea::FeaThermoMechanicalContext {
16716 enabled: tm.enabled,
16717 reference_temperature_k: tm.reference_temperature_k,
16718 applied_temperature_delta_k: tm.applied_temperature_delta_k,
16719 thermal_expansion_coefficient: tm.thermal_expansion_coefficient,
16720 field_source: tm
16721 .field_source
16722 .map(|source| runmat_analysis_fea::FeaThermoFieldSource {
16723 source_id: source.source_id,
16724 revision: source.revision,
16725 interpolation_mode: source.interpolation_mode.map(|mode| match mode {
16726 contracts::ThermoFieldInterpolationMode::Linear => {
16727 runmat_analysis_fea::FeaThermoFieldInterpolationMode::Linear
16728 }
16729 contracts::ThermoFieldInterpolationMode::Step => {
16730 runmat_analysis_fea::FeaThermoFieldInterpolationMode::Step
16731 }
16732 }),
16733 expected_region_ids: source.expected_region_ids,
16734 }),
16735 region_temperature_deltas: tm
16736 .region_temperature_deltas
16737 .into_iter()
16738 .map(
16739 |ThermoRegionTemperatureDelta {
16740 region_id,
16741 temperature_delta_k,
16742 }| runmat_analysis_fea::FeaThermoRegionTemperatureDelta {
16743 region_id,
16744 temperature_delta_k,
16745 },
16746 )
16747 .collect(),
16748 time_profile: tm
16749 .time_profile
16750 .into_iter()
16751 .map(
16752 |ThermoTimeProfilePoint {
16753 normalized_time,
16754 scale,
16755 }| runmat_analysis_fea::FeaThermoTimeProfilePoint {
16756 normalized_time,
16757 scale,
16758 },
16759 )
16760 .collect(),
16761 })
16762}
16763
16764fn to_fea_electro_thermal_context(
16765 options: Option<ElectroThermalCouplingOptions>,
16766) -> Option<runmat_analysis_fea::FeaElectroThermalContext> {
16767 options.map(|et| runmat_analysis_fea::FeaElectroThermalContext {
16768 enabled: et.enabled,
16769 reference_temperature_k: et.reference_temperature_k,
16770 applied_voltage_v: et.applied_voltage_v,
16771 base_electrical_conductivity_s_per_m: et.base_electrical_conductivity_s_per_m,
16772 resistive_heating_coefficient: et.resistive_heating_coefficient,
16773 region_conductivity_scales: et
16774 .region_conductivity_scales
16775 .into_iter()
16776 .map(
16777 |ElectroRegionConductivityScale {
16778 region_id,
16779 conductivity_scale,
16780 }| runmat_analysis_fea::FeaElectroRegionConductivityScale {
16781 region_id,
16782 conductivity_scale,
16783 },
16784 )
16785 .collect(),
16786 time_profile: et
16787 .time_profile
16788 .into_iter()
16789 .map(
16790 |ElectroTimeProfilePoint {
16791 normalized_time,
16792 current_scale,
16793 }| runmat_analysis_fea::FeaElectroTimeProfilePoint {
16794 normalized_time,
16795 current_scale,
16796 },
16797 )
16798 .collect(),
16799 })
16800}
16801
16802fn to_fea_plasticity_constitutive_context(
16803 options: Option<PlasticityConstitutiveOptions>,
16804) -> Option<runmat_analysis_fea::FeaPlasticityConstitutiveContext> {
16805 options.map(
16806 |plasticity| runmat_analysis_fea::FeaPlasticityConstitutiveContext {
16807 enabled: plasticity.enabled,
16808 yield_strain: plasticity.yield_strain,
16809 hardening_modulus_ratio: plasticity.hardening_modulus_ratio,
16810 saturation_exponent: plasticity.saturation_exponent,
16811 },
16812 )
16813}
16814
16815fn to_fea_contact_interface_context(
16816 options: Option<ContactInterfaceOptions>,
16817) -> Option<runmat_analysis_fea::FeaContactInterfaceContext> {
16818 options.map(|contact| runmat_analysis_fea::FeaContactInterfaceContext {
16819 enabled: contact.enabled,
16820 penalty_stiffness_scale: contact.penalty_stiffness_scale,
16821 max_penetration_ratio: contact.max_penetration_ratio,
16822 friction_coefficient: contact.friction_coefficient,
16823 })
16824}
16825
16826fn electro_thermal_invalid_options_error_code(operation: &str) -> &'static str {
16827 match operation {
16828 ANALYSIS_RUN_MODAL_OPERATION => "RM.FEA.RUN_MODAL.INVALID_ELECTRO_THERMAL_OPTIONS",
16829 ANALYSIS_RUN_ACOUSTIC_OPERATION => "RM.FEA.RUN_ACOUSTIC.INVALID_ELECTRO_THERMAL_OPTIONS",
16830 ANALYSIS_RUN_TRANSIENT_OPERATION => "RM.FEA.RUN_TRANSIENT.INVALID_ELECTRO_THERMAL_OPTIONS",
16831 ANALYSIS_RUN_NONLINEAR_OPERATION => "RM.FEA.RUN_NONLINEAR.INVALID_ELECTRO_THERMAL_OPTIONS",
16832 ANALYSIS_RUN_OPERATION => "RM.FEA.RUN_LINEAR_STATIC.INVALID_ELECTRO_THERMAL_OPTIONS",
16833 _ => "RM.FEA.RUN.INVALID_ELECTRO_THERMAL_OPTIONS",
16834 }
16835}
16836
16837fn validate_thermo_coupling_options(
16838 model: &AnalysisModel,
16839 options: &ThermoMechanicalCouplingOptions,
16840) -> Result<(), (String, BTreeMap<String, String>)> {
16841 if !options.enabled {
16842 return Ok(());
16843 }
16844 if !options.reference_temperature_k.is_finite() || options.reference_temperature_k <= 0.0 {
16845 return Err((
16846 "thermo coupling requires finite positive reference_temperature_k".to_string(),
16847 BTreeMap::from([(
16848 "reference_temperature_k".to_string(),
16849 options.reference_temperature_k.to_string(),
16850 )]),
16851 ));
16852 }
16853 if !options.applied_temperature_delta_k.is_finite() {
16854 return Err((
16855 "thermo coupling requires finite applied_temperature_delta_k".to_string(),
16856 BTreeMap::from([(
16857 "applied_temperature_delta_k".to_string(),
16858 options.applied_temperature_delta_k.to_string(),
16859 )]),
16860 ));
16861 }
16862 if !options.thermal_expansion_coefficient.is_finite()
16863 || options.thermal_expansion_coefficient < 0.0
16864 {
16865 return Err((
16866 "thermo coupling requires finite non-negative thermal_expansion_coefficient"
16867 .to_string(),
16868 BTreeMap::from([(
16869 "thermal_expansion_coefficient".to_string(),
16870 options.thermal_expansion_coefficient.to_string(),
16871 )]),
16872 ));
16873 }
16874
16875 let mut last_t = -1.0_f64;
16876 for (idx, point) in options.time_profile.iter().enumerate() {
16877 if !point.normalized_time.is_finite()
16878 || point.normalized_time < 0.0
16879 || point.normalized_time > 1.0
16880 {
16881 return Err((
16882 "thermo time_profile normalized_time must be finite and within [0, 1]".to_string(),
16883 BTreeMap::from([
16884 ("time_profile_index".to_string(), idx.to_string()),
16885 (
16886 "normalized_time".to_string(),
16887 point.normalized_time.to_string(),
16888 ),
16889 ]),
16890 ));
16891 }
16892 if !point.scale.is_finite() {
16893 return Err((
16894 "thermo time_profile scale must be finite".to_string(),
16895 BTreeMap::from([
16896 ("time_profile_index".to_string(), idx.to_string()),
16897 ("scale".to_string(), point.scale.to_string()),
16898 ]),
16899 ));
16900 }
16901 if point.normalized_time + 1.0e-12 < last_t {
16902 return Err((
16903 "thermo time_profile normalized_time must be monotonic non-decreasing".to_string(),
16904 BTreeMap::from([
16905 ("time_profile_index".to_string(), idx.to_string()),
16906 (
16907 "normalized_time".to_string(),
16908 point.normalized_time.to_string(),
16909 ),
16910 ]),
16911 ));
16912 }
16913 last_t = point.normalized_time;
16914 }
16915
16916 let model_region_ids = model
16917 .material_assignments
16918 .iter()
16919 .map(|assignment| assignment.region_id.as_str())
16920 .collect::<HashSet<_>>();
16921
16922 for delta in &options.region_temperature_deltas {
16923 if !delta.temperature_delta_k.is_finite() {
16924 return Err((
16925 "thermo region_temperature_deltas must use finite temperature_delta_k".to_string(),
16926 BTreeMap::from([
16927 ("region_id".to_string(), delta.region_id.clone()),
16928 (
16929 "temperature_delta_k".to_string(),
16930 delta.temperature_delta_k.to_string(),
16931 ),
16932 ]),
16933 ));
16934 }
16935 }
16936
16937 if let Some(source) = options.field_source.as_ref() {
16938 if source.source_id.trim().is_empty() {
16939 return Err((
16940 "thermo field_source requires a non-empty source_id".to_string(),
16941 BTreeMap::new(),
16942 ));
16943 }
16944 for expected_region in &source.expected_region_ids {
16945 if !model_region_ids.contains(expected_region.as_str()) {
16946 return Err((
16947 "thermo field_source expected_region_ids must exist in model material assignments"
16948 .to_string(),
16949 BTreeMap::from([("region_id".to_string(), expected_region.clone())]),
16950 ));
16951 }
16952 }
16953 }
16954
16955 Ok(())
16956}
16957
16958fn validate_electro_coupling_options(
16959 model: &AnalysisModel,
16960 options: &ElectroThermalCouplingOptions,
16961) -> Result<(), (String, BTreeMap<String, String>)> {
16962 if !options.enabled {
16963 return Ok(());
16964 }
16965 if !options.reference_temperature_k.is_finite() || options.reference_temperature_k <= 0.0 {
16966 return Err((
16967 "electro-thermal coupling requires finite positive reference_temperature_k".to_string(),
16968 BTreeMap::from([(
16969 "reference_temperature_k".to_string(),
16970 options.reference_temperature_k.to_string(),
16971 )]),
16972 ));
16973 }
16974 if !options.applied_voltage_v.is_finite() {
16975 return Err((
16976 "electro-thermal coupling requires finite applied_voltage_v".to_string(),
16977 BTreeMap::from([(
16978 "applied_voltage_v".to_string(),
16979 options.applied_voltage_v.to_string(),
16980 )]),
16981 ));
16982 }
16983 if !options.base_electrical_conductivity_s_per_m.is_finite()
16984 || options.base_electrical_conductivity_s_per_m <= 0.0
16985 {
16986 return Err((
16987 "electro-thermal coupling requires finite positive base_electrical_conductivity_s_per_m"
16988 .to_string(),
16989 BTreeMap::from([(
16990 "base_electrical_conductivity_s_per_m".to_string(),
16991 options.base_electrical_conductivity_s_per_m.to_string(),
16992 )]),
16993 ));
16994 }
16995 if !options.resistive_heating_coefficient.is_finite()
16996 || options.resistive_heating_coefficient < 0.0
16997 {
16998 return Err((
16999 "electro-thermal coupling requires finite non-negative resistive_heating_coefficient"
17000 .to_string(),
17001 BTreeMap::from([(
17002 "resistive_heating_coefficient".to_string(),
17003 options.resistive_heating_coefficient.to_string(),
17004 )]),
17005 ));
17006 }
17007 let mut last_t = -1.0_f64;
17008 for (idx, point) in options.time_profile.iter().enumerate() {
17009 if !point.normalized_time.is_finite()
17010 || point.normalized_time < 0.0
17011 || point.normalized_time > 1.0
17012 {
17013 return Err((
17014 "electro time_profile normalized_time must be finite and within [0, 1]".to_string(),
17015 BTreeMap::from([
17016 ("time_profile_index".to_string(), idx.to_string()),
17017 (
17018 "normalized_time".to_string(),
17019 point.normalized_time.to_string(),
17020 ),
17021 ]),
17022 ));
17023 }
17024 if !point.current_scale.is_finite() {
17025 return Err((
17026 "electro time_profile current_scale must be finite".to_string(),
17027 BTreeMap::from([
17028 ("time_profile_index".to_string(), idx.to_string()),
17029 ("current_scale".to_string(), point.current_scale.to_string()),
17030 ]),
17031 ));
17032 }
17033 if point.normalized_time + 1.0e-12 < last_t {
17034 return Err((
17035 "electro time_profile normalized_time must be monotonic non-decreasing".to_string(),
17036 BTreeMap::from([
17037 ("time_profile_index".to_string(), idx.to_string()),
17038 (
17039 "normalized_time".to_string(),
17040 point.normalized_time.to_string(),
17041 ),
17042 ]),
17043 ));
17044 }
17045 last_t = point.normalized_time;
17046 }
17047 let model_region_ids = model
17048 .material_assignments
17049 .iter()
17050 .map(|assignment| assignment.region_id.as_str())
17051 .collect::<HashSet<_>>();
17052 for scale in &options.region_conductivity_scales {
17053 if !scale.conductivity_scale.is_finite() || scale.conductivity_scale <= 0.0 {
17054 return Err((
17055 "electro region_conductivity_scales must use finite positive conductivity_scale"
17056 .to_string(),
17057 BTreeMap::from([
17058 ("region_id".to_string(), scale.region_id.clone()),
17059 (
17060 "conductivity_scale".to_string(),
17061 scale.conductivity_scale.to_string(),
17062 ),
17063 ]),
17064 ));
17065 }
17066 if !model_region_ids.is_empty() && !model_region_ids.contains(scale.region_id.as_str()) {
17067 return Err((
17068 "electro region_conductivity_scales region_id must exist in model material assignments"
17069 .to_string(),
17070 BTreeMap::from([("region_id".to_string(), scale.region_id.clone())]),
17071 ));
17072 }
17073 }
17074 Ok(())
17075}
17076
17077fn validate_plasticity_constitutive_options(
17078 options: &PlasticityConstitutiveOptions,
17079) -> Result<(), (String, BTreeMap<String, String>)> {
17080 if !options.enabled {
17081 return Ok(());
17082 }
17083 if !options.yield_strain.is_finite() || options.yield_strain <= 0.0 {
17084 return Err((
17085 "plasticity constitutive model requires finite positive yield_strain".to_string(),
17086 BTreeMap::from([("yield_strain".to_string(), options.yield_strain.to_string())]),
17087 ));
17088 }
17089 if !options.hardening_modulus_ratio.is_finite() || options.hardening_modulus_ratio < 0.0 {
17090 return Err((
17091 "plasticity constitutive model requires finite non-negative hardening_modulus_ratio"
17092 .to_string(),
17093 BTreeMap::from([(
17094 "hardening_modulus_ratio".to_string(),
17095 options.hardening_modulus_ratio.to_string(),
17096 )]),
17097 ));
17098 }
17099 if !options.saturation_exponent.is_finite() || options.saturation_exponent < 0.0 {
17100 return Err((
17101 "plasticity constitutive model requires finite non-negative saturation_exponent"
17102 .to_string(),
17103 BTreeMap::from([(
17104 "saturation_exponent".to_string(),
17105 options.saturation_exponent.to_string(),
17106 )]),
17107 ));
17108 }
17109 Ok(())
17110}
17111
17112fn validate_contact_interface_options(
17113 options: &ContactInterfaceOptions,
17114) -> Result<(), (String, BTreeMap<String, String>)> {
17115 if !options.enabled {
17116 return Ok(());
17117 }
17118 if !options.penalty_stiffness_scale.is_finite() || options.penalty_stiffness_scale <= 0.0 {
17119 return Err((
17120 "contact interface model requires finite positive penalty_stiffness_scale".to_string(),
17121 BTreeMap::from([(
17122 "penalty_stiffness_scale".to_string(),
17123 options.penalty_stiffness_scale.to_string(),
17124 )]),
17125 ));
17126 }
17127 if !options.max_penetration_ratio.is_finite() || options.max_penetration_ratio < 0.0 {
17128 return Err((
17129 "contact interface model requires finite non-negative max_penetration_ratio"
17130 .to_string(),
17131 BTreeMap::from([(
17132 "max_penetration_ratio".to_string(),
17133 options.max_penetration_ratio.to_string(),
17134 )]),
17135 ));
17136 }
17137 if !options.friction_coefficient.is_finite() || options.friction_coefficient < 0.0 {
17138 return Err((
17139 "contact interface model requires finite non-negative friction_coefficient".to_string(),
17140 BTreeMap::from([(
17141 "friction_coefficient".to_string(),
17142 options.friction_coefficient.to_string(),
17143 )]),
17144 ));
17145 }
17146 Ok(())
17147}
17148
17149fn validate_coupled_flow_interfaces(
17150 model: &AnalysisModel,
17151 family: &str,
17152) -> Result<(), (String, BTreeMap<String, String>)> {
17153 for interface in &model.interfaces {
17154 match &interface.kind {
17155 AnalysisInterfaceKind::Contact(_) => {
17156 return Err((
17157 format!(
17158 "{family} coupling does not accept structural contact interfaces as fluid/thermal interface mappings"
17159 ),
17160 BTreeMap::from([
17161 ("interface_id".to_string(), interface.interface_id.clone()),
17162 (
17163 "primary_region_id".to_string(),
17164 interface.primary_region_id.clone(),
17165 ),
17166 (
17167 "secondary_region_id".to_string(),
17168 interface.secondary_region_id.clone(),
17169 ),
17170 ("interface_kind".to_string(), "contact".to_string()),
17171 ]),
17172 ));
17173 }
17174 AnalysisInterfaceKind::FluidStructure(fluid_structure) => {
17175 if family != "FSI" {
17176 return Err((
17177 format!(
17178 "{family} coupling does not accept fluid-structure interfaces as fluid/thermal interface mappings"
17179 ),
17180 BTreeMap::from([
17181 ("interface_id".to_string(), interface.interface_id.clone()),
17182 (
17183 "primary_region_id".to_string(),
17184 interface.primary_region_id.clone(),
17185 ),
17186 (
17187 "secondary_region_id".to_string(),
17188 interface.secondary_region_id.clone(),
17189 ),
17190 ("interface_kind".to_string(), "fluid_structure".to_string()),
17191 ]),
17192 ));
17193 }
17194 if !fluid_structure.normal_stiffness_pa_per_m.is_finite()
17195 || fluid_structure.normal_stiffness_pa_per_m <= 0.0
17196 {
17197 return Err((
17198 format!(
17199 "{family} fluid-structure interface requires finite positive normal_stiffness_pa_per_m"
17200 ),
17201 BTreeMap::from([
17202 ("interface_id".to_string(), interface.interface_id.clone()),
17203 (
17204 "normal_stiffness_pa_per_m".to_string(),
17205 fluid_structure.normal_stiffness_pa_per_m.to_string(),
17206 ),
17207 ]),
17208 ));
17209 }
17210 if !fluid_structure.damping_ratio.is_finite() || fluid_structure.damping_ratio < 0.0
17211 {
17212 return Err((
17213 format!(
17214 "{family} fluid-structure interface requires finite non-negative damping_ratio"
17215 ),
17216 BTreeMap::from([
17217 ("interface_id".to_string(), interface.interface_id.clone()),
17218 (
17219 "damping_ratio".to_string(),
17220 fluid_structure.damping_ratio.to_string(),
17221 ),
17222 ]),
17223 ));
17224 }
17225 if !fluid_structure.relaxation_factor.is_finite()
17226 || fluid_structure.relaxation_factor <= 0.0
17227 || fluid_structure.relaxation_factor > 1.0
17228 {
17229 return Err((
17230 format!(
17231 "{family} fluid-structure interface requires relaxation_factor in (0, 1]"
17232 ),
17233 BTreeMap::from([
17234 ("interface_id".to_string(), interface.interface_id.clone()),
17235 (
17236 "relaxation_factor".to_string(),
17237 fluid_structure.relaxation_factor.to_string(),
17238 ),
17239 ]),
17240 ));
17241 }
17242 }
17243 AnalysisInterfaceKind::ConjugateHeatTransfer(cht_interface) => {
17244 if family != "CHT" {
17245 return Err((
17246 format!(
17247 "{family} coupling does not accept conjugate heat-transfer interfaces as fluid/structure interface mappings"
17248 ),
17249 BTreeMap::from([
17250 ("interface_id".to_string(), interface.interface_id.clone()),
17251 (
17252 "primary_region_id".to_string(),
17253 interface.primary_region_id.clone(),
17254 ),
17255 (
17256 "secondary_region_id".to_string(),
17257 interface.secondary_region_id.clone(),
17258 ),
17259 (
17260 "interface_kind".to_string(),
17261 "conjugate_heat_transfer".to_string(),
17262 ),
17263 ]),
17264 ));
17265 }
17266 if !cht_interface.thermal_conductance_w_per_m2k.is_finite()
17267 || cht_interface.thermal_conductance_w_per_m2k <= 0.0
17268 {
17269 return Err((
17270 format!(
17271 "{family} conjugate heat-transfer interface requires finite positive thermal_conductance_w_per_m2k"
17272 ),
17273 BTreeMap::from([
17274 ("interface_id".to_string(), interface.interface_id.clone()),
17275 (
17276 "thermal_conductance_w_per_m2k".to_string(),
17277 cht_interface.thermal_conductance_w_per_m2k.to_string(),
17278 ),
17279 ]),
17280 ));
17281 }
17282 if !cht_interface.contact_resistance_m2k_per_w.is_finite()
17283 || cht_interface.contact_resistance_m2k_per_w < 0.0
17284 {
17285 return Err((
17286 format!(
17287 "{family} conjugate heat-transfer interface requires finite non-negative contact_resistance_m2k_per_w"
17288 ),
17289 BTreeMap::from([
17290 ("interface_id".to_string(), interface.interface_id.clone()),
17291 (
17292 "contact_resistance_m2k_per_w".to_string(),
17293 cht_interface.contact_resistance_m2k_per_w.to_string(),
17294 ),
17295 ]),
17296 ));
17297 }
17298 if !cht_interface.relaxation_factor.is_finite()
17299 || cht_interface.relaxation_factor <= 0.0
17300 || cht_interface.relaxation_factor > 1.0
17301 {
17302 return Err((
17303 format!(
17304 "{family} conjugate heat-transfer interface requires relaxation_factor in (0, 1]"
17305 ),
17306 BTreeMap::from([
17307 ("interface_id".to_string(), interface.interface_id.clone()),
17308 (
17309 "relaxation_factor".to_string(),
17310 cht_interface.relaxation_factor.to_string(),
17311 ),
17312 ]),
17313 ));
17314 }
17315 }
17316 }
17317 }
17318 Ok(())
17319}
17320
17321#[derive(Debug, Clone, Deserialize)]
17322struct ThermoFieldArtifact {
17323 schema_version: String,
17324 source_geometry_id: String,
17325 source_geometry_revision: u32,
17326 #[serde(default)]
17327 artifact_status: Option<String>,
17328 #[serde(default)]
17329 approved_by: Option<String>,
17330 #[serde(default)]
17331 payload_hash: Option<String>,
17332 #[serde(default)]
17333 signature: Option<String>,
17334 #[serde(default)]
17335 field_source: Option<ThermoFieldSource>,
17336 #[serde(default)]
17337 region_temperature_deltas: Vec<ThermoRegionTemperatureDelta>,
17338 #[serde(default)]
17339 time_profile: Vec<ThermoTimeProfilePoint>,
17340}
17341
17342fn thermo_field_payload_hash(artifact: &ThermoFieldArtifact) -> String {
17343 let source = artifact.field_source.as_ref();
17344 let source_id = source.map(|s| s.source_id.as_str()).unwrap_or("");
17345 let source_revision = source.map(|s| s.revision).unwrap_or(0);
17346 let interpolation = source
17347 .and_then(|s| s.interpolation_mode)
17348 .map(|mode| match mode {
17349 ThermoFieldInterpolationMode::Linear => "linear",
17350 ThermoFieldInterpolationMode::Step => "step",
17351 })
17352 .unwrap_or("");
17353 let expected_regions = source
17354 .map(|s| s.expected_region_ids.join(","))
17355 .unwrap_or_default();
17356 let region_terms = artifact
17357 .region_temperature_deltas
17358 .iter()
17359 .map(|delta| {
17360 format!(
17361 "{}:{:016x}",
17362 delta.region_id,
17363 delta.temperature_delta_k.to_bits()
17364 )
17365 })
17366 .collect::<Vec<_>>()
17367 .join(",");
17368 let time_terms = artifact
17369 .time_profile
17370 .iter()
17371 .map(|point| {
17372 format!(
17373 "{:016x}:{:016x}",
17374 point.normalized_time.to_bits(),
17375 point.scale.to_bits()
17376 )
17377 })
17378 .collect::<Vec<_>>()
17379 .join(",");
17380 let canonical = format!(
17381 "{}|{}|{}|{}|{}|{}|{}|{}|{}",
17382 artifact.schema_version,
17383 artifact.source_geometry_id,
17384 artifact.source_geometry_revision,
17385 source_id,
17386 source_revision,
17387 interpolation,
17388 expected_regions,
17389 region_terms,
17390 time_terms
17391 );
17392 let mut hasher = Sha256::new();
17393 hasher.update(canonical.as_bytes());
17394 format!("sha256:{:x}", hasher.finalize())
17395}
17396
17397fn thermo_field_signature(payload_hash: &str, approved_by: &str, signing_key: &str) -> String {
17398 let mut hasher = Sha256::new();
17399 hasher.update(format!("{payload_hash}:{approved_by}:{signing_key}").as_bytes());
17400 format!("sigv1:sha256:{:x}", hasher.finalize())
17401}
17402
17403fn resolve_thermo_coupling_options(
17404 model: &AnalysisModel,
17405 options: Option<ThermoMechanicalCouplingOptions>,
17406 operation: &'static str,
17407 op_version: &'static str,
17408 context: &OperationContext,
17409) -> Result<Option<ThermoMechanicalCouplingOptions>, OperationErrorEnvelope> {
17410 let Some(mut options) = options else {
17411 return Ok(None);
17412 };
17413 let Some(field_artifact_id) = options.field_artifact_id.as_deref() else {
17414 return Ok(Some(options));
17415 };
17416
17417 let root = thermo_field_artifact_root();
17418 let path = root.join(format!("{field_artifact_id}.json"));
17419 if !fs_exists(&path).map_err(|err| {
17420 operation_error(
17421 operation,
17422 op_version,
17423 context,
17424 OperationErrorSpec {
17425 error_code: "RM.FEA.RUN_THERMO_FIELD.STORE_FAILED",
17426 error_type: OperationErrorType::Internal,
17427 retryable: true,
17428 severity: OperationErrorSeverity::Error,
17429 },
17430 format!("failed to inspect thermo field artifact: {err}"),
17431 BTreeMap::from([
17432 (
17433 "thermo_field_artifact_id".to_string(),
17434 field_artifact_id.to_string(),
17435 ),
17436 (
17437 "thermo_field_artifact_path".to_string(),
17438 path.display().to_string(),
17439 ),
17440 ]),
17441 )
17442 })? {
17443 return Err(operation_error(
17444 operation,
17445 op_version,
17446 context,
17447 OperationErrorSpec {
17448 error_code: "RM.FEA.RUN_THERMO_FIELD.NOT_FOUND",
17449 error_type: OperationErrorType::Input,
17450 retryable: false,
17451 severity: OperationErrorSeverity::Error,
17452 },
17453 format!(
17454 "thermo field artifact '{}' was not found",
17455 field_artifact_id
17456 ),
17457 BTreeMap::from([
17458 (
17459 "thermo_field_artifact_id".to_string(),
17460 field_artifact_id.to_string(),
17461 ),
17462 (
17463 "thermo_field_artifact_path".to_string(),
17464 path.display().to_string(),
17465 ),
17466 ]),
17467 ));
17468 }
17469
17470 let raw = fs_read_to_string(&path).map_err(|err| {
17471 operation_error(
17472 operation,
17473 op_version,
17474 context,
17475 OperationErrorSpec {
17476 error_code: "RM.FEA.RUN_THERMO_FIELD.STORE_FAILED",
17477 error_type: OperationErrorType::Internal,
17478 retryable: true,
17479 severity: OperationErrorSeverity::Error,
17480 },
17481 format!("failed to read thermo field artifact: {err}"),
17482 BTreeMap::from([(
17483 "thermo_field_artifact_path".to_string(),
17484 path.display().to_string(),
17485 )]),
17486 )
17487 })?;
17488 let artifact: ThermoFieldArtifact = serde_json::from_str(&raw).map_err(|err| {
17489 operation_error(
17490 operation,
17491 op_version,
17492 context,
17493 OperationErrorSpec {
17494 error_code: "RM.FEA.RUN_THERMO_FIELD.INVALID",
17495 error_type: OperationErrorType::Validation,
17496 retryable: false,
17497 severity: OperationErrorSeverity::Error,
17498 },
17499 format!("invalid thermo field artifact payload: {err}"),
17500 BTreeMap::from([(
17501 "thermo_field_artifact_path".to_string(),
17502 path.display().to_string(),
17503 )]),
17504 )
17505 })?;
17506
17507 if artifact.schema_version != "fea_thermo_field_artifact/v1"
17508 && artifact.schema_version != "analysis_thermo_field_artifact/v1"
17509 {
17510 return Err(operation_error(
17511 operation,
17512 op_version,
17513 context,
17514 OperationErrorSpec {
17515 error_code: "RM.FEA.RUN_THERMO_FIELD.SCHEMA_UNSUPPORTED",
17516 error_type: OperationErrorType::Validation,
17517 retryable: false,
17518 severity: OperationErrorSeverity::Error,
17519 },
17520 format!(
17521 "thermo field artifact schema '{}' is not supported",
17522 artifact.schema_version
17523 ),
17524 BTreeMap::from([
17525 (
17526 "thermo_field_artifact_id".to_string(),
17527 field_artifact_id.to_string(),
17528 ),
17529 (
17530 "thermo_field_artifact_schema".to_string(),
17531 artifact.schema_version.clone(),
17532 ),
17533 ]),
17534 ));
17535 }
17536
17537 if artifact.source_geometry_id != model.geometry_id
17538 || artifact.source_geometry_revision != model.geometry_revision
17539 {
17540 return Err(operation_error(
17541 operation,
17542 op_version,
17543 context,
17544 OperationErrorSpec {
17545 error_code: "RM.FEA.RUN_THERMO_FIELD.MISMATCH",
17546 error_type: OperationErrorType::Validation,
17547 retryable: false,
17548 severity: OperationErrorSeverity::Error,
17549 },
17550 "thermo field artifact geometry lineage does not match FEA model",
17551 BTreeMap::from([
17552 (
17553 "thermo_field_artifact_id".to_string(),
17554 field_artifact_id.to_string(),
17555 ),
17556 ("model_geometry_id".to_string(), model.geometry_id.clone()),
17557 (
17558 "model_geometry_revision".to_string(),
17559 model.geometry_revision.to_string(),
17560 ),
17561 (
17562 "artifact_geometry_id".to_string(),
17563 artifact.source_geometry_id.clone(),
17564 ),
17565 (
17566 "artifact_geometry_revision".to_string(),
17567 artifact.source_geometry_revision.to_string(),
17568 ),
17569 ]),
17570 ));
17571 }
17572
17573 let expected_hash = thermo_field_payload_hash(&artifact);
17574 if artifact.payload_hash.as_deref() != Some(expected_hash.as_str()) {
17575 return Err(operation_error(
17576 operation,
17577 op_version,
17578 context,
17579 OperationErrorSpec {
17580 error_code: "RM.FEA.RUN_THERMO_FIELD.DIGEST_MISMATCH",
17581 error_type: OperationErrorType::Validation,
17582 retryable: false,
17583 severity: OperationErrorSeverity::Error,
17584 },
17585 "thermo field artifact payload hash does not match payload contents",
17586 BTreeMap::from([
17587 (
17588 "thermo_field_artifact_id".to_string(),
17589 field_artifact_id.to_string(),
17590 ),
17591 ("expected_payload_hash".to_string(), expected_hash),
17592 (
17593 "artifact_payload_hash".to_string(),
17594 artifact.payload_hash.clone().unwrap_or_default(),
17595 ),
17596 ]),
17597 ));
17598 }
17599
17600 if matches!(artifact.artifact_status.as_deref(), Some("approved")) {
17601 let Some(approved_by) = artifact.approved_by.as_deref() else {
17602 return Err(operation_error(
17603 operation,
17604 op_version,
17605 context,
17606 OperationErrorSpec {
17607 error_code: "RM.FEA.RUN_THERMO_FIELD.APPROVER_MISSING",
17608 error_type: OperationErrorType::Validation,
17609 retryable: false,
17610 severity: OperationErrorSeverity::Error,
17611 },
17612 "approved thermo field artifact is missing approved_by",
17613 BTreeMap::from([(
17614 "thermo_field_artifact_id".to_string(),
17615 field_artifact_id.to_string(),
17616 )]),
17617 ));
17618 };
17619
17620 let allowed = std::env::var("RUNMAT_THERMO_FIELD_ALLOWED_APPROVERS")
17621 .ok()
17622 .map(|value| {
17623 value
17624 .split(',')
17625 .map(|entry| entry.trim().to_string())
17626 .filter(|entry| !entry.is_empty())
17627 .collect::<Vec<_>>()
17628 })
17629 .unwrap_or_default();
17630 if !allowed.is_empty() && !allowed.iter().any(|entry| entry == approved_by) {
17631 return Err(operation_error(
17632 operation,
17633 op_version,
17634 context,
17635 OperationErrorSpec {
17636 error_code: "RM.FEA.RUN_THERMO_FIELD.APPROVER_UNAUTHORIZED",
17637 error_type: OperationErrorType::Validation,
17638 retryable: false,
17639 severity: OperationErrorSeverity::Error,
17640 },
17641 "thermo field artifact approver is not authorized",
17642 BTreeMap::from([
17643 (
17644 "thermo_field_artifact_id".to_string(),
17645 field_artifact_id.to_string(),
17646 ),
17647 ("approved_by".to_string(), approved_by.to_string()),
17648 ]),
17649 ));
17650 }
17651
17652 let signing_key = std::env::var("RUNMAT_THERMO_FIELD_SIGNING_KEY")
17653 .unwrap_or_else(|_| "runmat-dev-thermo-signing-key".to_string());
17654 let expected_signature = thermo_field_signature(&expected_hash, approved_by, &signing_key);
17655 if artifact.signature.as_deref() != Some(expected_signature.as_str()) {
17656 return Err(operation_error(
17657 operation,
17658 op_version,
17659 context,
17660 OperationErrorSpec {
17661 error_code: "RM.FEA.RUN_THERMO_FIELD.SIGNATURE_INVALID",
17662 error_type: OperationErrorType::Validation,
17663 retryable: false,
17664 severity: OperationErrorSeverity::Error,
17665 },
17666 "thermo field artifact signature validation failed",
17667 BTreeMap::from([
17668 (
17669 "thermo_field_artifact_id".to_string(),
17670 field_artifact_id.to_string(),
17671 ),
17672 ("expected_signature".to_string(), expected_signature),
17673 (
17674 "artifact_signature".to_string(),
17675 artifact.signature.clone().unwrap_or_default(),
17676 ),
17677 ]),
17678 ));
17679 }
17680 }
17681
17682 options.field_source = artifact.field_source;
17683 options.region_temperature_deltas = artifact.region_temperature_deltas;
17684 options.time_profile = artifact.time_profile;
17685
17686 Ok(Some(options))
17687}
17688
17689fn resolve_run_prep_context(
17690 model: &AnalysisModel,
17691 prep_artifact_id: Option<&str>,
17692 legacy_prep_context: Option<AnalysisRunPrepContext>,
17693 operation: &'static str,
17694 op_version: &'static str,
17695 context: &OperationContext,
17696) -> Result<Option<AnalysisRunPrepContext>, OperationErrorEnvelope> {
17697 if prep_artifact_id.is_none() {
17698 if legacy_prep_context.is_some() {
17699 return Err(operation_error(
17700 operation,
17701 op_version,
17702 context,
17703 OperationErrorSpec {
17704 error_code: "RM.FEA.RUN_PREP.UNTRUSTED_CONTEXT",
17705 error_type: OperationErrorType::Input,
17706 retryable: false,
17707 severity: OperationErrorSeverity::Error,
17708 },
17709 "FEA run prep_context must be referenced by prep_artifact_id",
17710 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
17711 ));
17712 }
17713 return Ok(None);
17714 }
17715
17716 let prep_artifact_id = prep_artifact_id.expect("checked is_some");
17717 let Some(artifact) = crate::geometry::load_prep_artifact(prep_artifact_id).map_err(|err| {
17718 operation_error(
17719 operation,
17720 op_version,
17721 context,
17722 OperationErrorSpec {
17723 error_code: "RM.FEA.RUN_PREP.STORE_FAILED",
17724 error_type: OperationErrorType::Internal,
17725 retryable: true,
17726 severity: OperationErrorSeverity::Error,
17727 },
17728 format!("failed to load prep artifact: {err}"),
17729 BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
17730 )
17731 })?
17732 else {
17733 return Err(operation_error(
17734 operation,
17735 op_version,
17736 context,
17737 OperationErrorSpec {
17738 error_code: "RM.FEA.RUN_PREP.NOT_FOUND",
17739 error_type: OperationErrorType::Input,
17740 retryable: false,
17741 severity: OperationErrorSeverity::Error,
17742 },
17743 format!("prep artifact '{}' was not found", prep_artifact_id),
17744 BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
17745 ));
17746 };
17747
17748 if artifact.schema_version != "geometry_prep_artifact/v1" {
17749 return Err(operation_error(
17750 operation,
17751 op_version,
17752 context,
17753 OperationErrorSpec {
17754 error_code: "RM.FEA.RUN_PREP.SCHEMA_UNSUPPORTED",
17755 error_type: OperationErrorType::Validation,
17756 retryable: false,
17757 severity: OperationErrorSeverity::Error,
17758 },
17759 format!(
17760 "prep artifact schema '{}' is not supported",
17761 artifact.schema_version
17762 ),
17763 BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
17764 ));
17765 }
17766
17767 if artifact.source_geometry_id != model.geometry_id
17768 || artifact.source_geometry_revision != model.geometry_revision
17769 {
17770 crate::geometry::record_prep_mismatch_reject();
17771 return Err(operation_error(
17772 operation,
17773 op_version,
17774 context,
17775 OperationErrorSpec {
17776 error_code: "RM.FEA.RUN_PREP.MISMATCH",
17777 error_type: OperationErrorType::Validation,
17778 retryable: false,
17779 severity: OperationErrorSeverity::Error,
17780 },
17781 "prep artifact geometry lineage does not match FEA model",
17782 BTreeMap::from([
17783 ("prep_artifact_id".to_string(), prep_artifact_id.to_string()),
17784 ("model_geometry_id".to_string(), model.geometry_id.clone()),
17785 (
17786 "model_geometry_revision".to_string(),
17787 model.geometry_revision.to_string(),
17788 ),
17789 (
17790 "prep_geometry_id".to_string(),
17791 artifact.source_geometry_id.clone(),
17792 ),
17793 (
17794 "prep_geometry_revision".to_string(),
17795 artifact.source_geometry_revision.to_string(),
17796 ),
17797 ]),
17798 ));
17799 }
17800
17801 if crate::geometry::require_latest_prep_revision() {
17802 if let Some(latest_revision) = crate::geometry::latest_prep_revision_for_geometry(
17803 &model.geometry_id,
17804 )
17805 .map_err(|err| {
17806 operation_error(
17807 operation,
17808 op_version,
17809 context,
17810 OperationErrorSpec {
17811 error_code: "RM.FEA.RUN_PREP.STORE_FAILED",
17812 error_type: OperationErrorType::Internal,
17813 retryable: true,
17814 severity: OperationErrorSeverity::Error,
17815 },
17816 format!("failed to evaluate prep artifact freshness: {err}"),
17817 BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
17818 )
17819 })? {
17820 if artifact.source_geometry_revision < latest_revision {
17821 crate::geometry::record_prep_stale_reject();
17822 return Err(operation_error(
17823 operation,
17824 op_version,
17825 context,
17826 OperationErrorSpec {
17827 error_code: "RM.FEA.RUN_PREP.STALE",
17828 error_type: OperationErrorType::Validation,
17829 retryable: false,
17830 severity: OperationErrorSeverity::Error,
17831 },
17832 "prep artifact is stale; a newer geometry revision prep artifact exists",
17833 BTreeMap::from([
17834 ("prep_artifact_id".to_string(), prep_artifact_id.to_string()),
17835 (
17836 "prep_geometry_revision".to_string(),
17837 artifact.source_geometry_revision.to_string(),
17838 ),
17839 (
17840 "latest_geometry_revision".to_string(),
17841 latest_revision.to_string(),
17842 ),
17843 ]),
17844 ));
17845 }
17846 }
17847 }
17848
17849 let prepared_mesh_count = artifact.prep.prepared_meshes.len();
17850 let prepared_node_count = artifact
17851 .prep
17852 .prepared_meshes
17853 .iter()
17854 .map(|mesh| mesh.node_count as usize)
17855 .sum::<usize>();
17856 let prepared_element_count = artifact
17857 .prep
17858 .prepared_meshes
17859 .iter()
17860 .map(|mesh| mesh.element_count as usize)
17861 .sum::<usize>();
17862 let mesh_count = prepared_mesh_count.max(1) as f64;
17863 let topology_surface_patch_ratio = artifact
17864 .prep
17865 .prepared_meshes
17866 .iter()
17867 .filter(|mesh| mesh.connectivity_class == MeshConnectivityClass::SurfacePatch)
17868 .count() as f64
17869 / mesh_count;
17870 let topology_volume_core_ratio = artifact
17871 .prep
17872 .prepared_meshes
17873 .iter()
17874 .filter(|mesh| mesh.connectivity_class == MeshConnectivityClass::VolumeCore)
17875 .count() as f64
17876 / mesh_count;
17877 let topology_mixed_family_ratio = artifact
17878 .prep
17879 .prepared_meshes
17880 .iter()
17881 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Mixed)
17882 .count() as f64
17883 / mesh_count;
17884 let topology_triangle_family_ratio = artifact
17885 .prep
17886 .prepared_meshes
17887 .iter()
17888 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Triangle)
17889 .count() as f64
17890 / mesh_count;
17891 let topology_quad_family_ratio = artifact
17892 .prep
17893 .prepared_meshes
17894 .iter()
17895 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Quad)
17896 .count() as f64
17897 / mesh_count;
17898 let topology_tetrahedron_family_ratio = artifact
17899 .prep
17900 .prepared_meshes
17901 .iter()
17902 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Tetrahedron)
17903 .count() as f64
17904 / mesh_count;
17905 let topology_hex_family_ratio = artifact
17906 .prep
17907 .prepared_meshes
17908 .iter()
17909 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Hex)
17910 .count() as f64
17911 / mesh_count;
17912 let topology_region_span_mean = artifact
17913 .prep
17914 .prepared_meshes
17915 .iter()
17916 .map(|mesh| mesh.region_span_hint as f64)
17917 .sum::<f64>()
17918 / mesh_count;
17919 let region_block_count = artifact.prep.region_mappings.len().max(1);
17920 let region_mesh_counts = artifact
17921 .prep
17922 .region_mappings
17923 .iter()
17924 .map(|mapping| mapping.prepared_mesh_ids.len().max(1) as f64)
17925 .collect::<Vec<_>>();
17926 let topology_region_mesh_mean = if region_mesh_counts.is_empty() {
17927 1.0
17928 } else {
17929 region_mesh_counts.iter().sum::<f64>() / region_mesh_counts.len() as f64
17930 };
17931 let topology_region_mesh_variance = if region_mesh_counts.len() <= 1 {
17932 0.0
17933 } else {
17934 region_mesh_counts
17935 .iter()
17936 .map(|count| {
17937 let delta = *count - topology_region_mesh_mean;
17938 delta * delta
17939 })
17940 .sum::<f64>()
17941 / region_mesh_counts.len() as f64
17942 };
17943 let topology_dof_multiplier = if model.loads.is_empty() {
17944 1.0
17945 } else {
17946 ((prepared_node_count as f64 / (model.loads.len() as f64 * 3.0)).clamp(1.0, 4.0) * 0.35
17947 + 1.0)
17948 .min(4.0)
17949 };
17950 let topology_bandwidth_estimate = artifact
17951 .prep
17952 .prepared_meshes
17953 .iter()
17954 .map(|mesh| mesh.region_span_hint)
17955 .sum::<u32>()
17956 .clamp(1, 128);
17957 let mapped_region_participation_ratio = if artifact.prep.region_mappings.is_empty() {
17958 0.0
17959 } else {
17960 (artifact
17961 .prep
17962 .region_mappings
17963 .iter()
17964 .filter(|mapping| {
17965 model
17966 .loads
17967 .iter()
17968 .any(|load| load.region_id == mapping.region_id)
17969 || model
17970 .boundary_conditions
17971 .iter()
17972 .any(|bc| bc.region_id == mapping.region_id)
17973 })
17974 .count() as f64
17975 / artifact.prep.region_mappings.len() as f64)
17976 .clamp(0.0, 1.0)
17977 };
17978 let coordinate_span_x_m = artifact
17979 .prep
17980 .prepared_meshes
17981 .iter()
17982 .map(|mesh| mesh.coordinate_span_m[0])
17983 .fold(0.0_f64, f64::max)
17984 .max(1.0e-12);
17985 let coordinate_span_y_m = artifact
17986 .prep
17987 .prepared_meshes
17988 .iter()
17989 .map(|mesh| mesh.coordinate_span_m[1])
17990 .fold(0.0_f64, f64::max);
17991 let coordinate_span_z_m = artifact
17992 .prep
17993 .prepared_meshes
17994 .iter()
17995 .map(|mesh| mesh.coordinate_span_m[2])
17996 .fold(0.0_f64, f64::max);
17997 let coordinate_active_dimension_count = artifact
17998 .prep
17999 .prepared_meshes
18000 .iter()
18001 .map(|mesh| mesh.coordinate_active_dimension_count as usize)
18002 .max()
18003 .unwrap_or(1)
18004 .max(1);
18005 let (coordinate_length_sum, coordinate_length_weight) = artifact
18006 .prep
18007 .prepared_meshes
18008 .iter()
18009 .filter_map(|mesh| {
18010 let length = mesh.coordinate_characteristic_length_m;
18011 (length.is_finite() && length > 0.0)
18012 .then_some((length, mesh.element_count.max(1) as f64))
18013 })
18014 .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (length, weight)| {
18015 (sum + length * weight, weight_sum + weight)
18016 });
18017 let coordinate_characteristic_length_m = if coordinate_length_weight > 0.0 {
18018 coordinate_length_sum / coordinate_length_weight
18019 } else {
18020 1.0
18021 };
18022 let element_geometry_node_count = artifact
18023 .prep
18024 .prepared_meshes
18025 .iter()
18026 .map(|mesh| mesh.element_geometry_node_count as usize)
18027 .sum::<usize>();
18028 let element_geometry_edge_count = artifact
18029 .prep
18030 .prepared_meshes
18031 .iter()
18032 .map(|mesh| mesh.element_geometry_edge_count as usize)
18033 .sum::<usize>();
18034 let (edge_length_sum, edge_length_weight) = artifact
18035 .prep
18036 .prepared_meshes
18037 .iter()
18038 .filter_map(|mesh| {
18039 let length = mesh.mean_element_edge_length_m;
18040 (length.is_finite() && length > 0.0)
18041 .then_some((length, mesh.element_count.max(1) as f64))
18042 })
18043 .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (length, weight)| {
18044 (sum + length * weight, weight_sum + weight)
18045 });
18046 let mean_element_edge_length_m = if edge_length_weight > 0.0 {
18047 edge_length_sum / edge_length_weight
18048 } else {
18049 0.0
18050 };
18051 let (area_sum, area_weight) = artifact
18052 .prep
18053 .prepared_meshes
18054 .iter()
18055 .filter_map(|mesh| {
18056 let area = mesh.mean_element_area_m2;
18057 (area.is_finite() && area > 0.0).then_some((area, mesh.element_count.max(1) as f64))
18058 })
18059 .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (area, weight)| {
18060 (sum + area * weight, weight_sum + weight)
18061 });
18062 let mean_element_area_m2 = if area_weight > 0.0 {
18063 area_sum / area_weight
18064 } else {
18065 0.0
18066 };
18067 let (coverage_sum, coverage_weight) = artifact
18068 .prep
18069 .prepared_meshes
18070 .iter()
18071 .map(|mesh| {
18072 (
18073 mesh.element_geometry_coverage_ratio.clamp(0.0, 1.0),
18074 mesh.element_count.max(1) as f64,
18075 )
18076 })
18077 .fold(
18078 (0.0_f64, 0.0_f64),
18079 |(sum, weight_sum), (coverage, weight)| (sum + coverage * weight, weight_sum + weight),
18080 );
18081 let element_geometry_coverage_ratio = if coverage_weight > 0.0 {
18082 coverage_sum / coverage_weight
18083 } else {
18084 0.0
18085 };
18086 let (reference_element_coordinates_m, reference_element_area_m2) = artifact
18087 .prep
18088 .prepared_meshes
18089 .iter()
18090 .find_map(|mesh| {
18091 let area = mesh.reference_element_area_m2;
18092 (area.is_finite() && area > 0.0).then_some((mesh.reference_element_coordinates_m, area))
18093 })
18094 .unwrap_or(([[0.0; 3]; 3], 0.0));
18095 let (
18096 element_topology_sample_element_count,
18097 element_topology_sample_edge_count,
18098 element_topology_sample_edge_nodes,
18099 element_topology_sample_node_coordinates_m,
18100 element_topology_sample_element_edges,
18101 element_topology_sample_element_orientations,
18102 element_topology_sample_element_areas_m2,
18103 ) = artifact
18104 .prep
18105 .prepared_meshes
18106 .iter()
18107 .find_map(|mesh| {
18108 (mesh.element_topology_sample_element_count > 0
18109 && mesh.element_topology_sample_edge_count > 0)
18110 .then_some((
18111 mesh.element_topology_sample_element_count as usize,
18112 mesh.element_topology_sample_edge_count as usize,
18113 mesh.element_topology_sample_edge_nodes,
18114 mesh.element_topology_sample_node_coordinates_m,
18115 mesh.element_topology_sample_element_edges,
18116 mesh.element_topology_sample_element_orientations,
18117 mesh.element_topology_sample_element_areas_m2,
18118 ))
18119 })
18120 .unwrap_or((
18121 0,
18122 0,
18123 [[0; 2]; 8],
18124 [[0.0; 3]; 8],
18125 [[0; 3]; 4],
18126 [[0; 3]; 4],
18127 [0.0; 4],
18128 ));
18129 let mut element_topology_node_coordinates_m = Vec::<[f64; 3]>::new();
18130 let mut element_topology_edge_nodes = Vec::<[u32; 2]>::new();
18131 let mut element_topology_element_edges = Vec::<[u32; 3]>::new();
18132 let mut element_topology_element_orientations = Vec::<[i8; 3]>::new();
18133 let mut element_topology_element_areas_m2 = Vec::<f64>::new();
18134 let mut node_offset = 0_u32;
18135 let mut edge_offset = 0_u32;
18136 for mesh in &artifact.prep.prepared_meshes {
18137 let node_count = mesh.element_topology_node_coordinates_m.len();
18138 element_topology_node_coordinates_m
18139 .extend(mesh.element_topology_node_coordinates_m.iter().copied());
18140 for edge in &mesh.element_topology_edge_nodes {
18141 if let (Some(left), Some(right)) = (
18142 edge[0].checked_add(node_offset),
18143 edge[1].checked_add(node_offset),
18144 ) {
18145 element_topology_edge_nodes.push([left, right]);
18146 }
18147 }
18148 for element_edges in &mesh.element_topology_element_edges {
18149 if let (Some(a), Some(b), Some(c)) = (
18150 element_edges[0].checked_add(edge_offset),
18151 element_edges[1].checked_add(edge_offset),
18152 element_edges[2].checked_add(edge_offset),
18153 ) {
18154 element_topology_element_edges.push([a, b, c]);
18155 }
18156 }
18157 element_topology_element_orientations
18158 .extend(mesh.element_topology_element_orientations.iter().copied());
18159 element_topology_element_areas_m2
18160 .extend(mesh.element_topology_element_areas_m2.iter().copied());
18161 node_offset = node_offset.saturating_add(node_count as u32);
18162 edge_offset = edge_offset.saturating_add(mesh.element_topology_edge_nodes.len() as u32);
18163 }
18164 let control_volume_cell_count = artifact
18165 .prep
18166 .prepared_meshes
18167 .iter()
18168 .map(|mesh| mesh.control_volume_cell_count as usize)
18169 .sum::<usize>();
18170 let control_volume_face_count = artifact
18171 .prep
18172 .prepared_meshes
18173 .iter()
18174 .map(|mesh| mesh.control_volume_face_count as usize)
18175 .sum::<usize>();
18176 let control_volume_internal_face_count = artifact
18177 .prep
18178 .prepared_meshes
18179 .iter()
18180 .map(|mesh| mesh.control_volume_internal_face_count as usize)
18181 .sum::<usize>();
18182 let control_volume_boundary_face_count = artifact
18183 .prep
18184 .prepared_meshes
18185 .iter()
18186 .map(|mesh| mesh.control_volume_boundary_face_count as usize)
18187 .sum::<usize>();
18188 let (control_volume_coverage_sum, control_volume_coverage_weight) = artifact
18189 .prep
18190 .prepared_meshes
18191 .iter()
18192 .map(|mesh| {
18193 (
18194 mesh.control_volume_connectivity_coverage_ratio
18195 .clamp(0.0, 1.0),
18196 mesh.element_count.max(1) as f64,
18197 )
18198 })
18199 .fold(
18200 (0.0_f64, 0.0_f64),
18201 |(sum, weight_sum), (coverage, weight)| (sum + coverage * weight, weight_sum + weight),
18202 );
18203 let control_volume_connectivity_coverage_ratio = if control_volume_coverage_weight > 0.0 {
18204 control_volume_coverage_sum / control_volume_coverage_weight
18205 } else {
18206 0.0
18207 };
18208
18209 Ok(Some(AnalysisRunPrepContext {
18210 prepared_mesh_count,
18211 prepared_node_count,
18212 prepared_element_count,
18213 mapped_region_count: artifact.prep.region_mappings.len(),
18214 min_scaled_jacobian: artifact.prep.quality.min_scaled_jacobian,
18215 mean_aspect_ratio: artifact.prep.quality.mean_aspect_ratio,
18216 inverted_element_count: artifact.prep.quality.inverted_element_count as usize,
18217 mapped_load_count: model
18218 .loads
18219 .iter()
18220 .filter(|load| {
18221 artifact
18222 .prep
18223 .region_mappings
18224 .iter()
18225 .any(|mapping| mapping.region_id == load.region_id)
18226 })
18227 .count(),
18228 mapped_bc_count: model
18229 .boundary_conditions
18230 .iter()
18231 .filter(|bc| {
18232 artifact
18233 .prep
18234 .region_mappings
18235 .iter()
18236 .any(|mapping| mapping.region_id == bc.region_id)
18237 })
18238 .count(),
18239 layout_seed: {
18240 let mut seed = 1469598103934665603_u64;
18241 for mapping in &artifact.prep.region_mappings {
18242 for byte in mapping.region_id.as_bytes() {
18243 seed ^= *byte as u64;
18244 seed = seed.wrapping_mul(1099511628211_u64);
18245 }
18246 }
18247 seed
18248 },
18249 topology_dof_multiplier,
18250 topology_bandwidth_estimate,
18251 mapped_region_participation_ratio,
18252 topology_surface_patch_ratio,
18253 topology_volume_core_ratio,
18254 topology_mixed_family_ratio,
18255 topology_region_span_mean,
18256 topology_region_block_count: region_block_count,
18257 topology_region_mesh_mean,
18258 topology_region_mesh_variance,
18259 topology_triangle_family_ratio,
18260 topology_quad_family_ratio,
18261 topology_tetrahedron_family_ratio,
18262 topology_hex_family_ratio,
18263 coordinate_span_x_m,
18264 coordinate_span_y_m,
18265 coordinate_span_z_m,
18266 coordinate_active_dimension_count,
18267 coordinate_characteristic_length_m,
18268 element_geometry_node_count,
18269 element_geometry_edge_count,
18270 mean_element_edge_length_m,
18271 mean_element_area_m2,
18272 element_geometry_coverage_ratio,
18273 reference_element_coordinates_m,
18274 reference_element_area_m2,
18275 control_volume_cell_count,
18276 control_volume_face_count,
18277 control_volume_internal_face_count,
18278 control_volume_boundary_face_count,
18279 control_volume_connectivity_coverage_ratio,
18280 element_topology_sample_element_count,
18281 element_topology_sample_edge_count,
18282 element_topology_sample_edge_nodes,
18283 element_topology_sample_node_coordinates_m,
18284 element_topology_sample_element_edges,
18285 element_topology_sample_element_orientations,
18286 element_topology_sample_element_areas_m2,
18287 element_topology_node_coordinates_m,
18288 element_topology_edge_nodes,
18289 element_topology_element_edges,
18290 element_topology_element_orientations,
18291 element_topology_element_areas_m2,
18292 }))
18293}
18294
18295fn run_solve_ms(run: &AnalysisRunResult) -> Option<f64> {
18296 for code in [
18297 "FEA_NONLINEAR_COST",
18298 "FEA_TRANSIENT_COST",
18299 "FEA_MODAL_COST",
18300 "FEA_ACOUSTIC_COST",
18301 "FEA_CFD_COST",
18302 "FEA_CHT_COST",
18303 "FEA_FSI_COST",
18304 ] {
18305 if let Some(value) = diagnostic_metric(&run.run.diagnostics, code, "solve_ms") {
18306 return Some(value);
18307 }
18308 }
18309 None
18310}
18311
18312fn diagnostic_metric(
18313 diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
18314 code: &str,
18315 key: &str,
18316) -> Option<f64> {
18317 diagnostics
18318 .iter()
18319 .find(|diag| diag.code == code)
18320 .and_then(|diag| {
18321 diag.message
18322 .split_whitespace()
18323 .find_map(|token| token.strip_prefix(&format!("{key}=")))
18324 })
18325 .and_then(|value| value.parse::<f64>().ok())
18326}
18327
18328fn diagnostic_metric_u64(
18329 diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
18330 code: &str,
18331 key: &str,
18332) -> Option<u64> {
18333 diagnostics
18334 .iter()
18335 .find(|diag| diag.code == code)
18336 .and_then(|diag| {
18337 diag.message
18338 .split_whitespace()
18339 .find_map(|token| token.strip_prefix(&format!("{key}=")))
18340 })
18341 .and_then(|value| value.parse::<u64>().ok())
18342}
18343
18344fn diagnostic_metric_bool(
18345 diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
18346 code: &str,
18347 key: &str,
18348) -> Option<bool> {
18349 diagnostics
18350 .iter()
18351 .find(|diag| diag.code == code)
18352 .and_then(|diag| {
18353 diag.message
18354 .split_whitespace()
18355 .find_map(|token| token.strip_prefix(&format!("{key}=")))
18356 })
18357 .and_then(|value| value.parse::<bool>().ok())
18358}
18359
18360fn diagnostic_metric_string(
18361 diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
18362 code: &str,
18363 key: &str,
18364) -> Option<String> {
18365 diagnostics
18366 .iter()
18367 .find(|diag| diag.code == code)
18368 .and_then(|diag| {
18369 diag.message
18370 .split_whitespace()
18371 .find_map(|token| token.strip_prefix(&format!("{key}=")))
18372 })
18373 .map(|value| value.to_string())
18374}
18375
18376fn percentile(sorted_samples: &[f64], ratio: f64) -> Option<f64> {
18377 if sorted_samples.is_empty() {
18378 return None;
18379 }
18380 let index = ((sorted_samples.len() - 1) as f64 * ratio.clamp(0.0, 1.0)).round() as usize;
18381 sorted_samples.get(index).copied()
18382}
18383
18384fn mean(values: &[f64]) -> f64 {
18385 if values.is_empty() {
18386 0.0
18387 } else {
18388 values.iter().sum::<f64>() / values.len() as f64
18389 }
18390}
18391
18392fn calibration_profile_rate(entries: &[AnalysisRunResult], profile: &str) -> Option<f64> {
18393 let values = entries
18394 .iter()
18395 .filter_map(|run| {
18396 diagnostic_metric_string(&run.run.diagnostics, "FEA_PREP_CALIBRATION", "profile")
18397 })
18398 .collect::<Vec<_>>();
18399 if values.is_empty() {
18400 return None;
18401 }
18402 Some(
18403 values
18404 .iter()
18405 .filter(|value| value.as_str() == profile)
18406 .count() as f64
18407 / values.len() as f64,
18408 )
18409}
18410
18411fn diagnostic_warning_rate(entries: &[AnalysisRunResult], code: &str) -> Option<f64> {
18412 let values = entries
18413 .iter()
18414 .filter_map(|run| {
18415 run.run
18416 .diagnostics
18417 .iter()
18418 .find(|diag| diag.code == code)
18419 .map(|diag| {
18420 diag.severity
18421 == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
18422 })
18423 })
18424 .collect::<Vec<_>>();
18425 if values.is_empty() {
18426 None
18427 } else {
18428 Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
18429 }
18430}
18431
18432fn infer_material_models(geometry: &GeometryAsset) -> Vec<MaterialModel> {
18433 let mut materials = Vec::new();
18434 for evidence in &geometry.source_geometry.material_evidence {
18435 let value = evidence.value.to_ascii_lowercase();
18436 let (material_id, name, youngs_modulus_pa, poisson_ratio) = if value.contains("aluminum") {
18437 ("mat_aluminum", "Aluminum", 69e9, 0.33)
18438 } else if value.contains("steel") {
18439 ("mat_steel", "Steel", 200e9, 0.30)
18440 } else if value.contains("polymer") || value.contains("plastic") {
18441 ("mat_polymer", "Polymer", 3.2e9, 0.37)
18442 } else {
18443 ("mat_inferred", "Inferred Material", 100e9, 0.32)
18444 };
18445
18446 if materials
18447 .iter()
18448 .any(|m: &MaterialModel| m.material_id == material_id)
18449 {
18450 continue;
18451 }
18452 materials.push(MaterialModel {
18453 material_id: material_id.to_string(),
18454 name: name.to_string(),
18455 mechanical: MaterialMechanicalModel {
18456 youngs_modulus_pa,
18457 poisson_ratio,
18458 density_kg_per_m3: 7850.0,
18459 },
18460 thermal: MaterialThermalModel {
18461 reference_temperature_k: 293.15,
18462 modulus_temp_coeff_per_k: -2.5e-4,
18463 ..MaterialThermalModel::default()
18464 },
18465 acoustic: None,
18466 electrical: None,
18467 plastic: None,
18468 });
18469 }
18470
18471 if materials.is_empty() {
18472 materials.push(MaterialModel {
18473 material_id: "mat_default_steel".to_string(),
18474 name: "Steel (Default)".to_string(),
18475 mechanical: MaterialMechanicalModel {
18476 youngs_modulus_pa: 200e9,
18477 poisson_ratio: 0.3,
18478 density_kg_per_m3: 7850.0,
18479 },
18480 thermal: MaterialThermalModel {
18481 reference_temperature_k: 293.15,
18482 modulus_temp_coeff_per_k: -2.5e-4,
18483 ..MaterialThermalModel::default()
18484 },
18485 acoustic: None,
18486 electrical: None,
18487 plastic: None,
18488 });
18489 }
18490
18491 materials
18492}
18493
18494fn select_fixed_region_id(
18495 geometry: &GeometryAsset,
18496 prep_regions: Option<&HashSet<String>>,
18497) -> Option<String> {
18498 geometry
18499 .regions
18500 .iter()
18501 .filter(|region| {
18502 prep_regions
18503 .map(|mapped| mapped.contains(®ion.region_id))
18504 .unwrap_or(true)
18505 })
18506 .find(|region| {
18507 let key = format!(
18508 "{} {}",
18509 region.name.to_ascii_lowercase(),
18510 region
18511 .tag
18512 .as_deref()
18513 .unwrap_or_default()
18514 .to_ascii_lowercase()
18515 );
18516 key.contains("root")
18517 || key.contains("base")
18518 || key.contains("fixed")
18519 || key.contains("mount")
18520 })
18521 .map(|region| region.region_id.clone())
18522}
18523
18524fn select_load_region_id(
18525 geometry: &GeometryAsset,
18526 prep_regions: Option<&HashSet<String>>,
18527) -> Option<String> {
18528 geometry
18529 .regions
18530 .iter()
18531 .filter(|region| {
18532 prep_regions
18533 .map(|mapped| mapped.contains(®ion.region_id))
18534 .unwrap_or(true)
18535 })
18536 .find(|region| {
18537 let key = format!(
18538 "{} {}",
18539 region.name.to_ascii_lowercase(),
18540 region
18541 .tag
18542 .as_deref()
18543 .unwrap_or_default()
18544 .to_ascii_lowercase()
18545 );
18546 key.contains("tip")
18547 || key.contains("load")
18548 || key.contains("force")
18549 || key.contains("free")
18550 })
18551 .map(|region| region.region_id.clone())
18552}
18553
18554#[derive(Debug, Clone, Default)]
18555struct EmSweepSummary {
18556 sweep_count: usize,
18557 resonance_peak_frequency_hz: Option<f64>,
18558 resonance_peak_flux_density: Option<f64>,
18559 resonance_bandwidth_hz: Option<f64>,
18560 resonance_quality_factor: Option<f64>,
18561 resonance_flux_gain: Option<f64>,
18562}
18563
18564fn normalize_em_sweep_frequency_hz(
18565 reference_frequency_hz: f64,
18566 sweep_enabled: bool,
18567 requested: &[f64],
18568) -> Option<Vec<f64>> {
18569 let mut values = if sweep_enabled {
18570 requested.to_vec()
18571 } else {
18572 Vec::new()
18573 };
18574 if values.is_empty() {
18575 values.push(reference_frequency_hz);
18576 }
18577 if !values
18578 .iter()
18579 .all(|frequency_hz| frequency_hz.is_finite() && *frequency_hz > 0.0)
18580 {
18581 return None;
18582 }
18583 values.sort_by(|a, b| a.total_cmp(b));
18584 values.dedup_by(|a, b| (*a - *b).abs() <= 1.0e-9);
18585 Some(values)
18586}
18587
18588fn nearest_frequency_index(frequencies_hz: &[f64], target_hz: f64) -> Option<usize> {
18589 frequencies_hz
18590 .iter()
18591 .enumerate()
18592 .min_by(|(_, a), (_, b)| (*a - target_hz).abs().total_cmp(&(*b - target_hz).abs()))
18593 .map(|(index, _)| index)
18594}
18595
18596fn peak_abs_field_value(field: &runmat_analysis_core::AnalysisField) -> f64 {
18597 field
18598 .as_host_f64()
18599 .map(|values| values.iter().copied().map(f64::abs).fold(0.0_f64, f64::max))
18600 .unwrap_or(0.0)
18601}
18602
18603fn summarize_em_sweep(frequencies_hz: &[f64], peak_flux_density: &[f64]) -> EmSweepSummary {
18604 if frequencies_hz.is_empty() || frequencies_hz.len() != peak_flux_density.len() {
18605 return EmSweepSummary::default();
18606 }
18607 let sweep_count = frequencies_hz.len();
18608 let (peak_index, peak_flux_density_value) = peak_flux_density
18609 .iter()
18610 .copied()
18611 .enumerate()
18612 .max_by(|(_, a), (_, b)| a.total_cmp(b))
18613 .unwrap_or((0, 0.0));
18614 let peak_frequency_hz = frequencies_hz[peak_index];
18615 let min_flux_density_value = peak_flux_density
18616 .iter()
18617 .copied()
18618 .fold(f64::INFINITY, f64::min);
18619 let resonance_flux_gain =
18620 (peak_flux_density_value / min_flux_density_value.max(1.0e-12)).max(1.0);
18621
18622 let half_power = peak_flux_density_value * std::f64::consts::FRAC_1_SQRT_2;
18623 let mut left = peak_index;
18624 while left > 0 && peak_flux_density[left - 1] >= half_power {
18625 left -= 1;
18626 }
18627 let mut right = peak_index;
18628 while right + 1 < peak_flux_density.len() && peak_flux_density[right + 1] >= half_power {
18629 right += 1;
18630 }
18631 let resonance_bandwidth_hz = if right > left {
18632 Some((frequencies_hz[right] - frequencies_hz[left]).max(0.0))
18633 } else {
18634 None
18635 };
18636 let resonance_quality_factor = resonance_bandwidth_hz
18637 .filter(|bandwidth| *bandwidth > 0.0)
18638 .map(|bandwidth| (peak_frequency_hz / bandwidth).max(0.0));
18639
18640 EmSweepSummary {
18641 sweep_count,
18642 resonance_peak_frequency_hz: Some(peak_frequency_hz),
18643 resonance_peak_flux_density: Some(peak_flux_density_value),
18644 resonance_bandwidth_hz,
18645 resonance_quality_factor,
18646 resonance_flux_gain: Some(resonance_flux_gain),
18647 }
18648}
18649
18650fn em_sweep_known_answer_diagnostic(
18651 reference_frequency_hz: f64,
18652 frequencies_hz: &[f64],
18653 metrics: &EmSweepSummary,
18654) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
18655 let min_frequency_hz = frequencies_hz.iter().copied().fold(f64::INFINITY, f64::min);
18656 let max_frequency_hz = frequencies_hz.iter().copied().fold(0.0_f64, f64::max);
18657 let reference_scale = reference_frequency_hz.abs().max(1.0e-9);
18658 let reference_frequency_in_sweep_ratio = if frequencies_hz.iter().any(|frequency_hz| {
18659 (*frequency_hz - reference_frequency_hz).abs() <= reference_scale * 1.0e-9
18660 }) {
18661 1.0
18662 } else {
18663 0.0
18664 };
18665 let reference_frequency_bracket_ratio = if min_frequency_hz <= reference_frequency_hz
18666 && reference_frequency_hz <= max_frequency_hz
18667 {
18668 1.0
18669 } else {
18670 0.0
18671 };
18672 let normalized_peak_frequency_error_ratio = metrics
18673 .resonance_peak_frequency_hz
18674 .map(|peak_frequency_hz| {
18675 ((peak_frequency_hz - reference_frequency_hz).abs() / reference_scale).clamp(0.0, 1.0)
18676 })
18677 .unwrap_or(1.0);
18678 let resonance_flux_gain = metrics.resonance_flux_gain.unwrap_or(0.0);
18679 let resonance_quality_factor = metrics.resonance_quality_factor.unwrap_or(0.0);
18680 let sweep_known_answer_coverage_ratio = if metrics.sweep_count >= 3
18681 && reference_frequency_in_sweep_ratio >= 1.0
18682 && reference_frequency_bracket_ratio >= 1.0
18683 && normalized_peak_frequency_error_ratio <= 0.25
18684 && resonance_flux_gain >= 1.0
18685 && resonance_quality_factor >= 1.5
18686 {
18687 1.0
18688 } else {
18689 0.0
18690 };
18691 let severity = if sweep_known_answer_coverage_ratio >= 1.0 {
18692 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
18693 } else {
18694 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
18695 };
18696
18697 runmat_analysis_fea::diagnostics::FeaDiagnostic {
18698 code: "FEA_EM_SWEEP_KNOWN_ANSWER".to_string(),
18699 severity,
18700 message: format!(
18701 "basis=bracketed_reference_frequency sweep_count={} reference_frequency_hz={} sweep_frequency_min_hz={} sweep_frequency_max_hz={} reference_frequency_in_sweep_ratio={} reference_frequency_bracket_ratio={} normalized_peak_frequency_error_ratio={} resonance_flux_gain={} resonance_quality_factor={} sweep_known_answer_coverage_ratio={}",
18702 metrics.sweep_count,
18703 reference_frequency_hz,
18704 min_frequency_hz,
18705 max_frequency_hz,
18706 reference_frequency_in_sweep_ratio,
18707 reference_frequency_bracket_ratio,
18708 normalized_peak_frequency_error_ratio,
18709 resonance_flux_gain,
18710 resonance_quality_factor,
18711 sweep_known_answer_coverage_ratio,
18712 ),
18713 }
18714}
18715
18716fn infer_material_assignments(
18717 geometry: &GeometryAsset,
18718 materials: &[MaterialModel],
18719 prep_regions: Option<&HashSet<String>>,
18720) -> Vec<MaterialAssignment> {
18721 let default_material = materials
18722 .first()
18723 .map(|m| m.material_id.clone())
18724 .unwrap_or_else(|| "mat_default_steel".to_string());
18725 let mut assignments = Vec::new();
18726
18727 for region in &geometry.regions {
18728 let key = format!(
18729 "{} {}",
18730 region.name.to_ascii_lowercase(),
18731 region
18732 .tag
18733 .as_deref()
18734 .unwrap_or_default()
18735 .to_ascii_lowercase()
18736 );
18737 let assigned_material = if key.contains("aluminum") {
18738 materials
18739 .iter()
18740 .find(|m| m.material_id.contains("aluminum"))
18741 .map(|m| m.material_id.clone())
18742 .unwrap_or_else(|| default_material.clone())
18743 } else if key.contains("steel") {
18744 materials
18745 .iter()
18746 .find(|m| m.material_id.contains("steel"))
18747 .map(|m| m.material_id.clone())
18748 .unwrap_or_else(|| default_material.clone())
18749 } else if key.contains("polymer") || key.contains("plastic") {
18750 materials
18751 .iter()
18752 .find(|m| m.material_id.contains("polymer"))
18753 .map(|m| m.material_id.clone())
18754 .unwrap_or_else(|| default_material.clone())
18755 } else {
18756 default_material.clone()
18757 };
18758
18759 let evidence_confidence = if geometry
18760 .source_geometry
18761 .material_evidence
18762 .iter()
18763 .any(|e| e.confidence == MaterialEvidenceConfidence::High)
18764 {
18765 EvidenceConfidence::Verified
18766 } else if geometry
18767 .source_geometry
18768 .material_evidence
18769 .iter()
18770 .any(|e| e.confidence == MaterialEvidenceConfidence::Medium)
18771 {
18772 EvidenceConfidence::Probable
18773 } else {
18774 EvidenceConfidence::Inferred
18775 };
18776 let confidence = if prep_regions
18777 .map(|mapped| mapped.contains(®ion.region_id))
18778 .unwrap_or(false)
18779 {
18780 EvidenceConfidence::Verified
18781 } else {
18782 evidence_confidence
18783 };
18784
18785 assignments.push(MaterialAssignment {
18786 region_id: region.region_id.clone(),
18787 expected_material_id: assigned_material.clone(),
18788 assigned_material_id: assigned_material,
18789 confidence,
18790 });
18791 }
18792
18793 assignments
18794}
18795
18796fn map_validate_error(
18797 error: AnalysisValidationError,
18798 model: &AnalysisModel,
18799 context: &OperationContext,
18800) -> OperationErrorEnvelope {
18801 let (error_code, message, mut error_context) = match error {
18802 AnalysisValidationError::MissingMaterials => (
18803 "RM.FEA.VALIDATE.MISSING_MATERIALS",
18804 "FEA model must include at least one material".to_string(),
18805 BTreeMap::new(),
18806 ),
18807 AnalysisValidationError::MissingBoundaryConditions => (
18808 "RM.FEA.VALIDATE.MISSING_BCS",
18809 "FEA model must include at least one boundary condition".to_string(),
18810 BTreeMap::new(),
18811 ),
18812 AnalysisValidationError::MissingLoads => (
18813 "RM.FEA.VALIDATE.MISSING_LOADS",
18814 "FEA model must include at least one load".to_string(),
18815 BTreeMap::new(),
18816 ),
18817 AnalysisValidationError::InvalidMomentVector { load_id } => (
18818 "RM.FEA.VALIDATE.INVALID_MOMENT",
18819 format!("moment load {load_id} must have finite components"),
18820 BTreeMap::from([("load_id".to_string(), load_id)]),
18821 ),
18822 AnalysisValidationError::ZeroMomentVector { load_id } => (
18823 "RM.FEA.VALIDATE.ZERO_MOMENT",
18824 format!("moment load {load_id} must have nonzero magnitude"),
18825 BTreeMap::from([("load_id".to_string(), load_id)]),
18826 ),
18827 AnalysisValidationError::InvalidWrench { load_id } => (
18828 "RM.FEA.VALIDATE.INVALID_WRENCH",
18829 format!("wrench load {load_id} must have finite force, moment, and point components"),
18830 BTreeMap::from([("load_id".to_string(), load_id)]),
18831 ),
18832 AnalysisValidationError::ZeroWrench { load_id } => (
18833 "RM.FEA.VALIDATE.ZERO_WRENCH",
18834 format!("wrench load {load_id} must have nonzero force or moment"),
18835 BTreeMap::from([("load_id".to_string(), load_id)]),
18836 ),
18837 AnalysisValidationError::UnitMismatch { model, geometry } => (
18838 "RM.FEA.VALIDATE.UNIT_MISMATCH",
18839 format!("model units {model:?} do not match geometry units {geometry:?}"),
18840 BTreeMap::from([
18841 ("model_units".to_string(), format!("{model:?}")),
18842 ("geometry_units".to_string(), format!("{geometry:?}")),
18843 ]),
18844 ),
18845 AnalysisValidationError::FrameMismatch { model, geometry } => (
18846 "RM.FEA.VALIDATE.FRAME_MISMATCH",
18847 format!("model frame {model:?} does not match geometry frame {geometry:?}"),
18848 BTreeMap::from([
18849 ("model_frame".to_string(), format!("{model:?}")),
18850 ("geometry_frame".to_string(), format!("{geometry:?}")),
18851 ]),
18852 ),
18853 };
18854
18855 error_context.insert("analysis_model_id".to_string(), model.model_id.0.clone());
18856 error_context.insert("geometry_id".to_string(), model.geometry_id.clone());
18857
18858 operation_error(
18859 ANALYSIS_VALIDATE_OPERATION,
18860 ANALYSIS_VALIDATE_OP_VERSION,
18861 context,
18862 OperationErrorSpec {
18863 error_code,
18864 error_type: OperationErrorType::Validation,
18865 retryable: false,
18866 severity: OperationErrorSeverity::Error,
18867 },
18868 message,
18869 error_context,
18870 )
18871}
18872
18873#[cfg(test)]
18874mod tests;