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, FeaProgressEvent, FeaProgressHandler,
28 FeaProgressPhase, FeaProgressStatus, FeaRunError, FeaRunResult, LinearStaticSolveOptions,
29 ModalSolveOptions, ThermalSolveOptions, FEA_FIELD_ACOUSTIC_PARTICLE_VELOCITY,
30 FEA_FIELD_ACOUSTIC_PHASE, FEA_FIELD_ACOUSTIC_PRESSURE_IMAG,
31 FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE, FEA_FIELD_ACOUSTIC_PRESSURE_REAL,
32 FEA_FIELD_ACOUSTIC_SOUND_PRESSURE_LEVEL_DB, FEA_FIELD_CFD_PRESSURE,
33 FEA_FIELD_CFD_RESIDUAL_CONTINUITY, FEA_FIELD_CFD_RESIDUAL_MOMENTUM,
34 FEA_FIELD_CFD_REYNOLDS_NUMBER, FEA_FIELD_CFD_VELOCITY, FEA_FIELD_CFD_VORTICITY,
35 FEA_FIELD_CFD_WALL_SHEAR_STRESS, FEA_FIELD_CHT_FLUID_PRESSURE, FEA_FIELD_CHT_FLUID_VELOCITY,
36};
37use runmat_geometry_core::{GeometryAsset, MaterialEvidenceConfidence, UnitSystem};
38use runmat_meshing_core::{ElementFamilyHint, MeshConnectivityClass};
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41
42use crate::operations::{
43 operation_error, OperationContext, OperationEnvelope, OperationErrorEnvelope,
44 OperationErrorSeverity, OperationErrorSpec, OperationErrorType,
45};
46use policy::{
47 breach_rate_greater_than, breach_rate_less_than, electromagnetic_sweep_thresholds_for_policy,
48 electromagnetic_thresholds_for_policy, thermo_field_quality_thresholds_for_policy,
49 thermo_gradient_thresholds_for_policy, thermo_thresholds_for_policy,
50 ElectromagneticQualityThresholds, EM_ASSIGNMENT_COVERAGE_MIN_BALANCED,
51 EM_BOUNDARY_ANCHOR_MIN_BALANCED, EM_BOUNDARY_ENERGY_MIN_BALANCED,
52 EM_BOUNDARY_LOCALIZATION_MIN_BALANCED, EM_BOUNDARY_PENALTY_CONTRIBUTION_MAX_BALANCED,
53 EM_CONDITIONING_MAX_BALANCED, EM_CONDUCTIVITY_SPREAD_THRESHOLD_BALANCED,
54 EM_ENERGY_IMBALANCE_MAX_BALANCED, EM_FLUX_DIVERGENCE_MAX_BALANCED,
55 EM_GROUND_EFFECTIVENESS_MIN_BALANCED, EM_HETEROGENEITY_THRESHOLD_BALANCED,
56 EM_IMAG_RESIDUAL_MAX_BALANCED, EM_INSULATION_LEAKAGE_MAX_BALANCED,
57 EM_REAL_RESIDUAL_MAX_BALANCED, EM_REGION_CONTRAST_MAX_BALANCED, EM_RESONANCE_Q_MIN_BALANCED,
58 EM_SOURCE_INTERFERENCE_MAX_BALANCED, EM_SOURCE_MATERIAL_ALIGNMENT_MIN_BALANCED,
59 EM_SOURCE_OVERLAP_MAX_BALANCED, EM_SOURCE_REALIZATION_MIN_BALANCED,
60 EM_SOURCE_REGION_COVERAGE_MIN_BALANCED, EM_SOURCE_REGION_ENERGY_CONSISTENCY_MIN_BALANCED,
61 EM_SWEEP_COUNT_MIN_BALANCED, THERMO_HETEROGENEITY_THRESHOLD_BALANCED,
62 THERMO_SPREAD_THRESHOLD_BALANCED,
63};
64
65mod contracts;
66mod fea_document;
67#[cfg(feature = "plot-core")]
68mod figures;
69mod policy;
70mod promotion;
71pub mod storage;
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct FeaRuntimeConfig {
75 pub artifact_root: Option<PathBuf>,
76 pub study_artifact_root: Option<PathBuf>,
77 pub thermo_field_artifact_root: Option<PathBuf>,
78}
79
80fn fea_runtime_config() -> &'static RwLock<FeaRuntimeConfig> {
81 static CONFIG: OnceLock<RwLock<FeaRuntimeConfig>> = OnceLock::new();
82 CONFIG.get_or_init(|| RwLock::new(FeaRuntimeConfig::default()))
83}
84
85fn current_fea_runtime_config() -> FeaRuntimeConfig {
86 fea_runtime_config()
87 .read()
88 .map(|guard| guard.clone())
89 .unwrap_or_default()
90}
91
92pub fn default_fea_artifact_root() -> PathBuf {
93 PathBuf::from("artifacts")
94}
95
96pub fn configure_fea_runtime(config: FeaRuntimeConfig) -> Result<(), String> {
97 let mut guard = fea_runtime_config()
98 .write()
99 .map_err(|_| "FEA runtime config lock poisoned".to_string())?;
100 *guard = config;
101 Ok(())
102}
103
104thread_local! {
105 static FEA_PROGRESS_HANDLER: RefCell<Option<FeaProgressHandler>> = const { RefCell::new(None) };
106}
107
108pub struct FeaProgressHandlerGuard {
109 previous: Option<FeaProgressHandler>,
110}
111
112impl Drop for FeaProgressHandlerGuard {
113 fn drop(&mut self) {
114 FEA_PROGRESS_HANDLER.with(|slot| {
115 slot.replace(self.previous.take());
116 });
117 }
118}
119
120pub fn replace_fea_progress_handler(
121 handler: Option<FeaProgressHandler>,
122) -> FeaProgressHandlerGuard {
123 let previous = FEA_PROGRESS_HANDLER.with(|slot| slot.replace(handler));
124 FeaProgressHandlerGuard { previous }
125}
126
127fn install_fea_solver_context() -> runmat_analysis_fea::FeaProgressContextGuard {
128 let host_handler = FEA_PROGRESS_HANDLER.with(|slot| slot.borrow().clone());
129 let handler = Some(Arc::new(move |event: FeaProgressEvent| {
130 tracing::info!(
131 target: "runmat_analysis",
132 operation = %event.operation,
133 phase = ?event.phase,
134 status = ?event.status,
135 current = event.current,
136 total = event.total,
137 fraction = event.fraction,
138 "{}", event.message
139 );
140 if let Some(host_handler) = host_handler.as_ref() {
141 host_handler(event);
142 }
143 }) as FeaProgressHandler);
144 runmat_analysis_fea::replace_fea_progress_context(
145 handler,
146 Some(Arc::new(crate::interrupt::is_cancelled)),
147 )
148}
149
150pub use contracts::{
151 AnalysisAcousticRunOptions, AnalysisCfdRunOptions, AnalysisChtRunOptions,
152 AnalysisCreateModelIntentSpec, AnalysisCreateModelPrepContext, AnalysisCreateModelProfile,
153 AnalysisElectromagneticRunOptions, AnalysisFieldDescriptor, AnalysisFieldKind,
154 AnalysisFieldLocation, AnalysisFieldStorage, AnalysisFsiRunOptions, AnalysisModalRunOptions,
155 AnalysisNonlinearRunOptions, AnalysisRenderMesh, AnalysisRenderTopology,
156 AnalysisRenderTopologySource, AnalysisResultsCompareData, AnalysisResultsCompareQuery,
157 AnalysisResultsData, AnalysisResultsQuery, AnalysisResultsSummary, AnalysisRunKind,
158 AnalysisRunOptions, AnalysisRunPrepContext, AnalysisRunResult, AnalysisStudyIssue,
159 AnalysisStudyPlanData, AnalysisStudyRunData, AnalysisStudySpec, AnalysisStudySweepData,
160 AnalysisStudySweepFailureEntry, AnalysisStudySweepPlanData, AnalysisStudySweepPlanEntry,
161 AnalysisStudySweepRunEntry, AnalysisStudySweepSpec, AnalysisStudySweepValidateData,
162 AnalysisStudySweepValidateEntry, AnalysisStudyValidateResult, AnalysisThermalRunOptions,
163 AnalysisTransientRunOptions, AnalysisTrendKindSummary, AnalysisTrendsData, AnalysisTrendsQuery,
164 AnalysisValidateResult, ContactInterfaceOptions, ElectroRegionConductivityScale,
165 ElectroThermalCouplingOptions, ElectroTimeProfilePoint, ElectromagneticResultsData,
166 ModalFrequencyBasis, ModalFrequencyUnits, ModalResultsData, NonlinearMethod,
167 NonlinearResultsData, PlasticityConstitutiveOptions, PrecisionMode, PreconditionerMode,
168 PrepCalibrationProfile, QualityGate, QualityPolicy, QualityReason, QualityReasonCode,
169 RunProvenance, RunStatus, ThermalResultsData, ThermoFieldInterpolationMode, ThermoFieldSource,
170 ThermoMechanicalCouplingOptions, ThermoRegionTemperatureDelta, ThermoTimeProfilePoint,
171 TransientIntegrationMethod, TransientResultsData,
172};
173pub use fea_document::{
174 is_fea_file_path, load_fea_document_from_path_async, parse_and_resolve_fea_document,
175 FeaResolvedDocument,
176};
177#[cfg(feature = "plot-core")]
178pub use figures::{
179 analysis_generate_study_run_figures, AnalysisFigureGenerationOptions, AnalysisGeneratedFigure,
180 AnalysisGeneratedFigureKind,
181};
182
183const ANALYSIS_CREATE_MODEL_OPERATION: &str = "fea.create_model";
184const ANALYSIS_CREATE_MODEL_OP_VERSION: &str = "fea.create_model/v1";
185const ANALYSIS_VALIDATE_STUDY_OPERATION: &str = "fea.validate_study";
186const ANALYSIS_VALIDATE_STUDY_OP_VERSION: &str = "fea.validate_study/v1";
187const ANALYSIS_PLAN_STUDY_OPERATION: &str = "fea.plan_study";
188const ANALYSIS_PLAN_STUDY_OP_VERSION: &str = "fea.plan_study/v1";
189const ANALYSIS_PLAN_STUDY_SWEEP_OPERATION: &str = "fea.plan_study_sweep";
190const ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION: &str = "fea.plan_study_sweep/v1";
191const ANALYSIS_RUN_STUDY_OPERATION: &str = "fea.run_study";
192const ANALYSIS_RUN_STUDY_OP_VERSION: &str = "fea.run_study/v1";
193const ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION: &str = "fea.validate_study_sweep";
194const ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION: &str = "fea.validate_study_sweep/v1";
195const ANALYSIS_RUN_STUDY_SWEEP_OPERATION: &str = "fea.run_study_sweep";
196const ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION: &str = "fea.run_study_sweep/v1";
197const ANALYSIS_VALIDATE_OPERATION: &str = "fea.validate";
198const ANALYSIS_VALIDATE_OP_VERSION: &str = "fea.validate/v1";
199const ANALYSIS_RUN_OPERATION: &str = "fea.run_linear_static";
200const ANALYSIS_RUN_OP_VERSION: &str = "fea.run_linear_static/v1";
201const ANALYSIS_RUN_MODAL_OPERATION: &str = "fea.run_modal";
202const ANALYSIS_RUN_MODAL_OP_VERSION: &str = "fea.run_modal/v1";
203const ANALYSIS_RUN_ACOUSTIC_OPERATION: &str = "fea.run_acoustic";
204const ANALYSIS_RUN_ACOUSTIC_OP_VERSION: &str = "fea.run_acoustic/v1";
205const ANALYSIS_RUN_TRANSIENT_OPERATION: &str = "fea.run_transient";
206const ANALYSIS_RUN_TRANSIENT_OP_VERSION: &str = "fea.run_transient/v1";
207const ANALYSIS_RUN_THERMAL_OPERATION: &str = "fea.run_thermal";
208const ANALYSIS_RUN_THERMAL_OP_VERSION: &str = "fea.run_thermal/v1";
209const ANALYSIS_RUN_NONLINEAR_OPERATION: &str = "fea.run_nonlinear";
210const ANALYSIS_RUN_NONLINEAR_OP_VERSION: &str = "fea.run_nonlinear/v1";
211const ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION: &str = "fea.run_electromagnetic";
212const ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION: &str = "fea.run_electromagnetic/v1";
213const ANALYSIS_RUN_CFD_OPERATION: &str = "fea.run_cfd";
214const ANALYSIS_RUN_CFD_OP_VERSION: &str = "fea.run_cfd/v1";
215const ANALYSIS_RUN_CHT_OPERATION: &str = "fea.run_cht";
216const ANALYSIS_RUN_CHT_OP_VERSION: &str = "fea.run_cht/v1";
217const ANALYSIS_RUN_FSI_OPERATION: &str = "fea.run_fsi";
218const ANALYSIS_RUN_FSI_OP_VERSION: &str = "fea.run_fsi/v1";
219const ANALYSIS_RESULTS_OPERATION: &str = "fea.results";
220const ANALYSIS_RESULTS_OP_VERSION: &str = "fea.results/v1";
221const ANALYSIS_RESULTS_COMPARE_OPERATION: &str = "fea.results_compare";
222const ANALYSIS_RESULTS_COMPARE_OP_VERSION: &str = "fea.results_compare/v1";
223const ANALYSIS_TRENDS_OPERATION: &str = "fea.trends";
224const ANALYSIS_TRENDS_OP_VERSION: &str = "fea.trends/v1";
225const TRANSIENT_RESIDUAL_WARN_THRESHOLD: f64 = 1.0e-4;
226
227fn map_fea_run_error(
228 operation: &str,
229 op_version: &str,
230 default_error_code: &'static str,
231 cancel_error_code: &'static str,
232 model: &AnalysisModel,
233 context: &OperationContext,
234 err: FeaRunError,
235) -> OperationErrorEnvelope {
236 match err {
237 FeaRunError::Cancelled => operation_error(
238 operation,
239 op_version,
240 context,
241 OperationErrorSpec {
242 error_code: cancel_error_code,
243 error_type: OperationErrorType::Cancelled,
244 retryable: false,
245 severity: OperationErrorSeverity::Warning,
246 },
247 "FEA run cancelled by user",
248 BTreeMap::from([
249 ("analysis_model_id".to_string(), model.model_id.0.clone()),
250 ("geometry_id".to_string(), model.geometry_id.clone()),
251 ]),
252 ),
253 FeaRunError::InvalidModel(message) => operation_error(
254 operation,
255 op_version,
256 context,
257 OperationErrorSpec {
258 error_code: default_error_code,
259 error_type: OperationErrorType::Validation,
260 retryable: false,
261 severity: OperationErrorSeverity::Error,
262 },
263 message,
264 BTreeMap::from([
265 ("analysis_model_id".to_string(), model.model_id.0.clone()),
266 ("geometry_id".to_string(), model.geometry_id.clone()),
267 ]),
268 ),
269 }
270}
271
272fn reject_moment_loads_for_run_family(
273 model: &AnalysisModel,
274 operation: &'static str,
275 op_version: &'static str,
276 error_code: &'static str,
277 family: &'static str,
278 context: &OperationContext,
279) -> Result<(), OperationErrorEnvelope> {
280 if let Some(load) = model
281 .loads
282 .iter()
283 .find(|load| matches!(load.kind, LoadKind::Moment { .. }))
284 {
285 return Err(operation_error(
286 operation,
287 op_version,
288 context,
289 OperationErrorSpec {
290 error_code,
291 error_type: OperationErrorType::Validation,
292 retryable: false,
293 severity: OperationErrorSeverity::Error,
294 },
295 format!("moment loads are structural loads and cannot be used as {family} loads"),
296 BTreeMap::from([
297 ("analysis_model_id".to_string(), model.model_id.0.clone()),
298 ("load_id".to_string(), load.load_id.clone()),
299 ("region_id".to_string(), load.region_id.clone()),
300 ]),
301 ));
302 }
303 Ok(())
304}
305
306fn persist_fea_run_result_with_progress(
307 operation: &str,
308 op_version: &str,
309 artifact_error_code: &'static str,
310 context: &OperationContext,
311 result: &AnalysisRunResult,
312) -> Result<(), OperationErrorEnvelope> {
313 runmat_analysis_fea::emit_fea_progress_phase(
314 operation,
315 FeaProgressPhase::ArtifactPersistence,
316 FeaProgressStatus::Started,
317 "persisting FEA run artifact",
318 None,
319 None,
320 );
321 match storage::persist_run_result(result) {
322 Ok(_record) => {
323 runmat_analysis_fea::emit_fea_progress_phase(
324 operation,
325 FeaProgressPhase::ArtifactPersistence,
326 FeaProgressStatus::Completed,
327 "FEA run artifact persisted",
328 None,
329 None,
330 );
331 Ok(())
332 }
333 Err(err) => {
334 let message = format!("failed to persist FEA run artifact: {err}");
335 runmat_analysis_fea::emit_fea_progress_phase(
336 operation,
337 FeaProgressPhase::ArtifactPersistence,
338 FeaProgressStatus::Failed,
339 &message,
340 None,
341 None,
342 );
343 Err(operation_error(
344 operation,
345 op_version,
346 context,
347 OperationErrorSpec {
348 error_code: artifact_error_code,
349 error_type: OperationErrorType::Internal,
350 retryable: true,
351 severity: OperationErrorSeverity::Error,
352 },
353 message,
354 BTreeMap::from([("run_id".to_string(), result.run_id.clone())]),
355 ))
356 }
357 }
358}
359
360pub fn analysis_create_model_op(
361 geometry: &GeometryAsset,
362 intent: AnalysisCreateModelIntentSpec,
363 context: OperationContext,
364) -> Result<OperationEnvelope<AnalysisModel>, OperationErrorEnvelope> {
365 if intent.model_id.trim().is_empty() {
366 return Err(operation_error(
367 ANALYSIS_CREATE_MODEL_OPERATION,
368 ANALYSIS_CREATE_MODEL_OP_VERSION,
369 &context,
370 OperationErrorSpec {
371 error_code: "RM.FEA.CREATE_MODEL.INVALID_INTENT",
372 error_type: OperationErrorType::Input,
373 retryable: false,
374 severity: OperationErrorSeverity::Error,
375 },
376 "FEA model intent requires a non-empty model_id",
377 BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
378 ));
379 }
380
381 if geometry.meshes.is_empty() {
382 return Err(operation_error(
383 ANALYSIS_CREATE_MODEL_OPERATION,
384 ANALYSIS_CREATE_MODEL_OP_VERSION,
385 &context,
386 OperationErrorSpec {
387 error_code: "RM.FEA.CREATE_MODEL.GEOMETRY_EMPTY",
388 error_type: OperationErrorType::Validation,
389 retryable: false,
390 severity: OperationErrorSeverity::Error,
391 },
392 "geometry must contain at least one mesh to create an FEA model",
393 BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
394 ));
395 }
396
397 if geometry.units == UnitSystem::Unspecified {
398 return Err(operation_error(
399 ANALYSIS_CREATE_MODEL_OPERATION,
400 ANALYSIS_CREATE_MODEL_OP_VERSION,
401 &context,
402 OperationErrorSpec {
403 error_code: "RM.FEA.CREATE_MODEL.UNIT_UNSPECIFIED",
404 error_type: OperationErrorType::Validation,
405 retryable: false,
406 severity: OperationErrorSeverity::Error,
407 },
408 "geometry units must be specified before creating an FEA model",
409 BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
410 ));
411 }
412
413 let prep_mapped_region_ids = if let Some(prep) = intent.prep_context.as_ref() {
414 if prep.source_geometry_id != geometry.geometry_id
415 || prep.source_geometry_revision != geometry.revision
416 {
417 return Err(operation_error(
418 ANALYSIS_CREATE_MODEL_OPERATION,
419 ANALYSIS_CREATE_MODEL_OP_VERSION,
420 &context,
421 OperationErrorSpec {
422 error_code: "RM.FEA.CREATE_MODEL.PREP_MISMATCH",
423 error_type: OperationErrorType::Input,
424 retryable: false,
425 severity: OperationErrorSeverity::Error,
426 },
427 "FEA model prep context does not match geometry id/revision",
428 BTreeMap::from([
429 ("geometry_id".to_string(), geometry.geometry_id.clone()),
430 (
431 "geometry_revision".to_string(),
432 geometry.revision.to_string(),
433 ),
434 (
435 "prep_geometry_id".to_string(),
436 prep.source_geometry_id.clone(),
437 ),
438 (
439 "prep_geometry_revision".to_string(),
440 prep.source_geometry_revision.to_string(),
441 ),
442 ]),
443 ));
444 }
445
446 let mesh_id_set = geometry
447 .meshes
448 .iter()
449 .map(|mesh| mesh.mesh_id.as_str())
450 .collect::<HashSet<_>>();
451 let region_id_set = geometry
452 .regions
453 .iter()
454 .map(|region| region.region_id.as_str())
455 .collect::<HashSet<_>>();
456 for mapping in &prep.region_mappings {
457 if !region_id_set.is_empty() && !region_id_set.contains(mapping.region_id.as_str()) {
458 return Err(operation_error(
459 ANALYSIS_CREATE_MODEL_OPERATION,
460 ANALYSIS_CREATE_MODEL_OP_VERSION,
461 &context,
462 OperationErrorSpec {
463 error_code: "RM.FEA.CREATE_MODEL.PREP_REGION_NOT_FOUND",
464 error_type: OperationErrorType::Validation,
465 retryable: false,
466 severity: OperationErrorSeverity::Error,
467 },
468 format!(
469 "prep context region '{}' is not present in geometry regions",
470 mapping.region_id
471 ),
472 BTreeMap::from([("region_id".to_string(), mapping.region_id.clone())]),
473 ));
474 }
475 if mapping.source_mesh_ids.is_empty() || mapping.prepared_mesh_ids.is_empty() {
476 return Err(operation_error(
477 ANALYSIS_CREATE_MODEL_OPERATION,
478 ANALYSIS_CREATE_MODEL_OP_VERSION,
479 &context,
480 OperationErrorSpec {
481 error_code: "RM.FEA.CREATE_MODEL.PREP_INVALID_MAPPING",
482 error_type: OperationErrorType::Input,
483 retryable: false,
484 severity: OperationErrorSeverity::Error,
485 },
486 "prep context mapping requires non-empty source/prepared mesh ids",
487 BTreeMap::from([("region_id".to_string(), mapping.region_id.clone())]),
488 ));
489 }
490 for source_mesh_id in &mapping.source_mesh_ids {
491 if !mesh_id_set.contains(source_mesh_id.as_str()) {
492 return Err(operation_error(
493 ANALYSIS_CREATE_MODEL_OPERATION,
494 ANALYSIS_CREATE_MODEL_OP_VERSION,
495 &context,
496 OperationErrorSpec {
497 error_code: "RM.FEA.CREATE_MODEL.PREP_MESH_NOT_FOUND",
498 error_type: OperationErrorType::Validation,
499 retryable: false,
500 severity: OperationErrorSeverity::Error,
501 },
502 format!(
503 "prep context source mesh '{}' is not present in geometry",
504 source_mesh_id
505 ),
506 BTreeMap::from([("source_mesh_id".to_string(), source_mesh_id.clone())]),
507 ));
508 }
509 }
510 }
511
512 Some(
513 prep.region_mappings
514 .iter()
515 .map(|mapping| mapping.region_id.clone())
516 .collect::<HashSet<_>>(),
517 )
518 } else {
519 None
520 };
521
522 let fixed_region_id = select_fixed_region_id(geometry, prep_mapped_region_ids.as_ref())
523 .or_else(|| {
524 geometry
525 .regions
526 .first()
527 .map(|region| region.region_id.clone())
528 })
529 .unwrap_or_else(|| "region_default".to_string());
530 let load_region_id = select_load_region_id(geometry, prep_mapped_region_ids.as_ref())
531 .or_else(|| {
532 geometry
533 .regions
534 .last()
535 .map(|region| region.region_id.clone())
536 })
537 .unwrap_or_else(|| fixed_region_id.clone());
538
539 let mut inferred_materials = infer_material_models(geometry);
540 if matches!(intent.profile, AnalysisCreateModelProfile::AcousticHarmonic) {
541 for material in &mut inferred_materials {
542 material.acoustic = Some(runmat_analysis_core::MaterialAcousticModel::default());
543 }
544 }
545 if matches!(
546 intent.profile,
547 AnalysisCreateModelProfile::ElectromagneticStatic
548 ) {
549 for material in &mut inferred_materials {
550 material.electrical = Some(runmat_analysis_core::MaterialElectricalModel::default());
551 }
552 }
553 let inferred_assignments = infer_material_assignments(
554 geometry,
555 &inferred_materials,
556 prep_mapped_region_ids.as_ref(),
557 );
558
559 let (default_bc, default_load, default_steps) = match intent.profile {
560 AnalysisCreateModelProfile::LinearStaticStructural => (
561 BoundaryCondition {
562 bc_id: "bc_default_fixed".to_string(),
563 region_id: fixed_region_id,
564 kind: BoundaryConditionKind::Fixed,
565 },
566 LoadCase {
567 load_id: "load_default_force".to_string(),
568 region_id: load_region_id,
569 kind: LoadKind::Force {
570 fx: 0.0,
571 fy: -1000.0,
572 fz: 0.0,
573 },
574 },
575 vec![AnalysisStep {
576 step_id: "step_default_static".to_string(),
577 kind: AnalysisStepKind::Static,
578 }],
579 ),
580 AnalysisCreateModelProfile::ThermoMechanicalCoupled => (
581 BoundaryCondition {
582 bc_id: "bc_default_fixed".to_string(),
583 region_id: fixed_region_id,
584 kind: BoundaryConditionKind::Fixed,
585 },
586 LoadCase {
587 load_id: "load_default_thermal_mech_force".to_string(),
588 region_id: load_region_id,
589 kind: LoadKind::Force {
590 fx: 0.0,
591 fy: -650.0,
592 fz: 0.0,
593 },
594 },
595 vec![AnalysisStep {
596 step_id: "step_default_thermo_mech".to_string(),
597 kind: AnalysisStepKind::Transient,
598 }],
599 ),
600 AnalysisCreateModelProfile::ThermalStandalone => (
601 BoundaryCondition {
602 bc_id: "bc_default_fixed".to_string(),
603 region_id: fixed_region_id,
604 kind: BoundaryConditionKind::Fixed,
605 },
606 LoadCase {
607 load_id: "load_default_thermal_seed".to_string(),
608 region_id: load_region_id,
609 kind: LoadKind::BodyForce {
610 gx: 0.0,
611 gy: 0.0,
612 gz: 0.0,
613 },
614 },
615 vec![AnalysisStep {
616 step_id: "step_default_thermal".to_string(),
617 kind: AnalysisStepKind::Thermal,
618 }],
619 ),
620 AnalysisCreateModelProfile::ModalStructural => (
621 BoundaryCondition {
622 bc_id: "bc_default_fixed".to_string(),
623 region_id: fixed_region_id,
624 kind: BoundaryConditionKind::Fixed,
625 },
626 LoadCase {
627 load_id: "load_default_modal_seed".to_string(),
628 region_id: load_region_id,
629 kind: LoadKind::BodyForce {
630 gx: 0.0,
631 gy: 0.0,
632 gz: 0.0,
633 },
634 },
635 vec![AnalysisStep {
636 step_id: "step_default_modal".to_string(),
637 kind: AnalysisStepKind::Modal,
638 }],
639 ),
640 AnalysisCreateModelProfile::AcousticHarmonic => (
641 BoundaryCondition {
642 bc_id: "bc_default_acoustic_rigid_wall".to_string(),
643 region_id: fixed_region_id,
644 kind: BoundaryConditionKind::AcousticRigidWall,
645 },
646 LoadCase {
647 load_id: "load_default_acoustic_harmonic_seed".to_string(),
648 region_id: load_region_id,
649 kind: LoadKind::Pressure { magnitude_pa: 1.0 },
650 },
651 vec![AnalysisStep {
652 step_id: "step_default_acoustic_harmonic".to_string(),
653 kind: AnalysisStepKind::Modal,
654 }],
655 ),
656 AnalysisCreateModelProfile::TransientStructural => (
657 BoundaryCondition {
658 bc_id: "bc_default_fixed".to_string(),
659 region_id: fixed_region_id,
660 kind: BoundaryConditionKind::Fixed,
661 },
662 LoadCase {
663 load_id: "load_default_transient_force".to_string(),
664 region_id: load_region_id,
665 kind: LoadKind::Force {
666 fx: 0.0,
667 fy: -500.0,
668 fz: 0.0,
669 },
670 },
671 vec![AnalysisStep {
672 step_id: "step_default_transient".to_string(),
673 kind: AnalysisStepKind::Transient,
674 }],
675 ),
676 AnalysisCreateModelProfile::NonlinearStructural => (
677 BoundaryCondition {
678 bc_id: "bc_default_fixed".to_string(),
679 region_id: fixed_region_id,
680 kind: BoundaryConditionKind::Fixed,
681 },
682 LoadCase {
683 load_id: "load_default_nonlinear_force".to_string(),
684 region_id: load_region_id,
685 kind: LoadKind::Force {
686 fx: 0.0,
687 fy: -750.0,
688 fz: 0.0,
689 },
690 },
691 vec![AnalysisStep {
692 step_id: "step_default_nonlinear".to_string(),
693 kind: AnalysisStepKind::Nonlinear,
694 }],
695 ),
696 AnalysisCreateModelProfile::ElectromagneticStatic => (
697 BoundaryCondition {
698 bc_id: "bc_default_em_ground".to_string(),
699 region_id: fixed_region_id,
700 kind: BoundaryConditionKind::VectorPotentialGround,
701 },
702 LoadCase {
703 load_id: "load_default_em_coil_current".to_string(),
704 region_id: load_region_id,
705 kind: LoadKind::CoilCurrent {
706 current_a: 100.0,
707 phase_rad: 0.0,
708 amplitude_scale: 1.0,
709 },
710 },
711 vec![AnalysisStep {
712 step_id: "step_default_electromagnetic".to_string(),
713 kind: AnalysisStepKind::Electromagnetic,
714 }],
715 ),
716 AnalysisCreateModelProfile::CfdSteadyState => (
717 BoundaryCondition {
718 bc_id: "bc_default_fixed".to_string(),
719 region_id: fixed_region_id,
720 kind: BoundaryConditionKind::Fixed,
721 },
722 LoadCase {
723 load_id: "load_default_cfd_seed".to_string(),
724 region_id: load_region_id,
725 kind: LoadKind::BodyForce {
726 gx: 0.0,
727 gy: 0.0,
728 gz: 0.0,
729 },
730 },
731 vec![AnalysisStep {
732 step_id: "step_default_cfd".to_string(),
733 kind: AnalysisStepKind::Cfd,
734 }],
735 ),
736 AnalysisCreateModelProfile::CfdTransient => (
737 BoundaryCondition {
738 bc_id: "bc_default_fixed".to_string(),
739 region_id: fixed_region_id,
740 kind: BoundaryConditionKind::Fixed,
741 },
742 LoadCase {
743 load_id: "load_default_cfd_transient_seed".to_string(),
744 region_id: load_region_id,
745 kind: LoadKind::BodyForce {
746 gx: 0.0,
747 gy: 0.0,
748 gz: 0.0,
749 },
750 },
751 vec![AnalysisStep {
752 step_id: "step_default_cfd_transient".to_string(),
753 kind: AnalysisStepKind::Cfd,
754 }],
755 ),
756 AnalysisCreateModelProfile::ChtCoupled => (
757 BoundaryCondition {
758 bc_id: "bc_default_fixed".to_string(),
759 region_id: fixed_region_id,
760 kind: BoundaryConditionKind::Fixed,
761 },
762 LoadCase {
763 load_id: "load_default_cht_seed".to_string(),
764 region_id: load_region_id,
765 kind: LoadKind::BodyForce {
766 gx: 0.0,
767 gy: 0.0,
768 gz: 0.0,
769 },
770 },
771 vec![
772 AnalysisStep {
773 step_id: "step_default_cht_flow".to_string(),
774 kind: AnalysisStepKind::Cfd,
775 },
776 AnalysisStep {
777 step_id: "step_default_cht_thermal".to_string(),
778 kind: AnalysisStepKind::Thermal,
779 },
780 ],
781 ),
782 AnalysisCreateModelProfile::FsiCoupled => (
783 BoundaryCondition {
784 bc_id: "bc_default_fixed".to_string(),
785 region_id: fixed_region_id,
786 kind: BoundaryConditionKind::Fixed,
787 },
788 LoadCase {
789 load_id: "load_default_fsi_seed".to_string(),
790 region_id: load_region_id,
791 kind: LoadKind::Force {
792 fx: 0.0,
793 fy: -450.0,
794 fz: 0.0,
795 },
796 },
797 vec![
798 AnalysisStep {
799 step_id: "step_default_fsi_structure".to_string(),
800 kind: AnalysisStepKind::Transient,
801 },
802 AnalysisStep {
803 step_id: "step_default_fsi_flow".to_string(),
804 kind: AnalysisStepKind::Cfd,
805 },
806 ],
807 ),
808 };
809
810 let cfd = match intent.profile {
811 AnalysisCreateModelProfile::CfdSteadyState => Some(runmat_analysis_core::CfdDomain {
812 enabled: true,
813 solve_family: runmat_analysis_core::CfdSolveFamily::SteadyState,
814 reference_density_kg_per_m3: 1.225,
815 dynamic_viscosity_pa_s: 1.81e-5,
816 inlet_velocity_m_per_s: 5.0,
817 turbulence_intensity: 0.05,
818 time_profile: Vec::new(),
819 }),
820 AnalysisCreateModelProfile::CfdTransient => Some(runmat_analysis_core::CfdDomain {
821 enabled: true,
822 solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
823 reference_density_kg_per_m3: 1.225,
824 dynamic_viscosity_pa_s: 1.81e-5,
825 inlet_velocity_m_per_s: 5.0,
826 turbulence_intensity: 0.08,
827 time_profile: vec![
828 runmat_analysis_core::CfdTimeProfilePoint {
829 normalized_time: 0.0,
830 inlet_scale: 0.5,
831 },
832 runmat_analysis_core::CfdTimeProfilePoint {
833 normalized_time: 1.0,
834 inlet_scale: 1.0,
835 },
836 ],
837 }),
838 AnalysisCreateModelProfile::ChtCoupled => Some(runmat_analysis_core::CfdDomain {
839 enabled: true,
840 solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
841 reference_density_kg_per_m3: 1.225,
842 dynamic_viscosity_pa_s: 1.81e-5,
843 inlet_velocity_m_per_s: 4.5,
844 turbulence_intensity: 0.07,
845 time_profile: vec![
846 runmat_analysis_core::CfdTimeProfilePoint {
847 normalized_time: 0.0,
848 inlet_scale: 0.7,
849 },
850 runmat_analysis_core::CfdTimeProfilePoint {
851 normalized_time: 1.0,
852 inlet_scale: 1.0,
853 },
854 ],
855 }),
856 AnalysisCreateModelProfile::FsiCoupled => Some(runmat_analysis_core::CfdDomain {
857 enabled: true,
858 solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
859 reference_density_kg_per_m3: 1.225,
860 dynamic_viscosity_pa_s: 1.81e-5,
861 inlet_velocity_m_per_s: 4.0,
862 turbulence_intensity: 0.06,
863 time_profile: vec![
864 runmat_analysis_core::CfdTimeProfilePoint {
865 normalized_time: 0.0,
866 inlet_scale: 0.6,
867 },
868 runmat_analysis_core::CfdTimeProfilePoint {
869 normalized_time: 1.0,
870 inlet_scale: 1.0,
871 },
872 ],
873 }),
874 _ => None,
875 };
876 let electromagnetic = match intent.profile {
877 AnalysisCreateModelProfile::ElectromagneticStatic => {
878 Some(runmat_analysis_core::ElectromagneticDomain {
879 enabled: true,
880 reference_frequency_hz: 60.0,
881 applied_current_a: 100.0,
882 })
883 }
884 _ => None,
885 };
886 let thermo_mechanical = match intent.profile {
887 AnalysisCreateModelProfile::ChtCoupled => {
888 Some(runmat_analysis_core::ThermoMechanicalDomain {
889 enabled: true,
890 reference_temperature_k: 293.15,
891 applied_temperature_delta_k: 35.0,
892 field_artifact_id: None,
893 field_source: None,
894 region_temperature_deltas: Vec::new(),
895 time_profile: vec![
896 runmat_analysis_core::ThermoTimeProfilePoint {
897 normalized_time: 0.0,
898 scale: 0.6,
899 },
900 runmat_analysis_core::ThermoTimeProfilePoint {
901 normalized_time: 1.0,
902 scale: 1.0,
903 },
904 ],
905 })
906 }
907 _ => None,
908 };
909
910 let mut boundary_conditions = vec![default_bc];
911 if matches!(
912 intent.profile,
913 AnalysisCreateModelProfile::CfdSteadyState
914 | AnalysisCreateModelProfile::CfdTransient
915 | AnalysisCreateModelProfile::ChtCoupled
916 | AnalysisCreateModelProfile::FsiCoupled
917 ) {
918 let default_cfd_inlet_velocity = cfd
919 .as_ref()
920 .map(|domain| domain.inlet_velocity_m_per_s)
921 .unwrap_or(0.0);
922 boundary_conditions.extend([
923 BoundaryCondition {
924 bc_id: "bc_default_cfd_inlet".to_string(),
925 region_id: "inlet".to_string(),
926 kind: BoundaryConditionKind::CfdInletVelocity {
927 velocity_m_per_s: default_cfd_inlet_velocity,
928 },
929 },
930 BoundaryCondition {
931 bc_id: "bc_default_cfd_outlet".to_string(),
932 region_id: "outlet".to_string(),
933 kind: BoundaryConditionKind::CfdOutletPressure { pressure_pa: 0.0 },
934 },
935 BoundaryCondition {
936 bc_id: "bc_default_cfd_wall_upper".to_string(),
937 region_id: "wall_upper".to_string(),
938 kind: BoundaryConditionKind::CfdNoSlipWall,
939 },
940 BoundaryCondition {
941 bc_id: "bc_default_cfd_wall_lower".to_string(),
942 region_id: "wall_lower".to_string(),
943 kind: BoundaryConditionKind::CfdNoSlipWall,
944 },
945 ]);
946 }
947
948 let model = AnalysisModel {
949 model_id: AnalysisModelId(intent.model_id),
950 geometry_id: geometry.geometry_id.clone(),
951 geometry_revision: geometry.revision,
952 units: geometry.units,
953 frame: ReferenceFrame::Global,
954 materials: inferred_materials,
955 material_assignments: inferred_assignments,
956 structural: None,
957 thermo_mechanical,
958 electro_thermal: None,
959 electromagnetic,
960 cfd,
961 interfaces: Vec::new(),
962 boundary_conditions,
963 loads: vec![default_load],
964 steps: default_steps,
965 };
966
967 validate_model_against_geometry(&model, geometry.units, &ReferenceFrame::Global).map_err(
968 |error| {
969 operation_error(
970 ANALYSIS_CREATE_MODEL_OPERATION,
971 ANALYSIS_CREATE_MODEL_OP_VERSION,
972 &context,
973 OperationErrorSpec {
974 error_code: "RM.FEA.CREATE_MODEL.INVALID",
975 error_type: OperationErrorType::Validation,
976 retryable: false,
977 severity: OperationErrorSeverity::Error,
978 },
979 format!("created FEA model failed validation: {error:?}"),
980 BTreeMap::from([
981 ("analysis_model_id".to_string(), model.model_id.0.clone()),
982 ("geometry_id".to_string(), geometry.geometry_id.clone()),
983 ]),
984 )
985 },
986 )?;
987
988 Ok(OperationEnvelope::new(
989 ANALYSIS_CREATE_MODEL_OPERATION,
990 ANALYSIS_CREATE_MODEL_OP_VERSION,
991 &context,
992 model,
993 ))
994}
995
996pub fn analysis_validate_study_op(
997 spec: &AnalysisStudySpec,
998 context: OperationContext,
999) -> Result<OperationEnvelope<AnalysisStudyValidateResult>, OperationErrorEnvelope> {
1000 let issue_codes = validate_study_issue_codes(spec);
1001 let issues: Vec<AnalysisStudyIssue> = issue_codes
1002 .iter()
1003 .map(|code| AnalysisStudyIssue {
1004 code: code.clone(),
1005 message: study_issue_message(code).to_string(),
1006 })
1007 .collect();
1008 let study_fingerprint = study_fingerprint(spec);
1009 let evidence_artifact_path = persist_study_evidence(
1010 &study_fingerprint,
1011 "validate",
1012 serde_json::json!({
1013 "schema_version": "fea_study_validate_artifact/v1",
1014 "study_id": spec.study_id.clone(),
1015 "study_fingerprint": study_fingerprint.clone(),
1016 "valid": issue_codes.is_empty(),
1017 "issue_codes": issue_codes.clone(),
1018 "issues": issues.clone(),
1019 "electromagnetic_run_options": spec.electromagnetic_run_options.clone(),
1020 }),
1021 )
1022 .map_err(|err| {
1023 operation_error(
1024 ANALYSIS_VALIDATE_STUDY_OPERATION,
1025 ANALYSIS_VALIDATE_STUDY_OP_VERSION,
1026 &context,
1027 OperationErrorSpec {
1028 error_code: "RM.FEA.VALIDATE_STUDY.ARTIFACT_STORE_FAILED",
1029 error_type: OperationErrorType::Internal,
1030 retryable: true,
1031 severity: OperationErrorSeverity::Error,
1032 },
1033 format!("failed to persist study validation evidence artifact: {err}"),
1034 BTreeMap::from([("study_id".to_string(), spec.study_id.clone())]),
1035 )
1036 })?;
1037 Ok(OperationEnvelope::new(
1038 ANALYSIS_VALIDATE_STUDY_OPERATION,
1039 ANALYSIS_VALIDATE_STUDY_OP_VERSION,
1040 &context,
1041 AnalysisStudyValidateResult {
1042 valid: issue_codes.is_empty(),
1043 issue_codes,
1044 issues,
1045 evidence_artifact_path,
1046 },
1047 ))
1048}
1049
1050pub fn analysis_plan_study_op(
1051 spec: &AnalysisStudySpec,
1052 context: OperationContext,
1053) -> Result<OperationEnvelope<AnalysisStudyPlanData>, OperationErrorEnvelope> {
1054 let issue_codes = validate_study_issue_codes(spec);
1055 if !issue_codes.is_empty() {
1056 return Err(operation_error(
1057 ANALYSIS_PLAN_STUDY_OPERATION,
1058 ANALYSIS_PLAN_STUDY_OP_VERSION,
1059 &context,
1060 OperationErrorSpec {
1061 error_code: "RM.FEA.PLAN_STUDY.INVALID_SPEC",
1062 error_type: OperationErrorType::Validation,
1063 retryable: false,
1064 severity: OperationErrorSeverity::Error,
1065 },
1066 "study spec is invalid; run fea.validate for issue details",
1067 BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1068 ));
1069 }
1070
1071 let study_fingerprint = study_fingerprint(spec);
1072 let run_operation = run_operation_for_kind(spec.run_kind).to_string();
1073 let run_op_version = run_operation_version_for_kind(spec.run_kind).to_string();
1074 let operation_sequence = study_operation_sequence(spec, &run_op_version);
1075 let evidence_artifact_path = persist_study_evidence(
1076 &study_fingerprint,
1077 "plan",
1078 serde_json::json!({
1079 "schema_version": "fea_study_plan_artifact/v1",
1080 "study_id": spec.study_id.clone(),
1081 "model_id": spec.create_model_intent.model_id.clone(),
1082 "run_kind": spec.run_kind,
1083 "backend": spec.backend,
1084 "run_options": study_run_options_json(spec),
1085 "study_fingerprint": study_fingerprint.clone(),
1086 "operation_sequence": operation_sequence.clone(),
1087 "run_operation": run_operation.clone(),
1088 "run_op_version": run_op_version.clone(),
1089 }),
1090 )
1091 .map_err(|err| {
1092 operation_error(
1093 ANALYSIS_PLAN_STUDY_OPERATION,
1094 ANALYSIS_PLAN_STUDY_OP_VERSION,
1095 &context,
1096 OperationErrorSpec {
1097 error_code: "RM.FEA.PLAN_STUDY.ARTIFACT_STORE_FAILED",
1098 error_type: OperationErrorType::Internal,
1099 retryable: true,
1100 severity: OperationErrorSeverity::Error,
1101 },
1102 format!("failed to persist study plan evidence artifact: {err}"),
1103 BTreeMap::from([("study_id".to_string(), spec.study_id.clone())]),
1104 )
1105 })?;
1106 Ok(OperationEnvelope::new(
1107 ANALYSIS_PLAN_STUDY_OPERATION,
1108 ANALYSIS_PLAN_STUDY_OP_VERSION,
1109 &context,
1110 AnalysisStudyPlanData {
1111 study_id: spec.study_id.clone(),
1112 model_id: spec.create_model_intent.model_id.clone(),
1113 run_kind: spec.run_kind,
1114 backend: spec.backend,
1115 electromagnetic_run_options: spec.electromagnetic_run_options.clone(),
1116 run_options: study_run_options_json(spec),
1117 operation_sequence,
1118 run_operation,
1119 run_op_version,
1120 study_fingerprint,
1121 evidence_artifact_path,
1122 },
1123 ))
1124}
1125
1126pub fn analysis_run_study_op(
1127 spec: &AnalysisStudySpec,
1128 context: OperationContext,
1129) -> Result<OperationEnvelope<AnalysisStudyRunData>, OperationErrorEnvelope> {
1130 let issue_codes = validate_study_issue_codes(spec);
1131 if !issue_codes.is_empty() {
1132 return Err(operation_error(
1133 ANALYSIS_RUN_STUDY_OPERATION,
1134 ANALYSIS_RUN_STUDY_OP_VERSION,
1135 &context,
1136 OperationErrorSpec {
1137 error_code: "RM.FEA.RUN_STUDY.INVALID_SPEC",
1138 error_type: OperationErrorType::Validation,
1139 retryable: false,
1140 severity: OperationErrorSeverity::Error,
1141 },
1142 "study spec is invalid; run fea.validate for issue details",
1143 BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1144 ));
1145 }
1146
1147 let study_fingerprint = study_fingerprint(spec);
1148 let run_operation = run_operation_for_kind(spec.run_kind).to_string();
1149 let run_op_version = run_operation_version_for_kind(spec.run_kind).to_string();
1150 let operation_sequence = study_operation_sequence(spec, &run_op_version);
1151
1152 let study_prep = crate::geometry::geometry_prep_for_analysis_op(
1153 &spec.geometry,
1154 crate::geometry::GeometryPrepForAnalysisSpec::default(),
1155 context.clone(),
1156 )?
1157 .data;
1158 let study_prep_artifact_id = study_prep.prep_artifact_id.clone();
1159 let mut create_model_intent = spec.create_model_intent.clone();
1160 create_model_intent.prep_context = Some(AnalysisCreateModelPrepContext {
1161 source_geometry_id: spec.geometry.geometry_id.clone(),
1162 source_geometry_revision: spec.geometry.revision,
1163 region_mappings: study_prep.prep.region_mappings.clone(),
1164 });
1165
1166 let model = match &spec.model {
1167 Some(model) => model.clone(),
1168 None => {
1169 analysis_create_model_op(&spec.geometry, create_model_intent.clone(), context.clone())?
1170 .data
1171 }
1172 };
1173 analysis_validate(
1174 &model,
1175 spec.geometry.units,
1176 &ReferenceFrame::Global,
1177 context.clone(),
1178 )?;
1179 let (run_envelope, resolved_run_options, resolved_electromagnetic_run_options) = match spec
1180 .run_kind
1181 {
1182 AnalysisRunKind::LinearStatic => {
1183 let mut options = spec.linear_static_run_options.clone().unwrap_or_default();
1184 attach_prep_artifact_to_run_options(&mut options, &study_prep_artifact_id);
1185 let run = analysis_run_linear_static_with_options(
1186 &model,
1187 spec.backend,
1188 options.clone(),
1189 context.clone(),
1190 )?;
1191 Ok((run, run_options_to_json(&options), None))
1192 }
1193 AnalysisRunKind::Modal => {
1194 let mut options = spec.modal_run_options.clone().unwrap_or_default();
1195 attach_prep_artifact_to_modal_options(&mut options, &study_prep_artifact_id);
1196 let run = analysis_run_modal_with_options_op(
1197 &model,
1198 spec.backend,
1199 options.clone(),
1200 context.clone(),
1201 )?;
1202 Ok((run, run_options_to_json(&options), None))
1203 }
1204 AnalysisRunKind::Acoustic => {
1205 let mut options = spec.acoustic_run_options.clone().unwrap_or_default();
1206 attach_prep_artifact_to_acoustic_options(&mut options, &study_prep_artifact_id);
1207 let run = analysis_run_acoustic_with_options_op(
1208 &model,
1209 spec.backend,
1210 options.clone(),
1211 context.clone(),
1212 )?;
1213 Ok((run, run_options_to_json(&options), None))
1214 }
1215 AnalysisRunKind::Thermal => {
1216 let mut options = spec.thermal_run_options.clone().unwrap_or_default();
1217 attach_prep_artifact_to_thermal_options(&mut options, &study_prep_artifact_id);
1218 let run = analysis_run_thermal_with_options_op(
1219 &model,
1220 spec.backend,
1221 options.clone(),
1222 context.clone(),
1223 )?;
1224 Ok((run, run_options_to_json(&options), None))
1225 }
1226 AnalysisRunKind::Transient => {
1227 let mut options = spec.transient_run_options.clone().unwrap_or_default();
1228 attach_prep_artifact_to_transient_options(&mut options, &study_prep_artifact_id);
1229 let run = analysis_run_transient_with_options_op(
1230 &model,
1231 spec.backend,
1232 options.clone(),
1233 context.clone(),
1234 )?;
1235 Ok((run, run_options_to_json(&options), None))
1236 }
1237 AnalysisRunKind::Cfd => {
1238 let mut options = spec.cfd_run_options.clone().unwrap_or_default();
1239 attach_prep_artifact_to_cfd_options(&mut options, &study_prep_artifact_id);
1240 let run = analysis_run_cfd_with_options_op(
1241 &model,
1242 spec.backend,
1243 options.clone(),
1244 context.clone(),
1245 )?;
1246 Ok((run, run_options_to_json(&options), None))
1247 }
1248 AnalysisRunKind::Cht => {
1249 let mut options = spec.cht_run_options.clone().unwrap_or_default();
1250 attach_prep_artifact_to_cht_options(&mut options, &study_prep_artifact_id);
1251 let run = analysis_run_cht_with_options_op(
1252 &model,
1253 spec.backend,
1254 options.clone(),
1255 context.clone(),
1256 )?;
1257 Ok((run, run_options_to_json(&options), None))
1258 }
1259 AnalysisRunKind::Fsi => {
1260 let mut options = spec.fsi_run_options.clone().unwrap_or_default();
1261 attach_prep_artifact_to_fsi_options(&mut options, &study_prep_artifact_id);
1262 let run = analysis_run_fsi_with_options_op(
1263 &model,
1264 spec.backend,
1265 options.clone(),
1266 context.clone(),
1267 )?;
1268 Ok((run, run_options_to_json(&options), None))
1269 }
1270 AnalysisRunKind::Nonlinear => {
1271 let mut options = spec.nonlinear_run_options.clone().unwrap_or_default();
1272 attach_prep_artifact_to_nonlinear_options(&mut options, &study_prep_artifact_id);
1273 let run = analysis_run_nonlinear_with_options_op(
1274 &model,
1275 spec.backend,
1276 options.clone(),
1277 context.clone(),
1278 )?;
1279 Ok((run, run_options_to_json(&options), None))
1280 }
1281 AnalysisRunKind::Electromagnetic => {
1282 let mut options = spec.electromagnetic_run_options.clone().unwrap_or_default();
1283 attach_prep_artifact_to_electromagnetic_options(&mut options, &study_prep_artifact_id);
1284 let run = analysis_run_electromagnetic_with_options_op(
1285 &model,
1286 spec.backend,
1287 options.clone(),
1288 context.clone(),
1289 )?;
1290 Ok((run, run_options_to_json(&options), Some(options)))
1291 }
1292 }?;
1293
1294 let evidence_artifact_path = persist_study_evidence(
1295 &study_fingerprint,
1296 "run",
1297 serde_json::json!({
1298 "schema_version": "fea_study_run_artifact/v1",
1299 "study_id": spec.study_id.clone(),
1300 "model_id": model.model_id.0.clone(),
1301 "run_kind": spec.run_kind,
1302 "backend": spec.backend,
1303 "prep_artifact_id": study_prep_artifact_id.clone(),
1304 "run_options": resolved_run_options.clone(),
1305 "resolved_electromagnetic_run_options": resolved_electromagnetic_run_options.clone(),
1306 "study_fingerprint": study_fingerprint.clone(),
1307 "operation_sequence": operation_sequence.clone(),
1308 "run_operation": run_operation.clone(),
1309 "run_op_version": run_op_version.clone(),
1310 "run_id": run_envelope.data.run_id.clone(),
1311 "run_status": run_envelope.data.run_status,
1312 "publishable": run_envelope.data.publishable,
1313 "solver_convergence": run_envelope.data.solver_convergence,
1314 "result_quality": run_envelope.data.result_quality,
1315 "quality_reasons": run_envelope.data.quality_reasons.clone(),
1316 "provenance": run_envelope.data.provenance.clone(),
1317 }),
1318 )
1319 .map_err(|err| {
1320 operation_error(
1321 ANALYSIS_RUN_STUDY_OPERATION,
1322 ANALYSIS_RUN_STUDY_OP_VERSION,
1323 &context,
1324 OperationErrorSpec {
1325 error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
1326 error_type: OperationErrorType::Internal,
1327 retryable: true,
1328 severity: OperationErrorSeverity::Error,
1329 },
1330 format!("failed to persist study run evidence artifact: {err}"),
1331 BTreeMap::from([
1332 ("study_id".to_string(), spec.study_id.clone()),
1333 ("run_id".to_string(), run_envelope.data.run_id.clone()),
1334 ]),
1335 )
1336 })?;
1337
1338 Ok(OperationEnvelope::new(
1339 ANALYSIS_RUN_STUDY_OPERATION,
1340 ANALYSIS_RUN_STUDY_OP_VERSION,
1341 &context,
1342 AnalysisStudyRunData {
1343 study_id: spec.study_id.clone(),
1344 model_id: model.model_id.0.clone(),
1345 run_kind: spec.run_kind,
1346 backend: spec.backend,
1347 electromagnetic_run_options: resolved_electromagnetic_run_options,
1348 prep_artifact_id: Some(study_prep_artifact_id),
1349 run_options: resolved_run_options,
1350 study_fingerprint,
1351 operation_sequence,
1352 run_operation,
1353 run_op_version,
1354 run_id: run_envelope.data.run_id,
1355 run_status: run_envelope.data.run_status,
1356 publishable: run_envelope.data.publishable,
1357 solver_convergence: run_envelope.data.solver_convergence,
1358 result_quality: run_envelope.data.result_quality,
1359 quality_reasons: run_envelope.data.quality_reasons,
1360 provenance: run_envelope.data.provenance,
1361 evidence_artifact_path,
1362 },
1363 ))
1364}
1365
1366pub fn analysis_plan_study_sweep_op(
1367 spec: &AnalysisStudySweepSpec,
1368 context: OperationContext,
1369) -> Result<OperationEnvelope<AnalysisStudySweepPlanData>, OperationErrorEnvelope> {
1370 let mut issue_codes = Vec::new();
1371 if spec.sweep_id.trim().is_empty() {
1372 issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1373 }
1374 if spec.studies.is_empty() {
1375 issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1376 }
1377 if !issue_codes.is_empty() {
1378 return Err(operation_error(
1379 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1380 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1381 &context,
1382 OperationErrorSpec {
1383 error_code: "RM.FEA.PLAN_STUDY_SWEEP.INVALID_SPEC",
1384 error_type: OperationErrorType::Validation,
1385 retryable: false,
1386 severity: OperationErrorSeverity::Error,
1387 },
1388 "study sweep spec is invalid",
1389 BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1390 ));
1391 }
1392
1393 let mut plan_entries = Vec::with_capacity(spec.studies.len());
1394 let mut failure_entries = Vec::new();
1395 for (index, study) in spec.studies.iter().enumerate() {
1396 let planned = match analysis_plan_study_op(study, context.clone()) {
1397 Ok(plan) => plan,
1398 Err(err) => {
1399 if spec.fail_fast {
1400 return Err(operation_error(
1401 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1402 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1403 &context,
1404 OperationErrorSpec {
1405 error_code: "RM.FEA.PLAN_STUDY_SWEEP.STUDY_FAILED",
1406 error_type: OperationErrorType::Validation,
1407 retryable: false,
1408 severity: OperationErrorSeverity::Error,
1409 },
1410 format!(
1411 "study sweep planning failed at index {} for study_id {}: {}",
1412 index, study.study_id, err.error_code
1413 ),
1414 BTreeMap::from([
1415 ("sweep_id".to_string(), spec.sweep_id.clone()),
1416 ("study_id".to_string(), study.study_id.clone()),
1417 ("study_index".to_string(), index.to_string()),
1418 ("cause_error_code".to_string(), err.error_code),
1419 ]),
1420 ));
1421 }
1422 failure_entries.push(AnalysisStudySweepFailureEntry {
1423 study_id: study.study_id.clone(),
1424 study_index: index,
1425 error_code: err.error_code,
1426 message: err.message,
1427 });
1428 continue;
1429 }
1430 };
1431 plan_entries.push(AnalysisStudySweepPlanEntry {
1432 study_id: planned.data.study_id,
1433 model_id: planned.data.model_id,
1434 run_kind: planned.data.run_kind,
1435 backend: planned.data.backend,
1436 electromagnetic_run_options: planned.data.electromagnetic_run_options,
1437 run_options: planned.data.run_options,
1438 operation_sequence: planned.data.operation_sequence,
1439 run_operation: planned.data.run_operation,
1440 run_op_version: planned.data.run_op_version,
1441 study_fingerprint: planned.data.study_fingerprint,
1442 });
1443 }
1444
1445 let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1446 let evidence_path = study_evidence_root()
1447 .join("sweeps")
1448 .join(sanitized_sweep_id)
1449 .join("plan.json");
1450 if let Some(parent) = evidence_path.parent() {
1451 fs_create_dir_all(parent).map_err(|err| {
1452 operation_error(
1453 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1454 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1455 &context,
1456 OperationErrorSpec {
1457 error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1458 error_type: OperationErrorType::Internal,
1459 retryable: true,
1460 severity: OperationErrorSeverity::Error,
1461 },
1462 format!("failed to create study sweep planning evidence directory: {err}"),
1463 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1464 )
1465 })?;
1466 }
1467 let payload = serde_json::json!({
1468 "schema_version": "fea_study_sweep_plan_artifact/v1",
1469 "sweep_id": spec.sweep_id.clone(),
1470 "study_count": spec.studies.len(),
1471 "planned_count": plan_entries.len(),
1472 "failed_count": failure_entries.len(),
1473 "failure_entries": failure_entries.clone(),
1474 "plan_entries": plan_entries.clone(),
1475 });
1476 let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1477 operation_error(
1478 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1479 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1480 &context,
1481 OperationErrorSpec {
1482 error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1483 error_type: OperationErrorType::Internal,
1484 retryable: true,
1485 severity: OperationErrorSeverity::Error,
1486 },
1487 format!("failed to encode study sweep planning evidence payload: {err}"),
1488 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1489 )
1490 })?;
1491 atomic_write_bytes(&evidence_path, &payload_bytes).map_err(|err| {
1492 operation_error(
1493 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1494 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1495 &context,
1496 OperationErrorSpec {
1497 error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1498 error_type: OperationErrorType::Internal,
1499 retryable: true,
1500 severity: OperationErrorSeverity::Error,
1501 },
1502 err,
1503 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1504 )
1505 })?;
1506
1507 Ok(OperationEnvelope::new(
1508 ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1509 ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1510 &context,
1511 AnalysisStudySweepPlanData {
1512 sweep_id: spec.sweep_id.clone(),
1513 study_count: spec.studies.len(),
1514 planned_count: plan_entries.len(),
1515 failed_count: failure_entries.len(),
1516 failure_entries,
1517 plan_entries,
1518 evidence_artifact_path: evidence_path.display().to_string(),
1519 },
1520 ))
1521}
1522
1523pub fn analysis_validate_study_sweep_op(
1524 spec: &AnalysisStudySweepSpec,
1525 context: OperationContext,
1526) -> Result<OperationEnvelope<AnalysisStudySweepValidateData>, OperationErrorEnvelope> {
1527 let mut issue_codes = Vec::new();
1528 if spec.sweep_id.trim().is_empty() {
1529 issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1530 }
1531 if spec.studies.is_empty() {
1532 issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1533 }
1534
1535 let study_entries: Vec<AnalysisStudySweepValidateEntry> = spec
1536 .studies
1537 .iter()
1538 .map(|study| {
1539 let study_issue_codes = validate_study_issue_codes(study);
1540 let issues = study_issue_codes
1541 .iter()
1542 .map(|code| AnalysisStudyIssue {
1543 code: code.clone(),
1544 message: study_issue_message(code).to_string(),
1545 })
1546 .collect::<Vec<_>>();
1547 AnalysisStudySweepValidateEntry {
1548 study_id: study.study_id.clone(),
1549 valid: study_issue_codes.is_empty(),
1550 issue_codes: study_issue_codes,
1551 issues,
1552 }
1553 })
1554 .collect();
1555
1556 let valid = issue_codes.is_empty() && study_entries.iter().all(|entry| entry.valid);
1557 let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1558 let evidence_path = study_evidence_root()
1559 .join("sweeps")
1560 .join(sanitized_sweep_id)
1561 .join("validate.json");
1562 if let Some(parent) = evidence_path.parent() {
1563 fs_create_dir_all(parent).map_err(|err| {
1564 operation_error(
1565 ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1566 ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1567 &context,
1568 OperationErrorSpec {
1569 error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1570 error_type: OperationErrorType::Internal,
1571 retryable: true,
1572 severity: OperationErrorSeverity::Error,
1573 },
1574 format!("failed to create study sweep validation evidence directory: {err}"),
1575 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1576 )
1577 })?;
1578 }
1579 let payload = serde_json::json!({
1580 "schema_version": "fea_study_sweep_validate_artifact/v1",
1581 "sweep_id": spec.sweep_id.clone(),
1582 "valid": valid,
1583 "issue_codes": issue_codes.clone(),
1584 "study_entries": study_entries,
1585 });
1586 let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1587 operation_error(
1588 ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1589 ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1590 &context,
1591 OperationErrorSpec {
1592 error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1593 error_type: OperationErrorType::Internal,
1594 retryable: true,
1595 severity: OperationErrorSeverity::Error,
1596 },
1597 format!("failed to encode study sweep validation evidence payload: {err}"),
1598 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1599 )
1600 })?;
1601 atomic_write_bytes(&evidence_path, &payload_bytes).map_err(|err| {
1602 operation_error(
1603 ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1604 ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1605 &context,
1606 OperationErrorSpec {
1607 error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1608 error_type: OperationErrorType::Internal,
1609 retryable: true,
1610 severity: OperationErrorSeverity::Error,
1611 },
1612 err,
1613 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1614 )
1615 })?;
1616
1617 Ok(OperationEnvelope::new(
1618 ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1619 ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1620 &context,
1621 AnalysisStudySweepValidateData {
1622 sweep_id: spec.sweep_id.clone(),
1623 valid,
1624 issue_codes,
1625 study_entries,
1626 evidence_artifact_path: evidence_path.display().to_string(),
1627 },
1628 ))
1629}
1630
1631pub fn analysis_run_study_sweep_op(
1632 spec: &AnalysisStudySweepSpec,
1633 context: OperationContext,
1634) -> Result<OperationEnvelope<AnalysisStudySweepData>, OperationErrorEnvelope> {
1635 let mut issue_codes = Vec::new();
1636 if spec.sweep_id.trim().is_empty() {
1637 issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1638 }
1639 if spec.studies.is_empty() {
1640 issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1641 }
1642 if !issue_codes.is_empty() {
1643 return Err(operation_error(
1644 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1645 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1646 &context,
1647 OperationErrorSpec {
1648 error_code: "RM.FEA.RUN_STUDY_SWEEP.INVALID_SPEC",
1649 error_type: OperationErrorType::Validation,
1650 retryable: false,
1651 severity: OperationErrorSeverity::Error,
1652 },
1653 "study sweep spec is invalid",
1654 BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1655 ));
1656 }
1657
1658 let mut run_entries = Vec::with_capacity(spec.studies.len());
1659 let mut failure_entries = Vec::new();
1660 for (index, study) in spec.studies.iter().enumerate() {
1661 let run = match analysis_run_study_op(study, context.clone()) {
1662 Ok(run) => run,
1663 Err(err) => {
1664 if spec.fail_fast {
1665 return Err(operation_error(
1666 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1667 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1668 &context,
1669 OperationErrorSpec {
1670 error_code: "RM.FEA.RUN_STUDY_SWEEP.STUDY_FAILED",
1671 error_type: OperationErrorType::Validation,
1672 retryable: false,
1673 severity: OperationErrorSeverity::Error,
1674 },
1675 format!(
1676 "study sweep failed at index {} for study_id {}: {}",
1677 index, study.study_id, err.error_code
1678 ),
1679 BTreeMap::from([
1680 ("sweep_id".to_string(), spec.sweep_id.clone()),
1681 ("study_id".to_string(), study.study_id.clone()),
1682 ("study_index".to_string(), index.to_string()),
1683 ("cause_error_code".to_string(), err.error_code),
1684 ]),
1685 ));
1686 }
1687 failure_entries.push(AnalysisStudySweepFailureEntry {
1688 study_id: study.study_id.clone(),
1689 study_index: index,
1690 error_code: err.error_code,
1691 message: err.message,
1692 });
1693 continue;
1694 }
1695 };
1696 run_entries.push(AnalysisStudySweepRunEntry {
1697 study_id: run.data.study_id,
1698 run_kind: run.data.run_kind,
1699 run_id: run.data.run_id,
1700 run_status: run.data.run_status,
1701 publishable: run.data.publishable,
1702 run_operation: run.data.run_operation,
1703 run_op_version: run.data.run_op_version,
1704 });
1705 }
1706
1707 let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1708 let evidence_root = study_evidence_root()
1709 .join("sweeps")
1710 .join(sanitized_sweep_id)
1711 .join("run.json");
1712 if let Some(parent) = evidence_root.parent() {
1713 fs_create_dir_all(parent).map_err(|err| {
1714 operation_error(
1715 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1716 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1717 &context,
1718 OperationErrorSpec {
1719 error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1720 error_type: OperationErrorType::Internal,
1721 retryable: true,
1722 severity: OperationErrorSeverity::Error,
1723 },
1724 format!("failed to create study sweep evidence directory: {err}"),
1725 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1726 )
1727 })?;
1728 }
1729 let payload = serde_json::json!({
1730 "schema_version": "fea_study_sweep_run_artifact/v1",
1731 "sweep_id": spec.sweep_id.clone(),
1732 "fail_fast": spec.fail_fast,
1733 "study_count": spec.studies.len(),
1734 "success_count": run_entries.len(),
1735 "failed_count": failure_entries.len(),
1736 "failure_entries": failure_entries.clone(),
1737 "run_entries": run_entries.clone(),
1738 });
1739 let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1740 operation_error(
1741 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1742 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1743 &context,
1744 OperationErrorSpec {
1745 error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1746 error_type: OperationErrorType::Internal,
1747 retryable: true,
1748 severity: OperationErrorSeverity::Error,
1749 },
1750 format!("failed to encode study sweep evidence payload: {err}"),
1751 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1752 )
1753 })?;
1754 atomic_write_bytes(&evidence_root, &payload_bytes).map_err(|err| {
1755 operation_error(
1756 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1757 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1758 &context,
1759 OperationErrorSpec {
1760 error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1761 error_type: OperationErrorType::Internal,
1762 retryable: true,
1763 severity: OperationErrorSeverity::Error,
1764 },
1765 err,
1766 BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1767 )
1768 })?;
1769
1770 let evidence_artifact_path = evidence_root.display().to_string();
1771 Ok(OperationEnvelope::new(
1772 ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1773 ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1774 &context,
1775 AnalysisStudySweepData {
1776 sweep_id: spec.sweep_id.clone(),
1777 study_count: spec.studies.len(),
1778 success_count: run_entries.len(),
1779 failed_count: failure_entries.len(),
1780 failure_entries,
1781 run_entries,
1782 evidence_artifact_path,
1783 },
1784 ))
1785}
1786
1787pub fn analysis_validate(
1788 model: &AnalysisModel,
1789 geometry_units: UnitSystem,
1790 geometry_frame: &ReferenceFrame,
1791 context: OperationContext,
1792) -> Result<OperationEnvelope<AnalysisValidateResult>, OperationErrorEnvelope> {
1793 validate_model_against_geometry(model, geometry_units, geometry_frame)
1794 .map_err(|err| map_validate_error(err, model, &context))?;
1795
1796 Ok(OperationEnvelope::new(
1797 ANALYSIS_VALIDATE_OPERATION,
1798 ANALYSIS_VALIDATE_OP_VERSION,
1799 &context,
1800 AnalysisValidateResult { valid: true },
1801 ))
1802}
1803
1804pub fn analysis_run_linear_static_op(
1805 model: &AnalysisModel,
1806 backend: ComputeBackend,
1807 context: OperationContext,
1808) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1809 analysis_run_linear_static_with_options(model, backend, AnalysisRunOptions::default(), context)
1810}
1811
1812pub fn analysis_run_modal_op(
1813 model: &AnalysisModel,
1814 backend: ComputeBackend,
1815 context: OperationContext,
1816) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1817 analysis_run_modal_with_options_op(model, backend, AnalysisModalRunOptions::default(), context)
1818}
1819
1820pub fn analysis_run_acoustic_op(
1821 model: &AnalysisModel,
1822 backend: ComputeBackend,
1823 context: OperationContext,
1824) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1825 analysis_run_acoustic_with_options_op(
1826 model,
1827 backend,
1828 AnalysisAcousticRunOptions::default(),
1829 context,
1830 )
1831}
1832
1833pub fn analysis_run_modal_with_options_op(
1834 model: &AnalysisModel,
1835 backend: ComputeBackend,
1836 options: AnalysisModalRunOptions,
1837 context: OperationContext,
1838) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1839 let _solver_context = install_fea_solver_context();
1840 let has_modal_step = model
1841 .steps
1842 .iter()
1843 .any(|step| step.kind == AnalysisStepKind::Modal);
1844 if !has_modal_step {
1845 return Err(operation_error(
1846 ANALYSIS_RUN_MODAL_OPERATION,
1847 ANALYSIS_RUN_MODAL_OP_VERSION,
1848 &context,
1849 OperationErrorSpec {
1850 error_code: "RM.FEA.RUN_MODAL.INVALID_MODEL",
1851 error_type: OperationErrorType::Validation,
1852 retryable: false,
1853 severity: OperationErrorSeverity::Error,
1854 },
1855 "FEA model must include at least one modal step for fea.run_modal",
1856 BTreeMap::from([
1857 ("analysis_model_id".to_string(), model.model_id.0.clone()),
1858 ("geometry_id".to_string(), model.geometry_id.clone()),
1859 ]),
1860 ));
1861 }
1862
1863 if options.mode_count == 0 {
1864 return Err(operation_error(
1865 ANALYSIS_RUN_MODAL_OPERATION,
1866 ANALYSIS_RUN_MODAL_OP_VERSION,
1867 &context,
1868 OperationErrorSpec {
1869 error_code: "RM.FEA.RUN_MODAL.INVALID_OPTIONS",
1870 error_type: OperationErrorType::Input,
1871 retryable: false,
1872 severity: OperationErrorSeverity::Error,
1873 },
1874 "fea.run_modal options require mode_count greater than zero",
1875 BTreeMap::from([("mode_count".to_string(), options.mode_count.to_string())]),
1876 ));
1877 }
1878
1879 let thermo_options = resolve_thermo_coupling_options(
1880 model,
1881 model_thermo_coupling_options(model),
1882 ANALYSIS_RUN_MODAL_OPERATION,
1883 ANALYSIS_RUN_MODAL_OP_VERSION,
1884 &context,
1885 )?;
1886 if let Some(thermo_options) = thermo_options.as_ref() {
1887 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
1888 return Err(operation_error(
1889 ANALYSIS_RUN_MODAL_OPERATION,
1890 ANALYSIS_RUN_MODAL_OP_VERSION,
1891 &context,
1892 OperationErrorSpec {
1893 error_code: "RM.FEA.RUN_MODAL.INVALID_OPTIONS",
1894 error_type: OperationErrorType::Input,
1895 retryable: false,
1896 severity: OperationErrorSeverity::Error,
1897 },
1898 detail,
1899 metadata,
1900 ));
1901 }
1902 }
1903 let electro_options = model_electro_coupling_options(model);
1904 if let Some(electro_options) = electro_options.as_ref() {
1905 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
1906 return Err(operation_error(
1907 ANALYSIS_RUN_MODAL_OPERATION,
1908 ANALYSIS_RUN_MODAL_OP_VERSION,
1909 &context,
1910 OperationErrorSpec {
1911 error_code: electro_thermal_invalid_options_error_code(
1912 ANALYSIS_RUN_MODAL_OPERATION,
1913 ),
1914 error_type: OperationErrorType::Input,
1915 retryable: false,
1916 severity: OperationErrorSeverity::Error,
1917 },
1918 detail,
1919 metadata,
1920 ));
1921 }
1922 }
1923
1924 let prep_context = resolve_run_prep_context(
1925 model,
1926 options.prep_artifact_id.as_deref(),
1927 options.prep_context.clone(),
1928 ANALYSIS_RUN_MODAL_OPERATION,
1929 ANALYSIS_RUN_MODAL_OP_VERSION,
1930 &context,
1931 )?;
1932
1933 let modal_run = run_modal_with_options(
1934 model,
1935 backend,
1936 ModalSolveOptions {
1937 mode_count: options.mode_count,
1938 prep_context: to_fea_prep_context(
1939 prep_context.as_ref(),
1940 options.prep_calibration_profile,
1941 ),
1942 thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
1943 electro_thermal_context: to_fea_electro_thermal_context(electro_options),
1944 },
1945 )
1946 .map_err(|err| {
1947 map_fea_run_error(
1948 ANALYSIS_RUN_MODAL_OPERATION,
1949 ANALYSIS_RUN_MODAL_OP_VERSION,
1950 "RM.FEA.RUN_MODAL.SOLVER_MODEL_INVALID",
1951 "RM.FEA.RUN_MODAL.CANCELLED",
1952 model,
1953 &context,
1954 err,
1955 )
1956 })?;
1957
1958 let mut run = modal_run.run;
1959 let mut fallback_events = Vec::new();
1960 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
1961 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
1962 fallback_events.push(
1963 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
1964 );
1965 }
1966 let solver_convergence = if run.diagnostics.iter().any(|item| {
1967 item.code == "FEA_MODAL_CONVERGENCE"
1968 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
1969 }) {
1970 QualityGate::Pass
1971 } else {
1972 QualityGate::Warn
1973 };
1974 let result_quality = if modal_run.eigenvalues_hz.is_empty() || modal_run.mode_shapes.is_empty()
1975 {
1976 QualityGate::Fail
1977 } else if modal_run
1978 .residual_norms
1979 .iter()
1980 .copied()
1981 .fold(0.0_f64, f64::max)
1982 > options.residual_warn_threshold
1983 {
1984 QualityGate::Warn
1985 } else {
1986 QualityGate::Pass
1987 };
1988 let modal_orthogonality_warn = run.diagnostics.iter().any(|item| {
1989 item.code == "FEA_MODAL_ORTHOGONALITY"
1990 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
1991 });
1992 let modal_separation_warn = run.diagnostics.iter().any(|item| {
1993 item.code == "FEA_MODAL_SEPARATION"
1994 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
1995 });
1996
1997 let mut quality_reasons = Vec::new();
1998 if solver_convergence == QualityGate::Warn {
1999 quality_reasons.push(QualityReason {
2000 code: QualityReasonCode::SolverNotConverged,
2001 detail: "modal solver convergence gate is warning".to_string(),
2002 });
2003 }
2004 if result_quality == QualityGate::Warn {
2005 quality_reasons.push(QualityReason {
2006 code: QualityReasonCode::ModalResidualExceeded,
2007 detail: format!(
2008 "modal residual exceeds threshold {}",
2009 options.residual_warn_threshold
2010 ),
2011 });
2012 }
2013 if modal_orthogonality_warn {
2014 quality_reasons.push(QualityReason {
2015 code: QualityReasonCode::ModalOrthogonalityExceeded,
2016 detail: "modal M-orthogonality off-diagonal threshold exceeded".to_string(),
2017 });
2018 }
2019 if modal_separation_warn {
2020 quality_reasons.push(QualityReason {
2021 code: QualityReasonCode::ModalSeparationLow,
2022 detail: "modal frequency separation threshold is low".to_string(),
2023 });
2024 }
2025 if fallback_events
2026 .iter()
2027 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
2028 {
2029 quality_reasons.push(QualityReason {
2030 code: QualityReasonCode::SolverBackendFallback,
2031 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
2032 });
2033 }
2034 if fallback_events.iter().any(|event| {
2035 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
2036 }) {
2037 quality_reasons.push(QualityReason {
2038 code: QualityReasonCode::FieldPromotionFallback,
2039 detail: "field promotion fell back to host-backed values".to_string(),
2040 });
2041 }
2042
2043 let frequency_basis = ModalFrequencyBasis::NativeEigenSolve;
2044
2045 let publishable = match options.quality_policy {
2046 QualityPolicy::Strict => {
2047 solver_convergence == QualityGate::Pass
2048 && result_quality == QualityGate::Pass
2049 && quality_reasons.is_empty()
2050 }
2051 QualityPolicy::Balanced => {
2052 solver_convergence == QualityGate::Pass
2053 && result_quality == QualityGate::Pass
2054 && !quality_reasons.iter().any(|r| {
2055 matches!(
2056 r.code,
2057 QualityReasonCode::ModalOrthogonalityExceeded
2058 | QualityReasonCode::ModalSeparationLow
2059 )
2060 })
2061 }
2062 QualityPolicy::Exploratory => {
2063 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
2064 }
2065 };
2066 let run_status = if publishable {
2067 RunStatus::Publishable
2068 } else if result_quality == QualityGate::Fail {
2069 RunStatus::Rejected
2070 } else {
2071 RunStatus::Degraded
2072 };
2073 let solver_backend = run.solver_backend.clone();
2074 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
2075 let solver_host_sync_count = run.solver_host_sync_count;
2076 let solver_method = run.solver_method.clone();
2077 let selected_preconditioner = run.preconditioner.clone();
2078
2079 let result = AnalysisRunResult {
2080 run_id: storage::next_run_id(),
2081 run,
2082 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
2083 modal_results: Some(ModalResultsData {
2084 modal_payload_version: "modal_results/v1".to_string(),
2085 eigenvalues_hz: modal_run.eigenvalues_hz,
2086 mode_shapes: modal_run.mode_shapes,
2087 residual_norms: modal_run.residual_norms,
2088 mode_units: ModalFrequencyUnits::Hz,
2089 frequency_basis,
2090 }),
2091 thermal_results: None,
2092 transient_results: None,
2093 nonlinear_results: None,
2094 electromagnetic_results: None,
2095 model_validity: QualityGate::Pass,
2096 solver_convergence,
2097 result_quality,
2098 run_status,
2099 publishable,
2100 quality_reasons,
2101 provenance: RunProvenance {
2102 backend,
2103 solver_backend,
2104 solver_device_apply_k_ratio,
2105 solver_host_sync_count,
2106 precision_mode: contracts::format_precision_mode(options.precision_mode),
2107 deterministic_mode: options.deterministic_mode,
2108 solver_method,
2109 preconditioner: selected_preconditioner,
2110 quality_policy: contracts::format_quality_policy(options.quality_policy),
2111 fallback_events,
2112 },
2113 };
2114
2115 if let Some(nonlinear) = result.nonlinear_results.as_ref() {
2116 let event = format!(
2117 "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={}",
2118 result.run_id,
2119 model.model_id.0,
2120 backend,
2121 result.run_status,
2122 result.publishable,
2123 nonlinear.failed_increments,
2124 nonlinear.iteration_counts.iter().copied().max().unwrap_or(0),
2125 nonlinear.line_search_backtracks,
2126 nonlinear.tangent_rebuild_count,
2127 nonlinear
2128 .residual_norms
2129 .iter()
2130 .copied()
2131 .reduce(f64::max)
2132 .unwrap_or(0.0),
2133 nonlinear
2134 .increment_norms
2135 .iter()
2136 .copied()
2137 .reduce(f64::max)
2138 .unwrap_or(0.0),
2139 nonlinear.max_line_search_backtracks_per_increment,
2140 result.quality_reasons.len()
2141 );
2142 if matches!(result.run_status, RunStatus::Degraded | RunStatus::Rejected) {
2143 tracing::warn!(target: "runmat_analysis", "{event}");
2144 } else {
2145 tracing::info!(target: "runmat_analysis", "{event}");
2146 }
2147 }
2148
2149 persist_fea_run_result_with_progress(
2150 ANALYSIS_RUN_MODAL_OPERATION,
2151 ANALYSIS_RUN_MODAL_OP_VERSION,
2152 "RM.FEA.RUN_MODAL.ARTIFACT_STORE_FAILED",
2153 &context,
2154 &result,
2155 )?;
2156
2157 Ok(OperationEnvelope::new(
2158 ANALYSIS_RUN_MODAL_OPERATION,
2159 ANALYSIS_RUN_MODAL_OP_VERSION,
2160 &context,
2161 result,
2162 ))
2163}
2164
2165pub fn analysis_run_acoustic_with_options_op(
2166 model: &AnalysisModel,
2167 backend: ComputeBackend,
2168 options: AnalysisAcousticRunOptions,
2169 context: OperationContext,
2170) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
2171 let _solver_context = install_fea_solver_context();
2172 let has_modal_step = model
2173 .steps
2174 .iter()
2175 .any(|step| step.kind == AnalysisStepKind::Modal);
2176 if !has_modal_step {
2177 return Err(operation_error(
2178 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2179 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2180 &context,
2181 OperationErrorSpec {
2182 error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_MODEL",
2183 error_type: OperationErrorType::Validation,
2184 retryable: false,
2185 severity: OperationErrorSeverity::Error,
2186 },
2187 "FEA model must include an acoustic harmonic step marker for fea.run_acoustic",
2188 BTreeMap::from([
2189 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2190 ("geometry_id".to_string(), model.geometry_id.clone()),
2191 ]),
2192 ));
2193 }
2194 if !model
2195 .materials
2196 .iter()
2197 .any(|material| material.acoustic.is_some())
2198 {
2199 return Err(operation_error(
2200 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2201 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2202 &context,
2203 OperationErrorSpec {
2204 error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_MATERIAL",
2205 error_type: OperationErrorType::Validation,
2206 retryable: false,
2207 severity: OperationErrorSeverity::Error,
2208 },
2209 "fea.run_acoustic requires at least one acoustic material with density and sound-speed data",
2210 BTreeMap::from([
2211 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2212 ("material_count".to_string(), model.materials.len().to_string()),
2213 ]),
2214 ));
2215 }
2216 reject_moment_loads_for_run_family(
2217 model,
2218 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2219 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2220 "RM.FEA.RUN_ACOUSTIC.INVALID_ACOUSTIC_SOURCE",
2221 "acoustic",
2222 &context,
2223 )?;
2224 if !model.loads.iter().any(|load| match &load.kind {
2225 LoadKind::Pressure { magnitude_pa } => magnitude_pa.is_finite() && magnitude_pa.abs() > 0.0,
2226 _ => false,
2227 }) {
2228 return Err(operation_error(
2229 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2230 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2231 &context,
2232 OperationErrorSpec {
2233 error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_SOURCE",
2234 error_type: OperationErrorType::Validation,
2235 retryable: false,
2236 severity: OperationErrorSeverity::Error,
2237 },
2238 "fea.run_acoustic requires a nonzero acoustic pressure source load",
2239 BTreeMap::from([
2240 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2241 ("load_count".to_string(), model.loads.len().to_string()),
2242 ]),
2243 ));
2244 }
2245 if !model.boundary_conditions.iter().any(|bc| {
2246 matches!(
2247 &bc.kind,
2248 BoundaryConditionKind::AcousticRigidWall
2249 | BoundaryConditionKind::AcousticRadiation
2250 | BoundaryConditionKind::AcousticImpedance { .. }
2251 )
2252 }) {
2253 return Err(operation_error(
2254 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2255 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2256 &context,
2257 OperationErrorSpec {
2258 error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_BOUNDARY",
2259 error_type: OperationErrorType::Validation,
2260 retryable: false,
2261 severity: OperationErrorSeverity::Error,
2262 },
2263 "fea.run_acoustic requires at least one acoustic boundary condition",
2264 BTreeMap::from([
2265 ("analysis_model_id".to_string(), model.model_id.0.clone()),
2266 (
2267 "boundary_condition_count".to_string(),
2268 model.boundary_conditions.len().to_string(),
2269 ),
2270 ]),
2271 ));
2272 }
2273
2274 if options.mode_count == 0 {
2275 return Err(operation_error(
2276 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2277 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2278 &context,
2279 OperationErrorSpec {
2280 error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_OPTIONS",
2281 error_type: OperationErrorType::Input,
2282 retryable: false,
2283 severity: OperationErrorSeverity::Error,
2284 },
2285 "fea.run_acoustic options require mode_count greater than zero",
2286 BTreeMap::from([("mode_count".to_string(), options.mode_count.to_string())]),
2287 ));
2288 }
2289
2290 let thermo_options = resolve_thermo_coupling_options(
2291 model,
2292 model_thermo_coupling_options(model),
2293 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2294 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2295 &context,
2296 )?;
2297 if let Some(thermo_options) = thermo_options.as_ref() {
2298 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
2299 return Err(operation_error(
2300 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2301 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2302 &context,
2303 OperationErrorSpec {
2304 error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_OPTIONS",
2305 error_type: OperationErrorType::Input,
2306 retryable: false,
2307 severity: OperationErrorSeverity::Error,
2308 },
2309 detail,
2310 metadata,
2311 ));
2312 }
2313 }
2314 let electro_options = model_electro_coupling_options(model);
2315 if let Some(electro_options) = electro_options.as_ref() {
2316 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
2317 return Err(operation_error(
2318 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2319 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2320 &context,
2321 OperationErrorSpec {
2322 error_code: electro_thermal_invalid_options_error_code(
2323 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2324 ),
2325 error_type: OperationErrorType::Input,
2326 retryable: false,
2327 severity: OperationErrorSeverity::Error,
2328 },
2329 detail,
2330 metadata,
2331 ));
2332 }
2333 }
2334
2335 let prep_context = resolve_run_prep_context(
2336 model,
2337 options.prep_artifact_id.as_deref(),
2338 options.prep_context.clone(),
2339 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2340 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2341 &context,
2342 )?;
2343 let render_topology = render_topology_from_prep_context(prep_context.as_ref());
2344
2345 let solve_start = Instant::now();
2346 let mut run = solve_acoustic_harmonic(
2347 model,
2348 backend,
2349 options.mode_count,
2350 prep_context,
2351 options.residual_warn_threshold,
2352 );
2353 let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
2354 run.diagnostics
2355 .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
2356 code: "FEA_ACOUSTIC_COST".to_string(),
2357 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
2358 message: format!(
2359 "solve_ms={} mode_count={} residual_warn_threshold={}",
2360 solve_ms, options.mode_count, options.residual_warn_threshold,
2361 ),
2362 });
2363 let acoustic_residual_norm = diagnostic_metric(
2364 &run.diagnostics,
2365 "FEA_ACOUSTIC_HELMHOLTZ_RESIDUAL",
2366 "normalized_residual_norm",
2367 )
2368 .unwrap_or(f64::INFINITY);
2369 let mut fallback_events = Vec::new();
2370 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
2371 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
2372 fallback_events.push(
2373 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
2374 );
2375 }
2376 let solver_convergence = if acoustic_residual_norm <= options.residual_warn_threshold {
2377 QualityGate::Pass
2378 } else {
2379 QualityGate::Warn
2380 };
2381 let result_quality = if run.fields_are_empty() {
2382 QualityGate::Fail
2383 } else if acoustic_residual_norm > options.residual_warn_threshold {
2384 QualityGate::Warn
2385 } else {
2386 QualityGate::Pass
2387 };
2388
2389 let mut quality_reasons = Vec::new();
2390 if solver_convergence == QualityGate::Warn {
2391 quality_reasons.push(QualityReason {
2392 code: QualityReasonCode::SolverNotConverged,
2393 detail: "acoustic solver convergence gate is warning".to_string(),
2394 });
2395 }
2396 if result_quality == QualityGate::Warn {
2397 quality_reasons.push(QualityReason {
2398 code: QualityReasonCode::ModalResidualExceeded,
2399 detail: format!(
2400 "acoustic residual exceeds threshold {}",
2401 options.residual_warn_threshold
2402 ),
2403 });
2404 }
2405 if fallback_events
2406 .iter()
2407 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
2408 {
2409 quality_reasons.push(QualityReason {
2410 code: QualityReasonCode::SolverBackendFallback,
2411 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
2412 });
2413 }
2414 if fallback_events.iter().any(|event| {
2415 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
2416 }) {
2417 quality_reasons.push(QualityReason {
2418 code: QualityReasonCode::FieldPromotionFallback,
2419 detail: "field promotion fell back to host-backed values".to_string(),
2420 });
2421 }
2422
2423 let publishable = match options.quality_policy {
2424 QualityPolicy::Strict => {
2425 solver_convergence == QualityGate::Pass
2426 && result_quality == QualityGate::Pass
2427 && quality_reasons.is_empty()
2428 }
2429 QualityPolicy::Balanced => {
2430 solver_convergence == QualityGate::Pass
2431 && result_quality == QualityGate::Pass
2432 && quality_reasons.is_empty()
2433 }
2434 QualityPolicy::Exploratory => {
2435 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
2436 }
2437 };
2438 let run_status = if publishable {
2439 RunStatus::Publishable
2440 } else if result_quality == QualityGate::Fail {
2441 RunStatus::Rejected
2442 } else {
2443 RunStatus::Degraded
2444 };
2445 let solver_backend = run.solver_backend.clone();
2446 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
2447 let solver_host_sync_count = run.solver_host_sync_count;
2448 let solver_method = run.solver_method.clone();
2449 let selected_preconditioner = run.preconditioner.clone();
2450
2451 let result = AnalysisRunResult {
2452 run_id: storage::next_run_id(),
2453 run,
2454 render_topology,
2455 modal_results: None,
2456 thermal_results: None,
2457 transient_results: None,
2458 nonlinear_results: None,
2459 electromagnetic_results: None,
2460 model_validity: QualityGate::Pass,
2461 solver_convergence,
2462 result_quality,
2463 run_status,
2464 publishable,
2465 quality_reasons,
2466 provenance: RunProvenance {
2467 backend,
2468 solver_backend,
2469 solver_device_apply_k_ratio,
2470 solver_host_sync_count,
2471 precision_mode: contracts::format_precision_mode(options.precision_mode),
2472 deterministic_mode: options.deterministic_mode,
2473 solver_method,
2474 preconditioner: selected_preconditioner,
2475 quality_policy: contracts::format_quality_policy(options.quality_policy),
2476 fallback_events,
2477 },
2478 };
2479
2480 persist_fea_run_result_with_progress(
2481 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2482 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2483 "RM.FEA.RUN_ACOUSTIC.ARTIFACT_STORE_FAILED",
2484 &context,
2485 &result,
2486 )?;
2487
2488 Ok(OperationEnvelope::new(
2489 ANALYSIS_RUN_ACOUSTIC_OPERATION,
2490 ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2491 &context,
2492 result,
2493 ))
2494}
2495
2496fn solve_acoustic_harmonic(
2497 model: &AnalysisModel,
2498 backend: ComputeBackend,
2499 mode_count: usize,
2500 prep_context: Option<AnalysisRunPrepContext>,
2501 residual_warn_threshold: f64,
2502) -> FeaRunResult {
2503 let node_count = acoustic_node_count(model, prep_context.as_ref());
2504 let material_summary = acoustic_material_summary(model, mode_count);
2505 let boundary_summary = acoustic_boundary_summary(
2506 model,
2507 material_summary.characteristic_impedance_pa_s_per_m(),
2508 );
2509 let speed_of_sound_m_per_s = material_summary.speed_of_sound_m_per_s;
2510 let density_kg_per_m3 = material_summary.density_kg_per_m3;
2511 let drive_frequency_hz = acoustic_drive_frequency_hz(mode_count, node_count);
2512 let damping_ratio = material_summary.damping_ratio;
2513 let source_real = acoustic_source_vector(model, node_count);
2514 let source_imag = vec![0.0; node_count];
2515 let domain = acoustic_domain_topology(node_count, prep_context.as_ref());
2516 let system = acoustic_helmholtz_operator(
2517 domain,
2518 node_count,
2519 drive_frequency_hz,
2520 speed_of_sound_m_per_s,
2521 damping_ratio,
2522 &boundary_summary,
2523 );
2524 let (pressure_real, pressure_imag) =
2525 solve_complex_graph_operator(&system, &source_real, &source_imag);
2526 let normalized_residual_norm = acoustic_residual_norm(
2527 &system,
2528 &pressure_real,
2529 &pressure_imag,
2530 &source_real,
2531 &source_imag,
2532 );
2533 let pressure_magnitude = pressure_real
2534 .iter()
2535 .zip(pressure_imag.iter())
2536 .map(|(real, imag)| real.hypot(*imag))
2537 .collect::<Vec<_>>();
2538 let phase = pressure_real
2539 .iter()
2540 .zip(pressure_imag.iter())
2541 .map(|(real, imag)| imag.atan2(*real))
2542 .collect::<Vec<_>>();
2543 let sound_pressure_level_db = pressure_magnitude
2544 .iter()
2545 .map(|pressure| 20.0 * (pressure.max(2.0e-5) / 2.0e-5).log10())
2546 .collect::<Vec<_>>();
2547 let particle_velocity = recover_acoustic_particle_velocity(
2548 &pressure_real,
2549 domain,
2550 drive_frequency_hz,
2551 density_kg_per_m3,
2552 );
2553 let peak_pressure_pa = pressure_magnitude.iter().copied().fold(0.0_f64, f64::max);
2554 let sweep_frequencies_hz = acoustic_sweep_frequencies_hz(drive_frequency_hz);
2555 let mut frequency_response_fields = Vec::with_capacity(sweep_frequencies_hz.len());
2556 let mut sweep_peak_pressure_pa = 0.0_f64;
2557 let mut sweep_residual_norm = 0.0_f64;
2558 for frequency_hz in &sweep_frequencies_hz {
2559 let sweep_system = acoustic_helmholtz_operator(
2560 domain,
2561 node_count,
2562 *frequency_hz,
2563 speed_of_sound_m_per_s,
2564 damping_ratio,
2565 &boundary_summary,
2566 );
2567 let (sweep_real, sweep_imag) =
2568 solve_complex_graph_operator(&sweep_system, &source_real, &source_imag);
2569 let sweep_magnitude = sweep_real
2570 .iter()
2571 .zip(sweep_imag.iter())
2572 .map(|(real, imag)| real.hypot(*imag))
2573 .collect::<Vec<_>>();
2574 sweep_peak_pressure_pa =
2575 sweep_peak_pressure_pa.max(sweep_magnitude.iter().copied().fold(0.0_f64, f64::max));
2576 sweep_residual_norm = sweep_residual_norm.max(acoustic_residual_norm(
2577 &sweep_system,
2578 &sweep_real,
2579 &sweep_imag,
2580 &source_real,
2581 &source_imag,
2582 ));
2583 frequency_response_fields.push(AnalysisField::host_f64(
2584 fea_acoustic_frequency_response_field_id(*frequency_hz),
2585 vec![node_count],
2586 sweep_magnitude,
2587 ));
2588 }
2589 let sweep_frequency_min_hz = sweep_frequencies_hz
2590 .iter()
2591 .copied()
2592 .fold(f64::INFINITY, f64::min);
2593 let sweep_frequency_max_hz = sweep_frequencies_hz.iter().copied().fold(0.0_f64, f64::max);
2594 let sweep_bandwidth_hz = if sweep_frequency_min_hz.is_finite() {
2595 (sweep_frequency_max_hz - sweep_frequency_min_hz).max(0.0)
2596 } else {
2597 0.0
2598 };
2599 let known_answer = acoustic_known_answer_metrics(
2600 domain,
2601 &pressure_magnitude,
2602 drive_frequency_hz,
2603 speed_of_sound_m_per_s,
2604 );
2605 let mut fields = vec![
2606 AnalysisField::host_f64(
2607 FEA_FIELD_ACOUSTIC_PRESSURE_REAL,
2608 vec![node_count],
2609 pressure_real,
2610 ),
2611 AnalysisField::host_f64(
2612 FEA_FIELD_ACOUSTIC_PRESSURE_IMAG,
2613 vec![node_count],
2614 pressure_imag,
2615 ),
2616 AnalysisField::host_f64(
2617 FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE,
2618 vec![node_count],
2619 pressure_magnitude,
2620 ),
2621 AnalysisField::host_f64(FEA_FIELD_ACOUSTIC_PHASE, vec![node_count], phase),
2622 AnalysisField::host_f64(
2623 FEA_FIELD_ACOUSTIC_SOUND_PRESSURE_LEVEL_DB,
2624 vec![node_count],
2625 sound_pressure_level_db,
2626 ),
2627 AnalysisField::host_f64(
2628 FEA_FIELD_ACOUSTIC_PARTICLE_VELOCITY,
2629 vec![node_count, 3],
2630 particle_velocity,
2631 ),
2632 ];
2633 let acoustic_field_count = fields.len() + frequency_response_fields.len();
2634 fields.extend(frequency_response_fields);
2635 let severity = if normalized_residual_norm <= residual_warn_threshold {
2636 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2637 } else {
2638 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2639 };
2640 FeaRunResult {
2641 backend,
2642 solver_backend: "cpu_reference".to_string(),
2643 solver_device_apply_k_ratio: 0.0,
2644 solver_method: "acoustic_domain_graph_helmholtz_harmonic".to_string(),
2645 preconditioner: "none".to_string(),
2646 solver_host_sync_count: 0,
2647 diagnostics: vec![
2648 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2649 code: "FEA_ACOUSTIC_HELMHOLTZ_RESIDUAL".to_string(),
2650 severity,
2651 message: format!(
2652 "normalized_residual_norm={} equation_scale={} residual_warn_threshold={}",
2653 normalized_residual_norm,
2654 source_norm(&source_real, &source_imag).max(1.0),
2655 residual_warn_threshold,
2656 ),
2657 },
2658 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2659 code: "FEA_ACOUSTIC_DOMAIN_ASSEMBLY".to_string(),
2660 severity: if domain.edge_count > 0 && domain.active_dimension_count >= 2 {
2661 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2662 } else {
2663 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2664 },
2665 message: format!(
2666 "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={}",
2667 domain.node_count,
2668 domain.edge_count,
2669 domain.active_dimension_count,
2670 domain.dims[0],
2671 domain.dims[1],
2672 domain.dims[2],
2673 domain.spacing[0],
2674 domain.spacing[1],
2675 domain.spacing[2],
2676 domain.boundary_node_count,
2677 domain.average_node_degree(),
2678 source_real.iter().filter(|value| value.abs() > 1.0e-12).count(),
2679 domain.volume_m3(),
2680 ),
2681 },
2682 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2683 code: "FEA_ACOUSTIC_HARMONIC_RESPONSE".to_string(),
2684 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
2685 message: format!(
2686 "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={}",
2687 drive_frequency_hz,
2688 speed_of_sound_m_per_s,
2689 density_kg_per_m3,
2690 damping_ratio,
2691 node_count,
2692 acoustic_field_count,
2693 peak_pressure_pa,
2694 material_summary.explicit_material_count,
2695 material_summary.coverage_ratio,
2696 ),
2697 },
2698 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2699 code: "FEA_ACOUSTIC_BOUNDARY_MODEL".to_string(),
2700 severity: if boundary_summary.has_acoustic_boundary_data() {
2701 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2702 } else {
2703 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2704 },
2705 message: format!(
2706 "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={}",
2707 boundary_summary.acoustic_boundary_count,
2708 boundary_summary.rigid_wall_count,
2709 boundary_summary.radiation_boundary_count,
2710 boundary_summary.impedance_boundary_count,
2711 boundary_summary.coverage_ratio,
2712 boundary_summary.mean_specific_impedance_pa_s_per_m,
2713 boundary_summary.radiation_loss_factor,
2714 boundary_summary.impedance_loss_factor,
2715 ),
2716 },
2717 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2718 code: "FEA_ACOUSTIC_FREQUENCY_RESPONSE".to_string(),
2719 severity: if sweep_residual_norm <= residual_warn_threshold {
2720 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2721 } else {
2722 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2723 },
2724 message: format!(
2725 "sweep_count={} sweep_frequency_min_hz={} sweep_frequency_max_hz={} sweep_bandwidth_hz={} sweep_peak_pressure_pa={} sweep_max_residual_norm={} response_coverage_ratio={}",
2726 sweep_frequencies_hz.len(),
2727 sweep_frequency_min_hz,
2728 sweep_frequency_max_hz,
2729 sweep_bandwidth_hz,
2730 sweep_peak_pressure_pa,
2731 sweep_residual_norm,
2732 if sweep_frequencies_hz.is_empty() { 0.0 } else { 1.0 }
2733 ),
2734 },
2735 runmat_analysis_fea::diagnostics::FeaDiagnostic {
2736 code: "FEA_ACOUSTIC_KNOWN_ANSWER".to_string(),
2737 severity: if known_answer.known_answer_coverage_ratio >= 1.0
2738 && known_answer.tube_mode_alignment_error_ratio <= 0.5
2739 && known_answer.tube_pressure_variation_ratio > 1.0e-12
2740 && known_answer.cavity_mode_spacing_ratio.is_finite()
2741 && known_answer.cavity_mode_spacing_ratio > 0.0
2742 {
2743 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2744 } else {
2745 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2746 },
2747 message: format!(
2748 "tube_mode_alignment_error_ratio={} tube_pressure_variation_ratio={} cavity_mode_spacing_ratio={} cavity_reference_mode_count={} known_answer_coverage_ratio={}",
2749 known_answer.tube_mode_alignment_error_ratio,
2750 known_answer.tube_pressure_variation_ratio,
2751 known_answer.cavity_mode_spacing_ratio,
2752 known_answer.cavity_reference_mode_count,
2753 known_answer.known_answer_coverage_ratio,
2754 ),
2755 },
2756 ],
2757 fields,
2758 }
2759}
2760
2761#[derive(Debug, Clone, Copy)]
2762struct AcousticKnownAnswerMetrics {
2763 tube_mode_alignment_error_ratio: f64,
2764 tube_pressure_variation_ratio: f64,
2765 cavity_mode_spacing_ratio: f64,
2766 cavity_reference_mode_count: usize,
2767 known_answer_coverage_ratio: f64,
2768}
2769
2770fn acoustic_known_answer_metrics(
2771 topology: AcousticDomainTopology,
2772 pressure_magnitude: &[f64],
2773 drive_frequency_hz: f64,
2774 speed_of_sound_m_per_s: f64,
2775) -> AcousticKnownAnswerMetrics {
2776 let tube_length_m = topology.spacing[0] * topology.dims[0].saturating_sub(1).max(1) as f64;
2777 let fundamental_hz = speed_of_sound_m_per_s.max(1.0) / (2.0 * tube_length_m.max(1.0e-9));
2778 let nearest_mode = (drive_frequency_hz / fundamental_hz).round().max(1.0);
2779 let nearest_mode_frequency_hz = nearest_mode * fundamental_hz;
2780 let tube_mode_alignment_error_ratio = (drive_frequency_hz - nearest_mode_frequency_hz).abs()
2781 / drive_frequency_hz.abs().max(fundamental_hz);
2782 let (min_pressure, max_pressure) = pressure_magnitude
2783 .iter()
2784 .copied()
2785 .fold((f64::INFINITY, 0.0_f64), |(min_value, max_value), value| {
2786 (min_value.min(value), max_value.max(value))
2787 });
2788 let tube_pressure_variation_ratio = if min_pressure.is_finite() {
2789 (max_pressure - min_pressure).max(0.0) / max_pressure.max(1.0e-12)
2790 } else {
2791 0.0
2792 };
2793
2794 let lengths = [
2795 topology.spacing[0] * topology.dims[0].saturating_sub(1).max(1) as f64,
2796 topology.spacing[1] * topology.dims[1].saturating_sub(1).max(1) as f64,
2797 topology.spacing[2] * topology.dims[2].saturating_sub(1).max(1) as f64,
2798 ];
2799 let mut cavity_reference_modes = Vec::new();
2800 for nx in 0..=1 {
2801 for ny in 0..=1 {
2802 for nz in 0..=1 {
2803 if nx == 0 && ny == 0 && nz == 0 {
2804 continue;
2805 }
2806 let mode_sum = (nx as f64 / lengths[0].max(1.0e-9)).powi(2)
2807 + (ny as f64 / lengths[1].max(1.0e-9)).powi(2)
2808 + (nz as f64 / lengths[2].max(1.0e-9)).powi(2);
2809 cavity_reference_modes
2810 .push(0.5 * speed_of_sound_m_per_s.max(1.0) * mode_sum.sqrt());
2811 }
2812 }
2813 }
2814 cavity_reference_modes.sort_by(|a, b| a.total_cmp(b));
2815 let cavity_reference_mode_count = cavity_reference_modes.len();
2816 let cavity_mode_spacing_ratio = cavity_reference_modes
2817 .windows(2)
2818 .map(|window| (window[1] - window[0]).abs())
2819 .filter(|spacing| *spacing > 1.0e-9)
2820 .fold(f64::INFINITY, f64::min)
2821 / fundamental_hz.max(1.0e-12);
2822 let cavity_mode_spacing_ratio = if cavity_mode_spacing_ratio.is_finite() {
2823 cavity_mode_spacing_ratio
2824 } else {
2825 0.0
2826 };
2827
2828 AcousticKnownAnswerMetrics {
2829 tube_mode_alignment_error_ratio,
2830 tube_pressure_variation_ratio,
2831 cavity_mode_spacing_ratio,
2832 cavity_reference_mode_count,
2833 known_answer_coverage_ratio: if cavity_reference_mode_count > 0
2834 && !pressure_magnitude.is_empty()
2835 && drive_frequency_hz.is_finite()
2836 {
2837 1.0
2838 } else {
2839 0.0
2840 },
2841 }
2842}
2843
2844fn acoustic_sweep_frequencies_hz(drive_frequency_hz: f64) -> Vec<f64> {
2845 let mut frequencies = Vec::new();
2846 for scale in [0.75, 1.0, 1.25] {
2847 let frequency = (drive_frequency_hz * scale).clamp(50.0, 20_000.0);
2848 if !frequencies
2849 .iter()
2850 .any(|existing| f64::abs(*existing - frequency) <= 1.0e-9)
2851 {
2852 frequencies.push(frequency);
2853 }
2854 }
2855 frequencies
2856}
2857
2858fn acoustic_node_count(
2859 model: &AnalysisModel,
2860 prep_context: Option<&AnalysisRunPrepContext>,
2861) -> usize {
2862 prep_context
2863 .map(|prep| prep.prepared_node_count.max(3))
2864 .unwrap_or_else(|| model.loads.len().saturating_mul(3).max(3))
2865 .min(512)
2866}
2867
2868#[derive(Debug, Clone, Copy)]
2869struct AcousticMaterialSummary {
2870 density_kg_per_m3: f64,
2871 speed_of_sound_m_per_s: f64,
2872 damping_ratio: f64,
2873 explicit_material_count: usize,
2874 coverage_ratio: f64,
2875}
2876
2877impl AcousticMaterialSummary {
2878 fn characteristic_impedance_pa_s_per_m(self) -> f64 {
2879 (self.density_kg_per_m3 * self.speed_of_sound_m_per_s).max(1.0)
2880 }
2881}
2882
2883fn acoustic_material_summary(model: &AnalysisModel, mode_count: usize) -> AcousticMaterialSummary {
2884 let mut density_sum = 0.0;
2885 let mut speed_sum = 0.0;
2886 let mut damping_sum = 0.0;
2887 let mut explicit_material_count = 0usize;
2888 for material in &model.materials {
2889 let Some(acoustic) = &material.acoustic else {
2890 continue;
2891 };
2892 density_sum += acoustic.density_kg_per_m3.max(1.0e-9);
2893 speed_sum += acoustic.speed_of_sound_m_per_s.max(1.0);
2894 damping_sum += acoustic.damping_ratio.max(0.0);
2895 explicit_material_count += 1;
2896 }
2897 if explicit_material_count == 0 {
2898 let reference_temperature_k = acoustic_reference_temperature_k(model);
2899 let speed_of_sound_m_per_s = 331.3 * (reference_temperature_k / 273.15).sqrt();
2900 let density_kg_per_m3 = 1.225 * (293.15 / reference_temperature_k.max(1.0));
2901 return AcousticMaterialSummary {
2902 density_kg_per_m3,
2903 speed_of_sound_m_per_s,
2904 damping_ratio: 0.02 + 0.002 * mode_count.saturating_sub(1).min(12) as f64,
2905 explicit_material_count,
2906 coverage_ratio: 0.0,
2907 };
2908 }
2909 let inv_count = 1.0 / explicit_material_count as f64;
2910 AcousticMaterialSummary {
2911 density_kg_per_m3: density_sum * inv_count,
2912 speed_of_sound_m_per_s: speed_sum * inv_count,
2913 damping_ratio: damping_sum * inv_count,
2914 explicit_material_count,
2915 coverage_ratio: explicit_material_count as f64 / model.materials.len().max(1) as f64,
2916 }
2917}
2918
2919#[derive(Debug, Clone, Copy)]
2920struct AcousticBoundarySummary {
2921 acoustic_boundary_count: usize,
2922 rigid_wall_count: usize,
2923 radiation_boundary_count: usize,
2924 impedance_boundary_count: usize,
2925 coverage_ratio: f64,
2926 mean_specific_impedance_pa_s_per_m: f64,
2927 radiation_loss_factor: f64,
2928 impedance_loss_factor: f64,
2929}
2930
2931impl AcousticBoundarySummary {
2932 fn has_acoustic_boundary_data(self) -> bool {
2933 self.acoustic_boundary_count > 0
2934 }
2935}
2936
2937fn acoustic_boundary_summary(
2938 model: &AnalysisModel,
2939 characteristic_impedance_pa_s_per_m: f64,
2940) -> AcousticBoundarySummary {
2941 let mut rigid_wall_count = 0usize;
2942 let mut radiation_boundary_count = 0usize;
2943 let mut impedance_boundary_count = 0usize;
2944 let mut impedance_sum = 0.0;
2945 for bc in &model.boundary_conditions {
2946 match bc.kind {
2947 BoundaryConditionKind::AcousticRigidWall => rigid_wall_count += 1,
2948 BoundaryConditionKind::AcousticRadiation => radiation_boundary_count += 1,
2949 BoundaryConditionKind::AcousticImpedance {
2950 specific_impedance_pa_s_per_m,
2951 } => {
2952 impedance_boundary_count += 1;
2953 impedance_sum += specific_impedance_pa_s_per_m.max(1.0);
2954 }
2955 _ => {}
2956 }
2957 }
2958 let acoustic_boundary_count =
2959 rigid_wall_count + radiation_boundary_count + impedance_boundary_count;
2960 let mean_specific_impedance_pa_s_per_m = if impedance_boundary_count == 0 {
2961 characteristic_impedance_pa_s_per_m
2962 } else {
2963 impedance_sum / impedance_boundary_count as f64
2964 };
2965 let impedance_ratio =
2966 characteristic_impedance_pa_s_per_m / mean_specific_impedance_pa_s_per_m.max(1.0);
2967 AcousticBoundarySummary {
2968 acoustic_boundary_count,
2969 rigid_wall_count,
2970 radiation_boundary_count,
2971 impedance_boundary_count,
2972 coverage_ratio: acoustic_boundary_count as f64
2973 / model.boundary_conditions.len().max(1) as f64,
2974 mean_specific_impedance_pa_s_per_m,
2975 radiation_loss_factor: 0.05 * radiation_boundary_count as f64,
2976 impedance_loss_factor: 0.025
2977 * impedance_boundary_count as f64
2978 * impedance_ratio.clamp(0.1, 10.0),
2979 }
2980}
2981
2982fn acoustic_reference_temperature_k(model: &AnalysisModel) -> f64 {
2983 if model.materials.is_empty() {
2984 293.15
2985 } else {
2986 model
2987 .materials
2988 .iter()
2989 .map(|material| material.thermal.reference_temperature_k.max(1.0))
2990 .sum::<f64>()
2991 / model.materials.len() as f64
2992 }
2993}
2994
2995fn acoustic_drive_frequency_hz(mode_count: usize, node_count: usize) -> f64 {
2996 (125.0 * mode_count.max(1) as f64 * (node_count as f64).sqrt()).clamp(50.0, 20_000.0)
2997}
2998
2999#[derive(Debug, Clone, Copy)]
3000struct AcousticDomainTopology {
3001 node_count: usize,
3002 dims: [usize; 3],
3003 spacing: [f64; 3],
3004 edge_count: usize,
3005 boundary_node_count: usize,
3006 active_dimension_count: usize,
3007}
3008
3009impl AcousticDomainTopology {
3010 fn coords(self, index: usize) -> [usize; 3] {
3011 let x_dim = self.dims[0].max(1);
3012 let y_dim = self.dims[1].max(1);
3013 let plane = x_dim.saturating_mul(y_dim).max(1);
3014 let z = index / plane;
3015 let rem = index % plane;
3016 let y = rem / x_dim;
3017 let x = rem % x_dim;
3018 [x, y, z]
3019 }
3020
3021 fn index(self, coords: [usize; 3]) -> Option<usize> {
3022 if coords
3023 .iter()
3024 .zip(self.dims.iter())
3025 .any(|(coord, dim)| *coord >= *dim)
3026 {
3027 return None;
3028 }
3029 let index = coords[0]
3030 + coords[1].saturating_mul(self.dims[0])
3031 + coords[2].saturating_mul(self.dims[0].saturating_mul(self.dims[1]));
3032 (index < self.node_count).then_some(index)
3033 }
3034
3035 fn is_boundary_node(self, index: usize) -> bool {
3036 let coords = self.coords(index);
3037 coords
3038 .iter()
3039 .zip(self.dims.iter())
3040 .any(|(coord, dim)| *dim > 1 && (*coord == 0 || *coord + 1 == *dim))
3041 }
3042
3043 fn average_node_degree(self) -> f64 {
3044 if self.node_count == 0 {
3045 0.0
3046 } else {
3047 2.0 * self.edge_count as f64 / self.node_count as f64
3048 }
3049 }
3050
3051 fn volume_m3(self) -> f64 {
3052 self.spacing
3053 .iter()
3054 .zip(self.dims.iter())
3055 .map(|(spacing, dim)| spacing * dim.saturating_sub(1).max(1) as f64)
3056 .product::<f64>()
3057 .max(1.0e-12)
3058 }
3059}
3060
3061#[derive(Debug, Clone, Copy)]
3062struct AcousticGraphEdge {
3063 left: usize,
3064 right: usize,
3065 stiffness: f64,
3066}
3067
3068#[derive(Debug, Clone)]
3069struct AcousticDomainSystem {
3070 diag_real: Vec<f64>,
3071 diag_imag: Vec<f64>,
3072 edges: Vec<AcousticGraphEdge>,
3073}
3074
3075fn acoustic_domain_topology(
3076 node_count: usize,
3077 prep_context: Option<&AnalysisRunPrepContext>,
3078) -> AcousticDomainTopology {
3079 let n = node_count.max(1);
3080 let volume_hint = prep_context
3081 .map(|prep| {
3082 prep.topology_volume_core_ratio
3083 + prep.topology_tet_family_ratio
3084 + prep.topology_hex_family_ratio
3085 })
3086 .unwrap_or(0.0);
3087 let z_dim = if n >= 8 && volume_hint > 0.05 {
3088 (n as f64).cbrt().round().max(2.0) as usize
3089 } else if n >= 24 {
3090 2
3091 } else {
3092 1
3093 };
3094 let y_dim = if n >= 3 {
3095 ((n as f64 / z_dim as f64).sqrt().ceil() as usize).max(2)
3096 } else {
3097 1
3098 };
3099 let x_dim = n.div_ceil(y_dim * z_dim).max(1);
3100 let dims = [x_dim, y_dim, z_dim];
3101 let spacing = dims.map(|dim| {
3102 if dim <= 1 {
3103 1.0
3104 } else {
3105 1.0 / dim.saturating_sub(1) as f64
3106 }
3107 });
3108 let mut edge_count = 0usize;
3109 let mut boundary_node_count = 0usize;
3110 let topology = AcousticDomainTopology {
3111 node_count: n,
3112 dims,
3113 spacing,
3114 edge_count: 0,
3115 boundary_node_count: 0,
3116 active_dimension_count: dims.iter().filter(|dim| **dim > 1).count(),
3117 };
3118 for node in 0..n {
3119 if topology.is_boundary_node(node) {
3120 boundary_node_count += 1;
3121 }
3122 let coords = topology.coords(node);
3123 for axis in 0..3 {
3124 if coords[axis] + 1 >= topology.dims[axis] {
3125 continue;
3126 }
3127 let mut next = coords;
3128 next[axis] += 1;
3129 if topology.index(next).is_some() {
3130 edge_count += 1;
3131 }
3132 }
3133 }
3134 AcousticDomainTopology {
3135 edge_count,
3136 boundary_node_count,
3137 ..topology
3138 }
3139}
3140
3141fn acoustic_source_vector(model: &AnalysisModel, node_count: usize) -> Vec<f64> {
3142 let mut source = vec![0.0; node_count.max(1)];
3143 for (index, load) in model.loads.iter().enumerate() {
3144 let node = (index * 3 + load.region_id.len()) % source.len();
3145 let amplitude = match &load.kind {
3146 LoadKind::Pressure { magnitude_pa } => *magnitude_pa,
3147 _ => 0.0,
3148 };
3149 source[node] += amplitude;
3150 }
3151 source
3152}
3153
3154fn acoustic_helmholtz_operator(
3155 topology: AcousticDomainTopology,
3156 node_count: usize,
3157 drive_frequency_hz: f64,
3158 speed_of_sound_m_per_s: f64,
3159 damping_ratio: f64,
3160 boundary_summary: &AcousticBoundarySummary,
3161) -> AcousticDomainSystem {
3162 let n = node_count.max(1);
3163 let omega = 2.0 * std::f64::consts::PI * drive_frequency_hz.max(1.0);
3164 let wave_number = omega / speed_of_sound_m_per_s.max(1.0);
3165 let mut diag_real = vec![0.0; n];
3166 let mut diag_imag = vec![0.0; n];
3167 let mut edges = Vec::with_capacity(topology.edge_count);
3168 for node in 0..n {
3169 let coords = topology.coords(node);
3170 for axis in 0..3 {
3171 if coords[axis] + 1 >= topology.dims[axis] {
3172 continue;
3173 }
3174 let mut next = coords;
3175 next[axis] += 1;
3176 let Some(next_index) = topology.index(next) else {
3177 continue;
3178 };
3179 let stiffness = 1.0 / topology.spacing[axis].max(1.0e-9).powi(2);
3180 diag_real[node] += stiffness;
3181 diag_real[next_index] += stiffness;
3182 edges.push(AcousticGraphEdge {
3183 left: node,
3184 right: next_index,
3185 stiffness,
3186 });
3187 }
3188 }
3189 let mass_term =
3190 (wave_number * topology.volume_m3().cbrt()).powi(2) / topology.node_count.max(1) as f64;
3191 let damping = (2.0 * damping_ratio.max(0.0) * mass_term.max(1.0e-9)).max(1.0e-9);
3192 let boundary_loss = (boundary_summary.radiation_loss_factor
3193 + boundary_summary.impedance_loss_factor)
3194 * wave_number.abs().max(1.0e-9)
3195 / topology.boundary_node_count.max(1) as f64;
3196 for node in 0..n {
3197 diag_real[node] -= mass_term;
3198 diag_imag[node] += damping;
3199 if topology.is_boundary_node(node) {
3200 diag_imag[node] += boundary_loss;
3201 diag_real[node] += 0.02 * boundary_summary.rigid_wall_count as f64;
3202 }
3203 }
3204 AcousticDomainSystem {
3205 diag_real,
3206 diag_imag,
3207 edges,
3208 }
3209}
3210
3211fn solve_complex_graph_operator(
3212 system: &AcousticDomainSystem,
3213 source_real: &[f64],
3214 source_imag: &[f64],
3215) -> (Vec<f64>, Vec<f64>) {
3216 let n = system.diag_real.len().max(1);
3217 let mut matrix_real = vec![vec![0.0; n]; n];
3218 let mut matrix_imag = vec![vec![0.0; n]; n];
3219 for row in 0..n {
3220 matrix_real[row][row] = system.diag_real[row];
3221 matrix_imag[row][row] = system.diag_imag[row];
3222 }
3223 for edge in &system.edges {
3224 matrix_real[edge.left][edge.right] -= edge.stiffness;
3225 matrix_real[edge.right][edge.left] -= edge.stiffness;
3226 }
3227 let mut rhs_real = (0..n)
3228 .map(|index| source_real.get(index).copied().unwrap_or(0.0))
3229 .collect::<Vec<_>>();
3230 let mut rhs_imag = (0..n)
3231 .map(|index| source_imag.get(index).copied().unwrap_or(0.0))
3232 .collect::<Vec<_>>();
3233 solve_dense_complex_system(
3234 &mut matrix_real,
3235 &mut matrix_imag,
3236 &mut rhs_real,
3237 &mut rhs_imag,
3238 )
3239}
3240
3241fn acoustic_residual_norm(
3242 system: &AcousticDomainSystem,
3243 pressure_real: &[f64],
3244 pressure_imag: &[f64],
3245 source_real: &[f64],
3246 source_imag: &[f64],
3247) -> f64 {
3248 let mut residual_sq = 0.0_f64;
3249 for i in 0..system.diag_real.len() {
3250 let mut applied_real =
3251 system.diag_real[i] * pressure_real[i] - system.diag_imag[i] * pressure_imag[i];
3252 let mut applied_imag =
3253 system.diag_real[i] * pressure_imag[i] + system.diag_imag[i] * pressure_real[i];
3254 for edge in system
3255 .edges
3256 .iter()
3257 .filter(|edge| edge.left == i || edge.right == i)
3258 {
3259 let neighbor = if edge.left == i {
3260 edge.right
3261 } else {
3262 edge.left
3263 };
3264 applied_real -= edge.stiffness * pressure_real[neighbor];
3265 applied_imag -= edge.stiffness * pressure_imag[neighbor];
3266 }
3267 let real = applied_real - source_real.get(i).copied().unwrap_or(0.0);
3268 let imag = applied_imag - source_imag.get(i).copied().unwrap_or(0.0);
3269 residual_sq += real * real + imag * imag;
3270 }
3271 residual_sq.sqrt() / source_norm(source_real, source_imag).max(1.0)
3272}
3273
3274fn source_norm(source_real: &[f64], source_imag: &[f64]) -> f64 {
3275 source_real
3276 .iter()
3277 .zip(source_imag.iter().chain(std::iter::repeat(&0.0)))
3278 .map(|(real, imag)| real * real + imag * imag)
3279 .sum::<f64>()
3280 .sqrt()
3281}
3282
3283fn solve_dense_complex_system(
3284 matrix_real: &mut [Vec<f64>],
3285 matrix_imag: &mut [Vec<f64>],
3286 rhs_real: &mut [f64],
3287 rhs_imag: &mut [f64],
3288) -> (Vec<f64>, Vec<f64>) {
3289 let n = rhs_real.len();
3290 for pivot in 0..n {
3291 let mut pivot_row = pivot;
3292 let mut pivot_norm = complex_abs_sq(matrix_real[pivot][pivot], matrix_imag[pivot][pivot]);
3293 for candidate in pivot + 1..n {
3294 let candidate_norm =
3295 complex_abs_sq(matrix_real[candidate][pivot], matrix_imag[candidate][pivot]);
3296 if candidate_norm > pivot_norm {
3297 pivot_row = candidate;
3298 pivot_norm = candidate_norm;
3299 }
3300 }
3301 if pivot_row != pivot {
3302 matrix_real.swap(pivot, pivot_row);
3303 matrix_imag.swap(pivot, pivot_row);
3304 rhs_real.swap(pivot, pivot_row);
3305 rhs_imag.swap(pivot, pivot_row);
3306 }
3307 if pivot_norm <= 1.0e-24 {
3308 matrix_real[pivot][pivot] += 1.0e-9;
3309 }
3310 let (pivot_inv_real, pivot_inv_imag) =
3311 complex_recip(matrix_real[pivot][pivot], matrix_imag[pivot][pivot]);
3312 for row in pivot + 1..n {
3313 let (factor_real, factor_imag) = complex_mul(
3314 matrix_real[row][pivot],
3315 matrix_imag[row][pivot],
3316 pivot_inv_real,
3317 pivot_inv_imag,
3318 );
3319 matrix_real[row][pivot] = 0.0;
3320 matrix_imag[row][pivot] = 0.0;
3321 for col in pivot + 1..n {
3322 let (update_real, update_imag) = complex_mul(
3323 factor_real,
3324 factor_imag,
3325 matrix_real[pivot][col],
3326 matrix_imag[pivot][col],
3327 );
3328 matrix_real[row][col] -= update_real;
3329 matrix_imag[row][col] -= update_imag;
3330 }
3331 let (rhs_update_real, rhs_update_imag) =
3332 complex_mul(factor_real, factor_imag, rhs_real[pivot], rhs_imag[pivot]);
3333 rhs_real[row] -= rhs_update_real;
3334 rhs_imag[row] -= rhs_update_imag;
3335 }
3336 }
3337
3338 let mut solution_real = vec![0.0; n];
3339 let mut solution_imag = vec![0.0; n];
3340 for row in (0..n).rev() {
3341 let mut accum_real = rhs_real[row];
3342 let mut accum_imag = rhs_imag[row];
3343 for col in row + 1..n {
3344 let (update_real, update_imag) = complex_mul(
3345 matrix_real[row][col],
3346 matrix_imag[row][col],
3347 solution_real[col],
3348 solution_imag[col],
3349 );
3350 accum_real -= update_real;
3351 accum_imag -= update_imag;
3352 }
3353 let (inv_real, inv_imag) = complex_recip(matrix_real[row][row], matrix_imag[row][row]);
3354 (solution_real[row], solution_imag[row]) =
3355 complex_mul(accum_real, accum_imag, inv_real, inv_imag);
3356 }
3357 (solution_real, solution_imag)
3358}
3359
3360fn complex_abs_sq(real: f64, imag: f64) -> f64 {
3361 real * real + imag * imag
3362}
3363
3364fn complex_recip(real: f64, imag: f64) -> (f64, f64) {
3365 let denom = (real * real + imag * imag).max(1.0e-24);
3366 (real / denom, -imag / denom)
3367}
3368
3369fn complex_mul(a_real: f64, a_imag: f64, b_real: f64, b_imag: f64) -> (f64, f64) {
3370 (
3371 a_real * b_real - a_imag * b_imag,
3372 a_real * b_imag + a_imag * b_real,
3373 )
3374}
3375
3376fn recover_acoustic_particle_velocity(
3377 pressure_real: &[f64],
3378 topology: AcousticDomainTopology,
3379 drive_frequency_hz: f64,
3380 density_kg_per_m3: f64,
3381) -> Vec<f64> {
3382 let node_count = pressure_real.len().max(1);
3383 let omega = (2.0 * std::f64::consts::PI * drive_frequency_hz).max(1.0e-12);
3384 let impedance_scale = (density_kg_per_m3.max(1.0e-12) * omega).max(1.0e-12);
3385 let mut velocity = vec![0.0; node_count * 3];
3386 for node in 0..pressure_real.len() {
3387 for axis in 0..3 {
3388 velocity[node * 3 + axis] =
3389 -acoustic_axis_derivative(pressure_real, topology, node, axis) / impedance_scale;
3390 }
3391 }
3392 velocity
3393}
3394
3395fn acoustic_axis_derivative(
3396 pressure: &[f64],
3397 topology: AcousticDomainTopology,
3398 index: usize,
3399 axis: usize,
3400) -> f64 {
3401 if topology.dims[axis] <= 1 {
3402 return 0.0;
3403 }
3404 let coords = topology.coords(index);
3405 let mut prev_coords = coords;
3406 let prev_index = if coords[axis] > 0 {
3407 prev_coords[axis] -= 1;
3408 topology.index(prev_coords)
3409 } else {
3410 None
3411 };
3412 let mut next_coords = coords;
3413 let next_index = if coords[axis] + 1 < topology.dims[axis] {
3414 next_coords[axis] += 1;
3415 topology.index(next_coords)
3416 } else {
3417 None
3418 };
3419 let spacing = topology.spacing[axis].max(1.0e-12);
3420 match (prev_index, next_index) {
3421 (Some(prev), Some(next)) => (pressure[next] - pressure[prev]) / (2.0 * spacing),
3422 (Some(prev), None) => (pressure[index] - pressure[prev]) / spacing,
3423 (None, Some(next)) => (pressure[next] - pressure[index]) / spacing,
3424 (None, None) => 0.0,
3425 }
3426}
3427
3428fn cfd_reynolds_number(domain: &runmat_analysis_core::CfdDomain) -> f64 {
3429 cfd_reynolds_number_for_velocity(domain, domain.inlet_velocity_m_per_s)
3430}
3431
3432fn cfd_reynolds_number_for_velocity(
3433 domain: &runmat_analysis_core::CfdDomain,
3434 inlet_velocity_m_per_s: f64,
3435) -> f64 {
3436 domain.reference_density_kg_per_m3 * inlet_velocity_m_per_s.abs()
3437 / domain.dynamic_viscosity_pa_s
3438}
3439
3440fn cfd_profile_scale(domain: &runmat_analysis_core::CfdDomain, step_index: usize) -> f64 {
3441 domain
3442 .time_profile
3443 .get(step_index)
3444 .map(|point| point.inlet_scale)
3445 .filter(|scale| scale.is_finite() && *scale >= 0.0)
3446 .unwrap_or(1.0)
3447}
3448
3449fn cfd_node_count_from_model(
3450 model: &AnalysisModel,
3451 prep_context: Option<&AnalysisRunPrepContext>,
3452) -> usize {
3453 prep_context
3454 .map(|prep| prep.prepared_node_count.max(3))
3455 .unwrap_or_else(|| model.loads.len().saturating_mul(3).max(3))
3456 .min(512)
3457}
3458
3459#[derive(Clone, Debug)]
3460struct CfdDomainTopology {
3461 basis: CfdDomainTopologyBasis,
3462 geometry_source: CfdDomainGeometrySource,
3463 node_count: usize,
3464 control_volume_count: usize,
3465 control_volume_face_count: usize,
3466 control_volume_internal_face_count: usize,
3467 control_volume_boundary_face_count: usize,
3468 control_volume_connectivity_coverage_ratio: f64,
3469 domain_length_m: f64,
3470 hydraulic_diameter_m: f64,
3471 face_area_m2: f64,
3472 dx_m: f64,
3473 active_dimension_count: usize,
3474 element_geometry_node_count: usize,
3475 element_geometry_edge_count: usize,
3476 element_geometry_coverage_ratio: f64,
3477 element_topology_sample_element_count: usize,
3478 element_topology_sample_edge_count: usize,
3479 element_topology_sample_element_edges: [[u32; 3]; 4],
3480 element_topology_edge_nodes: Vec<[u32; 2]>,
3481 element_topology_element_edges: Vec<[u32; 3]>,
3482}
3483
3484impl CfdDomainTopology {
3485 fn from_model(model: &AnalysisModel, prep_context: Option<&AnalysisRunPrepContext>) -> Self {
3486 let node_count = cfd_node_count_from_model(model, prep_context);
3487 match prep_context {
3488 Some(prep) => Self::from_prep(node_count, prep),
3489 None => Self::implicit_channel(node_count),
3490 }
3491 }
3492
3493 fn implicit_channel(node_count: usize) -> Self {
3494 let node_count = node_count.max(2);
3495 let control_volume_count = node_count.saturating_sub(1).max(1);
3496 Self {
3497 basis: CfdDomainTopologyBasis::ImplicitChannel,
3498 geometry_source: CfdDomainGeometrySource::ImplicitChannel,
3499 node_count,
3500 control_volume_count,
3501 control_volume_face_count: control_volume_count.saturating_add(1),
3502 control_volume_internal_face_count: control_volume_count.saturating_sub(1),
3503 control_volume_boundary_face_count: 2,
3504 control_volume_connectivity_coverage_ratio: 0.0,
3505 domain_length_m: 1.0,
3506 hydraulic_diameter_m: 1.0,
3507 face_area_m2: 1.0,
3508 dx_m: 1.0 / control_volume_count as f64,
3509 active_dimension_count: 1,
3510 element_geometry_node_count: 0,
3511 element_geometry_edge_count: 0,
3512 element_geometry_coverage_ratio: 0.0,
3513 element_topology_sample_element_count: 0,
3514 element_topology_sample_edge_count: 0,
3515 element_topology_sample_element_edges: [[0; 3]; 4],
3516 element_topology_edge_nodes: Vec::new(),
3517 element_topology_element_edges: Vec::new(),
3518 }
3519 }
3520
3521 fn from_prep(node_count: usize, prep: &AnalysisRunPrepContext) -> Self {
3522 let has_control_volume_connectivity = prep.control_volume_cell_count > 0
3523 && prep.control_volume_face_count > 0
3524 && prep.control_volume_connectivity_coverage_ratio > 0.0;
3525 let control_volume_count = if has_control_volume_connectivity {
3526 prep.control_volume_cell_count.max(1)
3527 } else {
3528 node_count.max(2).saturating_sub(1).max(1)
3529 };
3530 let node_count = if has_control_volume_connectivity {
3531 control_volume_count.saturating_add(1).max(2)
3532 } else {
3533 node_count.max(2)
3534 };
3535 let fallback_length = finite_positive_or(prep.coordinate_characteristic_length_m, 1.0)
3536 * control_volume_count as f64;
3537 let domain_length_m = finite_positive_or(prep.coordinate_span_x_m, fallback_length);
3538 let transverse_y = finite_positive_or(prep.coordinate_span_y_m, 0.0);
3539 let transverse_z = finite_positive_or(prep.coordinate_span_z_m, 0.0);
3540 let hydraulic_diameter_m = if transverse_y > 0.0 && transverse_z > 0.0 {
3541 (2.0 * transverse_y * transverse_z / (transverse_y + transverse_z)).max(1.0e-12)
3542 } else {
3543 finite_positive_or(prep.coordinate_characteristic_length_m, 1.0)
3544 };
3545 let coordinate_face_area_m2 = hydraulic_diameter_m.max(1.0e-12).powi(2);
3546 let has_element_geometry = prep.element_geometry_coverage_ratio > 0.0
3547 && prep.mean_element_area_m2.is_finite()
3548 && prep.mean_element_area_m2 > 0.0;
3549 let face_area_m2 = if has_element_geometry {
3550 prep.mean_element_area_m2
3551 } else {
3552 coordinate_face_area_m2
3553 };
3554 let hydraulic_diameter_m = if has_element_geometry {
3555 (4.0 * face_area_m2 / std::f64::consts::PI)
3556 .sqrt()
3557 .max(1.0e-12)
3558 } else {
3559 hydraulic_diameter_m
3560 };
3561 Self {
3562 basis: CfdDomainTopologyBasis::PrepControlVolumeConnectivity,
3563 geometry_source: if has_element_geometry {
3564 CfdDomainGeometrySource::PrepElementGeometry
3565 } else {
3566 CfdDomainGeometrySource::CoordinateSpan
3567 },
3568 node_count,
3569 control_volume_count,
3570 control_volume_face_count: if has_control_volume_connectivity {
3571 prep.control_volume_face_count
3572 } else {
3573 control_volume_count.saturating_add(1)
3574 },
3575 control_volume_internal_face_count: if has_control_volume_connectivity {
3576 prep.control_volume_internal_face_count
3577 } else {
3578 control_volume_count.saturating_sub(1)
3579 },
3580 control_volume_boundary_face_count: if has_control_volume_connectivity {
3581 prep.control_volume_boundary_face_count
3582 } else {
3583 2
3584 },
3585 control_volume_connectivity_coverage_ratio: prep
3586 .control_volume_connectivity_coverage_ratio
3587 .clamp(0.0, 1.0),
3588 domain_length_m,
3589 hydraulic_diameter_m,
3590 face_area_m2,
3591 dx_m: domain_length_m / control_volume_count as f64,
3592 active_dimension_count: prep.coordinate_active_dimension_count.max(1),
3593 element_geometry_node_count: prep.element_geometry_node_count,
3594 element_geometry_edge_count: prep.element_geometry_edge_count,
3595 element_geometry_coverage_ratio: prep.element_geometry_coverage_ratio.clamp(0.0, 1.0),
3596 element_topology_sample_element_count: prep.element_topology_sample_element_count,
3597 element_topology_sample_edge_count: prep.element_topology_sample_edge_count,
3598 element_topology_sample_element_edges: prep.element_topology_sample_element_edges,
3599 element_topology_edge_nodes: prep.element_topology_edge_nodes.clone(),
3600 element_topology_element_edges: prep.element_topology_element_edges.clone(),
3601 }
3602 }
3603
3604 fn face_area_m2(&self) -> f64 {
3605 self.face_area_m2.max(1.0e-12)
3606 }
3607
3608 fn control_volume_volume_m3(&self) -> f64 {
3609 self.face_area_m2() * self.dx_m.max(1.0e-12)
3610 }
3611}
3612
3613#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3614enum CfdDomainTopologyBasis {
3615 PrepControlVolumeConnectivity,
3616 ImplicitChannel,
3617}
3618
3619impl CfdDomainTopologyBasis {
3620 fn as_str(self) -> &'static str {
3621 match self {
3622 Self::PrepControlVolumeConnectivity => "prep_control_volume_connectivity",
3623 Self::ImplicitChannel => "implicit_channel",
3624 }
3625 }
3626}
3627
3628#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3629enum CfdDomainGeometrySource {
3630 ImplicitChannel,
3631 CoordinateSpan,
3632 PrepElementGeometry,
3633}
3634
3635impl CfdDomainGeometrySource {
3636 fn as_str(self) -> &'static str {
3637 match self {
3638 Self::ImplicitChannel => "implicit_channel",
3639 Self::CoordinateSpan => "coordinate_span",
3640 Self::PrepElementGeometry => "prep_element_geometry",
3641 }
3642 }
3643}
3644
3645fn finite_positive_or(value: f64, fallback: f64) -> f64 {
3646 if value.is_finite() && value > 0.0 {
3647 value
3648 } else {
3649 fallback
3650 }
3651}
3652
3653#[derive(Clone, Debug)]
3654struct CfdBoundarySummary {
3655 inlet_boundary_count: usize,
3656 outlet_boundary_count: usize,
3657 no_slip_wall_boundary_count: usize,
3658 slip_wall_boundary_count: usize,
3659 symmetry_boundary_count: usize,
3660 authored_boundary_count: usize,
3661 boundary_coverage_ratio: f64,
3662 wall_boundary_coverage_ratio: f64,
3663 nominal_inlet_velocity_m_per_s: f64,
3664 outlet_pressure_pa: f64,
3665}
3666
3667impl CfdBoundarySummary {
3668 fn implicit_channel(domain: &runmat_analysis_core::CfdDomain, node_count: usize) -> Self {
3669 Self {
3670 inlet_boundary_count: 1,
3671 outlet_boundary_count: 1,
3672 no_slip_wall_boundary_count: node_count.saturating_sub(1).max(1),
3673 slip_wall_boundary_count: 0,
3674 symmetry_boundary_count: 0,
3675 authored_boundary_count: 0,
3676 boundary_coverage_ratio: 1.0,
3677 wall_boundary_coverage_ratio: 1.0,
3678 nominal_inlet_velocity_m_per_s: domain.inlet_velocity_m_per_s,
3679 outlet_pressure_pa: 0.0,
3680 }
3681 }
3682
3683 fn from_model(
3684 model: &AnalysisModel,
3685 domain: &runmat_analysis_core::CfdDomain,
3686 node_count: usize,
3687 ) -> Self {
3688 let mut inlet_velocity_sum = 0.0_f64;
3689 let mut outlet_pressure_sum = 0.0_f64;
3690 let mut summary = Self {
3691 inlet_boundary_count: 0,
3692 outlet_boundary_count: 0,
3693 no_slip_wall_boundary_count: 0,
3694 slip_wall_boundary_count: 0,
3695 symmetry_boundary_count: 0,
3696 authored_boundary_count: 0,
3697 boundary_coverage_ratio: 0.0,
3698 wall_boundary_coverage_ratio: 0.0,
3699 nominal_inlet_velocity_m_per_s: domain.inlet_velocity_m_per_s,
3700 outlet_pressure_pa: 0.0,
3701 };
3702
3703 for boundary in &model.boundary_conditions {
3704 match &boundary.kind {
3705 BoundaryConditionKind::CfdInletVelocity { velocity_m_per_s } => {
3706 summary.inlet_boundary_count += 1;
3707 summary.authored_boundary_count += 1;
3708 inlet_velocity_sum += *velocity_m_per_s;
3709 }
3710 BoundaryConditionKind::CfdOutletPressure { pressure_pa } => {
3711 summary.outlet_boundary_count += 1;
3712 summary.authored_boundary_count += 1;
3713 outlet_pressure_sum += *pressure_pa;
3714 }
3715 BoundaryConditionKind::CfdNoSlipWall => {
3716 summary.no_slip_wall_boundary_count += 1;
3717 summary.authored_boundary_count += 1;
3718 }
3719 BoundaryConditionKind::CfdSlipWall => {
3720 summary.slip_wall_boundary_count += 1;
3721 summary.authored_boundary_count += 1;
3722 }
3723 BoundaryConditionKind::CfdSymmetry => {
3724 summary.symmetry_boundary_count += 1;
3725 summary.authored_boundary_count += 1;
3726 }
3727 _ => {}
3728 }
3729 }
3730
3731 if summary.authored_boundary_count == 0 {
3732 return Self::implicit_channel(domain, node_count);
3733 }
3734
3735 if summary.inlet_boundary_count > 0 {
3736 summary.nominal_inlet_velocity_m_per_s =
3737 inlet_velocity_sum / summary.inlet_boundary_count as f64;
3738 }
3739 if summary.outlet_boundary_count > 0 {
3740 summary.outlet_pressure_pa = outlet_pressure_sum / summary.outlet_boundary_count as f64;
3741 }
3742
3743 let wall_like_count = summary.no_slip_wall_boundary_count
3744 + summary.slip_wall_boundary_count
3745 + summary.symmetry_boundary_count;
3746 let required_groups_present = usize::from(summary.inlet_boundary_count > 0)
3747 + usize::from(summary.outlet_boundary_count > 0)
3748 + usize::from(wall_like_count > 0);
3749 summary.boundary_coverage_ratio = required_groups_present as f64 / 3.0;
3750 summary.wall_boundary_coverage_ratio = if wall_like_count > 0 { 1.0 } else { 0.0 };
3751 summary
3752 }
3753
3754 fn wall_boundary_count(&self) -> usize {
3755 self.no_slip_wall_boundary_count + self.slip_wall_boundary_count
3756 }
3757}
3758
3759fn validate_authored_cfd_boundary_conditions(model: &AnalysisModel) -> Result<(), String> {
3760 let mut inlet_boundary_count = 0usize;
3761 let mut outlet_boundary_count = 0usize;
3762 let mut wall_like_boundary_count = 0usize;
3763 let mut authored_boundary_count = 0usize;
3764
3765 for boundary in &model.boundary_conditions {
3766 match &boundary.kind {
3767 BoundaryConditionKind::CfdInletVelocity { velocity_m_per_s } => {
3768 authored_boundary_count += 1;
3769 inlet_boundary_count += 1;
3770 if !velocity_m_per_s.is_finite() || *velocity_m_per_s < 0.0 {
3771 return Err(format!(
3772 "cfd inlet boundary {} requires finite non-negative velocity_m_per_s",
3773 boundary.bc_id
3774 ));
3775 }
3776 }
3777 BoundaryConditionKind::CfdOutletPressure { pressure_pa } => {
3778 authored_boundary_count += 1;
3779 outlet_boundary_count += 1;
3780 if !pressure_pa.is_finite() {
3781 return Err(format!(
3782 "cfd outlet boundary {} requires finite pressure_pa",
3783 boundary.bc_id
3784 ));
3785 }
3786 }
3787 BoundaryConditionKind::CfdNoSlipWall
3788 | BoundaryConditionKind::CfdSlipWall
3789 | BoundaryConditionKind::CfdSymmetry => {
3790 authored_boundary_count += 1;
3791 wall_like_boundary_count += 1;
3792 }
3793 _ => {}
3794 }
3795 }
3796
3797 if authored_boundary_count == 0 {
3798 return Ok(());
3799 }
3800 if inlet_boundary_count == 0 || outlet_boundary_count == 0 || wall_like_boundary_count == 0 {
3801 return Err(format!(
3802 "authored cfd boundaries require at least one inlet, outlet, and wall/symmetry boundary; got inlet={} outlet={} wall_like={}",
3803 inlet_boundary_count, outlet_boundary_count, wall_like_boundary_count,
3804 ));
3805 }
3806 Ok(())
3807}
3808
3809#[derive(Clone, Debug)]
3810struct CfdVelocityPressureSolution {
3811 topology: CfdDomainTopology,
3812 velocity: Vec<f64>,
3813 pressure: Vec<f64>,
3814 residual_momentum: Vec<f64>,
3815 residual_continuity: Vec<f64>,
3816 mass_balance_residual: f64,
3817 pressure_drop_pa: f64,
3818 control_volume_count: usize,
3819 inlet_boundary_count: usize,
3820 outlet_boundary_count: usize,
3821 wall_boundary_count: usize,
3822 no_slip_wall_boundary_count: usize,
3823 slip_wall_boundary_count: usize,
3824 symmetry_boundary_count: usize,
3825 authored_boundary_count: usize,
3826 boundary_coverage_ratio: f64,
3827 wall_boundary_coverage_ratio: f64,
3828 inlet_velocity_realization_ratio: f64,
3829 nominal_inlet_velocity_m_per_s: f64,
3830 outlet_pressure_pa: f64,
3831 pressure_correction_iteration_count: usize,
3832 pressure_correction_residual_ratio: f64,
3833 velocity_correction_residual_ratio: f64,
3834 transient_scale_min: f64,
3835 transient_scale_max: f64,
3836 transient_scale_variation: f64,
3837}
3838
3839#[derive(Clone, Debug)]
3840struct CfdKnownAnswerMetrics {
3841 pressure_drop_balance_ratio: f64,
3842 mass_flux_uniformity_ratio: f64,
3843 pressure_monotonic_cell_fraction: f64,
3844 known_answer_coverage_ratio: f64,
3845}
3846
3847fn recover_cfd_velocity_pressure(
3848 domain: &runmat_analysis_core::CfdDomain,
3849 topology: &CfdDomainTopology,
3850 step_index: usize,
3851) -> (Vec<f64>, Vec<f64>) {
3852 let boundary_summary = CfdBoundarySummary::implicit_channel(domain, topology.node_count);
3853 let solution = solve_cfd_velocity_pressure(
3854 domain,
3855 &boundary_summary,
3856 topology,
3857 step_index,
3858 1,
3859 32,
3860 1.0e-8,
3861 );
3862 (solution.velocity, solution.pressure)
3863}
3864
3865fn solve_cfd_velocity_pressure(
3866 domain: &runmat_analysis_core::CfdDomain,
3867 boundary_summary: &CfdBoundarySummary,
3868 topology: &CfdDomainTopology,
3869 step_index: usize,
3870 step_count: usize,
3871 max_linear_iters: usize,
3872 tolerance: f64,
3873) -> CfdVelocityPressureSolution {
3874 let node_count = topology.node_count.max(2);
3875 let profile_scale = cfd_profile_scale(domain, step_index);
3876 let nominal_inlet_velocity = boundary_summary.nominal_inlet_velocity_m_per_s;
3877 let inlet_velocity = nominal_inlet_velocity * profile_scale;
3878 let reynolds = cfd_reynolds_number_for_velocity(domain, nominal_inlet_velocity).max(1.0);
3879 let hydraulic_diameter_m = topology.hydraulic_diameter_m;
3880 let friction_factor = if reynolds <= 2300.0 {
3881 64.0 / reynolds
3882 } else {
3883 0.3164 / reynolds.powf(0.25)
3884 };
3885 let friction_gradient_pa_per_m = 0.5
3886 * domain.reference_density_kg_per_m3
3887 * inlet_velocity
3888 * inlet_velocity.abs()
3889 * friction_factor
3890 / hydraulic_diameter_m;
3891 let pressure_drop_pa = (friction_gradient_pa_per_m * topology.domain_length_m).max(0.0);
3892 let denom = node_count.saturating_sub(1).max(1) as f64;
3893 let mut axial_velocity = vec![inlet_velocity; node_count];
3894 let mut pressure = (0..node_count)
3895 .map(|node| {
3896 let xi = node as f64 / denom;
3897 boundary_summary.outlet_pressure_pa + 0.5 * pressure_drop_pa * (1.0 - xi)
3898 })
3899 .collect::<Vec<_>>();
3900 let target_pressure = (0..node_count)
3901 .map(|node| {
3902 let xi = node as f64 / denom;
3903 boundary_summary.outlet_pressure_pa + pressure_drop_pa * (1.0 - xi)
3904 })
3905 .collect::<Vec<_>>();
3906 let correction_iters = max_linear_iters.max(1);
3907 let correction_tolerance = tolerance.max(1.0e-12);
3908 let mut pressure_correction_residual_ratio = f64::INFINITY;
3909 let mut velocity_correction_residual_ratio = f64::INFINITY;
3910 let mut pressure_correction_iteration_count = 0usize;
3911 for iteration in 0..correction_iters {
3912 let previous_pressure = pressure.clone();
3913 let previous_velocity = axial_velocity.clone();
3914 for node in 0..node_count {
3915 pressure[node] = 0.35 * pressure[node] + 0.65 * target_pressure[node];
3916 }
3917 for node in 0..node_count {
3918 if node == 0 {
3919 axial_velocity[node] = inlet_velocity;
3920 continue;
3921 }
3922 if node + 1 == node_count {
3923 axial_velocity[node] = axial_velocity[node.saturating_sub(1)];
3924 continue;
3925 }
3926 let gradient = (pressure[node + 1] - pressure[node - 1]) / (2.0 * topology.dx_m);
3927 let pressure_driven_speed = ((-2.0 * gradient * hydraulic_diameter_m)
3928 / (domain.reference_density_kg_per_m3.max(1.0e-12) * friction_factor.max(1.0e-12)))
3929 .max(0.0)
3930 .sqrt();
3931 axial_velocity[node] = 0.50 * axial_velocity[node] + 0.50 * pressure_driven_speed;
3932 }
3933 axial_velocity[node_count - 1] = axial_velocity[node_count - 2];
3934
3935 let pressure_correction_norm = pressure
3936 .iter()
3937 .zip(previous_pressure.iter())
3938 .map(|(current, previous)| (current - previous) * (current - previous))
3939 .sum::<f64>()
3940 .sqrt();
3941 let pressure_scale = target_pressure
3942 .iter()
3943 .map(|value| value * value)
3944 .sum::<f64>()
3945 .sqrt()
3946 .max(1.0);
3947 pressure_correction_residual_ratio = pressure_correction_norm / pressure_scale;
3948 let velocity_correction_norm = axial_velocity
3949 .iter()
3950 .zip(previous_velocity.iter())
3951 .map(|(current, previous)| (current - previous) * (current - previous))
3952 .sum::<f64>()
3953 .sqrt();
3954 let velocity_scale = axial_velocity
3955 .iter()
3956 .map(|value| value * value)
3957 .sum::<f64>()
3958 .sqrt()
3959 .max(inlet_velocity.abs())
3960 .max(1.0e-12);
3961 velocity_correction_residual_ratio = velocity_correction_norm / velocity_scale;
3962 pressure_correction_iteration_count = iteration + 1;
3963 if pressure_correction_residual_ratio <= correction_tolerance
3964 && velocity_correction_residual_ratio <= correction_tolerance
3965 {
3966 break;
3967 }
3968 }
3969 pressure = target_pressure;
3970
3971 let mut velocity = Vec::with_capacity(node_count * 3);
3972 for (node, axial) in axial_velocity.iter().copied().enumerate() {
3973 let xi = node as f64 / denom;
3974 let recirculation = (2.0 * std::f64::consts::PI * xi).sin()
3975 * axial
3976 * domain.turbulence_intensity.clamp(0.0, 1.0)
3977 * 0.02;
3978 velocity.extend_from_slice(&[axial, recirculation, 0.0]);
3979 }
3980
3981 let (residual_momentum, residual_continuity) =
3982 cfd_residual_norms(&velocity, &pressure, domain, topology, step_count);
3983 let mass_balance_residual = residual_continuity.iter().copied().fold(0.0_f64, f64::max);
3984 let inlet_velocity_realization_ratio =
3985 inlet_velocity.abs() / nominal_inlet_velocity.abs().max(1.0e-12);
3986 let (transient_scale_min, transient_scale_max) = cfd_transient_scale_bounds(domain);
3987
3988 CfdVelocityPressureSolution {
3989 topology: topology.clone(),
3990 velocity,
3991 pressure,
3992 residual_momentum,
3993 residual_continuity,
3994 mass_balance_residual,
3995 pressure_drop_pa,
3996 control_volume_count: topology.control_volume_count,
3997 inlet_boundary_count: boundary_summary.inlet_boundary_count,
3998 outlet_boundary_count: boundary_summary.outlet_boundary_count,
3999 wall_boundary_count: boundary_summary.wall_boundary_count(),
4000 no_slip_wall_boundary_count: boundary_summary.no_slip_wall_boundary_count,
4001 slip_wall_boundary_count: boundary_summary.slip_wall_boundary_count,
4002 symmetry_boundary_count: boundary_summary.symmetry_boundary_count,
4003 authored_boundary_count: boundary_summary.authored_boundary_count,
4004 boundary_coverage_ratio: boundary_summary.boundary_coverage_ratio,
4005 wall_boundary_coverage_ratio: boundary_summary.wall_boundary_coverage_ratio,
4006 inlet_velocity_realization_ratio,
4007 nominal_inlet_velocity_m_per_s: nominal_inlet_velocity,
4008 outlet_pressure_pa: boundary_summary.outlet_pressure_pa,
4009 pressure_correction_iteration_count,
4010 pressure_correction_residual_ratio,
4011 velocity_correction_residual_ratio,
4012 transient_scale_min,
4013 transient_scale_max,
4014 transient_scale_variation: (transient_scale_max - transient_scale_min).abs(),
4015 }
4016}
4017
4018fn cfd_transient_scale_bounds(domain: &runmat_analysis_core::CfdDomain) -> (f64, f64) {
4019 if domain.time_profile.is_empty() {
4020 return (1.0, 1.0);
4021 }
4022 let (min, max) = domain
4023 .time_profile
4024 .iter()
4025 .filter_map(|point| point.inlet_scale.is_finite().then_some(point.inlet_scale))
4026 .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), scale| {
4027 (min.min(scale), max.max(scale))
4028 });
4029 if min.is_finite() && max.is_finite() {
4030 (min, max)
4031 } else {
4032 (1.0, 1.0)
4033 }
4034}
4035
4036fn cfd_known_answer_metrics(solution: &CfdVelocityPressureSolution) -> CfdKnownAnswerMetrics {
4037 let node_count = solution.pressure.len();
4038 let pressure_drop_observed = match (solution.pressure.first(), solution.pressure.last()) {
4039 (Some(first), Some(last)) => first - last,
4040 _ => 0.0,
4041 };
4042 let pressure_drop_balance_ratio = if solution.pressure_drop_pa.abs() > 1.0e-12 {
4043 pressure_drop_observed / solution.pressure_drop_pa
4044 } else if pressure_drop_observed.abs() <= 1.0e-12 {
4045 1.0
4046 } else {
4047 0.0
4048 };
4049
4050 let axial_values = (0..node_count)
4051 .map(|node| solution.velocity.get(node * 3).copied().unwrap_or(0.0))
4052 .collect::<Vec<_>>();
4053 let mean_axial = if axial_values.is_empty() {
4054 0.0
4055 } else {
4056 axial_values.iter().sum::<f64>() / axial_values.len() as f64
4057 };
4058 let max_axial_deviation = axial_values
4059 .iter()
4060 .map(|value| (value - mean_axial).abs())
4061 .fold(0.0_f64, f64::max);
4062 let mass_flux_uniformity_ratio = max_axial_deviation / mean_axial.abs().max(1.0e-12);
4063
4064 let pressure_edge_count = node_count.saturating_sub(1);
4065 let pressure_monotonic_cell_fraction = if pressure_edge_count == 0 {
4066 1.0
4067 } else {
4068 let tolerance = solution.pressure_drop_pa.abs().max(1.0) * 1.0e-12;
4069 let monotonic_edges = solution
4070 .pressure
4071 .windows(2)
4072 .filter(|pair| pair[0] + tolerance >= pair[1])
4073 .count();
4074 monotonic_edges as f64 / pressure_edge_count as f64
4075 };
4076
4077 let known_answer_coverage_ratio = if node_count >= 2
4078 && solution.control_volume_count == node_count - 1
4079 && solution.velocity.len() == node_count * 3
4080 && solution.pressure_drop_pa.is_finite()
4081 && solution.pressure.iter().all(|value| value.is_finite())
4082 && solution.velocity.iter().all(|value| value.is_finite())
4083 {
4084 1.0
4085 } else {
4086 0.0
4087 };
4088
4089 CfdKnownAnswerMetrics {
4090 pressure_drop_balance_ratio,
4091 mass_flux_uniformity_ratio,
4092 pressure_monotonic_cell_fraction,
4093 known_answer_coverage_ratio,
4094 }
4095}
4096
4097fn cfd_known_answer_diagnostic(
4098 metrics: &CfdKnownAnswerMetrics,
4099 topology: &CfdDomainTopology,
4100) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
4101 let severity = if (metrics.pressure_drop_balance_ratio - 1.0).abs() <= 1.0e-10
4102 && metrics.mass_flux_uniformity_ratio <= 1.0e-10
4103 && metrics.pressure_monotonic_cell_fraction >= 1.0
4104 && metrics.known_answer_coverage_ratio >= 1.0
4105 {
4106 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4107 } else {
4108 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4109 };
4110
4111 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4112 code: "FEA_CFD_KNOWN_ANSWER".to_string(),
4113 severity,
4114 message: format!(
4115 "basis=finite_volume_channel topology_basis={} pressure_drop_balance_ratio={} mass_flux_uniformity_ratio={} pressure_monotonic_cell_fraction={} known_answer_coverage_ratio={}",
4116 topology.basis.as_str(),
4117 metrics.pressure_drop_balance_ratio,
4118 metrics.mass_flux_uniformity_ratio,
4119 metrics.pressure_monotonic_cell_fraction,
4120 metrics.known_answer_coverage_ratio,
4121 ),
4122 }
4123}
4124
4125fn recover_cfd_vorticity(velocity: &[f64], node_count: usize, dx_m: f64) -> Vec<f64> {
4126 let mut vorticity = vec![0.0; node_count * 3];
4127 if node_count < 2 {
4128 return vorticity;
4129 }
4130 let dx_m = dx_m.max(1.0e-12);
4131
4132 for node in 0..node_count {
4133 let prev = node.saturating_sub(1);
4134 let next = (node + 1).min(node_count - 1);
4135 let prev_base = prev * 3;
4136 let next_base = next * 3;
4137 let dvx = velocity.get(next_base).copied().unwrap_or(0.0)
4138 - velocity.get(prev_base).copied().unwrap_or(0.0);
4139 let dvy = velocity.get(next_base + 1).copied().unwrap_or(0.0)
4140 - velocity.get(prev_base + 1).copied().unwrap_or(0.0);
4141 let base = node * 3;
4142 vorticity[base] = 0.0;
4143 vorticity[base + 1] = -dvx / (2.0 * dx_m);
4144 vorticity[base + 2] = dvy / (2.0 * dx_m);
4145 }
4146
4147 vorticity
4148}
4149
4150fn recover_cfd_wall_shear_stress(
4151 domain: &runmat_analysis_core::CfdDomain,
4152 velocity: &[f64],
4153 field_count: usize,
4154) -> Vec<f64> {
4155 let mut shear = vec![0.0; field_count * 3];
4156 let viscosity = domain.dynamic_viscosity_pa_s;
4157 for index in 0..field_count {
4158 let base = index * 3;
4159 shear[base] = viscosity * velocity.get(base).copied().unwrap_or(0.0);
4160 shear[base + 1] = viscosity * velocity.get(base + 1).copied().unwrap_or(0.0);
4161 }
4162 shear
4163}
4164
4165fn cfd_residual_norms(
4166 velocity: &[f64],
4167 pressure: &[f64],
4168 domain: &runmat_analysis_core::CfdDomain,
4169 topology: &CfdDomainTopology,
4170 step_count: usize,
4171) -> (Vec<f64>, Vec<f64>) {
4172 let node_count = pressure.len().max(1);
4173 let reynolds = cfd_reynolds_number(domain).max(1.0);
4174 let hydraulic_diameter_m = topology.hydraulic_diameter_m.max(1.0e-12);
4175 let friction_factor = if reynolds <= 2300.0 {
4176 64.0 / reynolds
4177 } else {
4178 0.3164 / reynolds.powf(0.25)
4179 };
4180 let mean_axial_velocity = (0..node_count)
4181 .map(|node| velocity.get(node * 3).copied().unwrap_or(0.0))
4182 .sum::<f64>()
4183 / node_count as f64;
4184 let friction_gradient_pa_per_m = 0.5
4185 * domain.reference_density_kg_per_m3.max(1.0e-12)
4186 * mean_axial_velocity
4187 * mean_axial_velocity.abs()
4188 * friction_factor
4189 / hydraulic_diameter_m;
4190 let dx = topology.dx_m.max(1.0e-12);
4191 let mut momentum_base = 0.0_f64;
4192 let mut continuity_base = 0.0_f64;
4193 for node in 0..node_count {
4194 let prev = node.saturating_sub(1);
4195 let next = (node + 1).min(node_count - 1);
4196 let velocity_prev = velocity.get(prev * 3).copied().unwrap_or(0.0);
4197 let velocity_next = velocity.get(next * 3).copied().unwrap_or(0.0);
4198 let pressure_prev = pressure.get(prev).copied().unwrap_or(0.0);
4199 let pressure_next = pressure.get(next).copied().unwrap_or(0.0);
4200 let stencil_width = if prev == next {
4201 1.0
4202 } else {
4203 (next - prev) as f64 * dx
4204 };
4205 let divergence = (velocity_next - velocity_prev) / stencil_width.max(1.0e-12);
4206 let pressure_gradient = (pressure_next - pressure_prev) / stencil_width.max(1.0e-12);
4207 continuity_base += divergence.abs();
4208 momentum_base += (pressure_gradient + friction_gradient_pa_per_m).abs();
4209 }
4210 let velocity_scale = mean_axial_velocity
4211 .abs()
4212 .max(domain.inlet_velocity_m_per_s)
4213 .max(1.0e-9);
4214 let pressure_scale = pressure
4215 .iter()
4216 .copied()
4217 .map(f64::abs)
4218 .fold(0.0_f64, f64::max)
4219 .max(1.0);
4220 let continuity_base = (continuity_base / (node_count as f64 * velocity_scale)).clamp(0.0, 1.0);
4221 let momentum_base = (momentum_base / (node_count as f64 * pressure_scale)).clamp(0.0, 1.0);
4222 let residual_count = step_count.max(1);
4223 (
4224 vec![momentum_base; residual_count],
4225 vec![continuity_base; residual_count],
4226 )
4227}
4228
4229fn build_cfd_run_fields(
4230 domain: &runmat_analysis_core::CfdDomain,
4231 solution: &CfdVelocityPressureSolution,
4232) -> Vec<AnalysisField> {
4233 let node_count = solution.pressure.len();
4234 let velocity = cell_centered_vector_from_nodal(&solution.velocity, node_count);
4235 let pressure = cell_centered_scalar_from_nodal(&solution.pressure);
4236 let cell_count = pressure.len().max(1);
4237 let vorticity = recover_cfd_vorticity(&velocity, cell_count, solution.topology.dx_m);
4238 let boundary_face_count = solution.wall_boundary_count.max(1);
4239 let wall_shear_stress = recover_cfd_wall_shear_stress(domain, &velocity, boundary_face_count);
4240 let residual_count = solution.residual_momentum.len();
4241
4242 vec![
4243 AnalysisField::host_f64(FEA_FIELD_CFD_VELOCITY, vec![cell_count, 3], velocity),
4244 AnalysisField::host_f64(FEA_FIELD_CFD_PRESSURE, vec![cell_count], pressure),
4245 AnalysisField::host_f64(FEA_FIELD_CFD_VORTICITY, vec![cell_count, 3], vorticity),
4246 AnalysisField::host_f64(
4247 FEA_FIELD_CFD_WALL_SHEAR_STRESS,
4248 vec![boundary_face_count, 3],
4249 wall_shear_stress,
4250 ),
4251 AnalysisField::host_f64(
4252 FEA_FIELD_CFD_RESIDUAL_MOMENTUM,
4253 vec![residual_count],
4254 solution.residual_momentum.clone(),
4255 ),
4256 AnalysisField::host_f64(
4257 FEA_FIELD_CFD_RESIDUAL_CONTINUITY,
4258 vec![residual_count],
4259 solution.residual_continuity.clone(),
4260 ),
4261 AnalysisField::host_f64(
4262 FEA_FIELD_CFD_REYNOLDS_NUMBER,
4263 vec![1],
4264 vec![cfd_reynolds_number(domain)],
4265 ),
4266 ]
4267}
4268
4269fn cfd_assembly_diagnostic(
4270 topology: &CfdDomainTopology,
4271 domain: &runmat_analysis_core::CfdDomain,
4272 time_step_s: f64,
4273 pressure_drop_pa: f64,
4274 mass_balance_residual: f64,
4275 residual_warn_threshold: f64,
4276) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
4277 let face_area_m2 = topology.face_area_m2();
4278 let control_volume_volume_m3 = topology.control_volume_volume_m3();
4279 let nominal_mass_flow_rate_kg_per_s =
4280 domain.reference_density_kg_per_m3 * domain.inlet_velocity_m_per_s * face_area_m2;
4281 let courant_number =
4282 domain.inlet_velocity_m_per_s.abs() * time_step_s.max(0.0) / topology.dx_m.max(1.0e-12);
4283 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4284 code: "FEA_CFD_ASSEMBLY".to_string(),
4285 severity: if mass_balance_residual <= residual_warn_threshold {
4286 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4287 } else {
4288 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4289 },
4290 message: format!(
4291 "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={}",
4292 topology.basis.as_str(),
4293 topology.geometry_source.as_str(),
4294 topology.control_volume_count,
4295 topology.control_volume_face_count,
4296 topology.control_volume_internal_face_count,
4297 topology.control_volume_boundary_face_count,
4298 topology.control_volume_connectivity_coverage_ratio,
4299 topology.hydraulic_diameter_m,
4300 topology.domain_length_m,
4301 topology.dx_m,
4302 face_area_m2,
4303 control_volume_volume_m3,
4304 nominal_mass_flow_rate_kg_per_s,
4305 courant_number,
4306 topology.active_dimension_count,
4307 topology.element_geometry_node_count,
4308 topology.element_geometry_edge_count,
4309 topology.element_geometry_coverage_ratio,
4310 topology.element_topology_sample_element_count,
4311 topology.element_topology_sample_edge_count,
4312 pressure_drop_pa,
4313 mass_balance_residual,
4314 ),
4315 }
4316}
4317
4318fn pressure_drop_from_nodal_pressure(pressure: &[f64]) -> f64 {
4319 match (pressure.first(), pressure.last()) {
4320 (Some(first), Some(last)) => first - last,
4321 _ => 0.0,
4322 }
4323}
4324
4325fn cell_centered_vector_from_nodal(nodal: &[f64], node_count: usize) -> Vec<f64> {
4326 let cell_count = node_count.saturating_sub(1).max(1);
4327 let mut cell_values = Vec::with_capacity(cell_count * 3);
4328 for cell in 0..cell_count {
4329 let left = cell.min(node_count.saturating_sub(1));
4330 let right = (cell + 1).min(node_count.saturating_sub(1));
4331 for component in 0..3 {
4332 let left_value = nodal.get(left * 3 + component).copied().unwrap_or(0.0);
4333 let right_value = nodal
4334 .get(right * 3 + component)
4335 .copied()
4336 .unwrap_or(left_value);
4337 cell_values.push(0.5 * (left_value + right_value));
4338 }
4339 }
4340 cell_values
4341}
4342
4343fn cell_centered_scalar_from_nodal(nodal: &[f64]) -> Vec<f64> {
4344 let node_count = nodal.len();
4345 let cell_count = node_count.saturating_sub(1).max(1);
4346 let mut cell_values = Vec::with_capacity(cell_count);
4347 for cell in 0..cell_count {
4348 let left = cell.min(node_count.saturating_sub(1));
4349 let right = (cell + 1).min(node_count.saturating_sub(1));
4350 let left_value = nodal.get(left).copied().unwrap_or(0.0);
4351 let right_value = nodal.get(right).copied().unwrap_or(left_value);
4352 cell_values.push(0.5 * (left_value + right_value));
4353 }
4354 cell_values
4355}
4356
4357fn resample_scalar_profile(values: &[f64], target_count: usize) -> Vec<f64> {
4358 let target_count = target_count.max(1);
4359 if values.is_empty() {
4360 return vec![0.0; target_count];
4361 }
4362 if values.len() == target_count {
4363 return values.to_vec();
4364 }
4365 if target_count == 1 {
4366 return vec![values[0]];
4367 }
4368 let source_max = values.len().saturating_sub(1) as f64;
4369 let target_max = target_count.saturating_sub(1) as f64;
4370 (0..target_count)
4371 .map(|target_index| {
4372 let source_position = target_index as f64 * source_max / target_max.max(1.0);
4373 let left = source_position.floor() as usize;
4374 let right = source_position.ceil() as usize;
4375 if left == right {
4376 values.get(left).copied().unwrap_or(0.0)
4377 } else {
4378 let t = source_position - left as f64;
4379 let left_value = values.get(left).copied().unwrap_or(0.0);
4380 let right_value = values.get(right).copied().unwrap_or(left_value);
4381 left_value * (1.0 - t) + right_value * t
4382 }
4383 })
4384 .collect()
4385}
4386
4387fn fluid_interface_face_count(topology: &CfdDomainTopology) -> usize {
4388 if topology.control_volume_connectivity_coverage_ratio > 0.0
4389 && topology.control_volume_boundary_face_count > 0
4390 {
4391 topology.control_volume_boundary_face_count
4392 } else {
4393 topology.control_volume_count
4394 }
4395 .max(1)
4396}
4397
4398fn coupled_interface_graph_edge_target(
4399 topology: &CfdDomainTopology,
4400 interface_face_count: usize,
4401) -> usize {
4402 if interface_face_count < 2 {
4403 return 0;
4404 }
4405 let line_edge_count = interface_face_count - 1;
4406 let complete_graph_edge_count = interface_face_count * (interface_face_count - 1) / 2;
4407 if topology.control_volume_connectivity_coverage_ratio > 0.0 {
4408 topology
4409 .control_volume_internal_face_count
4410 .max(line_edge_count)
4411 .min(complete_graph_edge_count)
4412 } else {
4413 line_edge_count
4414 }
4415}
4416
4417fn coupled_interface_graph_edges_for_topology(
4418 topology: &CfdDomainTopology,
4419 interface_face_count: usize,
4420) -> Vec<(usize, usize)> {
4421 use std::collections::BTreeSet;
4422
4423 let target = coupled_interface_graph_edge_target(topology, interface_face_count);
4424 if target == 0 {
4425 return Vec::new();
4426 }
4427
4428 let mut seen = BTreeSet::<(usize, usize)>::new();
4429 let mut edges = Vec::with_capacity(target);
4430
4431 let full_edge_count = topology
4432 .element_topology_edge_nodes
4433 .len()
4434 .min(interface_face_count);
4435 if full_edge_count > 0 {
4436 for element_edges in &topology.element_topology_element_edges {
4437 let local_edges = element_edges
4438 .iter()
4439 .map(|edge| *edge as usize)
4440 .filter(|edge| *edge < full_edge_count)
4441 .collect::<Vec<_>>();
4442 if local_edges.len() < 2 {
4443 continue;
4444 }
4445 let pair_count = if local_edges.len() == 2 {
4446 1
4447 } else {
4448 local_edges.len()
4449 };
4450 for offset in 0..pair_count {
4451 let next = if offset + 1 < local_edges.len() {
4452 offset + 1
4453 } else {
4454 0
4455 };
4456 let left = local_edges[offset].min(local_edges[next]);
4457 let right = local_edges[offset].max(local_edges[next]);
4458 if left != right && seen.insert((left, right)) {
4459 edges.push((left, right));
4460 if edges.len() == target {
4461 return edges;
4462 }
4463 }
4464 }
4465 }
4466 }
4467
4468 let sample_edge_count = topology
4469 .element_topology_sample_edge_count
4470 .min(interface_face_count);
4471 if edges.is_empty() {
4472 for element_edges in topology
4473 .element_topology_sample_element_edges
4474 .iter()
4475 .take(topology.element_topology_sample_element_count.min(4))
4476 {
4477 let local_edges = element_edges
4478 .iter()
4479 .map(|edge| *edge as usize)
4480 .filter(|edge| *edge < sample_edge_count)
4481 .collect::<Vec<_>>();
4482 if local_edges.len() < 2 {
4483 continue;
4484 }
4485 let pair_count = if local_edges.len() == 2 {
4486 1
4487 } else {
4488 local_edges.len()
4489 };
4490 for offset in 0..pair_count {
4491 let next = if offset + 1 < local_edges.len() {
4492 offset + 1
4493 } else {
4494 0
4495 };
4496 let left = local_edges[offset].min(local_edges[next]);
4497 let right = local_edges[offset].max(local_edges[next]);
4498 if left != right && seen.insert((left, right)) {
4499 edges.push((left, right));
4500 if edges.len() == target {
4501 return edges;
4502 }
4503 }
4504 }
4505 }
4506 }
4507
4508 for (left, right) in coupled_interface_graph_edges(interface_face_count, target) {
4509 let edge = (left.min(right), left.max(right));
4510 if seen.insert(edge) {
4511 edges.push(edge);
4512 if edges.len() == target {
4513 break;
4514 }
4515 }
4516 }
4517 edges
4518}
4519
4520fn coupled_interface_connectivity_coverage_ratio(
4521 topology: &CfdDomainTopology,
4522 interface_face_count: usize,
4523 edge_count: usize,
4524) -> f64 {
4525 let target = coupled_interface_graph_edge_target(topology, interface_face_count);
4526 if target == 0 {
4527 return 1.0;
4528 }
4529 (edge_count as f64 / target as f64).clamp(0.0, 1.0)
4530}
4531
4532fn coupled_interface_mesh_backed_connectivity_ratio(
4533 topology: &CfdDomainTopology,
4534 edge_count: usize,
4535) -> f64 {
4536 if topology.basis == CfdDomainTopologyBasis::PrepControlVolumeConnectivity
4537 && topology.control_volume_connectivity_coverage_ratio > 0.0
4538 && edge_count > 0
4539 {
4540 1.0
4541 } else {
4542 0.0
4543 }
4544}
4545
4546fn solve_cfd_finite_volume_run(
4547 model: &AnalysisModel,
4548 domain: &runmat_analysis_core::CfdDomain,
4549 backend: ComputeBackend,
4550 options: &AnalysisCfdRunOptions,
4551 prep_context: Option<&AnalysisRunPrepContext>,
4552) -> FeaRunResult {
4553 let topology = CfdDomainTopology::from_model(model, prep_context);
4554 let node_count = topology.node_count;
4555 let step_count = options.step_count.max(1);
4556 let field_step = match domain.solve_family {
4557 runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
4558 runmat_analysis_core::CfdSolveFamily::Transient => step_count.saturating_sub(1),
4559 };
4560 let boundary_summary = CfdBoundarySummary::from_model(model, domain, node_count);
4561 let solution = solve_cfd_velocity_pressure(
4562 domain,
4563 &boundary_summary,
4564 &topology,
4565 field_step,
4566 step_count,
4567 options.max_linear_iters,
4568 options.tolerance,
4569 );
4570 let max_momentum_residual = solution
4571 .residual_momentum
4572 .iter()
4573 .copied()
4574 .fold(0.0_f64, f64::max);
4575 let max_continuity_residual = solution
4576 .residual_continuity
4577 .iter()
4578 .copied()
4579 .fold(0.0_f64, f64::max);
4580 let known_answer_metrics = cfd_known_answer_metrics(&solution);
4581 let fields = build_cfd_run_fields(domain, &solution);
4582 let residual_severity = if max_momentum_residual <= options.residual_warn_threshold
4583 && max_continuity_residual <= options.residual_warn_threshold
4584 {
4585 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4586 } else {
4587 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4588 };
4589 FeaRunResult {
4590 backend,
4591 solver_backend: "cpu_reference".to_string(),
4592 solver_device_apply_k_ratio: 0.0,
4593 solver_method: "cfd_velocity_pressure_finite_volume".to_string(),
4594 preconditioner: "finite_volume_pressure_balance".to_string(),
4595 solver_host_sync_count: 0,
4596 diagnostics: vec![
4597 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4598 code: "FEA_CFD_RESIDUAL".to_string(),
4599 severity: residual_severity,
4600 message: format!(
4601 "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
4602 max_momentum_residual,
4603 max_continuity_residual,
4604 options.residual_warn_threshold,
4605 node_count,
4606 step_count,
4607 ),
4608 },
4609 cfd_assembly_diagnostic(
4610 &solution.topology,
4611 domain,
4612 options.time_step_s,
4613 solution.pressure_drop_pa,
4614 solution.mass_balance_residual,
4615 options.residual_warn_threshold,
4616 ),
4617 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4618 code: "FEA_CFD_BOUNDARY_CONDITIONS".to_string(),
4619 severity: if solution.boundary_coverage_ratio >= 1.0
4620 && solution.wall_boundary_coverage_ratio >= 1.0
4621 && solution.inlet_velocity_realization_ratio.is_finite()
4622 {
4623 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4624 } else {
4625 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4626 },
4627 message: format!(
4628 "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={}",
4629 if solution.authored_boundary_count > 0 {
4630 "authored"
4631 } else {
4632 "implicit_channel"
4633 },
4634 solution.authored_boundary_count,
4635 solution.inlet_boundary_count,
4636 solution.outlet_boundary_count,
4637 solution.wall_boundary_count,
4638 solution.no_slip_wall_boundary_count,
4639 solution.slip_wall_boundary_count,
4640 solution.symmetry_boundary_count,
4641 solution.boundary_coverage_ratio,
4642 solution.wall_boundary_coverage_ratio,
4643 solution.nominal_inlet_velocity_m_per_s,
4644 solution.outlet_pressure_pa,
4645 solution.inlet_velocity_realization_ratio,
4646 ),
4647 },
4648 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4649 code: "FEA_CFD_PRESSURE_CORRECTION".to_string(),
4650 severity: if solution.pressure_correction_residual_ratio <= options.tolerance
4651 && solution.velocity_correction_residual_ratio <= options.tolerance
4652 {
4653 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4654 } else {
4655 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4656 },
4657 message: format!(
4658 "iteration_count={} max_linear_iters={} tolerance={} pressure_correction_residual_ratio={} velocity_correction_residual_ratio={}",
4659 solution.pressure_correction_iteration_count,
4660 options.max_linear_iters,
4661 options.tolerance,
4662 solution.pressure_correction_residual_ratio,
4663 solution.velocity_correction_residual_ratio,
4664 ),
4665 },
4666 runmat_analysis_fea::diagnostics::FeaDiagnostic {
4667 code: "FEA_CFD_TRANSIENT_EVOLUTION".to_string(),
4668 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
4669 message: format!(
4670 "solve_family={} step_count={} time_step_s={} transient_profile_point_count={} transient_scale_min={} transient_scale_max={} transient_scale_variation={}",
4671 match domain.solve_family {
4672 runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
4673 runmat_analysis_core::CfdSolveFamily::Transient => "transient",
4674 },
4675 step_count,
4676 options.time_step_s,
4677 domain.time_profile.len(),
4678 solution.transient_scale_min,
4679 solution.transient_scale_max,
4680 solution.transient_scale_variation,
4681 ),
4682 },
4683 cfd_known_answer_diagnostic(&known_answer_metrics, &topology),
4684 ],
4685 fields,
4686 }
4687}
4688
4689fn field_scalar_magnitudes(field: &AnalysisField, fallback_len: usize) -> Vec<f64> {
4690 let Some(values) = field.as_host_f64() else {
4691 return vec![0.0; fallback_len.max(1)];
4692 };
4693 match field.shape.as_slice() {
4694 [count, components] if *components > 1 => {
4695 let mut magnitudes = Vec::with_capacity(*count);
4696 for index in 0..*count {
4697 let start = index * *components;
4698 let magnitude = values
4699 .get(start..start + *components)
4700 .unwrap_or(&[])
4701 .iter()
4702 .map(|value| value * value)
4703 .sum::<f64>()
4704 .sqrt();
4705 magnitudes.push(magnitude);
4706 }
4707 magnitudes
4708 }
4709 _ => values.to_vec(),
4710 }
4711}
4712
4713fn build_cht_run_fields(
4714 domain: &runmat_analysis_core::CfdDomain,
4715 topology: &CfdDomainTopology,
4716 thermal_run: &runmat_analysis_fea::FeaThermalRunResult,
4717 authored_interface_conductance_w_per_m2k: Option<f64>,
4718 max_linear_iters: usize,
4719 tolerance: f64,
4720) -> (Vec<AnalysisField>, ChtInterfaceClosure) {
4721 let node_count = topology.node_count;
4722 let (fluid_velocity, fluid_pressure) = recover_cfd_velocity_pressure(domain, topology, 0);
4723 let mean_axial_velocity = mean_cfd_axial_velocity(&fluid_velocity);
4724 let mut fields = vec![
4725 AnalysisField::host_f64(
4726 FEA_FIELD_CHT_FLUID_VELOCITY,
4727 vec![node_count, 3],
4728 fluid_velocity,
4729 ),
4730 AnalysisField::host_f64(
4731 FEA_FIELD_CHT_FLUID_PRESSURE,
4732 vec![node_count],
4733 fluid_pressure,
4734 ),
4735 ];
4736 let mut closure = ChtInterfaceClosure::default();
4737
4738 for (step_index, temperature) in thermal_run.temperature_snapshots.iter().enumerate() {
4739 let fallback_len = temperature.element_count().max(1);
4740 let base_temperature = temperature
4741 .as_host_f64()
4742 .map(|values| values.to_vec())
4743 .unwrap_or_else(|| vec![thermal_run.reference_temperature_k; fallback_len]);
4744 let mut heat_flux = thermal_run
4745 .heat_flux_snapshots
4746 .get(step_index)
4747 .map(|field| field_scalar_magnitudes(field, fallback_len))
4748 .unwrap_or_else(|| vec![0.0; fallback_len]);
4749 if heat_flux.is_empty() {
4750 heat_flux.push(0.0);
4751 }
4752 let target_interface_count = fluid_interface_face_count(topology);
4753 if heat_flux.len() != target_interface_count {
4754 heat_flux = resample_scalar_profile(&heat_flux, target_interface_count);
4755 }
4756 let interface_count = heat_flux.len();
4757 let max_heat_flux = heat_flux
4758 .iter()
4759 .copied()
4760 .map(f64::abs)
4761 .fold(0.0_f64, f64::max);
4762 let target_jump_k = 0.01_f64;
4763 let interface_conductance_w_per_m2k = authored_interface_conductance_w_per_m2k
4764 .unwrap_or_else(|| (max_heat_flux / target_jump_k).max(25.0))
4765 .max(1.0e-12);
4766 let advection_shift_k = cht_advection_shift_k(
4767 domain,
4768 mean_axial_velocity,
4769 &base_temperature,
4770 thermal_run.reference_temperature_k,
4771 );
4772
4773 let interface_solution = solve_cht_conjugate_interface(
4774 &base_temperature,
4775 &heat_flux,
4776 topology,
4777 interface_conductance_w_per_m2k,
4778 advection_shift_k,
4779 thermal_run.reference_temperature_k,
4780 max_linear_iters,
4781 tolerance,
4782 );
4783 let fluid_temperature = interface_solution.fluid_temperature;
4784 let solid_temperature = interface_solution.solid_temperature;
4785 let temperature_jump = interface_solution.temperature_jump;
4786 let coupled_heat_flux = interface_solution.coupled_heat_flux;
4787 fields.push(AnalysisField::host_f64(
4788 fea_cht_fluid_temperature_field_id(step_index),
4789 vec![base_temperature.len().max(1)],
4790 fluid_temperature,
4791 ));
4792 fields.push(AnalysisField::host_f64(
4793 fea_cht_solid_temperature_field_id(step_index),
4794 vec![base_temperature.len().max(1)],
4795 solid_temperature,
4796 ));
4797
4798 closure.interface_face_count = closure.interface_face_count.max(interface_count);
4799 closure.max_temperature_jump_k = closure.max_temperature_jump_k.max(
4800 temperature_jump
4801 .iter()
4802 .copied()
4803 .map(f64::abs)
4804 .fold(0.0_f64, f64::max),
4805 );
4806 closure.max_advection_temperature_shift_k = closure
4807 .max_advection_temperature_shift_k
4808 .max(advection_shift_k.abs());
4809 closure.interface_conductance_w_per_m2k = closure
4810 .interface_conductance_w_per_m2k
4811 .max(interface_conductance_w_per_m2k);
4812 closure.max_flux_temperature_law_residual_ratio = closure
4813 .max_flux_temperature_law_residual_ratio
4814 .max(interface_solution.flux_temperature_law_residual_ratio);
4815 closure.max_heat_flux_realization_residual_ratio = closure
4816 .max_heat_flux_realization_residual_ratio
4817 .max(interface_solution.heat_flux_realization_residual_ratio);
4818 closure.max_coupled_interface_iteration_count = closure
4819 .max_coupled_interface_iteration_count
4820 .max(interface_solution.iteration_count);
4821 closure.max_coupled_interface_residual_ratio = closure
4822 .max_coupled_interface_residual_ratio
4823 .max(interface_solution.coupled_interface_residual_ratio);
4824 closure.thermal_network_edge_count = closure
4825 .thermal_network_edge_count
4826 .max(interface_solution.thermal_network_edge_count);
4827 closure.thermal_network_node_count = closure
4828 .thermal_network_node_count
4829 .max(interface_solution.thermal_network_node_count);
4830 closure.interface_connectivity_coverage_ratio = closure
4831 .interface_connectivity_coverage_ratio
4832 .max(interface_solution.interface_connectivity_coverage_ratio);
4833 closure.mesh_backed_interface_connectivity_ratio = closure
4834 .mesh_backed_interface_connectivity_ratio
4835 .max(interface_solution.mesh_backed_interface_connectivity_ratio);
4836 closure.full_topology_edge_count = closure
4837 .full_topology_edge_count
4838 .max(interface_solution.full_topology_edge_count);
4839 closure.full_topology_element_count = closure
4840 .full_topology_element_count
4841 .max(interface_solution.full_topology_element_count);
4842 closure.max_thermal_network_residual_ratio = closure
4843 .max_thermal_network_residual_ratio
4844 .max(interface_solution.thermal_network_residual_ratio);
4845 let fluid_heat = coupled_heat_flux.iter().sum::<f64>();
4846 let solid_heat = -fluid_heat;
4847 let heat_balance_ratio =
4848 (fluid_heat + solid_heat).abs() / (fluid_heat.abs() + solid_heat.abs() + 1.0e-12);
4849 closure.heat_flux_balance_ratio = closure.heat_flux_balance_ratio.max(heat_balance_ratio);
4850 let thermal_scale_k = base_temperature
4851 .iter()
4852 .map(|value| (value - thermal_run.reference_temperature_k).abs())
4853 .fold(0.0_f64, f64::max)
4854 .max(1.0);
4855 let normalized_temperature_jump =
4856 closure.max_temperature_jump_k / thermal_scale_k.max(1.0e-12);
4857 closure.max_thermal_transport_residual_ratio =
4858 closure.max_thermal_transport_residual_ratio.max(
4859 interface_solution
4860 .flux_temperature_law_residual_ratio
4861 .max(heat_balance_ratio)
4862 .max(interface_solution.heat_flux_realization_residual_ratio)
4863 .max(interface_solution.coupled_interface_residual_ratio)
4864 .max(interface_solution.thermal_network_residual_ratio),
4865 );
4866 closure.interface_temperature_continuity_ratio = closure
4867 .interface_temperature_continuity_ratio
4868 .max((1.0 - normalized_temperature_jump).clamp(0.0, 1.0));
4869 let mean_heat_flux = if coupled_heat_flux.is_empty() {
4870 0.0
4871 } else {
4872 coupled_heat_flux.iter().sum::<f64>() / coupled_heat_flux.len() as f64
4873 };
4874 closure.mean_interface_heat_flux_w_per_m2 = closure
4875 .mean_interface_heat_flux_w_per_m2
4876 .max(mean_heat_flux.abs());
4877 fields.push(AnalysisField::host_f64(
4878 fea_cht_interface_heat_flux_field_id(step_index),
4879 vec![interface_count],
4880 coupled_heat_flux,
4881 ));
4882
4883 fields.push(AnalysisField::host_f64(
4884 fea_cht_interface_temperature_jump_field_id(step_index),
4885 vec![interface_count],
4886 temperature_jump,
4887 ));
4888 let energy_residual = heat_balance_ratio
4889 .max(interface_solution.flux_temperature_law_residual_ratio)
4890 .max(interface_solution.heat_flux_realization_residual_ratio)
4891 .max(interface_solution.thermal_network_residual_ratio);
4892 closure.max_energy_residual = closure.max_energy_residual.max(energy_residual);
4893 fields.push(AnalysisField::host_f64(
4894 fea_cht_energy_residual_field_id(step_index),
4895 vec![1],
4896 vec![energy_residual],
4897 ));
4898 }
4899
4900 (fields, closure)
4901}
4902
4903#[derive(Debug, Clone)]
4904struct ChtConjugateInterfaceSolution {
4905 fluid_temperature: Vec<f64>,
4906 solid_temperature: Vec<f64>,
4907 temperature_jump: Vec<f64>,
4908 coupled_heat_flux: Vec<f64>,
4909 iteration_count: usize,
4910 coupled_interface_residual_ratio: f64,
4911 flux_temperature_law_residual_ratio: f64,
4912 heat_flux_realization_residual_ratio: f64,
4913 thermal_network_edge_count: usize,
4914 thermal_network_node_count: usize,
4915 interface_connectivity_coverage_ratio: f64,
4916 mesh_backed_interface_connectivity_ratio: f64,
4917 full_topology_edge_count: usize,
4918 full_topology_element_count: usize,
4919 thermal_network_residual_ratio: f64,
4920}
4921
4922fn solve_cht_conjugate_interface(
4923 base_temperature: &[f64],
4924 heat_flux: &[f64],
4925 topology: &CfdDomainTopology,
4926 interface_conductance_w_per_m2k: f64,
4927 advection_shift_k: f64,
4928 reference_temperature_k: f64,
4929 _max_linear_iters: usize,
4930 _tolerance: f64,
4931) -> ChtConjugateInterfaceSolution {
4932 let temperature_count = base_temperature.len().max(1);
4933 let interface_count = heat_flux.len().max(1);
4934 let interface_denom = interface_count.saturating_sub(1).max(1) as f64;
4935 let conductance = interface_conductance_w_per_m2k.max(1.0e-12);
4936 let axial_conductance = (0.05 * conductance).max(1.0e-12);
4937 let anchor_conductance = conductance;
4938 let base_interface_temperature = resample_scalar_profile(base_temperature, interface_count);
4939 let thermal_network_edges =
4940 coupled_interface_graph_edges_for_topology(topology, interface_count);
4941 let interface_connectivity_coverage_ratio = coupled_interface_connectivity_coverage_ratio(
4942 topology,
4943 interface_count,
4944 thermal_network_edges.len(),
4945 );
4946 let mesh_backed_interface_connectivity_ratio =
4947 coupled_interface_mesh_backed_connectivity_ratio(topology, thermal_network_edges.len());
4948 let mut operator = vec![vec![0.0; interface_count]; interface_count];
4949 for (row, diagonal) in operator.iter_mut().enumerate() {
4950 diagonal[row] = anchor_conductance;
4951 }
4952 for (left, right) in &thermal_network_edges {
4953 operator[*left][*left] += axial_conductance;
4954 operator[*right][*right] += axial_conductance;
4955 operator[*left][*right] -= axial_conductance;
4956 operator[*right][*left] -= axial_conductance;
4957 }
4958 let mut center_rhs = Vec::with_capacity(interface_count);
4959
4960 for index in 0..interface_count {
4961 let base = base_interface_temperature
4962 .get(index)
4963 .copied()
4964 .unwrap_or(reference_temperature_k);
4965 let xi = index as f64 / interface_denom;
4966 let center_temperature = base + advection_shift_k * xi;
4967 center_rhs.push(anchor_conductance * center_temperature);
4968 }
4969
4970 let mut matrix = operator.clone();
4971 let mut rhs = center_rhs.clone();
4972 let interface_center_temperature = solve_dense_real_system(&mut matrix, &mut rhs);
4973 let center_reaction = apply_dense_real_operator(&operator, &interface_center_temperature);
4974 let temperature_scale = interface_center_temperature
4975 .iter()
4976 .map(|value| (*value - reference_temperature_k).abs())
4977 .fold(0.0_f64, f64::max)
4978 .max(1.0);
4979 let thermal_network_residual_ratio = center_reaction
4980 .iter()
4981 .zip(center_rhs.iter())
4982 .map(|(reaction, rhs)| (reaction - rhs).abs())
4983 .fold(0.0_f64, f64::max)
4984 / (anchor_conductance * temperature_scale).max(1.0e-12);
4985 let mut interface_fluid_temperature = Vec::with_capacity(interface_count);
4986 let mut interface_solid_temperature = Vec::with_capacity(interface_count);
4987 for (center, flux) in interface_center_temperature
4988 .iter()
4989 .zip(heat_flux.iter().chain(std::iter::repeat(&0.0)))
4990 {
4991 let jump = *flux / conductance;
4992 interface_fluid_temperature.push(*center + 0.5 * jump);
4993 interface_solid_temperature.push(*center - 0.5 * jump);
4994 }
4995 let fluid_temperature =
4996 resample_scalar_profile(&interface_fluid_temperature, temperature_count);
4997 let solid_temperature =
4998 resample_scalar_profile(&interface_solid_temperature, temperature_count);
4999 let iteration_count = usize::from(temperature_count > 0);
5000 let coupled_interface_residual_ratio = thermal_network_residual_ratio;
5001
5002 let mut temperature_jump = Vec::with_capacity(interface_count);
5003 let mut coupled_heat_flux = Vec::with_capacity(interface_count);
5004 let mut max_flux_temperature_law_residual = 0.0_f64;
5005 let mut max_heat_flux_realization_residual = 0.0_f64;
5006 let heat_flux_scale = heat_flux
5007 .iter()
5008 .copied()
5009 .map(f64::abs)
5010 .fold(0.0_f64, f64::max)
5011 .max(1.0e-12);
5012 for index in 0..interface_count {
5013 let jump = interface_fluid_temperature
5014 .get(index)
5015 .copied()
5016 .unwrap_or(reference_temperature_k)
5017 - interface_solid_temperature
5018 .get(index)
5019 .copied()
5020 .unwrap_or(reference_temperature_k);
5021 let coupled_flux = conductance * jump;
5022 let input_flux = heat_flux.get(index).copied().unwrap_or(0.0);
5023 max_flux_temperature_law_residual = max_flux_temperature_law_residual
5024 .max((conductance * jump - coupled_flux).abs() / heat_flux_scale);
5025 max_heat_flux_realization_residual = max_heat_flux_realization_residual
5026 .max((coupled_flux - input_flux).abs() / heat_flux_scale);
5027 temperature_jump.push(jump);
5028 coupled_heat_flux.push(coupled_flux);
5029 }
5030
5031 ChtConjugateInterfaceSolution {
5032 fluid_temperature,
5033 solid_temperature,
5034 temperature_jump,
5035 coupled_heat_flux,
5036 iteration_count,
5037 coupled_interface_residual_ratio,
5038 flux_temperature_law_residual_ratio: max_flux_temperature_law_residual,
5039 heat_flux_realization_residual_ratio: max_heat_flux_realization_residual,
5040 thermal_network_edge_count: thermal_network_edges.len(),
5041 thermal_network_node_count: interface_count,
5042 interface_connectivity_coverage_ratio,
5043 mesh_backed_interface_connectivity_ratio,
5044 full_topology_edge_count: topology.element_topology_edge_nodes.len(),
5045 full_topology_element_count: topology.element_topology_element_edges.len(),
5046 thermal_network_residual_ratio,
5047 }
5048}
5049
5050fn mean_cfd_axial_velocity(velocity: &[f64]) -> f64 {
5051 let node_count = velocity.len() / 3;
5052 if node_count == 0 {
5053 return 0.0;
5054 }
5055 (0..node_count)
5056 .map(|node| velocity.get(node * 3).copied().unwrap_or(0.0))
5057 .sum::<f64>()
5058 / node_count as f64
5059}
5060
5061fn cht_advection_shift_k(
5062 domain: &runmat_analysis_core::CfdDomain,
5063 mean_axial_velocity_m_per_s: f64,
5064 temperature: &[f64],
5065 reference_temperature_k: f64,
5066) -> f64 {
5067 let thermal_span_k = temperature
5068 .iter()
5069 .map(|value| (value - reference_temperature_k).abs())
5070 .fold(0.0_f64, f64::max)
5071 .max(1.0);
5072 let reynolds_ratio = (cfd_reynolds_number_for_velocity(domain, mean_axial_velocity_m_per_s)
5073 / 1.0e5)
5074 .clamp(0.0, 5.0);
5075 thermal_span_k * reynolds_ratio * domain.turbulence_intensity.clamp(0.0, 1.0) * 2.0e-3
5076}
5077
5078fn cht_interface_conductance_w_per_m2k(model: &AnalysisModel) -> Option<f64> {
5079 model
5080 .interfaces
5081 .iter()
5082 .find_map(|interface| match &interface.kind {
5083 AnalysisInterfaceKind::ConjugateHeatTransfer(interface)
5084 if interface.thermal_conductance_w_per_m2k.is_finite()
5085 && interface.thermal_conductance_w_per_m2k > 0.0 =>
5086 {
5087 Some(interface.thermal_conductance_w_per_m2k)
5088 }
5089 AnalysisInterfaceKind::ConjugateHeatTransfer(_)
5090 | AnalysisInterfaceKind::FluidStructure(_)
5091 | AnalysisInterfaceKind::Contact(_) => None,
5092 })
5093}
5094
5095#[derive(Debug, Clone, Copy, Default)]
5096struct ChtInterfaceClosure {
5097 interface_face_count: usize,
5098 max_temperature_jump_k: f64,
5099 max_energy_residual: f64,
5100 heat_flux_balance_ratio: f64,
5101 mean_interface_heat_flux_w_per_m2: f64,
5102 max_thermal_transport_residual_ratio: f64,
5103 interface_temperature_continuity_ratio: f64,
5104 max_advection_temperature_shift_k: f64,
5105 interface_conductance_w_per_m2k: f64,
5106 max_flux_temperature_law_residual_ratio: f64,
5107 max_heat_flux_realization_residual_ratio: f64,
5108 max_coupled_interface_iteration_count: usize,
5109 max_coupled_interface_residual_ratio: f64,
5110 thermal_network_edge_count: usize,
5111 thermal_network_node_count: usize,
5112 interface_connectivity_coverage_ratio: f64,
5113 mesh_backed_interface_connectivity_ratio: f64,
5114 full_topology_edge_count: usize,
5115 full_topology_element_count: usize,
5116 max_thermal_network_residual_ratio: f64,
5117}
5118
5119#[derive(Debug, Clone, Copy)]
5120struct ChtKnownAnswerMetrics {
5121 heated_channel_energy_residual_ratio: f64,
5122 conjugate_slab_flux_law_residual_ratio: f64,
5123 interface_temperature_continuity_ratio: f64,
5124 advection_shift_coverage_ratio: f64,
5125 coupled_interface_residual_ratio: f64,
5126 heat_flux_realization_residual_ratio: f64,
5127 interface_connectivity_coverage_ratio: f64,
5128 mesh_backed_interface_connectivity_ratio: f64,
5129 thermal_network_residual_ratio: f64,
5130 known_answer_coverage_ratio: f64,
5131}
5132
5133fn cht_known_answer_metrics(
5134 domain: &runmat_analysis_core::CfdDomain,
5135 closure: &ChtInterfaceClosure,
5136) -> ChtKnownAnswerMetrics {
5137 let reynolds = cfd_reynolds_number(domain);
5138 let advection_shift_coverage_ratio = if reynolds.is_finite()
5139 && reynolds > 0.0
5140 && closure.max_advection_temperature_shift_k.is_finite()
5141 && closure.max_advection_temperature_shift_k >= 0.0
5142 {
5143 1.0
5144 } else {
5145 0.0
5146 };
5147 let known_answer_coverage_ratio = if closure.interface_face_count > 0
5148 && closure.interface_conductance_w_per_m2k.is_finite()
5149 && closure.interface_conductance_w_per_m2k > 0.0
5150 && closure.max_energy_residual.is_finite()
5151 && closure.max_flux_temperature_law_residual_ratio.is_finite()
5152 && closure.max_heat_flux_realization_residual_ratio.is_finite()
5153 && closure.interface_temperature_continuity_ratio.is_finite()
5154 && closure.max_coupled_interface_residual_ratio.is_finite()
5155 && closure.thermal_network_node_count > 0
5156 && closure.interface_connectivity_coverage_ratio.is_finite()
5157 && closure.interface_connectivity_coverage_ratio >= 1.0
5158 && closure.mesh_backed_interface_connectivity_ratio.is_finite()
5159 && closure.max_thermal_network_residual_ratio.is_finite()
5160 && advection_shift_coverage_ratio >= 1.0
5161 {
5162 1.0
5163 } else {
5164 0.0
5165 };
5166
5167 ChtKnownAnswerMetrics {
5168 heated_channel_energy_residual_ratio: closure.max_energy_residual,
5169 conjugate_slab_flux_law_residual_ratio: closure.max_flux_temperature_law_residual_ratio,
5170 interface_temperature_continuity_ratio: closure.interface_temperature_continuity_ratio,
5171 advection_shift_coverage_ratio,
5172 coupled_interface_residual_ratio: closure.max_coupled_interface_residual_ratio,
5173 heat_flux_realization_residual_ratio: closure.max_heat_flux_realization_residual_ratio,
5174 interface_connectivity_coverage_ratio: closure.interface_connectivity_coverage_ratio,
5175 mesh_backed_interface_connectivity_ratio: closure.mesh_backed_interface_connectivity_ratio,
5176 thermal_network_residual_ratio: closure.max_thermal_network_residual_ratio,
5177 known_answer_coverage_ratio,
5178 }
5179}
5180
5181fn cht_known_answer_diagnostic(
5182 metrics: &ChtKnownAnswerMetrics,
5183 residual_threshold: f64,
5184) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
5185 let severity = if metrics.heated_channel_energy_residual_ratio <= residual_threshold
5186 && metrics.conjugate_slab_flux_law_residual_ratio <= residual_threshold
5187 && metrics.interface_temperature_continuity_ratio >= 0.999
5188 && metrics.advection_shift_coverage_ratio >= 1.0
5189 && metrics.coupled_interface_residual_ratio <= residual_threshold
5190 && metrics.heat_flux_realization_residual_ratio <= residual_threshold
5191 && metrics.interface_connectivity_coverage_ratio >= 1.0
5192 && metrics.thermal_network_residual_ratio <= residual_threshold
5193 && metrics.known_answer_coverage_ratio >= 1.0
5194 {
5195 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
5196 } else {
5197 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
5198 };
5199
5200 runmat_analysis_fea::diagnostics::FeaDiagnostic {
5201 code: "FEA_CHT_KNOWN_ANSWER".to_string(),
5202 severity,
5203 message: format!(
5204 "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={}",
5205 metrics.heated_channel_energy_residual_ratio,
5206 metrics.conjugate_slab_flux_law_residual_ratio,
5207 metrics.interface_temperature_continuity_ratio,
5208 metrics.advection_shift_coverage_ratio,
5209 metrics.coupled_interface_residual_ratio,
5210 metrics.heat_flux_realization_residual_ratio,
5211 metrics.interface_connectivity_coverage_ratio,
5212 metrics.mesh_backed_interface_connectivity_ratio,
5213 metrics.thermal_network_residual_ratio,
5214 metrics.known_answer_coverage_ratio,
5215 ),
5216 }
5217}
5218
5219fn build_fsi_run_fields(
5220 domain: &runmat_analysis_core::CfdDomain,
5221 topology: &CfdDomainTopology,
5222 step_count: usize,
5223 structural_compliance_per_pa: f64,
5224 max_linear_iters: usize,
5225 tolerance: f64,
5226 residual_momentum: &[f64],
5227 residual_continuity: &[f64],
5228) -> (Vec<AnalysisField>, FsiInterfaceClosure) {
5229 let mut fields = Vec::new();
5230 let mut closure = FsiInterfaceClosure::default();
5231 let step_count = step_count.max(1);
5232 let node_count = topology.node_count;
5233
5234 for step_index in 0..step_count {
5235 let (fluid_velocity, fluid_pressure) =
5236 recover_cfd_velocity_pressure(domain, topology, step_index);
5237 let interface_step = solve_fsi_partitioned_interface(
5238 &fluid_pressure,
5239 topology,
5240 structural_compliance_per_pa,
5241 max_linear_iters,
5242 tolerance,
5243 );
5244 let displacement = interface_step.structural_displacement;
5245 closure.interface_node_count = closure.interface_node_count.max(fluid_pressure.len());
5246 closure.interface_face_count = closure
5247 .interface_face_count
5248 .max(interface_step.interface_pressure.len());
5249 closure.max_coupling_iteration_count = closure
5250 .max_coupling_iteration_count
5251 .max(interface_step.iteration_count);
5252 closure.max_pressure_feedback_residual_ratio = closure
5253 .max_pressure_feedback_residual_ratio
5254 .max(interface_step.pressure_feedback_residual_ratio);
5255 closure.max_two_way_interface_residual_ratio = closure
5256 .max_two_way_interface_residual_ratio
5257 .max(interface_step.two_way_interface_residual_ratio);
5258 closure.max_structural_traction_update_residual_ratio = closure
5259 .max_structural_traction_update_residual_ratio
5260 .max(interface_step.structural_traction_update_residual_ratio);
5261 closure.max_pressure_displacement_law_residual_ratio = closure
5262 .max_pressure_displacement_law_residual_ratio
5263 .max(interface_step.pressure_displacement_law_residual_ratio);
5264 closure.max_structural_solve_residual_ratio = closure
5265 .max_structural_solve_residual_ratio
5266 .max(interface_step.structural_solve_residual_ratio);
5267 closure.max_interface_work_energy_residual_ratio = closure
5268 .max_interface_work_energy_residual_ratio
5269 .max(interface_step.interface_work_energy_residual_ratio);
5270 closure.max_interface_work_j_per_m2 = closure
5271 .max_interface_work_j_per_m2
5272 .max(interface_step.interface_work_j_per_m2.abs());
5273 closure.max_structural_strain_energy_j_per_m2 = closure
5274 .max_structural_strain_energy_j_per_m2
5275 .max(interface_step.structural_strain_energy_j_per_m2.abs());
5276 closure.structural_coupling_edge_count = closure
5277 .structural_coupling_edge_count
5278 .max(interface_step.structural_coupling_edge_count);
5279 closure.interface_connectivity_coverage_ratio = closure
5280 .interface_connectivity_coverage_ratio
5281 .max(interface_step.interface_connectivity_coverage_ratio);
5282 closure.mesh_backed_interface_connectivity_ratio = closure
5283 .mesh_backed_interface_connectivity_ratio
5284 .max(interface_step.mesh_backed_interface_connectivity_ratio);
5285 closure.full_topology_edge_count = closure
5286 .full_topology_edge_count
5287 .max(interface_step.full_topology_edge_count);
5288 closure.full_topology_element_count = closure
5289 .full_topology_element_count
5290 .max(interface_step.full_topology_element_count);
5291 closure.interface_stiffness_pa_per_m = closure
5292 .interface_stiffness_pa_per_m
5293 .max(interface_step.interface_stiffness_pa_per_m);
5294 let fluid_normal_force = interface_step.interface_pressure.iter().sum::<f64>();
5295 let structural_normal_force = -fluid_normal_force;
5296 let force_balance_ratio = (fluid_normal_force + structural_normal_force).abs()
5297 / (fluid_normal_force.abs() + structural_normal_force.abs() + 1.0e-12);
5298 closure.force_balance_ratio = closure.force_balance_ratio.max(force_balance_ratio);
5299 let mean_pressure = if interface_step.interface_pressure.is_empty() {
5300 0.0
5301 } else {
5302 interface_step.interface_pressure.iter().sum::<f64>()
5303 / interface_step.interface_pressure.len() as f64
5304 };
5305 closure.mean_interface_pressure_pa =
5306 closure.mean_interface_pressure_pa.max(mean_pressure.abs());
5307 closure.max_traction_magnitude_pa = closure.max_traction_magnitude_pa.max(
5308 interface_step
5309 .interface_pressure
5310 .iter()
5311 .map(|pressure| pressure.abs())
5312 .fold(0.0_f64, f64::max),
5313 );
5314 let interface_displacement = interface_step.interface_displacement.clone();
5315 let displacement_transfer_residual = displacement
5316 .iter()
5317 .zip(interface_displacement.iter())
5318 .map(|(structural, interface)| (structural - interface).abs())
5319 .fold(0.0_f64, f64::max);
5320 closure.max_displacement_transfer_residual_m = closure
5321 .max_displacement_transfer_residual_m
5322 .max(displacement_transfer_residual);
5323 closure.max_interface_displacement_m = closure.max_interface_displacement_m.max(
5324 interface_displacement
5325 .chunks_exact(3)
5326 .map(|components| {
5327 (components[0] * components[0]
5328 + components[1] * components[1]
5329 + components[2] * components[2])
5330 .sqrt()
5331 })
5332 .fold(0.0_f64, f64::max),
5333 );
5334 fields.push(AnalysisField::host_f64(
5335 fea_fsi_fluid_velocity_field_id(step_index),
5336 vec![node_count, 3],
5337 fluid_velocity,
5338 ));
5339 fields.push(AnalysisField::host_f64(
5340 fea_fsi_fluid_pressure_field_id(step_index),
5341 vec![node_count],
5342 fluid_pressure.clone(),
5343 ));
5344
5345 fields.push(AnalysisField::host_f64(
5346 fea_fsi_structural_displacement_field_id(step_index),
5347 vec![node_count, 3],
5348 displacement.clone(),
5349 ));
5350 fields.push(AnalysisField::host_f64(
5351 fea_fsi_interface_displacement_field_id(step_index),
5352 vec![node_count, 3],
5353 interface_displacement,
5354 ));
5355
5356 fields.push(AnalysisField::host_f64(
5357 fea_fsi_interface_pressure_field_id(step_index),
5358 vec![interface_step.interface_pressure.len().max(1)],
5359 interface_step.interface_pressure.clone(),
5360 ));
5361 let mut traction = Vec::with_capacity(interface_step.interface_pressure.len() * 3);
5362 for pressure in &interface_step.interface_pressure {
5363 traction.extend_from_slice(&[-*pressure, 0.0, 0.0]);
5364 }
5365 fields.push(AnalysisField::host_f64(
5366 fea_fsi_interface_traction_field_id(step_index),
5367 vec![interface_step.interface_pressure.len().max(1), 3],
5368 traction,
5369 ));
5370
5371 let residual = residual_momentum
5372 .get(step_index)
5373 .copied()
5374 .unwrap_or(0.0)
5375 .max(residual_continuity.get(step_index).copied().unwrap_or(0.0))
5376 .max(interface_step.two_way_interface_residual_ratio);
5377 closure.max_interface_residual = closure.max_interface_residual.max(residual);
5378 fields.push(AnalysisField::host_f64(
5379 fea_fsi_interface_residual_field_id(step_index),
5380 vec![1],
5381 vec![residual],
5382 ));
5383 fields.push(AnalysisField::host_f64(
5384 fea_fsi_coupling_iteration_count_field_id(step_index),
5385 vec![1],
5386 vec![interface_step.iteration_count as f64],
5387 ));
5388 }
5389
5390 (fields, closure)
5391}
5392
5393#[derive(Debug, Clone)]
5394struct FsiPartitionedInterfaceStep {
5395 interface_pressure: Vec<f64>,
5396 structural_displacement: Vec<f64>,
5397 interface_displacement: Vec<f64>,
5398 iteration_count: usize,
5399 pressure_feedback_residual_ratio: f64,
5400 two_way_interface_residual_ratio: f64,
5401 structural_traction_update_residual_ratio: f64,
5402 pressure_displacement_law_residual_ratio: f64,
5403 structural_solve_residual_ratio: f64,
5404 interface_work_j_per_m2: f64,
5405 structural_strain_energy_j_per_m2: f64,
5406 interface_work_energy_residual_ratio: f64,
5407 structural_coupling_edge_count: usize,
5408 interface_connectivity_coverage_ratio: f64,
5409 mesh_backed_interface_connectivity_ratio: f64,
5410 full_topology_edge_count: usize,
5411 full_topology_element_count: usize,
5412 interface_stiffness_pa_per_m: f64,
5413}
5414
5415#[derive(Debug, Clone)]
5416struct FsiStructuralInterfaceResponse {
5417 displacement_x: Vec<f64>,
5418 reaction_pressure: Vec<f64>,
5419 residual_ratio: f64,
5420 coupling_edge_count: usize,
5421}
5422
5423fn solve_fsi_partitioned_interface(
5424 fluid_pressure: &[f64],
5425 topology: &CfdDomainTopology,
5426 structural_compliance_per_pa: f64,
5427 max_linear_iters: usize,
5428 tolerance: f64,
5429) -> FsiPartitionedInterfaceStep {
5430 let max_linear_iters = max_linear_iters.max(1);
5431 let tolerance = tolerance.max(1.0e-12);
5432 let interface_face_count = fluid_interface_face_count(topology);
5433 let face_pressure = resample_scalar_profile(
5434 &cell_centered_scalar_from_nodal(fluid_pressure),
5435 interface_face_count,
5436 );
5437 let pressure_scale = face_pressure
5438 .iter()
5439 .map(|pressure| pressure.abs())
5440 .fold(0.0_f64, f64::max)
5441 .max(1.0);
5442 let compliance = structural_compliance_per_pa.max(1.0e-18);
5443 let pressure_relaxation = 0.65_f64;
5444 let displacement_relaxation = 0.65_f64;
5445 let traction_relaxation = 0.65_f64;
5446 let interface_stiffness_pa_per_m = 1.0 / compliance;
5447 let feedback_stiffness_pa_per_m = 0.25 * interface_stiffness_pa_per_m;
5448 let structural_coupling_edges =
5449 coupled_interface_graph_edges_for_topology(topology, interface_face_count);
5450 let interface_connectivity_coverage_ratio = coupled_interface_connectivity_coverage_ratio(
5451 topology,
5452 interface_face_count,
5453 structural_coupling_edges.len(),
5454 );
5455 let mesh_backed_interface_connectivity_ratio =
5456 coupled_interface_mesh_backed_connectivity_ratio(topology, structural_coupling_edges.len());
5457 let mut interface_pressure = vec![0.0; face_pressure.len()];
5458 let mut structural_traction = vec![0.0; face_pressure.len()];
5459 let mut structural_face_displacement = vec![0.0; face_pressure.len() * 3];
5460 let mut interface_face_displacement = vec![0.0; face_pressure.len() * 3];
5461 let mut iteration_count = 0_usize;
5462 let mut pressure_feedback_residual_ratio = if face_pressure.is_empty() { 0.0 } else { 1.0 };
5463 let mut displacement_transfer_residual_ratio = 0.0_f64;
5464 let mut structural_traction_update_residual_ratio =
5465 if face_pressure.is_empty() { 0.0 } else { 1.0 };
5466 let mut structural_solve_residual_ratio = if face_pressure.is_empty() { 0.0 } else { 1.0 };
5467 let mut structural_coupling_edge_count = 0_usize;
5468
5469 for iteration in 1..=max_linear_iters {
5470 let target_pressure: Vec<f64> = face_pressure
5471 .iter()
5472 .enumerate()
5473 .map(|(face, pressure)| {
5474 let displacement = interface_face_displacement
5475 .get(face * 3)
5476 .copied()
5477 .unwrap_or(0.0);
5478 *pressure - feedback_stiffness_pa_per_m * displacement
5479 })
5480 .collect();
5481 for (interface, target) in interface_pressure.iter_mut().zip(target_pressure.iter()) {
5482 *interface += pressure_relaxation * (*target - *interface);
5483 }
5484 let structural_response = solve_fsi_structural_interface_response(
5485 &interface_pressure,
5486 interface_stiffness_pa_per_m,
5487 &structural_coupling_edges,
5488 );
5489 structural_solve_residual_ratio = structural_response.residual_ratio;
5490 structural_coupling_edge_count =
5491 structural_coupling_edge_count.max(structural_response.coupling_edge_count);
5492 for (face, displacement_x) in structural_response.displacement_x.iter().enumerate() {
5493 structural_face_displacement[face * 3] = *displacement_x;
5494 structural_face_displacement[face * 3 + 1] = 0.0;
5495 structural_face_displacement[face * 3 + 2] = 0.0;
5496 }
5497 let target_structural_traction = structural_response.reaction_pressure;
5498 for (traction, target) in structural_traction
5499 .iter_mut()
5500 .zip(target_structural_traction.iter())
5501 {
5502 *traction += traction_relaxation * (*target - *traction);
5503 }
5504 for (interface, structural) in interface_face_displacement
5505 .iter_mut()
5506 .zip(structural_face_displacement.iter())
5507 {
5508 *interface += displacement_relaxation * (*structural - *interface);
5509 }
5510 let max_feedback_residual = target_pressure
5511 .iter()
5512 .zip(interface_pressure.iter())
5513 .map(|(target, interface)| (target - interface).abs())
5514 .fold(0.0_f64, f64::max);
5515 pressure_feedback_residual_ratio = max_feedback_residual / pressure_scale;
5516 let displacement_scale = structural_face_displacement
5517 .iter()
5518 .copied()
5519 .map(f64::abs)
5520 .fold(0.0_f64, f64::max)
5521 .max(1.0e-18);
5522 displacement_transfer_residual_ratio = structural_face_displacement
5523 .iter()
5524 .zip(interface_face_displacement.iter())
5525 .map(|(structural, interface)| (structural - interface).abs())
5526 .fold(0.0_f64, f64::max)
5527 / displacement_scale;
5528 structural_traction_update_residual_ratio = target_structural_traction
5529 .iter()
5530 .zip(structural_traction.iter())
5531 .map(|(target, traction)| (target - traction).abs())
5532 .fold(0.0_f64, f64::max)
5533 / pressure_scale;
5534 iteration_count = iteration;
5535 if pressure_feedback_residual_ratio <= tolerance
5536 && displacement_transfer_residual_ratio <= tolerance
5537 && structural_traction_update_residual_ratio <= tolerance
5538 && structural_solve_residual_ratio <= tolerance
5539 {
5540 break;
5541 }
5542 }
5543 let structural_response = solve_fsi_structural_interface_response(
5544 &interface_pressure,
5545 interface_stiffness_pa_per_m,
5546 &structural_coupling_edges,
5547 );
5548 structural_solve_residual_ratio = structural_solve_residual_ratio
5549 .max(structural_response.residual_ratio)
5550 .min(1.0);
5551 structural_coupling_edge_count =
5552 structural_coupling_edge_count.max(structural_response.coupling_edge_count);
5553 let pressure_displacement_law_residual_ratio = structural_response
5554 .reaction_pressure
5555 .iter()
5556 .zip(interface_pressure.iter())
5557 .map(|(reaction, pressure)| (reaction - pressure).abs() / pressure.abs().max(1.0))
5558 .fold(0.0_f64, f64::max);
5559 let interface_work_j_per_m2 = interface_pressure
5560 .iter()
5561 .zip(structural_response.displacement_x.iter())
5562 .map(|(pressure, displacement)| pressure * displacement)
5563 .sum::<f64>();
5564 let structural_strain_energy_j_per_m2 = 0.5
5565 * structural_response
5566 .reaction_pressure
5567 .iter()
5568 .zip(structural_response.displacement_x.iter())
5569 .map(|(reaction, displacement)| reaction * displacement)
5570 .sum::<f64>();
5571 let interface_work_energy_residual_ratio =
5572 (interface_work_j_per_m2 - 2.0 * structural_strain_energy_j_per_m2).abs()
5573 / (interface_work_j_per_m2.abs()
5574 + (2.0 * structural_strain_energy_j_per_m2).abs()
5575 + 1.0e-12);
5576 let interface_face_displacement_x = interface_face_displacement
5577 .chunks_exact(3)
5578 .map(|components| components[0])
5579 .collect::<Vec<_>>();
5580 let structural_displacement =
5581 vector_field_from_x_profile(&structural_response.displacement_x, fluid_pressure.len());
5582 let interface_displacement =
5583 vector_field_from_x_profile(&interface_face_displacement_x, fluid_pressure.len());
5584
5585 FsiPartitionedInterfaceStep {
5586 interface_pressure,
5587 structural_displacement,
5588 interface_displacement,
5589 iteration_count,
5590 pressure_feedback_residual_ratio,
5591 two_way_interface_residual_ratio: pressure_feedback_residual_ratio
5592 .max(displacement_transfer_residual_ratio)
5593 .max(structural_traction_update_residual_ratio)
5594 .max(structural_solve_residual_ratio),
5595 structural_traction_update_residual_ratio,
5596 pressure_displacement_law_residual_ratio,
5597 structural_solve_residual_ratio,
5598 interface_work_j_per_m2,
5599 structural_strain_energy_j_per_m2,
5600 interface_work_energy_residual_ratio,
5601 structural_coupling_edge_count,
5602 interface_connectivity_coverage_ratio,
5603 mesh_backed_interface_connectivity_ratio,
5604 full_topology_edge_count: topology.element_topology_edge_nodes.len(),
5605 full_topology_element_count: topology.element_topology_element_edges.len(),
5606 interface_stiffness_pa_per_m,
5607 }
5608}
5609
5610fn solve_fsi_structural_interface_response(
5611 pressure_load: &[f64],
5612 interface_stiffness_pa_per_m: f64,
5613 coupling_edges: &[(usize, usize)],
5614) -> FsiStructuralInterfaceResponse {
5615 let face_count = pressure_load.len();
5616 if face_count == 0 {
5617 return FsiStructuralInterfaceResponse {
5618 displacement_x: Vec::new(),
5619 reaction_pressure: Vec::new(),
5620 residual_ratio: 0.0,
5621 coupling_edge_count: 0,
5622 };
5623 }
5624
5625 let diagonal_stiffness = interface_stiffness_pa_per_m.max(1.0e-9);
5626 let coupling_stiffness = 0.20 * diagonal_stiffness;
5627 let mut operator = vec![vec![0.0; face_count]; face_count];
5628 for (row, diagonal) in operator.iter_mut().enumerate() {
5629 diagonal[row] = diagonal_stiffness;
5630 }
5631 for (left, right) in coupling_edges {
5632 operator[*left][*left] += coupling_stiffness;
5633 operator[*right][*right] += coupling_stiffness;
5634 operator[*left][*right] -= coupling_stiffness;
5635 operator[*right][*left] -= coupling_stiffness;
5636 }
5637
5638 let mut matrix = operator.clone();
5639 let mut rhs = pressure_load.to_vec();
5640 let displacement_x = solve_dense_real_system(&mut matrix, &mut rhs);
5641 let reaction_pressure = apply_dense_real_operator(&operator, &displacement_x);
5642 let pressure_scale = pressure_load
5643 .iter()
5644 .copied()
5645 .map(f64::abs)
5646 .fold(0.0_f64, f64::max)
5647 .max(1.0);
5648 let residual_ratio = reaction_pressure
5649 .iter()
5650 .zip(pressure_load.iter())
5651 .map(|(reaction, pressure)| (reaction - pressure).abs())
5652 .fold(0.0_f64, f64::max)
5653 / pressure_scale;
5654
5655 FsiStructuralInterfaceResponse {
5656 displacement_x,
5657 reaction_pressure,
5658 residual_ratio,
5659 coupling_edge_count: coupling_edges.len(),
5660 }
5661}
5662
5663fn coupled_interface_graph_edges(face_count: usize, edge_target: usize) -> Vec<(usize, usize)> {
5664 if face_count < 2 || edge_target == 0 {
5665 return Vec::new();
5666 }
5667 let complete_graph_edge_count = face_count * (face_count - 1) / 2;
5668 let edge_target = edge_target.min(complete_graph_edge_count);
5669 let mut edges = Vec::with_capacity(edge_target);
5670 for span in 1..face_count {
5671 for left in 0..face_count - span {
5672 edges.push((left, left + span));
5673 if edges.len() == edge_target {
5674 return edges;
5675 }
5676 }
5677 }
5678 edges
5679}
5680
5681fn solve_dense_real_system(matrix: &mut [Vec<f64>], rhs: &mut [f64]) -> Vec<f64> {
5682 let n = rhs.len();
5683 for pivot in 0..n {
5684 let mut pivot_row = pivot;
5685 let mut pivot_abs = matrix[pivot][pivot].abs();
5686 for (candidate, row) in matrix.iter().enumerate().skip(pivot + 1) {
5687 let candidate_abs = row[pivot].abs();
5688 if candidate_abs > pivot_abs {
5689 pivot_row = candidate;
5690 pivot_abs = candidate_abs;
5691 }
5692 }
5693 if pivot_row != pivot {
5694 matrix.swap(pivot, pivot_row);
5695 rhs.swap(pivot, pivot_row);
5696 }
5697 if pivot_abs <= 1.0e-24 {
5698 matrix[pivot][pivot] += 1.0e-9;
5699 }
5700 let pivot_value = matrix[pivot][pivot];
5701 for row in pivot + 1..n {
5702 let factor = matrix[row][pivot] / pivot_value;
5703 matrix[row][pivot] = 0.0;
5704 for col in pivot + 1..n {
5705 matrix[row][col] -= factor * matrix[pivot][col];
5706 }
5707 rhs[row] -= factor * rhs[pivot];
5708 }
5709 }
5710
5711 let mut solution = vec![0.0; n];
5712 for row in (0..n).rev() {
5713 let mut accum = rhs[row];
5714 for (col, value) in solution.iter().enumerate().skip(row + 1) {
5715 accum -= matrix[row][col] * value;
5716 }
5717 solution[row] = accum
5718 / matrix[row][row]
5719 .abs()
5720 .max(1.0e-18)
5721 .copysign(matrix[row][row]);
5722 }
5723 solution
5724}
5725
5726fn apply_dense_real_operator(matrix: &[Vec<f64>], x: &[f64]) -> Vec<f64> {
5727 matrix
5728 .iter()
5729 .map(|row| {
5730 row.iter()
5731 .zip(x.iter())
5732 .map(|(coefficient, value)| coefficient * value)
5733 .sum::<f64>()
5734 })
5735 .collect()
5736}
5737
5738fn vector_field_from_x_profile(x: &[f64], target_count: usize) -> Vec<f64> {
5739 let profile = resample_scalar_profile(x, target_count);
5740 let mut field = Vec::with_capacity(profile.len() * 3);
5741 for displacement_x in profile {
5742 field.extend_from_slice(&[displacement_x, 0.0, 0.0]);
5743 }
5744 field
5745}
5746
5747#[derive(Debug, Clone, Copy, Default)]
5748struct FsiInterfaceClosure {
5749 interface_node_count: usize,
5750 interface_face_count: usize,
5751 max_interface_residual: f64,
5752 force_balance_ratio: f64,
5753 max_displacement_transfer_residual_m: f64,
5754 max_interface_displacement_m: f64,
5755 mean_interface_pressure_pa: f64,
5756 max_traction_magnitude_pa: f64,
5757 max_coupling_iteration_count: usize,
5758 max_pressure_feedback_residual_ratio: f64,
5759 max_two_way_interface_residual_ratio: f64,
5760 max_structural_traction_update_residual_ratio: f64,
5761 max_pressure_displacement_law_residual_ratio: f64,
5762 max_structural_solve_residual_ratio: f64,
5763 max_interface_work_j_per_m2: f64,
5764 max_structural_strain_energy_j_per_m2: f64,
5765 max_interface_work_energy_residual_ratio: f64,
5766 structural_coupling_edge_count: usize,
5767 interface_connectivity_coverage_ratio: f64,
5768 mesh_backed_interface_connectivity_ratio: f64,
5769 full_topology_edge_count: usize,
5770 full_topology_element_count: usize,
5771 interface_stiffness_pa_per_m: f64,
5772}
5773
5774#[derive(Debug, Clone, Copy)]
5775struct FsiKnownAnswerMetrics {
5776 pressure_loaded_wall_displacement_law_residual_ratio: f64,
5777 interface_traction_balance_residual_ratio: f64,
5778 interface_displacement_transfer_residual_m: f64,
5779 partitioned_pressure_feedback_residual_ratio: f64,
5780 two_way_interface_residual_ratio: f64,
5781 structural_traction_update_residual_ratio: f64,
5782 structural_solve_residual_ratio: f64,
5783 interface_work_energy_residual_ratio: f64,
5784 interface_connectivity_coverage_ratio: f64,
5785 mesh_backed_interface_connectivity_ratio: f64,
5786 known_answer_coverage_ratio: f64,
5787}
5788
5789fn fsi_known_answer_metrics(closure: &FsiInterfaceClosure) -> FsiKnownAnswerMetrics {
5790 let known_answer_coverage_ratio = if closure.interface_node_count > 0
5791 && closure.interface_face_count > 0
5792 && closure.mean_interface_pressure_pa.is_finite()
5793 && closure.mean_interface_pressure_pa > 0.0
5794 && closure.max_traction_magnitude_pa.is_finite()
5795 && closure.max_traction_magnitude_pa > 0.0
5796 && closure.max_interface_displacement_m.is_finite()
5797 && closure.max_interface_displacement_m > 0.0
5798 && closure.interface_stiffness_pa_per_m.is_finite()
5799 && closure.interface_stiffness_pa_per_m > 0.0
5800 && closure
5801 .max_pressure_displacement_law_residual_ratio
5802 .is_finite()
5803 && closure.force_balance_ratio.is_finite()
5804 && closure.max_pressure_feedback_residual_ratio.is_finite()
5805 && closure.max_two_way_interface_residual_ratio.is_finite()
5806 && closure
5807 .max_structural_traction_update_residual_ratio
5808 .is_finite()
5809 && closure.max_structural_solve_residual_ratio.is_finite()
5810 && closure.max_interface_work_j_per_m2.is_finite()
5811 && closure.max_interface_work_j_per_m2 > 0.0
5812 && closure.max_structural_strain_energy_j_per_m2.is_finite()
5813 && closure.max_structural_strain_energy_j_per_m2 > 0.0
5814 && closure.max_interface_work_energy_residual_ratio.is_finite()
5815 && closure.structural_coupling_edge_count > 0
5816 && closure.interface_connectivity_coverage_ratio.is_finite()
5817 && closure.interface_connectivity_coverage_ratio >= 1.0
5818 && closure.mesh_backed_interface_connectivity_ratio.is_finite()
5819 {
5820 1.0
5821 } else {
5822 0.0
5823 };
5824
5825 FsiKnownAnswerMetrics {
5826 pressure_loaded_wall_displacement_law_residual_ratio: closure
5827 .max_pressure_displacement_law_residual_ratio,
5828 interface_traction_balance_residual_ratio: closure.force_balance_ratio,
5829 interface_displacement_transfer_residual_m: closure.max_displacement_transfer_residual_m,
5830 partitioned_pressure_feedback_residual_ratio: closure.max_pressure_feedback_residual_ratio,
5831 two_way_interface_residual_ratio: closure.max_two_way_interface_residual_ratio,
5832 structural_traction_update_residual_ratio: closure
5833 .max_structural_traction_update_residual_ratio,
5834 structural_solve_residual_ratio: closure.max_structural_solve_residual_ratio,
5835 interface_work_energy_residual_ratio: closure.max_interface_work_energy_residual_ratio,
5836 interface_connectivity_coverage_ratio: closure.interface_connectivity_coverage_ratio,
5837 mesh_backed_interface_connectivity_ratio: closure.mesh_backed_interface_connectivity_ratio,
5838 known_answer_coverage_ratio,
5839 }
5840}
5841
5842fn fsi_known_answer_diagnostic(
5843 metrics: &FsiKnownAnswerMetrics,
5844 tolerance: f64,
5845) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
5846 let severity = if metrics.pressure_loaded_wall_displacement_law_residual_ratio <= tolerance
5847 && metrics.interface_traction_balance_residual_ratio <= 1.0e-9
5848 && metrics.interface_displacement_transfer_residual_m <= 1.0e-12
5849 && metrics.partitioned_pressure_feedback_residual_ratio <= tolerance
5850 && metrics.two_way_interface_residual_ratio <= tolerance
5851 && metrics.structural_traction_update_residual_ratio <= tolerance
5852 && metrics.structural_solve_residual_ratio <= tolerance
5853 && metrics.interface_work_energy_residual_ratio <= tolerance
5854 && metrics.interface_connectivity_coverage_ratio >= 1.0
5855 && metrics.known_answer_coverage_ratio >= 1.0
5856 {
5857 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
5858 } else {
5859 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
5860 };
5861
5862 runmat_analysis_fea::diagnostics::FeaDiagnostic {
5863 code: "FEA_FSI_KNOWN_ANSWER".to_string(),
5864 severity,
5865 message: format!(
5866 "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={}",
5867 metrics.pressure_loaded_wall_displacement_law_residual_ratio,
5868 metrics.interface_traction_balance_residual_ratio,
5869 metrics.interface_displacement_transfer_residual_m,
5870 metrics.partitioned_pressure_feedback_residual_ratio,
5871 metrics.two_way_interface_residual_ratio,
5872 metrics.structural_traction_update_residual_ratio,
5873 metrics.structural_solve_residual_ratio,
5874 metrics.interface_work_energy_residual_ratio,
5875 metrics.interface_connectivity_coverage_ratio,
5876 metrics.mesh_backed_interface_connectivity_ratio,
5877 metrics.known_answer_coverage_ratio,
5878 ),
5879 }
5880}
5881
5882fn fsi_structural_compliance_per_pa(model: &AnalysisModel) -> f64 {
5883 if let Some(stiffness) = fsi_interface_normal_stiffness_pa_per_m(model) {
5884 return 1.0 / stiffness.max(1.0e-18);
5885 }
5886
5887 let mean_modulus = model
5888 .materials
5889 .iter()
5890 .filter_map(|material| {
5891 let modulus = material.mechanical.youngs_modulus_pa;
5892 (modulus.is_finite() && modulus > 0.0).then_some(modulus)
5893 })
5894 .fold((0.0_f64, 0_usize), |(sum, count), modulus| {
5895 (sum + modulus, count + 1)
5896 });
5897 let youngs_modulus = if mean_modulus.1 > 0 {
5898 mean_modulus.0 / mean_modulus.1 as f64
5899 } else {
5900 200.0e9
5901 };
5902 1.0 / youngs_modulus.max(1.0e6)
5903}
5904
5905fn fsi_interface_normal_stiffness_pa_per_m(model: &AnalysisModel) -> Option<f64> {
5906 model
5907 .interfaces
5908 .iter()
5909 .find_map(|interface| match &interface.kind {
5910 AnalysisInterfaceKind::FluidStructure(fluid_structure)
5911 if fluid_structure.normal_stiffness_pa_per_m.is_finite()
5912 && fluid_structure.normal_stiffness_pa_per_m > 0.0 =>
5913 {
5914 Some(fluid_structure.normal_stiffness_pa_per_m)
5915 }
5916 AnalysisInterfaceKind::FluidStructure(_)
5917 | AnalysisInterfaceKind::ConjugateHeatTransfer(_)
5918 | AnalysisInterfaceKind::Contact(_) => None,
5919 })
5920}
5921
5922pub fn analysis_run_transient_op(
5923 model: &AnalysisModel,
5924 backend: ComputeBackend,
5925 context: OperationContext,
5926) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
5927 analysis_run_transient_with_options_op(
5928 model,
5929 backend,
5930 AnalysisTransientRunOptions::default(),
5931 context,
5932 )
5933}
5934
5935pub fn analysis_run_cfd_op(
5936 model: &AnalysisModel,
5937 backend: ComputeBackend,
5938 context: OperationContext,
5939) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
5940 analysis_run_cfd_with_options_op(model, backend, AnalysisCfdRunOptions::default(), context)
5941}
5942
5943pub fn analysis_run_cfd_with_options_op(
5944 model: &AnalysisModel,
5945 backend: ComputeBackend,
5946 options: AnalysisCfdRunOptions,
5947 context: OperationContext,
5948) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
5949 let _solver_context = install_fea_solver_context();
5950 let has_cfd_step = model
5951 .steps
5952 .iter()
5953 .any(|step| step.kind == AnalysisStepKind::Cfd);
5954 if !has_cfd_step {
5955 return Err(operation_error(
5956 ANALYSIS_RUN_CFD_OPERATION,
5957 ANALYSIS_RUN_CFD_OP_VERSION,
5958 &context,
5959 OperationErrorSpec {
5960 error_code: "RM.FEA.RUN_CFD.INVALID_MODEL",
5961 error_type: OperationErrorType::Validation,
5962 retryable: false,
5963 severity: OperationErrorSeverity::Error,
5964 },
5965 "FEA model must include at least one cfd step for fea.run_cfd",
5966 BTreeMap::from([
5967 ("analysis_model_id".to_string(), model.model_id.0.clone()),
5968 ("geometry_id".to_string(), model.geometry_id.clone()),
5969 ]),
5970 ));
5971 }
5972
5973 let Some(cfd_domain) = model.cfd.as_ref() else {
5974 return Err(operation_error(
5975 ANALYSIS_RUN_CFD_OPERATION,
5976 ANALYSIS_RUN_CFD_OP_VERSION,
5977 &context,
5978 OperationErrorSpec {
5979 error_code: "RM.FEA.RUN_CFD.INVALID_MODEL",
5980 error_type: OperationErrorType::Validation,
5981 retryable: false,
5982 severity: OperationErrorSeverity::Error,
5983 },
5984 "fea.run_cfd requires model.cfd to be configured",
5985 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
5986 ));
5987 };
5988
5989 if !cfd_domain.enabled {
5990 return Err(operation_error(
5991 ANALYSIS_RUN_CFD_OPERATION,
5992 ANALYSIS_RUN_CFD_OP_VERSION,
5993 &context,
5994 OperationErrorSpec {
5995 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
5996 error_type: OperationErrorType::Input,
5997 retryable: false,
5998 severity: OperationErrorSeverity::Error,
5999 },
6000 "fea.run_cfd requires cfd domain enabled=true",
6001 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6002 ));
6003 }
6004 if !cfd_domain.reference_density_kg_per_m3.is_finite()
6005 || cfd_domain.reference_density_kg_per_m3 <= 0.0
6006 {
6007 return Err(operation_error(
6008 ANALYSIS_RUN_CFD_OPERATION,
6009 ANALYSIS_RUN_CFD_OP_VERSION,
6010 &context,
6011 OperationErrorSpec {
6012 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6013 error_type: OperationErrorType::Input,
6014 retryable: false,
6015 severity: OperationErrorSeverity::Error,
6016 },
6017 "fea.run_cfd requires finite positive reference_density_kg_per_m3",
6018 BTreeMap::from([(
6019 "reference_density_kg_per_m3".to_string(),
6020 cfd_domain.reference_density_kg_per_m3.to_string(),
6021 )]),
6022 ));
6023 }
6024 if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
6025 return Err(operation_error(
6026 ANALYSIS_RUN_CFD_OPERATION,
6027 ANALYSIS_RUN_CFD_OP_VERSION,
6028 &context,
6029 OperationErrorSpec {
6030 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6031 error_type: OperationErrorType::Input,
6032 retryable: false,
6033 severity: OperationErrorSeverity::Error,
6034 },
6035 "fea.run_cfd requires finite positive dynamic_viscosity_pa_s",
6036 BTreeMap::from([(
6037 "dynamic_viscosity_pa_s".to_string(),
6038 cfd_domain.dynamic_viscosity_pa_s.to_string(),
6039 )]),
6040 ));
6041 }
6042 if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
6043 return Err(operation_error(
6044 ANALYSIS_RUN_CFD_OPERATION,
6045 ANALYSIS_RUN_CFD_OP_VERSION,
6046 &context,
6047 OperationErrorSpec {
6048 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6049 error_type: OperationErrorType::Input,
6050 retryable: false,
6051 severity: OperationErrorSeverity::Error,
6052 },
6053 "fea.run_cfd requires finite non-negative inlet_velocity_m_per_s",
6054 BTreeMap::from([(
6055 "inlet_velocity_m_per_s".to_string(),
6056 cfd_domain.inlet_velocity_m_per_s.to_string(),
6057 )]),
6058 ));
6059 }
6060 if !cfd_domain.turbulence_intensity.is_finite()
6061 || cfd_domain.turbulence_intensity < 0.0
6062 || cfd_domain.turbulence_intensity > 1.0
6063 {
6064 return Err(operation_error(
6065 ANALYSIS_RUN_CFD_OPERATION,
6066 ANALYSIS_RUN_CFD_OP_VERSION,
6067 &context,
6068 OperationErrorSpec {
6069 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6070 error_type: OperationErrorType::Input,
6071 retryable: false,
6072 severity: OperationErrorSeverity::Error,
6073 },
6074 "fea.run_cfd requires turbulence_intensity in [0, 1]",
6075 BTreeMap::from([(
6076 "turbulence_intensity".to_string(),
6077 cfd_domain.turbulence_intensity.to_string(),
6078 )]),
6079 ));
6080 }
6081 reject_moment_loads_for_run_family(
6082 model,
6083 ANALYSIS_RUN_CFD_OPERATION,
6084 ANALYSIS_RUN_CFD_OP_VERSION,
6085 "RM.FEA.RUN_CFD.INVALID_LOAD",
6086 "CFD",
6087 &context,
6088 )?;
6089 if let Err(detail) = validate_authored_cfd_boundary_conditions(model) {
6090 return Err(operation_error(
6091 ANALYSIS_RUN_CFD_OPERATION,
6092 ANALYSIS_RUN_CFD_OP_VERSION,
6093 &context,
6094 OperationErrorSpec {
6095 error_code: "RM.FEA.RUN_CFD.INVALID_BOUNDARY_CONDITIONS",
6096 error_type: OperationErrorType::Validation,
6097 retryable: false,
6098 severity: OperationErrorSeverity::Error,
6099 },
6100 detail.clone(),
6101 BTreeMap::from([
6102 ("analysis_model_id".to_string(), model.model_id.0.clone()),
6103 ("detail".to_string(), detail),
6104 ]),
6105 ));
6106 }
6107
6108 if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
6109 return Err(operation_error(
6110 ANALYSIS_RUN_CFD_OPERATION,
6111 ANALYSIS_RUN_CFD_OP_VERSION,
6112 &context,
6113 OperationErrorSpec {
6114 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6115 error_type: OperationErrorType::Input,
6116 retryable: false,
6117 severity: OperationErrorSeverity::Error,
6118 },
6119 "fea.run_cfd options require finite positive time_step_s",
6120 BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
6121 ));
6122 }
6123 if options.step_count == 0 {
6124 return Err(operation_error(
6125 ANALYSIS_RUN_CFD_OPERATION,
6126 ANALYSIS_RUN_CFD_OP_VERSION,
6127 &context,
6128 OperationErrorSpec {
6129 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6130 error_type: OperationErrorType::Input,
6131 retryable: false,
6132 severity: OperationErrorSeverity::Error,
6133 },
6134 "fea.run_cfd options require step_count greater than zero",
6135 BTreeMap::from([("step_count".to_string(), options.step_count.to_string())]),
6136 ));
6137 }
6138 if options.max_linear_iters == 0 {
6139 return Err(operation_error(
6140 ANALYSIS_RUN_CFD_OPERATION,
6141 ANALYSIS_RUN_CFD_OP_VERSION,
6142 &context,
6143 OperationErrorSpec {
6144 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6145 error_type: OperationErrorType::Input,
6146 retryable: false,
6147 severity: OperationErrorSeverity::Error,
6148 },
6149 "fea.run_cfd options require max_linear_iters greater than zero",
6150 BTreeMap::from([(
6151 "max_linear_iters".to_string(),
6152 options.max_linear_iters.to_string(),
6153 )]),
6154 ));
6155 }
6156 if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
6157 return Err(operation_error(
6158 ANALYSIS_RUN_CFD_OPERATION,
6159 ANALYSIS_RUN_CFD_OP_VERSION,
6160 &context,
6161 OperationErrorSpec {
6162 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6163 error_type: OperationErrorType::Input,
6164 retryable: false,
6165 severity: OperationErrorSeverity::Error,
6166 },
6167 "fea.run_cfd options require finite positive tolerance",
6168 BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
6169 ));
6170 }
6171 if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
6172 return Err(operation_error(
6173 ANALYSIS_RUN_CFD_OPERATION,
6174 ANALYSIS_RUN_CFD_OP_VERSION,
6175 &context,
6176 OperationErrorSpec {
6177 error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6178 error_type: OperationErrorType::Input,
6179 retryable: false,
6180 severity: OperationErrorSeverity::Error,
6181 },
6182 "fea.run_cfd options require finite positive residual_warn_threshold",
6183 BTreeMap::from([(
6184 "residual_warn_threshold".to_string(),
6185 options.residual_warn_threshold.to_string(),
6186 )]),
6187 ));
6188 }
6189
6190 let prep_context = resolve_run_prep_context(
6191 model,
6192 options.prep_artifact_id.as_deref(),
6193 options.prep_context.clone(),
6194 ANALYSIS_RUN_CFD_OPERATION,
6195 ANALYSIS_RUN_CFD_OP_VERSION,
6196 &context,
6197 )?;
6198
6199 let solve_start = Instant::now();
6200 let mut run =
6201 solve_cfd_finite_volume_run(model, cfd_domain, backend, &options, prep_context.as_ref());
6202 let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
6203 run.diagnostics
6204 .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6205 code: "FEA_CFD_COST".to_string(),
6206 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6207 message: format!(
6208 "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
6209 solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
6210 ),
6211 });
6212 let mut fallback_events = Vec::new();
6213 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
6214 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
6215 fallback_events.push(
6216 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
6217 );
6218 }
6219
6220 let flow_topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
6221 let flow_boundary_summary = CfdBoundarySummary::from_model(model, cfd_domain, 2);
6222 let flow_inlet_velocity = flow_boundary_summary.nominal_inlet_velocity_m_per_s;
6223 let reynolds_number = cfd_reynolds_number_for_velocity(cfd_domain, flow_inlet_velocity);
6224 let solve_family = match cfd_domain.solve_family {
6225 runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
6226 runmat_analysis_core::CfdSolveFamily::Transient => "transient",
6227 };
6228 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6229 code: "FEA_CFD_FLOW".to_string(),
6230 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6231 message: format!(
6232 "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={}",
6233 cfd_domain.reference_density_kg_per_m3,
6234 cfd_domain.dynamic_viscosity_pa_s,
6235 flow_inlet_velocity,
6236 cfd_domain.turbulence_intensity,
6237 reynolds_number,
6238 solve_family,
6239 cfd_domain.time_profile.len(),
6240 flow_topology.basis.as_str(),
6241 flow_topology.control_volume_count,
6242 flow_topology.control_volume_face_count,
6243 flow_topology.control_volume_internal_face_count,
6244 flow_topology.control_volume_boundary_face_count,
6245 flow_topology.control_volume_connectivity_coverage_ratio,
6246 flow_topology.domain_length_m,
6247 flow_topology.hydraulic_diameter_m,
6248 ),
6249 });
6250
6251 let max_momentum_residual = diagnostic_metric(
6252 &run.diagnostics,
6253 "FEA_CFD_RESIDUAL",
6254 "max_momentum_residual",
6255 )
6256 .unwrap_or(f64::INFINITY);
6257 let max_continuity_residual = diagnostic_metric(
6258 &run.diagnostics,
6259 "FEA_CFD_RESIDUAL",
6260 "max_continuity_residual",
6261 )
6262 .unwrap_or(f64::INFINITY);
6263 let solver_convergence = if max_momentum_residual <= options.residual_warn_threshold
6264 && max_continuity_residual <= options.residual_warn_threshold
6265 {
6266 QualityGate::Pass
6267 } else {
6268 QualityGate::Warn
6269 };
6270 let result_quality = if run.fields_are_empty()
6271 || !max_momentum_residual.is_finite()
6272 || !max_continuity_residual.is_finite()
6273 {
6274 QualityGate::Fail
6275 } else if max_momentum_residual > options.residual_warn_threshold
6276 || max_continuity_residual > options.residual_warn_threshold
6277 {
6278 QualityGate::Warn
6279 } else {
6280 QualityGate::Pass
6281 };
6282
6283 let mut quality_reasons = Vec::new();
6284 if solver_convergence == QualityGate::Warn {
6285 quality_reasons.push(QualityReason {
6286 code: QualityReasonCode::SolverNotConverged,
6287 detail: "cfd solver convergence gate is warning".to_string(),
6288 });
6289 }
6290 if result_quality == QualityGate::Warn {
6291 quality_reasons.push(QualityReason {
6292 code: QualityReasonCode::TransientResidualExceeded,
6293 detail: format!(
6294 "cfd residual exceeds threshold {}",
6295 options.residual_warn_threshold
6296 ),
6297 });
6298 }
6299 if fallback_events
6300 .iter()
6301 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
6302 {
6303 quality_reasons.push(QualityReason {
6304 code: QualityReasonCode::SolverBackendFallback,
6305 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
6306 });
6307 }
6308 if fallback_events.iter().any(|event| {
6309 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
6310 }) {
6311 quality_reasons.push(QualityReason {
6312 code: QualityReasonCode::FieldPromotionFallback,
6313 detail: "field promotion fell back to host-backed values".to_string(),
6314 });
6315 }
6316
6317 let publishable = match options.quality_policy {
6318 QualityPolicy::Strict => {
6319 solver_convergence == QualityGate::Pass
6320 && result_quality == QualityGate::Pass
6321 && quality_reasons.is_empty()
6322 }
6323 QualityPolicy::Balanced => {
6324 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
6325 }
6326 QualityPolicy::Exploratory => {
6327 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
6328 }
6329 };
6330 let run_status = if publishable {
6331 RunStatus::Publishable
6332 } else if result_quality == QualityGate::Fail {
6333 RunStatus::Rejected
6334 } else {
6335 RunStatus::Degraded
6336 };
6337 let solver_backend = run.solver_backend.clone();
6338 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
6339 let solver_host_sync_count = run.solver_host_sync_count;
6340 let solver_method = run.solver_method.clone();
6341 let selected_preconditioner = run.preconditioner.clone();
6342
6343 let result = AnalysisRunResult {
6344 run_id: storage::next_run_id(),
6345 run,
6346 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
6347 modal_results: None,
6348 thermal_results: None,
6349 transient_results: None,
6350 nonlinear_results: None,
6351 electromagnetic_results: None,
6352 model_validity: QualityGate::Pass,
6353 solver_convergence,
6354 result_quality,
6355 run_status,
6356 publishable,
6357 quality_reasons,
6358 provenance: RunProvenance {
6359 backend,
6360 solver_backend,
6361 solver_device_apply_k_ratio,
6362 solver_host_sync_count,
6363 precision_mode: contracts::format_precision_mode(options.precision_mode),
6364 deterministic_mode: options.deterministic_mode,
6365 solver_method,
6366 preconditioner: selected_preconditioner,
6367 quality_policy: contracts::format_quality_policy(options.quality_policy),
6368 fallback_events,
6369 },
6370 };
6371
6372 persist_fea_run_result_with_progress(
6373 ANALYSIS_RUN_CFD_OPERATION,
6374 ANALYSIS_RUN_CFD_OP_VERSION,
6375 "RM.FEA.RUN_CFD.ARTIFACT_STORE_FAILED",
6376 &context,
6377 &result,
6378 )?;
6379
6380 Ok(OperationEnvelope::new(
6381 ANALYSIS_RUN_CFD_OPERATION,
6382 ANALYSIS_RUN_CFD_OP_VERSION,
6383 &context,
6384 result,
6385 ))
6386}
6387
6388pub fn analysis_run_thermal_op(
6389 model: &AnalysisModel,
6390 backend: ComputeBackend,
6391 context: OperationContext,
6392) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6393 analysis_run_thermal_with_options_op(
6394 model,
6395 backend,
6396 AnalysisThermalRunOptions::default(),
6397 context,
6398 )
6399}
6400
6401pub fn analysis_run_cht_op(
6402 model: &AnalysisModel,
6403 backend: ComputeBackend,
6404 context: OperationContext,
6405) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6406 analysis_run_cht_with_options_op(model, backend, AnalysisChtRunOptions::default(), context)
6407}
6408
6409pub fn analysis_run_cht_with_options_op(
6410 model: &AnalysisModel,
6411 backend: ComputeBackend,
6412 options: AnalysisChtRunOptions,
6413 context: OperationContext,
6414) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6415 let _solver_context = install_fea_solver_context();
6416 let has_cfd_step = model
6417 .steps
6418 .iter()
6419 .any(|step| step.kind == AnalysisStepKind::Cfd);
6420 if !has_cfd_step {
6421 return Err(operation_error(
6422 ANALYSIS_RUN_CHT_OPERATION,
6423 ANALYSIS_RUN_CHT_OP_VERSION,
6424 &context,
6425 OperationErrorSpec {
6426 error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6427 error_type: OperationErrorType::Validation,
6428 retryable: false,
6429 severity: OperationErrorSeverity::Error,
6430 },
6431 "FEA model must include at least one cfd step for fea.run_cht",
6432 BTreeMap::from([
6433 ("analysis_model_id".to_string(), model.model_id.0.clone()),
6434 ("geometry_id".to_string(), model.geometry_id.clone()),
6435 ]),
6436 ));
6437 }
6438 let has_thermal_step = model
6439 .steps
6440 .iter()
6441 .any(|step| step.kind == AnalysisStepKind::Thermal);
6442 if !has_thermal_step {
6443 return Err(operation_error(
6444 ANALYSIS_RUN_CHT_OPERATION,
6445 ANALYSIS_RUN_CHT_OP_VERSION,
6446 &context,
6447 OperationErrorSpec {
6448 error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6449 error_type: OperationErrorType::Validation,
6450 retryable: false,
6451 severity: OperationErrorSeverity::Error,
6452 },
6453 "FEA model must include at least one thermal step for fea.run_cht",
6454 BTreeMap::from([
6455 ("analysis_model_id".to_string(), model.model_id.0.clone()),
6456 ("geometry_id".to_string(), model.geometry_id.clone()),
6457 ]),
6458 ));
6459 }
6460 let Some(cfd_domain) = model.cfd.as_ref() else {
6461 return Err(operation_error(
6462 ANALYSIS_RUN_CHT_OPERATION,
6463 ANALYSIS_RUN_CHT_OP_VERSION,
6464 &context,
6465 OperationErrorSpec {
6466 error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6467 error_type: OperationErrorType::Validation,
6468 retryable: false,
6469 severity: OperationErrorSeverity::Error,
6470 },
6471 "fea.run_cht requires model.cfd to be configured",
6472 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6473 ));
6474 };
6475 if !cfd_domain.enabled {
6476 return Err(operation_error(
6477 ANALYSIS_RUN_CHT_OPERATION,
6478 ANALYSIS_RUN_CHT_OP_VERSION,
6479 &context,
6480 OperationErrorSpec {
6481 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6482 error_type: OperationErrorType::Input,
6483 retryable: false,
6484 severity: OperationErrorSeverity::Error,
6485 },
6486 "fea.run_cht requires cfd domain enabled=true",
6487 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6488 ));
6489 }
6490 if !cfd_domain.reference_density_kg_per_m3.is_finite()
6491 || cfd_domain.reference_density_kg_per_m3 <= 0.0
6492 {
6493 return Err(operation_error(
6494 ANALYSIS_RUN_CHT_OPERATION,
6495 ANALYSIS_RUN_CHT_OP_VERSION,
6496 &context,
6497 OperationErrorSpec {
6498 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6499 error_type: OperationErrorType::Input,
6500 retryable: false,
6501 severity: OperationErrorSeverity::Error,
6502 },
6503 "fea.run_cht requires finite positive reference_density_kg_per_m3",
6504 BTreeMap::from([(
6505 "reference_density_kg_per_m3".to_string(),
6506 cfd_domain.reference_density_kg_per_m3.to_string(),
6507 )]),
6508 ));
6509 }
6510 if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
6511 return Err(operation_error(
6512 ANALYSIS_RUN_CHT_OPERATION,
6513 ANALYSIS_RUN_CHT_OP_VERSION,
6514 &context,
6515 OperationErrorSpec {
6516 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6517 error_type: OperationErrorType::Input,
6518 retryable: false,
6519 severity: OperationErrorSeverity::Error,
6520 },
6521 "fea.run_cht requires finite positive dynamic_viscosity_pa_s",
6522 BTreeMap::from([(
6523 "dynamic_viscosity_pa_s".to_string(),
6524 cfd_domain.dynamic_viscosity_pa_s.to_string(),
6525 )]),
6526 ));
6527 }
6528 if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
6529 return Err(operation_error(
6530 ANALYSIS_RUN_CHT_OPERATION,
6531 ANALYSIS_RUN_CHT_OP_VERSION,
6532 &context,
6533 OperationErrorSpec {
6534 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6535 error_type: OperationErrorType::Input,
6536 retryable: false,
6537 severity: OperationErrorSeverity::Error,
6538 },
6539 "fea.run_cht requires finite non-negative inlet_velocity_m_per_s",
6540 BTreeMap::from([(
6541 "inlet_velocity_m_per_s".to_string(),
6542 cfd_domain.inlet_velocity_m_per_s.to_string(),
6543 )]),
6544 ));
6545 }
6546 if !cfd_domain.turbulence_intensity.is_finite()
6547 || cfd_domain.turbulence_intensity < 0.0
6548 || cfd_domain.turbulence_intensity > 1.0
6549 {
6550 return Err(operation_error(
6551 ANALYSIS_RUN_CHT_OPERATION,
6552 ANALYSIS_RUN_CHT_OP_VERSION,
6553 &context,
6554 OperationErrorSpec {
6555 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6556 error_type: OperationErrorType::Input,
6557 retryable: false,
6558 severity: OperationErrorSeverity::Error,
6559 },
6560 "fea.run_cht requires turbulence_intensity in [0, 1]",
6561 BTreeMap::from([(
6562 "turbulence_intensity".to_string(),
6563 cfd_domain.turbulence_intensity.to_string(),
6564 )]),
6565 ));
6566 }
6567 reject_moment_loads_for_run_family(
6568 model,
6569 ANALYSIS_RUN_CHT_OPERATION,
6570 ANALYSIS_RUN_CHT_OP_VERSION,
6571 "RM.FEA.RUN_CHT.INVALID_LOAD",
6572 "CHT",
6573 &context,
6574 )?;
6575 if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
6576 return Err(operation_error(
6577 ANALYSIS_RUN_CHT_OPERATION,
6578 ANALYSIS_RUN_CHT_OP_VERSION,
6579 &context,
6580 OperationErrorSpec {
6581 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6582 error_type: OperationErrorType::Input,
6583 retryable: false,
6584 severity: OperationErrorSeverity::Error,
6585 },
6586 "fea.run_cht options require finite positive time_step_s",
6587 BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
6588 ));
6589 }
6590 if options.step_count == 0 || options.max_linear_iters == 0 {
6591 return Err(operation_error(
6592 ANALYSIS_RUN_CHT_OPERATION,
6593 ANALYSIS_RUN_CHT_OP_VERSION,
6594 &context,
6595 OperationErrorSpec {
6596 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6597 error_type: OperationErrorType::Input,
6598 retryable: false,
6599 severity: OperationErrorSeverity::Error,
6600 },
6601 "fea.run_cht options require step_count/max_linear_iters greater than zero",
6602 BTreeMap::new(),
6603 ));
6604 }
6605 if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
6606 return Err(operation_error(
6607 ANALYSIS_RUN_CHT_OPERATION,
6608 ANALYSIS_RUN_CHT_OP_VERSION,
6609 &context,
6610 OperationErrorSpec {
6611 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6612 error_type: OperationErrorType::Input,
6613 retryable: false,
6614 severity: OperationErrorSeverity::Error,
6615 },
6616 "fea.run_cht options require finite positive tolerance",
6617 BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
6618 ));
6619 }
6620 if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
6621 return Err(operation_error(
6622 ANALYSIS_RUN_CHT_OPERATION,
6623 ANALYSIS_RUN_CHT_OP_VERSION,
6624 &context,
6625 OperationErrorSpec {
6626 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6627 error_type: OperationErrorType::Input,
6628 retryable: false,
6629 severity: OperationErrorSeverity::Error,
6630 },
6631 "fea.run_cht options require finite positive residual_warn_threshold",
6632 BTreeMap::from([(
6633 "residual_warn_threshold".to_string(),
6634 options.residual_warn_threshold.to_string(),
6635 )]),
6636 ));
6637 }
6638
6639 let thermo_options = resolve_thermo_coupling_options(
6640 model,
6641 model_thermo_coupling_options(model),
6642 ANALYSIS_RUN_CHT_OPERATION,
6643 ANALYSIS_RUN_CHT_OP_VERSION,
6644 &context,
6645 )?;
6646 let Some(thermo_options) = thermo_options else {
6647 return Err(operation_error(
6648 ANALYSIS_RUN_CHT_OPERATION,
6649 ANALYSIS_RUN_CHT_OP_VERSION,
6650 &context,
6651 OperationErrorSpec {
6652 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6653 error_type: OperationErrorType::Input,
6654 retryable: false,
6655 severity: OperationErrorSeverity::Error,
6656 },
6657 "fea.run_cht requires model.thermo_mechanical to be configured",
6658 BTreeMap::new(),
6659 ));
6660 };
6661 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, &thermo_options) {
6662 return Err(operation_error(
6663 ANALYSIS_RUN_CHT_OPERATION,
6664 ANALYSIS_RUN_CHT_OP_VERSION,
6665 &context,
6666 OperationErrorSpec {
6667 error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6668 error_type: OperationErrorType::Input,
6669 retryable: false,
6670 severity: OperationErrorSeverity::Error,
6671 },
6672 detail,
6673 metadata,
6674 ));
6675 }
6676 if let Err((detail, metadata)) = validate_coupled_flow_interfaces(model, "CHT") {
6677 return Err(operation_error(
6678 ANALYSIS_RUN_CHT_OPERATION,
6679 ANALYSIS_RUN_CHT_OP_VERSION,
6680 &context,
6681 OperationErrorSpec {
6682 error_code: "RM.FEA.RUN_CHT.INVALID_INTERFACE_MAPPING",
6683 error_type: OperationErrorType::Validation,
6684 retryable: false,
6685 severity: OperationErrorSeverity::Error,
6686 },
6687 detail,
6688 metadata,
6689 ));
6690 }
6691 let applied_temperature_delta_k = thermo_options.applied_temperature_delta_k;
6692
6693 let prep_context = resolve_run_prep_context(
6694 model,
6695 options.prep_artifact_id.as_deref(),
6696 options.prep_context.clone(),
6697 ANALYSIS_RUN_CHT_OPERATION,
6698 ANALYSIS_RUN_CHT_OP_VERSION,
6699 &context,
6700 )?;
6701
6702 let solve_start = Instant::now();
6703 let thermal_run = run_thermal_with_options(
6704 model,
6705 backend,
6706 ThermalSolveOptions {
6707 step_count: options.step_count,
6708 time_step_s: options.time_step_s,
6709 residual_target: options.residual_warn_threshold,
6710 prep_context: to_fea_prep_context(
6711 prep_context.as_ref(),
6712 options.prep_calibration_profile,
6713 ),
6714 thermo_mechanical_context: to_fea_thermo_mechanical_context(Some(
6715 thermo_options.clone(),
6716 )),
6717 },
6718 )
6719 .map_err(|err| {
6720 map_fea_run_error(
6721 ANALYSIS_RUN_CHT_OPERATION,
6722 ANALYSIS_RUN_CHT_OP_VERSION,
6723 "RM.FEA.RUN_CHT.SOLVER_MODEL_INVALID",
6724 "RM.FEA.RUN_CHT.CANCELLED",
6725 model,
6726 &context,
6727 err,
6728 )
6729 })?;
6730
6731 let topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
6732 let node_count = topology.node_count;
6733 let field_step = match cfd_domain.solve_family {
6734 runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
6735 runmat_analysis_core::CfdSolveFamily::Transient => options.step_count.saturating_sub(1),
6736 };
6737 let (fluid_velocity, fluid_pressure) =
6738 recover_cfd_velocity_pressure(cfd_domain, &topology, field_step);
6739 let (cfd_residual_momentum, cfd_residual_continuity) = cfd_residual_norms(
6740 &fluid_velocity,
6741 &fluid_pressure,
6742 cfd_domain,
6743 &topology,
6744 options.step_count,
6745 );
6746 let max_cfd_momentum_residual = cfd_residual_momentum
6747 .iter()
6748 .copied()
6749 .fold(0.0_f64, f64::max);
6750 let max_cfd_continuity_residual = cfd_residual_continuity
6751 .iter()
6752 .copied()
6753 .fold(0.0_f64, f64::max);
6754 let (cht_fields, cht_interface_closure) = build_cht_run_fields(
6755 cfd_domain,
6756 &topology,
6757 &thermal_run,
6758 cht_interface_conductance_w_per_m2k(model),
6759 options.max_linear_iters,
6760 options.tolerance,
6761 );
6762 let mut run = thermal_run.run.clone();
6763 run.solver_method = "cht_conjugate_projection".to_string();
6764 run.preconditioner = "thermal_cfd_projection".to_string();
6765 run.fields.extend(cht_fields);
6766 let reynolds_number = cfd_reynolds_number(cfd_domain);
6767 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6768 code: "FEA_CFD_FLOW".to_string(),
6769 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6770 message: format!(
6771 "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={}",
6772 cfd_domain.reference_density_kg_per_m3,
6773 cfd_domain.dynamic_viscosity_pa_s,
6774 cfd_domain.inlet_velocity_m_per_s,
6775 cfd_domain.turbulence_intensity,
6776 reynolds_number,
6777 match cfd_domain.solve_family {
6778 runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
6779 runmat_analysis_core::CfdSolveFamily::Transient => "transient",
6780 },
6781 cfd_domain.time_profile.len(),
6782 topology.basis.as_str(),
6783 topology.control_volume_count,
6784 topology.control_volume_face_count,
6785 topology.control_volume_internal_face_count,
6786 topology.control_volume_boundary_face_count,
6787 topology.control_volume_connectivity_coverage_ratio,
6788 topology.domain_length_m,
6789 topology.hydraulic_diameter_m,
6790 ),
6791 });
6792 let cfd_residual_severity = if max_cfd_momentum_residual <= options.residual_warn_threshold
6793 && max_cfd_continuity_residual <= options.residual_warn_threshold
6794 {
6795 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
6796 } else {
6797 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
6798 };
6799 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6800 code: "FEA_CFD_RESIDUAL".to_string(),
6801 severity: cfd_residual_severity,
6802 message: format!(
6803 "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
6804 max_cfd_momentum_residual,
6805 max_cfd_continuity_residual,
6806 options.residual_warn_threshold,
6807 node_count,
6808 options.step_count,
6809 ),
6810 });
6811 run.diagnostics.push(cfd_assembly_diagnostic(
6812 &topology,
6813 cfd_domain,
6814 options.time_step_s,
6815 pressure_drop_from_nodal_pressure(&fluid_pressure),
6816 max_cfd_continuity_residual,
6817 options.residual_warn_threshold,
6818 ));
6819 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6820 code: "FEA_CHT_COUPLING".to_string(),
6821 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6822 message: format!(
6823 "reference_temperature_k={} applied_temperature_delta_k={} step_count={} time_step_s={} authored_interface_count={}",
6824 thermal_run.reference_temperature_k,
6825 applied_temperature_delta_k,
6826 options.step_count,
6827 options.time_step_s,
6828 model.interfaces.len(),
6829 ),
6830 });
6831 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6832 code: "FEA_CHT_INTERFACE_CLOSURE".to_string(),
6833 severity: if cht_interface_closure.heat_flux_balance_ratio <= 1.0e-9
6834 && cht_interface_closure.max_energy_residual <= options.residual_warn_threshold
6835 && cht_interface_closure.max_temperature_jump_k <= 0.1
6836 && cht_interface_closure.max_thermal_transport_residual_ratio
6837 <= options.residual_warn_threshold
6838 && cht_interface_closure.max_flux_temperature_law_residual_ratio
6839 <= options.residual_warn_threshold
6840 && cht_interface_closure.max_heat_flux_realization_residual_ratio
6841 <= options.residual_warn_threshold
6842 && cht_interface_closure.max_coupled_interface_residual_ratio
6843 <= options.residual_warn_threshold
6844 && cht_interface_closure.interface_connectivity_coverage_ratio >= 1.0
6845 && cht_interface_closure.max_thermal_network_residual_ratio
6846 <= options.residual_warn_threshold
6847 {
6848 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
6849 } else {
6850 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
6851 },
6852 message: format!(
6853 "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={}",
6854 cht_interface_closure.interface_face_count,
6855 cht_interface_closure.max_temperature_jump_k,
6856 cht_interface_closure.max_energy_residual,
6857 cht_interface_closure.heat_flux_balance_ratio,
6858 cht_interface_closure.mean_interface_heat_flux_w_per_m2,
6859 cht_interface_closure.max_thermal_transport_residual_ratio,
6860 cht_interface_closure.interface_temperature_continuity_ratio,
6861 cht_interface_closure.max_advection_temperature_shift_k,
6862 cht_interface_closure.interface_conductance_w_per_m2k,
6863 cht_interface_closure.max_flux_temperature_law_residual_ratio,
6864 cht_interface_closure.max_heat_flux_realization_residual_ratio,
6865 cht_interface_closure.max_coupled_interface_iteration_count,
6866 cht_interface_closure.max_coupled_interface_residual_ratio,
6867 cht_interface_closure.thermal_network_node_count,
6868 cht_interface_closure.thermal_network_edge_count,
6869 cht_interface_closure.interface_connectivity_coverage_ratio,
6870 cht_interface_closure.mesh_backed_interface_connectivity_ratio,
6871 cht_interface_closure.full_topology_edge_count,
6872 cht_interface_closure.full_topology_element_count,
6873 cht_interface_closure.max_thermal_network_residual_ratio,
6874 ),
6875 });
6876 let cht_known_answer = cht_known_answer_metrics(cfd_domain, &cht_interface_closure);
6877 run.diagnostics.push(cht_known_answer_diagnostic(
6878 &cht_known_answer,
6879 options.residual_warn_threshold,
6880 ));
6881 let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
6882 run.diagnostics
6883 .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6884 code: "FEA_CHT_COST".to_string(),
6885 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6886 message: format!(
6887 "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
6888 solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
6889 ),
6890 });
6891
6892 let mut fallback_events = Vec::new();
6893 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
6894 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
6895 fallback_events.push(
6896 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
6897 );
6898 }
6899
6900 let max_cht_transport_residual = cht_interface_closure
6901 .max_thermal_transport_residual_ratio
6902 .max(
6903 cht_interface_closure
6904 .max_energy_residual
6905 .max(cht_interface_closure.heat_flux_balance_ratio),
6906 );
6907 let solver_convergence = if max_cfd_momentum_residual <= options.residual_warn_threshold
6908 && max_cfd_continuity_residual <= options.residual_warn_threshold
6909 && max_cht_transport_residual <= options.residual_warn_threshold
6910 {
6911 QualityGate::Pass
6912 } else {
6913 QualityGate::Warn
6914 };
6915 let result_quality = if run.fields_are_empty()
6916 || thermal_run.temperature_snapshots.is_empty()
6917 || thermal_run.time_points_s.is_empty()
6918 || thermal_run.residual_norms.iter().any(|r| !r.is_finite())
6919 || !max_cfd_momentum_residual.is_finite()
6920 || !max_cfd_continuity_residual.is_finite()
6921 || !max_cht_transport_residual.is_finite()
6922 {
6923 QualityGate::Fail
6924 } else if max_cfd_momentum_residual > options.residual_warn_threshold
6925 || max_cfd_continuity_residual > options.residual_warn_threshold
6926 || max_cht_transport_residual > options.residual_warn_threshold
6927 {
6928 QualityGate::Warn
6929 } else {
6930 QualityGate::Pass
6931 };
6932
6933 let mut quality_reasons = Vec::new();
6934 if solver_convergence == QualityGate::Warn {
6935 quality_reasons.push(QualityReason {
6936 code: QualityReasonCode::SolverNotConverged,
6937 detail: "cht solver convergence gate is warning".to_string(),
6938 });
6939 }
6940 if max_cfd_momentum_residual > options.residual_warn_threshold
6941 || max_cfd_continuity_residual > options.residual_warn_threshold
6942 {
6943 quality_reasons.push(QualityReason {
6944 code: QualityReasonCode::SolverNotConverged,
6945 detail: format!(
6946 "cht cfd residual exceeds threshold {}",
6947 options.residual_warn_threshold,
6948 ),
6949 });
6950 }
6951 if max_cht_transport_residual > options.residual_warn_threshold {
6952 quality_reasons.push(QualityReason {
6953 code: QualityReasonCode::SolverNotConverged,
6954 detail: format!(
6955 "cht interface transport residual exceeds threshold {}",
6956 options.residual_warn_threshold
6957 ),
6958 });
6959 }
6960 if fallback_events
6961 .iter()
6962 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
6963 {
6964 quality_reasons.push(QualityReason {
6965 code: QualityReasonCode::SolverBackendFallback,
6966 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
6967 });
6968 }
6969 if fallback_events.iter().any(|event| {
6970 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
6971 }) {
6972 quality_reasons.push(QualityReason {
6973 code: QualityReasonCode::FieldPromotionFallback,
6974 detail: "field promotion fell back to host-backed values".to_string(),
6975 });
6976 }
6977
6978 let publishable = match options.quality_policy {
6979 QualityPolicy::Strict => {
6980 solver_convergence == QualityGate::Pass
6981 && result_quality == QualityGate::Pass
6982 && quality_reasons.is_empty()
6983 }
6984 QualityPolicy::Balanced => {
6985 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
6986 }
6987 QualityPolicy::Exploratory => {
6988 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
6989 }
6990 };
6991 let run_status = if publishable {
6992 RunStatus::Publishable
6993 } else if result_quality == QualityGate::Fail {
6994 RunStatus::Rejected
6995 } else {
6996 RunStatus::Degraded
6997 };
6998 let solver_backend = run.solver_backend.clone();
6999 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
7000 let solver_host_sync_count = run.solver_host_sync_count;
7001 let solver_method = run.solver_method.clone();
7002 let selected_preconditioner = run.preconditioner.clone();
7003
7004 let result = AnalysisRunResult {
7005 run_id: storage::next_run_id(),
7006 run,
7007 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
7008 modal_results: None,
7009 thermal_results: Some(ThermalResultsData {
7010 thermal_payload_version: "thermal_results/v1".to_string(),
7011 time_points_s: thermal_run.time_points_s,
7012 temperature_snapshots: thermal_run.temperature_snapshots,
7013 temperature_gradient_snapshots: thermal_run.temperature_gradient_snapshots,
7014 heat_flux_snapshots: thermal_run.heat_flux_snapshots,
7015 heat_source_snapshots: thermal_run.heat_source_snapshots,
7016 boundary_heat_flux_snapshots: thermal_run.boundary_heat_flux_snapshots,
7017 residual_norms: thermal_run.residual_norms,
7018 reference_temperature_k: thermal_run.reference_temperature_k,
7019 }),
7020 transient_results: None,
7021 nonlinear_results: None,
7022 electromagnetic_results: None,
7023 model_validity: QualityGate::Pass,
7024 solver_convergence,
7025 result_quality,
7026 run_status,
7027 publishable,
7028 quality_reasons,
7029 provenance: RunProvenance {
7030 backend,
7031 solver_backend,
7032 solver_device_apply_k_ratio,
7033 solver_host_sync_count,
7034 precision_mode: contracts::format_precision_mode(options.precision_mode),
7035 deterministic_mode: options.deterministic_mode,
7036 solver_method,
7037 preconditioner: selected_preconditioner,
7038 quality_policy: contracts::format_quality_policy(options.quality_policy),
7039 fallback_events,
7040 },
7041 };
7042
7043 persist_fea_run_result_with_progress(
7044 ANALYSIS_RUN_CHT_OPERATION,
7045 ANALYSIS_RUN_CHT_OP_VERSION,
7046 "RM.FEA.RUN_CHT.ARTIFACT_STORE_FAILED",
7047 &context,
7048 &result,
7049 )?;
7050
7051 Ok(OperationEnvelope::new(
7052 ANALYSIS_RUN_CHT_OPERATION,
7053 ANALYSIS_RUN_CHT_OP_VERSION,
7054 &context,
7055 result,
7056 ))
7057}
7058
7059pub fn analysis_run_fsi_op(
7060 model: &AnalysisModel,
7061 backend: ComputeBackend,
7062 context: OperationContext,
7063) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7064 analysis_run_fsi_with_options_op(model, backend, AnalysisFsiRunOptions::default(), context)
7065}
7066
7067pub fn analysis_run_fsi_with_options_op(
7068 model: &AnalysisModel,
7069 backend: ComputeBackend,
7070 options: AnalysisFsiRunOptions,
7071 context: OperationContext,
7072) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7073 let _solver_context = install_fea_solver_context();
7074 let has_cfd_step = model
7075 .steps
7076 .iter()
7077 .any(|step| step.kind == AnalysisStepKind::Cfd);
7078 if !has_cfd_step {
7079 return Err(operation_error(
7080 ANALYSIS_RUN_FSI_OPERATION,
7081 ANALYSIS_RUN_FSI_OP_VERSION,
7082 &context,
7083 OperationErrorSpec {
7084 error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7085 error_type: OperationErrorType::Validation,
7086 retryable: false,
7087 severity: OperationErrorSeverity::Error,
7088 },
7089 "FEA model must include at least one cfd step for fea.run_fsi",
7090 BTreeMap::from([
7091 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7092 ("geometry_id".to_string(), model.geometry_id.clone()),
7093 ]),
7094 ));
7095 }
7096 let has_transient_step = model
7097 .steps
7098 .iter()
7099 .any(|step| step.kind == AnalysisStepKind::Transient);
7100 if !has_transient_step {
7101 return Err(operation_error(
7102 ANALYSIS_RUN_FSI_OPERATION,
7103 ANALYSIS_RUN_FSI_OP_VERSION,
7104 &context,
7105 OperationErrorSpec {
7106 error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7107 error_type: OperationErrorType::Validation,
7108 retryable: false,
7109 severity: OperationErrorSeverity::Error,
7110 },
7111 "FEA model must include at least one transient step for fea.run_fsi",
7112 BTreeMap::from([
7113 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7114 ("geometry_id".to_string(), model.geometry_id.clone()),
7115 ]),
7116 ));
7117 }
7118 let Some(cfd_domain) = model.cfd.as_ref() else {
7119 return Err(operation_error(
7120 ANALYSIS_RUN_FSI_OPERATION,
7121 ANALYSIS_RUN_FSI_OP_VERSION,
7122 &context,
7123 OperationErrorSpec {
7124 error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7125 error_type: OperationErrorType::Validation,
7126 retryable: false,
7127 severity: OperationErrorSeverity::Error,
7128 },
7129 "fea.run_fsi requires model.cfd to be configured",
7130 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
7131 ));
7132 };
7133 if !cfd_domain.enabled {
7134 return Err(operation_error(
7135 ANALYSIS_RUN_FSI_OPERATION,
7136 ANALYSIS_RUN_FSI_OP_VERSION,
7137 &context,
7138 OperationErrorSpec {
7139 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7140 error_type: OperationErrorType::Input,
7141 retryable: false,
7142 severity: OperationErrorSeverity::Error,
7143 },
7144 "fea.run_fsi requires cfd domain enabled=true",
7145 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
7146 ));
7147 }
7148 if !cfd_domain.reference_density_kg_per_m3.is_finite()
7149 || cfd_domain.reference_density_kg_per_m3 <= 0.0
7150 {
7151 return Err(operation_error(
7152 ANALYSIS_RUN_FSI_OPERATION,
7153 ANALYSIS_RUN_FSI_OP_VERSION,
7154 &context,
7155 OperationErrorSpec {
7156 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7157 error_type: OperationErrorType::Input,
7158 retryable: false,
7159 severity: OperationErrorSeverity::Error,
7160 },
7161 "fea.run_fsi requires finite positive reference_density_kg_per_m3",
7162 BTreeMap::from([(
7163 "reference_density_kg_per_m3".to_string(),
7164 cfd_domain.reference_density_kg_per_m3.to_string(),
7165 )]),
7166 ));
7167 }
7168 if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
7169 return Err(operation_error(
7170 ANALYSIS_RUN_FSI_OPERATION,
7171 ANALYSIS_RUN_FSI_OP_VERSION,
7172 &context,
7173 OperationErrorSpec {
7174 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7175 error_type: OperationErrorType::Input,
7176 retryable: false,
7177 severity: OperationErrorSeverity::Error,
7178 },
7179 "fea.run_fsi requires finite positive dynamic_viscosity_pa_s",
7180 BTreeMap::from([(
7181 "dynamic_viscosity_pa_s".to_string(),
7182 cfd_domain.dynamic_viscosity_pa_s.to_string(),
7183 )]),
7184 ));
7185 }
7186 if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
7187 return Err(operation_error(
7188 ANALYSIS_RUN_FSI_OPERATION,
7189 ANALYSIS_RUN_FSI_OP_VERSION,
7190 &context,
7191 OperationErrorSpec {
7192 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7193 error_type: OperationErrorType::Input,
7194 retryable: false,
7195 severity: OperationErrorSeverity::Error,
7196 },
7197 "fea.run_fsi requires finite non-negative inlet_velocity_m_per_s",
7198 BTreeMap::from([(
7199 "inlet_velocity_m_per_s".to_string(),
7200 cfd_domain.inlet_velocity_m_per_s.to_string(),
7201 )]),
7202 ));
7203 }
7204 if !cfd_domain.turbulence_intensity.is_finite()
7205 || cfd_domain.turbulence_intensity < 0.0
7206 || cfd_domain.turbulence_intensity > 1.0
7207 {
7208 return Err(operation_error(
7209 ANALYSIS_RUN_FSI_OPERATION,
7210 ANALYSIS_RUN_FSI_OP_VERSION,
7211 &context,
7212 OperationErrorSpec {
7213 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7214 error_type: OperationErrorType::Input,
7215 retryable: false,
7216 severity: OperationErrorSeverity::Error,
7217 },
7218 "fea.run_fsi requires turbulence_intensity in [0, 1]",
7219 BTreeMap::from([(
7220 "turbulence_intensity".to_string(),
7221 cfd_domain.turbulence_intensity.to_string(),
7222 )]),
7223 ));
7224 }
7225 reject_moment_loads_for_run_family(
7226 model,
7227 ANALYSIS_RUN_FSI_OPERATION,
7228 ANALYSIS_RUN_FSI_OP_VERSION,
7229 "RM.FEA.RUN_FSI.INVALID_LOAD",
7230 "FSI",
7231 &context,
7232 )?;
7233 if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
7234 return Err(operation_error(
7235 ANALYSIS_RUN_FSI_OPERATION,
7236 ANALYSIS_RUN_FSI_OP_VERSION,
7237 &context,
7238 OperationErrorSpec {
7239 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7240 error_type: OperationErrorType::Input,
7241 retryable: false,
7242 severity: OperationErrorSeverity::Error,
7243 },
7244 "fea.run_fsi options require finite positive time_step_s",
7245 BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
7246 ));
7247 }
7248 if options.step_count == 0 || options.max_linear_iters == 0 {
7249 return Err(operation_error(
7250 ANALYSIS_RUN_FSI_OPERATION,
7251 ANALYSIS_RUN_FSI_OP_VERSION,
7252 &context,
7253 OperationErrorSpec {
7254 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7255 error_type: OperationErrorType::Input,
7256 retryable: false,
7257 severity: OperationErrorSeverity::Error,
7258 },
7259 "fea.run_fsi options require step_count/max_linear_iters greater than zero",
7260 BTreeMap::new(),
7261 ));
7262 }
7263 if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
7264 return Err(operation_error(
7265 ANALYSIS_RUN_FSI_OPERATION,
7266 ANALYSIS_RUN_FSI_OP_VERSION,
7267 &context,
7268 OperationErrorSpec {
7269 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7270 error_type: OperationErrorType::Input,
7271 retryable: false,
7272 severity: OperationErrorSeverity::Error,
7273 },
7274 "fea.run_fsi options require finite positive tolerance",
7275 BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
7276 ));
7277 }
7278 if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
7279 return Err(operation_error(
7280 ANALYSIS_RUN_FSI_OPERATION,
7281 ANALYSIS_RUN_FSI_OP_VERSION,
7282 &context,
7283 OperationErrorSpec {
7284 error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7285 error_type: OperationErrorType::Input,
7286 retryable: false,
7287 severity: OperationErrorSeverity::Error,
7288 },
7289 "fea.run_fsi options require finite positive residual_warn_threshold",
7290 BTreeMap::from([(
7291 "residual_warn_threshold".to_string(),
7292 options.residual_warn_threshold.to_string(),
7293 )]),
7294 ));
7295 }
7296 if let Err((detail, metadata)) = validate_coupled_flow_interfaces(model, "FSI") {
7297 return Err(operation_error(
7298 ANALYSIS_RUN_FSI_OPERATION,
7299 ANALYSIS_RUN_FSI_OP_VERSION,
7300 &context,
7301 OperationErrorSpec {
7302 error_code: "RM.FEA.RUN_FSI.INVALID_INTERFACE_MAPPING",
7303 error_type: OperationErrorType::Validation,
7304 retryable: false,
7305 severity: OperationErrorSeverity::Error,
7306 },
7307 detail,
7308 metadata,
7309 ));
7310 }
7311
7312 let prep_context = resolve_run_prep_context(
7313 model,
7314 options.prep_artifact_id.as_deref(),
7315 options.prep_context.clone(),
7316 ANALYSIS_RUN_FSI_OPERATION,
7317 ANALYSIS_RUN_FSI_OP_VERSION,
7318 &context,
7319 )?;
7320
7321 let solve_start = Instant::now();
7322 let topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
7323 let node_count = topology.node_count;
7324 let field_step = match cfd_domain.solve_family {
7325 runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
7326 runmat_analysis_core::CfdSolveFamily::Transient => options.step_count.saturating_sub(1),
7327 };
7328 let (fluid_velocity, fluid_pressure) =
7329 recover_cfd_velocity_pressure(cfd_domain, &topology, field_step);
7330 let (cfd_residual_momentum, cfd_residual_continuity) = cfd_residual_norms(
7331 &fluid_velocity,
7332 &fluid_pressure,
7333 cfd_domain,
7334 &topology,
7335 options.step_count,
7336 );
7337 let max_cfd_momentum_residual = cfd_residual_momentum
7338 .iter()
7339 .copied()
7340 .fold(0.0_f64, f64::max);
7341 let max_cfd_continuity_residual = cfd_residual_continuity
7342 .iter()
7343 .copied()
7344 .fold(0.0_f64, f64::max);
7345 let structural_compliance_per_pa = fsi_structural_compliance_per_pa(model);
7346 let (fsi_fields, fsi_interface_closure) = build_fsi_run_fields(
7347 cfd_domain,
7348 &topology,
7349 options.step_count,
7350 structural_compliance_per_pa,
7351 options.max_linear_iters,
7352 options.tolerance,
7353 &cfd_residual_momentum,
7354 &cfd_residual_continuity,
7355 );
7356 let max_interface_residual = fsi_interface_closure.max_interface_residual;
7357 let mut run = FeaRunResult {
7358 backend,
7359 solver_backend: "cpu_reference".to_string(),
7360 solver_device_apply_k_ratio: 0.0,
7361 solver_method: "fsi_partitioned_projection".to_string(),
7362 preconditioner: "interface_relaxation".to_string(),
7363 solver_host_sync_count: 0,
7364 diagnostics: Vec::new(),
7365 fields: fsi_fields,
7366 };
7367 let reynolds_number = cfd_reynolds_number(cfd_domain);
7368 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7369 code: "FEA_CFD_FLOW".to_string(),
7370 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7371 message: format!(
7372 "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={}",
7373 cfd_domain.reference_density_kg_per_m3,
7374 cfd_domain.dynamic_viscosity_pa_s,
7375 cfd_domain.inlet_velocity_m_per_s,
7376 cfd_domain.turbulence_intensity,
7377 reynolds_number,
7378 match cfd_domain.solve_family {
7379 runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
7380 runmat_analysis_core::CfdSolveFamily::Transient => "transient",
7381 },
7382 cfd_domain.time_profile.len(),
7383 topology.basis.as_str(),
7384 topology.control_volume_count,
7385 topology.control_volume_face_count,
7386 topology.control_volume_internal_face_count,
7387 topology.control_volume_boundary_face_count,
7388 topology.control_volume_connectivity_coverage_ratio,
7389 topology.domain_length_m,
7390 topology.hydraulic_diameter_m,
7391 ),
7392 });
7393 let residual_severity = if max_interface_residual <= options.residual_warn_threshold {
7394 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
7395 } else {
7396 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
7397 };
7398 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7399 code: "FEA_CFD_RESIDUAL".to_string(),
7400 severity: residual_severity,
7401 message: format!(
7402 "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
7403 max_cfd_momentum_residual,
7404 max_cfd_continuity_residual,
7405 options.residual_warn_threshold,
7406 node_count,
7407 options.step_count,
7408 ),
7409 });
7410 run.diagnostics.push(cfd_assembly_diagnostic(
7411 &topology,
7412 cfd_domain,
7413 options.time_step_s,
7414 pressure_drop_from_nodal_pressure(&fluid_pressure),
7415 max_cfd_continuity_residual,
7416 options.residual_warn_threshold,
7417 ));
7418 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7419 code: "FEA_FSI_INTERFACE_RESIDUAL".to_string(),
7420 severity: residual_severity,
7421 message: format!(
7422 "max_interface_residual={} structural_compliance_per_pa={} residual_warn_threshold={} interface_node_count={} interface_face_count={}",
7423 max_interface_residual,
7424 structural_compliance_per_pa,
7425 options.residual_warn_threshold,
7426 fsi_interface_closure.interface_node_count,
7427 fsi_interface_closure.interface_face_count,
7428 ),
7429 });
7430 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7431 code: "FEA_FSI_INTERFACE_CLOSURE".to_string(),
7432 severity: if fsi_interface_closure.force_balance_ratio <= 1.0e-9
7433 && fsi_interface_closure.max_displacement_transfer_residual_m <= 1.0e-12
7434 && fsi_interface_closure.max_pressure_feedback_residual_ratio <= options.tolerance
7435 && fsi_interface_closure.max_two_way_interface_residual_ratio <= options.tolerance
7436 && fsi_interface_closure.max_structural_traction_update_residual_ratio
7437 <= options.tolerance
7438 && fsi_interface_closure.max_pressure_displacement_law_residual_ratio
7439 <= options.tolerance
7440 && fsi_interface_closure.max_structural_solve_residual_ratio <= options.tolerance
7441 && fsi_interface_closure.max_interface_work_energy_residual_ratio <= options.tolerance
7442 && fsi_interface_closure.structural_coupling_edge_count > 0
7443 && fsi_interface_closure.interface_connectivity_coverage_ratio >= 1.0
7444 && fsi_interface_closure.max_interface_residual <= options.residual_warn_threshold
7445 {
7446 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
7447 } else {
7448 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
7449 },
7450 message: format!(
7451 "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={}",
7452 fsi_interface_closure.interface_node_count,
7453 fsi_interface_closure.interface_face_count,
7454 fsi_interface_closure.max_interface_residual,
7455 fsi_interface_closure.force_balance_ratio,
7456 fsi_interface_closure.max_displacement_transfer_residual_m,
7457 fsi_interface_closure.max_interface_displacement_m,
7458 fsi_interface_closure.mean_interface_pressure_pa,
7459 fsi_interface_closure.max_traction_magnitude_pa,
7460 fsi_interface_closure.max_coupling_iteration_count,
7461 fsi_interface_closure.max_pressure_feedback_residual_ratio,
7462 fsi_interface_closure.max_two_way_interface_residual_ratio,
7463 fsi_interface_closure.max_structural_traction_update_residual_ratio,
7464 fsi_interface_closure.max_pressure_displacement_law_residual_ratio,
7465 fsi_interface_closure.max_structural_solve_residual_ratio,
7466 fsi_interface_closure.max_interface_work_j_per_m2,
7467 fsi_interface_closure.max_structural_strain_energy_j_per_m2,
7468 fsi_interface_closure.max_interface_work_energy_residual_ratio,
7469 fsi_interface_closure.structural_coupling_edge_count,
7470 fsi_interface_closure.interface_connectivity_coverage_ratio,
7471 fsi_interface_closure.mesh_backed_interface_connectivity_ratio,
7472 fsi_interface_closure.full_topology_edge_count,
7473 fsi_interface_closure.full_topology_element_count,
7474 fsi_interface_closure.interface_stiffness_pa_per_m,
7475 ),
7476 });
7477 let fsi_known_answer = fsi_known_answer_metrics(&fsi_interface_closure);
7478 run.diagnostics.push(fsi_known_answer_diagnostic(
7479 &fsi_known_answer,
7480 options.tolerance,
7481 ));
7482 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7483 code: "FEA_FSI_COUPLING".to_string(),
7484 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7485 message: format!(
7486 "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={}",
7487 options.step_count,
7488 options.time_step_s,
7489 model
7490 .steps
7491 .iter()
7492 .filter(|step| step.kind == AnalysisStepKind::Transient)
7493 .count(),
7494 cfd_domain.time_profile.len(),
7495 model.interfaces.len(),
7496 fsi_interface_closure.interface_node_count,
7497 fsi_interface_closure.interface_face_count,
7498 options.max_linear_iters,
7499 options.tolerance,
7500 ),
7501 });
7502 let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
7503 run.diagnostics
7504 .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7505 code: "FEA_FSI_COST".to_string(),
7506 severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7507 message: format!(
7508 "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
7509 solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
7510 ),
7511 });
7512
7513 let mut fallback_events = Vec::new();
7514 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
7515 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
7516 fallback_events.push(
7517 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
7518 );
7519 }
7520
7521 let solver_convergence = if max_interface_residual <= options.residual_warn_threshold {
7522 QualityGate::Pass
7523 } else {
7524 QualityGate::Warn
7525 };
7526 let result_quality = if run.fields_are_empty()
7527 || !max_cfd_momentum_residual.is_finite()
7528 || !max_cfd_continuity_residual.is_finite()
7529 || !max_interface_residual.is_finite()
7530 {
7531 QualityGate::Fail
7532 } else if max_interface_residual > options.residual_warn_threshold {
7533 QualityGate::Warn
7534 } else {
7535 QualityGate::Pass
7536 };
7537
7538 let mut quality_reasons = Vec::new();
7539 if solver_convergence == QualityGate::Warn {
7540 quality_reasons.push(QualityReason {
7541 code: QualityReasonCode::SolverNotConverged,
7542 detail: "fsi solver convergence gate is warning".to_string(),
7543 });
7544 }
7545 if max_interface_residual > options.residual_warn_threshold {
7546 quality_reasons.push(QualityReason {
7547 code: QualityReasonCode::SolverNotConverged,
7548 detail: format!(
7549 "fsi interface residual exceeds threshold {}",
7550 options.residual_warn_threshold
7551 ),
7552 });
7553 }
7554 if fallback_events
7555 .iter()
7556 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
7557 {
7558 quality_reasons.push(QualityReason {
7559 code: QualityReasonCode::SolverBackendFallback,
7560 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
7561 });
7562 }
7563 if fallback_events.iter().any(|event| {
7564 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
7565 }) {
7566 quality_reasons.push(QualityReason {
7567 code: QualityReasonCode::FieldPromotionFallback,
7568 detail: "field promotion fell back to host-backed values".to_string(),
7569 });
7570 }
7571
7572 let publishable = match options.quality_policy {
7573 QualityPolicy::Strict => {
7574 solver_convergence == QualityGate::Pass
7575 && result_quality == QualityGate::Pass
7576 && quality_reasons.is_empty()
7577 }
7578 QualityPolicy::Balanced => {
7579 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
7580 }
7581 QualityPolicy::Exploratory => {
7582 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
7583 }
7584 };
7585 let run_status = if publishable {
7586 RunStatus::Publishable
7587 } else if result_quality == QualityGate::Fail {
7588 RunStatus::Rejected
7589 } else {
7590 RunStatus::Degraded
7591 };
7592 let solver_backend = run.solver_backend.clone();
7593 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
7594 let solver_host_sync_count = run.solver_host_sync_count;
7595 let solver_method = run.solver_method.clone();
7596 let selected_preconditioner = run.preconditioner.clone();
7597
7598 let result = AnalysisRunResult {
7599 run_id: storage::next_run_id(),
7600 run,
7601 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
7602 modal_results: None,
7603 thermal_results: None,
7604 transient_results: None,
7605 nonlinear_results: None,
7606 electromagnetic_results: None,
7607 model_validity: QualityGate::Pass,
7608 solver_convergence,
7609 result_quality,
7610 run_status,
7611 publishable,
7612 quality_reasons,
7613 provenance: RunProvenance {
7614 backend,
7615 solver_backend,
7616 solver_device_apply_k_ratio,
7617 solver_host_sync_count,
7618 precision_mode: contracts::format_precision_mode(options.precision_mode),
7619 deterministic_mode: options.deterministic_mode,
7620 solver_method,
7621 preconditioner: selected_preconditioner,
7622 quality_policy: contracts::format_quality_policy(options.quality_policy),
7623 fallback_events,
7624 },
7625 };
7626
7627 persist_fea_run_result_with_progress(
7628 ANALYSIS_RUN_FSI_OPERATION,
7629 ANALYSIS_RUN_FSI_OP_VERSION,
7630 "RM.FEA.RUN_FSI.ARTIFACT_STORE_FAILED",
7631 &context,
7632 &result,
7633 )?;
7634
7635 Ok(OperationEnvelope::new(
7636 ANALYSIS_RUN_FSI_OPERATION,
7637 ANALYSIS_RUN_FSI_OP_VERSION,
7638 &context,
7639 result,
7640 ))
7641}
7642
7643pub fn analysis_run_thermal_with_options_op(
7644 model: &AnalysisModel,
7645 backend: ComputeBackend,
7646 options: AnalysisThermalRunOptions,
7647 context: OperationContext,
7648) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7649 let _solver_context = install_fea_solver_context();
7650 let has_thermal_step = model
7651 .steps
7652 .iter()
7653 .any(|step| step.kind == AnalysisStepKind::Thermal);
7654 if !has_thermal_step {
7655 return Err(operation_error(
7656 ANALYSIS_RUN_THERMAL_OPERATION,
7657 ANALYSIS_RUN_THERMAL_OP_VERSION,
7658 &context,
7659 OperationErrorSpec {
7660 error_code: "RM.FEA.RUN_THERMAL.INVALID_MODEL",
7661 error_type: OperationErrorType::Validation,
7662 retryable: false,
7663 severity: OperationErrorSeverity::Error,
7664 },
7665 "FEA model must include at least one thermal step for fea.run_thermal",
7666 BTreeMap::from([
7667 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7668 ("geometry_id".to_string(), model.geometry_id.clone()),
7669 ]),
7670 ));
7671 }
7672 validate_thermal_run_model(model, &context)?;
7673
7674 let thermo_options = resolve_thermo_coupling_options(
7675 model,
7676 model_thermo_coupling_options(model),
7677 ANALYSIS_RUN_THERMAL_OPERATION,
7678 ANALYSIS_RUN_THERMAL_OP_VERSION,
7679 &context,
7680 )?;
7681 let Some(thermo_options) = thermo_options else {
7682 return Err(operation_error(
7683 ANALYSIS_RUN_THERMAL_OPERATION,
7684 ANALYSIS_RUN_THERMAL_OP_VERSION,
7685 &context,
7686 OperationErrorSpec {
7687 error_code: "RM.FEA.RUN_THERMAL.INVALID_OPTIONS",
7688 error_type: OperationErrorType::Input,
7689 retryable: false,
7690 severity: OperationErrorSeverity::Error,
7691 },
7692 "fea.run_thermal requires model.thermo_mechanical to be configured",
7693 BTreeMap::new(),
7694 ));
7695 };
7696 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, &thermo_options) {
7697 return Err(operation_error(
7698 ANALYSIS_RUN_THERMAL_OPERATION,
7699 ANALYSIS_RUN_THERMAL_OP_VERSION,
7700 &context,
7701 OperationErrorSpec {
7702 error_code: "RM.FEA.RUN_THERMAL.INVALID_OPTIONS",
7703 error_type: OperationErrorType::Input,
7704 retryable: false,
7705 severity: OperationErrorSeverity::Error,
7706 },
7707 detail,
7708 metadata,
7709 ));
7710 }
7711
7712 let prep_context = resolve_run_prep_context(
7713 model,
7714 options.prep_artifact_id.as_deref(),
7715 options.prep_context.clone(),
7716 ANALYSIS_RUN_THERMAL_OPERATION,
7717 ANALYSIS_RUN_THERMAL_OP_VERSION,
7718 &context,
7719 )?;
7720
7721 let thermal_run = run_thermal_with_options(
7722 model,
7723 backend,
7724 ThermalSolveOptions {
7725 step_count: options.step_count,
7726 time_step_s: options.time_step_s,
7727 residual_target: options.residual_warn_threshold,
7728 prep_context: to_fea_prep_context(
7729 prep_context.as_ref(),
7730 options.prep_calibration_profile,
7731 ),
7732 thermo_mechanical_context: to_fea_thermo_mechanical_context(Some(thermo_options)),
7733 },
7734 )
7735 .map_err(|err| {
7736 map_fea_run_error(
7737 ANALYSIS_RUN_THERMAL_OPERATION,
7738 ANALYSIS_RUN_THERMAL_OP_VERSION,
7739 "RM.FEA.RUN_THERMAL.SOLVER_MODEL_INVALID",
7740 "RM.FEA.RUN_THERMAL.CANCELLED",
7741 model,
7742 &context,
7743 err,
7744 )
7745 })?;
7746
7747 let mut run = thermal_run.run;
7748 let mut fallback_events = Vec::new();
7749 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
7750 let solver_convergence = if diagnostic_metric(
7751 &run.diagnostics,
7752 "FEA_THERMAL_STABILITY",
7753 "max_residual_norm",
7754 )
7755 .unwrap_or(0.0)
7756 <= options.residual_warn_threshold
7757 {
7758 QualityGate::Pass
7759 } else {
7760 QualityGate::Warn
7761 };
7762 let result_quality = if thermal_run.temperature_snapshots.is_empty() {
7763 QualityGate::Fail
7764 } else {
7765 solver_convergence
7766 };
7767 let mut quality_reasons = Vec::new();
7768 if result_quality == QualityGate::Warn {
7769 quality_reasons.push(QualityReason {
7770 code: QualityReasonCode::ThermalResidualExceeded,
7771 detail: format!(
7772 "thermal residual exceeds threshold {}",
7773 options.residual_warn_threshold
7774 ),
7775 });
7776 }
7777 let thermal_conductivity_spread_ratio = diagnostic_metric(
7778 &run.diagnostics,
7779 "FEA_THERMAL_CONSTITUTIVE",
7780 "conductivity_spread_ratio",
7781 );
7782 let thermal_heat_capacity_spread_ratio = diagnostic_metric(
7783 &run.diagnostics,
7784 "FEA_THERMAL_CONSTITUTIVE",
7785 "heat_capacity_spread_ratio",
7786 );
7787 if thermal_conductivity_spread_ratio.unwrap_or(1.0) > 2.5
7788 || thermal_heat_capacity_spread_ratio.unwrap_or(1.0) > 2.5
7789 {
7790 quality_reasons.push(QualityReason {
7791 code: QualityReasonCode::ThermalConstitutiveSpreadHigh,
7792 detail: format!(
7793 "thermal constitutive spread exceeds limit: conductivity_spread_ratio={} heat_capacity_spread_ratio={}",
7794 thermal_conductivity_spread_ratio.unwrap_or(1.0),
7795 thermal_heat_capacity_spread_ratio.unwrap_or(1.0)
7796 ),
7797 });
7798 }
7799
7800 let publishable = match options.quality_policy {
7801 QualityPolicy::Strict => {
7802 solver_convergence == QualityGate::Pass
7803 && result_quality == QualityGate::Pass
7804 && quality_reasons.is_empty()
7805 }
7806 QualityPolicy::Balanced => {
7807 result_quality != QualityGate::Fail
7808 && !quality_reasons
7809 .iter()
7810 .any(|reason| reason.code == QualityReasonCode::ThermalConstitutiveSpreadHigh)
7811 }
7812 QualityPolicy::Exploratory => true,
7813 };
7814 let run_status = if publishable {
7815 RunStatus::Publishable
7816 } else if result_quality == QualityGate::Fail {
7817 RunStatus::Rejected
7818 } else {
7819 RunStatus::Degraded
7820 };
7821
7822 let solver_backend = run.solver_backend.clone();
7823 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
7824 let solver_host_sync_count = run.solver_host_sync_count;
7825 let solver_method = run.solver_method.clone();
7826 let selected_preconditioner = run.preconditioner.clone();
7827
7828 let result = AnalysisRunResult {
7829 run_id: storage::next_run_id(),
7830 run,
7831 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
7832 modal_results: None,
7833 thermal_results: Some(ThermalResultsData {
7834 thermal_payload_version: "thermal_results/v1".to_string(),
7835 time_points_s: thermal_run.time_points_s,
7836 temperature_snapshots: thermal_run.temperature_snapshots,
7837 temperature_gradient_snapshots: thermal_run.temperature_gradient_snapshots,
7838 heat_flux_snapshots: thermal_run.heat_flux_snapshots,
7839 heat_source_snapshots: thermal_run.heat_source_snapshots,
7840 boundary_heat_flux_snapshots: thermal_run.boundary_heat_flux_snapshots,
7841 residual_norms: thermal_run.residual_norms,
7842 reference_temperature_k: thermal_run.reference_temperature_k,
7843 }),
7844 transient_results: None,
7845 nonlinear_results: None,
7846 electromagnetic_results: None,
7847 model_validity: QualityGate::Pass,
7848 solver_convergence,
7849 result_quality,
7850 run_status,
7851 publishable,
7852 quality_reasons,
7853 provenance: RunProvenance {
7854 backend,
7855 solver_backend,
7856 solver_device_apply_k_ratio,
7857 solver_host_sync_count,
7858 precision_mode: contracts::format_precision_mode(options.precision_mode),
7859 deterministic_mode: options.deterministic_mode,
7860 solver_method,
7861 preconditioner: selected_preconditioner,
7862 quality_policy: contracts::format_quality_policy(options.quality_policy),
7863 fallback_events,
7864 },
7865 };
7866
7867 persist_fea_run_result_with_progress(
7868 ANALYSIS_RUN_THERMAL_OPERATION,
7869 ANALYSIS_RUN_THERMAL_OP_VERSION,
7870 "RM.FEA.RUN_THERMAL.ARTIFACT_STORE_FAILED",
7871 &context,
7872 &result,
7873 )?;
7874
7875 Ok(OperationEnvelope::new(
7876 ANALYSIS_RUN_THERMAL_OPERATION,
7877 ANALYSIS_RUN_THERMAL_OP_VERSION,
7878 &context,
7879 result,
7880 ))
7881}
7882
7883fn validate_thermal_run_model(
7884 model: &AnalysisModel,
7885 context: &OperationContext,
7886) -> Result<(), OperationErrorEnvelope> {
7887 if let Some(material) = model.materials.iter().find(|material| {
7888 !material.thermal.conductivity_w_per_mk.is_finite()
7889 || material.thermal.conductivity_w_per_mk <= 0.0
7890 || !material.thermal.specific_heat_j_per_kgk.is_finite()
7891 || material.thermal.specific_heat_j_per_kgk <= 0.0
7892 }) {
7893 return Err(operation_error(
7894 ANALYSIS_RUN_THERMAL_OPERATION,
7895 ANALYSIS_RUN_THERMAL_OP_VERSION,
7896 context,
7897 OperationErrorSpec {
7898 error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_MATERIAL",
7899 error_type: OperationErrorType::Validation,
7900 retryable: false,
7901 severity: OperationErrorSeverity::Error,
7902 },
7903 "fea.run_thermal requires finite positive thermal conductivity and specific heat",
7904 BTreeMap::from([
7905 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7906 ("material_id".to_string(), material.material_id.clone()),
7907 (
7908 "conductivity_w_per_mk".to_string(),
7909 material.thermal.conductivity_w_per_mk.to_string(),
7910 ),
7911 (
7912 "specific_heat_j_per_kgk".to_string(),
7913 material.thermal.specific_heat_j_per_kgk.to_string(),
7914 ),
7915 ]),
7916 ));
7917 }
7918
7919 reject_moment_loads_for_run_family(
7920 model,
7921 ANALYSIS_RUN_THERMAL_OPERATION,
7922 ANALYSIS_RUN_THERMAL_OP_VERSION,
7923 "RM.FEA.RUN_THERMAL.INVALID_THERMAL_SOURCE",
7924 "thermal",
7925 context,
7926 )?;
7927
7928 if let Some(load) = model.loads.iter().find(|load| {
7929 matches!(
7930 &load.kind,
7931 LoadKind::HeatSource {
7932 volumetric_w_per_m3
7933 } if !volumetric_w_per_m3.is_finite()
7934 )
7935 }) {
7936 return Err(operation_error(
7937 ANALYSIS_RUN_THERMAL_OPERATION,
7938 ANALYSIS_RUN_THERMAL_OP_VERSION,
7939 context,
7940 OperationErrorSpec {
7941 error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_SOURCE",
7942 error_type: OperationErrorType::Validation,
7943 retryable: false,
7944 severity: OperationErrorSeverity::Error,
7945 },
7946 "fea.run_thermal requires finite thermal heat-source values",
7947 BTreeMap::from([
7948 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7949 ("load_id".to_string(), load.load_id.clone()),
7950 ]),
7951 ));
7952 }
7953
7954 if let Some(boundary) = model.boundary_conditions.iter().find(|bc| match &bc.kind {
7955 BoundaryConditionKind::ThermalPrescribedTemperature { temperature_k } => {
7956 !temperature_k.is_finite() || *temperature_k <= 0.0
7957 }
7958 BoundaryConditionKind::ThermalHeatFlux { heat_flux_w_per_m2 } => {
7959 !heat_flux_w_per_m2.is_finite()
7960 }
7961 BoundaryConditionKind::ThermalConvection {
7962 ambient_temperature_k,
7963 coefficient_w_per_m2k,
7964 } => {
7965 !ambient_temperature_k.is_finite()
7966 || *ambient_temperature_k <= 0.0
7967 || !coefficient_w_per_m2k.is_finite()
7968 || *coefficient_w_per_m2k < 0.0
7969 }
7970 _ => false,
7971 }) {
7972 return Err(operation_error(
7973 ANALYSIS_RUN_THERMAL_OPERATION,
7974 ANALYSIS_RUN_THERMAL_OP_VERSION,
7975 context,
7976 OperationErrorSpec {
7977 error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_BOUNDARY",
7978 error_type: OperationErrorType::Validation,
7979 retryable: false,
7980 severity: OperationErrorSeverity::Error,
7981 },
7982 "fea.run_thermal requires finite physically valid thermal boundary condition values",
7983 BTreeMap::from([
7984 ("analysis_model_id".to_string(), model.model_id.0.clone()),
7985 ("boundary_condition_id".to_string(), boundary.bc_id.clone()),
7986 ]),
7987 ));
7988 }
7989
7990 Ok(())
7991}
7992
7993pub fn analysis_run_transient_with_options_op(
7994 model: &AnalysisModel,
7995 backend: ComputeBackend,
7996 options: AnalysisTransientRunOptions,
7997 context: OperationContext,
7998) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7999 let _solver_context = install_fea_solver_context();
8000 let has_transient_step = model
8001 .steps
8002 .iter()
8003 .any(|step| step.kind == AnalysisStepKind::Transient);
8004 if !has_transient_step {
8005 return Err(operation_error(
8006 ANALYSIS_RUN_TRANSIENT_OPERATION,
8007 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8008 &context,
8009 OperationErrorSpec {
8010 error_code: "RM.FEA.RUN_TRANSIENT.INVALID_MODEL",
8011 error_type: OperationErrorType::Validation,
8012 retryable: false,
8013 severity: OperationErrorSeverity::Error,
8014 },
8015 "FEA model must include at least one transient step for fea.run_transient",
8016 BTreeMap::from([
8017 ("analysis_model_id".to_string(), model.model_id.0.clone()),
8018 ("geometry_id".to_string(), model.geometry_id.clone()),
8019 ]),
8020 ));
8021 }
8022
8023 let thermo_options = resolve_thermo_coupling_options(
8024 model,
8025 model_thermo_coupling_options(model),
8026 ANALYSIS_RUN_TRANSIENT_OPERATION,
8027 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8028 &context,
8029 )?;
8030 if let Some(thermo_options) = thermo_options.as_ref() {
8031 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
8032 return Err(operation_error(
8033 ANALYSIS_RUN_TRANSIENT_OPERATION,
8034 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8035 &context,
8036 OperationErrorSpec {
8037 error_code: "RM.FEA.RUN_TRANSIENT.INVALID_OPTIONS",
8038 error_type: OperationErrorType::Input,
8039 retryable: false,
8040 severity: OperationErrorSeverity::Error,
8041 },
8042 detail,
8043 metadata,
8044 ));
8045 }
8046 }
8047 let electro_options = model_electro_coupling_options(model);
8048 if let Some(electro_options) = electro_options.as_ref() {
8049 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
8050 return Err(operation_error(
8051 ANALYSIS_RUN_TRANSIENT_OPERATION,
8052 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8053 &context,
8054 OperationErrorSpec {
8055 error_code: electro_thermal_invalid_options_error_code(
8056 ANALYSIS_RUN_TRANSIENT_OPERATION,
8057 ),
8058 error_type: OperationErrorType::Input,
8059 retryable: false,
8060 severity: OperationErrorSeverity::Error,
8061 },
8062 detail,
8063 metadata,
8064 ));
8065 }
8066 }
8067
8068 let prep_context = resolve_run_prep_context(
8069 model,
8070 options.prep_artifact_id.as_deref(),
8071 options.prep_context.clone(),
8072 ANALYSIS_RUN_TRANSIENT_OPERATION,
8073 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8074 &context,
8075 )?;
8076 let transient_run = run_transient_with_options(
8077 model,
8078 backend,
8079 runmat_analysis_fea::solve::transient::TransientSolveOptions {
8080 time_step_s: options.time_step_s,
8081 min_time_step_s: options.min_time_step_s,
8082 max_time_step_s: options.max_time_step_s,
8083 step_count: options.step_count,
8084 max_linear_iters: options.max_linear_iters,
8085 tolerance: options.tolerance,
8086 residual_target: options.residual_target,
8087 adaptive_time_step: options.adaptive_time_step,
8088 max_step_retries: options.max_step_retries,
8089 adapt_min_scale: options.adapt_min_scale,
8090 adapt_max_scale: options.adapt_max_scale,
8091 adapt_growth_exponent: options.adapt_growth_exponent,
8092 adapt_retry_growth_cap: options.adapt_retry_growth_cap,
8093 adapt_nonconverged_shrink: options.adapt_nonconverged_shrink,
8094 dt_bucket_rel_tolerance: options.dt_bucket_rel_tolerance,
8095 progress_operation: ANALYSIS_RUN_TRANSIENT_OPERATION.to_string(),
8096 prep_context: to_fea_prep_context(
8097 prep_context.as_ref(),
8098 options.prep_calibration_profile,
8099 ),
8100 thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
8101 electro_thermal_context: to_fea_electro_thermal_context(electro_options),
8102 },
8103 )
8104 .map_err(|err| {
8105 map_fea_run_error(
8106 ANALYSIS_RUN_TRANSIENT_OPERATION,
8107 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8108 "RM.FEA.RUN_TRANSIENT.SOLVER_MODEL_INVALID",
8109 "RM.FEA.RUN_TRANSIENT.CANCELLED",
8110 model,
8111 &context,
8112 err,
8113 )
8114 })?;
8115
8116 let mut run = transient_run.run;
8117 let mut fallback_events = Vec::new();
8118 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
8119 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
8120 fallback_events.push(
8121 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
8122 );
8123 }
8124 let solver_convergence = if run.diagnostics.iter().any(|item| {
8125 item.code == "FEA_TRANSIENT_CONVERGENCE"
8126 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
8127 }) {
8128 QualityGate::Pass
8129 } else {
8130 QualityGate::Warn
8131 };
8132 let result_quality = if transient_run.displacement_snapshots.is_empty()
8133 || transient_run.time_points_s.is_empty()
8134 || transient_run
8135 .residual_norms
8136 .iter()
8137 .any(|residual| !residual.is_finite())
8138 {
8139 QualityGate::Fail
8140 } else if transient_run
8141 .residual_norms
8142 .iter()
8143 .copied()
8144 .fold(0.0_f64, f64::max)
8145 > TRANSIENT_RESIDUAL_WARN_THRESHOLD
8146 {
8147 QualityGate::Warn
8148 } else {
8149 QualityGate::Pass
8150 };
8151 let transient_stability_warn = run.diagnostics.iter().any(|item| {
8152 item.code == "FEA_TRANSIENT_STABILITY"
8153 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8154 }) || run.diagnostics.iter().any(|item| {
8155 item.code == "FEA_TRANSIENT_ENERGY"
8156 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8157 });
8158 let transient_step_failure_warn = run.diagnostics.iter().any(|item| {
8159 item.code == "FEA_TRANSIENT_STEP_FAILURE"
8160 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8161 });
8162 let thermo_transient_warn = run.diagnostics.iter().any(|item| {
8163 item.code == "FEA_TM_TRANSIENT"
8164 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8165 });
8166 let electro_transient_warn = run.diagnostics.iter().any(|item| {
8167 item.code == "FEA_ET_TRANSIENT"
8168 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8169 });
8170 let thermo_spatial_gradient_index = diagnostic_metric(
8171 &run.diagnostics,
8172 "FEA_TM_COUPLING",
8173 "spatial_gradient_index",
8174 );
8175 let thermo_spatial_coverage_ratio = diagnostic_metric(
8176 &run.diagnostics,
8177 "FEA_TM_COUPLING",
8178 "spatial_coverage_ratio",
8179 );
8180 let thermo_temporal_variation =
8181 diagnostic_metric(&run.diagnostics, "FEA_TM_TRANSIENT", "temporal_variation");
8182 let thermo_field_extrapolation_ratio = diagnostic_metric(
8183 &run.diagnostics,
8184 "FEA_TM_TRANSIENT",
8185 "field_extrapolation_ratio",
8186 );
8187 let (thermo_gradient_spatial_threshold, thermo_gradient_temporal_threshold) =
8188 thermo_gradient_thresholds_for_policy(options.quality_policy);
8189 let thermo_gradient_instability = thermo_spatial_gradient_index
8190 .map(|value| value > thermo_gradient_spatial_threshold)
8191 .unwrap_or(false)
8192 || thermo_temporal_variation
8193 .map(|value| value > thermo_gradient_temporal_threshold)
8194 .unwrap_or(false);
8195 let thermo_spread_ratio = diagnostic_metric(
8196 &run.diagnostics,
8197 "FEA_TM_COUPLING",
8198 "constitutive_material_spread_ratio",
8199 );
8200 let thermo_heterogeneity_index = diagnostic_metric(
8201 &run.diagnostics,
8202 "FEA_TM_COUPLING",
8203 "assignment_heterogeneity_index",
8204 );
8205 let (thermo_spread_threshold, thermo_heterogeneity_threshold) =
8206 thermo_thresholds_for_policy(options.quality_policy);
8207 let (thermo_field_coverage_min, thermo_field_extrapolation_max) =
8208 thermo_field_quality_thresholds_for_policy(options.quality_policy);
8209 let thermo_spread_breach = thermo_spread_ratio
8210 .map(|value| value > thermo_spread_threshold)
8211 .unwrap_or(false);
8212 let thermo_heterogeneity_breach = thermo_heterogeneity_index
8213 .map(|value| value > thermo_heterogeneity_threshold)
8214 .unwrap_or(false);
8215 let thermo_field_coverage_breach = thermo_spatial_coverage_ratio
8216 .map(|value| value < thermo_field_coverage_min)
8217 .unwrap_or(false);
8218 let thermo_field_extrapolation_breach = thermo_field_extrapolation_ratio
8219 .map(|value| value > thermo_field_extrapolation_max)
8220 .unwrap_or(false);
8221
8222 let mut quality_reasons = Vec::new();
8223 if solver_convergence == QualityGate::Warn {
8224 quality_reasons.push(QualityReason {
8225 code: QualityReasonCode::SolverNotConverged,
8226 detail: "transient solver convergence gate is warning".to_string(),
8227 });
8228 }
8229 if result_quality == QualityGate::Warn {
8230 quality_reasons.push(QualityReason {
8231 code: QualityReasonCode::TransientResidualExceeded,
8232 detail: format!(
8233 "transient residual exceeds threshold {}",
8234 TRANSIENT_RESIDUAL_WARN_THRESHOLD
8235 ),
8236 });
8237 }
8238 if transient_stability_warn {
8239 quality_reasons.push(QualityReason {
8240 code: QualityReasonCode::TransientStabilityExceeded,
8241 detail: "transient stability diagnostic exceeded threshold".to_string(),
8242 });
8243 }
8244 if transient_step_failure_warn {
8245 quality_reasons.push(QualityReason {
8246 code: QualityReasonCode::TransientStepFailure,
8247 detail: "transient step retry budget was exhausted".to_string(),
8248 });
8249 }
8250 if thermo_transient_warn {
8251 quality_reasons.push(QualityReason {
8252 code: QualityReasonCode::ThermoMechanicalTransientStress,
8253 detail: "thermo-mechanical transient coupling severity exceeded balanced threshold"
8254 .to_string(),
8255 });
8256 }
8257 if electro_transient_warn {
8258 quality_reasons.push(QualityReason {
8259 code: QualityReasonCode::ElectroThermalTransientStress,
8260 detail: "electro-thermal transient coupling severity exceeded balanced threshold"
8261 .to_string(),
8262 });
8263 }
8264 if thermo_spread_breach {
8265 quality_reasons.push(QualityReason {
8266 code: QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh,
8267 detail: format!(
8268 "thermo constitutive material spread ratio {} exceeds threshold {}",
8269 thermo_spread_ratio.unwrap_or(0.0),
8270 thermo_spread_threshold
8271 ),
8272 });
8273 }
8274 if thermo_heterogeneity_breach {
8275 quality_reasons.push(QualityReason {
8276 code: QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh,
8277 detail: format!(
8278 "thermo assignment heterogeneity index {} exceeds threshold {}",
8279 thermo_heterogeneity_index.unwrap_or(0.0),
8280 thermo_heterogeneity_threshold
8281 ),
8282 });
8283 }
8284 if thermo_gradient_instability {
8285 quality_reasons.push(QualityReason {
8286 code: QualityReasonCode::ThermoMechanicalGradientInstability,
8287 detail: format!(
8288 "thermo gradient instability spatial_gradient_index={} temporal_variation={} thresholds=({}, {})",
8289 thermo_spatial_gradient_index.unwrap_or(0.0),
8290 thermo_temporal_variation.unwrap_or(0.0),
8291 thermo_gradient_spatial_threshold,
8292 thermo_gradient_temporal_threshold,
8293 ),
8294 });
8295 }
8296 if thermo_field_coverage_breach {
8297 quality_reasons.push(QualityReason {
8298 code: QualityReasonCode::ThermoMechanicalFieldCoverageLow,
8299 detail: format!(
8300 "thermo field spatial coverage ratio {} is below minimum {}",
8301 thermo_spatial_coverage_ratio.unwrap_or(0.0),
8302 thermo_field_coverage_min
8303 ),
8304 });
8305 }
8306 if thermo_field_extrapolation_breach {
8307 quality_reasons.push(QualityReason {
8308 code: QualityReasonCode::ThermoMechanicalFieldExtrapolationHigh,
8309 detail: format!(
8310 "thermo field extrapolation ratio {} exceeds maximum {}",
8311 thermo_field_extrapolation_ratio.unwrap_or(0.0),
8312 thermo_field_extrapolation_max
8313 ),
8314 });
8315 }
8316 if fallback_events
8317 .iter()
8318 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
8319 {
8320 quality_reasons.push(QualityReason {
8321 code: QualityReasonCode::SolverBackendFallback,
8322 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
8323 });
8324 }
8325 if fallback_events.iter().any(|event| {
8326 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
8327 }) {
8328 quality_reasons.push(QualityReason {
8329 code: QualityReasonCode::FieldPromotionFallback,
8330 detail: "field promotion fell back to host-backed values".to_string(),
8331 });
8332 }
8333 let publishable = match options.quality_policy {
8334 QualityPolicy::Strict => {
8335 solver_convergence == QualityGate::Pass
8336 && result_quality == QualityGate::Pass
8337 && quality_reasons.is_empty()
8338 }
8339 QualityPolicy::Balanced => {
8340 solver_convergence == QualityGate::Pass
8341 && result_quality == QualityGate::Pass
8342 && !quality_reasons.iter().any(|r| {
8343 matches!(
8344 r.code,
8345 QualityReasonCode::TransientStabilityExceeded
8346 | QualityReasonCode::TransientStepFailure
8347 | QualityReasonCode::ThermoMechanicalTransientStress
8348 | QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh
8349 | QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh
8350 | QualityReasonCode::ThermoMechanicalGradientInstability
8351 )
8352 })
8353 }
8354 QualityPolicy::Exploratory => {
8355 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
8356 }
8357 };
8358 let run_status = if publishable {
8359 RunStatus::Publishable
8360 } else if result_quality == QualityGate::Fail {
8361 RunStatus::Rejected
8362 } else {
8363 RunStatus::Degraded
8364 };
8365
8366 let solver_backend = run.solver_backend.clone();
8367 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
8368 let solver_host_sync_count = run.solver_host_sync_count;
8369 let solver_method = run.solver_method.clone();
8370 let selected_preconditioner = run.preconditioner.clone();
8371
8372 let result = AnalysisRunResult {
8373 run_id: storage::next_run_id(),
8374 run,
8375 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
8376 modal_results: None,
8377 thermal_results: None,
8378 transient_results: Some(TransientResultsData {
8379 transient_payload_version: "transient_results/v1".to_string(),
8380 time_points_s: transient_run.time_points_s,
8381 displacement_snapshots: transient_run.displacement_snapshots,
8382 rotation_snapshots: transient_run.rotation_snapshots,
8383 velocity_snapshots: transient_run.velocity_snapshots,
8384 angular_velocity_snapshots: transient_run.angular_velocity_snapshots,
8385 acceleration_snapshots: transient_run.acceleration_snapshots,
8386 angular_acceleration_snapshots: transient_run.angular_acceleration_snapshots,
8387 von_mises_snapshots: transient_run.von_mises_snapshots,
8388 kinetic_energy_snapshots: transient_run.kinetic_energy_snapshots,
8389 strain_energy_snapshots: transient_run.strain_energy_snapshots,
8390 residual_norm_snapshots: transient_run.residual_norm_snapshots,
8391 thermo_mechanical_temperature_snapshots: transient_run
8392 .thermo_mechanical_temperature_snapshots,
8393 thermo_mechanical_thermal_strain_snapshots: transient_run
8394 .thermo_mechanical_thermal_strain_snapshots,
8395 thermo_mechanical_thermal_stress_snapshots: transient_run
8396 .thermo_mechanical_thermal_stress_snapshots,
8397 thermo_mechanical_displacement_snapshots: transient_run
8398 .thermo_mechanical_displacement_snapshots,
8399 thermo_mechanical_von_mises_snapshots: transient_run
8400 .thermo_mechanical_von_mises_snapshots,
8401 thermo_mechanical_coupling_residual_snapshots: transient_run
8402 .thermo_mechanical_coupling_residual_snapshots,
8403 electro_thermal_temperature_snapshots: transient_run
8404 .electro_thermal_temperature_snapshots,
8405 electro_thermal_thermal_residual_snapshots: transient_run
8406 .electro_thermal_thermal_residual_snapshots,
8407 residual_norms: transient_run.residual_norms,
8408 integration_method: TransientIntegrationMethod::ImplicitEuler,
8409 }),
8410 nonlinear_results: None,
8411 electromagnetic_results: None,
8412 model_validity: QualityGate::Pass,
8413 solver_convergence,
8414 result_quality,
8415 run_status,
8416 publishable,
8417 quality_reasons,
8418 provenance: RunProvenance {
8419 backend,
8420 solver_backend,
8421 solver_device_apply_k_ratio,
8422 solver_host_sync_count,
8423 precision_mode: contracts::format_precision_mode(options.precision_mode),
8424 deterministic_mode: options.deterministic_mode,
8425 solver_method,
8426 preconditioner: selected_preconditioner,
8427 quality_policy: contracts::format_quality_policy(options.quality_policy),
8428 fallback_events,
8429 },
8430 };
8431
8432 persist_fea_run_result_with_progress(
8433 ANALYSIS_RUN_TRANSIENT_OPERATION,
8434 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8435 "RM.FEA.RUN_TRANSIENT.ARTIFACT_STORE_FAILED",
8436 &context,
8437 &result,
8438 )?;
8439
8440 Ok(OperationEnvelope::new(
8441 ANALYSIS_RUN_TRANSIENT_OPERATION,
8442 ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8443 &context,
8444 result,
8445 ))
8446}
8447
8448pub fn analysis_run_nonlinear_op(
8449 model: &AnalysisModel,
8450 backend: ComputeBackend,
8451 context: OperationContext,
8452) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
8453 analysis_run_nonlinear_with_options_op(
8454 model,
8455 backend,
8456 AnalysisNonlinearRunOptions::default(),
8457 context,
8458 )
8459}
8460
8461pub fn analysis_run_nonlinear_with_options_op(
8462 model: &AnalysisModel,
8463 backend: ComputeBackend,
8464 options: AnalysisNonlinearRunOptions,
8465 context: OperationContext,
8466) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
8467 let _solver_context = install_fea_solver_context();
8468 let has_nonlinear_step = model
8469 .steps
8470 .iter()
8471 .any(|step| step.kind == AnalysisStepKind::Nonlinear);
8472 if !has_nonlinear_step {
8473 return Err(operation_error(
8474 ANALYSIS_RUN_NONLINEAR_OPERATION,
8475 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8476 &context,
8477 OperationErrorSpec {
8478 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_MODEL",
8479 error_type: OperationErrorType::Validation,
8480 retryable: false,
8481 severity: OperationErrorSeverity::Error,
8482 },
8483 "FEA model must include at least one nonlinear step for fea.run_nonlinear",
8484 BTreeMap::from([
8485 ("analysis_model_id".to_string(), model.model_id.0.clone()),
8486 ("geometry_id".to_string(), model.geometry_id.clone()),
8487 ]),
8488 ));
8489 }
8490
8491 if options.increment_count == 0 {
8492 return Err(operation_error(
8493 ANALYSIS_RUN_NONLINEAR_OPERATION,
8494 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8495 &context,
8496 OperationErrorSpec {
8497 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8498 error_type: OperationErrorType::Input,
8499 retryable: false,
8500 severity: OperationErrorSeverity::Error,
8501 },
8502 "fea.run_nonlinear options require increment_count greater than zero",
8503 BTreeMap::from([(
8504 "increment_count".to_string(),
8505 options.increment_count.to_string(),
8506 )]),
8507 ));
8508 }
8509 if options.max_newton_iters == 0 {
8510 return Err(operation_error(
8511 ANALYSIS_RUN_NONLINEAR_OPERATION,
8512 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8513 &context,
8514 OperationErrorSpec {
8515 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8516 error_type: OperationErrorType::Input,
8517 retryable: false,
8518 severity: OperationErrorSeverity::Error,
8519 },
8520 "fea.run_nonlinear options require max_newton_iters greater than zero",
8521 BTreeMap::from([(
8522 "max_newton_iters".to_string(),
8523 options.max_newton_iters.to_string(),
8524 )]),
8525 ));
8526 }
8527 if options.tolerance <= 0.0 || !options.tolerance.is_finite() {
8528 return Err(operation_error(
8529 ANALYSIS_RUN_NONLINEAR_OPERATION,
8530 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8531 &context,
8532 OperationErrorSpec {
8533 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8534 error_type: OperationErrorType::Input,
8535 retryable: false,
8536 severity: OperationErrorSeverity::Error,
8537 },
8538 "fea.run_nonlinear options require finite positive tolerance",
8539 BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
8540 ));
8541 }
8542 if options.increment_norm_tolerance <= 0.0 || !options.increment_norm_tolerance.is_finite() {
8543 return Err(operation_error(
8544 ANALYSIS_RUN_NONLINEAR_OPERATION,
8545 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8546 &context,
8547 OperationErrorSpec {
8548 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8549 error_type: OperationErrorType::Input,
8550 retryable: false,
8551 severity: OperationErrorSeverity::Error,
8552 },
8553 "fea.run_nonlinear options require finite positive increment_norm_tolerance",
8554 BTreeMap::from([(
8555 "increment_norm_tolerance".to_string(),
8556 options.increment_norm_tolerance.to_string(),
8557 )]),
8558 ));
8559 }
8560 if options.residual_convergence_factor < 1.0 || !options.residual_convergence_factor.is_finite()
8561 {
8562 return Err(operation_error(
8563 ANALYSIS_RUN_NONLINEAR_OPERATION,
8564 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8565 &context,
8566 OperationErrorSpec {
8567 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8568 error_type: OperationErrorType::Input,
8569 retryable: false,
8570 severity: OperationErrorSeverity::Error,
8571 },
8572 "fea.run_nonlinear options require residual_convergence_factor >= 1.0",
8573 BTreeMap::from([(
8574 "residual_convergence_factor".to_string(),
8575 options.residual_convergence_factor.to_string(),
8576 )]),
8577 ));
8578 }
8579 if options.line_search_reduction <= 0.0
8580 || options.line_search_reduction >= 1.0
8581 || !options.line_search_reduction.is_finite()
8582 {
8583 return Err(operation_error(
8584 ANALYSIS_RUN_NONLINEAR_OPERATION,
8585 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8586 &context,
8587 OperationErrorSpec {
8588 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8589 error_type: OperationErrorType::Input,
8590 retryable: false,
8591 severity: OperationErrorSeverity::Error,
8592 },
8593 "fea.run_nonlinear options require line_search_reduction in (0, 1)",
8594 BTreeMap::from([(
8595 "line_search_reduction".to_string(),
8596 options.line_search_reduction.to_string(),
8597 )]),
8598 ));
8599 }
8600 if options.tangent_refresh_interval == 0 {
8601 return Err(operation_error(
8602 ANALYSIS_RUN_NONLINEAR_OPERATION,
8603 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8604 &context,
8605 OperationErrorSpec {
8606 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8607 error_type: OperationErrorType::Input,
8608 retryable: false,
8609 severity: OperationErrorSeverity::Error,
8610 },
8611 "fea.run_nonlinear options require tangent_refresh_interval greater than zero",
8612 BTreeMap::from([(
8613 "tangent_refresh_interval".to_string(),
8614 options.tangent_refresh_interval.to_string(),
8615 )]),
8616 ));
8617 }
8618
8619 let thermo_options = resolve_thermo_coupling_options(
8620 model,
8621 model_thermo_coupling_options(model),
8622 ANALYSIS_RUN_NONLINEAR_OPERATION,
8623 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8624 &context,
8625 )?;
8626 if let Some(thermo_options) = thermo_options.as_ref() {
8627 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
8628 return Err(operation_error(
8629 ANALYSIS_RUN_NONLINEAR_OPERATION,
8630 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8631 &context,
8632 OperationErrorSpec {
8633 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8634 error_type: OperationErrorType::Input,
8635 retryable: false,
8636 severity: OperationErrorSeverity::Error,
8637 },
8638 detail,
8639 metadata,
8640 ));
8641 }
8642 }
8643 let electro_options = model_electro_coupling_options(model);
8644 if let Some(electro_options) = electro_options.as_ref() {
8645 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
8646 return Err(operation_error(
8647 ANALYSIS_RUN_NONLINEAR_OPERATION,
8648 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8649 &context,
8650 OperationErrorSpec {
8651 error_code: electro_thermal_invalid_options_error_code(
8652 ANALYSIS_RUN_NONLINEAR_OPERATION,
8653 ),
8654 error_type: OperationErrorType::Input,
8655 retryable: false,
8656 severity: OperationErrorSeverity::Error,
8657 },
8658 detail,
8659 metadata,
8660 ));
8661 }
8662 }
8663 let plasticity_options = model_plasticity_constitutive_options(model);
8664 if let Some(plasticity_options) = plasticity_options.as_ref() {
8665 if let Err((detail, metadata)) =
8666 validate_plasticity_constitutive_options(plasticity_options)
8667 {
8668 return Err(operation_error(
8669 ANALYSIS_RUN_NONLINEAR_OPERATION,
8670 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8671 &context,
8672 OperationErrorSpec {
8673 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8674 error_type: OperationErrorType::Input,
8675 retryable: false,
8676 severity: OperationErrorSeverity::Error,
8677 },
8678 detail,
8679 metadata,
8680 ));
8681 }
8682 }
8683 let contact_options = model_contact_interface_options(model);
8684 if let Some(contact_options) = contact_options.as_ref() {
8685 if let Err((detail, metadata)) = validate_contact_interface_options(contact_options) {
8686 return Err(operation_error(
8687 ANALYSIS_RUN_NONLINEAR_OPERATION,
8688 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8689 &context,
8690 OperationErrorSpec {
8691 error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8692 error_type: OperationErrorType::Input,
8693 retryable: false,
8694 severity: OperationErrorSeverity::Error,
8695 },
8696 detail,
8697 metadata,
8698 ));
8699 }
8700 }
8701
8702 let prep_context = resolve_run_prep_context(
8703 model,
8704 options.prep_artifact_id.as_deref(),
8705 options.prep_context.clone(),
8706 ANALYSIS_RUN_NONLINEAR_OPERATION,
8707 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8708 &context,
8709 )?;
8710 let nonlinear_run = run_nonlinear_with_options(
8711 model,
8712 backend,
8713 runmat_analysis_fea::solve::nonlinear::NonlinearSolveOptions {
8714 increment_count: options.increment_count,
8715 max_newton_iters: options.max_newton_iters,
8716 tolerance: options.tolerance,
8717 residual_convergence_factor: options.residual_convergence_factor,
8718 increment_norm_tolerance: options.increment_norm_tolerance,
8719 line_search: options.line_search,
8720 max_line_search_backtracks: options.max_line_search_backtracks,
8721 line_search_reduction: options.line_search_reduction,
8722 tangent_refresh_interval: options.tangent_refresh_interval,
8723 prep_context: to_fea_prep_context(
8724 prep_context.as_ref(),
8725 options.prep_calibration_profile,
8726 ),
8727 thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
8728 electro_thermal_context: to_fea_electro_thermal_context(electro_options),
8729 plasticity_context: to_fea_plasticity_constitutive_context(plasticity_options),
8730 contact_context: to_fea_contact_interface_context(contact_options),
8731 },
8732 )
8733 .map_err(|err| {
8734 map_fea_run_error(
8735 ANALYSIS_RUN_NONLINEAR_OPERATION,
8736 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8737 "RM.FEA.RUN_NONLINEAR.SOLVER_MODEL_INVALID",
8738 "RM.FEA.RUN_NONLINEAR.CANCELLED",
8739 model,
8740 &context,
8741 err,
8742 )
8743 })?;
8744
8745 let mut run = nonlinear_run.run;
8746 let mut fallback_events = Vec::new();
8747 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
8748 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
8749 fallback_events.push(
8750 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
8751 );
8752 }
8753
8754 let solver_convergence = if run.diagnostics.iter().any(|item| {
8755 item.code == "FEA_NONLINEAR_CONVERGENCE"
8756 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
8757 }) {
8758 QualityGate::Pass
8759 } else {
8760 QualityGate::Warn
8761 };
8762 let max_nonlinear_residual = nonlinear_run
8763 .residual_norms
8764 .iter()
8765 .copied()
8766 .reduce(f64::max)
8767 .unwrap_or(0.0);
8768 let max_nonlinear_increment_norm = nonlinear_run
8769 .increment_norms
8770 .iter()
8771 .copied()
8772 .reduce(f64::max)
8773 .unwrap_or(0.0);
8774 let result_quality = if nonlinear_run.load_factors.is_empty()
8775 || nonlinear_run.displacement_snapshots.is_empty()
8776 || nonlinear_run.residual_norms.iter().any(|r| !r.is_finite())
8777 || nonlinear_run.increment_norms.iter().any(|v| !v.is_finite())
8778 {
8779 QualityGate::Fail
8780 } else if max_nonlinear_residual > options.tolerance * options.residual_convergence_factor * 2.0
8781 || max_nonlinear_increment_norm > options.increment_norm_tolerance * 4.0
8782 {
8783 QualityGate::Warn
8784 } else {
8785 QualityGate::Pass
8786 };
8787 let nonlinear_increment_warn = run.diagnostics.iter().any(|item| {
8788 item.code == "FEA_NONLINEAR_CONVERGENCE"
8789 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8790 });
8791 let max_nonlinear_iteration_count = nonlinear_run
8792 .iteration_counts
8793 .iter()
8794 .copied()
8795 .max()
8796 .unwrap_or(0);
8797 let iteration_cap_hits = nonlinear_run
8798 .iteration_counts
8799 .iter()
8800 .filter(|&&count| count >= options.max_newton_iters.max(1))
8801 .count();
8802 let strict_increment_failure = nonlinear_run.failed_increments > 0;
8803 let strict_iteration_cap_exhausted = iteration_cap_hits > 0;
8804 let thermo_nonlinear_warn = run.diagnostics.iter().any(|item| {
8805 item.code == "FEA_TM_NONLINEAR"
8806 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8807 });
8808 let electro_nonlinear_warn = run.diagnostics.iter().any(|item| {
8809 item.code == "FEA_ET_NONLINEAR"
8810 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8811 });
8812 let plastic_nonlinear_warn = run.diagnostics.iter().any(|item| {
8813 item.code == "FEA_PLASTIC_NONLINEAR"
8814 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8815 });
8816 let contact_nonlinear_warn = run.diagnostics.iter().any(|item| {
8817 item.code == "FEA_CONTACT_NONLINEAR"
8818 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8819 });
8820 let thermo_spatial_gradient_index = diagnostic_metric(
8821 &run.diagnostics,
8822 "FEA_TM_COUPLING",
8823 "spatial_gradient_index",
8824 );
8825 let thermo_spatial_coverage_ratio = diagnostic_metric(
8826 &run.diagnostics,
8827 "FEA_TM_COUPLING",
8828 "spatial_coverage_ratio",
8829 );
8830 let thermo_temporal_variation =
8831 diagnostic_metric(&run.diagnostics, "FEA_TM_NONLINEAR", "temporal_variation");
8832 let thermo_field_extrapolation_ratio = diagnostic_metric(
8833 &run.diagnostics,
8834 "FEA_TM_NONLINEAR",
8835 "field_extrapolation_ratio",
8836 );
8837 let (thermo_gradient_spatial_threshold, thermo_gradient_temporal_threshold) =
8838 thermo_gradient_thresholds_for_policy(options.quality_policy);
8839 let thermo_gradient_instability = thermo_spatial_gradient_index
8840 .map(|value| value > thermo_gradient_spatial_threshold)
8841 .unwrap_or(false)
8842 || thermo_temporal_variation
8843 .map(|value| value > thermo_gradient_temporal_threshold)
8844 .unwrap_or(false);
8845 let thermo_spread_ratio = diagnostic_metric(
8846 &run.diagnostics,
8847 "FEA_TM_COUPLING",
8848 "constitutive_material_spread_ratio",
8849 );
8850 let thermo_heterogeneity_index = diagnostic_metric(
8851 &run.diagnostics,
8852 "FEA_TM_COUPLING",
8853 "assignment_heterogeneity_index",
8854 );
8855 let (thermo_spread_threshold, thermo_heterogeneity_threshold) =
8856 thermo_thresholds_for_policy(options.quality_policy);
8857 let (thermo_field_coverage_min, thermo_field_extrapolation_max) =
8858 thermo_field_quality_thresholds_for_policy(options.quality_policy);
8859 let thermo_spread_breach = thermo_spread_ratio
8860 .map(|value| value > thermo_spread_threshold)
8861 .unwrap_or(false);
8862 let thermo_heterogeneity_breach = thermo_heterogeneity_index
8863 .map(|value| value > thermo_heterogeneity_threshold)
8864 .unwrap_or(false);
8865 let thermo_field_coverage_breach = thermo_spatial_coverage_ratio
8866 .map(|value| value < thermo_field_coverage_min)
8867 .unwrap_or(false);
8868 let thermo_field_extrapolation_breach = thermo_field_extrapolation_ratio
8869 .map(|value| value > thermo_field_extrapolation_max)
8870 .unwrap_or(false);
8871
8872 let mut quality_reasons = Vec::new();
8873 if solver_convergence == QualityGate::Warn {
8874 quality_reasons.push(QualityReason {
8875 code: QualityReasonCode::SolverNotConverged,
8876 detail: "nonlinear solver convergence gate is warning".to_string(),
8877 });
8878 }
8879 if result_quality == QualityGate::Warn {
8880 quality_reasons.push(QualityReason {
8881 code: QualityReasonCode::NonlinearResidualExceeded,
8882 detail: format!(
8883 "nonlinear residual/increment norm exceeds thresholds residual={} increment_norm={}",
8884 options.tolerance * options.residual_convergence_factor * 2.0,
8885 options.increment_norm_tolerance * 4.0
8886 ),
8887 });
8888 }
8889 if nonlinear_increment_warn || strict_increment_failure || strict_iteration_cap_exhausted {
8890 quality_reasons.push(QualityReason {
8891 code: QualityReasonCode::NonlinearIncrementFailure,
8892 detail: format!(
8893 "nonlinear increment convergence warnings failed_increments={} iteration_cap_hits={} max_iteration_count={}",
8894 nonlinear_run.failed_increments,
8895 iteration_cap_hits,
8896 max_nonlinear_iteration_count
8897 ),
8898 });
8899 }
8900 if thermo_nonlinear_warn {
8901 quality_reasons.push(QualityReason {
8902 code: QualityReasonCode::ThermoMechanicalNonlinearStress,
8903 detail: "thermo-mechanical nonlinear coupling severity exceeded balanced threshold"
8904 .to_string(),
8905 });
8906 }
8907 if electro_nonlinear_warn {
8908 quality_reasons.push(QualityReason {
8909 code: QualityReasonCode::ElectroThermalNonlinearStress,
8910 detail: "electro-thermal nonlinear coupling severity exceeded balanced threshold"
8911 .to_string(),
8912 });
8913 }
8914 if plastic_nonlinear_warn {
8915 quality_reasons.push(QualityReason {
8916 code: QualityReasonCode::PlasticityNonlinearStress,
8917 detail: "plasticity nonlinear severity exceeded balanced threshold".to_string(),
8918 });
8919 }
8920 if contact_nonlinear_warn {
8921 quality_reasons.push(QualityReason {
8922 code: QualityReasonCode::ContactNonlinearStress,
8923 detail: "contact nonlinear severity exceeded balanced threshold".to_string(),
8924 });
8925 }
8926 if thermo_spread_breach {
8927 quality_reasons.push(QualityReason {
8928 code: QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh,
8929 detail: format!(
8930 "thermo constitutive material spread ratio {} exceeds threshold {}",
8931 thermo_spread_ratio.unwrap_or(0.0),
8932 thermo_spread_threshold
8933 ),
8934 });
8935 }
8936 if thermo_heterogeneity_breach {
8937 quality_reasons.push(QualityReason {
8938 code: QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh,
8939 detail: format!(
8940 "thermo assignment heterogeneity index {} exceeds threshold {}",
8941 thermo_heterogeneity_index.unwrap_or(0.0),
8942 thermo_heterogeneity_threshold
8943 ),
8944 });
8945 }
8946 if thermo_gradient_instability {
8947 quality_reasons.push(QualityReason {
8948 code: QualityReasonCode::ThermoMechanicalGradientInstability,
8949 detail: format!(
8950 "thermo gradient instability spatial_gradient_index={} temporal_variation={} thresholds=({}, {})",
8951 thermo_spatial_gradient_index.unwrap_or(0.0),
8952 thermo_temporal_variation.unwrap_or(0.0),
8953 thermo_gradient_spatial_threshold,
8954 thermo_gradient_temporal_threshold,
8955 ),
8956 });
8957 }
8958 if thermo_field_coverage_breach {
8959 quality_reasons.push(QualityReason {
8960 code: QualityReasonCode::ThermoMechanicalFieldCoverageLow,
8961 detail: format!(
8962 "thermo field spatial coverage ratio {} is below minimum {}",
8963 thermo_spatial_coverage_ratio.unwrap_or(0.0),
8964 thermo_field_coverage_min
8965 ),
8966 });
8967 }
8968 if thermo_field_extrapolation_breach {
8969 quality_reasons.push(QualityReason {
8970 code: QualityReasonCode::ThermoMechanicalFieldExtrapolationHigh,
8971 detail: format!(
8972 "thermo field extrapolation ratio {} exceeds maximum {}",
8973 thermo_field_extrapolation_ratio.unwrap_or(0.0),
8974 thermo_field_extrapolation_max
8975 ),
8976 });
8977 }
8978 if fallback_events
8979 .iter()
8980 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
8981 {
8982 quality_reasons.push(QualityReason {
8983 code: QualityReasonCode::SolverBackendFallback,
8984 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
8985 });
8986 }
8987 if fallback_events.iter().any(|event| {
8988 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
8989 }) {
8990 quality_reasons.push(QualityReason {
8991 code: QualityReasonCode::FieldPromotionFallback,
8992 detail: "field promotion fell back to host-backed values".to_string(),
8993 });
8994 }
8995
8996 let publishable = match options.quality_policy {
8997 QualityPolicy::Strict => {
8998 solver_convergence == QualityGate::Pass
8999 && result_quality == QualityGate::Pass
9000 && !strict_increment_failure
9001 && !strict_iteration_cap_exhausted
9002 && quality_reasons.is_empty()
9003 }
9004 QualityPolicy::Balanced => {
9005 solver_convergence == QualityGate::Pass
9006 && result_quality == QualityGate::Pass
9007 && !quality_reasons.iter().any(|r| {
9008 matches!(
9009 r.code,
9010 QualityReasonCode::NonlinearResidualExceeded
9011 | QualityReasonCode::NonlinearIncrementFailure
9012 | QualityReasonCode::ThermoMechanicalNonlinearStress
9013 | QualityReasonCode::PlasticityNonlinearStress
9014 | QualityReasonCode::ContactNonlinearStress
9015 | QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh
9016 | QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh
9017 | QualityReasonCode::ThermoMechanicalGradientInstability
9018 )
9019 })
9020 }
9021 QualityPolicy::Exploratory => {
9022 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
9023 }
9024 };
9025 let run_status = if publishable {
9026 RunStatus::Publishable
9027 } else if result_quality == QualityGate::Fail {
9028 RunStatus::Rejected
9029 } else {
9030 RunStatus::Degraded
9031 };
9032
9033 let solver_backend = run.solver_backend.clone();
9034 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
9035 let solver_host_sync_count = run.solver_host_sync_count;
9036 let solver_method = run.solver_method.clone();
9037 let selected_preconditioner = run.preconditioner.clone();
9038
9039 let result = AnalysisRunResult {
9040 run_id: storage::next_run_id(),
9041 run,
9042 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
9043 modal_results: None,
9044 thermal_results: None,
9045 transient_results: None,
9046 nonlinear_results: Some(NonlinearResultsData {
9047 nonlinear_payload_version: "nonlinear_results/v1".to_string(),
9048 load_factors: nonlinear_run.load_factors,
9049 displacement_snapshots: nonlinear_run.displacement_snapshots,
9050 rotation_snapshots: nonlinear_run.rotation_snapshots,
9051 von_mises_snapshots: nonlinear_run.von_mises_snapshots,
9052 plastic_strain_snapshots: nonlinear_run.plastic_strain_snapshots,
9053 equivalent_plastic_strain_snapshots: nonlinear_run.equivalent_plastic_strain_snapshots,
9054 contact_pressure_snapshots: nonlinear_run.contact_pressure_snapshots,
9055 contact_gap_snapshots: nonlinear_run.contact_gap_snapshots,
9056 load_factor_snapshots: nonlinear_run.load_factor_snapshots,
9057 residual_norm_snapshots: nonlinear_run.residual_norm_snapshots,
9058 thermo_mechanical_temperature_snapshots: nonlinear_run
9059 .thermo_mechanical_temperature_snapshots,
9060 thermo_mechanical_thermal_strain_snapshots: nonlinear_run
9061 .thermo_mechanical_thermal_strain_snapshots,
9062 thermo_mechanical_thermal_stress_snapshots: nonlinear_run
9063 .thermo_mechanical_thermal_stress_snapshots,
9064 thermo_mechanical_displacement_snapshots: nonlinear_run
9065 .thermo_mechanical_displacement_snapshots,
9066 thermo_mechanical_von_mises_snapshots: nonlinear_run
9067 .thermo_mechanical_von_mises_snapshots,
9068 thermo_mechanical_coupling_residual_snapshots: nonlinear_run
9069 .thermo_mechanical_coupling_residual_snapshots,
9070 electro_thermal_temperature_snapshots: nonlinear_run
9071 .electro_thermal_temperature_snapshots,
9072 electro_thermal_thermal_residual_snapshots: nonlinear_run
9073 .electro_thermal_thermal_residual_snapshots,
9074 residual_norms: nonlinear_run.residual_norms,
9075 increment_norms: nonlinear_run.increment_norms,
9076 iteration_counts: nonlinear_run.iteration_counts,
9077 failed_increments: nonlinear_run.failed_increments,
9078 line_search_backtracks: nonlinear_run.line_search_backtracks,
9079 max_line_search_backtracks_per_increment: nonlinear_run
9080 .max_line_search_backtracks_per_increment,
9081 tangent_rebuild_count: nonlinear_run.tangent_rebuild_count,
9082 iteration_spike_count: nonlinear_run.iteration_spike_count,
9083 convergence_stall_count: nonlinear_run.convergence_stall_count,
9084 backtrack_burst_count: nonlinear_run.backtrack_burst_count,
9085 method: NonlinearMethod::IncrementalNewtonRaphson,
9086 }),
9087 electromagnetic_results: None,
9088 model_validity: QualityGate::Pass,
9089 solver_convergence,
9090 result_quality,
9091 run_status,
9092 publishable,
9093 quality_reasons,
9094 provenance: RunProvenance {
9095 backend,
9096 solver_backend,
9097 solver_device_apply_k_ratio,
9098 solver_host_sync_count,
9099 precision_mode: contracts::format_precision_mode(options.precision_mode),
9100 deterministic_mode: options.deterministic_mode,
9101 solver_method,
9102 preconditioner: selected_preconditioner,
9103 quality_policy: contracts::format_quality_policy(options.quality_policy),
9104 fallback_events,
9105 },
9106 };
9107
9108 persist_fea_run_result_with_progress(
9109 ANALYSIS_RUN_NONLINEAR_OPERATION,
9110 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
9111 "RM.FEA.RUN_NONLINEAR.ARTIFACT_STORE_FAILED",
9112 &context,
9113 &result,
9114 )?;
9115
9116 Ok(OperationEnvelope::new(
9117 ANALYSIS_RUN_NONLINEAR_OPERATION,
9118 ANALYSIS_RUN_NONLINEAR_OP_VERSION,
9119 &context,
9120 result,
9121 ))
9122}
9123
9124pub fn analysis_run_linear_static_with_options(
9125 model: &AnalysisModel,
9126 backend: ComputeBackend,
9127 options: AnalysisRunOptions,
9128 context: OperationContext,
9129) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
9130 let _solver_context = install_fea_solver_context();
9131 let thermo_options = resolve_thermo_coupling_options(
9132 model,
9133 model_thermo_coupling_options(model),
9134 ANALYSIS_RUN_OPERATION,
9135 ANALYSIS_RUN_OP_VERSION,
9136 &context,
9137 )?;
9138 if let Some(thermo_options) = thermo_options.as_ref() {
9139 if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
9140 return Err(operation_error(
9141 ANALYSIS_RUN_OPERATION,
9142 ANALYSIS_RUN_OP_VERSION,
9143 &context,
9144 OperationErrorSpec {
9145 error_code: "RM.FEA.RUN_LINEAR_STATIC.INVALID_OPTIONS",
9146 error_type: OperationErrorType::Input,
9147 retryable: false,
9148 severity: OperationErrorSeverity::Error,
9149 },
9150 detail,
9151 metadata,
9152 ));
9153 }
9154 }
9155 let electro_options = model_electro_coupling_options(model);
9156 if let Some(electro_options) = electro_options.as_ref() {
9157 if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
9158 return Err(operation_error(
9159 ANALYSIS_RUN_OPERATION,
9160 ANALYSIS_RUN_OP_VERSION,
9161 &context,
9162 OperationErrorSpec {
9163 error_code: electro_thermal_invalid_options_error_code(ANALYSIS_RUN_OPERATION),
9164 error_type: OperationErrorType::Input,
9165 retryable: false,
9166 severity: OperationErrorSeverity::Error,
9167 },
9168 detail,
9169 metadata,
9170 ));
9171 }
9172 }
9173
9174 let requested_preconditioner = match options.preconditioner_mode {
9175 PreconditionerMode::Auto | PreconditionerMode::Jacobi => SpdPreconditionerKind::Jacobi,
9176 PreconditionerMode::Ilu => SpdPreconditionerKind::Ilu0,
9177 PreconditionerMode::Amg => SpdPreconditionerKind::Jacobi,
9178 };
9179 let runtime_tensor_available = runmat_accelerate_api::provider().is_some();
9180 let requested_solver_backend = match backend {
9181 ComputeBackend::Cpu => LinearAlgebraBackendKind::CpuReference,
9182 ComputeBackend::Gpu => {
9183 if runtime_tensor_available {
9184 LinearAlgebraBackendKind::RuntimeTensor
9185 } else {
9186 LinearAlgebraBackendKind::CpuReference
9187 }
9188 }
9189 };
9190 let prep_context = resolve_run_prep_context(
9191 model,
9192 options.prep_artifact_id.as_deref(),
9193 options.prep_context.clone(),
9194 ANALYSIS_RUN_OPERATION,
9195 ANALYSIS_RUN_OP_VERSION,
9196 &context,
9197 )?;
9198 let run = run_linear_static_with_options(
9199 model,
9200 backend,
9201 LinearStaticSolveOptions {
9202 preconditioner_kind: requested_preconditioner,
9203 algebra_backend_kind: requested_solver_backend,
9204 prep_context: to_fea_prep_context(
9205 prep_context.as_ref(),
9206 options.prep_calibration_profile,
9207 ),
9208 thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
9209 electro_thermal_context: to_fea_electro_thermal_context(electro_options),
9210 },
9211 )
9212 .map_err(|err| {
9213 map_fea_run_error(
9214 ANALYSIS_RUN_OPERATION,
9215 ANALYSIS_RUN_OP_VERSION,
9216 "RM.FEA.RUN_LINEAR_STATIC.SOLVER_MODEL_INVALID",
9217 "RM.FEA.RUN_LINEAR_STATIC.CANCELLED",
9218 model,
9219 &context,
9220 err,
9221 )
9222 })?;
9223
9224 let mut run = run;
9225 let mut fallback_events = Vec::new();
9226 promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
9227
9228 match options.preconditioner_mode {
9229 PreconditionerMode::Auto | PreconditionerMode::Jacobi | PreconditionerMode::Ilu => {}
9230 PreconditionerMode::Amg => {
9231 fallback_events
9232 .push("SOLVER_PRECONDITIONER_FALLBACK:requested=amg:using=jacobi".to_string());
9233 }
9234 }
9235
9236 if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
9237 fallback_events.push(
9238 "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
9239 );
9240 }
9241
9242 let solver_convergence = if run.diagnostics.iter().any(|item| {
9243 item.code == "FEA_CONVERGENCE"
9244 && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
9245 }) {
9246 QualityGate::Pass
9247 } else {
9248 QualityGate::Warn
9249 };
9250
9251 let has_material_assignment_conflict = run.diagnostics.iter().any(|diag| {
9252 diag.code
9253 .starts_with("ANALYSIS_MATERIAL_ASSIGNMENT_CONFLICT_")
9254 });
9255 let result_quality = if run.fields_are_empty() {
9256 QualityGate::Fail
9257 } else if has_material_assignment_conflict {
9258 QualityGate::Warn
9259 } else {
9260 QualityGate::Pass
9261 };
9262
9263 let mut quality_reasons = Vec::new();
9264 if has_material_assignment_conflict {
9265 quality_reasons.push(QualityReason {
9266 code: QualityReasonCode::MaterialAssignmentConflict,
9267 detail: "material assignment confidence conflict detected".to_string(),
9268 });
9269 }
9270 if solver_convergence == QualityGate::Warn {
9271 quality_reasons.push(QualityReason {
9272 code: QualityReasonCode::SolverNotConverged,
9273 detail: "solver convergence gate is warning".to_string(),
9274 });
9275 }
9276 if fallback_events
9277 .iter()
9278 .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
9279 {
9280 quality_reasons.push(QualityReason {
9281 code: QualityReasonCode::SolverBackendFallback,
9282 detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
9283 });
9284 }
9285 if fallback_events.iter().any(|event| {
9286 event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
9287 }) {
9288 quality_reasons.push(QualityReason {
9289 code: QualityReasonCode::FieldPromotionFallback,
9290 detail: "field promotion fell back to host-backed values".to_string(),
9291 });
9292 }
9293 let publishable = match options.quality_policy {
9294 QualityPolicy::Strict => {
9295 solver_convergence == QualityGate::Pass
9296 && result_quality == QualityGate::Pass
9297 && quality_reasons.is_empty()
9298 }
9299 QualityPolicy::Balanced => {
9300 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
9301 }
9302 QualityPolicy::Exploratory => {
9303 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
9304 }
9305 };
9306 let run_status = if publishable {
9307 RunStatus::Publishable
9308 } else if result_quality == QualityGate::Fail {
9309 RunStatus::Rejected
9310 } else {
9311 RunStatus::Degraded
9312 };
9313 let solver_backend = run.solver_backend.clone();
9314 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
9315 let solver_host_sync_count = run.solver_host_sync_count;
9316 let solver_method = run.solver_method.clone();
9317 let preconditioner = run.preconditioner.clone();
9318
9319 let result = AnalysisRunResult {
9320 run_id: storage::next_run_id(),
9321 run,
9322 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
9323 modal_results: None,
9324 thermal_results: None,
9325 transient_results: None,
9326 nonlinear_results: None,
9327 electromagnetic_results: None,
9328 model_validity: QualityGate::Pass,
9329 solver_convergence,
9330 result_quality,
9331 run_status,
9332 publishable,
9333 quality_reasons,
9334 provenance: RunProvenance {
9335 backend,
9336 solver_backend,
9337 solver_device_apply_k_ratio,
9338 solver_host_sync_count,
9339 precision_mode: contracts::format_precision_mode(options.precision_mode),
9340 deterministic_mode: options.deterministic_mode,
9341 solver_method,
9342 preconditioner,
9343 quality_policy: contracts::format_quality_policy(options.quality_policy),
9344 fallback_events,
9345 },
9346 };
9347
9348 persist_fea_run_result_with_progress(
9349 ANALYSIS_RUN_OPERATION,
9350 ANALYSIS_RUN_OP_VERSION,
9351 "RM.FEA.RUN_LINEAR_STATIC.ARTIFACT_STORE_FAILED",
9352 &context,
9353 &result,
9354 )?;
9355
9356 Ok(OperationEnvelope::new(
9357 ANALYSIS_RUN_OPERATION,
9358 ANALYSIS_RUN_OP_VERSION,
9359 &context,
9360 result,
9361 ))
9362}
9363
9364pub fn analysis_run_electromagnetic_op(
9365 model: &AnalysisModel,
9366 backend: ComputeBackend,
9367 context: OperationContext,
9368) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
9369 analysis_run_electromagnetic_with_options_op(
9370 model,
9371 backend,
9372 AnalysisElectromagneticRunOptions::default(),
9373 context,
9374 )
9375}
9376
9377pub fn analysis_run_electromagnetic_with_options_op(
9378 model: &AnalysisModel,
9379 backend: ComputeBackend,
9380 options: AnalysisElectromagneticRunOptions,
9381 context: OperationContext,
9382) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
9383 let _solver_context = install_fea_solver_context();
9384 let has_electromagnetic_step = model
9385 .steps
9386 .iter()
9387 .any(|step| step.kind == AnalysisStepKind::Electromagnetic);
9388 if !has_electromagnetic_step {
9389 return Err(operation_error(
9390 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9391 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9392 &context,
9393 OperationErrorSpec {
9394 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.REQUIRES_STEP",
9395 error_type: OperationErrorType::Validation,
9396 retryable: false,
9397 severity: OperationErrorSeverity::Error,
9398 },
9399 "FEA model must include at least one electromagnetic step for fea.run_electromagnetic",
9400 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
9401 ));
9402 }
9403
9404 let Some(em_domain) = model.electromagnetic.as_ref() else {
9405 return Err(operation_error(
9406 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9407 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9408 &context,
9409 OperationErrorSpec {
9410 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_MODEL",
9411 error_type: OperationErrorType::Validation,
9412 retryable: false,
9413 severity: OperationErrorSeverity::Error,
9414 },
9415 "fea.run_electromagnetic requires model.electromagnetic to be configured",
9416 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
9417 ));
9418 };
9419 if !em_domain.enabled {
9420 return Err(operation_error(
9421 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9422 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9423 &context,
9424 OperationErrorSpec {
9425 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
9426 error_type: OperationErrorType::Input,
9427 retryable: false,
9428 severity: OperationErrorSeverity::Error,
9429 },
9430 "fea.run_electromagnetic requires electromagnetic domain enabled=true",
9431 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
9432 ));
9433 }
9434 if !em_domain.reference_frequency_hz.is_finite() || em_domain.reference_frequency_hz <= 0.0 {
9435 return Err(operation_error(
9436 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9437 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9438 &context,
9439 OperationErrorSpec {
9440 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
9441 error_type: OperationErrorType::Input,
9442 retryable: false,
9443 severity: OperationErrorSeverity::Error,
9444 },
9445 "fea.run_electromagnetic requires finite positive reference_frequency_hz",
9446 BTreeMap::from([(
9447 "reference_frequency_hz".to_string(),
9448 em_domain.reference_frequency_hz.to_string(),
9449 )]),
9450 ));
9451 }
9452 if !em_domain.applied_current_a.is_finite() || em_domain.applied_current_a <= 0.0 {
9453 return Err(operation_error(
9454 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9455 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9456 &context,
9457 OperationErrorSpec {
9458 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
9459 error_type: OperationErrorType::Input,
9460 retryable: false,
9461 severity: OperationErrorSeverity::Error,
9462 },
9463 "fea.run_electromagnetic requires finite positive applied_current_a",
9464 BTreeMap::from([(
9465 "applied_current_a".to_string(),
9466 em_domain.applied_current_a.to_string(),
9467 )]),
9468 ));
9469 }
9470 if !options.residual_target.is_finite() || options.residual_target <= 0.0 {
9471 return Err(operation_error(
9472 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9473 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9474 &context,
9475 OperationErrorSpec {
9476 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
9477 error_type: OperationErrorType::Input,
9478 retryable: false,
9479 severity: OperationErrorSeverity::Error,
9480 },
9481 "fea.run_electromagnetic requires residual_target to be finite and positive",
9482 BTreeMap::from([(
9483 "residual_target".to_string(),
9484 options.residual_target.to_string(),
9485 )]),
9486 ));
9487 }
9488 if !options.harmonic_tolerance.is_finite() || options.harmonic_tolerance <= 0.0 {
9489 return Err(operation_error(
9490 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9491 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9492 &context,
9493 OperationErrorSpec {
9494 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
9495 error_type: OperationErrorType::Input,
9496 retryable: false,
9497 severity: OperationErrorSeverity::Error,
9498 },
9499 "fea.run_electromagnetic requires harmonic_tolerance to be finite and positive",
9500 BTreeMap::from([(
9501 "harmonic_tolerance".to_string(),
9502 options.harmonic_tolerance.to_string(),
9503 )]),
9504 ));
9505 }
9506 if options.harmonic_max_iterations == 0 {
9507 return Err(operation_error(
9508 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9509 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9510 &context,
9511 OperationErrorSpec {
9512 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
9513 error_type: OperationErrorType::Input,
9514 retryable: false,
9515 severity: OperationErrorSeverity::Error,
9516 },
9517 "fea.run_electromagnetic requires harmonic_max_iterations greater than zero",
9518 BTreeMap::from([(
9519 "harmonic_max_iterations".to_string(),
9520 options.harmonic_max_iterations.to_string(),
9521 )]),
9522 ));
9523 }
9524 validate_electromagnetic_run_model(model, &context)?;
9525
9526 let prep_context = resolve_run_prep_context(
9527 model,
9528 options.prep_artifact_id.as_deref(),
9529 options.prep_context.clone(),
9530 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9531 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9532 &context,
9533 )?;
9534
9535 let sweep_frequency_hz = normalize_em_sweep_frequency_hz(
9536 em_domain.reference_frequency_hz,
9537 options.sweep_enabled,
9538 &options.sweep_frequency_hz,
9539 )
9540 .ok_or_else(|| {
9541 operation_error(
9542 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9543 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9544 &context,
9545 OperationErrorSpec {
9546 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
9547 error_type: OperationErrorType::Input,
9548 retryable: false,
9549 severity: OperationErrorSeverity::Error,
9550 },
9551 "fea.run_electromagnetic sweep_frequency_hz must contain finite positive values",
9552 BTreeMap::new(),
9553 )
9554 })?;
9555 let solve_options = ElectromagneticSolveOptions {
9556 prep_context: to_fea_prep_context(prep_context.as_ref(), options.prep_calibration_profile),
9557 residual_target: options.residual_target,
9558 harmonic_tolerance: options.harmonic_tolerance,
9559 harmonic_max_iterations: options.harmonic_max_iterations,
9560 };
9561 let mut sweep_runs = Vec::with_capacity(sweep_frequency_hz.len());
9562 let mut sweep_peak_flux_density = Vec::with_capacity(sweep_frequency_hz.len());
9563 let mut sweep_solve_quality = Vec::with_capacity(sweep_frequency_hz.len());
9564 for frequency_hz in &sweep_frequency_hz {
9565 let mut sweep_model = model.clone();
9566 if let Some(domain) = sweep_model.electromagnetic.as_mut() {
9567 domain.reference_frequency_hz = *frequency_hz;
9568 }
9569 let sweep_run =
9570 run_electromagnetic_with_options(&sweep_model, backend, solve_options.clone())
9571 .map_err(|err| {
9572 map_fea_run_error(
9573 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
9574 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
9575 "RM.FEA.RUN_ELECTROMAGNETIC.SOLVER_MODEL_INVALID",
9576 "RM.FEA.RUN_ELECTROMAGNETIC.CANCELLED",
9577 model,
9578 &context,
9579 err,
9580 )
9581 })?;
9582 sweep_peak_flux_density.push(peak_abs_field_value(
9583 &sweep_run.magnetic_flux_density_magnitude_field,
9584 ));
9585 sweep_solve_quality.push(sweep_run.solve_quality);
9586 sweep_runs.push(sweep_run);
9587 }
9588 let sweep_metrics = summarize_em_sweep(&sweep_frequency_hz, &sweep_peak_flux_density);
9589 let primary_index =
9590 nearest_frequency_index(&sweep_frequency_hz, em_domain.reference_frequency_hz).unwrap_or(0);
9591 let em_run = sweep_runs[primary_index].clone();
9592 let mut run = em_run.run.clone();
9593 run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
9594 code: "FEA_EM_SWEEP".to_string(),
9595 severity: if sweep_metrics.sweep_count > 1 {
9596 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
9597 } else {
9598 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
9599 },
9600 message: format!(
9601 "sweep_count={} resonance_peak_frequency_hz={} resonance_peak_flux_density={} resonance_bandwidth_hz={} resonance_quality_factor={} resonance_flux_gain={}",
9602 sweep_metrics.sweep_count,
9603 sweep_metrics.resonance_peak_frequency_hz.unwrap_or(0.0),
9604 sweep_metrics.resonance_peak_flux_density.unwrap_or(0.0),
9605 sweep_metrics.resonance_bandwidth_hz.unwrap_or(0.0),
9606 sweep_metrics.resonance_quality_factor.unwrap_or(0.0),
9607 sweep_metrics.resonance_flux_gain.unwrap_or(0.0),
9608 ),
9609 });
9610 if sweep_metrics.sweep_count > 1 {
9611 run.diagnostics.push(em_sweep_known_answer_diagnostic(
9612 em_domain.reference_frequency_hz,
9613 &sweep_frequency_hz,
9614 &sweep_metrics,
9615 ));
9616 }
9617 let solver_convergence = if run.diagnostics.iter().any(|diag| {
9618 diag.code == "FEA_EM_STATIC"
9619 && diag.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
9620 }) {
9621 QualityGate::Pass
9622 } else {
9623 QualityGate::Warn
9624 };
9625 let mut result_quality = if em_run.solve_quality >= 0.85 {
9626 QualityGate::Pass
9627 } else if em_run.solve_quality >= 0.6 {
9628 QualityGate::Warn
9629 } else {
9630 QualityGate::Fail
9631 };
9632 let mut quality_reasons = Vec::new();
9633 let em_conductivity_spread_ratio = diagnostic_metric(
9634 &run.diagnostics,
9635 "FEA_EM_STATIC",
9636 "conductivity_spread_ratio",
9637 );
9638 let em_assignment_heterogeneity_index = diagnostic_metric(
9639 &run.diagnostics,
9640 "FEA_EM_STATIC",
9641 "electromagnetic_material_heterogeneity_index",
9642 );
9643 let em_assignment_coverage_ratio = diagnostic_metric(
9644 &run.diagnostics,
9645 "FEA_EM_STATIC",
9646 "assignment_coverage_ratio",
9647 );
9648 let em_assigned_coefficient_coverage_ratio = diagnostic_metric(
9649 &run.diagnostics,
9650 "FEA_EM_STATIC",
9651 "assigned_coefficient_coverage_ratio",
9652 );
9653 let em_region_contrast_index = diagnostic_metric(
9654 &run.diagnostics,
9655 "FEA_EM_STATIC",
9656 "region_coefficient_contrast_index",
9657 );
9658 let em_condition_number_estimate = diagnostic_metric(
9659 &run.diagnostics,
9660 "FEA_EM_STATIC",
9661 "condition_number_estimate",
9662 );
9663 let em_source_realization_ratio = diagnostic_metric(
9664 &run.diagnostics,
9665 "FEA_EM_SOURCE_ENERGY",
9666 "source_realization_ratio",
9667 );
9668 let em_source_region_coverage_ratio = diagnostic_metric(
9669 &run.diagnostics,
9670 "FEA_EM_SOURCE_ENERGY",
9671 "source_region_coverage_ratio",
9672 );
9673 let em_source_material_alignment_ratio = diagnostic_metric(
9674 &run.diagnostics,
9675 "FEA_EM_SOURCE_ENERGY",
9676 "source_material_alignment_ratio",
9677 );
9678 let em_source_overlap_ratio = diagnostic_metric(
9679 &run.diagnostics,
9680 "FEA_EM_SOURCE_ENERGY",
9681 "source_overlap_ratio",
9682 );
9683 let em_source_interference_index = diagnostic_metric(
9684 &run.diagnostics,
9685 "FEA_EM_SOURCE_ENERGY",
9686 "source_interference_index",
9687 );
9688 let em_boundary_anchor_ratio = diagnostic_metric(
9689 &run.diagnostics,
9690 "FEA_EM_SOURCE_ENERGY",
9691 "boundary_anchor_ratio",
9692 );
9693 let em_boundary_condition_localization_ratio = diagnostic_metric(
9694 &run.diagnostics,
9695 "FEA_EM_SOURCE_ENERGY",
9696 "boundary_condition_localization_ratio",
9697 );
9698 let em_ground_anchor_effectiveness_ratio = diagnostic_metric(
9699 &run.diagnostics,
9700 "FEA_EM_SOURCE_ENERGY",
9701 "ground_anchor_effectiveness_ratio",
9702 );
9703 let em_insulation_leakage_ratio = diagnostic_metric(
9704 &run.diagnostics,
9705 "FEA_EM_SOURCE_ENERGY",
9706 "insulation_leakage_ratio",
9707 );
9708 let em_flux_divergence_ratio =
9709 diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "flux_divergence_ratio");
9710 let em_energy_imbalance_ratio = diagnostic_metric(
9711 &run.diagnostics,
9712 "FEA_EM_SOURCE_ENERGY",
9713 "energy_imbalance_ratio",
9714 );
9715 let em_boundary_energy_ratio = diagnostic_metric(
9716 &run.diagnostics,
9717 "FEA_EM_SOURCE_ENERGY",
9718 "boundary_energy_ratio",
9719 );
9720 let em_boundary_penalty_conditioning_contribution = diagnostic_metric(
9721 &run.diagnostics,
9722 "FEA_EM_SOURCE_ENERGY",
9723 "boundary_penalty_conditioning_contribution",
9724 );
9725 let em_source_region_energy_consistency_ratio = diagnostic_metric(
9726 &run.diagnostics,
9727 "FEA_EM_SOURCE_ENERGY",
9728 "source_region_energy_consistency_ratio",
9729 );
9730 let em_real_residual_norm =
9731 diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "real_residual_norm");
9732 let em_imag_residual_norm =
9733 diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "imag_residual_norm");
9734 let em_sweep_count = diagnostic_metric(&run.diagnostics, "FEA_EM_SWEEP", "sweep_count");
9735 let em_resonance_quality_factor =
9736 diagnostic_metric(&run.diagnostics, "FEA_EM_SWEEP", "resonance_quality_factor");
9737 let ElectromagneticQualityThresholds {
9738 em_spread_threshold,
9739 em_heterogeneity_threshold,
9740 em_coverage_min_threshold,
9741 em_contrast_max_threshold,
9742 em_conditioning_max_threshold,
9743 em_source_realization_min_threshold,
9744 em_source_region_coverage_min_threshold,
9745 em_source_material_alignment_min_threshold,
9746 em_source_overlap_max_threshold,
9747 em_source_interference_max_threshold,
9748 em_boundary_anchor_min_threshold,
9749 em_boundary_localization_min_threshold,
9750 em_ground_effectiveness_min_threshold,
9751 em_insulation_leakage_max_threshold,
9752 em_divergence_max_threshold,
9753 em_energy_imbalance_max_threshold,
9754 em_boundary_energy_min_threshold,
9755 em_boundary_penalty_contribution_max_threshold,
9756 em_source_region_energy_consistency_min_threshold,
9757 em_real_residual_max_threshold,
9758 em_imag_residual_max_threshold,
9759 } = electromagnetic_thresholds_for_policy(options.quality_policy);
9760 let (em_sweep_count_min_threshold, em_resonance_q_min_threshold) =
9761 electromagnetic_sweep_thresholds_for_policy(options.quality_policy);
9762 let em_spread_breach = em_conductivity_spread_ratio
9763 .map(|value| value > em_spread_threshold)
9764 .unwrap_or(false);
9765 let em_heterogeneity_breach = em_assignment_heterogeneity_index
9766 .map(|value| value > em_heterogeneity_threshold)
9767 .unwrap_or(false);
9768 let em_coverage_breach = em_assignment_coverage_ratio
9769 .map(|value| value < em_coverage_min_threshold)
9770 .unwrap_or(false);
9771 let em_assigned_coefficient_breach = em_assigned_coefficient_coverage_ratio
9772 .map(|value| value < em_coverage_min_threshold)
9773 .unwrap_or(false);
9774 let em_contrast_breach = em_region_contrast_index
9775 .map(|value| value > em_contrast_max_threshold)
9776 .unwrap_or(false);
9777 let em_conditioning_breach = em_condition_number_estimate
9778 .map(|value| value > em_conditioning_max_threshold)
9779 .unwrap_or(false);
9780 let em_source_realization_breach = em_source_realization_ratio
9781 .map(|value| value < em_source_realization_min_threshold)
9782 .unwrap_or(false);
9783 let em_source_region_coverage_breach = em_source_region_coverage_ratio
9784 .map(|value| value < em_source_region_coverage_min_threshold)
9785 .unwrap_or(false);
9786 let em_source_material_alignment_breach = em_source_material_alignment_ratio
9787 .map(|value| value < em_source_material_alignment_min_threshold)
9788 .unwrap_or(false);
9789 let em_source_overlap_breach = em_source_overlap_ratio
9790 .map(|value| value > em_source_overlap_max_threshold)
9791 .unwrap_or(false);
9792 let em_source_interference_breach = em_source_interference_index
9793 .map(|value| value > em_source_interference_max_threshold)
9794 .unwrap_or(false);
9795 let em_boundary_anchor_breach = em_boundary_anchor_ratio
9796 .map(|value| value < em_boundary_anchor_min_threshold)
9797 .unwrap_or(false);
9798 let em_boundary_localization_breach = em_boundary_condition_localization_ratio
9799 .map(|value| value < em_boundary_localization_min_threshold)
9800 .unwrap_or(false);
9801 let em_ground_effectiveness_breach = em_ground_anchor_effectiveness_ratio
9802 .map(|value| value < em_ground_effectiveness_min_threshold)
9803 .unwrap_or(false);
9804 let em_insulation_leakage_breach = em_insulation_leakage_ratio
9805 .map(|value| value > em_insulation_leakage_max_threshold)
9806 .unwrap_or(false);
9807 let em_divergence_breach = em_flux_divergence_ratio
9808 .map(|value| value > em_divergence_max_threshold)
9809 .unwrap_or(false);
9810 let em_energy_imbalance_breach = em_energy_imbalance_ratio
9811 .map(|value| value > em_energy_imbalance_max_threshold)
9812 .unwrap_or(false);
9813 let em_boundary_energy_breach = em_boundary_energy_ratio
9814 .map(|value| value < em_boundary_energy_min_threshold)
9815 .unwrap_or(false);
9816 let em_boundary_penalty_contribution_breach = em_boundary_penalty_conditioning_contribution
9817 .map(|value| value > em_boundary_penalty_contribution_max_threshold)
9818 .unwrap_or(false);
9819 let em_source_region_energy_consistency_breach = em_source_region_energy_consistency_ratio
9820 .map(|value| value < em_source_region_energy_consistency_min_threshold)
9821 .unwrap_or(false);
9822 let em_real_residual_breach = em_real_residual_norm
9823 .map(|value| value > em_real_residual_max_threshold)
9824 .unwrap_or(false);
9825 let em_imag_residual_breach = em_imag_residual_norm
9826 .map(|value| value > em_imag_residual_max_threshold)
9827 .unwrap_or(false);
9828 let sweep_governance_active = options.sweep_enabled || !options.sweep_frequency_hz.is_empty();
9829 let em_sweep_coverage_breach = sweep_governance_active
9830 && em_sweep_count
9831 .map(|value| value < em_sweep_count_min_threshold)
9832 .unwrap_or(false);
9833 let em_resonance_sharpness_breach = sweep_governance_active
9834 && em_resonance_quality_factor
9835 .map(|value| value < em_resonance_q_min_threshold)
9836 .unwrap_or(false);
9837 if (em_spread_breach
9838 || em_heterogeneity_breach
9839 || em_coverage_breach
9840 || em_assigned_coefficient_breach
9841 || em_contrast_breach
9842 || em_conditioning_breach
9843 || em_source_realization_breach
9844 || em_source_region_coverage_breach
9845 || em_source_material_alignment_breach
9846 || em_source_overlap_breach
9847 || em_source_interference_breach
9848 || em_boundary_anchor_breach
9849 || em_boundary_localization_breach
9850 || em_ground_effectiveness_breach
9851 || em_insulation_leakage_breach
9852 || em_divergence_breach
9853 || em_energy_imbalance_breach
9854 || em_boundary_energy_breach
9855 || em_boundary_penalty_contribution_breach
9856 || em_source_region_energy_consistency_breach
9857 || em_real_residual_breach
9858 || em_imag_residual_breach
9859 || em_sweep_coverage_breach
9860 || em_resonance_sharpness_breach)
9861 && result_quality == QualityGate::Pass
9862 {
9863 result_quality = QualityGate::Warn;
9864 }
9865 if solver_convergence == QualityGate::Warn {
9866 quality_reasons.push(QualityReason {
9867 code: QualityReasonCode::SolverNotConverged,
9868 detail: "electromagnetic solver convergence gate is warning".to_string(),
9869 });
9870 }
9871 if result_quality != QualityGate::Pass {
9872 quality_reasons.push(QualityReason {
9873 code: QualityReasonCode::ElectromagneticSolveQualityLow,
9874 detail: "electromagnetic static solve quality below production target".to_string(),
9875 });
9876 }
9877 if em_spread_breach {
9878 quality_reasons.push(QualityReason {
9879 code: QualityReasonCode::ElectromagneticConductivitySpreadHigh,
9880 detail: format!(
9881 "electromagnetic conductivity spread ratio {} exceeds threshold {}",
9882 em_conductivity_spread_ratio.unwrap_or(0.0),
9883 em_spread_threshold
9884 ),
9885 });
9886 }
9887 if em_heterogeneity_breach {
9888 quality_reasons.push(QualityReason {
9889 code: QualityReasonCode::ElectromagneticMaterialHeterogeneityHigh,
9890 detail: format!(
9891 "electromagnetic material heterogeneity index {} exceeds threshold {}",
9892 em_assignment_heterogeneity_index.unwrap_or(0.0),
9893 em_heterogeneity_threshold
9894 ),
9895 });
9896 }
9897 if em_coverage_breach {
9898 quality_reasons.push(QualityReason {
9899 code: QualityReasonCode::ElectromagneticAssignmentCoverageLow,
9900 detail: format!(
9901 "electromagnetic assignment coverage ratio {} is below threshold {}",
9902 em_assignment_coverage_ratio.unwrap_or(0.0),
9903 em_coverage_min_threshold
9904 ),
9905 });
9906 }
9907 if em_assigned_coefficient_breach {
9908 quality_reasons.push(QualityReason {
9909 code: QualityReasonCode::ElectromagneticAssignmentCoverageLow,
9910 detail: format!(
9911 "electromagnetic assigned coefficient coverage ratio {} is below threshold {}",
9912 em_assigned_coefficient_coverage_ratio.unwrap_or(0.0),
9913 em_coverage_min_threshold
9914 ),
9915 });
9916 }
9917 if em_contrast_breach {
9918 quality_reasons.push(QualityReason {
9919 code: QualityReasonCode::ElectromagneticRegionContrastHigh,
9920 detail: format!(
9921 "electromagnetic region coefficient contrast index {} exceeds threshold {}",
9922 em_region_contrast_index.unwrap_or(0.0),
9923 em_contrast_max_threshold
9924 ),
9925 });
9926 }
9927 if em_conditioning_breach {
9928 quality_reasons.push(QualityReason {
9929 code: QualityReasonCode::ElectromagneticConditioningHigh,
9930 detail: format!(
9931 "electromagnetic condition-number estimate {} exceeds threshold {}",
9932 em_condition_number_estimate.unwrap_or(0.0),
9933 em_conditioning_max_threshold
9934 ),
9935 });
9936 }
9937 if em_source_realization_breach {
9938 quality_reasons.push(QualityReason {
9939 code: QualityReasonCode::ElectromagneticSourceRealizationLow,
9940 detail: format!(
9941 "electromagnetic source realization ratio {} is below threshold {}",
9942 em_source_realization_ratio.unwrap_or(0.0),
9943 em_source_realization_min_threshold
9944 ),
9945 });
9946 }
9947 if em_source_region_coverage_breach {
9948 quality_reasons.push(QualityReason {
9949 code: QualityReasonCode::ElectromagneticSourceRegionCoverageLow,
9950 detail: format!(
9951 "electromagnetic source region coverage ratio {} is below threshold {}",
9952 em_source_region_coverage_ratio.unwrap_or(0.0),
9953 em_source_region_coverage_min_threshold
9954 ),
9955 });
9956 }
9957 if em_source_material_alignment_breach {
9958 quality_reasons.push(QualityReason {
9959 code: QualityReasonCode::ElectromagneticSourceMaterialAlignmentLow,
9960 detail: format!(
9961 "electromagnetic source material alignment ratio {} is below threshold {}",
9962 em_source_material_alignment_ratio.unwrap_or(0.0),
9963 em_source_material_alignment_min_threshold
9964 ),
9965 });
9966 }
9967 if em_source_overlap_breach {
9968 quality_reasons.push(QualityReason {
9969 code: QualityReasonCode::ElectromagneticSourceOverlapHigh,
9970 detail: format!(
9971 "electromagnetic source overlap ratio {} exceeds threshold {}",
9972 em_source_overlap_ratio.unwrap_or(0.0),
9973 em_source_overlap_max_threshold
9974 ),
9975 });
9976 }
9977 if em_source_interference_breach {
9978 quality_reasons.push(QualityReason {
9979 code: QualityReasonCode::ElectromagneticSourceInterferenceHigh,
9980 detail: format!(
9981 "electromagnetic source interference index {} exceeds threshold {}",
9982 em_source_interference_index.unwrap_or(0.0),
9983 em_source_interference_max_threshold
9984 ),
9985 });
9986 }
9987 if em_boundary_anchor_breach {
9988 quality_reasons.push(QualityReason {
9989 code: QualityReasonCode::ElectromagneticBoundaryAnchoringLow,
9990 detail: format!(
9991 "electromagnetic boundary anchor ratio {} is below threshold {}",
9992 em_boundary_anchor_ratio.unwrap_or(0.0),
9993 em_boundary_anchor_min_threshold
9994 ),
9995 });
9996 }
9997 if em_boundary_localization_breach {
9998 quality_reasons.push(QualityReason {
9999 code: QualityReasonCode::ElectromagneticBoundaryLocalizationLow,
10000 detail: format!(
10001 "electromagnetic boundary condition localization ratio {} is below threshold {}",
10002 em_boundary_condition_localization_ratio.unwrap_or(0.0),
10003 em_boundary_localization_min_threshold
10004 ),
10005 });
10006 }
10007 if em_ground_effectiveness_breach {
10008 quality_reasons.push(QualityReason {
10009 code: QualityReasonCode::ElectromagneticGroundAnchorEffectivenessLow,
10010 detail: format!(
10011 "electromagnetic ground anchor effectiveness ratio {} is below threshold {}",
10012 em_ground_anchor_effectiveness_ratio.unwrap_or(0.0),
10013 em_ground_effectiveness_min_threshold
10014 ),
10015 });
10016 }
10017 if em_insulation_leakage_breach {
10018 quality_reasons.push(QualityReason {
10019 code: QualityReasonCode::ElectromagneticInsulationLeakageHigh,
10020 detail: format!(
10021 "electromagnetic insulation leakage ratio {} exceeds threshold {}",
10022 em_insulation_leakage_ratio.unwrap_or(0.0),
10023 em_insulation_leakage_max_threshold
10024 ),
10025 });
10026 }
10027 if em_divergence_breach {
10028 quality_reasons.push(QualityReason {
10029 code: QualityReasonCode::ElectromagneticFluxDivergenceHigh,
10030 detail: format!(
10031 "electromagnetic flux divergence ratio {} exceeds threshold {}",
10032 em_flux_divergence_ratio.unwrap_or(0.0),
10033 em_divergence_max_threshold
10034 ),
10035 });
10036 }
10037 if em_energy_imbalance_breach {
10038 quality_reasons.push(QualityReason {
10039 code: QualityReasonCode::ElectromagneticEnergyImbalanceHigh,
10040 detail: format!(
10041 "electromagnetic energy imbalance ratio {} exceeds threshold {}",
10042 em_energy_imbalance_ratio.unwrap_or(0.0),
10043 em_energy_imbalance_max_threshold
10044 ),
10045 });
10046 }
10047 if em_boundary_energy_breach {
10048 quality_reasons.push(QualityReason {
10049 code: QualityReasonCode::ElectromagneticBoundaryEnergyLow,
10050 detail: format!(
10051 "electromagnetic boundary energy ratio {} is below threshold {}",
10052 em_boundary_energy_ratio.unwrap_or(0.0),
10053 em_boundary_energy_min_threshold
10054 ),
10055 });
10056 }
10057 if em_boundary_penalty_contribution_breach {
10058 quality_reasons.push(QualityReason {
10059 code: QualityReasonCode::ElectromagneticBoundaryPenaltyConditioningHigh,
10060 detail: format!(
10061 "electromagnetic boundary penalty conditioning contribution {} exceeds threshold {}",
10062 em_boundary_penalty_conditioning_contribution.unwrap_or(0.0),
10063 em_boundary_penalty_contribution_max_threshold
10064 ),
10065 });
10066 }
10067 if em_source_region_energy_consistency_breach {
10068 quality_reasons.push(QualityReason {
10069 code: QualityReasonCode::ElectromagneticSourceRegionEnergyConsistencyLow,
10070 detail: format!(
10071 "electromagnetic source-region energy consistency ratio {} is below threshold {}",
10072 em_source_region_energy_consistency_ratio.unwrap_or(0.0),
10073 em_source_region_energy_consistency_min_threshold
10074 ),
10075 });
10076 }
10077 if em_real_residual_breach {
10078 quality_reasons.push(QualityReason {
10079 code: QualityReasonCode::ElectromagneticRealResidualHigh,
10080 detail: format!(
10081 "electromagnetic real residual norm {} exceeds threshold {}",
10082 em_real_residual_norm.unwrap_or(0.0),
10083 em_real_residual_max_threshold
10084 ),
10085 });
10086 }
10087 if em_imag_residual_breach {
10088 quality_reasons.push(QualityReason {
10089 code: QualityReasonCode::ElectromagneticImagResidualHigh,
10090 detail: format!(
10091 "electromagnetic imaginary residual norm {} exceeds threshold {}",
10092 em_imag_residual_norm.unwrap_or(0.0),
10093 em_imag_residual_max_threshold
10094 ),
10095 });
10096 }
10097 if em_sweep_coverage_breach {
10098 quality_reasons.push(QualityReason {
10099 code: QualityReasonCode::ElectromagneticSweepCoverageLow,
10100 detail: format!(
10101 "electromagnetic sweep count {} is below threshold {}",
10102 em_sweep_count.unwrap_or(0.0),
10103 em_sweep_count_min_threshold
10104 ),
10105 });
10106 }
10107 if em_resonance_sharpness_breach {
10108 quality_reasons.push(QualityReason {
10109 code: QualityReasonCode::ElectromagneticResonanceSharpnessLow,
10110 detail: format!(
10111 "electromagnetic resonance quality factor {} is below threshold {}",
10112 em_resonance_quality_factor.unwrap_or(0.0),
10113 em_resonance_q_min_threshold
10114 ),
10115 });
10116 }
10117
10118 let publishable = match options.quality_policy {
10119 QualityPolicy::Strict => {
10120 solver_convergence == QualityGate::Pass
10121 && result_quality == QualityGate::Pass
10122 && quality_reasons.is_empty()
10123 }
10124 QualityPolicy::Balanced => {
10125 solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
10126 }
10127 QualityPolicy::Exploratory => {
10128 solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
10129 }
10130 };
10131 let run_status = if publishable {
10132 RunStatus::Publishable
10133 } else if result_quality == QualityGate::Fail {
10134 RunStatus::Rejected
10135 } else {
10136 RunStatus::Degraded
10137 };
10138 let solver_backend = run.solver_backend.clone();
10139 let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
10140 let solver_host_sync_count = run.solver_host_sync_count;
10141 let solver_method = run.solver_method.clone();
10142 let preconditioner = run.preconditioner.clone();
10143
10144 let result = AnalysisRunResult {
10145 run_id: storage::next_run_id(),
10146 run,
10147 render_topology: render_topology_from_prep_context(prep_context.as_ref()),
10148 modal_results: None,
10149 thermal_results: None,
10150 transient_results: None,
10151 nonlinear_results: None,
10152 electromagnetic_results: Some(ElectromagneticResultsData {
10153 electromagnetic_payload_version: "electromagnetic_results/v1".to_string(),
10154 reference_frequency_hz: em_run.reference_frequency_hz,
10155 applied_current_a: em_run.applied_current_a,
10156 vector_potential_real: em_run.vector_potential_real_field,
10157 vector_potential_imag: em_run.vector_potential_imag_field,
10158 magnetic_flux_density_real: em_run.magnetic_flux_density_real_field,
10159 magnetic_flux_density_imag: em_run.magnetic_flux_density_imag_field,
10160 magnetic_flux_density_magnitude: em_run.magnetic_flux_density_magnitude_field,
10161 magnetic_field_real: em_run.magnetic_field_real_field,
10162 magnetic_field_imag: em_run.magnetic_field_imag_field,
10163 current_density_real: em_run.current_density_real_field,
10164 current_density_imag: em_run.current_density_imag_field,
10165 electric_field_real: em_run.electric_field_real_field,
10166 electric_field_imag: em_run.electric_field_imag_field,
10167 power_loss_density: em_run.power_loss_density_field,
10168 energy_density: em_run.energy_density_field,
10169 residual_real: em_run.residual_real_field,
10170 residual_imag: em_run.residual_imag_field,
10171 electric_flux_density_real: em_run.electric_flux_density_real_field,
10172 electric_flux_density_imag: em_run.electric_flux_density_imag_field,
10173 poynting_vector_real: em_run.poynting_vector_real_field,
10174 poynting_vector_imag: em_run.poynting_vector_imag_field,
10175 sweep_frequency_hz,
10176 sweep_peak_flux_density,
10177 sweep_solve_quality,
10178 resonance_peak_frequency_hz: sweep_metrics.resonance_peak_frequency_hz,
10179 resonance_peak_flux_density: sweep_metrics.resonance_peak_flux_density,
10180 resonance_bandwidth_hz: sweep_metrics.resonance_bandwidth_hz,
10181 resonance_quality_factor: sweep_metrics.resonance_quality_factor,
10182 resonance_flux_gain: sweep_metrics.resonance_flux_gain,
10183 }),
10184 model_validity: QualityGate::Pass,
10185 solver_convergence,
10186 result_quality,
10187 run_status,
10188 publishable,
10189 quality_reasons,
10190 provenance: RunProvenance {
10191 backend,
10192 solver_backend,
10193 solver_device_apply_k_ratio,
10194 solver_host_sync_count,
10195 precision_mode: contracts::format_precision_mode(options.precision_mode),
10196 deterministic_mode: options.deterministic_mode,
10197 solver_method,
10198 preconditioner,
10199 quality_policy: contracts::format_quality_policy(options.quality_policy),
10200 fallback_events: Vec::new(),
10201 },
10202 };
10203
10204 persist_fea_run_result_with_progress(
10205 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10206 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10207 "RM.FEA.RUN_ELECTROMAGNETIC.ARTIFACT_STORE_FAILED",
10208 &context,
10209 &result,
10210 )?;
10211
10212 Ok(OperationEnvelope::new(
10213 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10214 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10215 &context,
10216 result,
10217 ))
10218}
10219
10220fn validate_electromagnetic_run_model(
10221 model: &AnalysisModel,
10222 context: &OperationContext,
10223) -> Result<(), OperationErrorEnvelope> {
10224 if let Some(material) = model.materials.iter().find(|material| {
10225 material.electrical.as_ref().is_some_and(|electrical| {
10226 !electrical.conductivity_s_per_m.is_finite()
10227 || electrical.conductivity_s_per_m <= 0.0
10228 || !electrical.relative_permittivity.is_finite()
10229 || electrical.relative_permittivity <= 0.0
10230 || !electrical.relative_permeability.is_finite()
10231 || electrical.relative_permeability <= 0.0
10232 || electrical
10233 .conductivity_frequency_response
10234 .iter()
10235 .any(|point| {
10236 !point.frequency_hz.is_finite()
10237 || point.frequency_hz <= 0.0
10238 || !point.conductivity_scale.is_finite()
10239 || point.conductivity_scale <= 0.0
10240 || point
10241 .dispersive_loss_scale
10242 .is_some_and(|value| !value.is_finite() || value < 0.0)
10243 || point
10244 .relative_permittivity_scale
10245 .is_some_and(|value| !value.is_finite() || value <= 0.0)
10246 || point
10247 .relative_permeability_scale
10248 .is_some_and(|value| !value.is_finite() || value <= 0.0)
10249 })
10250 })
10251 }) {
10252 return Err(operation_error(
10253 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10254 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10255 context,
10256 OperationErrorSpec {
10257 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_ELECTROMAGNETIC_MATERIAL",
10258 error_type: OperationErrorType::Validation,
10259 retryable: false,
10260 severity: OperationErrorSeverity::Error,
10261 },
10262 "fea.run_electromagnetic requires finite positive electrical material coefficients",
10263 BTreeMap::from([
10264 ("analysis_model_id".to_string(), model.model_id.0.clone()),
10265 ("material_id".to_string(), material.material_id.clone()),
10266 ]),
10267 ));
10268 }
10269
10270 let electrical_material_count = model
10271 .materials
10272 .iter()
10273 .filter(|material| material.electrical.is_some())
10274 .count();
10275 if electrical_material_count == 0 {
10276 return Err(operation_error(
10277 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10278 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10279 context,
10280 OperationErrorSpec {
10281 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_MATERIAL",
10282 error_type: OperationErrorType::Validation,
10283 retryable: false,
10284 severity: OperationErrorSeverity::Error,
10285 },
10286 "fea.run_electromagnetic requires at least one electrical material",
10287 BTreeMap::from([
10288 ("analysis_model_id".to_string(), model.model_id.0.clone()),
10289 (
10290 "material_count".to_string(),
10291 model.materials.len().to_string(),
10292 ),
10293 ]),
10294 ));
10295 }
10296 let electrical_material_by_id = model
10297 .materials
10298 .iter()
10299 .filter(|material| material.electrical.is_some())
10300 .map(|material| material.material_id.as_str())
10301 .collect::<std::collections::BTreeSet<_>>();
10302 if let Some(assignment) = model.material_assignments.iter().find(|assignment| {
10303 !electrical_material_by_id.contains(assignment.assigned_material_id.as_str())
10304 }) {
10305 return Err(operation_error(
10306 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10307 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10308 context,
10309 OperationErrorSpec {
10310 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_MATERIAL",
10311 error_type: OperationErrorType::Validation,
10312 retryable: false,
10313 severity: OperationErrorSeverity::Error,
10314 },
10315 "fea.run_electromagnetic requires every material assignment to reference an assigned electrical material",
10316 BTreeMap::from([
10317 ("analysis_model_id".to_string(), model.model_id.0.clone()),
10318 ("region_id".to_string(), assignment.region_id.clone()),
10319 (
10320 "assigned_material_id".to_string(),
10321 assignment.assigned_material_id.clone(),
10322 ),
10323 ]),
10324 ));
10325 }
10326
10327 reject_moment_loads_for_run_family(
10328 model,
10329 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10330 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10331 "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_ELECTROMAGNETIC_SOURCE",
10332 "electromagnetic",
10333 context,
10334 )?;
10335
10336 let has_electromagnetic_source = model.loads.iter().any(|load| match &load.kind {
10337 LoadKind::CurrentDensity {
10338 jx,
10339 jy,
10340 jz,
10341 phase_rad,
10342 amplitude_scale,
10343 } => {
10344 jx.is_finite()
10345 && jy.is_finite()
10346 && jz.is_finite()
10347 && phase_rad.is_finite()
10348 && amplitude_scale.is_finite()
10349 && *amplitude_scale > 0.0
10350 && (jx.abs() + jy.abs() + jz.abs()) > 0.0
10351 }
10352 LoadKind::CoilCurrent {
10353 current_a,
10354 phase_rad,
10355 amplitude_scale,
10356 } => {
10357 current_a.is_finite()
10358 && current_a.abs() > 0.0
10359 && phase_rad.is_finite()
10360 && amplitude_scale.is_finite()
10361 && *amplitude_scale > 0.0
10362 }
10363 _ => false,
10364 });
10365 if !has_electromagnetic_source {
10366 return Err(operation_error(
10367 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10368 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10369 context,
10370 OperationErrorSpec {
10371 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_SOURCE",
10372 error_type: OperationErrorType::Validation,
10373 retryable: false,
10374 severity: OperationErrorSeverity::Error,
10375 },
10376 "fea.run_electromagnetic requires a nonzero current-density or coil-current source",
10377 BTreeMap::from([
10378 ("analysis_model_id".to_string(), model.model_id.0.clone()),
10379 ("load_count".to_string(), model.loads.len().to_string()),
10380 ]),
10381 ));
10382 }
10383
10384 let has_electromagnetic_boundary = model.boundary_conditions.iter().any(|bc| {
10385 matches!(
10386 &bc.kind,
10387 BoundaryConditionKind::MagneticInsulation
10388 | BoundaryConditionKind::VectorPotentialGround
10389 )
10390 });
10391 if !has_electromagnetic_boundary {
10392 return Err(operation_error(
10393 ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10394 ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10395 context,
10396 OperationErrorSpec {
10397 error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_BOUNDARY",
10398 error_type: OperationErrorType::Validation,
10399 retryable: false,
10400 severity: OperationErrorSeverity::Error,
10401 },
10402 "fea.run_electromagnetic requires magnetic insulation or vector-potential ground boundary data",
10403 BTreeMap::from([
10404 ("analysis_model_id".to_string(), model.model_id.0.clone()),
10405 (
10406 "boundary_condition_count".to_string(),
10407 model.boundary_conditions.len().to_string(),
10408 ),
10409 ]),
10410 ));
10411 }
10412
10413 Ok(())
10414}
10415
10416fn collect_analysis_result_fields(run_result: &AnalysisRunResult) -> Vec<AnalysisField> {
10417 let mut fields = Vec::new();
10418 let mut seen = HashSet::new();
10419
10420 for field in &run_result.run.fields {
10421 push_analysis_result_field(&mut fields, &mut seen, field);
10422 }
10423
10424 if let Some(modal) = run_result.modal_results.as_ref() {
10425 for field in &modal.mode_shapes {
10426 push_analysis_result_field(&mut fields, &mut seen, field);
10427 }
10428 }
10429
10430 if let Some(thermal) = run_result.thermal_results.as_ref() {
10431 for field in &thermal.temperature_snapshots {
10432 push_analysis_result_field(&mut fields, &mut seen, field);
10433 }
10434 for field in &thermal.temperature_gradient_snapshots {
10435 push_analysis_result_field(&mut fields, &mut seen, field);
10436 }
10437 for field in &thermal.heat_flux_snapshots {
10438 push_analysis_result_field(&mut fields, &mut seen, field);
10439 }
10440 for field in &thermal.heat_source_snapshots {
10441 push_analysis_result_field(&mut fields, &mut seen, field);
10442 }
10443 for field in &thermal.boundary_heat_flux_snapshots {
10444 push_analysis_result_field(&mut fields, &mut seen, field);
10445 }
10446 }
10447
10448 if let Some(transient) = run_result.transient_results.as_ref() {
10449 for field in &transient.displacement_snapshots {
10450 push_analysis_result_field(&mut fields, &mut seen, field);
10451 }
10452 for field in &transient.rotation_snapshots {
10453 push_analysis_result_field(&mut fields, &mut seen, field);
10454 }
10455 for field in &transient.velocity_snapshots {
10456 push_analysis_result_field(&mut fields, &mut seen, field);
10457 }
10458 for field in &transient.angular_velocity_snapshots {
10459 push_analysis_result_field(&mut fields, &mut seen, field);
10460 }
10461 for field in &transient.acceleration_snapshots {
10462 push_analysis_result_field(&mut fields, &mut seen, field);
10463 }
10464 for field in &transient.angular_acceleration_snapshots {
10465 push_analysis_result_field(&mut fields, &mut seen, field);
10466 }
10467 for field in &transient.von_mises_snapshots {
10468 push_analysis_result_field(&mut fields, &mut seen, field);
10469 }
10470 for field in &transient.kinetic_energy_snapshots {
10471 push_analysis_result_field(&mut fields, &mut seen, field);
10472 }
10473 for field in &transient.strain_energy_snapshots {
10474 push_analysis_result_field(&mut fields, &mut seen, field);
10475 }
10476 for field in &transient.residual_norm_snapshots {
10477 push_analysis_result_field(&mut fields, &mut seen, field);
10478 }
10479 for field in &transient.thermo_mechanical_temperature_snapshots {
10480 push_analysis_result_field(&mut fields, &mut seen, field);
10481 }
10482 for field in &transient.thermo_mechanical_thermal_strain_snapshots {
10483 push_analysis_result_field(&mut fields, &mut seen, field);
10484 }
10485 for field in &transient.thermo_mechanical_thermal_stress_snapshots {
10486 push_analysis_result_field(&mut fields, &mut seen, field);
10487 }
10488 for field in &transient.thermo_mechanical_displacement_snapshots {
10489 push_analysis_result_field(&mut fields, &mut seen, field);
10490 }
10491 for field in &transient.thermo_mechanical_von_mises_snapshots {
10492 push_analysis_result_field(&mut fields, &mut seen, field);
10493 }
10494 for field in &transient.thermo_mechanical_coupling_residual_snapshots {
10495 push_analysis_result_field(&mut fields, &mut seen, field);
10496 }
10497 for field in &transient.electro_thermal_temperature_snapshots {
10498 push_analysis_result_field(&mut fields, &mut seen, field);
10499 }
10500 for field in &transient.electro_thermal_thermal_residual_snapshots {
10501 push_analysis_result_field(&mut fields, &mut seen, field);
10502 }
10503 }
10504
10505 if let Some(nonlinear) = run_result.nonlinear_results.as_ref() {
10506 for field in &nonlinear.displacement_snapshots {
10507 push_analysis_result_field(&mut fields, &mut seen, field);
10508 }
10509 for field in &nonlinear.rotation_snapshots {
10510 push_analysis_result_field(&mut fields, &mut seen, field);
10511 }
10512 for field in &nonlinear.von_mises_snapshots {
10513 push_analysis_result_field(&mut fields, &mut seen, field);
10514 }
10515 for field in &nonlinear.plastic_strain_snapshots {
10516 push_analysis_result_field(&mut fields, &mut seen, field);
10517 }
10518 for field in &nonlinear.equivalent_plastic_strain_snapshots {
10519 push_analysis_result_field(&mut fields, &mut seen, field);
10520 }
10521 for field in &nonlinear.contact_pressure_snapshots {
10522 push_analysis_result_field(&mut fields, &mut seen, field);
10523 }
10524 for field in &nonlinear.contact_gap_snapshots {
10525 push_analysis_result_field(&mut fields, &mut seen, field);
10526 }
10527 for field in &nonlinear.load_factor_snapshots {
10528 push_analysis_result_field(&mut fields, &mut seen, field);
10529 }
10530 for field in &nonlinear.residual_norm_snapshots {
10531 push_analysis_result_field(&mut fields, &mut seen, field);
10532 }
10533 for field in &nonlinear.thermo_mechanical_temperature_snapshots {
10534 push_analysis_result_field(&mut fields, &mut seen, field);
10535 }
10536 for field in &nonlinear.thermo_mechanical_thermal_strain_snapshots {
10537 push_analysis_result_field(&mut fields, &mut seen, field);
10538 }
10539 for field in &nonlinear.thermo_mechanical_thermal_stress_snapshots {
10540 push_analysis_result_field(&mut fields, &mut seen, field);
10541 }
10542 for field in &nonlinear.thermo_mechanical_displacement_snapshots {
10543 push_analysis_result_field(&mut fields, &mut seen, field);
10544 }
10545 for field in &nonlinear.thermo_mechanical_von_mises_snapshots {
10546 push_analysis_result_field(&mut fields, &mut seen, field);
10547 }
10548 for field in &nonlinear.thermo_mechanical_coupling_residual_snapshots {
10549 push_analysis_result_field(&mut fields, &mut seen, field);
10550 }
10551 for field in &nonlinear.electro_thermal_temperature_snapshots {
10552 push_analysis_result_field(&mut fields, &mut seen, field);
10553 }
10554 for field in &nonlinear.electro_thermal_thermal_residual_snapshots {
10555 push_analysis_result_field(&mut fields, &mut seen, field);
10556 }
10557 }
10558
10559 if let Some(electromagnetic) = run_result.electromagnetic_results.as_ref() {
10560 for field in [
10561 &electromagnetic.vector_potential_real,
10562 &electromagnetic.vector_potential_imag,
10563 &electromagnetic.magnetic_flux_density_real,
10564 &electromagnetic.magnetic_flux_density_imag,
10565 &electromagnetic.magnetic_flux_density_magnitude,
10566 &electromagnetic.magnetic_field_real,
10567 &electromagnetic.magnetic_field_imag,
10568 &electromagnetic.current_density_real,
10569 &electromagnetic.current_density_imag,
10570 &electromagnetic.electric_field_real,
10571 &electromagnetic.electric_field_imag,
10572 &electromagnetic.power_loss_density,
10573 &electromagnetic.energy_density,
10574 &electromagnetic.residual_real,
10575 &electromagnetic.residual_imag,
10576 &electromagnetic.electric_flux_density_real,
10577 &electromagnetic.electric_flux_density_imag,
10578 &electromagnetic.poynting_vector_real,
10579 &electromagnetic.poynting_vector_imag,
10580 ] {
10581 push_analysis_result_field(&mut fields, &mut seen, field);
10582 }
10583 }
10584
10585 fields
10586}
10587
10588fn push_analysis_result_field(
10589 fields: &mut Vec<AnalysisField>,
10590 seen: &mut HashSet<String>,
10591 field: &AnalysisField,
10592) {
10593 if !seen.insert(field.field_id.clone()) {
10594 return;
10595 }
10596 fields.push(field.clone());
10597}
10598
10599fn filter_analysis_fields_by_indices(
10600 fields: &[AnalysisField],
10601 indices: &[usize],
10602) -> Vec<AnalysisField> {
10603 if fields.is_empty() {
10604 return Vec::new();
10605 }
10606 indices
10607 .iter()
10608 .filter_map(|index| fields.get(*index).cloned())
10609 .collect()
10610}
10611
10612pub(crate) fn analysis_run_field_ids(run_result: &AnalysisRunResult) -> Vec<String> {
10613 collect_analysis_result_fields(run_result)
10614 .into_iter()
10615 .map(|field| field.field_id)
10616 .collect()
10617}
10618
10619pub fn analysis_results_op(
10620 run_result: &AnalysisRunResult,
10621 query: AnalysisResultsQuery,
10622 context: OperationContext,
10623) -> Result<OperationEnvelope<AnalysisResultsData>, OperationErrorEnvelope> {
10624 let mut collected_fields = collect_analysis_result_fields(run_result);
10625
10626 if !query.include_fields.is_empty() {
10627 let mut filtered = Vec::new();
10628 for requested in &query.include_fields {
10629 let Some(field) = collected_fields
10630 .iter()
10631 .find(|field| &field.field_id == requested)
10632 else {
10633 return Err(operation_error(
10634 ANALYSIS_RESULTS_OPERATION,
10635 ANALYSIS_RESULTS_OP_VERSION,
10636 &context,
10637 OperationErrorSpec {
10638 error_code: "RM.FEA.RESULTS.FIELD_NOT_FOUND",
10639 error_type: OperationErrorType::Input,
10640 retryable: false,
10641 severity: OperationErrorSeverity::Error,
10642 },
10643 format!("requested FEA field '{requested}' was not produced by run"),
10644 BTreeMap::from([
10645 ("requested_field".to_string(), requested.clone()),
10646 (
10647 "available_fields".to_string(),
10648 collected_fields
10649 .iter()
10650 .map(|field| field.field_id.clone())
10651 .collect::<Vec<_>>()
10652 .join(","),
10653 ),
10654 ]),
10655 ));
10656 };
10657 filtered.push(field.clone());
10658 }
10659 collected_fields = filtered;
10660 }
10661 let field_descriptors = collected_fields
10662 .iter()
10663 .map(AnalysisFieldDescriptor::from_field)
10664 .collect::<Vec<_>>();
10665
10666 let (
10667 mode_count,
10668 available_mode_indices,
10669 min_frequency_hz,
10670 max_frequency_hz,
10671 max_modal_residual_norm,
10672 first_mode_converged,
10673 ) = if let Some(modal) = run_result.modal_results.as_ref() {
10674 let count = modal.eigenvalues_hz.len().min(modal.mode_shapes.len());
10675 let max_modal_residual_norm = modal.residual_norms.iter().copied().reduce(f64::max);
10676 let first_mode_converged = modal.residual_norms.first().copied().map(|v| v <= 1.0e-6);
10677 let (min_frequency_hz, max_frequency_hz) = if count == 0 {
10678 (None, None)
10679 } else {
10680 let mut min_value = f64::INFINITY;
10681 let mut max_value = f64::NEG_INFINITY;
10682 for value in modal.eigenvalues_hz.iter().copied().take(count) {
10683 min_value = min_value.min(value);
10684 max_value = max_value.max(value);
10685 }
10686 (Some(min_value), Some(max_value))
10687 };
10688 (
10689 count,
10690 (0..count).collect(),
10691 min_frequency_hz,
10692 max_frequency_hz,
10693 max_modal_residual_norm,
10694 first_mode_converged,
10695 )
10696 } else {
10697 (0, Vec::new(), None, None, None, None)
10698 };
10699
10700 let (
10701 snapshot_count,
10702 time_start_s,
10703 time_end_s,
10704 max_transient_residual_norm,
10705 final_step_converged,
10706 ) = if let Some(transient) = run_result.transient_results.as_ref() {
10707 let count = transient
10708 .time_points_s
10709 .len()
10710 .min(transient.displacement_snapshots.len());
10711 let max_residual = transient.residual_norms.iter().copied().reduce(f64::max);
10712 let final_step_converged = max_residual.map(|value| value <= 1.0e-6);
10713 if count == 0 {
10714 (0, None, None, max_residual, final_step_converged)
10715 } else {
10716 (
10717 count,
10718 transient.time_points_s.first().copied(),
10719 transient.time_points_s.get(count - 1).copied(),
10720 max_residual,
10721 final_step_converged,
10722 )
10723 }
10724 } else if let Some(thermal) = run_result.thermal_results.as_ref() {
10725 let count = thermal
10726 .time_points_s
10727 .len()
10728 .min(thermal.temperature_snapshots.len());
10729 let max_residual = thermal.residual_norms.iter().copied().reduce(f64::max);
10730 let final_step_converged = max_residual.map(|value| value <= 1.0e-6);
10731 if count == 0 {
10732 (0, None, None, max_residual, final_step_converged)
10733 } else {
10734 (
10735 count,
10736 thermal.time_points_s.first().copied(),
10737 thermal.time_points_s.get(count - 1).copied(),
10738 max_residual,
10739 final_step_converged,
10740 )
10741 }
10742 } else {
10743 (0, None, None, None, None)
10744 };
10745
10746 let (
10747 increment_count,
10748 failed_increment_count,
10749 max_nonlinear_residual_norm,
10750 max_nonlinear_increment_norm,
10751 max_nonlinear_iteration_count,
10752 final_increment_converged,
10753 nonlinear_line_search_backtracks,
10754 nonlinear_max_backtracks_per_increment,
10755 nonlinear_tangent_rebuild_count,
10756 nonlinear_iteration_spike_count,
10757 nonlinear_convergence_stall_count,
10758 nonlinear_backtrack_burst_count,
10759 ) = if let Some(nonlinear) = run_result.nonlinear_results.as_ref() {
10760 let count = nonlinear.load_factors.len();
10761 let max_residual = nonlinear.residual_norms.iter().copied().reduce(f64::max);
10762 let max_increment_norm = nonlinear.increment_norms.iter().copied().reduce(f64::max);
10763 let max_iteration_count = nonlinear.iteration_counts.iter().copied().max();
10764 let final_converged =
10765 max_residual.map(|value| value <= 1.0e-6 && nonlinear.failed_increments == 0);
10766 (
10767 count,
10768 Some(nonlinear.failed_increments),
10769 max_residual,
10770 max_increment_norm,
10771 max_iteration_count,
10772 final_converged,
10773 Some(nonlinear.line_search_backtracks),
10774 Some(nonlinear.max_line_search_backtracks_per_increment),
10775 Some(nonlinear.tangent_rebuild_count),
10776 Some(nonlinear.iteration_spike_count),
10777 Some(nonlinear.convergence_stall_count),
10778 Some(nonlinear.backtrack_burst_count),
10779 )
10780 } else {
10781 (
10782 0, None, None, None, None, None, None, None, None, None, None, None,
10783 )
10784 };
10785
10786 let prep_calibration_profile = diagnostic_metric_string(
10787 &run_result.run.diagnostics,
10788 "FEA_PREP_CALIBRATION",
10789 "profile",
10790 );
10791 let prep_calibration_fingerprint = diagnostic_metric_u64(
10792 &run_result.run.diagnostics,
10793 "FEA_PREP_CALIBRATION",
10794 "calibration_fingerprint",
10795 );
10796 let prep_acceptance_score = diagnostic_metric(
10797 &run_result.run.diagnostics,
10798 "FEA_PREP_ACCEPTANCE",
10799 "acceptance_score",
10800 );
10801 let prep_acceptance_passed = diagnostic_metric_bool(
10802 &run_result.run.diagnostics,
10803 "FEA_PREP_ACCEPTANCE",
10804 "accepted",
10805 );
10806 let prep_acceptance_fingerprint = diagnostic_metric_u64(
10807 &run_result.run.diagnostics,
10808 "FEA_PREP_ACCEPTANCE",
10809 "acceptance_fingerprint",
10810 );
10811 let thermo_coupling_enabled =
10812 diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_TM_COUPLING", "enabled");
10813 let thermo_coupling_fingerprint = diagnostic_metric_u64(
10814 &run_result.run.diagnostics,
10815 "FEA_TM_COUPLING",
10816 "coupling_fingerprint",
10817 );
10818 let thermo_constitutive_temperature_factor = diagnostic_metric(
10819 &run_result.run.diagnostics,
10820 "FEA_TM_COUPLING",
10821 "constitutive_temperature_factor",
10822 );
10823 let thermo_effective_modulus_scale = diagnostic_metric(
10824 &run_result.run.diagnostics,
10825 "FEA_TM_COUPLING",
10826 "effective_modulus_scale",
10827 );
10828 let thermo_constitutive_material_spread_ratio = diagnostic_metric(
10829 &run_result.run.diagnostics,
10830 "FEA_TM_COUPLING",
10831 "constitutive_material_spread_ratio",
10832 );
10833 let thermo_assignment_heterogeneity_index = diagnostic_metric(
10834 &run_result.run.diagnostics,
10835 "FEA_TM_COUPLING",
10836 "assignment_heterogeneity_index",
10837 );
10838 let thermo_region_delta_count = diagnostic_metric(
10839 &run_result.run.diagnostics,
10840 "FEA_TM_COUPLING",
10841 "region_delta_count",
10842 );
10843 let thermo_spatial_coverage_ratio = diagnostic_metric(
10844 &run_result.run.diagnostics,
10845 "FEA_TM_COUPLING",
10846 "spatial_coverage_ratio",
10847 );
10848 let thermo_field_extrapolation_ratio = diagnostic_metric(
10849 &run_result.run.diagnostics,
10850 "FEA_TM_TRANSIENT",
10851 "field_extrapolation_ratio",
10852 )
10853 .or_else(|| {
10854 diagnostic_metric(
10855 &run_result.run.diagnostics,
10856 "FEA_TM_NONLINEAR",
10857 "field_extrapolation_ratio",
10858 )
10859 });
10860 let thermo_field_clamp_ratio = diagnostic_metric(
10861 &run_result.run.diagnostics,
10862 "FEA_TM_TRANSIENT",
10863 "field_clamp_ratio",
10864 )
10865 .or_else(|| {
10866 diagnostic_metric(
10867 &run_result.run.diagnostics,
10868 "FEA_TM_NONLINEAR",
10869 "field_clamp_ratio",
10870 )
10871 });
10872 let thermo_transient_severity = diagnostic_metric(
10873 &run_result.run.diagnostics,
10874 "FEA_TM_TRANSIENT",
10875 "severity_peak",
10876 )
10877 .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_TM_TRANSIENT", "severity"));
10878 let thermo_nonlinear_severity = diagnostic_metric(
10879 &run_result.run.diagnostics,
10880 "FEA_TM_NONLINEAR",
10881 "severity_peak",
10882 )
10883 .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_TM_NONLINEAR", "severity"));
10884 let electro_thermal_coupling_enabled =
10885 diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_ET_COUPLING", "enabled");
10886 let electro_thermal_coupling_fingerprint = diagnostic_metric_u64(
10887 &run_result.run.diagnostics,
10888 "FEA_ET_COUPLING",
10889 "coupling_fingerprint",
10890 );
10891 let electro_joule_heating_scale = diagnostic_metric(
10892 &run_result.run.diagnostics,
10893 "FEA_ET_COUPLING",
10894 "joule_heating_scale",
10895 );
10896 let electro_conductivity_spread_ratio = diagnostic_metric(
10897 &run_result.run.diagnostics,
10898 "FEA_ET_COUPLING",
10899 "conductivity_spread_ratio",
10900 );
10901 let electro_transient_severity = diagnostic_metric(
10902 &run_result.run.diagnostics,
10903 "FEA_ET_TRANSIENT",
10904 "severity_peak",
10905 )
10906 .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_ET_TRANSIENT", "severity"));
10907 let electro_transient_time_scale_mean = diagnostic_metric(
10908 &run_result.run.diagnostics,
10909 "FEA_ET_TRANSIENT",
10910 "time_scale_mean",
10911 );
10912 let electro_nonlinear_severity = diagnostic_metric(
10913 &run_result.run.diagnostics,
10914 "FEA_ET_NONLINEAR",
10915 "severity_peak",
10916 )
10917 .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_ET_NONLINEAR", "severity"));
10918 let electro_nonlinear_time_scale_mean = diagnostic_metric(
10919 &run_result.run.diagnostics,
10920 "FEA_ET_NONLINEAR",
10921 "time_scale_mean",
10922 );
10923 let plastic_nonlinear_severity = diagnostic_metric(
10924 &run_result.run.diagnostics,
10925 "FEA_PLASTIC_NONLINEAR",
10926 "severity_peak",
10927 )
10928 .or_else(|| {
10929 diagnostic_metric(
10930 &run_result.run.diagnostics,
10931 "FEA_PLASTIC_NONLINEAR",
10932 "severity",
10933 )
10934 });
10935 let plastic_nonlinear_severity_mean = diagnostic_metric(
10936 &run_result.run.diagnostics,
10937 "FEA_PLASTIC_NONLINEAR",
10938 "severity_mean",
10939 );
10940 let plastic_load_realization_ratio = diagnostic_metric(
10941 &run_result.run.diagnostics,
10942 "FEA_PLASTIC_NONLINEAR",
10943 "load_realization_ratio",
10944 );
10945 let plastic_load_amplification_ratio = diagnostic_metric(
10946 &run_result.run.diagnostics,
10947 "FEA_PLASTIC_NONLINEAR",
10948 "load_amplification_ratio",
10949 );
10950 let contact_nonlinear_severity = diagnostic_metric(
10951 &run_result.run.diagnostics,
10952 "FEA_CONTACT_NONLINEAR",
10953 "severity_peak",
10954 )
10955 .or_else(|| {
10956 diagnostic_metric(
10957 &run_result.run.diagnostics,
10958 "FEA_CONTACT_NONLINEAR",
10959 "severity",
10960 )
10961 });
10962 let contact_nonlinear_severity_mean = diagnostic_metric(
10963 &run_result.run.diagnostics,
10964 "FEA_CONTACT_NONLINEAR",
10965 "severity_mean",
10966 );
10967 let contact_load_realization_ratio = diagnostic_metric(
10968 &run_result.run.diagnostics,
10969 "FEA_CONTACT_NONLINEAR",
10970 "load_realization_ratio",
10971 );
10972 let contact_load_amplification_ratio = diagnostic_metric(
10973 &run_result.run.diagnostics,
10974 "FEA_CONTACT_NONLINEAR",
10975 "load_amplification_ratio",
10976 );
10977 let thermal_max_residual_norm = diagnostic_metric(
10978 &run_result.run.diagnostics,
10979 "FEA_THERMAL_STABILITY",
10980 "max_residual_norm",
10981 );
10982 let thermal_min_temperature_k = diagnostic_metric(
10983 &run_result.run.diagnostics,
10984 "FEA_THERMAL_STABILITY",
10985 "min_temperature_k",
10986 );
10987 let thermal_max_temperature_k = diagnostic_metric(
10988 &run_result.run.diagnostics,
10989 "FEA_THERMAL_STABILITY",
10990 "max_temperature_k",
10991 );
10992 let thermal_conductivity_spread_ratio = diagnostic_metric(
10993 &run_result.run.diagnostics,
10994 "FEA_THERMAL_CONSTITUTIVE",
10995 "conductivity_spread_ratio",
10996 );
10997 let thermal_heat_capacity_spread_ratio = diagnostic_metric(
10998 &run_result.run.diagnostics,
10999 "FEA_THERMAL_CONSTITUTIVE",
11000 "heat_capacity_spread_ratio",
11001 );
11002 let thermal_spatial_gradient_index = diagnostic_metric(
11003 &run_result.run.diagnostics,
11004 "FEA_THERMAL_OUTCOME",
11005 "spatial_gradient_index",
11006 );
11007 let thermal_monotonic_response_fraction = diagnostic_metric(
11008 &run_result.run.diagnostics,
11009 "FEA_THERMAL_OUTCOME",
11010 "monotonic_response_fraction",
11011 );
11012 let thermal_response_realization_ratio = diagnostic_metric(
11013 &run_result.run.diagnostics,
11014 "FEA_THERMAL_OUTCOME",
11015 "thermal_response_realization_ratio",
11016 );
11017 let electromagnetic_enabled =
11018 diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_EM_STATIC", "enabled");
11019 let electromagnetic_formulation_coverage_ratio = diagnostic_metric(
11020 &run_result.run.diagnostics,
11021 "FEA_EM_FORMULATION",
11022 "formulation_coverage_ratio",
11023 );
11024 let electromagnetic_magnetostatic_curl_curl_coverage_ratio = diagnostic_metric(
11025 &run_result.run.diagnostics,
11026 "FEA_EM_FORMULATION",
11027 "magnetostatic_curl_curl_coverage_ratio",
11028 );
11029 let electromagnetic_magnetoquasistatic_eddy_current_coverage_ratio = diagnostic_metric(
11030 &run_result.run.diagnostics,
11031 "FEA_EM_FORMULATION",
11032 "magnetoquasistatic_eddy_current_coverage_ratio",
11033 );
11034 let electromagnetic_full_wave_displacement_current_coverage_ratio = diagnostic_metric(
11035 &run_result.run.diagnostics,
11036 "FEA_EM_FORMULATION",
11037 "full_wave_displacement_current_coverage_ratio",
11038 );
11039 let electromagnetic_displacement_to_conduction_ratio = diagnostic_metric(
11040 &run_result.run.diagnostics,
11041 "FEA_EM_FORMULATION",
11042 "displacement_to_conduction_ratio",
11043 );
11044 let electromagnetic_material_frequency_response_coverage_ratio = diagnostic_metric(
11045 &run_result.run.diagnostics,
11046 "FEA_EM_FORMULATION",
11047 "material_frequency_response_coverage_ratio",
11048 );
11049 let electromagnetic_reference_frequency_hz = diagnostic_metric(
11050 &run_result.run.diagnostics,
11051 "FEA_EM_STATIC",
11052 "reference_frequency_hz",
11053 );
11054 let electromagnetic_applied_current_a = diagnostic_metric(
11055 &run_result.run.diagnostics,
11056 "FEA_EM_STATIC",
11057 "applied_current_a",
11058 );
11059 let electromagnetic_solve_quality = diagnostic_metric(
11060 &run_result.run.diagnostics,
11061 "FEA_EM_STATIC",
11062 "solve_quality",
11063 );
11064 let electromagnetic_conductivity_spread_ratio = diagnostic_metric(
11065 &run_result.run.diagnostics,
11066 "FEA_EM_STATIC",
11067 "conductivity_spread_ratio",
11068 );
11069 let electromagnetic_relative_permittivity_spread_ratio = diagnostic_metric(
11070 &run_result.run.diagnostics,
11071 "FEA_EM_STATIC",
11072 "relative_permittivity_spread_ratio",
11073 );
11074 let electromagnetic_relative_permeability_spread_ratio = diagnostic_metric(
11075 &run_result.run.diagnostics,
11076 "FEA_EM_STATIC",
11077 "relative_permeability_spread_ratio",
11078 );
11079 let electromagnetic_material_heterogeneity_index = diagnostic_metric(
11080 &run_result.run.diagnostics,
11081 "FEA_EM_STATIC",
11082 "electromagnetic_material_heterogeneity_index",
11083 );
11084 let electromagnetic_assignment_coverage_ratio = diagnostic_metric(
11085 &run_result.run.diagnostics,
11086 "FEA_EM_STATIC",
11087 "assignment_coverage_ratio",
11088 );
11089 let electromagnetic_assigned_coefficient_coverage_ratio = diagnostic_metric(
11090 &run_result.run.diagnostics,
11091 "FEA_EM_STATIC",
11092 "assigned_coefficient_coverage_ratio",
11093 );
11094 let electromagnetic_region_coefficient_contrast_index = diagnostic_metric(
11095 &run_result.run.diagnostics,
11096 "FEA_EM_STATIC",
11097 "region_coefficient_contrast_index",
11098 );
11099 let electromagnetic_condition_number_estimate = diagnostic_metric(
11100 &run_result.run.diagnostics,
11101 "FEA_EM_STATIC",
11102 "condition_number_estimate",
11103 );
11104 let electromagnetic_source_realization_ratio = diagnostic_metric(
11105 &run_result.run.diagnostics,
11106 "FEA_EM_SOURCE_ENERGY",
11107 "source_realization_ratio",
11108 );
11109 let electromagnetic_source_region_coverage_ratio = diagnostic_metric(
11110 &run_result.run.diagnostics,
11111 "FEA_EM_SOURCE_ENERGY",
11112 "source_region_coverage_ratio",
11113 );
11114 let electromagnetic_source_material_alignment_ratio = diagnostic_metric(
11115 &run_result.run.diagnostics,
11116 "FEA_EM_SOURCE_ENERGY",
11117 "source_material_alignment_ratio",
11118 );
11119 let electromagnetic_source_localization_ratio = diagnostic_metric(
11120 &run_result.run.diagnostics,
11121 "FEA_EM_SOURCE_ENERGY",
11122 "source_localization_ratio",
11123 );
11124 let electromagnetic_source_overlap_ratio = diagnostic_metric(
11125 &run_result.run.diagnostics,
11126 "FEA_EM_SOURCE_ENERGY",
11127 "source_overlap_ratio",
11128 );
11129 let electromagnetic_source_interference_index = diagnostic_metric(
11130 &run_result.run.diagnostics,
11131 "FEA_EM_SOURCE_ENERGY",
11132 "source_interference_index",
11133 );
11134 let electromagnetic_boundary_anchor_ratio = diagnostic_metric(
11135 &run_result.run.diagnostics,
11136 "FEA_EM_SOURCE_ENERGY",
11137 "boundary_anchor_ratio",
11138 );
11139 let electromagnetic_boundary_condition_localization_ratio = diagnostic_metric(
11140 &run_result.run.diagnostics,
11141 "FEA_EM_SOURCE_ENERGY",
11142 "boundary_condition_localization_ratio",
11143 );
11144 let electromagnetic_ground_anchor_effectiveness_ratio = diagnostic_metric(
11145 &run_result.run.diagnostics,
11146 "FEA_EM_SOURCE_ENERGY",
11147 "ground_anchor_effectiveness_ratio",
11148 );
11149 let electromagnetic_insulation_leakage_ratio = diagnostic_metric(
11150 &run_result.run.diagnostics,
11151 "FEA_EM_SOURCE_ENERGY",
11152 "insulation_leakage_ratio",
11153 );
11154 let electromagnetic_flux_divergence_ratio = diagnostic_metric(
11155 &run_result.run.diagnostics,
11156 "FEA_EM_STATIC",
11157 "flux_divergence_ratio",
11158 );
11159 let electromagnetic_energy_imbalance_ratio = diagnostic_metric(
11160 &run_result.run.diagnostics,
11161 "FEA_EM_SOURCE_ENERGY",
11162 "energy_imbalance_ratio",
11163 );
11164 let electromagnetic_boundary_energy_ratio = diagnostic_metric(
11165 &run_result.run.diagnostics,
11166 "FEA_EM_SOURCE_ENERGY",
11167 "boundary_energy_ratio",
11168 );
11169 let electromagnetic_boundary_penalty_conditioning_contribution = diagnostic_metric(
11170 &run_result.run.diagnostics,
11171 "FEA_EM_SOURCE_ENERGY",
11172 "boundary_penalty_conditioning_contribution",
11173 );
11174 let electromagnetic_source_region_energy_consistency_ratio = diagnostic_metric(
11175 &run_result.run.diagnostics,
11176 "FEA_EM_SOURCE_ENERGY",
11177 "source_region_energy_consistency_ratio",
11178 );
11179 let electromagnetic_real_residual_norm = diagnostic_metric(
11180 &run_result.run.diagnostics,
11181 "FEA_EM_STATIC",
11182 "real_residual_norm",
11183 );
11184 let electromagnetic_imag_residual_norm = diagnostic_metric(
11185 &run_result.run.diagnostics,
11186 "FEA_EM_STATIC",
11187 "imag_residual_norm",
11188 );
11189 let electromagnetic_sweep_count =
11190 diagnostic_metric(&run_result.run.diagnostics, "FEA_EM_SWEEP", "sweep_count");
11191 let electromagnetic_resonance_peak_frequency_hz = diagnostic_metric(
11192 &run_result.run.diagnostics,
11193 "FEA_EM_SWEEP",
11194 "resonance_peak_frequency_hz",
11195 );
11196 let electromagnetic_resonance_peak_flux_density = diagnostic_metric(
11197 &run_result.run.diagnostics,
11198 "FEA_EM_SWEEP",
11199 "resonance_peak_flux_density",
11200 );
11201 let electromagnetic_resonance_bandwidth_hz = diagnostic_metric(
11202 &run_result.run.diagnostics,
11203 "FEA_EM_SWEEP",
11204 "resonance_bandwidth_hz",
11205 );
11206 let electromagnetic_resonance_quality_factor = diagnostic_metric(
11207 &run_result.run.diagnostics,
11208 "FEA_EM_SWEEP",
11209 "resonance_quality_factor",
11210 );
11211 let electromagnetic_resonance_flux_gain = diagnostic_metric(
11212 &run_result.run.diagnostics,
11213 "FEA_EM_SWEEP",
11214 "resonance_flux_gain",
11215 );
11216
11217 let summary = AnalysisResultsSummary {
11218 field_count: field_descriptors.len(),
11219 total_elements: field_descriptors
11220 .iter()
11221 .map(|field| field.element_count)
11222 .sum(),
11223 mode_count,
11224 available_mode_indices,
11225 min_frequency_hz,
11226 max_frequency_hz,
11227 max_modal_residual_norm,
11228 first_mode_converged,
11229 snapshot_count,
11230 time_start_s,
11231 time_end_s,
11232 max_transient_residual_norm,
11233 final_step_converged,
11234 increment_count,
11235 failed_increment_count,
11236 max_nonlinear_residual_norm,
11237 max_nonlinear_increment_norm,
11238 max_nonlinear_iteration_count,
11239 final_increment_converged,
11240 nonlinear_line_search_backtracks,
11241 nonlinear_max_backtracks_per_increment,
11242 nonlinear_tangent_rebuild_count,
11243 nonlinear_iteration_spike_count,
11244 nonlinear_convergence_stall_count,
11245 nonlinear_backtrack_burst_count,
11246 prep_calibration_profile,
11247 prep_calibration_fingerprint,
11248 prep_acceptance_score,
11249 prep_acceptance_passed,
11250 prep_acceptance_fingerprint,
11251 thermo_coupling_enabled,
11252 thermo_coupling_fingerprint,
11253 thermo_constitutive_temperature_factor,
11254 thermo_effective_modulus_scale,
11255 thermo_constitutive_material_spread_ratio,
11256 thermo_assignment_heterogeneity_index,
11257 thermo_region_delta_count,
11258 thermo_spatial_coverage_ratio,
11259 thermo_field_extrapolation_ratio,
11260 thermo_field_clamp_ratio,
11261 thermo_transient_severity,
11262 thermo_nonlinear_severity,
11263 electro_thermal_coupling_enabled,
11264 electro_thermal_coupling_fingerprint,
11265 electro_joule_heating_scale,
11266 electro_conductivity_spread_ratio,
11267 electro_transient_severity,
11268 electro_transient_time_scale_mean,
11269 electro_nonlinear_severity,
11270 electro_nonlinear_time_scale_mean,
11271 plastic_nonlinear_severity,
11272 plastic_nonlinear_severity_mean,
11273 plastic_load_realization_ratio,
11274 plastic_load_amplification_ratio,
11275 contact_nonlinear_severity,
11276 contact_nonlinear_severity_mean,
11277 contact_load_realization_ratio,
11278 contact_load_amplification_ratio,
11279 thermal_max_residual_norm,
11280 thermal_min_temperature_k,
11281 thermal_max_temperature_k,
11282 thermal_conductivity_spread_ratio,
11283 thermal_heat_capacity_spread_ratio,
11284 thermal_spatial_gradient_index,
11285 thermal_monotonic_response_fraction,
11286 thermal_response_realization_ratio,
11287 electromagnetic_enabled,
11288 electromagnetic_formulation_coverage_ratio,
11289 electromagnetic_magnetostatic_curl_curl_coverage_ratio,
11290 electromagnetic_magnetoquasistatic_eddy_current_coverage_ratio,
11291 electromagnetic_full_wave_displacement_current_coverage_ratio,
11292 electromagnetic_displacement_to_conduction_ratio,
11293 electromagnetic_material_frequency_response_coverage_ratio,
11294 electromagnetic_reference_frequency_hz,
11295 electromagnetic_applied_current_a,
11296 electromagnetic_solve_quality,
11297 electromagnetic_conductivity_spread_ratio,
11298 electromagnetic_relative_permittivity_spread_ratio,
11299 electromagnetic_relative_permeability_spread_ratio,
11300 electromagnetic_material_heterogeneity_index,
11301 electromagnetic_assignment_coverage_ratio,
11302 electromagnetic_assigned_coefficient_coverage_ratio,
11303 electromagnetic_region_coefficient_contrast_index,
11304 electromagnetic_condition_number_estimate,
11305 electromagnetic_source_realization_ratio,
11306 electromagnetic_source_region_coverage_ratio,
11307 electromagnetic_source_material_alignment_ratio,
11308 electromagnetic_source_localization_ratio,
11309 electromagnetic_source_overlap_ratio,
11310 electromagnetic_source_interference_index,
11311 electromagnetic_boundary_anchor_ratio,
11312 electromagnetic_boundary_condition_localization_ratio,
11313 electromagnetic_ground_anchor_effectiveness_ratio,
11314 electromagnetic_insulation_leakage_ratio,
11315 electromagnetic_flux_divergence_ratio,
11316 electromagnetic_energy_imbalance_ratio,
11317 electromagnetic_boundary_energy_ratio,
11318 electromagnetic_boundary_penalty_conditioning_contribution,
11319 electromagnetic_source_region_energy_consistency_ratio,
11320 electromagnetic_real_residual_norm,
11321 electromagnetic_imag_residual_norm,
11322 electromagnetic_sweep_count,
11323 electromagnetic_resonance_peak_frequency_hz,
11324 electromagnetic_resonance_peak_flux_density,
11325 electromagnetic_resonance_bandwidth_hz,
11326 electromagnetic_resonance_quality_factor,
11327 electromagnetic_resonance_flux_gain,
11328 };
11329
11330 let modal_results = if query.include_modal_results && query.include_field_values {
11331 if let Some(modal) = run_result.modal_results.as_ref() {
11332 if query.mode_indices.is_empty() {
11333 Some(modal.clone())
11334 } else {
11335 let mut eigenvalues_hz = Vec::with_capacity(query.mode_indices.len());
11336 let mut mode_shapes = Vec::with_capacity(query.mode_indices.len());
11337 let mut residual_norms = Vec::with_capacity(query.mode_indices.len());
11338 for &index in &query.mode_indices {
11339 let eigenvalue = modal.eigenvalues_hz.get(index).copied().ok_or_else(|| {
11340 operation_error(
11341 ANALYSIS_RESULTS_OPERATION,
11342 ANALYSIS_RESULTS_OP_VERSION,
11343 &context,
11344 OperationErrorSpec {
11345 error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
11346 error_type: OperationErrorType::Input,
11347 retryable: false,
11348 severity: OperationErrorSeverity::Error,
11349 },
11350 format!("requested modal mode index '{index}' was not produced by run"),
11351 BTreeMap::from([
11352 ("requested_mode_index".to_string(), index.to_string()),
11353 (
11354 "available_mode_count".to_string(),
11355 modal.eigenvalues_hz.len().to_string(),
11356 ),
11357 ]),
11358 )
11359 })?;
11360 let mode_shape = modal.mode_shapes.get(index).cloned().ok_or_else(|| {
11361 operation_error(
11362 ANALYSIS_RESULTS_OPERATION,
11363 ANALYSIS_RESULTS_OP_VERSION,
11364 &context,
11365 OperationErrorSpec {
11366 error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
11367 error_type: OperationErrorType::Input,
11368 retryable: false,
11369 severity: OperationErrorSeverity::Error,
11370 },
11371 format!(
11372 "requested modal mode index '{index}' is missing mode shape data"
11373 ),
11374 BTreeMap::from([
11375 ("requested_mode_index".to_string(), index.to_string()),
11376 (
11377 "available_shape_count".to_string(),
11378 modal.mode_shapes.len().to_string(),
11379 ),
11380 ]),
11381 )
11382 })?;
11383 let residual_norm =
11384 modal.residual_norms.get(index).copied().ok_or_else(|| {
11385 operation_error(
11386 ANALYSIS_RESULTS_OPERATION,
11387 ANALYSIS_RESULTS_OP_VERSION,
11388 &context,
11389 OperationErrorSpec {
11390 error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
11391 error_type: OperationErrorType::Input,
11392 retryable: false,
11393 severity: OperationErrorSeverity::Error,
11394 },
11395 format!(
11396 "requested modal mode index '{index}' is missing residual data"
11397 ),
11398 BTreeMap::from([
11399 ("requested_mode_index".to_string(), index.to_string()),
11400 (
11401 "available_residual_count".to_string(),
11402 modal.residual_norms.len().to_string(),
11403 ),
11404 ]),
11405 )
11406 })?;
11407 eigenvalues_hz.push(eigenvalue);
11408 mode_shapes.push(mode_shape);
11409 residual_norms.push(residual_norm);
11410 }
11411 Some(ModalResultsData {
11412 modal_payload_version: modal.modal_payload_version.clone(),
11413 eigenvalues_hz,
11414 mode_shapes,
11415 residual_norms,
11416 mode_units: modal.mode_units,
11417 frequency_basis: modal.frequency_basis,
11418 })
11419 }
11420 } else {
11421 None
11422 }
11423 } else {
11424 None
11425 };
11426
11427 let transient_results = if query.include_transient_results && query.include_field_values {
11428 if let Some(transient) = run_result.transient_results.as_ref() {
11429 if query.transient_snapshot_indices.is_empty() {
11430 Some(transient.clone())
11431 } else {
11432 let mut time_points_s = Vec::with_capacity(query.transient_snapshot_indices.len());
11433 let mut displacement_snapshots =
11434 Vec::with_capacity(query.transient_snapshot_indices.len());
11435 let rotation_snapshots = filter_analysis_fields_by_indices(
11436 &transient.rotation_snapshots,
11437 &query.transient_snapshot_indices,
11438 );
11439 let mut velocity_snapshots =
11440 Vec::with_capacity(query.transient_snapshot_indices.len());
11441 let angular_velocity_snapshots = filter_analysis_fields_by_indices(
11442 &transient.angular_velocity_snapshots,
11443 &query.transient_snapshot_indices,
11444 );
11445 let mut acceleration_snapshots =
11446 Vec::with_capacity(query.transient_snapshot_indices.len());
11447 let angular_acceleration_snapshots = filter_analysis_fields_by_indices(
11448 &transient.angular_acceleration_snapshots,
11449 &query.transient_snapshot_indices,
11450 );
11451 let mut von_mises_snapshots =
11452 Vec::with_capacity(query.transient_snapshot_indices.len());
11453 let mut kinetic_energy_snapshots =
11454 Vec::with_capacity(query.transient_snapshot_indices.len());
11455 let mut strain_energy_snapshots =
11456 Vec::with_capacity(query.transient_snapshot_indices.len());
11457 let mut residual_norm_snapshots =
11458 Vec::with_capacity(query.transient_snapshot_indices.len());
11459 let mut residual_norms = Vec::with_capacity(query.transient_snapshot_indices.len());
11460 let thermo_mechanical_temperature_snapshots = filter_analysis_fields_by_indices(
11461 &transient.thermo_mechanical_temperature_snapshots,
11462 &query.transient_snapshot_indices,
11463 );
11464 let thermo_mechanical_thermal_strain_snapshots = filter_analysis_fields_by_indices(
11465 &transient.thermo_mechanical_thermal_strain_snapshots,
11466 &query.transient_snapshot_indices,
11467 );
11468 let thermo_mechanical_thermal_stress_snapshots = filter_analysis_fields_by_indices(
11469 &transient.thermo_mechanical_thermal_stress_snapshots,
11470 &query.transient_snapshot_indices,
11471 );
11472 let thermo_mechanical_displacement_snapshots = filter_analysis_fields_by_indices(
11473 &transient.thermo_mechanical_displacement_snapshots,
11474 &query.transient_snapshot_indices,
11475 );
11476 let thermo_mechanical_von_mises_snapshots = filter_analysis_fields_by_indices(
11477 &transient.thermo_mechanical_von_mises_snapshots,
11478 &query.transient_snapshot_indices,
11479 );
11480 let thermo_mechanical_coupling_residual_snapshots =
11481 filter_analysis_fields_by_indices(
11482 &transient.thermo_mechanical_coupling_residual_snapshots,
11483 &query.transient_snapshot_indices,
11484 );
11485 let electro_thermal_temperature_snapshots = filter_analysis_fields_by_indices(
11486 &transient.electro_thermal_temperature_snapshots,
11487 &query.transient_snapshot_indices,
11488 );
11489 let electro_thermal_thermal_residual_snapshots = filter_analysis_fields_by_indices(
11490 &transient.electro_thermal_thermal_residual_snapshots,
11491 &query.transient_snapshot_indices,
11492 );
11493
11494 for &index in &query.transient_snapshot_indices {
11495 let time_point = transient.time_points_s.get(index).copied().ok_or_else(|| {
11496 operation_error(
11497 ANALYSIS_RESULTS_OPERATION,
11498 ANALYSIS_RESULTS_OP_VERSION,
11499 &context,
11500 OperationErrorSpec {
11501 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11502 error_type: OperationErrorType::Input,
11503 retryable: false,
11504 severity: OperationErrorSeverity::Error,
11505 },
11506 format!(
11507 "requested transient snapshot index '{index}' was not produced by run"
11508 ),
11509 BTreeMap::from([
11510 ("requested_snapshot_index".to_string(), index.to_string()),
11511 (
11512 "available_snapshot_count".to_string(),
11513 transient.time_points_s.len().to_string(),
11514 ),
11515 ]),
11516 )
11517 })?;
11518 let snapshot = transient
11519 .displacement_snapshots
11520 .get(index)
11521 .cloned()
11522 .ok_or_else(|| {
11523 operation_error(
11524 ANALYSIS_RESULTS_OPERATION,
11525 ANALYSIS_RESULTS_OP_VERSION,
11526 &context,
11527 OperationErrorSpec {
11528 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11529 error_type: OperationErrorType::Input,
11530 retryable: false,
11531 severity: OperationErrorSeverity::Error,
11532 },
11533 format!(
11534 "requested transient snapshot index '{index}' is missing displacement data"
11535 ),
11536 BTreeMap::from([
11537 ("requested_snapshot_index".to_string(), index.to_string()),
11538 (
11539 "available_displacement_snapshot_count".to_string(),
11540 transient.displacement_snapshots.len().to_string(),
11541 ),
11542 ]),
11543 )
11544 })?;
11545 let velocity = transient.velocity_snapshots.get(index).cloned().ok_or_else(|| {
11546 operation_error(
11547 ANALYSIS_RESULTS_OPERATION,
11548 ANALYSIS_RESULTS_OP_VERSION,
11549 &context,
11550 OperationErrorSpec {
11551 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11552 error_type: OperationErrorType::Input,
11553 retryable: false,
11554 severity: OperationErrorSeverity::Error,
11555 },
11556 format!(
11557 "requested transient snapshot index '{index}' is missing velocity data"
11558 ),
11559 BTreeMap::from([
11560 ("requested_snapshot_index".to_string(), index.to_string()),
11561 (
11562 "available_velocity_snapshot_count".to_string(),
11563 transient.velocity_snapshots.len().to_string(),
11564 ),
11565 ]),
11566 )
11567 })?;
11568 let acceleration =
11569 transient
11570 .acceleration_snapshots
11571 .get(index)
11572 .cloned()
11573 .ok_or_else(|| {
11574 operation_error(
11575 ANALYSIS_RESULTS_OPERATION,
11576 ANALYSIS_RESULTS_OP_VERSION,
11577 &context,
11578 OperationErrorSpec {
11579 error_code:
11580 "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11581 error_type: OperationErrorType::Input,
11582 retryable: false,
11583 severity: OperationErrorSeverity::Error,
11584 },
11585 format!(
11586 "requested transient snapshot index '{index}' is missing acceleration data"
11587 ),
11588 BTreeMap::from([
11589 ("requested_snapshot_index".to_string(), index.to_string()),
11590 (
11591 "available_acceleration_snapshot_count".to_string(),
11592 transient.acceleration_snapshots.len().to_string(),
11593 ),
11594 ]),
11595 )
11596 })?;
11597 let von_mises =
11598 transient
11599 .von_mises_snapshots
11600 .get(index)
11601 .cloned()
11602 .ok_or_else(|| {
11603 operation_error(
11604 ANALYSIS_RESULTS_OPERATION,
11605 ANALYSIS_RESULTS_OP_VERSION,
11606 &context,
11607 OperationErrorSpec {
11608 error_code:
11609 "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11610 error_type: OperationErrorType::Input,
11611 retryable: false,
11612 severity: OperationErrorSeverity::Error,
11613 },
11614 format!(
11615 "requested transient snapshot index '{index}' is missing von Mises data"
11616 ),
11617 BTreeMap::from([
11618 ("requested_snapshot_index".to_string(), index.to_string()),
11619 (
11620 "available_von_mises_snapshot_count".to_string(),
11621 transient.von_mises_snapshots.len().to_string(),
11622 ),
11623 ]),
11624 )
11625 })?;
11626 let kinetic_energy =
11627 transient
11628 .kinetic_energy_snapshots
11629 .get(index)
11630 .cloned()
11631 .ok_or_else(|| {
11632 operation_error(
11633 ANALYSIS_RESULTS_OPERATION,
11634 ANALYSIS_RESULTS_OP_VERSION,
11635 &context,
11636 OperationErrorSpec {
11637 error_code:
11638 "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11639 error_type: OperationErrorType::Input,
11640 retryable: false,
11641 severity: OperationErrorSeverity::Error,
11642 },
11643 format!(
11644 "requested transient snapshot index '{index}' is missing kinetic energy data"
11645 ),
11646 BTreeMap::from([
11647 ("requested_snapshot_index".to_string(), index.to_string()),
11648 (
11649 "available_kinetic_energy_snapshot_count".to_string(),
11650 transient.kinetic_energy_snapshots.len().to_string(),
11651 ),
11652 ]),
11653 )
11654 })?;
11655 let strain_energy =
11656 transient
11657 .strain_energy_snapshots
11658 .get(index)
11659 .cloned()
11660 .ok_or_else(|| {
11661 operation_error(
11662 ANALYSIS_RESULTS_OPERATION,
11663 ANALYSIS_RESULTS_OP_VERSION,
11664 &context,
11665 OperationErrorSpec {
11666 error_code:
11667 "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11668 error_type: OperationErrorType::Input,
11669 retryable: false,
11670 severity: OperationErrorSeverity::Error,
11671 },
11672 format!(
11673 "requested transient snapshot index '{index}' is missing strain energy data"
11674 ),
11675 BTreeMap::from([
11676 ("requested_snapshot_index".to_string(), index.to_string()),
11677 (
11678 "available_strain_energy_snapshot_count".to_string(),
11679 transient.strain_energy_snapshots.len().to_string(),
11680 ),
11681 ]),
11682 )
11683 })?;
11684 let residual_norm_snapshot = transient
11685 .residual_norm_snapshots
11686 .get(index)
11687 .cloned()
11688 .ok_or_else(|| {
11689 operation_error(
11690 ANALYSIS_RESULTS_OPERATION,
11691 ANALYSIS_RESULTS_OP_VERSION,
11692 &context,
11693 OperationErrorSpec {
11694 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11695 error_type: OperationErrorType::Input,
11696 retryable: false,
11697 severity: OperationErrorSeverity::Error,
11698 },
11699 format!(
11700 "requested transient snapshot index '{index}' is missing residual field data"
11701 ),
11702 BTreeMap::from([
11703 ("requested_snapshot_index".to_string(), index.to_string()),
11704 (
11705 "available_residual_snapshot_count".to_string(),
11706 transient.residual_norm_snapshots.len().to_string(),
11707 ),
11708 ]),
11709 )
11710 })?;
11711
11712 if index > 0 {
11713 let residual = transient.residual_norms.get(index - 1).copied().ok_or_else(|| {
11714 operation_error(
11715 ANALYSIS_RESULTS_OPERATION,
11716 ANALYSIS_RESULTS_OP_VERSION,
11717 &context,
11718 OperationErrorSpec {
11719 error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
11720 error_type: OperationErrorType::Input,
11721 retryable: false,
11722 severity: OperationErrorSeverity::Error,
11723 },
11724 format!(
11725 "requested transient snapshot index '{index}' is missing residual data"
11726 ),
11727 BTreeMap::from([
11728 ("requested_snapshot_index".to_string(), index.to_string()),
11729 (
11730 "available_residual_count".to_string(),
11731 transient.residual_norms.len().to_string(),
11732 ),
11733 ]),
11734 )
11735 })?;
11736 residual_norms.push(residual);
11737 }
11738
11739 time_points_s.push(time_point);
11740 displacement_snapshots.push(snapshot);
11741 velocity_snapshots.push(velocity);
11742 acceleration_snapshots.push(acceleration);
11743 von_mises_snapshots.push(von_mises);
11744 kinetic_energy_snapshots.push(kinetic_energy);
11745 strain_energy_snapshots.push(strain_energy);
11746 residual_norm_snapshots.push(residual_norm_snapshot);
11747 }
11748
11749 Some(TransientResultsData {
11750 transient_payload_version: transient.transient_payload_version.clone(),
11751 time_points_s,
11752 displacement_snapshots,
11753 rotation_snapshots,
11754 velocity_snapshots,
11755 angular_velocity_snapshots,
11756 acceleration_snapshots,
11757 angular_acceleration_snapshots,
11758 von_mises_snapshots,
11759 kinetic_energy_snapshots,
11760 strain_energy_snapshots,
11761 residual_norm_snapshots,
11762 thermo_mechanical_temperature_snapshots,
11763 thermo_mechanical_thermal_strain_snapshots,
11764 thermo_mechanical_thermal_stress_snapshots,
11765 thermo_mechanical_displacement_snapshots,
11766 thermo_mechanical_von_mises_snapshots,
11767 thermo_mechanical_coupling_residual_snapshots,
11768 electro_thermal_temperature_snapshots,
11769 electro_thermal_thermal_residual_snapshots,
11770 residual_norms,
11771 integration_method: transient.integration_method,
11772 })
11773 }
11774 } else {
11775 None
11776 }
11777 } else {
11778 None
11779 };
11780
11781 let thermal_results = if query.include_field_values {
11782 run_result.thermal_results.clone()
11783 } else {
11784 None
11785 };
11786
11787 let nonlinear_results = if query.include_nonlinear_results && query.include_field_values {
11788 run_result.nonlinear_results.clone()
11789 } else {
11790 None
11791 };
11792 let electromagnetic_results =
11793 if query.include_electromagnetic_results && query.include_field_values {
11794 run_result.electromagnetic_results.clone()
11795 } else {
11796 None
11797 };
11798 let fields = if query.include_field_values {
11799 collected_fields
11800 } else {
11801 Vec::new()
11802 };
11803
11804 let data = AnalysisResultsData {
11805 field_descriptors,
11806 fields,
11807 modal_results,
11808 thermal_results,
11809 transient_results,
11810 nonlinear_results,
11811 electromagnetic_results,
11812 diagnostics: if query.include_diagnostics {
11813 if query.diagnostic_codes.is_empty() {
11814 Some(run_result.run.diagnostics.clone())
11815 } else {
11816 Some(
11817 run_result
11818 .run
11819 .diagnostics
11820 .iter()
11821 .filter(|diag| query.diagnostic_codes.iter().any(|code| code == &diag.code))
11822 .cloned()
11823 .collect(),
11824 )
11825 }
11826 } else {
11827 None
11828 },
11829 run_status: run_result.run_status,
11830 publishable: run_result.publishable,
11831 quality_reasons: run_result.quality_reasons.clone(),
11832 provenance: run_result.provenance.clone(),
11833 summary,
11834 };
11835
11836 Ok(OperationEnvelope::new(
11837 ANALYSIS_RESULTS_OPERATION,
11838 ANALYSIS_RESULTS_OP_VERSION,
11839 &context,
11840 data,
11841 ))
11842}
11843
11844pub fn analysis_results_by_run_id_op(
11845 run_id: &str,
11846 query: AnalysisResultsQuery,
11847 context: OperationContext,
11848) -> Result<OperationEnvelope<AnalysisResultsData>, OperationErrorEnvelope> {
11849 let run_result = storage::load_run_result(run_id).map_err(|err| {
11850 operation_error(
11851 ANALYSIS_RESULTS_OPERATION,
11852 ANALYSIS_RESULTS_OP_VERSION,
11853 &context,
11854 OperationErrorSpec {
11855 error_code: "RM.FEA.RESULTS.ARTIFACT_STORE_FAILED",
11856 error_type: OperationErrorType::Internal,
11857 retryable: true,
11858 severity: OperationErrorSeverity::Error,
11859 },
11860 format!("failed to load FEA run artifact: {err}"),
11861 BTreeMap::from([("run_id".to_string(), run_id.to_string())]),
11862 )
11863 })?;
11864
11865 let Some(run_result) = run_result else {
11866 return Err(operation_error(
11867 ANALYSIS_RESULTS_OPERATION,
11868 ANALYSIS_RESULTS_OP_VERSION,
11869 &context,
11870 OperationErrorSpec {
11871 error_code: "RM.FEA.RESULTS.RUN_NOT_FOUND",
11872 error_type: OperationErrorType::Input,
11873 retryable: false,
11874 severity: OperationErrorSeverity::Error,
11875 },
11876 format!("FEA run_id '{run_id}' was not found"),
11877 BTreeMap::from([("run_id".to_string(), run_id.to_string())]),
11878 ));
11879 };
11880
11881 analysis_results_op(&run_result, query, context)
11882}
11883
11884pub fn analysis_results_compare_op(
11885 query: AnalysisResultsCompareQuery,
11886 context: OperationContext,
11887) -> Result<OperationEnvelope<AnalysisResultsCompareData>, OperationErrorEnvelope> {
11888 let baseline = storage::load_run_result(&query.baseline_run_id).map_err(|err| {
11889 operation_error(
11890 ANALYSIS_RESULTS_COMPARE_OPERATION,
11891 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
11892 &context,
11893 OperationErrorSpec {
11894 error_code: "RM.FEA.RESULTS_COMPARE.ARTIFACT_STORE_FAILED",
11895 error_type: OperationErrorType::Internal,
11896 retryable: true,
11897 severity: OperationErrorSeverity::Error,
11898 },
11899 format!("failed to load baseline FEA run artifact: {err}"),
11900 BTreeMap::from([("run_id".to_string(), query.baseline_run_id.clone())]),
11901 )
11902 })?;
11903 let Some(baseline) = baseline else {
11904 return Err(operation_error(
11905 ANALYSIS_RESULTS_COMPARE_OPERATION,
11906 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
11907 &context,
11908 OperationErrorSpec {
11909 error_code: "RM.FEA.RESULTS_COMPARE.RUN_NOT_FOUND",
11910 error_type: OperationErrorType::Input,
11911 retryable: false,
11912 severity: OperationErrorSeverity::Error,
11913 },
11914 format!(
11915 "FEA baseline run_id '{}' was not found",
11916 query.baseline_run_id
11917 ),
11918 BTreeMap::from([("run_id".to_string(), query.baseline_run_id.clone())]),
11919 ));
11920 };
11921
11922 let candidate = storage::load_run_result(&query.candidate_run_id).map_err(|err| {
11923 operation_error(
11924 ANALYSIS_RESULTS_COMPARE_OPERATION,
11925 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
11926 &context,
11927 OperationErrorSpec {
11928 error_code: "RM.FEA.RESULTS_COMPARE.ARTIFACT_STORE_FAILED",
11929 error_type: OperationErrorType::Internal,
11930 retryable: true,
11931 severity: OperationErrorSeverity::Error,
11932 },
11933 format!("failed to load candidate FEA run artifact: {err}"),
11934 BTreeMap::from([("run_id".to_string(), query.candidate_run_id.clone())]),
11935 )
11936 })?;
11937 let Some(candidate) = candidate else {
11938 return Err(operation_error(
11939 ANALYSIS_RESULTS_COMPARE_OPERATION,
11940 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
11941 &context,
11942 OperationErrorSpec {
11943 error_code: "RM.FEA.RESULTS_COMPARE.RUN_NOT_FOUND",
11944 error_type: OperationErrorType::Input,
11945 retryable: false,
11946 severity: OperationErrorSeverity::Error,
11947 },
11948 format!(
11949 "FEA candidate run_id '{}' was not found",
11950 query.candidate_run_id
11951 ),
11952 BTreeMap::from([("run_id".to_string(), query.candidate_run_id.clone())]),
11953 ));
11954 };
11955
11956 let baseline_solve_ms = run_solve_ms(&baseline);
11957 let candidate_solve_ms = run_solve_ms(&candidate);
11958 let failed_increment_delta = match (
11959 baseline.nonlinear_results.as_ref(),
11960 candidate.nonlinear_results.as_ref(),
11961 ) {
11962 (Some(a), Some(b)) => Some(b.failed_increments as i64 - a.failed_increments as i64),
11963 _ => None,
11964 };
11965 let max_iteration_delta = match (
11966 baseline.nonlinear_results.as_ref(),
11967 candidate.nonlinear_results.as_ref(),
11968 ) {
11969 (Some(a), Some(b)) => Some(
11970 b.iteration_counts.iter().copied().max().unwrap_or(0) as i64
11971 - a.iteration_counts.iter().copied().max().unwrap_or(0) as i64,
11972 ),
11973 _ => None,
11974 };
11975 let nonlinear_spike_count_delta = match (
11976 baseline.nonlinear_results.as_ref(),
11977 candidate.nonlinear_results.as_ref(),
11978 ) {
11979 (Some(a), Some(b)) => Some(b.iteration_spike_count as i64 - a.iteration_spike_count as i64),
11980 _ => None,
11981 };
11982 let nonlinear_stall_count_delta = match (
11983 baseline.nonlinear_results.as_ref(),
11984 candidate.nonlinear_results.as_ref(),
11985 ) {
11986 (Some(a), Some(b)) => {
11987 Some(b.convergence_stall_count as i64 - a.convergence_stall_count as i64)
11988 }
11989 _ => None,
11990 };
11991
11992 let data = AnalysisResultsCompareData {
11993 baseline_run_id: baseline.run_id,
11994 candidate_run_id: candidate.run_id,
11995 publishable_changed: baseline.publishable != candidate.publishable,
11996 run_status_changed: baseline.run_status != candidate.run_status,
11997 quality_reason_count_delta: candidate.quality_reasons.len() as i64
11998 - baseline.quality_reasons.len() as i64,
11999 failed_increment_delta,
12000 max_iteration_delta,
12001 nonlinear_spike_count_delta,
12002 nonlinear_stall_count_delta,
12003 solve_ms_delta: match (baseline_solve_ms, candidate_solve_ms) {
12004 (Some(a), Some(b)) => Some(b - a),
12005 _ => None,
12006 },
12007 };
12008
12009 Ok(OperationEnvelope::new(
12010 ANALYSIS_RESULTS_COMPARE_OPERATION,
12011 ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12012 &context,
12013 data,
12014 ))
12015}
12016
12017pub fn analysis_trends_op(
12018 query: AnalysisTrendsQuery,
12019 context: OperationContext,
12020) -> Result<OperationEnvelope<AnalysisTrendsData>, OperationErrorEnvelope> {
12021 let runs = storage::list_run_results().map_err(|err| {
12022 operation_error(
12023 ANALYSIS_TRENDS_OPERATION,
12024 ANALYSIS_TRENDS_OP_VERSION,
12025 &context,
12026 OperationErrorSpec {
12027 error_code: "RM.FEA.TRENDS.ARTIFACT_STORE_FAILED",
12028 error_type: OperationErrorType::Internal,
12029 retryable: true,
12030 severity: OperationErrorSeverity::Error,
12031 },
12032 format!("failed to list FEA run artifacts: {err}"),
12033 BTreeMap::new(),
12034 )
12035 })?;
12036
12037 let mut grouped: HashMap<AnalysisRunKind, Vec<AnalysisRunResult>> = HashMap::new();
12038 for run in runs {
12039 grouped.entry(run_kind(&run)).or_default().push(run);
12040 }
12041
12042 let window = query.window_size.max(1);
12043 let mut summaries = Vec::new();
12044 for kind in [
12045 AnalysisRunKind::LinearStatic,
12046 AnalysisRunKind::Modal,
12047 AnalysisRunKind::Acoustic,
12048 AnalysisRunKind::Thermal,
12049 AnalysisRunKind::Transient,
12050 AnalysisRunKind::Cfd,
12051 AnalysisRunKind::Cht,
12052 AnalysisRunKind::Fsi,
12053 AnalysisRunKind::Nonlinear,
12054 AnalysisRunKind::Electromagnetic,
12055 ] {
12056 let Some(mut entries) = grouped.remove(&kind) else {
12057 continue;
12058 };
12059 entries.sort_by(|a, b| b.run_id.cmp(&a.run_id));
12060 if entries.len() > window {
12061 entries.truncate(window);
12062 }
12063 let sample_count = entries.len();
12064 if sample_count == 0 {
12065 continue;
12066 }
12067
12068 let mut solve_samples = entries
12069 .iter()
12070 .filter_map(run_solve_ms)
12071 .filter(|value| value.is_finite())
12072 .collect::<Vec<_>>();
12073 solve_samples.sort_by(|a, b| a.total_cmp(b));
12074 let median_solve_ms = percentile(&solve_samples, 0.5);
12075 let p95_solve_ms = percentile(&solve_samples, 0.95);
12076 let publishable_rate =
12077 entries.iter().filter(|run| run.publishable).count() as f64 / sample_count as f64;
12078
12079 let failed_increment_rate = if kind == AnalysisRunKind::Nonlinear {
12080 let failed = entries
12081 .iter()
12082 .filter_map(|run| run.nonlinear_results.as_ref())
12083 .filter(|nonlinear| nonlinear.failed_increments > 0)
12084 .count();
12085 Some(failed as f64 / sample_count as f64)
12086 } else {
12087 None
12088 };
12089 let mean_spike_count = if kind == AnalysisRunKind::Nonlinear {
12090 let values = entries
12091 .iter()
12092 .filter_map(|run| run.nonlinear_results.as_ref())
12093 .map(|nonlinear| nonlinear.iteration_spike_count as f64)
12094 .collect::<Vec<_>>();
12095 Some(mean(&values))
12096 } else {
12097 None
12098 };
12099 let mean_stall_count = if kind == AnalysisRunKind::Nonlinear {
12100 let values = entries
12101 .iter()
12102 .filter_map(|run| run.nonlinear_results.as_ref())
12103 .map(|nonlinear| nonlinear.convergence_stall_count as f64)
12104 .collect::<Vec<_>>();
12105 Some(mean(&values))
12106 } else {
12107 None
12108 };
12109 let prep_acceptance_rate = {
12110 let values = entries
12111 .iter()
12112 .filter_map(|run| {
12113 diagnostic_metric_bool(&run.run.diagnostics, "FEA_PREP_ACCEPTANCE", "accepted")
12114 })
12115 .collect::<Vec<_>>();
12116 if values.is_empty() {
12117 None
12118 } else {
12119 Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12120 }
12121 };
12122 let prep_calibration_fast_rate = calibration_profile_rate(&entries, "fast");
12123 let prep_calibration_balanced_rate = calibration_profile_rate(&entries, "balanced");
12124 let prep_calibration_conservative_rate = calibration_profile_rate(&entries, "conservative");
12125 let thermo_coupling_enabled_rate = {
12126 let values = entries
12127 .iter()
12128 .filter_map(|run| {
12129 diagnostic_metric_bool(&run.run.diagnostics, "FEA_TM_COUPLING", "enabled")
12130 })
12131 .collect::<Vec<_>>();
12132 if values.is_empty() {
12133 None
12134 } else {
12135 Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12136 }
12137 };
12138 let thermo_transient_warn_rate = if kind == AnalysisRunKind::Transient {
12139 diagnostic_warning_rate(&entries, "FEA_TM_TRANSIENT")
12140 } else {
12141 None
12142 };
12143 let thermo_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12144 diagnostic_warning_rate(&entries, "FEA_TM_NONLINEAR")
12145 } else {
12146 None
12147 };
12148 let thermo_spread_breach_rate = {
12149 let values = entries
12150 .iter()
12151 .filter_map(|run| {
12152 diagnostic_metric(
12153 &run.run.diagnostics,
12154 "FEA_TM_COUPLING",
12155 "constitutive_material_spread_ratio",
12156 )
12157 })
12158 .collect::<Vec<_>>();
12159 breach_rate_greater_than(&values, THERMO_SPREAD_THRESHOLD_BALANCED)
12160 };
12161 let thermo_heterogeneity_breach_rate = {
12162 let values = entries
12163 .iter()
12164 .filter_map(|run| {
12165 diagnostic_metric(
12166 &run.run.diagnostics,
12167 "FEA_TM_COUPLING",
12168 "assignment_heterogeneity_index",
12169 )
12170 })
12171 .collect::<Vec<_>>();
12172 breach_rate_greater_than(&values, THERMO_HETEROGENEITY_THRESHOLD_BALANCED)
12173 };
12174 let electro_thermal_coupling_enabled_rate = {
12175 let values = entries
12176 .iter()
12177 .filter_map(|run| {
12178 diagnostic_metric_bool(&run.run.diagnostics, "FEA_ET_COUPLING", "enabled")
12179 })
12180 .collect::<Vec<_>>();
12181 if values.is_empty() {
12182 None
12183 } else {
12184 Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12185 }
12186 };
12187 let electro_transient_warn_rate = if kind == AnalysisRunKind::Transient {
12188 diagnostic_warning_rate(&entries, "FEA_ET_TRANSIENT")
12189 } else {
12190 None
12191 };
12192 let electro_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12193 diagnostic_warning_rate(&entries, "FEA_ET_NONLINEAR")
12194 } else {
12195 None
12196 };
12197 let plastic_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12198 diagnostic_warning_rate(&entries, "FEA_PLASTIC_NONLINEAR")
12199 } else {
12200 None
12201 };
12202 let contact_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12203 diagnostic_warning_rate(&entries, "FEA_CONTACT_NONLINEAR")
12204 } else {
12205 None
12206 };
12207 let thermal_stability_warn_rate = if kind == AnalysisRunKind::Thermal {
12208 diagnostic_warning_rate(&entries, "FEA_THERMAL_STABILITY")
12209 } else {
12210 None
12211 };
12212 let thermal_constitutive_warn_rate = if kind == AnalysisRunKind::Thermal {
12213 diagnostic_warning_rate(&entries, "FEA_THERMAL_CONSTITUTIVE")
12214 } else {
12215 None
12216 };
12217 let thermal_spread_breach_rate = if kind == AnalysisRunKind::Thermal {
12218 let values = entries
12219 .iter()
12220 .filter_map(|run| {
12221 diagnostic_metric(
12222 &run.run.diagnostics,
12223 "FEA_THERMAL_CONSTITUTIVE",
12224 "conductivity_spread_ratio",
12225 )
12226 })
12227 .collect::<Vec<_>>();
12228 breach_rate_greater_than(&values, 2.5)
12229 } else {
12230 None
12231 };
12232 let electromagnetic_solve_warn_rate = if kind == AnalysisRunKind::Electromagnetic {
12233 Some(diagnostic_warning_rate(&entries, "FEA_EM_STATIC").unwrap_or(0.0))
12234 } else {
12235 None
12236 };
12237 let electromagnetic_spread_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
12238 let values = entries
12239 .iter()
12240 .filter_map(|run| {
12241 diagnostic_metric(
12242 &run.run.diagnostics,
12243 "FEA_EM_STATIC",
12244 "conductivity_spread_ratio",
12245 )
12246 })
12247 .collect::<Vec<_>>();
12248 breach_rate_greater_than(&values, EM_CONDUCTIVITY_SPREAD_THRESHOLD_BALANCED)
12249 } else {
12250 None
12251 };
12252 let electromagnetic_heterogeneity_breach_rate = if kind == AnalysisRunKind::Electromagnetic
12253 {
12254 let values = entries
12255 .iter()
12256 .filter_map(|run| {
12257 diagnostic_metric(
12258 &run.run.diagnostics,
12259 "FEA_EM_STATIC",
12260 "electromagnetic_material_heterogeneity_index",
12261 )
12262 })
12263 .collect::<Vec<_>>();
12264 breach_rate_greater_than(&values, EM_HETEROGENEITY_THRESHOLD_BALANCED)
12265 } else {
12266 None
12267 };
12268 let electromagnetic_coverage_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
12269 let values = entries
12270 .iter()
12271 .filter_map(|run| {
12272 diagnostic_metric(
12273 &run.run.diagnostics,
12274 "FEA_EM_STATIC",
12275 "assignment_coverage_ratio",
12276 )
12277 })
12278 .collect::<Vec<_>>();
12279 breach_rate_less_than(&values, EM_ASSIGNMENT_COVERAGE_MIN_BALANCED)
12280 } else {
12281 None
12282 };
12283 let electromagnetic_contrast_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
12284 let values = entries
12285 .iter()
12286 .filter_map(|run| {
12287 diagnostic_metric(
12288 &run.run.diagnostics,
12289 "FEA_EM_STATIC",
12290 "region_coefficient_contrast_index",
12291 )
12292 })
12293 .collect::<Vec<_>>();
12294 breach_rate_greater_than(&values, EM_REGION_CONTRAST_MAX_BALANCED)
12295 } else {
12296 None
12297 };
12298 let electromagnetic_conditioning_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
12299 let values = entries
12300 .iter()
12301 .filter_map(|run| {
12302 diagnostic_metric(
12303 &run.run.diagnostics,
12304 "FEA_EM_STATIC",
12305 "condition_number_estimate",
12306 )
12307 })
12308 .collect::<Vec<_>>();
12309 breach_rate_greater_than(&values, EM_CONDITIONING_MAX_BALANCED)
12310 } else {
12311 None
12312 };
12313 let electromagnetic_source_realization_breach_rate =
12314 if kind == AnalysisRunKind::Electromagnetic {
12315 let values = entries
12316 .iter()
12317 .filter_map(|run| {
12318 diagnostic_metric(
12319 &run.run.diagnostics,
12320 "FEA_EM_SOURCE_ENERGY",
12321 "source_realization_ratio",
12322 )
12323 })
12324 .collect::<Vec<_>>();
12325 breach_rate_less_than(&values, EM_SOURCE_REALIZATION_MIN_BALANCED)
12326 } else {
12327 None
12328 };
12329 let electromagnetic_source_region_coverage_breach_rate =
12330 if kind == AnalysisRunKind::Electromagnetic {
12331 let values = entries
12332 .iter()
12333 .filter_map(|run| {
12334 diagnostic_metric(
12335 &run.run.diagnostics,
12336 "FEA_EM_SOURCE_ENERGY",
12337 "source_region_coverage_ratio",
12338 )
12339 })
12340 .collect::<Vec<_>>();
12341 breach_rate_less_than(&values, EM_SOURCE_REGION_COVERAGE_MIN_BALANCED)
12342 } else {
12343 None
12344 };
12345 let electromagnetic_source_material_alignment_breach_rate =
12346 if kind == AnalysisRunKind::Electromagnetic {
12347 let values = entries
12348 .iter()
12349 .filter_map(|run| {
12350 diagnostic_metric(
12351 &run.run.diagnostics,
12352 "FEA_EM_SOURCE_ENERGY",
12353 "source_material_alignment_ratio",
12354 )
12355 })
12356 .collect::<Vec<_>>();
12357 breach_rate_less_than(&values, EM_SOURCE_MATERIAL_ALIGNMENT_MIN_BALANCED)
12358 } else {
12359 None
12360 };
12361 let electromagnetic_source_overlap_breach_rate = if kind == AnalysisRunKind::Electromagnetic
12362 {
12363 let values = entries
12364 .iter()
12365 .filter_map(|run| {
12366 diagnostic_metric(
12367 &run.run.diagnostics,
12368 "FEA_EM_SOURCE_ENERGY",
12369 "source_overlap_ratio",
12370 )
12371 })
12372 .collect::<Vec<_>>();
12373 breach_rate_greater_than(&values, EM_SOURCE_OVERLAP_MAX_BALANCED)
12374 } else {
12375 None
12376 };
12377 let electromagnetic_source_interference_breach_rate =
12378 if kind == AnalysisRunKind::Electromagnetic {
12379 let values = entries
12380 .iter()
12381 .filter_map(|run| {
12382 diagnostic_metric(
12383 &run.run.diagnostics,
12384 "FEA_EM_SOURCE_ENERGY",
12385 "source_interference_index",
12386 )
12387 })
12388 .collect::<Vec<_>>();
12389 breach_rate_greater_than(&values, EM_SOURCE_INTERFERENCE_MAX_BALANCED)
12390 } else {
12391 None
12392 };
12393 let electromagnetic_boundary_anchor_breach_rate =
12394 if kind == AnalysisRunKind::Electromagnetic {
12395 let values = entries
12396 .iter()
12397 .filter_map(|run| {
12398 diagnostic_metric(
12399 &run.run.diagnostics,
12400 "FEA_EM_SOURCE_ENERGY",
12401 "boundary_anchor_ratio",
12402 )
12403 })
12404 .collect::<Vec<_>>();
12405 breach_rate_less_than(&values, EM_BOUNDARY_ANCHOR_MIN_BALANCED)
12406 } else {
12407 None
12408 };
12409 let electromagnetic_boundary_localization_breach_rate =
12410 if kind == AnalysisRunKind::Electromagnetic {
12411 let values = entries
12412 .iter()
12413 .filter_map(|run| {
12414 diagnostic_metric(
12415 &run.run.diagnostics,
12416 "FEA_EM_SOURCE_ENERGY",
12417 "boundary_condition_localization_ratio",
12418 )
12419 })
12420 .collect::<Vec<_>>();
12421 breach_rate_less_than(&values, EM_BOUNDARY_LOCALIZATION_MIN_BALANCED)
12422 } else {
12423 None
12424 };
12425 let electromagnetic_ground_effectiveness_breach_rate =
12426 if kind == AnalysisRunKind::Electromagnetic {
12427 let values = entries
12428 .iter()
12429 .filter_map(|run| {
12430 diagnostic_metric(
12431 &run.run.diagnostics,
12432 "FEA_EM_SOURCE_ENERGY",
12433 "ground_anchor_effectiveness_ratio",
12434 )
12435 })
12436 .collect::<Vec<_>>();
12437 breach_rate_less_than(&values, EM_GROUND_EFFECTIVENESS_MIN_BALANCED)
12438 } else {
12439 None
12440 };
12441 let electromagnetic_insulation_leakage_breach_rate =
12442 if kind == AnalysisRunKind::Electromagnetic {
12443 let values = entries
12444 .iter()
12445 .filter_map(|run| {
12446 diagnostic_metric(
12447 &run.run.diagnostics,
12448 "FEA_EM_SOURCE_ENERGY",
12449 "insulation_leakage_ratio",
12450 )
12451 })
12452 .collect::<Vec<_>>();
12453 breach_rate_greater_than(&values, EM_INSULATION_LEAKAGE_MAX_BALANCED)
12454 } else {
12455 None
12456 };
12457 let electromagnetic_divergence_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
12458 let values = entries
12459 .iter()
12460 .filter_map(|run| {
12461 diagnostic_metric(
12462 &run.run.diagnostics,
12463 "FEA_EM_STATIC",
12464 "flux_divergence_ratio",
12465 )
12466 })
12467 .collect::<Vec<_>>();
12468 breach_rate_greater_than(&values, EM_FLUX_DIVERGENCE_MAX_BALANCED)
12469 } else {
12470 None
12471 };
12472 let electromagnetic_energy_imbalance_breach_rate =
12473 if kind == AnalysisRunKind::Electromagnetic {
12474 let values = entries
12475 .iter()
12476 .filter_map(|run| {
12477 diagnostic_metric(
12478 &run.run.diagnostics,
12479 "FEA_EM_SOURCE_ENERGY",
12480 "energy_imbalance_ratio",
12481 )
12482 })
12483 .collect::<Vec<_>>();
12484 breach_rate_greater_than(&values, EM_ENERGY_IMBALANCE_MAX_BALANCED)
12485 } else {
12486 None
12487 };
12488 let electromagnetic_boundary_energy_breach_rate =
12489 if kind == AnalysisRunKind::Electromagnetic {
12490 let values = entries
12491 .iter()
12492 .filter_map(|run| {
12493 diagnostic_metric(
12494 &run.run.diagnostics,
12495 "FEA_EM_SOURCE_ENERGY",
12496 "boundary_energy_ratio",
12497 )
12498 })
12499 .collect::<Vec<_>>();
12500 breach_rate_less_than(&values, EM_BOUNDARY_ENERGY_MIN_BALANCED)
12501 } else {
12502 None
12503 };
12504 let electromagnetic_boundary_penalty_contribution_breach_rate =
12505 if kind == AnalysisRunKind::Electromagnetic {
12506 let values = entries
12507 .iter()
12508 .filter_map(|run| {
12509 diagnostic_metric(
12510 &run.run.diagnostics,
12511 "FEA_EM_SOURCE_ENERGY",
12512 "boundary_penalty_conditioning_contribution",
12513 )
12514 })
12515 .collect::<Vec<_>>();
12516 breach_rate_greater_than(&values, EM_BOUNDARY_PENALTY_CONTRIBUTION_MAX_BALANCED)
12517 } else {
12518 None
12519 };
12520 let electromagnetic_source_region_energy_consistency_breach_rate =
12521 if kind == AnalysisRunKind::Electromagnetic {
12522 let values = entries
12523 .iter()
12524 .filter_map(|run| {
12525 diagnostic_metric(
12526 &run.run.diagnostics,
12527 "FEA_EM_SOURCE_ENERGY",
12528 "source_region_energy_consistency_ratio",
12529 )
12530 })
12531 .collect::<Vec<_>>();
12532 breach_rate_less_than(&values, EM_SOURCE_REGION_ENERGY_CONSISTENCY_MIN_BALANCED)
12533 } else {
12534 None
12535 };
12536 let electromagnetic_real_residual_breach_rate = if kind == AnalysisRunKind::Electromagnetic
12537 {
12538 let values = entries
12539 .iter()
12540 .filter_map(|run| {
12541 diagnostic_metric(&run.run.diagnostics, "FEA_EM_STATIC", "real_residual_norm")
12542 })
12543 .collect::<Vec<_>>();
12544 breach_rate_greater_than(&values, EM_REAL_RESIDUAL_MAX_BALANCED)
12545 } else {
12546 None
12547 };
12548 let electromagnetic_imag_residual_breach_rate = if kind == AnalysisRunKind::Electromagnetic
12549 {
12550 let values = entries
12551 .iter()
12552 .filter_map(|run| {
12553 diagnostic_metric(&run.run.diagnostics, "FEA_EM_STATIC", "imag_residual_norm")
12554 })
12555 .collect::<Vec<_>>();
12556 breach_rate_greater_than(&values, EM_IMAG_RESIDUAL_MAX_BALANCED)
12557 } else {
12558 None
12559 };
12560 let electromagnetic_sweep_coverage_breach_rate = if kind == AnalysisRunKind::Electromagnetic
12561 {
12562 let values = entries
12563 .iter()
12564 .filter_map(|run| {
12565 diagnostic_metric(&run.run.diagnostics, "FEA_EM_SWEEP", "sweep_count")
12566 })
12567 .collect::<Vec<_>>();
12568 breach_rate_less_than(&values, EM_SWEEP_COUNT_MIN_BALANCED)
12569 } else {
12570 None
12571 };
12572 let electromagnetic_resonance_sharpness_breach_rate =
12573 if kind == AnalysisRunKind::Electromagnetic {
12574 let values = entries
12575 .iter()
12576 .filter_map(|run| {
12577 diagnostic_metric(
12578 &run.run.diagnostics,
12579 "FEA_EM_SWEEP",
12580 "resonance_quality_factor",
12581 )
12582 })
12583 .collect::<Vec<_>>();
12584 breach_rate_less_than(&values, EM_RESONANCE_Q_MIN_BALANCED)
12585 } else {
12586 None
12587 };
12588
12589 summaries.push(AnalysisTrendKindSummary {
12590 run_kind: kind,
12591 sample_count,
12592 median_solve_ms,
12593 p95_solve_ms,
12594 publishable_rate,
12595 failed_increment_rate,
12596 mean_spike_count,
12597 mean_stall_count,
12598 prep_acceptance_rate,
12599 prep_calibration_fast_rate,
12600 prep_calibration_balanced_rate,
12601 prep_calibration_conservative_rate,
12602 thermo_coupling_enabled_rate,
12603 thermo_transient_warn_rate,
12604 thermo_nonlinear_warn_rate,
12605 thermo_spread_breach_rate,
12606 thermo_heterogeneity_breach_rate,
12607 electro_thermal_coupling_enabled_rate,
12608 electro_transient_warn_rate,
12609 electro_nonlinear_warn_rate,
12610 plastic_nonlinear_warn_rate,
12611 contact_nonlinear_warn_rate,
12612 thermal_stability_warn_rate,
12613 thermal_constitutive_warn_rate,
12614 thermal_spread_breach_rate,
12615 electromagnetic_solve_warn_rate,
12616 electromagnetic_spread_breach_rate,
12617 electromagnetic_heterogeneity_breach_rate,
12618 electromagnetic_coverage_breach_rate,
12619 electromagnetic_contrast_breach_rate,
12620 electromagnetic_conditioning_breach_rate,
12621 electromagnetic_source_realization_breach_rate,
12622 electromagnetic_source_region_coverage_breach_rate,
12623 electromagnetic_source_material_alignment_breach_rate,
12624 electromagnetic_source_overlap_breach_rate,
12625 electromagnetic_source_interference_breach_rate,
12626 electromagnetic_boundary_anchor_breach_rate,
12627 electromagnetic_boundary_localization_breach_rate,
12628 electromagnetic_ground_effectiveness_breach_rate,
12629 electromagnetic_insulation_leakage_breach_rate,
12630 electromagnetic_divergence_breach_rate,
12631 electromagnetic_energy_imbalance_breach_rate,
12632 electromagnetic_boundary_energy_breach_rate,
12633 electromagnetic_boundary_penalty_contribution_breach_rate,
12634 electromagnetic_source_region_energy_consistency_breach_rate,
12635 electromagnetic_real_residual_breach_rate,
12636 electromagnetic_imag_residual_breach_rate,
12637 electromagnetic_sweep_coverage_breach_rate,
12638 electromagnetic_resonance_sharpness_breach_rate,
12639 });
12640 }
12641
12642 Ok(OperationEnvelope::new(
12643 ANALYSIS_TRENDS_OPERATION,
12644 ANALYSIS_TRENDS_OP_VERSION,
12645 &context,
12646 AnalysisTrendsData {
12647 window_size: window,
12648 summaries,
12649 },
12650 ))
12651}
12652
12653fn run_kind(run: &AnalysisRunResult) -> AnalysisRunKind {
12654 if run
12655 .run
12656 .diagnostics
12657 .iter()
12658 .any(|diag| diag.code == "FEA_ACOUSTIC_HARMONIC_RESPONSE")
12659 {
12660 AnalysisRunKind::Acoustic
12661 } else if run.electromagnetic_results.is_some()
12662 || run
12663 .run
12664 .diagnostics
12665 .iter()
12666 .any(|diag| diag.code == "FEA_EM_STATIC")
12667 {
12668 AnalysisRunKind::Electromagnetic
12669 } else if run
12670 .run
12671 .diagnostics
12672 .iter()
12673 .any(|diag| diag.code == "FEA_CHT_COUPLING")
12674 {
12675 AnalysisRunKind::Cht
12676 } else if run
12677 .run
12678 .diagnostics
12679 .iter()
12680 .any(|diag| diag.code == "FEA_FSI_COUPLING")
12681 {
12682 AnalysisRunKind::Fsi
12683 } else if run
12684 .run
12685 .diagnostics
12686 .iter()
12687 .any(|diag| diag.code == "FEA_CFD_FLOW")
12688 {
12689 AnalysisRunKind::Cfd
12690 } else if run.nonlinear_results.is_some() {
12691 AnalysisRunKind::Nonlinear
12692 } else if run.thermal_results.is_some() {
12693 AnalysisRunKind::Thermal
12694 } else if run.transient_results.is_some() {
12695 AnalysisRunKind::Transient
12696 } else if run.modal_results.is_some() {
12697 AnalysisRunKind::Modal
12698 } else {
12699 AnalysisRunKind::LinearStatic
12700 }
12701}
12702
12703fn run_operation_version_for_kind(kind: AnalysisRunKind) -> &'static str {
12704 match kind {
12705 AnalysisRunKind::LinearStatic => ANALYSIS_RUN_OP_VERSION,
12706 AnalysisRunKind::Modal => ANALYSIS_RUN_MODAL_OP_VERSION,
12707 AnalysisRunKind::Acoustic => ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
12708 AnalysisRunKind::Thermal => ANALYSIS_RUN_THERMAL_OP_VERSION,
12709 AnalysisRunKind::Transient => ANALYSIS_RUN_TRANSIENT_OP_VERSION,
12710 AnalysisRunKind::Cfd => ANALYSIS_RUN_CFD_OP_VERSION,
12711 AnalysisRunKind::Cht => ANALYSIS_RUN_CHT_OP_VERSION,
12712 AnalysisRunKind::Fsi => ANALYSIS_RUN_FSI_OP_VERSION,
12713 AnalysisRunKind::Nonlinear => ANALYSIS_RUN_NONLINEAR_OP_VERSION,
12714 AnalysisRunKind::Electromagnetic => ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
12715 }
12716}
12717
12718fn run_operation_for_kind(kind: AnalysisRunKind) -> &'static str {
12719 match kind {
12720 AnalysisRunKind::LinearStatic => ANALYSIS_RUN_OPERATION,
12721 AnalysisRunKind::Modal => ANALYSIS_RUN_MODAL_OPERATION,
12722 AnalysisRunKind::Acoustic => ANALYSIS_RUN_ACOUSTIC_OPERATION,
12723 AnalysisRunKind::Thermal => ANALYSIS_RUN_THERMAL_OPERATION,
12724 AnalysisRunKind::Transient => ANALYSIS_RUN_TRANSIENT_OPERATION,
12725 AnalysisRunKind::Cfd => ANALYSIS_RUN_CFD_OPERATION,
12726 AnalysisRunKind::Cht => ANALYSIS_RUN_CHT_OPERATION,
12727 AnalysisRunKind::Fsi => ANALYSIS_RUN_FSI_OPERATION,
12728 AnalysisRunKind::Nonlinear => ANALYSIS_RUN_NONLINEAR_OPERATION,
12729 AnalysisRunKind::Electromagnetic => ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
12730 }
12731}
12732
12733fn sanitize_study_sweep_id(sweep_id: &str) -> String {
12734 sweep_id
12735 .chars()
12736 .map(|ch| {
12737 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
12738 ch
12739 } else {
12740 '_'
12741 }
12742 })
12743 .collect()
12744}
12745
12746fn validate_study_issue_codes(spec: &AnalysisStudySpec) -> Vec<String> {
12747 let mut issue_codes = Vec::new();
12748
12749 if spec.study_id.trim().is_empty() {
12750 issue_codes.push("RM.FEA.STUDY.ID_EMPTY".to_string());
12751 }
12752 if spec.create_model_intent.model_id.trim().is_empty() {
12753 issue_codes.push("RM.FEA.STUDY.MODEL_ID_EMPTY".to_string());
12754 }
12755 if spec.geometry.meshes.is_empty() {
12756 issue_codes.push("RM.FEA.STUDY.GEOMETRY_MESHES_EMPTY".to_string());
12757 }
12758 if spec.geometry.units == UnitSystem::Unspecified {
12759 issue_codes.push("RM.FEA.STUDY.GEOMETRY_UNITS_UNSPECIFIED".to_string());
12760 }
12761 if !profile_supports_run_kind(spec.create_model_intent.profile, spec.run_kind) {
12762 issue_codes.push("RM.FEA.STUDY.RUN_KIND_PROFILE_MISMATCH".to_string());
12763 }
12764 if let Some(model) = &spec.model {
12765 if model.geometry_id != spec.geometry.geometry_id
12766 || model.geometry_revision != spec.geometry.revision
12767 {
12768 issue_codes.push("RM.FEA.STUDY.MODEL_GEOMETRY_MISMATCH".to_string());
12769 }
12770 if validate_model_against_geometry(model, spec.geometry.units, &ReferenceFrame::Global)
12771 .is_err()
12772 {
12773 issue_codes.push("RM.FEA.STUDY.MODEL_INVALID".to_string());
12774 }
12775 }
12776 if spec.electromagnetic_run_options.is_some()
12777 && spec.run_kind != AnalysisRunKind::Electromagnetic
12778 {
12779 issue_codes.push("RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH".to_string());
12780 }
12781 if spec.linear_static_run_options.is_some() && spec.run_kind != AnalysisRunKind::LinearStatic
12782 || spec.modal_run_options.is_some() && spec.run_kind != AnalysisRunKind::Modal
12783 || spec.acoustic_run_options.is_some() && spec.run_kind != AnalysisRunKind::Acoustic
12784 || spec.thermal_run_options.is_some() && spec.run_kind != AnalysisRunKind::Thermal
12785 || spec.transient_run_options.is_some() && spec.run_kind != AnalysisRunKind::Transient
12786 || spec.cfd_run_options.is_some() && spec.run_kind != AnalysisRunKind::Cfd
12787 || spec.cht_run_options.is_some() && spec.run_kind != AnalysisRunKind::Cht
12788 || spec.fsi_run_options.is_some() && spec.run_kind != AnalysisRunKind::Fsi
12789 || spec.nonlinear_run_options.is_some() && spec.run_kind != AnalysisRunKind::Nonlinear
12790 {
12791 issue_codes.push("RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH".to_string());
12792 }
12793 if spec.run_kind == AnalysisRunKind::Electromagnetic {
12794 if let Some(options) = spec.electromagnetic_run_options.as_ref() {
12795 if !options.residual_target.is_finite() || options.residual_target <= 0.0 {
12796 issue_codes
12797 .push("RM.FEA.STUDY.ELECTROMAGNETIC_RESIDUAL_TARGET_INVALID".to_string());
12798 }
12799 if !options.harmonic_tolerance.is_finite() || options.harmonic_tolerance <= 0.0 {
12800 issue_codes
12801 .push("RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_TOLERANCE_INVALID".to_string());
12802 }
12803 if options.harmonic_max_iterations == 0 {
12804 issue_codes.push(
12805 "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_MAX_ITERATIONS_INVALID".to_string(),
12806 );
12807 }
12808 if options.sweep_enabled
12809 && !options
12810 .sweep_frequency_hz
12811 .iter()
12812 .all(|frequency_hz| frequency_hz.is_finite() && *frequency_hz > 0.0)
12813 {
12814 issue_codes
12815 .push("RM.FEA.STUDY.ELECTROMAGNETIC_SWEEP_FREQUENCY_INVALID".to_string());
12816 }
12817 }
12818 }
12819
12820 issue_codes
12821}
12822
12823fn study_issue_message(code: &str) -> &'static str {
12824 match code {
12825 "RM.FEA.STUDY.ID_EMPTY" => "study_id must be non-empty",
12826 "RM.FEA.STUDY.MODEL_ID_EMPTY" => "create_model_intent.model_id must be non-empty",
12827 "RM.FEA.STUDY.GEOMETRY_MESHES_EMPTY" => "geometry must contain at least one mesh",
12828 "RM.FEA.STUDY.GEOMETRY_UNITS_UNSPECIFIED" => {
12829 "geometry.units must be specified (not unspecified)"
12830 }
12831 "RM.FEA.STUDY.RUN_KIND_PROFILE_MISMATCH" => {
12832 "model.profile selects the solver; run kind must match the selected profile when supplied"
12833 }
12834 "RM.FEA.STUDY.MODEL_GEOMETRY_MISMATCH" => {
12835 "resolved model geometry id or revision does not match the study geometry"
12836 }
12837 "RM.FEA.STUDY.MODEL_INVALID" => "resolved model failed FEA validation",
12838 "RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH" => {
12839 "run options are only valid for the solver selected by model.profile"
12840 }
12841 "RM.FEA.STUDY.ELECTROMAGNETIC_RESIDUAL_TARGET_INVALID" => {
12842 "electromagnetic_run_options.residual_target must be finite and positive"
12843 }
12844 "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_TOLERANCE_INVALID" => {
12845 "electromagnetic_run_options.harmonic_tolerance must be finite and positive"
12846 }
12847 "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_MAX_ITERATIONS_INVALID" => {
12848 "electromagnetic_run_options.harmonic_max_iterations must be greater than zero"
12849 }
12850 "RM.FEA.STUDY.ELECTROMAGNETIC_SWEEP_FREQUENCY_INVALID" => {
12851 "electromagnetic_run_options.sweep_frequency_hz must contain finite positive values when sweep_enabled is true"
12852 }
12853 _ => "unrecognized study validation issue",
12854 }
12855}
12856
12857fn profile_supports_run_kind(
12858 profile: AnalysisCreateModelProfile,
12859 run_kind: AnalysisRunKind,
12860) -> bool {
12861 profile.derived_run_kind() == run_kind
12862}
12863
12864fn study_fingerprint(spec: &AnalysisStudySpec) -> String {
12865 let payload = serde_json::to_vec(spec).unwrap_or_else(|_| format!("{spec:?}").into_bytes());
12866 let mut hasher = Sha256::new();
12867 hasher.update(payload);
12868 format!("sha256:{:x}", hasher.finalize())
12869}
12870
12871fn study_operation_sequence(spec: &AnalysisStudySpec, run_op_version: &str) -> Vec<String> {
12872 let mut operation_sequence = Vec::with_capacity(3);
12873 if spec.model.is_none() {
12874 operation_sequence.push(ANALYSIS_CREATE_MODEL_OP_VERSION.to_string());
12875 }
12876 operation_sequence.push(ANALYSIS_VALIDATE_OP_VERSION.to_string());
12877 operation_sequence.push(run_op_version.to_string());
12878 operation_sequence
12879}
12880
12881fn study_run_options_json(spec: &AnalysisStudySpec) -> serde_json::Value {
12882 match spec.run_kind {
12883 AnalysisRunKind::LinearStatic => serde_json::to_value(&spec.linear_static_run_options),
12884 AnalysisRunKind::Modal => serde_json::to_value(&spec.modal_run_options),
12885 AnalysisRunKind::Acoustic => serde_json::to_value(&spec.acoustic_run_options),
12886 AnalysisRunKind::Thermal => serde_json::to_value(&spec.thermal_run_options),
12887 AnalysisRunKind::Transient => serde_json::to_value(&spec.transient_run_options),
12888 AnalysisRunKind::Cfd => serde_json::to_value(&spec.cfd_run_options),
12889 AnalysisRunKind::Cht => serde_json::to_value(&spec.cht_run_options),
12890 AnalysisRunKind::Fsi => serde_json::to_value(&spec.fsi_run_options),
12891 AnalysisRunKind::Nonlinear => serde_json::to_value(&spec.nonlinear_run_options),
12892 AnalysisRunKind::Electromagnetic => serde_json::to_value(&spec.electromagnetic_run_options),
12893 }
12894 .unwrap_or(serde_json::Value::Null)
12895}
12896
12897fn run_options_to_json<T: Serialize>(options: &T) -> serde_json::Value {
12898 serde_json::to_value(options).unwrap_or(serde_json::Value::Null)
12899}
12900
12901fn attach_prep_artifact_to_run_options(options: &mut AnalysisRunOptions, prep_artifact_id: &str) {
12902 if options.prep_artifact_id.is_none() {
12903 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12904 }
12905}
12906
12907fn attach_prep_artifact_to_modal_options(
12908 options: &mut AnalysisModalRunOptions,
12909 prep_artifact_id: &str,
12910) {
12911 if options.prep_artifact_id.is_none() {
12912 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12913 }
12914}
12915
12916fn attach_prep_artifact_to_acoustic_options(
12917 options: &mut AnalysisAcousticRunOptions,
12918 prep_artifact_id: &str,
12919) {
12920 if options.prep_artifact_id.is_none() {
12921 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12922 }
12923}
12924
12925fn attach_prep_artifact_to_thermal_options(
12926 options: &mut AnalysisThermalRunOptions,
12927 prep_artifact_id: &str,
12928) {
12929 if options.prep_artifact_id.is_none() {
12930 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12931 }
12932}
12933
12934fn attach_prep_artifact_to_transient_options(
12935 options: &mut AnalysisTransientRunOptions,
12936 prep_artifact_id: &str,
12937) {
12938 if options.prep_artifact_id.is_none() {
12939 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12940 }
12941}
12942
12943fn attach_prep_artifact_to_cfd_options(
12944 options: &mut AnalysisCfdRunOptions,
12945 prep_artifact_id: &str,
12946) {
12947 if options.prep_artifact_id.is_none() {
12948 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12949 }
12950}
12951
12952fn attach_prep_artifact_to_cht_options(
12953 options: &mut AnalysisChtRunOptions,
12954 prep_artifact_id: &str,
12955) {
12956 if options.prep_artifact_id.is_none() {
12957 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12958 }
12959}
12960
12961fn attach_prep_artifact_to_fsi_options(
12962 options: &mut AnalysisFsiRunOptions,
12963 prep_artifact_id: &str,
12964) {
12965 if options.prep_artifact_id.is_none() {
12966 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12967 }
12968}
12969
12970fn attach_prep_artifact_to_nonlinear_options(
12971 options: &mut AnalysisNonlinearRunOptions,
12972 prep_artifact_id: &str,
12973) {
12974 if options.prep_artifact_id.is_none() {
12975 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12976 }
12977}
12978
12979fn attach_prep_artifact_to_electromagnetic_options(
12980 options: &mut AnalysisElectromagneticRunOptions,
12981 prep_artifact_id: &str,
12982) {
12983 if options.prep_artifact_id.is_none() {
12984 options.prep_artifact_id = Some(prep_artifact_id.to_string());
12985 }
12986}
12987
12988fn study_evidence_root() -> PathBuf {
12989 let config = current_fea_runtime_config();
12990 config
12991 .study_artifact_root
12992 .or_else(|| {
12993 std::env::var("RUNMAT_FEA_STUDY_ARTIFACT_ROOT")
12994 .or_else(|_| std::env::var("RUNMAT_ANALYSIS_STUDY_ARTIFACT_ROOT"))
12995 .ok()
12996 .map(PathBuf::from)
12997 })
12998 .unwrap_or_else(|| {
12999 config
13000 .artifact_root
13001 .unwrap_or_else(default_fea_artifact_root)
13002 .join("studies")
13003 })
13004}
13005
13006fn thermo_field_artifact_root() -> PathBuf {
13007 let config = current_fea_runtime_config();
13008 config
13009 .thermo_field_artifact_root
13010 .or_else(|| {
13011 std::env::var("RUNMAT_THERMO_FIELD_ARTIFACT_ROOT")
13012 .ok()
13013 .map(PathBuf::from)
13014 })
13015 .unwrap_or_else(|| {
13016 config
13017 .artifact_root
13018 .unwrap_or_else(default_fea_artifact_root)
13019 .join("thermo-fields")
13020 })
13021}
13022
13023fn persist_study_evidence(
13024 study_fingerprint: &str,
13025 stage: &str,
13026 payload: serde_json::Value,
13027) -> Result<String, String> {
13028 let study_key = study_fingerprint.replace(':', "_");
13029 let root = study_evidence_root().join(study_key);
13030 fs_create_dir_all(&root)
13031 .map_err(|err| format!("failed to create study evidence directory: {err}"))?;
13032 let path = root.join(format!("{stage}.json"));
13033 let bytes = serde_json::to_vec_pretty(&payload)
13034 .map_err(|err| format!("failed to encode study evidence payload: {err}"))?;
13035 atomic_write_bytes(&path, &bytes)?;
13036 Ok(path.display().to_string())
13037}
13038
13039fn atomic_write_bytes(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
13040 let tmp = path.with_extension(format!(
13041 "tmp-{}-{}",
13042 std::process::id(),
13043 Utc::now().timestamp_nanos_opt().unwrap_or_default()
13044 ));
13045 fs_write(&tmp, bytes)
13046 .map_err(|err| format!("failed to write temporary study evidence file: {err}"))?;
13047 fs_rename(&tmp, path).map_err(|err| {
13048 let _ = fs_remove_file(&tmp);
13049 format!("failed to atomically persist study evidence file: {err}")
13050 })
13051}
13052
13053fn fs_create_dir_all(path: impl Into<PathBuf>) -> std::io::Result<()> {
13054 runmat_filesystem::create_dir_all(path.into())
13055}
13056
13057fn fs_write(path: impl Into<PathBuf>, bytes: &[u8]) -> std::io::Result<()> {
13058 runmat_filesystem::write(path.into(), bytes)
13059}
13060
13061fn fs_rename(from: impl Into<PathBuf>, to: impl Into<PathBuf>) -> std::io::Result<()> {
13062 runmat_filesystem::rename(from.into(), to.into())
13063}
13064
13065fn fs_remove_file(path: impl Into<PathBuf>) -> std::io::Result<()> {
13066 match runmat_filesystem::remove_file(path.into()) {
13067 Ok(()) => Ok(()),
13068 Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
13069 Err(err) => Err(err),
13070 }
13071}
13072
13073fn fs_exists(path: impl Into<PathBuf>) -> std::io::Result<bool> {
13074 match runmat_filesystem::metadata(path.into()) {
13075 Ok(_) => Ok(true),
13076 Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
13077 Err(err) => Err(err),
13078 }
13079}
13080
13081fn fs_read_to_string(path: impl Into<PathBuf>) -> std::io::Result<String> {
13082 runmat_filesystem::read_to_string(path.into())
13083}
13084
13085fn to_fea_prep_context(
13086 context: Option<&AnalysisRunPrepContext>,
13087 calibration_profile: Option<PrepCalibrationProfile>,
13088) -> Option<runmat_analysis_fea::FeaPrepContext> {
13089 context.map(|prep| runmat_analysis_fea::FeaPrepContext {
13090 prepared_mesh_count: prep.prepared_mesh_count,
13091 prepared_node_count: prep.prepared_node_count,
13092 prepared_element_count: prep.prepared_element_count,
13093 mapped_region_count: prep.mapped_region_count,
13094 min_scaled_jacobian: prep.min_scaled_jacobian,
13095 mean_aspect_ratio: prep.mean_aspect_ratio,
13096 inverted_element_count: prep.inverted_element_count,
13097 mapped_load_count: prep.mapped_load_count,
13098 mapped_bc_count: prep.mapped_bc_count,
13099 layout_seed: prep.layout_seed,
13100 topology_dof_multiplier: prep.topology_dof_multiplier,
13101 topology_bandwidth_estimate: prep.topology_bandwidth_estimate,
13102 mapped_region_participation_ratio: prep.mapped_region_participation_ratio,
13103 topology_surface_patch_ratio: prep.topology_surface_patch_ratio,
13104 topology_volume_core_ratio: prep.topology_volume_core_ratio,
13105 topology_mixed_family_ratio: prep.topology_mixed_family_ratio,
13106 topology_region_span_mean: prep.topology_region_span_mean,
13107 topology_region_block_count: prep.topology_region_block_count,
13108 topology_region_mesh_mean: prep.topology_region_mesh_mean,
13109 topology_region_mesh_variance: prep.topology_region_mesh_variance,
13110 topology_triangle_family_ratio: prep.topology_triangle_family_ratio,
13111 topology_quad_family_ratio: prep.topology_quad_family_ratio,
13112 topology_tet_family_ratio: prep.topology_tet_family_ratio,
13113 topology_hex_family_ratio: prep.topology_hex_family_ratio,
13114 coordinate_span_x_m: prep.coordinate_span_x_m,
13115 coordinate_span_y_m: prep.coordinate_span_y_m,
13116 coordinate_span_z_m: prep.coordinate_span_z_m,
13117 coordinate_active_dimension_count: prep.coordinate_active_dimension_count,
13118 coordinate_characteristic_length_m: prep.coordinate_characteristic_length_m,
13119 element_geometry_node_count: prep.element_geometry_node_count,
13120 element_geometry_edge_count: prep.element_geometry_edge_count,
13121 mean_element_edge_length_m: prep.mean_element_edge_length_m,
13122 mean_element_area_m2: prep.mean_element_area_m2,
13123 element_geometry_coverage_ratio: prep.element_geometry_coverage_ratio,
13124 reference_element_coordinates_m: prep.reference_element_coordinates_m,
13125 reference_element_area_m2: prep.reference_element_area_m2,
13126 element_topology_sample_element_count: prep.element_topology_sample_element_count,
13127 element_topology_sample_edge_count: prep.element_topology_sample_edge_count,
13128 element_topology_sample_edge_nodes: prep.element_topology_sample_edge_nodes,
13129 element_topology_sample_node_coordinates_m: prep.element_topology_sample_node_coordinates_m,
13130 element_topology_sample_element_edges: prep.element_topology_sample_element_edges,
13131 element_topology_sample_element_orientations: prep
13132 .element_topology_sample_element_orientations,
13133 element_topology_sample_element_areas_m2: prep.element_topology_sample_element_areas_m2,
13134 element_topology_node_coordinates_m: prep.element_topology_node_coordinates_m.clone(),
13135 element_topology_edge_nodes: prep.element_topology_edge_nodes.clone(),
13136 element_topology_element_edges: prep.element_topology_element_edges.clone(),
13137 element_topology_element_orientations: prep.element_topology_element_orientations.clone(),
13138 element_topology_element_areas_m2: prep.element_topology_element_areas_m2.clone(),
13139 calibration_profile_override: calibration_profile.and_then(map_calibration_profile),
13140 })
13141}
13142
13143fn render_topology_from_prep_context(
13144 context: Option<&AnalysisRunPrepContext>,
13145) -> Option<AnalysisRenderTopology> {
13146 let prep = context?;
13147 if prep.element_topology_node_coordinates_m.is_empty()
13148 || prep.element_topology_edge_nodes.is_empty()
13149 || prep.element_topology_element_edges.is_empty()
13150 {
13151 return None;
13152 }
13153
13154 let triangles = prep
13155 .element_topology_element_edges
13156 .iter()
13157 .filter_map(|element_edges| triangle_from_element_edges(element_edges, prep))
13158 .collect::<Vec<_>>();
13159 if triangles.is_empty() {
13160 return None;
13161 }
13162
13163 Some(AnalysisRenderTopology {
13164 schema_version: "analysis_render_topology/v1".to_string(),
13165 source: AnalysisRenderTopologySource::SolverPrep,
13166 meshes: vec![AnalysisRenderMesh {
13167 mesh_id: "solver_surface".to_string(),
13168 vertices: prep.element_topology_node_coordinates_m.clone(),
13169 triangles,
13170 }],
13171 })
13172}
13173
13174fn triangle_from_element_edges(
13175 element_edges: &[u32; 3],
13176 prep: &AnalysisRunPrepContext,
13177) -> Option<[u32; 3]> {
13178 let mut nodes = Vec::<u32>::with_capacity(3);
13179 for edge_index in element_edges {
13180 let edge = prep.element_topology_edge_nodes.get(*edge_index as usize)?;
13181 for node in edge {
13182 if !nodes.contains(node) {
13183 nodes.push(*node);
13184 }
13185 }
13186 }
13187 if nodes.len() == 3 {
13188 Some([nodes[0], nodes[1], nodes[2]])
13189 } else {
13190 None
13191 }
13192}
13193
13194fn map_calibration_profile(
13195 profile: PrepCalibrationProfile,
13196) -> Option<runmat_analysis_fea::FeaPrepCalibrationProfile> {
13197 match profile {
13198 PrepCalibrationProfile::Auto => None,
13199 PrepCalibrationProfile::Fast => Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Fast),
13200 PrepCalibrationProfile::Balanced => {
13201 Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Balanced)
13202 }
13203 PrepCalibrationProfile::Conservative => {
13204 Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Conservative)
13205 }
13206 }
13207}
13208
13209fn model_thermo_coupling_options(model: &AnalysisModel) -> Option<ThermoMechanicalCouplingOptions> {
13210 let domain = model.thermo_mechanical.as_ref()?;
13211 let expansion = if model.materials.is_empty() {
13212 1.2e-5
13213 } else {
13214 model
13215 .materials
13216 .iter()
13217 .map(|material| material.thermal.expansion_coefficient_per_k.max(0.0))
13218 .sum::<f64>()
13219 / model.materials.len() as f64
13220 };
13221
13222 Some(ThermoMechanicalCouplingOptions {
13223 enabled: domain.enabled,
13224 reference_temperature_k: domain.reference_temperature_k,
13225 applied_temperature_delta_k: domain.applied_temperature_delta_k,
13226 thermal_expansion_coefficient: expansion,
13227 field_artifact_id: domain.field_artifact_id.clone(),
13228 field_source: domain
13229 .field_source
13230 .as_ref()
13231 .map(|source| ThermoFieldSource {
13232 source_id: source.source_id.clone(),
13233 revision: source.revision,
13234 interpolation_mode: source.interpolation_mode.map(|mode| match mode {
13235 runmat_analysis_core::ThermoFieldInterpolationMode::Linear => {
13236 ThermoFieldInterpolationMode::Linear
13237 }
13238 runmat_analysis_core::ThermoFieldInterpolationMode::Step => {
13239 ThermoFieldInterpolationMode::Step
13240 }
13241 }),
13242 expected_region_ids: source.expected_region_ids.clone(),
13243 }),
13244 region_temperature_deltas: domain
13245 .region_temperature_deltas
13246 .iter()
13247 .map(|delta| ThermoRegionTemperatureDelta {
13248 region_id: delta.region_id.clone(),
13249 temperature_delta_k: delta.temperature_delta_k,
13250 })
13251 .collect(),
13252 time_profile: domain
13253 .time_profile
13254 .iter()
13255 .map(|point| ThermoTimeProfilePoint {
13256 normalized_time: point.normalized_time,
13257 scale: point.scale,
13258 })
13259 .collect(),
13260 })
13261}
13262
13263fn model_electro_coupling_options(model: &AnalysisModel) -> Option<ElectroThermalCouplingOptions> {
13264 let domain = model.electro_thermal.as_ref()?;
13265 let electrical_materials: Vec<_> = model
13266 .materials
13267 .iter()
13268 .filter_map(|material| material.electrical.as_ref())
13269 .collect();
13270 let base_conductivity = if electrical_materials.is_empty() {
13271 1.0
13272 } else {
13273 electrical_materials
13274 .iter()
13275 .map(|e| e.conductivity_s_per_m.max(1.0e-12))
13276 .sum::<f64>()
13277 / electrical_materials.len() as f64
13278 };
13279 let resistive_coeff = if electrical_materials.is_empty() {
13280 0.0
13281 } else {
13282 electrical_materials
13283 .iter()
13284 .map(|e| e.resistive_heating_coefficient.max(0.0))
13285 .sum::<f64>()
13286 / electrical_materials.len() as f64
13287 };
13288
13289 Some(ElectroThermalCouplingOptions {
13290 enabled: domain.enabled,
13291 reference_temperature_k: domain.reference_temperature_k,
13292 applied_voltage_v: domain.applied_voltage_v,
13293 base_electrical_conductivity_s_per_m: base_conductivity,
13294 resistive_heating_coefficient: resistive_coeff,
13295 region_conductivity_scales: domain
13296 .region_conductivity_scales
13297 .iter()
13298 .map(|scale| ElectroRegionConductivityScale {
13299 region_id: scale.region_id.clone(),
13300 conductivity_scale: scale.conductivity_scale,
13301 })
13302 .collect(),
13303 time_profile: domain
13304 .time_profile
13305 .iter()
13306 .map(|point| ElectroTimeProfilePoint {
13307 normalized_time: point.normalized_time,
13308 current_scale: point.current_scale,
13309 })
13310 .collect(),
13311 })
13312}
13313
13314fn model_plasticity_constitutive_options(
13315 model: &AnalysisModel,
13316) -> Option<PlasticityConstitutiveOptions> {
13317 let plastic = model
13318 .materials
13319 .iter()
13320 .find_map(|material| material.plastic.as_ref())?;
13321 Some(PlasticityConstitutiveOptions {
13322 enabled: true,
13323 yield_strain: plastic.yield_strain,
13324 hardening_modulus_ratio: plastic.hardening_modulus_ratio,
13325 saturation_exponent: plastic.saturation_exponent,
13326 })
13327}
13328
13329fn model_contact_interface_options(model: &AnalysisModel) -> Option<ContactInterfaceOptions> {
13330 model
13331 .interfaces
13332 .iter()
13333 .filter_map(|interface| match &interface.kind {
13334 AnalysisInterfaceKind::Contact(contact) => Some(ContactInterfaceOptions {
13335 enabled: true,
13336 penalty_stiffness_scale: contact.penalty_stiffness_scale,
13337 max_penetration_ratio: contact.max_penetration_ratio,
13338 friction_coefficient: contact.friction_coefficient,
13339 }),
13340 AnalysisInterfaceKind::FluidStructure(_)
13341 | AnalysisInterfaceKind::ConjugateHeatTransfer(_) => None,
13342 })
13343 .next()
13344}
13345
13346fn to_fea_thermo_mechanical_context(
13347 options: Option<ThermoMechanicalCouplingOptions>,
13348) -> Option<runmat_analysis_fea::FeaThermoMechanicalContext> {
13349 options.map(|tm| runmat_analysis_fea::FeaThermoMechanicalContext {
13350 enabled: tm.enabled,
13351 reference_temperature_k: tm.reference_temperature_k,
13352 applied_temperature_delta_k: tm.applied_temperature_delta_k,
13353 thermal_expansion_coefficient: tm.thermal_expansion_coefficient,
13354 field_source: tm
13355 .field_source
13356 .map(|source| runmat_analysis_fea::FeaThermoFieldSource {
13357 source_id: source.source_id,
13358 revision: source.revision,
13359 interpolation_mode: source.interpolation_mode.map(|mode| match mode {
13360 contracts::ThermoFieldInterpolationMode::Linear => {
13361 runmat_analysis_fea::FeaThermoFieldInterpolationMode::Linear
13362 }
13363 contracts::ThermoFieldInterpolationMode::Step => {
13364 runmat_analysis_fea::FeaThermoFieldInterpolationMode::Step
13365 }
13366 }),
13367 expected_region_ids: source.expected_region_ids,
13368 }),
13369 region_temperature_deltas: tm
13370 .region_temperature_deltas
13371 .into_iter()
13372 .map(
13373 |ThermoRegionTemperatureDelta {
13374 region_id,
13375 temperature_delta_k,
13376 }| runmat_analysis_fea::FeaThermoRegionTemperatureDelta {
13377 region_id,
13378 temperature_delta_k,
13379 },
13380 )
13381 .collect(),
13382 time_profile: tm
13383 .time_profile
13384 .into_iter()
13385 .map(
13386 |ThermoTimeProfilePoint {
13387 normalized_time,
13388 scale,
13389 }| runmat_analysis_fea::FeaThermoTimeProfilePoint {
13390 normalized_time,
13391 scale,
13392 },
13393 )
13394 .collect(),
13395 })
13396}
13397
13398fn to_fea_electro_thermal_context(
13399 options: Option<ElectroThermalCouplingOptions>,
13400) -> Option<runmat_analysis_fea::FeaElectroThermalContext> {
13401 options.map(|et| runmat_analysis_fea::FeaElectroThermalContext {
13402 enabled: et.enabled,
13403 reference_temperature_k: et.reference_temperature_k,
13404 applied_voltage_v: et.applied_voltage_v,
13405 base_electrical_conductivity_s_per_m: et.base_electrical_conductivity_s_per_m,
13406 resistive_heating_coefficient: et.resistive_heating_coefficient,
13407 region_conductivity_scales: et
13408 .region_conductivity_scales
13409 .into_iter()
13410 .map(
13411 |ElectroRegionConductivityScale {
13412 region_id,
13413 conductivity_scale,
13414 }| runmat_analysis_fea::FeaElectroRegionConductivityScale {
13415 region_id,
13416 conductivity_scale,
13417 },
13418 )
13419 .collect(),
13420 time_profile: et
13421 .time_profile
13422 .into_iter()
13423 .map(
13424 |ElectroTimeProfilePoint {
13425 normalized_time,
13426 current_scale,
13427 }| runmat_analysis_fea::FeaElectroTimeProfilePoint {
13428 normalized_time,
13429 current_scale,
13430 },
13431 )
13432 .collect(),
13433 })
13434}
13435
13436fn to_fea_plasticity_constitutive_context(
13437 options: Option<PlasticityConstitutiveOptions>,
13438) -> Option<runmat_analysis_fea::FeaPlasticityConstitutiveContext> {
13439 options.map(
13440 |plasticity| runmat_analysis_fea::FeaPlasticityConstitutiveContext {
13441 enabled: plasticity.enabled,
13442 yield_strain: plasticity.yield_strain,
13443 hardening_modulus_ratio: plasticity.hardening_modulus_ratio,
13444 saturation_exponent: plasticity.saturation_exponent,
13445 },
13446 )
13447}
13448
13449fn to_fea_contact_interface_context(
13450 options: Option<ContactInterfaceOptions>,
13451) -> Option<runmat_analysis_fea::FeaContactInterfaceContext> {
13452 options.map(|contact| runmat_analysis_fea::FeaContactInterfaceContext {
13453 enabled: contact.enabled,
13454 penalty_stiffness_scale: contact.penalty_stiffness_scale,
13455 max_penetration_ratio: contact.max_penetration_ratio,
13456 friction_coefficient: contact.friction_coefficient,
13457 })
13458}
13459
13460fn electro_thermal_invalid_options_error_code(operation: &str) -> &'static str {
13461 match operation {
13462 ANALYSIS_RUN_MODAL_OPERATION => "RM.FEA.RUN_MODAL.INVALID_ELECTRO_THERMAL_OPTIONS",
13463 ANALYSIS_RUN_ACOUSTIC_OPERATION => "RM.FEA.RUN_ACOUSTIC.INVALID_ELECTRO_THERMAL_OPTIONS",
13464 ANALYSIS_RUN_TRANSIENT_OPERATION => "RM.FEA.RUN_TRANSIENT.INVALID_ELECTRO_THERMAL_OPTIONS",
13465 ANALYSIS_RUN_NONLINEAR_OPERATION => "RM.FEA.RUN_NONLINEAR.INVALID_ELECTRO_THERMAL_OPTIONS",
13466 ANALYSIS_RUN_OPERATION => "RM.FEA.RUN_LINEAR_STATIC.INVALID_ELECTRO_THERMAL_OPTIONS",
13467 _ => "RM.FEA.RUN.INVALID_ELECTRO_THERMAL_OPTIONS",
13468 }
13469}
13470
13471fn validate_thermo_coupling_options(
13472 model: &AnalysisModel,
13473 options: &ThermoMechanicalCouplingOptions,
13474) -> Result<(), (String, BTreeMap<String, String>)> {
13475 if !options.enabled {
13476 return Ok(());
13477 }
13478 if !options.reference_temperature_k.is_finite() || options.reference_temperature_k <= 0.0 {
13479 return Err((
13480 "thermo coupling requires finite positive reference_temperature_k".to_string(),
13481 BTreeMap::from([(
13482 "reference_temperature_k".to_string(),
13483 options.reference_temperature_k.to_string(),
13484 )]),
13485 ));
13486 }
13487 if !options.applied_temperature_delta_k.is_finite() {
13488 return Err((
13489 "thermo coupling requires finite applied_temperature_delta_k".to_string(),
13490 BTreeMap::from([(
13491 "applied_temperature_delta_k".to_string(),
13492 options.applied_temperature_delta_k.to_string(),
13493 )]),
13494 ));
13495 }
13496 if !options.thermal_expansion_coefficient.is_finite()
13497 || options.thermal_expansion_coefficient < 0.0
13498 {
13499 return Err((
13500 "thermo coupling requires finite non-negative thermal_expansion_coefficient"
13501 .to_string(),
13502 BTreeMap::from([(
13503 "thermal_expansion_coefficient".to_string(),
13504 options.thermal_expansion_coefficient.to_string(),
13505 )]),
13506 ));
13507 }
13508
13509 let mut last_t = -1.0_f64;
13510 for (idx, point) in options.time_profile.iter().enumerate() {
13511 if !point.normalized_time.is_finite()
13512 || point.normalized_time < 0.0
13513 || point.normalized_time > 1.0
13514 {
13515 return Err((
13516 "thermo time_profile normalized_time must be finite and within [0, 1]".to_string(),
13517 BTreeMap::from([
13518 ("time_profile_index".to_string(), idx.to_string()),
13519 (
13520 "normalized_time".to_string(),
13521 point.normalized_time.to_string(),
13522 ),
13523 ]),
13524 ));
13525 }
13526 if !point.scale.is_finite() {
13527 return Err((
13528 "thermo time_profile scale must be finite".to_string(),
13529 BTreeMap::from([
13530 ("time_profile_index".to_string(), idx.to_string()),
13531 ("scale".to_string(), point.scale.to_string()),
13532 ]),
13533 ));
13534 }
13535 if point.normalized_time + 1.0e-12 < last_t {
13536 return Err((
13537 "thermo time_profile normalized_time must be monotonic non-decreasing".to_string(),
13538 BTreeMap::from([
13539 ("time_profile_index".to_string(), idx.to_string()),
13540 (
13541 "normalized_time".to_string(),
13542 point.normalized_time.to_string(),
13543 ),
13544 ]),
13545 ));
13546 }
13547 last_t = point.normalized_time;
13548 }
13549
13550 let model_region_ids = model
13551 .material_assignments
13552 .iter()
13553 .map(|assignment| assignment.region_id.as_str())
13554 .collect::<HashSet<_>>();
13555
13556 for delta in &options.region_temperature_deltas {
13557 if !delta.temperature_delta_k.is_finite() {
13558 return Err((
13559 "thermo region_temperature_deltas must use finite temperature_delta_k".to_string(),
13560 BTreeMap::from([
13561 ("region_id".to_string(), delta.region_id.clone()),
13562 (
13563 "temperature_delta_k".to_string(),
13564 delta.temperature_delta_k.to_string(),
13565 ),
13566 ]),
13567 ));
13568 }
13569 }
13570
13571 if let Some(source) = options.field_source.as_ref() {
13572 if source.source_id.trim().is_empty() {
13573 return Err((
13574 "thermo field_source requires a non-empty source_id".to_string(),
13575 BTreeMap::new(),
13576 ));
13577 }
13578 for expected_region in &source.expected_region_ids {
13579 if !model_region_ids.contains(expected_region.as_str()) {
13580 return Err((
13581 "thermo field_source expected_region_ids must exist in model material assignments"
13582 .to_string(),
13583 BTreeMap::from([("region_id".to_string(), expected_region.clone())]),
13584 ));
13585 }
13586 }
13587 }
13588
13589 Ok(())
13590}
13591
13592fn validate_electro_coupling_options(
13593 model: &AnalysisModel,
13594 options: &ElectroThermalCouplingOptions,
13595) -> Result<(), (String, BTreeMap<String, String>)> {
13596 if !options.enabled {
13597 return Ok(());
13598 }
13599 if !options.reference_temperature_k.is_finite() || options.reference_temperature_k <= 0.0 {
13600 return Err((
13601 "electro-thermal coupling requires finite positive reference_temperature_k".to_string(),
13602 BTreeMap::from([(
13603 "reference_temperature_k".to_string(),
13604 options.reference_temperature_k.to_string(),
13605 )]),
13606 ));
13607 }
13608 if !options.applied_voltage_v.is_finite() {
13609 return Err((
13610 "electro-thermal coupling requires finite applied_voltage_v".to_string(),
13611 BTreeMap::from([(
13612 "applied_voltage_v".to_string(),
13613 options.applied_voltage_v.to_string(),
13614 )]),
13615 ));
13616 }
13617 if !options.base_electrical_conductivity_s_per_m.is_finite()
13618 || options.base_electrical_conductivity_s_per_m <= 0.0
13619 {
13620 return Err((
13621 "electro-thermal coupling requires finite positive base_electrical_conductivity_s_per_m"
13622 .to_string(),
13623 BTreeMap::from([(
13624 "base_electrical_conductivity_s_per_m".to_string(),
13625 options.base_electrical_conductivity_s_per_m.to_string(),
13626 )]),
13627 ));
13628 }
13629 if !options.resistive_heating_coefficient.is_finite()
13630 || options.resistive_heating_coefficient < 0.0
13631 {
13632 return Err((
13633 "electro-thermal coupling requires finite non-negative resistive_heating_coefficient"
13634 .to_string(),
13635 BTreeMap::from([(
13636 "resistive_heating_coefficient".to_string(),
13637 options.resistive_heating_coefficient.to_string(),
13638 )]),
13639 ));
13640 }
13641 let mut last_t = -1.0_f64;
13642 for (idx, point) in options.time_profile.iter().enumerate() {
13643 if !point.normalized_time.is_finite()
13644 || point.normalized_time < 0.0
13645 || point.normalized_time > 1.0
13646 {
13647 return Err((
13648 "electro time_profile normalized_time must be finite and within [0, 1]".to_string(),
13649 BTreeMap::from([
13650 ("time_profile_index".to_string(), idx.to_string()),
13651 (
13652 "normalized_time".to_string(),
13653 point.normalized_time.to_string(),
13654 ),
13655 ]),
13656 ));
13657 }
13658 if !point.current_scale.is_finite() {
13659 return Err((
13660 "electro time_profile current_scale must be finite".to_string(),
13661 BTreeMap::from([
13662 ("time_profile_index".to_string(), idx.to_string()),
13663 ("current_scale".to_string(), point.current_scale.to_string()),
13664 ]),
13665 ));
13666 }
13667 if point.normalized_time + 1.0e-12 < last_t {
13668 return Err((
13669 "electro time_profile normalized_time must be monotonic non-decreasing".to_string(),
13670 BTreeMap::from([
13671 ("time_profile_index".to_string(), idx.to_string()),
13672 (
13673 "normalized_time".to_string(),
13674 point.normalized_time.to_string(),
13675 ),
13676 ]),
13677 ));
13678 }
13679 last_t = point.normalized_time;
13680 }
13681 let model_region_ids = model
13682 .material_assignments
13683 .iter()
13684 .map(|assignment| assignment.region_id.as_str())
13685 .collect::<HashSet<_>>();
13686 for scale in &options.region_conductivity_scales {
13687 if !scale.conductivity_scale.is_finite() || scale.conductivity_scale <= 0.0 {
13688 return Err((
13689 "electro region_conductivity_scales must use finite positive conductivity_scale"
13690 .to_string(),
13691 BTreeMap::from([
13692 ("region_id".to_string(), scale.region_id.clone()),
13693 (
13694 "conductivity_scale".to_string(),
13695 scale.conductivity_scale.to_string(),
13696 ),
13697 ]),
13698 ));
13699 }
13700 if !model_region_ids.is_empty() && !model_region_ids.contains(scale.region_id.as_str()) {
13701 return Err((
13702 "electro region_conductivity_scales region_id must exist in model material assignments"
13703 .to_string(),
13704 BTreeMap::from([("region_id".to_string(), scale.region_id.clone())]),
13705 ));
13706 }
13707 }
13708 Ok(())
13709}
13710
13711fn validate_plasticity_constitutive_options(
13712 options: &PlasticityConstitutiveOptions,
13713) -> Result<(), (String, BTreeMap<String, String>)> {
13714 if !options.enabled {
13715 return Ok(());
13716 }
13717 if !options.yield_strain.is_finite() || options.yield_strain <= 0.0 {
13718 return Err((
13719 "plasticity constitutive model requires finite positive yield_strain".to_string(),
13720 BTreeMap::from([("yield_strain".to_string(), options.yield_strain.to_string())]),
13721 ));
13722 }
13723 if !options.hardening_modulus_ratio.is_finite() || options.hardening_modulus_ratio < 0.0 {
13724 return Err((
13725 "plasticity constitutive model requires finite non-negative hardening_modulus_ratio"
13726 .to_string(),
13727 BTreeMap::from([(
13728 "hardening_modulus_ratio".to_string(),
13729 options.hardening_modulus_ratio.to_string(),
13730 )]),
13731 ));
13732 }
13733 if !options.saturation_exponent.is_finite() || options.saturation_exponent < 0.0 {
13734 return Err((
13735 "plasticity constitutive model requires finite non-negative saturation_exponent"
13736 .to_string(),
13737 BTreeMap::from([(
13738 "saturation_exponent".to_string(),
13739 options.saturation_exponent.to_string(),
13740 )]),
13741 ));
13742 }
13743 Ok(())
13744}
13745
13746fn validate_contact_interface_options(
13747 options: &ContactInterfaceOptions,
13748) -> Result<(), (String, BTreeMap<String, String>)> {
13749 if !options.enabled {
13750 return Ok(());
13751 }
13752 if !options.penalty_stiffness_scale.is_finite() || options.penalty_stiffness_scale <= 0.0 {
13753 return Err((
13754 "contact interface model requires finite positive penalty_stiffness_scale".to_string(),
13755 BTreeMap::from([(
13756 "penalty_stiffness_scale".to_string(),
13757 options.penalty_stiffness_scale.to_string(),
13758 )]),
13759 ));
13760 }
13761 if !options.max_penetration_ratio.is_finite() || options.max_penetration_ratio < 0.0 {
13762 return Err((
13763 "contact interface model requires finite non-negative max_penetration_ratio"
13764 .to_string(),
13765 BTreeMap::from([(
13766 "max_penetration_ratio".to_string(),
13767 options.max_penetration_ratio.to_string(),
13768 )]),
13769 ));
13770 }
13771 if !options.friction_coefficient.is_finite() || options.friction_coefficient < 0.0 {
13772 return Err((
13773 "contact interface model requires finite non-negative friction_coefficient".to_string(),
13774 BTreeMap::from([(
13775 "friction_coefficient".to_string(),
13776 options.friction_coefficient.to_string(),
13777 )]),
13778 ));
13779 }
13780 Ok(())
13781}
13782
13783fn validate_coupled_flow_interfaces(
13784 model: &AnalysisModel,
13785 family: &str,
13786) -> Result<(), (String, BTreeMap<String, String>)> {
13787 for interface in &model.interfaces {
13788 match &interface.kind {
13789 AnalysisInterfaceKind::Contact(_) => {
13790 return Err((
13791 format!(
13792 "{family} coupling does not accept structural contact interfaces as fluid/thermal interface mappings"
13793 ),
13794 BTreeMap::from([
13795 ("interface_id".to_string(), interface.interface_id.clone()),
13796 (
13797 "primary_region_id".to_string(),
13798 interface.primary_region_id.clone(),
13799 ),
13800 (
13801 "secondary_region_id".to_string(),
13802 interface.secondary_region_id.clone(),
13803 ),
13804 ("interface_kind".to_string(), "contact".to_string()),
13805 ]),
13806 ));
13807 }
13808 AnalysisInterfaceKind::FluidStructure(fluid_structure) => {
13809 if family != "FSI" {
13810 return Err((
13811 format!(
13812 "{family} coupling does not accept fluid-structure interfaces as fluid/thermal interface mappings"
13813 ),
13814 BTreeMap::from([
13815 ("interface_id".to_string(), interface.interface_id.clone()),
13816 (
13817 "primary_region_id".to_string(),
13818 interface.primary_region_id.clone(),
13819 ),
13820 (
13821 "secondary_region_id".to_string(),
13822 interface.secondary_region_id.clone(),
13823 ),
13824 ("interface_kind".to_string(), "fluid_structure".to_string()),
13825 ]),
13826 ));
13827 }
13828 if !fluid_structure.normal_stiffness_pa_per_m.is_finite()
13829 || fluid_structure.normal_stiffness_pa_per_m <= 0.0
13830 {
13831 return Err((
13832 format!(
13833 "{family} fluid-structure interface requires finite positive normal_stiffness_pa_per_m"
13834 ),
13835 BTreeMap::from([
13836 ("interface_id".to_string(), interface.interface_id.clone()),
13837 (
13838 "normal_stiffness_pa_per_m".to_string(),
13839 fluid_structure.normal_stiffness_pa_per_m.to_string(),
13840 ),
13841 ]),
13842 ));
13843 }
13844 if !fluid_structure.damping_ratio.is_finite() || fluid_structure.damping_ratio < 0.0
13845 {
13846 return Err((
13847 format!(
13848 "{family} fluid-structure interface requires finite non-negative damping_ratio"
13849 ),
13850 BTreeMap::from([
13851 ("interface_id".to_string(), interface.interface_id.clone()),
13852 (
13853 "damping_ratio".to_string(),
13854 fluid_structure.damping_ratio.to_string(),
13855 ),
13856 ]),
13857 ));
13858 }
13859 if !fluid_structure.relaxation_factor.is_finite()
13860 || fluid_structure.relaxation_factor <= 0.0
13861 || fluid_structure.relaxation_factor > 1.0
13862 {
13863 return Err((
13864 format!(
13865 "{family} fluid-structure interface requires relaxation_factor in (0, 1]"
13866 ),
13867 BTreeMap::from([
13868 ("interface_id".to_string(), interface.interface_id.clone()),
13869 (
13870 "relaxation_factor".to_string(),
13871 fluid_structure.relaxation_factor.to_string(),
13872 ),
13873 ]),
13874 ));
13875 }
13876 }
13877 AnalysisInterfaceKind::ConjugateHeatTransfer(cht_interface) => {
13878 if family != "CHT" {
13879 return Err((
13880 format!(
13881 "{family} coupling does not accept conjugate heat-transfer interfaces as fluid/structure interface mappings"
13882 ),
13883 BTreeMap::from([
13884 ("interface_id".to_string(), interface.interface_id.clone()),
13885 (
13886 "primary_region_id".to_string(),
13887 interface.primary_region_id.clone(),
13888 ),
13889 (
13890 "secondary_region_id".to_string(),
13891 interface.secondary_region_id.clone(),
13892 ),
13893 (
13894 "interface_kind".to_string(),
13895 "conjugate_heat_transfer".to_string(),
13896 ),
13897 ]),
13898 ));
13899 }
13900 if !cht_interface.thermal_conductance_w_per_m2k.is_finite()
13901 || cht_interface.thermal_conductance_w_per_m2k <= 0.0
13902 {
13903 return Err((
13904 format!(
13905 "{family} conjugate heat-transfer interface requires finite positive thermal_conductance_w_per_m2k"
13906 ),
13907 BTreeMap::from([
13908 ("interface_id".to_string(), interface.interface_id.clone()),
13909 (
13910 "thermal_conductance_w_per_m2k".to_string(),
13911 cht_interface.thermal_conductance_w_per_m2k.to_string(),
13912 ),
13913 ]),
13914 ));
13915 }
13916 if !cht_interface.contact_resistance_m2k_per_w.is_finite()
13917 || cht_interface.contact_resistance_m2k_per_w < 0.0
13918 {
13919 return Err((
13920 format!(
13921 "{family} conjugate heat-transfer interface requires finite non-negative contact_resistance_m2k_per_w"
13922 ),
13923 BTreeMap::from([
13924 ("interface_id".to_string(), interface.interface_id.clone()),
13925 (
13926 "contact_resistance_m2k_per_w".to_string(),
13927 cht_interface.contact_resistance_m2k_per_w.to_string(),
13928 ),
13929 ]),
13930 ));
13931 }
13932 if !cht_interface.relaxation_factor.is_finite()
13933 || cht_interface.relaxation_factor <= 0.0
13934 || cht_interface.relaxation_factor > 1.0
13935 {
13936 return Err((
13937 format!(
13938 "{family} conjugate heat-transfer interface requires relaxation_factor in (0, 1]"
13939 ),
13940 BTreeMap::from([
13941 ("interface_id".to_string(), interface.interface_id.clone()),
13942 (
13943 "relaxation_factor".to_string(),
13944 cht_interface.relaxation_factor.to_string(),
13945 ),
13946 ]),
13947 ));
13948 }
13949 }
13950 }
13951 }
13952 Ok(())
13953}
13954
13955#[derive(Debug, Clone, Deserialize)]
13956struct ThermoFieldArtifact {
13957 schema_version: String,
13958 source_geometry_id: String,
13959 source_geometry_revision: u32,
13960 #[serde(default)]
13961 artifact_status: Option<String>,
13962 #[serde(default)]
13963 approved_by: Option<String>,
13964 #[serde(default)]
13965 payload_hash: Option<String>,
13966 #[serde(default)]
13967 signature: Option<String>,
13968 #[serde(default)]
13969 field_source: Option<ThermoFieldSource>,
13970 #[serde(default)]
13971 region_temperature_deltas: Vec<ThermoRegionTemperatureDelta>,
13972 #[serde(default)]
13973 time_profile: Vec<ThermoTimeProfilePoint>,
13974}
13975
13976fn thermo_field_payload_hash(artifact: &ThermoFieldArtifact) -> String {
13977 let source = artifact.field_source.as_ref();
13978 let source_id = source.map(|s| s.source_id.as_str()).unwrap_or("");
13979 let source_revision = source.map(|s| s.revision).unwrap_or(0);
13980 let interpolation = source
13981 .and_then(|s| s.interpolation_mode)
13982 .map(|mode| match mode {
13983 ThermoFieldInterpolationMode::Linear => "linear",
13984 ThermoFieldInterpolationMode::Step => "step",
13985 })
13986 .unwrap_or("");
13987 let expected_regions = source
13988 .map(|s| s.expected_region_ids.join(","))
13989 .unwrap_or_default();
13990 let region_terms = artifact
13991 .region_temperature_deltas
13992 .iter()
13993 .map(|delta| {
13994 format!(
13995 "{}:{:016x}",
13996 delta.region_id,
13997 delta.temperature_delta_k.to_bits()
13998 )
13999 })
14000 .collect::<Vec<_>>()
14001 .join(",");
14002 let time_terms = artifact
14003 .time_profile
14004 .iter()
14005 .map(|point| {
14006 format!(
14007 "{:016x}:{:016x}",
14008 point.normalized_time.to_bits(),
14009 point.scale.to_bits()
14010 )
14011 })
14012 .collect::<Vec<_>>()
14013 .join(",");
14014 let canonical = format!(
14015 "{}|{}|{}|{}|{}|{}|{}|{}|{}",
14016 artifact.schema_version,
14017 artifact.source_geometry_id,
14018 artifact.source_geometry_revision,
14019 source_id,
14020 source_revision,
14021 interpolation,
14022 expected_regions,
14023 region_terms,
14024 time_terms
14025 );
14026 let mut hasher = Sha256::new();
14027 hasher.update(canonical.as_bytes());
14028 format!("sha256:{:x}", hasher.finalize())
14029}
14030
14031fn thermo_field_signature(payload_hash: &str, approved_by: &str, signing_key: &str) -> String {
14032 let mut hasher = Sha256::new();
14033 hasher.update(format!("{payload_hash}:{approved_by}:{signing_key}").as_bytes());
14034 format!("sigv1:sha256:{:x}", hasher.finalize())
14035}
14036
14037fn resolve_thermo_coupling_options(
14038 model: &AnalysisModel,
14039 options: Option<ThermoMechanicalCouplingOptions>,
14040 operation: &'static str,
14041 op_version: &'static str,
14042 context: &OperationContext,
14043) -> Result<Option<ThermoMechanicalCouplingOptions>, OperationErrorEnvelope> {
14044 let Some(mut options) = options else {
14045 return Ok(None);
14046 };
14047 let Some(field_artifact_id) = options.field_artifact_id.as_deref() else {
14048 return Ok(Some(options));
14049 };
14050
14051 let root = thermo_field_artifact_root();
14052 let path = root.join(format!("{field_artifact_id}.json"));
14053 if !fs_exists(&path).map_err(|err| {
14054 operation_error(
14055 operation,
14056 op_version,
14057 context,
14058 OperationErrorSpec {
14059 error_code: "RM.FEA.RUN_THERMO_FIELD.STORE_FAILED",
14060 error_type: OperationErrorType::Internal,
14061 retryable: true,
14062 severity: OperationErrorSeverity::Error,
14063 },
14064 format!("failed to inspect thermo field artifact: {err}"),
14065 BTreeMap::from([
14066 (
14067 "thermo_field_artifact_id".to_string(),
14068 field_artifact_id.to_string(),
14069 ),
14070 (
14071 "thermo_field_artifact_path".to_string(),
14072 path.display().to_string(),
14073 ),
14074 ]),
14075 )
14076 })? {
14077 return Err(operation_error(
14078 operation,
14079 op_version,
14080 context,
14081 OperationErrorSpec {
14082 error_code: "RM.FEA.RUN_THERMO_FIELD.NOT_FOUND",
14083 error_type: OperationErrorType::Input,
14084 retryable: false,
14085 severity: OperationErrorSeverity::Error,
14086 },
14087 format!(
14088 "thermo field artifact '{}' was not found",
14089 field_artifact_id
14090 ),
14091 BTreeMap::from([
14092 (
14093 "thermo_field_artifact_id".to_string(),
14094 field_artifact_id.to_string(),
14095 ),
14096 (
14097 "thermo_field_artifact_path".to_string(),
14098 path.display().to_string(),
14099 ),
14100 ]),
14101 ));
14102 }
14103
14104 let raw = fs_read_to_string(&path).map_err(|err| {
14105 operation_error(
14106 operation,
14107 op_version,
14108 context,
14109 OperationErrorSpec {
14110 error_code: "RM.FEA.RUN_THERMO_FIELD.STORE_FAILED",
14111 error_type: OperationErrorType::Internal,
14112 retryable: true,
14113 severity: OperationErrorSeverity::Error,
14114 },
14115 format!("failed to read thermo field artifact: {err}"),
14116 BTreeMap::from([(
14117 "thermo_field_artifact_path".to_string(),
14118 path.display().to_string(),
14119 )]),
14120 )
14121 })?;
14122 let artifact: ThermoFieldArtifact = serde_json::from_str(&raw).map_err(|err| {
14123 operation_error(
14124 operation,
14125 op_version,
14126 context,
14127 OperationErrorSpec {
14128 error_code: "RM.FEA.RUN_THERMO_FIELD.INVALID",
14129 error_type: OperationErrorType::Validation,
14130 retryable: false,
14131 severity: OperationErrorSeverity::Error,
14132 },
14133 format!("invalid thermo field artifact payload: {err}"),
14134 BTreeMap::from([(
14135 "thermo_field_artifact_path".to_string(),
14136 path.display().to_string(),
14137 )]),
14138 )
14139 })?;
14140
14141 if artifact.schema_version != "fea_thermo_field_artifact/v1"
14142 && artifact.schema_version != "analysis_thermo_field_artifact/v1"
14143 {
14144 return Err(operation_error(
14145 operation,
14146 op_version,
14147 context,
14148 OperationErrorSpec {
14149 error_code: "RM.FEA.RUN_THERMO_FIELD.SCHEMA_UNSUPPORTED",
14150 error_type: OperationErrorType::Validation,
14151 retryable: false,
14152 severity: OperationErrorSeverity::Error,
14153 },
14154 format!(
14155 "thermo field artifact schema '{}' is not supported",
14156 artifact.schema_version
14157 ),
14158 BTreeMap::from([
14159 (
14160 "thermo_field_artifact_id".to_string(),
14161 field_artifact_id.to_string(),
14162 ),
14163 (
14164 "thermo_field_artifact_schema".to_string(),
14165 artifact.schema_version.clone(),
14166 ),
14167 ]),
14168 ));
14169 }
14170
14171 if artifact.source_geometry_id != model.geometry_id
14172 || artifact.source_geometry_revision != model.geometry_revision
14173 {
14174 return Err(operation_error(
14175 operation,
14176 op_version,
14177 context,
14178 OperationErrorSpec {
14179 error_code: "RM.FEA.RUN_THERMO_FIELD.MISMATCH",
14180 error_type: OperationErrorType::Validation,
14181 retryable: false,
14182 severity: OperationErrorSeverity::Error,
14183 },
14184 "thermo field artifact geometry lineage does not match FEA model",
14185 BTreeMap::from([
14186 (
14187 "thermo_field_artifact_id".to_string(),
14188 field_artifact_id.to_string(),
14189 ),
14190 ("model_geometry_id".to_string(), model.geometry_id.clone()),
14191 (
14192 "model_geometry_revision".to_string(),
14193 model.geometry_revision.to_string(),
14194 ),
14195 (
14196 "artifact_geometry_id".to_string(),
14197 artifact.source_geometry_id.clone(),
14198 ),
14199 (
14200 "artifact_geometry_revision".to_string(),
14201 artifact.source_geometry_revision.to_string(),
14202 ),
14203 ]),
14204 ));
14205 }
14206
14207 let expected_hash = thermo_field_payload_hash(&artifact);
14208 if artifact.payload_hash.as_deref() != Some(expected_hash.as_str()) {
14209 return Err(operation_error(
14210 operation,
14211 op_version,
14212 context,
14213 OperationErrorSpec {
14214 error_code: "RM.FEA.RUN_THERMO_FIELD.DIGEST_MISMATCH",
14215 error_type: OperationErrorType::Validation,
14216 retryable: false,
14217 severity: OperationErrorSeverity::Error,
14218 },
14219 "thermo field artifact payload hash does not match payload contents",
14220 BTreeMap::from([
14221 (
14222 "thermo_field_artifact_id".to_string(),
14223 field_artifact_id.to_string(),
14224 ),
14225 ("expected_payload_hash".to_string(), expected_hash),
14226 (
14227 "artifact_payload_hash".to_string(),
14228 artifact.payload_hash.clone().unwrap_or_default(),
14229 ),
14230 ]),
14231 ));
14232 }
14233
14234 if matches!(artifact.artifact_status.as_deref(), Some("approved")) {
14235 let Some(approved_by) = artifact.approved_by.as_deref() else {
14236 return Err(operation_error(
14237 operation,
14238 op_version,
14239 context,
14240 OperationErrorSpec {
14241 error_code: "RM.FEA.RUN_THERMO_FIELD.APPROVER_MISSING",
14242 error_type: OperationErrorType::Validation,
14243 retryable: false,
14244 severity: OperationErrorSeverity::Error,
14245 },
14246 "approved thermo field artifact is missing approved_by",
14247 BTreeMap::from([(
14248 "thermo_field_artifact_id".to_string(),
14249 field_artifact_id.to_string(),
14250 )]),
14251 ));
14252 };
14253
14254 let allowed = std::env::var("RUNMAT_THERMO_FIELD_ALLOWED_APPROVERS")
14255 .ok()
14256 .map(|value| {
14257 value
14258 .split(',')
14259 .map(|entry| entry.trim().to_string())
14260 .filter(|entry| !entry.is_empty())
14261 .collect::<Vec<_>>()
14262 })
14263 .unwrap_or_default();
14264 if !allowed.is_empty() && !allowed.iter().any(|entry| entry == approved_by) {
14265 return Err(operation_error(
14266 operation,
14267 op_version,
14268 context,
14269 OperationErrorSpec {
14270 error_code: "RM.FEA.RUN_THERMO_FIELD.APPROVER_UNAUTHORIZED",
14271 error_type: OperationErrorType::Validation,
14272 retryable: false,
14273 severity: OperationErrorSeverity::Error,
14274 },
14275 "thermo field artifact approver is not authorized",
14276 BTreeMap::from([
14277 (
14278 "thermo_field_artifact_id".to_string(),
14279 field_artifact_id.to_string(),
14280 ),
14281 ("approved_by".to_string(), approved_by.to_string()),
14282 ]),
14283 ));
14284 }
14285
14286 let signing_key = std::env::var("RUNMAT_THERMO_FIELD_SIGNING_KEY")
14287 .unwrap_or_else(|_| "runmat-dev-thermo-signing-key".to_string());
14288 let expected_signature = thermo_field_signature(&expected_hash, approved_by, &signing_key);
14289 if artifact.signature.as_deref() != Some(expected_signature.as_str()) {
14290 return Err(operation_error(
14291 operation,
14292 op_version,
14293 context,
14294 OperationErrorSpec {
14295 error_code: "RM.FEA.RUN_THERMO_FIELD.SIGNATURE_INVALID",
14296 error_type: OperationErrorType::Validation,
14297 retryable: false,
14298 severity: OperationErrorSeverity::Error,
14299 },
14300 "thermo field artifact signature validation failed",
14301 BTreeMap::from([
14302 (
14303 "thermo_field_artifact_id".to_string(),
14304 field_artifact_id.to_string(),
14305 ),
14306 ("expected_signature".to_string(), expected_signature),
14307 (
14308 "artifact_signature".to_string(),
14309 artifact.signature.clone().unwrap_or_default(),
14310 ),
14311 ]),
14312 ));
14313 }
14314 }
14315
14316 options.field_source = artifact.field_source;
14317 options.region_temperature_deltas = artifact.region_temperature_deltas;
14318 options.time_profile = artifact.time_profile;
14319
14320 Ok(Some(options))
14321}
14322
14323fn resolve_run_prep_context(
14324 model: &AnalysisModel,
14325 prep_artifact_id: Option<&str>,
14326 legacy_prep_context: Option<AnalysisRunPrepContext>,
14327 operation: &'static str,
14328 op_version: &'static str,
14329 context: &OperationContext,
14330) -> Result<Option<AnalysisRunPrepContext>, OperationErrorEnvelope> {
14331 if prep_artifact_id.is_none() {
14332 if legacy_prep_context.is_some() {
14333 return Err(operation_error(
14334 operation,
14335 op_version,
14336 context,
14337 OperationErrorSpec {
14338 error_code: "RM.FEA.RUN_PREP.UNTRUSTED_CONTEXT",
14339 error_type: OperationErrorType::Input,
14340 retryable: false,
14341 severity: OperationErrorSeverity::Error,
14342 },
14343 "FEA run prep_context must be referenced by prep_artifact_id",
14344 BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
14345 ));
14346 }
14347 return Ok(None);
14348 }
14349
14350 let prep_artifact_id = prep_artifact_id.expect("checked is_some");
14351 let Some(artifact) = crate::geometry::load_prep_artifact(prep_artifact_id).map_err(|err| {
14352 operation_error(
14353 operation,
14354 op_version,
14355 context,
14356 OperationErrorSpec {
14357 error_code: "RM.FEA.RUN_PREP.STORE_FAILED",
14358 error_type: OperationErrorType::Internal,
14359 retryable: true,
14360 severity: OperationErrorSeverity::Error,
14361 },
14362 format!("failed to load prep artifact: {err}"),
14363 BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
14364 )
14365 })?
14366 else {
14367 return Err(operation_error(
14368 operation,
14369 op_version,
14370 context,
14371 OperationErrorSpec {
14372 error_code: "RM.FEA.RUN_PREP.NOT_FOUND",
14373 error_type: OperationErrorType::Input,
14374 retryable: false,
14375 severity: OperationErrorSeverity::Error,
14376 },
14377 format!("prep artifact '{}' was not found", prep_artifact_id),
14378 BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
14379 ));
14380 };
14381
14382 if artifact.schema_version != "geometry_prep_artifact/v1" {
14383 return Err(operation_error(
14384 operation,
14385 op_version,
14386 context,
14387 OperationErrorSpec {
14388 error_code: "RM.FEA.RUN_PREP.SCHEMA_UNSUPPORTED",
14389 error_type: OperationErrorType::Validation,
14390 retryable: false,
14391 severity: OperationErrorSeverity::Error,
14392 },
14393 format!(
14394 "prep artifact schema '{}' is not supported",
14395 artifact.schema_version
14396 ),
14397 BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
14398 ));
14399 }
14400
14401 if artifact.source_geometry_id != model.geometry_id
14402 || artifact.source_geometry_revision != model.geometry_revision
14403 {
14404 crate::geometry::record_prep_mismatch_reject();
14405 return Err(operation_error(
14406 operation,
14407 op_version,
14408 context,
14409 OperationErrorSpec {
14410 error_code: "RM.FEA.RUN_PREP.MISMATCH",
14411 error_type: OperationErrorType::Validation,
14412 retryable: false,
14413 severity: OperationErrorSeverity::Error,
14414 },
14415 "prep artifact geometry lineage does not match FEA model",
14416 BTreeMap::from([
14417 ("prep_artifact_id".to_string(), prep_artifact_id.to_string()),
14418 ("model_geometry_id".to_string(), model.geometry_id.clone()),
14419 (
14420 "model_geometry_revision".to_string(),
14421 model.geometry_revision.to_string(),
14422 ),
14423 (
14424 "prep_geometry_id".to_string(),
14425 artifact.source_geometry_id.clone(),
14426 ),
14427 (
14428 "prep_geometry_revision".to_string(),
14429 artifact.source_geometry_revision.to_string(),
14430 ),
14431 ]),
14432 ));
14433 }
14434
14435 if crate::geometry::require_latest_prep_revision() {
14436 if let Some(latest_revision) = crate::geometry::latest_prep_revision_for_geometry(
14437 &model.geometry_id,
14438 )
14439 .map_err(|err| {
14440 operation_error(
14441 operation,
14442 op_version,
14443 context,
14444 OperationErrorSpec {
14445 error_code: "RM.FEA.RUN_PREP.STORE_FAILED",
14446 error_type: OperationErrorType::Internal,
14447 retryable: true,
14448 severity: OperationErrorSeverity::Error,
14449 },
14450 format!("failed to evaluate prep artifact freshness: {err}"),
14451 BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
14452 )
14453 })? {
14454 if artifact.source_geometry_revision < latest_revision {
14455 crate::geometry::record_prep_stale_reject();
14456 return Err(operation_error(
14457 operation,
14458 op_version,
14459 context,
14460 OperationErrorSpec {
14461 error_code: "RM.FEA.RUN_PREP.STALE",
14462 error_type: OperationErrorType::Validation,
14463 retryable: false,
14464 severity: OperationErrorSeverity::Error,
14465 },
14466 "prep artifact is stale; a newer geometry revision prep artifact exists",
14467 BTreeMap::from([
14468 ("prep_artifact_id".to_string(), prep_artifact_id.to_string()),
14469 (
14470 "prep_geometry_revision".to_string(),
14471 artifact.source_geometry_revision.to_string(),
14472 ),
14473 (
14474 "latest_geometry_revision".to_string(),
14475 latest_revision.to_string(),
14476 ),
14477 ]),
14478 ));
14479 }
14480 }
14481 }
14482
14483 let prepared_mesh_count = artifact.prep.prepared_meshes.len();
14484 let prepared_node_count = artifact
14485 .prep
14486 .prepared_meshes
14487 .iter()
14488 .map(|mesh| mesh.node_count as usize)
14489 .sum::<usize>();
14490 let prepared_element_count = artifact
14491 .prep
14492 .prepared_meshes
14493 .iter()
14494 .map(|mesh| mesh.element_count as usize)
14495 .sum::<usize>();
14496 let mesh_count = prepared_mesh_count.max(1) as f64;
14497 let topology_surface_patch_ratio = artifact
14498 .prep
14499 .prepared_meshes
14500 .iter()
14501 .filter(|mesh| mesh.connectivity_class == MeshConnectivityClass::SurfacePatch)
14502 .count() as f64
14503 / mesh_count;
14504 let topology_volume_core_ratio = artifact
14505 .prep
14506 .prepared_meshes
14507 .iter()
14508 .filter(|mesh| mesh.connectivity_class == MeshConnectivityClass::VolumeCore)
14509 .count() as f64
14510 / mesh_count;
14511 let topology_mixed_family_ratio = artifact
14512 .prep
14513 .prepared_meshes
14514 .iter()
14515 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Mixed)
14516 .count() as f64
14517 / mesh_count;
14518 let topology_triangle_family_ratio = artifact
14519 .prep
14520 .prepared_meshes
14521 .iter()
14522 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Triangle)
14523 .count() as f64
14524 / mesh_count;
14525 let topology_quad_family_ratio = artifact
14526 .prep
14527 .prepared_meshes
14528 .iter()
14529 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Quad)
14530 .count() as f64
14531 / mesh_count;
14532 let topology_tet_family_ratio = artifact
14533 .prep
14534 .prepared_meshes
14535 .iter()
14536 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Tet)
14537 .count() as f64
14538 / mesh_count;
14539 let topology_hex_family_ratio = artifact
14540 .prep
14541 .prepared_meshes
14542 .iter()
14543 .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Hex)
14544 .count() as f64
14545 / mesh_count;
14546 let topology_region_span_mean = artifact
14547 .prep
14548 .prepared_meshes
14549 .iter()
14550 .map(|mesh| mesh.region_span_hint as f64)
14551 .sum::<f64>()
14552 / mesh_count;
14553 let region_block_count = artifact.prep.region_mappings.len().max(1);
14554 let region_mesh_counts = artifact
14555 .prep
14556 .region_mappings
14557 .iter()
14558 .map(|mapping| mapping.prepared_mesh_ids.len().max(1) as f64)
14559 .collect::<Vec<_>>();
14560 let topology_region_mesh_mean = if region_mesh_counts.is_empty() {
14561 1.0
14562 } else {
14563 region_mesh_counts.iter().sum::<f64>() / region_mesh_counts.len() as f64
14564 };
14565 let topology_region_mesh_variance = if region_mesh_counts.len() <= 1 {
14566 0.0
14567 } else {
14568 region_mesh_counts
14569 .iter()
14570 .map(|count| {
14571 let delta = *count - topology_region_mesh_mean;
14572 delta * delta
14573 })
14574 .sum::<f64>()
14575 / region_mesh_counts.len() as f64
14576 };
14577 let topology_dof_multiplier = if model.loads.is_empty() {
14578 1.0
14579 } else {
14580 ((prepared_node_count as f64 / (model.loads.len() as f64 * 3.0)).clamp(1.0, 4.0) * 0.35
14581 + 1.0)
14582 .min(4.0)
14583 };
14584 let topology_bandwidth_estimate = artifact
14585 .prep
14586 .prepared_meshes
14587 .iter()
14588 .map(|mesh| mesh.region_span_hint)
14589 .sum::<u32>()
14590 .clamp(1, 128);
14591 let mapped_region_participation_ratio = if artifact.prep.region_mappings.is_empty() {
14592 0.0
14593 } else {
14594 (artifact
14595 .prep
14596 .region_mappings
14597 .iter()
14598 .filter(|mapping| {
14599 model
14600 .loads
14601 .iter()
14602 .any(|load| load.region_id == mapping.region_id)
14603 || model
14604 .boundary_conditions
14605 .iter()
14606 .any(|bc| bc.region_id == mapping.region_id)
14607 })
14608 .count() as f64
14609 / artifact.prep.region_mappings.len() as f64)
14610 .clamp(0.0, 1.0)
14611 };
14612 let coordinate_span_x_m = artifact
14613 .prep
14614 .prepared_meshes
14615 .iter()
14616 .map(|mesh| mesh.coordinate_span_m[0])
14617 .fold(0.0_f64, f64::max)
14618 .max(1.0e-12);
14619 let coordinate_span_y_m = artifact
14620 .prep
14621 .prepared_meshes
14622 .iter()
14623 .map(|mesh| mesh.coordinate_span_m[1])
14624 .fold(0.0_f64, f64::max);
14625 let coordinate_span_z_m = artifact
14626 .prep
14627 .prepared_meshes
14628 .iter()
14629 .map(|mesh| mesh.coordinate_span_m[2])
14630 .fold(0.0_f64, f64::max);
14631 let coordinate_active_dimension_count = artifact
14632 .prep
14633 .prepared_meshes
14634 .iter()
14635 .map(|mesh| mesh.coordinate_active_dimension_count as usize)
14636 .max()
14637 .unwrap_or(1)
14638 .max(1);
14639 let (coordinate_length_sum, coordinate_length_weight) = artifact
14640 .prep
14641 .prepared_meshes
14642 .iter()
14643 .filter_map(|mesh| {
14644 let length = mesh.coordinate_characteristic_length_m;
14645 (length.is_finite() && length > 0.0)
14646 .then_some((length, mesh.element_count.max(1) as f64))
14647 })
14648 .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (length, weight)| {
14649 (sum + length * weight, weight_sum + weight)
14650 });
14651 let coordinate_characteristic_length_m = if coordinate_length_weight > 0.0 {
14652 coordinate_length_sum / coordinate_length_weight
14653 } else {
14654 1.0
14655 };
14656 let element_geometry_node_count = artifact
14657 .prep
14658 .prepared_meshes
14659 .iter()
14660 .map(|mesh| mesh.element_geometry_node_count as usize)
14661 .sum::<usize>();
14662 let element_geometry_edge_count = artifact
14663 .prep
14664 .prepared_meshes
14665 .iter()
14666 .map(|mesh| mesh.element_geometry_edge_count as usize)
14667 .sum::<usize>();
14668 let (edge_length_sum, edge_length_weight) = artifact
14669 .prep
14670 .prepared_meshes
14671 .iter()
14672 .filter_map(|mesh| {
14673 let length = mesh.mean_element_edge_length_m;
14674 (length.is_finite() && length > 0.0)
14675 .then_some((length, mesh.element_count.max(1) as f64))
14676 })
14677 .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (length, weight)| {
14678 (sum + length * weight, weight_sum + weight)
14679 });
14680 let mean_element_edge_length_m = if edge_length_weight > 0.0 {
14681 edge_length_sum / edge_length_weight
14682 } else {
14683 0.0
14684 };
14685 let (area_sum, area_weight) = artifact
14686 .prep
14687 .prepared_meshes
14688 .iter()
14689 .filter_map(|mesh| {
14690 let area = mesh.mean_element_area_m2;
14691 (area.is_finite() && area > 0.0).then_some((area, mesh.element_count.max(1) as f64))
14692 })
14693 .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (area, weight)| {
14694 (sum + area * weight, weight_sum + weight)
14695 });
14696 let mean_element_area_m2 = if area_weight > 0.0 {
14697 area_sum / area_weight
14698 } else {
14699 0.0
14700 };
14701 let (coverage_sum, coverage_weight) = artifact
14702 .prep
14703 .prepared_meshes
14704 .iter()
14705 .map(|mesh| {
14706 (
14707 mesh.element_geometry_coverage_ratio.clamp(0.0, 1.0),
14708 mesh.element_count.max(1) as f64,
14709 )
14710 })
14711 .fold(
14712 (0.0_f64, 0.0_f64),
14713 |(sum, weight_sum), (coverage, weight)| (sum + coverage * weight, weight_sum + weight),
14714 );
14715 let element_geometry_coverage_ratio = if coverage_weight > 0.0 {
14716 coverage_sum / coverage_weight
14717 } else {
14718 0.0
14719 };
14720 let (reference_element_coordinates_m, reference_element_area_m2) = artifact
14721 .prep
14722 .prepared_meshes
14723 .iter()
14724 .find_map(|mesh| {
14725 let area = mesh.reference_element_area_m2;
14726 (area.is_finite() && area > 0.0).then_some((mesh.reference_element_coordinates_m, area))
14727 })
14728 .unwrap_or(([[0.0; 3]; 3], 0.0));
14729 let (
14730 element_topology_sample_element_count,
14731 element_topology_sample_edge_count,
14732 element_topology_sample_edge_nodes,
14733 element_topology_sample_node_coordinates_m,
14734 element_topology_sample_element_edges,
14735 element_topology_sample_element_orientations,
14736 element_topology_sample_element_areas_m2,
14737 ) = artifact
14738 .prep
14739 .prepared_meshes
14740 .iter()
14741 .find_map(|mesh| {
14742 (mesh.element_topology_sample_element_count > 0
14743 && mesh.element_topology_sample_edge_count > 0)
14744 .then_some((
14745 mesh.element_topology_sample_element_count as usize,
14746 mesh.element_topology_sample_edge_count as usize,
14747 mesh.element_topology_sample_edge_nodes,
14748 mesh.element_topology_sample_node_coordinates_m,
14749 mesh.element_topology_sample_element_edges,
14750 mesh.element_topology_sample_element_orientations,
14751 mesh.element_topology_sample_element_areas_m2,
14752 ))
14753 })
14754 .unwrap_or((
14755 0,
14756 0,
14757 [[0; 2]; 8],
14758 [[0.0; 3]; 8],
14759 [[0; 3]; 4],
14760 [[0; 3]; 4],
14761 [0.0; 4],
14762 ));
14763 let mut element_topology_node_coordinates_m = Vec::<[f64; 3]>::new();
14764 let mut element_topology_edge_nodes = Vec::<[u32; 2]>::new();
14765 let mut element_topology_element_edges = Vec::<[u32; 3]>::new();
14766 let mut element_topology_element_orientations = Vec::<[i8; 3]>::new();
14767 let mut element_topology_element_areas_m2 = Vec::<f64>::new();
14768 let mut node_offset = 0_u32;
14769 let mut edge_offset = 0_u32;
14770 for mesh in &artifact.prep.prepared_meshes {
14771 let node_count = mesh.element_topology_node_coordinates_m.len();
14772 element_topology_node_coordinates_m
14773 .extend(mesh.element_topology_node_coordinates_m.iter().copied());
14774 for edge in &mesh.element_topology_edge_nodes {
14775 if let (Some(left), Some(right)) = (
14776 edge[0].checked_add(node_offset),
14777 edge[1].checked_add(node_offset),
14778 ) {
14779 element_topology_edge_nodes.push([left, right]);
14780 }
14781 }
14782 for element_edges in &mesh.element_topology_element_edges {
14783 if let (Some(a), Some(b), Some(c)) = (
14784 element_edges[0].checked_add(edge_offset),
14785 element_edges[1].checked_add(edge_offset),
14786 element_edges[2].checked_add(edge_offset),
14787 ) {
14788 element_topology_element_edges.push([a, b, c]);
14789 }
14790 }
14791 element_topology_element_orientations
14792 .extend(mesh.element_topology_element_orientations.iter().copied());
14793 element_topology_element_areas_m2
14794 .extend(mesh.element_topology_element_areas_m2.iter().copied());
14795 node_offset = node_offset.saturating_add(node_count as u32);
14796 edge_offset = edge_offset.saturating_add(mesh.element_topology_edge_nodes.len() as u32);
14797 }
14798 let control_volume_cell_count = artifact
14799 .prep
14800 .prepared_meshes
14801 .iter()
14802 .map(|mesh| mesh.control_volume_cell_count as usize)
14803 .sum::<usize>();
14804 let control_volume_face_count = artifact
14805 .prep
14806 .prepared_meshes
14807 .iter()
14808 .map(|mesh| mesh.control_volume_face_count as usize)
14809 .sum::<usize>();
14810 let control_volume_internal_face_count = artifact
14811 .prep
14812 .prepared_meshes
14813 .iter()
14814 .map(|mesh| mesh.control_volume_internal_face_count as usize)
14815 .sum::<usize>();
14816 let control_volume_boundary_face_count = artifact
14817 .prep
14818 .prepared_meshes
14819 .iter()
14820 .map(|mesh| mesh.control_volume_boundary_face_count as usize)
14821 .sum::<usize>();
14822 let (control_volume_coverage_sum, control_volume_coverage_weight) = artifact
14823 .prep
14824 .prepared_meshes
14825 .iter()
14826 .map(|mesh| {
14827 (
14828 mesh.control_volume_connectivity_coverage_ratio
14829 .clamp(0.0, 1.0),
14830 mesh.element_count.max(1) as f64,
14831 )
14832 })
14833 .fold(
14834 (0.0_f64, 0.0_f64),
14835 |(sum, weight_sum), (coverage, weight)| (sum + coverage * weight, weight_sum + weight),
14836 );
14837 let control_volume_connectivity_coverage_ratio = if control_volume_coverage_weight > 0.0 {
14838 control_volume_coverage_sum / control_volume_coverage_weight
14839 } else {
14840 0.0
14841 };
14842
14843 Ok(Some(AnalysisRunPrepContext {
14844 prepared_mesh_count,
14845 prepared_node_count,
14846 prepared_element_count,
14847 mapped_region_count: artifact.prep.region_mappings.len(),
14848 min_scaled_jacobian: artifact.prep.quality.min_scaled_jacobian,
14849 mean_aspect_ratio: artifact.prep.quality.mean_aspect_ratio,
14850 inverted_element_count: artifact.prep.quality.inverted_element_count as usize,
14851 mapped_load_count: model
14852 .loads
14853 .iter()
14854 .filter(|load| {
14855 artifact
14856 .prep
14857 .region_mappings
14858 .iter()
14859 .any(|mapping| mapping.region_id == load.region_id)
14860 })
14861 .count(),
14862 mapped_bc_count: model
14863 .boundary_conditions
14864 .iter()
14865 .filter(|bc| {
14866 artifact
14867 .prep
14868 .region_mappings
14869 .iter()
14870 .any(|mapping| mapping.region_id == bc.region_id)
14871 })
14872 .count(),
14873 layout_seed: {
14874 let mut seed = 1469598103934665603_u64;
14875 for mapping in &artifact.prep.region_mappings {
14876 for byte in mapping.region_id.as_bytes() {
14877 seed ^= *byte as u64;
14878 seed = seed.wrapping_mul(1099511628211_u64);
14879 }
14880 }
14881 seed
14882 },
14883 topology_dof_multiplier,
14884 topology_bandwidth_estimate,
14885 mapped_region_participation_ratio,
14886 topology_surface_patch_ratio,
14887 topology_volume_core_ratio,
14888 topology_mixed_family_ratio,
14889 topology_region_span_mean,
14890 topology_region_block_count: region_block_count,
14891 topology_region_mesh_mean,
14892 topology_region_mesh_variance,
14893 topology_triangle_family_ratio,
14894 topology_quad_family_ratio,
14895 topology_tet_family_ratio,
14896 topology_hex_family_ratio,
14897 coordinate_span_x_m,
14898 coordinate_span_y_m,
14899 coordinate_span_z_m,
14900 coordinate_active_dimension_count,
14901 coordinate_characteristic_length_m,
14902 element_geometry_node_count,
14903 element_geometry_edge_count,
14904 mean_element_edge_length_m,
14905 mean_element_area_m2,
14906 element_geometry_coverage_ratio,
14907 reference_element_coordinates_m,
14908 reference_element_area_m2,
14909 control_volume_cell_count,
14910 control_volume_face_count,
14911 control_volume_internal_face_count,
14912 control_volume_boundary_face_count,
14913 control_volume_connectivity_coverage_ratio,
14914 element_topology_sample_element_count,
14915 element_topology_sample_edge_count,
14916 element_topology_sample_edge_nodes,
14917 element_topology_sample_node_coordinates_m,
14918 element_topology_sample_element_edges,
14919 element_topology_sample_element_orientations,
14920 element_topology_sample_element_areas_m2,
14921 element_topology_node_coordinates_m,
14922 element_topology_edge_nodes,
14923 element_topology_element_edges,
14924 element_topology_element_orientations,
14925 element_topology_element_areas_m2,
14926 }))
14927}
14928
14929fn run_solve_ms(run: &AnalysisRunResult) -> Option<f64> {
14930 for code in [
14931 "FEA_NONLINEAR_COST",
14932 "FEA_TRANSIENT_COST",
14933 "FEA_MODAL_COST",
14934 "FEA_ACOUSTIC_COST",
14935 "FEA_CFD_COST",
14936 "FEA_CHT_COST",
14937 "FEA_FSI_COST",
14938 ] {
14939 if let Some(value) = diagnostic_metric(&run.run.diagnostics, code, "solve_ms") {
14940 return Some(value);
14941 }
14942 }
14943 None
14944}
14945
14946fn diagnostic_metric(
14947 diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
14948 code: &str,
14949 key: &str,
14950) -> Option<f64> {
14951 diagnostics
14952 .iter()
14953 .find(|diag| diag.code == code)
14954 .and_then(|diag| {
14955 diag.message
14956 .split_whitespace()
14957 .find_map(|token| token.strip_prefix(&format!("{key}=")))
14958 })
14959 .and_then(|value| value.parse::<f64>().ok())
14960}
14961
14962fn diagnostic_metric_u64(
14963 diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
14964 code: &str,
14965 key: &str,
14966) -> Option<u64> {
14967 diagnostics
14968 .iter()
14969 .find(|diag| diag.code == code)
14970 .and_then(|diag| {
14971 diag.message
14972 .split_whitespace()
14973 .find_map(|token| token.strip_prefix(&format!("{key}=")))
14974 })
14975 .and_then(|value| value.parse::<u64>().ok())
14976}
14977
14978fn diagnostic_metric_bool(
14979 diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
14980 code: &str,
14981 key: &str,
14982) -> Option<bool> {
14983 diagnostics
14984 .iter()
14985 .find(|diag| diag.code == code)
14986 .and_then(|diag| {
14987 diag.message
14988 .split_whitespace()
14989 .find_map(|token| token.strip_prefix(&format!("{key}=")))
14990 })
14991 .and_then(|value| value.parse::<bool>().ok())
14992}
14993
14994fn diagnostic_metric_string(
14995 diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
14996 code: &str,
14997 key: &str,
14998) -> Option<String> {
14999 diagnostics
15000 .iter()
15001 .find(|diag| diag.code == code)
15002 .and_then(|diag| {
15003 diag.message
15004 .split_whitespace()
15005 .find_map(|token| token.strip_prefix(&format!("{key}=")))
15006 })
15007 .map(|value| value.to_string())
15008}
15009
15010fn percentile(sorted_samples: &[f64], ratio: f64) -> Option<f64> {
15011 if sorted_samples.is_empty() {
15012 return None;
15013 }
15014 let index = ((sorted_samples.len() - 1) as f64 * ratio.clamp(0.0, 1.0)).round() as usize;
15015 sorted_samples.get(index).copied()
15016}
15017
15018fn mean(values: &[f64]) -> f64 {
15019 if values.is_empty() {
15020 0.0
15021 } else {
15022 values.iter().sum::<f64>() / values.len() as f64
15023 }
15024}
15025
15026fn calibration_profile_rate(entries: &[AnalysisRunResult], profile: &str) -> Option<f64> {
15027 let values = entries
15028 .iter()
15029 .filter_map(|run| {
15030 diagnostic_metric_string(&run.run.diagnostics, "FEA_PREP_CALIBRATION", "profile")
15031 })
15032 .collect::<Vec<_>>();
15033 if values.is_empty() {
15034 return None;
15035 }
15036 Some(
15037 values
15038 .iter()
15039 .filter(|value| value.as_str() == profile)
15040 .count() as f64
15041 / values.len() as f64,
15042 )
15043}
15044
15045fn diagnostic_warning_rate(entries: &[AnalysisRunResult], code: &str) -> Option<f64> {
15046 let values = entries
15047 .iter()
15048 .filter_map(|run| {
15049 run.run
15050 .diagnostics
15051 .iter()
15052 .find(|diag| diag.code == code)
15053 .map(|diag| {
15054 diag.severity
15055 == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
15056 })
15057 })
15058 .collect::<Vec<_>>();
15059 if values.is_empty() {
15060 None
15061 } else {
15062 Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
15063 }
15064}
15065
15066fn infer_material_models(geometry: &GeometryAsset) -> Vec<MaterialModel> {
15067 let mut materials = Vec::new();
15068 for evidence in &geometry.source_geometry.material_evidence {
15069 let value = evidence.value.to_ascii_lowercase();
15070 let (material_id, name, youngs_modulus_pa, poisson_ratio) = if value.contains("aluminum") {
15071 ("mat_aluminum", "Aluminum", 69e9, 0.33)
15072 } else if value.contains("steel") {
15073 ("mat_steel", "Steel", 200e9, 0.30)
15074 } else if value.contains("polymer") || value.contains("plastic") {
15075 ("mat_polymer", "Polymer", 3.2e9, 0.37)
15076 } else {
15077 ("mat_inferred", "Inferred Material", 100e9, 0.32)
15078 };
15079
15080 if materials
15081 .iter()
15082 .any(|m: &MaterialModel| m.material_id == material_id)
15083 {
15084 continue;
15085 }
15086 materials.push(MaterialModel {
15087 material_id: material_id.to_string(),
15088 name: name.to_string(),
15089 mechanical: MaterialMechanicalModel {
15090 youngs_modulus_pa,
15091 poisson_ratio,
15092 density_kg_per_m3: 7850.0,
15093 },
15094 thermal: MaterialThermalModel {
15095 reference_temperature_k: 293.15,
15096 modulus_temp_coeff_per_k: -2.5e-4,
15097 ..MaterialThermalModel::default()
15098 },
15099 acoustic: None,
15100 electrical: None,
15101 plastic: None,
15102 });
15103 }
15104
15105 if materials.is_empty() {
15106 materials.push(MaterialModel {
15107 material_id: "mat_default_steel".to_string(),
15108 name: "Steel (Default)".to_string(),
15109 mechanical: MaterialMechanicalModel {
15110 youngs_modulus_pa: 200e9,
15111 poisson_ratio: 0.3,
15112 density_kg_per_m3: 7850.0,
15113 },
15114 thermal: MaterialThermalModel {
15115 reference_temperature_k: 293.15,
15116 modulus_temp_coeff_per_k: -2.5e-4,
15117 ..MaterialThermalModel::default()
15118 },
15119 acoustic: None,
15120 electrical: None,
15121 plastic: None,
15122 });
15123 }
15124
15125 materials
15126}
15127
15128fn select_fixed_region_id(
15129 geometry: &GeometryAsset,
15130 prep_regions: Option<&HashSet<String>>,
15131) -> Option<String> {
15132 geometry
15133 .regions
15134 .iter()
15135 .filter(|region| {
15136 prep_regions
15137 .map(|mapped| mapped.contains(®ion.region_id))
15138 .unwrap_or(true)
15139 })
15140 .find(|region| {
15141 let key = format!(
15142 "{} {}",
15143 region.name.to_ascii_lowercase(),
15144 region
15145 .tag
15146 .as_deref()
15147 .unwrap_or_default()
15148 .to_ascii_lowercase()
15149 );
15150 key.contains("root")
15151 || key.contains("base")
15152 || key.contains("fixed")
15153 || key.contains("mount")
15154 })
15155 .map(|region| region.region_id.clone())
15156}
15157
15158fn select_load_region_id(
15159 geometry: &GeometryAsset,
15160 prep_regions: Option<&HashSet<String>>,
15161) -> Option<String> {
15162 geometry
15163 .regions
15164 .iter()
15165 .filter(|region| {
15166 prep_regions
15167 .map(|mapped| mapped.contains(®ion.region_id))
15168 .unwrap_or(true)
15169 })
15170 .find(|region| {
15171 let key = format!(
15172 "{} {}",
15173 region.name.to_ascii_lowercase(),
15174 region
15175 .tag
15176 .as_deref()
15177 .unwrap_or_default()
15178 .to_ascii_lowercase()
15179 );
15180 key.contains("tip")
15181 || key.contains("load")
15182 || key.contains("force")
15183 || key.contains("free")
15184 })
15185 .map(|region| region.region_id.clone())
15186}
15187
15188#[derive(Debug, Clone, Default)]
15189struct EmSweepSummary {
15190 sweep_count: usize,
15191 resonance_peak_frequency_hz: Option<f64>,
15192 resonance_peak_flux_density: Option<f64>,
15193 resonance_bandwidth_hz: Option<f64>,
15194 resonance_quality_factor: Option<f64>,
15195 resonance_flux_gain: Option<f64>,
15196}
15197
15198fn normalize_em_sweep_frequency_hz(
15199 reference_frequency_hz: f64,
15200 sweep_enabled: bool,
15201 requested: &[f64],
15202) -> Option<Vec<f64>> {
15203 let mut values = if sweep_enabled {
15204 requested.to_vec()
15205 } else {
15206 Vec::new()
15207 };
15208 if values.is_empty() {
15209 values.push(reference_frequency_hz);
15210 }
15211 if !values
15212 .iter()
15213 .all(|frequency_hz| frequency_hz.is_finite() && *frequency_hz > 0.0)
15214 {
15215 return None;
15216 }
15217 values.sort_by(|a, b| a.total_cmp(b));
15218 values.dedup_by(|a, b| (*a - *b).abs() <= 1.0e-9);
15219 Some(values)
15220}
15221
15222fn nearest_frequency_index(frequencies_hz: &[f64], target_hz: f64) -> Option<usize> {
15223 frequencies_hz
15224 .iter()
15225 .enumerate()
15226 .min_by(|(_, a), (_, b)| (*a - target_hz).abs().total_cmp(&(*b - target_hz).abs()))
15227 .map(|(index, _)| index)
15228}
15229
15230fn peak_abs_field_value(field: &runmat_analysis_core::AnalysisField) -> f64 {
15231 field
15232 .as_host_f64()
15233 .map(|values| values.iter().copied().map(f64::abs).fold(0.0_f64, f64::max))
15234 .unwrap_or(0.0)
15235}
15236
15237fn summarize_em_sweep(frequencies_hz: &[f64], peak_flux_density: &[f64]) -> EmSweepSummary {
15238 if frequencies_hz.is_empty() || frequencies_hz.len() != peak_flux_density.len() {
15239 return EmSweepSummary::default();
15240 }
15241 let sweep_count = frequencies_hz.len();
15242 let (peak_index, peak_flux_density_value) = peak_flux_density
15243 .iter()
15244 .copied()
15245 .enumerate()
15246 .max_by(|(_, a), (_, b)| a.total_cmp(b))
15247 .unwrap_or((0, 0.0));
15248 let peak_frequency_hz = frequencies_hz[peak_index];
15249 let min_flux_density_value = peak_flux_density
15250 .iter()
15251 .copied()
15252 .fold(f64::INFINITY, f64::min);
15253 let resonance_flux_gain =
15254 (peak_flux_density_value / min_flux_density_value.max(1.0e-12)).max(1.0);
15255
15256 let half_power = peak_flux_density_value * std::f64::consts::FRAC_1_SQRT_2;
15257 let mut left = peak_index;
15258 while left > 0 && peak_flux_density[left - 1] >= half_power {
15259 left -= 1;
15260 }
15261 let mut right = peak_index;
15262 while right + 1 < peak_flux_density.len() && peak_flux_density[right + 1] >= half_power {
15263 right += 1;
15264 }
15265 let resonance_bandwidth_hz = if right > left {
15266 Some((frequencies_hz[right] - frequencies_hz[left]).max(0.0))
15267 } else {
15268 None
15269 };
15270 let resonance_quality_factor = resonance_bandwidth_hz
15271 .filter(|bandwidth| *bandwidth > 0.0)
15272 .map(|bandwidth| (peak_frequency_hz / bandwidth).max(0.0));
15273
15274 EmSweepSummary {
15275 sweep_count,
15276 resonance_peak_frequency_hz: Some(peak_frequency_hz),
15277 resonance_peak_flux_density: Some(peak_flux_density_value),
15278 resonance_bandwidth_hz,
15279 resonance_quality_factor,
15280 resonance_flux_gain: Some(resonance_flux_gain),
15281 }
15282}
15283
15284fn em_sweep_known_answer_diagnostic(
15285 reference_frequency_hz: f64,
15286 frequencies_hz: &[f64],
15287 metrics: &EmSweepSummary,
15288) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
15289 let min_frequency_hz = frequencies_hz.iter().copied().fold(f64::INFINITY, f64::min);
15290 let max_frequency_hz = frequencies_hz.iter().copied().fold(0.0_f64, f64::max);
15291 let reference_scale = reference_frequency_hz.abs().max(1.0e-9);
15292 let reference_frequency_in_sweep_ratio = if frequencies_hz.iter().any(|frequency_hz| {
15293 (*frequency_hz - reference_frequency_hz).abs() <= reference_scale * 1.0e-9
15294 }) {
15295 1.0
15296 } else {
15297 0.0
15298 };
15299 let reference_frequency_bracket_ratio = if min_frequency_hz <= reference_frequency_hz
15300 && reference_frequency_hz <= max_frequency_hz
15301 {
15302 1.0
15303 } else {
15304 0.0
15305 };
15306 let normalized_peak_frequency_error_ratio = metrics
15307 .resonance_peak_frequency_hz
15308 .map(|peak_frequency_hz| {
15309 ((peak_frequency_hz - reference_frequency_hz).abs() / reference_scale).clamp(0.0, 1.0)
15310 })
15311 .unwrap_or(1.0);
15312 let resonance_flux_gain = metrics.resonance_flux_gain.unwrap_or(0.0);
15313 let resonance_quality_factor = metrics.resonance_quality_factor.unwrap_or(0.0);
15314 let sweep_known_answer_coverage_ratio = if metrics.sweep_count >= 3
15315 && reference_frequency_in_sweep_ratio >= 1.0
15316 && reference_frequency_bracket_ratio >= 1.0
15317 && normalized_peak_frequency_error_ratio <= 0.25
15318 && resonance_flux_gain >= 1.0
15319 && resonance_quality_factor >= 1.5
15320 {
15321 1.0
15322 } else {
15323 0.0
15324 };
15325 let severity = if sweep_known_answer_coverage_ratio >= 1.0 {
15326 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
15327 } else {
15328 runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
15329 };
15330
15331 runmat_analysis_fea::diagnostics::FeaDiagnostic {
15332 code: "FEA_EM_SWEEP_KNOWN_ANSWER".to_string(),
15333 severity,
15334 message: format!(
15335 "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={}",
15336 metrics.sweep_count,
15337 reference_frequency_hz,
15338 min_frequency_hz,
15339 max_frequency_hz,
15340 reference_frequency_in_sweep_ratio,
15341 reference_frequency_bracket_ratio,
15342 normalized_peak_frequency_error_ratio,
15343 resonance_flux_gain,
15344 resonance_quality_factor,
15345 sweep_known_answer_coverage_ratio,
15346 ),
15347 }
15348}
15349
15350fn infer_material_assignments(
15351 geometry: &GeometryAsset,
15352 materials: &[MaterialModel],
15353 prep_regions: Option<&HashSet<String>>,
15354) -> Vec<MaterialAssignment> {
15355 let default_material = materials
15356 .first()
15357 .map(|m| m.material_id.clone())
15358 .unwrap_or_else(|| "mat_default_steel".to_string());
15359 let mut assignments = Vec::new();
15360
15361 for region in &geometry.regions {
15362 let key = format!(
15363 "{} {}",
15364 region.name.to_ascii_lowercase(),
15365 region
15366 .tag
15367 .as_deref()
15368 .unwrap_or_default()
15369 .to_ascii_lowercase()
15370 );
15371 let assigned_material = if key.contains("aluminum") {
15372 materials
15373 .iter()
15374 .find(|m| m.material_id.contains("aluminum"))
15375 .map(|m| m.material_id.clone())
15376 .unwrap_or_else(|| default_material.clone())
15377 } else if key.contains("steel") {
15378 materials
15379 .iter()
15380 .find(|m| m.material_id.contains("steel"))
15381 .map(|m| m.material_id.clone())
15382 .unwrap_or_else(|| default_material.clone())
15383 } else if key.contains("polymer") || key.contains("plastic") {
15384 materials
15385 .iter()
15386 .find(|m| m.material_id.contains("polymer"))
15387 .map(|m| m.material_id.clone())
15388 .unwrap_or_else(|| default_material.clone())
15389 } else {
15390 default_material.clone()
15391 };
15392
15393 let evidence_confidence = if geometry
15394 .source_geometry
15395 .material_evidence
15396 .iter()
15397 .any(|e| e.confidence == MaterialEvidenceConfidence::High)
15398 {
15399 EvidenceConfidence::Verified
15400 } else if geometry
15401 .source_geometry
15402 .material_evidence
15403 .iter()
15404 .any(|e| e.confidence == MaterialEvidenceConfidence::Medium)
15405 {
15406 EvidenceConfidence::Probable
15407 } else {
15408 EvidenceConfidence::Inferred
15409 };
15410 let confidence = if prep_regions
15411 .map(|mapped| mapped.contains(®ion.region_id))
15412 .unwrap_or(false)
15413 {
15414 EvidenceConfidence::Verified
15415 } else {
15416 evidence_confidence
15417 };
15418
15419 assignments.push(MaterialAssignment {
15420 region_id: region.region_id.clone(),
15421 expected_material_id: assigned_material.clone(),
15422 assigned_material_id: assigned_material,
15423 confidence,
15424 });
15425 }
15426
15427 assignments
15428}
15429
15430fn map_validate_error(
15431 error: AnalysisValidationError,
15432 model: &AnalysisModel,
15433 context: &OperationContext,
15434) -> OperationErrorEnvelope {
15435 let (error_code, message, mut error_context) = match error {
15436 AnalysisValidationError::MissingMaterials => (
15437 "RM.FEA.VALIDATE.MISSING_MATERIALS",
15438 "FEA model must include at least one material".to_string(),
15439 BTreeMap::new(),
15440 ),
15441 AnalysisValidationError::MissingBoundaryConditions => (
15442 "RM.FEA.VALIDATE.MISSING_BCS",
15443 "FEA model must include at least one boundary condition".to_string(),
15444 BTreeMap::new(),
15445 ),
15446 AnalysisValidationError::MissingLoads => (
15447 "RM.FEA.VALIDATE.MISSING_LOADS",
15448 "FEA model must include at least one load".to_string(),
15449 BTreeMap::new(),
15450 ),
15451 AnalysisValidationError::InvalidMomentVector { load_id } => (
15452 "RM.FEA.VALIDATE.INVALID_MOMENT",
15453 format!("moment load {load_id} must have finite components"),
15454 BTreeMap::from([("load_id".to_string(), load_id)]),
15455 ),
15456 AnalysisValidationError::ZeroMomentVector { load_id } => (
15457 "RM.FEA.VALIDATE.ZERO_MOMENT",
15458 format!("moment load {load_id} must have nonzero magnitude"),
15459 BTreeMap::from([("load_id".to_string(), load_id)]),
15460 ),
15461 AnalysisValidationError::UnitMismatch { model, geometry } => (
15462 "RM.FEA.VALIDATE.UNIT_MISMATCH",
15463 format!("model units {model:?} do not match geometry units {geometry:?}"),
15464 BTreeMap::from([
15465 ("model_units".to_string(), format!("{model:?}")),
15466 ("geometry_units".to_string(), format!("{geometry:?}")),
15467 ]),
15468 ),
15469 AnalysisValidationError::FrameMismatch { model, geometry } => (
15470 "RM.FEA.VALIDATE.FRAME_MISMATCH",
15471 format!("model frame {model:?} does not match geometry frame {geometry:?}"),
15472 BTreeMap::from([
15473 ("model_frame".to_string(), format!("{model:?}")),
15474 ("geometry_frame".to_string(), format!("{geometry:?}")),
15475 ]),
15476 ),
15477 };
15478
15479 error_context.insert("analysis_model_id".to_string(), model.model_id.0.clone());
15480 error_context.insert("geometry_id".to_string(), model.geometry_id.clone());
15481
15482 operation_error(
15483 ANALYSIS_VALIDATE_OPERATION,
15484 ANALYSIS_VALIDATE_OP_VERSION,
15485 context,
15486 OperationErrorSpec {
15487 error_code,
15488 error_type: OperationErrorType::Validation,
15489 retryable: false,
15490 severity: OperationErrorSeverity::Error,
15491 },
15492 message,
15493 error_context,
15494 )
15495}
15496
15497#[cfg(test)]
15498mod tests;