Skip to main content

runmat_runtime/analysis/
mod.rs

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(&region.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(&region.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(&region.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;