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, FeaProgressHandler, FeaRunError, FeaRunResult,
28    LinearStaticSolveOptions, ModalSolveOptions, ThermalSolveOptions,
29    FEA_FIELD_ACOUSTIC_PARTICLE_VELOCITY, FEA_FIELD_ACOUSTIC_PHASE,
30    FEA_FIELD_ACOUSTIC_PRESSURE_IMAG, FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE,
31    FEA_FIELD_ACOUSTIC_PRESSURE_REAL, FEA_FIELD_ACOUSTIC_SOUND_PRESSURE_LEVEL_DB,
32    FEA_FIELD_CFD_PRESSURE, FEA_FIELD_CFD_RESIDUAL_CONTINUITY, FEA_FIELD_CFD_RESIDUAL_MOMENTUM,
33    FEA_FIELD_CFD_REYNOLDS_NUMBER, FEA_FIELD_CFD_VELOCITY, FEA_FIELD_CFD_VORTICITY,
34    FEA_FIELD_CFD_WALL_SHEAR_STRESS, FEA_FIELD_CHT_FLUID_PRESSURE, FEA_FIELD_CHT_FLUID_VELOCITY,
35    FEA_FIELD_STRUCTURAL_DISPLACEMENT, FEA_FIELD_STRUCTURAL_NODAL_VON_MISES,
36    FEA_FIELD_STRUCTURAL_REACTION_FORCE, FEA_FIELD_STRUCTURAL_REACTION_MOMENT,
37    FEA_FIELD_STRUCTURAL_ROTATION, FEA_FIELD_STRUCTURAL_STRAIN,
38    FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY, FEA_FIELD_STRUCTURAL_STRESS,
39    FEA_FIELD_STRUCTURAL_VON_MISES,
40};
41use runmat_geometry_core::{EntityKind, GeometryAsset, MaterialEvidenceConfidence, UnitSystem};
42use runmat_meshing::{
43    generate_analysis_mesh, generate_analysis_mesh_with_sizing, ElementFamilyHint,
44    MeshConnectivityClass,
45};
46use runmat_meshing_core::{
47    build_refinement_markers_from_samples, plan_refinement_indicators, AdaptiveConvergenceStatus,
48    AdaptiveIterationSummary, AnalysisFieldTopologyDescriptor, AnalysisFieldTopologyLocation,
49    AnalysisMeshArtifact, AnalysisMeshValidationOptions, MeshSizingField, MeshTargetSize,
50    RefinementIndicatorAvailability, RefinementIndicatorSample, RefinementMarkerOptions,
51    RefinementStrategy, SizingFieldUpdate, SourceEntityKind, VolumeMeshingOptions,
52    TETRAHEDRON4_FIELD_ELEMENT_KIND,
53};
54use runmat_meshing_evidence::{
55    build_mesh_authoring_summary, build_mesh_evidence_artifact,
56    build_mesh_evidence_artifact_with_validation_evidence, MeshValidationEvidence,
57};
58use serde::{Deserialize, Serialize};
59use sha2::{Digest, Sha256};
60
61use crate::operations::{
62    operation_error, OperationContext, OperationEnvelope, OperationErrorEnvelope,
63    OperationErrorSeverity, OperationErrorSpec, OperationErrorType,
64};
65use policy::{
66    breach_rate_greater_than, breach_rate_less_than, electromagnetic_sweep_thresholds_for_policy,
67    electromagnetic_thresholds_for_policy, thermo_field_quality_thresholds_for_policy,
68    thermo_gradient_thresholds_for_policy, thermo_thresholds_for_policy,
69    ElectromagneticQualityThresholds, EM_ASSIGNMENT_COVERAGE_MIN_BALANCED,
70    EM_BOUNDARY_ANCHOR_MIN_BALANCED, EM_BOUNDARY_ENERGY_MIN_BALANCED,
71    EM_BOUNDARY_LOCALIZATION_MIN_BALANCED, EM_BOUNDARY_PENALTY_CONTRIBUTION_MAX_BALANCED,
72    EM_CONDITIONING_MAX_BALANCED, EM_CONDUCTIVITY_SPREAD_THRESHOLD_BALANCED,
73    EM_ENERGY_IMBALANCE_MAX_BALANCED, EM_FLUX_DIVERGENCE_MAX_BALANCED,
74    EM_GROUND_EFFECTIVENESS_MIN_BALANCED, EM_HETEROGENEITY_THRESHOLD_BALANCED,
75    EM_IMAG_RESIDUAL_MAX_BALANCED, EM_INSULATION_LEAKAGE_MAX_BALANCED,
76    EM_REAL_RESIDUAL_MAX_BALANCED, EM_REGION_CONTRAST_MAX_BALANCED, EM_RESONANCE_Q_MIN_BALANCED,
77    EM_SOURCE_INTERFERENCE_MAX_BALANCED, EM_SOURCE_MATERIAL_ALIGNMENT_MIN_BALANCED,
78    EM_SOURCE_OVERLAP_MAX_BALANCED, EM_SOURCE_REALIZATION_MIN_BALANCED,
79    EM_SOURCE_REGION_COVERAGE_MIN_BALANCED, EM_SOURCE_REGION_ENERGY_CONSISTENCY_MIN_BALANCED,
80    EM_SWEEP_COUNT_MIN_BALANCED, THERMO_HETEROGENEITY_THRESHOLD_BALANCED,
81    THERMO_SPREAD_THRESHOLD_BALANCED,
82};
83
84mod contracts;
85mod fea_document;
86mod fea_document_authoring;
87#[cfg(feature = "plot-core")]
88mod figures;
89mod policy;
90mod promotion;
91pub mod storage;
92mod study_authoring;
93
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct FeaRuntimeConfig {
96    pub artifact_root: Option<PathBuf>,
97    pub study_artifact_root: Option<PathBuf>,
98    pub thermo_field_artifact_root: Option<PathBuf>,
99}
100
101fn fea_runtime_config() -> &'static RwLock<FeaRuntimeConfig> {
102    static CONFIG: OnceLock<RwLock<FeaRuntimeConfig>> = OnceLock::new();
103    CONFIG.get_or_init(|| RwLock::new(FeaRuntimeConfig::default()))
104}
105
106fn current_fea_runtime_config() -> FeaRuntimeConfig {
107    fea_runtime_config()
108        .read()
109        .map(|guard| guard.clone())
110        .unwrap_or_default()
111}
112
113pub fn default_fea_artifact_root() -> PathBuf {
114    PathBuf::from("artifacts")
115}
116
117pub fn configure_fea_runtime(config: FeaRuntimeConfig) -> Result<(), String> {
118    let mut guard = fea_runtime_config()
119        .write()
120        .map_err(|_| "FEA runtime config lock poisoned".to_string())?;
121    *guard = config;
122    Ok(())
123}
124
125thread_local! {
126    static FEA_PROGRESS_HANDLER: RefCell<Option<FeaProgressHandler>> = const { RefCell::new(None) };
127}
128
129pub struct FeaProgressHandlerGuard {
130    previous: Option<FeaProgressHandler>,
131}
132
133impl Drop for FeaProgressHandlerGuard {
134    fn drop(&mut self) {
135        FEA_PROGRESS_HANDLER.with(|slot| {
136            slot.replace(self.previous.take());
137        });
138    }
139}
140
141pub fn replace_fea_progress_handler(
142    handler: Option<FeaProgressHandler>,
143) -> FeaProgressHandlerGuard {
144    let previous = FEA_PROGRESS_HANDLER.with(|slot| slot.replace(handler));
145    FeaProgressHandlerGuard { previous }
146}
147
148fn install_fea_solver_context() -> runmat_analysis_fea::FeaProgressContextGuard {
149    let host_handler = FEA_PROGRESS_HANDLER.with(|slot| slot.borrow().clone());
150    let handler = Some(Arc::new(move |event: FeaProgressEvent| {
151        tracing::info!(
152            target: "runmat_analysis",
153            operation = %event.operation,
154            phase = ?event.phase,
155            status = ?event.status,
156            current = event.current,
157            total = event.total,
158            fraction = event.fraction,
159            "{}", event.message
160        );
161        if let Some(host_handler) = host_handler.as_ref() {
162            host_handler(event);
163        }
164    }) as FeaProgressHandler);
165    runmat_analysis_fea::replace_fea_progress_context(
166        handler,
167        Some(Arc::new(crate::interrupt::is_cancelled)),
168    )
169}
170
171pub use contracts::{
172    analysis_runtime_physics_profile_catalog, AnalysisAcousticRunOptions, AnalysisCfdRunOptions,
173    AnalysisChtRunOptions, AnalysisCreateModelIntentSpec, AnalysisCreateModelPrepContext,
174    AnalysisCreateModelProfile, AnalysisDiagnosticsArtifactPayload, AnalysisDocumentCheckResult,
175    AnalysisDocumentKind, AnalysisDocumentRunResult, AnalysisElectromagneticRunOptions,
176    AnalysisFieldDescriptor, AnalysisFieldDescriptorsArtifactPayload, AnalysisFieldKind,
177    AnalysisFieldLocation, AnalysisFieldPageResult, AnalysisFieldPagingDescriptor,
178    AnalysisFieldRequestOptions, AnalysisFieldStorage, AnalysisFieldStorageRef,
179    AnalysisFsiRunOptions, AnalysisModalRunOptions, AnalysisNonlinearRunOptions,
180    AnalysisObjectArtifactMetadata, AnalysisRenderMesh, AnalysisRenderRegion,
181    AnalysisRenderTopology, AnalysisRenderTopologySource, AnalysisRenderTriangleRange,
182    AnalysisResultsCompareData, AnalysisResultsCompareQuery, AnalysisResultsData,
183    AnalysisResultsQuery, AnalysisResultsSummary, AnalysisRunDatasetFieldPagingPolicy,
184    AnalysisRunDatasetPayload, AnalysisRunDatasetStudyRef, AnalysisRunKind, AnalysisRunOptions,
185    AnalysisRunPrepContext, AnalysisRunResult, AnalysisRuntimeCapabilities,
186    AnalysisRuntimePhysicsProfileCatalogEntry, AnalysisRuntimePhysicsProfileDefaultOutput,
187    AnalysisStudyAuthoringData, AnalysisStudyAuthoringEvidence, AnalysisStudyAuthoringIntent,
188    AnalysisStudyDiagramObservation, AnalysisStudyIssue, AnalysisStudyPlanData,
189    AnalysisStudyRunData, AnalysisStudySpec, AnalysisStudySweepData,
190    AnalysisStudySweepFailureEntry, AnalysisStudySweepPlanData, AnalysisStudySweepPlanEntry,
191    AnalysisStudySweepRunEntry, AnalysisStudySweepSpec, AnalysisStudySweepValidateData,
192    AnalysisStudySweepValidateEntry, AnalysisStudyValidateResult, AnalysisThermalRunOptions,
193    AnalysisTransientRunOptions, AnalysisTrendKindSummary, AnalysisTrendsData, AnalysisTrendsQuery,
194    AnalysisValidateResult, ContactInterfaceOptions, ElectroRegionConductivityScale,
195    ElectroThermalCouplingOptions, ElectroTimeProfilePoint, ElectromagneticResultsData,
196    ModalFrequencyBasis, ModalFrequencyUnits, ModalResultsData, NonlinearMethod,
197    NonlinearResultsData, PlasticityConstitutiveOptions, PrecisionMode, PreconditionerMode,
198    PrepCalibrationProfile, QualityGate, QualityPolicy, QualityReason, QualityReasonCode,
199    RunProvenance, RunStatus, ThermalResultsData, ThermoFieldInterpolationMode, ThermoFieldSource,
200    ThermoMechanicalCouplingOptions, ThermoRegionTemperatureDelta, ThermoTimeProfilePoint,
201    TransientIntegrationMethod, TransientResultsData, ANALYSIS_ARTIFACT_MANIFEST_KIND,
202    ANALYSIS_DATASET_ARTIFACT_KIND, ANALYSIS_DIAGNOSTICS_ARTIFACT_KIND,
203    ANALYSIS_DIAGNOSTICS_SCHEMA_VERSION, ANALYSIS_FIELD_DEFAULT_MATERIALIZE_LIMIT,
204    ANALYSIS_FIELD_DEFAULT_PAGE_SIZE, ANALYSIS_FIELD_DESCRIPTORS_ARTIFACT_KIND,
205    ANALYSIS_FIELD_DESCRIPTORS_SCHEMA_VERSION, ANALYSIS_OBJECT_ARTIFACT_METADATA_SCHEMA_VERSION,
206    ANALYSIS_RUN_DATASET_KIND, ANALYSIS_RUN_DATASET_SCHEMA_VERSION,
207};
208pub use fea_document::{
209    is_fea_file_path, load_fea_document_from_path_async, parse_and_resolve_fea_document,
210    FeaResolvedDocument,
211};
212pub use fea_document_authoring::{
213    apply_fea_study_document_operation, apply_fea_study_document_operation_typed,
214    summarize_fea_study_document, FeaStudyDocumentOperation, FeaStudyDocumentOperationOutput,
215    FEA_STUDY_DOCUMENT_OPERATION_NAMES,
216};
217#[cfg(feature = "plot-core")]
218pub use figures::{
219    analysis_generate_study_run_figures, AnalysisFigureGenerationOptions, AnalysisFigureMeshSource,
220    AnalysisGeneratedFigure, AnalysisGeneratedFigureKind,
221};
222pub use runmat_analysis_fea::{FeaProgressEvent, FeaProgressPhase, FeaProgressStatus};
223pub use study_authoring::analysis_author_study_op;
224
225const ANALYSIS_CREATE_MODEL_OPERATION: &str = "fea.create_model";
226const ANALYSIS_CREATE_MODEL_OP_VERSION: &str = "fea.create_model/v1";
227const ANALYSIS_AUTHOR_STUDY_OPERATION: &str = "fea.author_study";
228const ANALYSIS_AUTHOR_STUDY_OP_VERSION: &str = "fea.author_study/v1";
229const ANALYSIS_VALIDATE_STUDY_OPERATION: &str = "fea.validate_study";
230const ANALYSIS_VALIDATE_STUDY_OP_VERSION: &str = "fea.validate_study/v1";
231const ANALYSIS_PLAN_STUDY_OPERATION: &str = "fea.plan_study";
232const ANALYSIS_PLAN_STUDY_OP_VERSION: &str = "fea.plan_study/v1";
233const ANALYSIS_PLAN_STUDY_SWEEP_OPERATION: &str = "fea.plan_study_sweep";
234const ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION: &str = "fea.plan_study_sweep/v1";
235const ANALYSIS_RUN_STUDY_OPERATION: &str = "fea.run_study";
236const ANALYSIS_RUN_STUDY_OP_VERSION: &str = "fea.run_study/v1";
237const ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION: &str = "fea.validate_study_sweep";
238const ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION: &str = "fea.validate_study_sweep/v1";
239const ANALYSIS_RUN_STUDY_SWEEP_OPERATION: &str = "fea.run_study_sweep";
240const ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION: &str = "fea.run_study_sweep/v1";
241const ANALYSIS_VALIDATE_OPERATION: &str = "fea.validate";
242const ANALYSIS_VALIDATE_OP_VERSION: &str = "fea.validate/v1";
243const ANALYSIS_RUN_OPERATION: &str = "fea.run_linear_static";
244const ANALYSIS_RUN_OP_VERSION: &str = "fea.run_linear_static/v1";
245const ANALYSIS_RUN_MODAL_OPERATION: &str = "fea.run_modal";
246const ANALYSIS_RUN_MODAL_OP_VERSION: &str = "fea.run_modal/v1";
247const ANALYSIS_RUN_ACOUSTIC_OPERATION: &str = "fea.run_acoustic";
248const ANALYSIS_RUN_ACOUSTIC_OP_VERSION: &str = "fea.run_acoustic/v1";
249const ANALYSIS_RUN_TRANSIENT_OPERATION: &str = "fea.run_transient";
250const ANALYSIS_RUN_TRANSIENT_OP_VERSION: &str = "fea.run_transient/v1";
251const ANALYSIS_RUN_THERMAL_OPERATION: &str = "fea.run_thermal";
252const ANALYSIS_RUN_THERMAL_OP_VERSION: &str = "fea.run_thermal/v1";
253const ANALYSIS_RUN_NONLINEAR_OPERATION: &str = "fea.run_nonlinear";
254const ANALYSIS_RUN_NONLINEAR_OP_VERSION: &str = "fea.run_nonlinear/v1";
255const ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION: &str = "fea.run_electromagnetic";
256const ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION: &str = "fea.run_electromagnetic/v1";
257const ANALYSIS_RUN_CFD_OPERATION: &str = "fea.run_cfd";
258const ANALYSIS_RUN_CFD_OP_VERSION: &str = "fea.run_cfd/v1";
259const ANALYSIS_RUN_CHT_OPERATION: &str = "fea.run_cht";
260const ANALYSIS_RUN_CHT_OP_VERSION: &str = "fea.run_cht/v1";
261const ANALYSIS_RUN_FSI_OPERATION: &str = "fea.run_fsi";
262const ANALYSIS_RUN_FSI_OP_VERSION: &str = "fea.run_fsi/v1";
263const ANALYSIS_RESULTS_OPERATION: &str = "fea.results";
264const ANALYSIS_RESULTS_OP_VERSION: &str = "fea.results/v1";
265const ANALYSIS_RESULTS_COMPARE_OPERATION: &str = "fea.results_compare";
266const ANALYSIS_RESULTS_COMPARE_OP_VERSION: &str = "fea.results_compare/v1";
267const ANALYSIS_TRENDS_OPERATION: &str = "fea.trends";
268const ANALYSIS_TRENDS_OP_VERSION: &str = "fea.trends/v1";
269const TRANSIENT_RESIDUAL_WARN_THRESHOLD: f64 = 1.0e-4;
270
271fn map_fea_run_error(
272    operation: &str,
273    op_version: &str,
274    default_error_code: &'static str,
275    cancel_error_code: &'static str,
276    model: &AnalysisModel,
277    context: &OperationContext,
278    err: FeaRunError,
279) -> OperationErrorEnvelope {
280    match err {
281        FeaRunError::Cancelled => operation_error(
282            operation,
283            op_version,
284            context,
285            OperationErrorSpec {
286                error_code: cancel_error_code,
287                error_type: OperationErrorType::Cancelled,
288                retryable: false,
289                severity: OperationErrorSeverity::Warning,
290            },
291            "FEA run cancelled by user",
292            BTreeMap::from([
293                ("analysis_model_id".to_string(), model.model_id.0.clone()),
294                ("geometry_id".to_string(), model.geometry_id.clone()),
295            ]),
296        ),
297        FeaRunError::InvalidModel(message) => operation_error(
298            operation,
299            op_version,
300            context,
301            OperationErrorSpec {
302                error_code: default_error_code,
303                error_type: OperationErrorType::Validation,
304                retryable: false,
305                severity: OperationErrorSeverity::Error,
306            },
307            message,
308            BTreeMap::from([
309                ("analysis_model_id".to_string(), model.model_id.0.clone()),
310                ("geometry_id".to_string(), model.geometry_id.clone()),
311            ]),
312        ),
313        FeaRunError::Assembly(message) => operation_error(
314            operation,
315            op_version,
316            context,
317            OperationErrorSpec {
318                error_code: "RM.FEA.RUN_LINEAR_STATIC.ASSEMBLY_FAILED",
319                error_type: OperationErrorType::Validation,
320                retryable: false,
321                severity: OperationErrorSeverity::Error,
322            },
323            message,
324            BTreeMap::from([
325                ("analysis_model_id".to_string(), model.model_id.0.clone()),
326                ("geometry_id".to_string(), model.geometry_id.clone()),
327            ]),
328        ),
329    }
330}
331
332fn reject_moment_loads_for_run_family(
333    model: &AnalysisModel,
334    operation: &'static str,
335    op_version: &'static str,
336    error_code: &'static str,
337    family: &'static str,
338    context: &OperationContext,
339) -> Result<(), OperationErrorEnvelope> {
340    if let Some(load) = model
341        .loads
342        .iter()
343        .find(|load| matches!(load.kind, LoadKind::Moment { .. } | LoadKind::Wrench { .. }))
344    {
345        let load_kind = match load.kind {
346            LoadKind::Moment { .. } => "moment",
347            LoadKind::Wrench { .. } => "wrench",
348            _ => "structural",
349        };
350        return Err(operation_error(
351            operation,
352            op_version,
353            context,
354            OperationErrorSpec {
355                error_code,
356                error_type: OperationErrorType::Validation,
357                retryable: false,
358                severity: OperationErrorSeverity::Error,
359            },
360            format!("{load_kind} loads are structural loads and cannot be used as {family} loads"),
361            BTreeMap::from([
362                ("analysis_model_id".to_string(), model.model_id.0.clone()),
363                ("load_id".to_string(), load.load_id.clone()),
364                ("region_id".to_string(), load.region_id.clone()),
365            ]),
366        ));
367    }
368    Ok(())
369}
370
371fn persist_fea_run_result_with_progress(
372    operation: &str,
373    op_version: &str,
374    artifact_error_code: &'static str,
375    context: &OperationContext,
376    result: &AnalysisRunResult,
377) -> Result<(), OperationErrorEnvelope> {
378    runmat_analysis_fea::emit_fea_progress_phase(
379        operation,
380        FeaProgressPhase::ArtifactPersistence,
381        FeaProgressStatus::Started,
382        "persisting FEA run artifact",
383        None,
384        None,
385    );
386    match storage::persist_run_result(result) {
387        Ok(_record) => {
388            runmat_analysis_fea::emit_fea_progress_phase(
389                operation,
390                FeaProgressPhase::ArtifactPersistence,
391                FeaProgressStatus::Completed,
392                "FEA run artifact persisted",
393                None,
394                None,
395            );
396            Ok(())
397        }
398        Err(err) => {
399            let message = format!("failed to persist FEA run artifact: {err}");
400            runmat_analysis_fea::emit_fea_progress_phase(
401                operation,
402                FeaProgressPhase::ArtifactPersistence,
403                FeaProgressStatus::Failed,
404                &message,
405                None,
406                None,
407            );
408            Err(operation_error(
409                operation,
410                op_version,
411                context,
412                OperationErrorSpec {
413                    error_code: artifact_error_code,
414                    error_type: OperationErrorType::Internal,
415                    retryable: true,
416                    severity: OperationErrorSeverity::Error,
417                },
418                message,
419                BTreeMap::from([("run_id".to_string(), result.run_id.clone())]),
420            ))
421        }
422    }
423}
424
425pub fn analysis_create_model_op(
426    geometry: &GeometryAsset,
427    intent: AnalysisCreateModelIntentSpec,
428    context: OperationContext,
429) -> Result<OperationEnvelope<AnalysisModel>, OperationErrorEnvelope> {
430    if intent.model_id.trim().is_empty() {
431        return Err(operation_error(
432            ANALYSIS_CREATE_MODEL_OPERATION,
433            ANALYSIS_CREATE_MODEL_OP_VERSION,
434            &context,
435            OperationErrorSpec {
436                error_code: "RM.FEA.CREATE_MODEL.INVALID_INTENT",
437                error_type: OperationErrorType::Input,
438                retryable: false,
439                severity: OperationErrorSeverity::Error,
440            },
441            "FEA model intent requires a non-empty model_id",
442            BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
443        ));
444    }
445
446    if geometry.meshes.is_empty() {
447        return Err(operation_error(
448            ANALYSIS_CREATE_MODEL_OPERATION,
449            ANALYSIS_CREATE_MODEL_OP_VERSION,
450            &context,
451            OperationErrorSpec {
452                error_code: "RM.FEA.CREATE_MODEL.GEOMETRY_EMPTY",
453                error_type: OperationErrorType::Validation,
454                retryable: false,
455                severity: OperationErrorSeverity::Error,
456            },
457            "geometry must contain at least one mesh to create an FEA model",
458            BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
459        ));
460    }
461
462    if geometry.units == UnitSystem::Unspecified {
463        return Err(operation_error(
464            ANALYSIS_CREATE_MODEL_OPERATION,
465            ANALYSIS_CREATE_MODEL_OP_VERSION,
466            &context,
467            OperationErrorSpec {
468                error_code: "RM.FEA.CREATE_MODEL.UNIT_UNSPECIFIED",
469                error_type: OperationErrorType::Validation,
470                retryable: false,
471                severity: OperationErrorSeverity::Error,
472            },
473            "geometry units must be specified before creating an FEA model",
474            BTreeMap::from([("geometry_id".to_string(), geometry.geometry_id.clone())]),
475        ));
476    }
477
478    let prep_mapped_region_ids = if let Some(prep) = intent.prep_context.as_ref() {
479        if prep.source_geometry_id != geometry.geometry_id
480            || prep.source_geometry_revision != geometry.revision
481        {
482            return Err(operation_error(
483                ANALYSIS_CREATE_MODEL_OPERATION,
484                ANALYSIS_CREATE_MODEL_OP_VERSION,
485                &context,
486                OperationErrorSpec {
487                    error_code: "RM.FEA.CREATE_MODEL.PREP_MISMATCH",
488                    error_type: OperationErrorType::Input,
489                    retryable: false,
490                    severity: OperationErrorSeverity::Error,
491                },
492                "FEA model prep context does not match geometry id/revision",
493                BTreeMap::from([
494                    ("geometry_id".to_string(), geometry.geometry_id.clone()),
495                    (
496                        "geometry_revision".to_string(),
497                        geometry.revision.to_string(),
498                    ),
499                    (
500                        "prep_geometry_id".to_string(),
501                        prep.source_geometry_id.clone(),
502                    ),
503                    (
504                        "prep_geometry_revision".to_string(),
505                        prep.source_geometry_revision.to_string(),
506                    ),
507                ]),
508            ));
509        }
510
511        let mesh_id_set = geometry
512            .meshes
513            .iter()
514            .map(|mesh| mesh.mesh_id.as_str())
515            .collect::<HashSet<_>>();
516        let region_id_set = geometry
517            .regions
518            .iter()
519            .map(|region| region.region_id.as_str())
520            .collect::<HashSet<_>>();
521        for mapping in &prep.region_mappings {
522            if !region_id_set.is_empty() && !region_id_set.contains(mapping.region_id.as_str()) {
523                return Err(operation_error(
524                    ANALYSIS_CREATE_MODEL_OPERATION,
525                    ANALYSIS_CREATE_MODEL_OP_VERSION,
526                    &context,
527                    OperationErrorSpec {
528                        error_code: "RM.FEA.CREATE_MODEL.PREP_REGION_NOT_FOUND",
529                        error_type: OperationErrorType::Validation,
530                        retryable: false,
531                        severity: OperationErrorSeverity::Error,
532                    },
533                    format!(
534                        "prep context region '{}' is not present in geometry regions",
535                        mapping.region_id
536                    ),
537                    BTreeMap::from([("region_id".to_string(), mapping.region_id.clone())]),
538                ));
539            }
540            if mapping.source_mesh_ids.is_empty() || mapping.prepared_mesh_ids.is_empty() {
541                return Err(operation_error(
542                    ANALYSIS_CREATE_MODEL_OPERATION,
543                    ANALYSIS_CREATE_MODEL_OP_VERSION,
544                    &context,
545                    OperationErrorSpec {
546                        error_code: "RM.FEA.CREATE_MODEL.PREP_INVALID_MAPPING",
547                        error_type: OperationErrorType::Input,
548                        retryable: false,
549                        severity: OperationErrorSeverity::Error,
550                    },
551                    "prep context mapping requires non-empty source/prepared mesh ids",
552                    BTreeMap::from([("region_id".to_string(), mapping.region_id.clone())]),
553                ));
554            }
555            for source_mesh_id in &mapping.source_mesh_ids {
556                if !mesh_id_set.contains(source_mesh_id.as_str()) {
557                    return Err(operation_error(
558                        ANALYSIS_CREATE_MODEL_OPERATION,
559                        ANALYSIS_CREATE_MODEL_OP_VERSION,
560                        &context,
561                        OperationErrorSpec {
562                            error_code: "RM.FEA.CREATE_MODEL.PREP_MESH_NOT_FOUND",
563                            error_type: OperationErrorType::Validation,
564                            retryable: false,
565                            severity: OperationErrorSeverity::Error,
566                        },
567                        format!(
568                            "prep context source mesh '{}' is not present in geometry",
569                            source_mesh_id
570                        ),
571                        BTreeMap::from([("source_mesh_id".to_string(), source_mesh_id.clone())]),
572                    ));
573                }
574            }
575        }
576
577        Some(
578            prep.region_mappings
579                .iter()
580                .map(|mapping| mapping.region_id.clone())
581                .collect::<HashSet<_>>(),
582        )
583    } else {
584        None
585    };
586
587    let fixed_region_id = select_fixed_region_id(geometry, prep_mapped_region_ids.as_ref())
588        .or_else(|| {
589            geometry
590                .regions
591                .first()
592                .map(|region| region.region_id.clone())
593        })
594        .unwrap_or_else(|| "region_default".to_string());
595    let load_region_id = select_load_region_id(geometry, prep_mapped_region_ids.as_ref())
596        .or_else(|| {
597            geometry
598                .regions
599                .last()
600                .map(|region| region.region_id.clone())
601        })
602        .unwrap_or_else(|| fixed_region_id.clone());
603
604    let mut inferred_materials = infer_material_models(geometry);
605    if matches!(intent.profile, AnalysisCreateModelProfile::AcousticHarmonic) {
606        for material in &mut inferred_materials {
607            material.acoustic = Some(runmat_analysis_core::MaterialAcousticModel::default());
608        }
609    }
610    if matches!(
611        intent.profile,
612        AnalysisCreateModelProfile::ElectromagneticStatic
613            | AnalysisCreateModelProfile::ElectroThermalCoupled
614    ) {
615        for material in &mut inferred_materials {
616            material.electrical = Some(runmat_analysis_core::MaterialElectricalModel::default());
617        }
618    }
619    let inferred_assignments = infer_material_assignments(
620        geometry,
621        &inferred_materials,
622        prep_mapped_region_ids.as_ref(),
623    );
624
625    let (default_bc, default_load, default_steps) = match intent.profile {
626        AnalysisCreateModelProfile::LinearStaticStructural => (
627            BoundaryCondition {
628                bc_id: "bc_default_fixed".to_string(),
629                region_id: fixed_region_id,
630                kind: BoundaryConditionKind::Fixed,
631            },
632            LoadCase {
633                load_id: "load_default_force".to_string(),
634                region_id: load_region_id,
635                kind: LoadKind::Force {
636                    fx: 0.0,
637                    fy: -1000.0,
638                    fz: 0.0,
639                },
640            },
641            vec![AnalysisStep {
642                step_id: "step_default_static".to_string(),
643                kind: AnalysisStepKind::Static,
644            }],
645        ),
646        AnalysisCreateModelProfile::ThermoMechanicalCoupled => (
647            BoundaryCondition {
648                bc_id: "bc_default_fixed".to_string(),
649                region_id: fixed_region_id,
650                kind: BoundaryConditionKind::Fixed,
651            },
652            LoadCase {
653                load_id: "load_default_thermal_mech_force".to_string(),
654                region_id: load_region_id,
655                kind: LoadKind::Force {
656                    fx: 0.0,
657                    fy: -650.0,
658                    fz: 0.0,
659                },
660            },
661            vec![AnalysisStep {
662                step_id: "step_default_thermo_mech".to_string(),
663                kind: AnalysisStepKind::Transient,
664            }],
665        ),
666        AnalysisCreateModelProfile::ElectroThermalCoupled => (
667            BoundaryCondition {
668                bc_id: "bc_default_electro_thermal_ground".to_string(),
669                region_id: fixed_region_id,
670                kind: BoundaryConditionKind::VectorPotentialGround,
671            },
672            LoadCase {
673                load_id: "load_default_electro_thermal_current".to_string(),
674                region_id: load_region_id,
675                kind: LoadKind::CoilCurrent {
676                    current_a: 50.0,
677                    phase_rad: 0.0,
678                    amplitude_scale: 1.0,
679                },
680            },
681            vec![AnalysisStep {
682                step_id: "step_default_electro_thermal".to_string(),
683                kind: AnalysisStepKind::Transient,
684            }],
685        ),
686        AnalysisCreateModelProfile::ThermalStandalone => (
687            BoundaryCondition {
688                bc_id: "bc_default_fixed".to_string(),
689                region_id: fixed_region_id,
690                kind: BoundaryConditionKind::Fixed,
691            },
692            LoadCase {
693                load_id: "load_default_thermal_seed".to_string(),
694                region_id: load_region_id,
695                kind: LoadKind::BodyForce {
696                    gx: 0.0,
697                    gy: 0.0,
698                    gz: 0.0,
699                },
700            },
701            vec![AnalysisStep {
702                step_id: "step_default_thermal".to_string(),
703                kind: AnalysisStepKind::Thermal,
704            }],
705        ),
706        AnalysisCreateModelProfile::ModalStructural => (
707            BoundaryCondition {
708                bc_id: "bc_default_fixed".to_string(),
709                region_id: fixed_region_id,
710                kind: BoundaryConditionKind::Fixed,
711            },
712            LoadCase {
713                load_id: "load_default_modal_seed".to_string(),
714                region_id: load_region_id,
715                kind: LoadKind::BodyForce {
716                    gx: 0.0,
717                    gy: 0.0,
718                    gz: 0.0,
719                },
720            },
721            vec![AnalysisStep {
722                step_id: "step_default_modal".to_string(),
723                kind: AnalysisStepKind::Modal,
724            }],
725        ),
726        AnalysisCreateModelProfile::AcousticHarmonic => (
727            BoundaryCondition {
728                bc_id: "bc_default_acoustic_rigid_wall".to_string(),
729                region_id: fixed_region_id,
730                kind: BoundaryConditionKind::AcousticRigidWall,
731            },
732            LoadCase {
733                load_id: "load_default_acoustic_harmonic_seed".to_string(),
734                region_id: load_region_id,
735                kind: LoadKind::Pressure { magnitude_pa: 1.0 },
736            },
737            vec![AnalysisStep {
738                step_id: "step_default_acoustic_harmonic".to_string(),
739                kind: AnalysisStepKind::Modal,
740            }],
741        ),
742        AnalysisCreateModelProfile::TransientStructural => (
743            BoundaryCondition {
744                bc_id: "bc_default_fixed".to_string(),
745                region_id: fixed_region_id,
746                kind: BoundaryConditionKind::Fixed,
747            },
748            LoadCase {
749                load_id: "load_default_transient_force".to_string(),
750                region_id: load_region_id,
751                kind: LoadKind::Force {
752                    fx: 0.0,
753                    fy: -500.0,
754                    fz: 0.0,
755                },
756            },
757            vec![AnalysisStep {
758                step_id: "step_default_transient".to_string(),
759                kind: AnalysisStepKind::Transient,
760            }],
761        ),
762        AnalysisCreateModelProfile::NonlinearStructural => (
763            BoundaryCondition {
764                bc_id: "bc_default_fixed".to_string(),
765                region_id: fixed_region_id,
766                kind: BoundaryConditionKind::Fixed,
767            },
768            LoadCase {
769                load_id: "load_default_nonlinear_force".to_string(),
770                region_id: load_region_id,
771                kind: LoadKind::Force {
772                    fx: 0.0,
773                    fy: -750.0,
774                    fz: 0.0,
775                },
776            },
777            vec![AnalysisStep {
778                step_id: "step_default_nonlinear".to_string(),
779                kind: AnalysisStepKind::Nonlinear,
780            }],
781        ),
782        AnalysisCreateModelProfile::ElectromagneticStatic => (
783            BoundaryCondition {
784                bc_id: "bc_default_em_ground".to_string(),
785                region_id: fixed_region_id,
786                kind: BoundaryConditionKind::VectorPotentialGround,
787            },
788            LoadCase {
789                load_id: "load_default_em_coil_current".to_string(),
790                region_id: load_region_id,
791                kind: LoadKind::CoilCurrent {
792                    current_a: 100.0,
793                    phase_rad: 0.0,
794                    amplitude_scale: 1.0,
795                },
796            },
797            vec![AnalysisStep {
798                step_id: "step_default_electromagnetic".to_string(),
799                kind: AnalysisStepKind::Electromagnetic,
800            }],
801        ),
802        AnalysisCreateModelProfile::CfdSteadyState => (
803            BoundaryCondition {
804                bc_id: "bc_default_fixed".to_string(),
805                region_id: fixed_region_id,
806                kind: BoundaryConditionKind::Fixed,
807            },
808            LoadCase {
809                load_id: "load_default_cfd_seed".to_string(),
810                region_id: load_region_id,
811                kind: LoadKind::BodyForce {
812                    gx: 0.0,
813                    gy: 0.0,
814                    gz: 0.0,
815                },
816            },
817            vec![AnalysisStep {
818                step_id: "step_default_cfd".to_string(),
819                kind: AnalysisStepKind::Cfd,
820            }],
821        ),
822        AnalysisCreateModelProfile::CfdTransient => (
823            BoundaryCondition {
824                bc_id: "bc_default_fixed".to_string(),
825                region_id: fixed_region_id,
826                kind: BoundaryConditionKind::Fixed,
827            },
828            LoadCase {
829                load_id: "load_default_cfd_transient_seed".to_string(),
830                region_id: load_region_id,
831                kind: LoadKind::BodyForce {
832                    gx: 0.0,
833                    gy: 0.0,
834                    gz: 0.0,
835                },
836            },
837            vec![AnalysisStep {
838                step_id: "step_default_cfd_transient".to_string(),
839                kind: AnalysisStepKind::Cfd,
840            }],
841        ),
842        AnalysisCreateModelProfile::ChtCoupled => (
843            BoundaryCondition {
844                bc_id: "bc_default_fixed".to_string(),
845                region_id: fixed_region_id,
846                kind: BoundaryConditionKind::Fixed,
847            },
848            LoadCase {
849                load_id: "load_default_cht_seed".to_string(),
850                region_id: load_region_id,
851                kind: LoadKind::BodyForce {
852                    gx: 0.0,
853                    gy: 0.0,
854                    gz: 0.0,
855                },
856            },
857            vec![
858                AnalysisStep {
859                    step_id: "step_default_cht_flow".to_string(),
860                    kind: AnalysisStepKind::Cfd,
861                },
862                AnalysisStep {
863                    step_id: "step_default_cht_thermal".to_string(),
864                    kind: AnalysisStepKind::Thermal,
865                },
866            ],
867        ),
868        AnalysisCreateModelProfile::FsiCoupled => (
869            BoundaryCondition {
870                bc_id: "bc_default_fixed".to_string(),
871                region_id: fixed_region_id,
872                kind: BoundaryConditionKind::Fixed,
873            },
874            LoadCase {
875                load_id: "load_default_fsi_seed".to_string(),
876                region_id: load_region_id,
877                kind: LoadKind::Force {
878                    fx: 0.0,
879                    fy: -450.0,
880                    fz: 0.0,
881                },
882            },
883            vec![
884                AnalysisStep {
885                    step_id: "step_default_fsi_structure".to_string(),
886                    kind: AnalysisStepKind::Transient,
887                },
888                AnalysisStep {
889                    step_id: "step_default_fsi_flow".to_string(),
890                    kind: AnalysisStepKind::Cfd,
891                },
892            ],
893        ),
894    };
895
896    let cfd = match intent.profile {
897        AnalysisCreateModelProfile::CfdSteadyState => Some(runmat_analysis_core::CfdDomain {
898            enabled: true,
899            solve_family: runmat_analysis_core::CfdSolveFamily::SteadyState,
900            reference_density_kg_per_m3: 1.225,
901            dynamic_viscosity_pa_s: 1.81e-5,
902            inlet_velocity_m_per_s: 5.0,
903            turbulence_intensity: 0.05,
904            time_profile: Vec::new(),
905        }),
906        AnalysisCreateModelProfile::CfdTransient => Some(runmat_analysis_core::CfdDomain {
907            enabled: true,
908            solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
909            reference_density_kg_per_m3: 1.225,
910            dynamic_viscosity_pa_s: 1.81e-5,
911            inlet_velocity_m_per_s: 5.0,
912            turbulence_intensity: 0.08,
913            time_profile: vec![
914                runmat_analysis_core::CfdTimeProfilePoint {
915                    normalized_time: 0.0,
916                    inlet_scale: 0.5,
917                },
918                runmat_analysis_core::CfdTimeProfilePoint {
919                    normalized_time: 1.0,
920                    inlet_scale: 1.0,
921                },
922            ],
923        }),
924        AnalysisCreateModelProfile::ChtCoupled => Some(runmat_analysis_core::CfdDomain {
925            enabled: true,
926            solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
927            reference_density_kg_per_m3: 1.225,
928            dynamic_viscosity_pa_s: 1.81e-5,
929            inlet_velocity_m_per_s: 4.5,
930            turbulence_intensity: 0.07,
931            time_profile: vec![
932                runmat_analysis_core::CfdTimeProfilePoint {
933                    normalized_time: 0.0,
934                    inlet_scale: 0.7,
935                },
936                runmat_analysis_core::CfdTimeProfilePoint {
937                    normalized_time: 1.0,
938                    inlet_scale: 1.0,
939                },
940            ],
941        }),
942        AnalysisCreateModelProfile::FsiCoupled => Some(runmat_analysis_core::CfdDomain {
943            enabled: true,
944            solve_family: runmat_analysis_core::CfdSolveFamily::Transient,
945            reference_density_kg_per_m3: 1.225,
946            dynamic_viscosity_pa_s: 1.81e-5,
947            inlet_velocity_m_per_s: 4.0,
948            turbulence_intensity: 0.06,
949            time_profile: vec![
950                runmat_analysis_core::CfdTimeProfilePoint {
951                    normalized_time: 0.0,
952                    inlet_scale: 0.6,
953                },
954                runmat_analysis_core::CfdTimeProfilePoint {
955                    normalized_time: 1.0,
956                    inlet_scale: 1.0,
957                },
958            ],
959        }),
960        _ => None,
961    };
962    let electromagnetic = match intent.profile {
963        AnalysisCreateModelProfile::ElectromagneticStatic => {
964            Some(runmat_analysis_core::ElectromagneticDomain {
965                enabled: true,
966                reference_frequency_hz: 60.0,
967                applied_current_a: 100.0,
968            })
969        }
970        _ => None,
971    };
972    let thermo_mechanical = match intent.profile {
973        AnalysisCreateModelProfile::ChtCoupled => {
974            Some(runmat_analysis_core::ThermoMechanicalDomain {
975                enabled: true,
976                reference_temperature_k: 293.15,
977                applied_temperature_delta_k: 35.0,
978                field_artifact_id: None,
979                field_source: None,
980                region_temperature_deltas: Vec::new(),
981                time_profile: vec![
982                    runmat_analysis_core::ThermoTimeProfilePoint {
983                        normalized_time: 0.0,
984                        scale: 0.6,
985                    },
986                    runmat_analysis_core::ThermoTimeProfilePoint {
987                        normalized_time: 1.0,
988                        scale: 1.0,
989                    },
990                ],
991            })
992        }
993        _ => None,
994    };
995    let electro_thermal = match intent.profile {
996        AnalysisCreateModelProfile::ElectroThermalCoupled => {
997            Some(runmat_analysis_core::ElectroThermalDomain {
998                enabled: true,
999                reference_temperature_k: 293.15,
1000                applied_voltage_v: 24.0,
1001                region_conductivity_scales: Vec::new(),
1002                time_profile: vec![
1003                    runmat_analysis_core::ElectroTimeProfilePoint {
1004                        normalized_time: 0.0,
1005                        current_scale: 0.5,
1006                    },
1007                    runmat_analysis_core::ElectroTimeProfilePoint {
1008                        normalized_time: 1.0,
1009                        current_scale: 1.0,
1010                    },
1011                ],
1012            })
1013        }
1014        _ => None,
1015    };
1016
1017    let mut boundary_conditions = vec![default_bc];
1018    if matches!(
1019        intent.profile,
1020        AnalysisCreateModelProfile::CfdSteadyState
1021            | AnalysisCreateModelProfile::CfdTransient
1022            | AnalysisCreateModelProfile::ChtCoupled
1023            | AnalysisCreateModelProfile::FsiCoupled
1024    ) {
1025        let default_cfd_inlet_velocity = cfd
1026            .as_ref()
1027            .map(|domain| domain.inlet_velocity_m_per_s)
1028            .unwrap_or(0.0);
1029        boundary_conditions.extend([
1030            BoundaryCondition {
1031                bc_id: "bc_default_cfd_inlet".to_string(),
1032                region_id: "inlet".to_string(),
1033                kind: BoundaryConditionKind::CfdInletVelocity {
1034                    velocity_m_per_s: default_cfd_inlet_velocity,
1035                },
1036            },
1037            BoundaryCondition {
1038                bc_id: "bc_default_cfd_outlet".to_string(),
1039                region_id: "outlet".to_string(),
1040                kind: BoundaryConditionKind::CfdOutletPressure { pressure_pa: 0.0 },
1041            },
1042            BoundaryCondition {
1043                bc_id: "bc_default_cfd_wall_upper".to_string(),
1044                region_id: "wall_upper".to_string(),
1045                kind: BoundaryConditionKind::CfdNoSlipWall,
1046            },
1047            BoundaryCondition {
1048                bc_id: "bc_default_cfd_wall_lower".to_string(),
1049                region_id: "wall_lower".to_string(),
1050                kind: BoundaryConditionKind::CfdNoSlipWall,
1051            },
1052        ]);
1053    }
1054
1055    let model = AnalysisModel {
1056        model_id: AnalysisModelId(intent.model_id),
1057        geometry_id: geometry.geometry_id.clone(),
1058        geometry_revision: geometry.revision,
1059        units: geometry.units,
1060        frame: ReferenceFrame::Global,
1061        materials: inferred_materials,
1062        material_assignments: inferred_assignments,
1063        structural: None,
1064        thermo_mechanical,
1065        electro_thermal,
1066        electromagnetic,
1067        cfd,
1068        interfaces: Vec::new(),
1069        boundary_conditions,
1070        loads: vec![default_load],
1071        steps: default_steps,
1072    };
1073
1074    validate_model_against_geometry(&model, geometry.units, &ReferenceFrame::Global).map_err(
1075        |error| {
1076            operation_error(
1077                ANALYSIS_CREATE_MODEL_OPERATION,
1078                ANALYSIS_CREATE_MODEL_OP_VERSION,
1079                &context,
1080                OperationErrorSpec {
1081                    error_code: "RM.FEA.CREATE_MODEL.INVALID",
1082                    error_type: OperationErrorType::Validation,
1083                    retryable: false,
1084                    severity: OperationErrorSeverity::Error,
1085                },
1086                format!("created FEA model failed validation: {error:?}"),
1087                BTreeMap::from([
1088                    ("analysis_model_id".to_string(), model.model_id.0.clone()),
1089                    ("geometry_id".to_string(), geometry.geometry_id.clone()),
1090                ]),
1091            )
1092        },
1093    )?;
1094
1095    Ok(OperationEnvelope::new(
1096        ANALYSIS_CREATE_MODEL_OPERATION,
1097        ANALYSIS_CREATE_MODEL_OP_VERSION,
1098        &context,
1099        model,
1100    ))
1101}
1102
1103pub fn analysis_validate_study_op(
1104    spec: &AnalysisStudySpec,
1105    context: OperationContext,
1106) -> Result<OperationEnvelope<AnalysisStudyValidateResult>, OperationErrorEnvelope> {
1107    let issue_codes = validate_study_issue_codes(spec);
1108    let issues: Vec<AnalysisStudyIssue> = issue_codes
1109        .iter()
1110        .map(|code| AnalysisStudyIssue {
1111            code: code.clone(),
1112            message: study_issue_message(code).to_string(),
1113        })
1114        .collect();
1115    let study_fingerprint = study_fingerprint(spec);
1116    let evidence_artifact_path = persist_study_evidence(
1117        &study_fingerprint,
1118        "validate",
1119        serde_json::json!({
1120            "schema_version": "fea_study_validate_artifact/v1",
1121            "study_id": spec.study_id.clone(),
1122            "study_fingerprint": study_fingerprint.clone(),
1123            "valid": issue_codes.is_empty(),
1124            "issue_codes": issue_codes.clone(),
1125            "issues": issues.clone(),
1126            "electromagnetic_run_options": spec.electromagnetic_run_options.clone(),
1127        }),
1128    )
1129    .map_err(|err| {
1130        operation_error(
1131            ANALYSIS_VALIDATE_STUDY_OPERATION,
1132            ANALYSIS_VALIDATE_STUDY_OP_VERSION,
1133            &context,
1134            OperationErrorSpec {
1135                error_code: "RM.FEA.VALIDATE_STUDY.ARTIFACT_STORE_FAILED",
1136                error_type: OperationErrorType::Internal,
1137                retryable: true,
1138                severity: OperationErrorSeverity::Error,
1139            },
1140            format!("failed to persist study validation evidence artifact: {err}"),
1141            BTreeMap::from([("study_id".to_string(), spec.study_id.clone())]),
1142        )
1143    })?;
1144    Ok(OperationEnvelope::new(
1145        ANALYSIS_VALIDATE_STUDY_OPERATION,
1146        ANALYSIS_VALIDATE_STUDY_OP_VERSION,
1147        &context,
1148        AnalysisStudyValidateResult {
1149            valid: issue_codes.is_empty(),
1150            issue_codes,
1151            issues,
1152            evidence_artifact_path,
1153        },
1154    ))
1155}
1156
1157pub fn analysis_plan_study_op(
1158    spec: &AnalysisStudySpec,
1159    context: OperationContext,
1160) -> Result<OperationEnvelope<AnalysisStudyPlanData>, OperationErrorEnvelope> {
1161    let issue_codes = validate_study_issue_codes(spec);
1162    if !issue_codes.is_empty() {
1163        return Err(operation_error(
1164            ANALYSIS_PLAN_STUDY_OPERATION,
1165            ANALYSIS_PLAN_STUDY_OP_VERSION,
1166            &context,
1167            OperationErrorSpec {
1168                error_code: "RM.FEA.PLAN_STUDY.INVALID_SPEC",
1169                error_type: OperationErrorType::Validation,
1170                retryable: false,
1171                severity: OperationErrorSeverity::Error,
1172            },
1173            "study spec is invalid; run fea.validate for issue details",
1174            BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1175        ));
1176    }
1177
1178    let study_fingerprint = study_fingerprint(spec);
1179    let run_operation = run_operation_for_kind(spec.run_kind).to_string();
1180    let run_op_version = run_operation_version_for_kind(spec.run_kind).to_string();
1181    let operation_sequence = study_operation_sequence(spec, &run_op_version);
1182    let evidence_artifact_path = persist_study_evidence(
1183        &study_fingerprint,
1184        "plan",
1185        serde_json::json!({
1186            "schema_version": "fea_study_plan_artifact/v1",
1187            "study_id": spec.study_id.clone(),
1188            "model_id": spec.create_model_intent.model_id.clone(),
1189            "run_kind": spec.run_kind,
1190            "backend": spec.backend,
1191            "run_options": study_run_options_json(spec),
1192            "study_fingerprint": study_fingerprint.clone(),
1193            "operation_sequence": operation_sequence.clone(),
1194            "run_operation": run_operation.clone(),
1195            "run_op_version": run_op_version.clone(),
1196        }),
1197    )
1198    .map_err(|err| {
1199        operation_error(
1200            ANALYSIS_PLAN_STUDY_OPERATION,
1201            ANALYSIS_PLAN_STUDY_OP_VERSION,
1202            &context,
1203            OperationErrorSpec {
1204                error_code: "RM.FEA.PLAN_STUDY.ARTIFACT_STORE_FAILED",
1205                error_type: OperationErrorType::Internal,
1206                retryable: true,
1207                severity: OperationErrorSeverity::Error,
1208            },
1209            format!("failed to persist study plan evidence artifact: {err}"),
1210            BTreeMap::from([("study_id".to_string(), spec.study_id.clone())]),
1211        )
1212    })?;
1213    Ok(OperationEnvelope::new(
1214        ANALYSIS_PLAN_STUDY_OPERATION,
1215        ANALYSIS_PLAN_STUDY_OP_VERSION,
1216        &context,
1217        AnalysisStudyPlanData {
1218            study_id: spec.study_id.clone(),
1219            model_id: spec.create_model_intent.model_id.clone(),
1220            run_kind: spec.run_kind,
1221            backend: spec.backend,
1222            electromagnetic_run_options: spec.electromagnetic_run_options.clone(),
1223            run_options: study_run_options_json(spec),
1224            operation_sequence,
1225            run_operation,
1226            run_op_version,
1227            study_fingerprint,
1228            evidence_artifact_path,
1229        },
1230    ))
1231}
1232
1233pub fn analysis_run_study_op(
1234    spec: &AnalysisStudySpec,
1235    context: OperationContext,
1236) -> Result<OperationEnvelope<AnalysisStudyRunData>, OperationErrorEnvelope> {
1237    let issue_codes = validate_study_issue_codes(spec);
1238    if !issue_codes.is_empty() {
1239        return Err(operation_error(
1240            ANALYSIS_RUN_STUDY_OPERATION,
1241            ANALYSIS_RUN_STUDY_OP_VERSION,
1242            &context,
1243            OperationErrorSpec {
1244                error_code: "RM.FEA.RUN_STUDY.INVALID_SPEC",
1245                error_type: OperationErrorType::Validation,
1246                retryable: false,
1247                severity: OperationErrorSeverity::Error,
1248            },
1249            "study spec is invalid; run fea.validate for issue details",
1250            BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1251        ));
1252    }
1253
1254    let study_fingerprint = study_fingerprint(spec);
1255    let run_operation = run_operation_for_kind(spec.run_kind).to_string();
1256    let run_op_version = run_operation_version_for_kind(spec.run_kind).to_string();
1257    let operation_sequence = study_operation_sequence(spec, &run_op_version);
1258    let analysis_mesh_artifact =
1259        generate_and_persist_study_analysis_mesh(spec, &study_fingerprint, &context)?;
1260    let analysis_mesh_artifact_path = analysis_mesh_artifact
1261        .as_ref()
1262        .map(|artifact| artifact.path.clone());
1263    let analysis_mesh_evidence_artifact_path = analysis_mesh_artifact
1264        .as_ref()
1265        .map(|artifact| artifact.evidence_path.clone());
1266
1267    let study_prep = crate::geometry::geometry_prep_for_analysis_op(
1268        &spec.geometry,
1269        crate::geometry::GeometryPrepForAnalysisSpec::default(),
1270        context.clone(),
1271    )?
1272    .data;
1273    let study_prep_artifact_id = study_prep.prep_artifact_id.clone();
1274    let mut create_model_intent = spec.create_model_intent.clone();
1275    create_model_intent.prep_context = Some(AnalysisCreateModelPrepContext {
1276        source_geometry_id: spec.geometry.geometry_id.clone(),
1277        source_geometry_revision: spec.geometry.revision,
1278        region_mappings: study_prep.prep.region_mappings.clone(),
1279    });
1280
1281    let model = match &spec.model {
1282        Some(model) => model.clone(),
1283        None => {
1284            analysis_create_model_op(&spec.geometry, create_model_intent.clone(), context.clone())?
1285                .data
1286        }
1287    };
1288    analysis_validate(
1289        &model,
1290        spec.geometry.units,
1291        &ReferenceFrame::Global,
1292        context.clone(),
1293    )?;
1294    let (
1295        run_envelope,
1296        resolved_run_options,
1297        resolved_electromagnetic_run_options,
1298        refined_analysis_mesh_artifact,
1299    ) = match spec.run_kind {
1300        AnalysisRunKind::LinearStatic => {
1301            let mut options = spec.linear_static_run_options.clone().unwrap_or_default();
1302            attach_prep_artifact_to_run_options(&mut options, &study_prep_artifact_id);
1303            attach_analysis_mesh_artifact_to_run_options(
1304                &mut options,
1305                analysis_mesh_artifact_path.as_deref(),
1306            );
1307            let initial_run = analysis_run_linear_static_with_options(
1308                &model,
1309                spec.backend,
1310                options.clone(),
1311                context.clone(),
1312            )?;
1313            let refinement_candidate_path = analysis_mesh_artifact
1314                .as_ref()
1315                .filter(|artifact| artifact.allow_refinement)
1316                .map(|artifact| artifact.path.as_str());
1317            let refined_analysis_mesh_artifact = generate_and_persist_refined_study_analysis_mesh(
1318                spec,
1319                &study_fingerprint,
1320                refinement_candidate_path,
1321                &context,
1322            )?;
1323            if let Some(refined_artifact) = refined_analysis_mesh_artifact.as_ref() {
1324                let mut refined_options = options.clone();
1325                refined_options.analysis_mesh_artifact_path = Some(refined_artifact.path.clone());
1326                let refined_run = analysis_run_linear_static_with_options(
1327                    &model,
1328                    spec.backend,
1329                    refined_options.clone(),
1330                    context.clone(),
1331                )?;
1332                Ok((
1333                    refined_run,
1334                    run_options_to_json(&refined_options),
1335                    None,
1336                    refined_analysis_mesh_artifact,
1337                ))
1338            } else {
1339                Ok((
1340                    initial_run,
1341                    run_options_to_json(&options),
1342                    None,
1343                    refined_analysis_mesh_artifact,
1344                ))
1345            }
1346        }
1347        AnalysisRunKind::Modal => {
1348            let mut options = spec.modal_run_options.clone().unwrap_or_default();
1349            attach_prep_artifact_to_modal_options(&mut options, &study_prep_artifact_id);
1350            let run = analysis_run_modal_with_options_op(
1351                &model,
1352                spec.backend,
1353                options.clone(),
1354                context.clone(),
1355            )?;
1356            Ok((run, run_options_to_json(&options), None, None))
1357        }
1358        AnalysisRunKind::Acoustic => {
1359            let mut options = spec.acoustic_run_options.clone().unwrap_or_default();
1360            attach_prep_artifact_to_acoustic_options(&mut options, &study_prep_artifact_id);
1361            let run = analysis_run_acoustic_with_options_op(
1362                &model,
1363                spec.backend,
1364                options.clone(),
1365                context.clone(),
1366            )?;
1367            Ok((run, run_options_to_json(&options), None, None))
1368        }
1369        AnalysisRunKind::Thermal => {
1370            let mut options = spec.thermal_run_options.clone().unwrap_or_default();
1371            attach_prep_artifact_to_thermal_options(&mut options, &study_prep_artifact_id);
1372            let run = analysis_run_thermal_with_options_op(
1373                &model,
1374                spec.backend,
1375                options.clone(),
1376                context.clone(),
1377            )?;
1378            Ok((run, run_options_to_json(&options), None, None))
1379        }
1380        AnalysisRunKind::Transient => {
1381            let mut options = spec.transient_run_options.clone().unwrap_or_default();
1382            attach_prep_artifact_to_transient_options(&mut options, &study_prep_artifact_id);
1383            let run = analysis_run_transient_with_options_op(
1384                &model,
1385                spec.backend,
1386                options.clone(),
1387                context.clone(),
1388            )?;
1389            Ok((run, run_options_to_json(&options), None, None))
1390        }
1391        AnalysisRunKind::Cfd => {
1392            let mut options = spec.cfd_run_options.clone().unwrap_or_default();
1393            attach_prep_artifact_to_cfd_options(&mut options, &study_prep_artifact_id);
1394            let run = analysis_run_cfd_with_options_op(
1395                &model,
1396                spec.backend,
1397                options.clone(),
1398                context.clone(),
1399            )?;
1400            Ok((run, run_options_to_json(&options), None, None))
1401        }
1402        AnalysisRunKind::Cht => {
1403            let mut options = spec.cht_run_options.clone().unwrap_or_default();
1404            attach_prep_artifact_to_cht_options(&mut options, &study_prep_artifact_id);
1405            let run = analysis_run_cht_with_options_op(
1406                &model,
1407                spec.backend,
1408                options.clone(),
1409                context.clone(),
1410            )?;
1411            Ok((run, run_options_to_json(&options), None, None))
1412        }
1413        AnalysisRunKind::Fsi => {
1414            let mut options = spec.fsi_run_options.clone().unwrap_or_default();
1415            attach_prep_artifact_to_fsi_options(&mut options, &study_prep_artifact_id);
1416            let run = analysis_run_fsi_with_options_op(
1417                &model,
1418                spec.backend,
1419                options.clone(),
1420                context.clone(),
1421            )?;
1422            Ok((run, run_options_to_json(&options), None, None))
1423        }
1424        AnalysisRunKind::Nonlinear => {
1425            let mut options = spec.nonlinear_run_options.clone().unwrap_or_default();
1426            attach_prep_artifact_to_nonlinear_options(&mut options, &study_prep_artifact_id);
1427            let run = analysis_run_nonlinear_with_options_op(
1428                &model,
1429                spec.backend,
1430                options.clone(),
1431                context.clone(),
1432            )?;
1433            Ok((run, run_options_to_json(&options), None, None))
1434        }
1435        AnalysisRunKind::Electromagnetic => {
1436            let mut options = spec.electromagnetic_run_options.clone().unwrap_or_default();
1437            attach_prep_artifact_to_electromagnetic_options(&mut options, &study_prep_artifact_id);
1438            let run = analysis_run_electromagnetic_with_options_op(
1439                &model,
1440                spec.backend,
1441                options.clone(),
1442                context.clone(),
1443            )?;
1444            Ok((run, run_options_to_json(&options), Some(options), None))
1445        }
1446    }?;
1447    let refined_analysis_mesh_artifact_path = refined_analysis_mesh_artifact
1448        .as_ref()
1449        .map(|artifact| artifact.path.clone());
1450    let refined_analysis_mesh_evidence_artifact_path = refined_analysis_mesh_artifact
1451        .as_ref()
1452        .map(|artifact| artifact.evidence_path.clone());
1453    let refinement_effect = refined_analysis_mesh_artifact
1454        .as_ref()
1455        .map(|artifact| artifact.refinement_effect.clone());
1456
1457    let evidence_artifact_path = persist_study_evidence(
1458        &study_fingerprint,
1459        "run",
1460        serde_json::json!({
1461            "schema_version": "fea_study_run_artifact/v1",
1462            "study_id": spec.study_id.clone(),
1463            "model_id": model.model_id.0.clone(),
1464            "model_profile": spec.create_model_intent.profile,
1465            "run_kind": spec.run_kind,
1466            "backend": spec.backend,
1467            "prep_artifact_id": study_prep_artifact_id.clone(),
1468            "analysis_mesh_artifact_path": analysis_mesh_artifact_path.clone(),
1469            "analysis_mesh_evidence_artifact_path": analysis_mesh_evidence_artifact_path.clone(),
1470            "refined_analysis_mesh_artifact_path": refined_analysis_mesh_artifact_path.clone(),
1471            "refined_analysis_mesh_evidence_artifact_path": refined_analysis_mesh_evidence_artifact_path.clone(),
1472            "refinement_effect": refinement_effect,
1473            "run_options": resolved_run_options.clone(),
1474            "resolved_electromagnetic_run_options": resolved_electromagnetic_run_options.clone(),
1475            "study_fingerprint": study_fingerprint.clone(),
1476            "operation_sequence": operation_sequence.clone(),
1477            "run_operation": run_operation.clone(),
1478            "run_op_version": run_op_version.clone(),
1479            "run_id": run_envelope.data.run_id.clone(),
1480            "run_status": run_envelope.data.run_status,
1481            "publishable": run_envelope.data.publishable,
1482            "solver_convergence": run_envelope.data.solver_convergence,
1483            "result_quality": run_envelope.data.result_quality,
1484            "quality_reasons": run_envelope.data.quality_reasons.clone(),
1485            "provenance": run_envelope.data.provenance.clone(),
1486        }),
1487    )
1488    .map_err(|err| {
1489        operation_error(
1490            ANALYSIS_RUN_STUDY_OPERATION,
1491            ANALYSIS_RUN_STUDY_OP_VERSION,
1492            &context,
1493            OperationErrorSpec {
1494                error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
1495                error_type: OperationErrorType::Internal,
1496                retryable: true,
1497                severity: OperationErrorSeverity::Error,
1498            },
1499            format!("failed to persist study run evidence artifact: {err}"),
1500            BTreeMap::from([
1501                ("study_id".to_string(), spec.study_id.clone()),
1502                ("run_id".to_string(), run_envelope.data.run_id.clone()),
1503            ]),
1504        )
1505    })?;
1506
1507    Ok(OperationEnvelope::new(
1508        ANALYSIS_RUN_STUDY_OPERATION,
1509        ANALYSIS_RUN_STUDY_OP_VERSION,
1510        &context,
1511        AnalysisStudyRunData {
1512            study_id: spec.study_id.clone(),
1513            model_id: model.model_id.0.clone(),
1514            model_profile: spec.create_model_intent.profile,
1515            run_kind: spec.run_kind,
1516            backend: spec.backend,
1517            electromagnetic_run_options: resolved_electromagnetic_run_options,
1518            prep_artifact_id: Some(study_prep_artifact_id),
1519            analysis_mesh_artifact_path,
1520            analysis_mesh_evidence_artifact_path,
1521            refined_analysis_mesh_artifact_path,
1522            refined_analysis_mesh_evidence_artifact_path,
1523            run_options: resolved_run_options,
1524            study_fingerprint,
1525            operation_sequence,
1526            run_operation,
1527            run_op_version,
1528            run_id: run_envelope.data.run_id,
1529            run_status: run_envelope.data.run_status,
1530            publishable: run_envelope.data.publishable,
1531            solver_convergence: run_envelope.data.solver_convergence,
1532            result_quality: run_envelope.data.result_quality,
1533            quality_reasons: run_envelope.data.quality_reasons,
1534            provenance: run_envelope.data.provenance,
1535            evidence_artifact_path,
1536        },
1537    ))
1538}
1539
1540pub fn analysis_plan_study_sweep_op(
1541    spec: &AnalysisStudySweepSpec,
1542    context: OperationContext,
1543) -> Result<OperationEnvelope<AnalysisStudySweepPlanData>, OperationErrorEnvelope> {
1544    let mut issue_codes = Vec::new();
1545    if spec.sweep_id.trim().is_empty() {
1546        issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1547    }
1548    if spec.studies.is_empty() {
1549        issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1550    }
1551    if !issue_codes.is_empty() {
1552        return Err(operation_error(
1553            ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1554            ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1555            &context,
1556            OperationErrorSpec {
1557                error_code: "RM.FEA.PLAN_STUDY_SWEEP.INVALID_SPEC",
1558                error_type: OperationErrorType::Validation,
1559                retryable: false,
1560                severity: OperationErrorSeverity::Error,
1561            },
1562            "study sweep spec is invalid",
1563            BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1564        ));
1565    }
1566
1567    let mut plan_entries = Vec::with_capacity(spec.studies.len());
1568    let mut failure_entries = Vec::new();
1569    for (index, study) in spec.studies.iter().enumerate() {
1570        let planned = match analysis_plan_study_op(study, context.clone()) {
1571            Ok(plan) => plan,
1572            Err(err) => {
1573                if spec.fail_fast {
1574                    return Err(operation_error(
1575                        ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1576                        ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1577                        &context,
1578                        OperationErrorSpec {
1579                            error_code: "RM.FEA.PLAN_STUDY_SWEEP.STUDY_FAILED",
1580                            error_type: OperationErrorType::Validation,
1581                            retryable: false,
1582                            severity: OperationErrorSeverity::Error,
1583                        },
1584                        format!(
1585                            "study sweep planning failed at index {} for study_id {}: {}",
1586                            index, study.study_id, err.error_code
1587                        ),
1588                        BTreeMap::from([
1589                            ("sweep_id".to_string(), spec.sweep_id.clone()),
1590                            ("study_id".to_string(), study.study_id.clone()),
1591                            ("study_index".to_string(), index.to_string()),
1592                            ("cause_error_code".to_string(), err.error_code),
1593                        ]),
1594                    ));
1595                }
1596                failure_entries.push(AnalysisStudySweepFailureEntry {
1597                    study_id: study.study_id.clone(),
1598                    study_index: index,
1599                    error_code: err.error_code,
1600                    message: err.message,
1601                });
1602                continue;
1603            }
1604        };
1605        plan_entries.push(AnalysisStudySweepPlanEntry {
1606            study_id: planned.data.study_id,
1607            model_id: planned.data.model_id,
1608            run_kind: planned.data.run_kind,
1609            backend: planned.data.backend,
1610            electromagnetic_run_options: planned.data.electromagnetic_run_options,
1611            run_options: planned.data.run_options,
1612            operation_sequence: planned.data.operation_sequence,
1613            run_operation: planned.data.run_operation,
1614            run_op_version: planned.data.run_op_version,
1615            study_fingerprint: planned.data.study_fingerprint,
1616        });
1617    }
1618
1619    let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1620    let evidence_path = study_evidence_root()
1621        .join("sweeps")
1622        .join(sanitized_sweep_id)
1623        .join("plan.json");
1624    if let Some(parent) = evidence_path.parent() {
1625        fs_create_dir_all(parent).map_err(|err| {
1626            operation_error(
1627                ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1628                ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1629                &context,
1630                OperationErrorSpec {
1631                    error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1632                    error_type: OperationErrorType::Internal,
1633                    retryable: true,
1634                    severity: OperationErrorSeverity::Error,
1635                },
1636                format!("failed to create study sweep planning evidence directory: {err}"),
1637                BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1638            )
1639        })?;
1640    }
1641    let payload = serde_json::json!({
1642        "schema_version": "fea_study_sweep_plan_artifact/v1",
1643        "sweep_id": spec.sweep_id.clone(),
1644        "study_count": spec.studies.len(),
1645        "planned_count": plan_entries.len(),
1646        "failed_count": failure_entries.len(),
1647        "failure_entries": failure_entries.clone(),
1648        "plan_entries": plan_entries.clone(),
1649    });
1650    let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1651        operation_error(
1652            ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1653            ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1654            &context,
1655            OperationErrorSpec {
1656                error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1657                error_type: OperationErrorType::Internal,
1658                retryable: true,
1659                severity: OperationErrorSeverity::Error,
1660            },
1661            format!("failed to encode study sweep planning evidence payload: {err}"),
1662            BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1663        )
1664    })?;
1665    atomic_write_bytes(&evidence_path, &payload_bytes).map_err(|err| {
1666        operation_error(
1667            ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1668            ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1669            &context,
1670            OperationErrorSpec {
1671                error_code: "RM.FEA.PLAN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1672                error_type: OperationErrorType::Internal,
1673                retryable: true,
1674                severity: OperationErrorSeverity::Error,
1675            },
1676            err,
1677            BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1678        )
1679    })?;
1680
1681    Ok(OperationEnvelope::new(
1682        ANALYSIS_PLAN_STUDY_SWEEP_OPERATION,
1683        ANALYSIS_PLAN_STUDY_SWEEP_OP_VERSION,
1684        &context,
1685        AnalysisStudySweepPlanData {
1686            sweep_id: spec.sweep_id.clone(),
1687            study_count: spec.studies.len(),
1688            planned_count: plan_entries.len(),
1689            failed_count: failure_entries.len(),
1690            failure_entries,
1691            plan_entries,
1692            evidence_artifact_path: evidence_path.display().to_string(),
1693        },
1694    ))
1695}
1696
1697pub fn analysis_validate_study_sweep_op(
1698    spec: &AnalysisStudySweepSpec,
1699    context: OperationContext,
1700) -> Result<OperationEnvelope<AnalysisStudySweepValidateData>, OperationErrorEnvelope> {
1701    let mut issue_codes = Vec::new();
1702    if spec.sweep_id.trim().is_empty() {
1703        issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1704    }
1705    if spec.studies.is_empty() {
1706        issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1707    }
1708
1709    let study_entries: Vec<AnalysisStudySweepValidateEntry> = spec
1710        .studies
1711        .iter()
1712        .map(|study| {
1713            let study_issue_codes = validate_study_issue_codes(study);
1714            let issues = study_issue_codes
1715                .iter()
1716                .map(|code| AnalysisStudyIssue {
1717                    code: code.clone(),
1718                    message: study_issue_message(code).to_string(),
1719                })
1720                .collect::<Vec<_>>();
1721            AnalysisStudySweepValidateEntry {
1722                study_id: study.study_id.clone(),
1723                valid: study_issue_codes.is_empty(),
1724                issue_codes: study_issue_codes,
1725                issues,
1726            }
1727        })
1728        .collect();
1729
1730    let valid = issue_codes.is_empty() && study_entries.iter().all(|entry| entry.valid);
1731    let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1732    let evidence_path = study_evidence_root()
1733        .join("sweeps")
1734        .join(sanitized_sweep_id)
1735        .join("validate.json");
1736    if let Some(parent) = evidence_path.parent() {
1737        fs_create_dir_all(parent).map_err(|err| {
1738            operation_error(
1739                ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1740                ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1741                &context,
1742                OperationErrorSpec {
1743                    error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1744                    error_type: OperationErrorType::Internal,
1745                    retryable: true,
1746                    severity: OperationErrorSeverity::Error,
1747                },
1748                format!("failed to create study sweep validation evidence directory: {err}"),
1749                BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1750            )
1751        })?;
1752    }
1753    let payload = serde_json::json!({
1754        "schema_version": "fea_study_sweep_validate_artifact/v1",
1755        "sweep_id": spec.sweep_id.clone(),
1756        "valid": valid,
1757        "issue_codes": issue_codes.clone(),
1758        "study_entries": study_entries,
1759    });
1760    let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1761        operation_error(
1762            ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1763            ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1764            &context,
1765            OperationErrorSpec {
1766                error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1767                error_type: OperationErrorType::Internal,
1768                retryable: true,
1769                severity: OperationErrorSeverity::Error,
1770            },
1771            format!("failed to encode study sweep validation evidence payload: {err}"),
1772            BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1773        )
1774    })?;
1775    atomic_write_bytes(&evidence_path, &payload_bytes).map_err(|err| {
1776        operation_error(
1777            ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1778            ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1779            &context,
1780            OperationErrorSpec {
1781                error_code: "RM.FEA.VALIDATE_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1782                error_type: OperationErrorType::Internal,
1783                retryable: true,
1784                severity: OperationErrorSeverity::Error,
1785            },
1786            err,
1787            BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1788        )
1789    })?;
1790
1791    Ok(OperationEnvelope::new(
1792        ANALYSIS_VALIDATE_STUDY_SWEEP_OPERATION,
1793        ANALYSIS_VALIDATE_STUDY_SWEEP_OP_VERSION,
1794        &context,
1795        AnalysisStudySweepValidateData {
1796            sweep_id: spec.sweep_id.clone(),
1797            valid,
1798            issue_codes,
1799            study_entries,
1800            evidence_artifact_path: evidence_path.display().to_string(),
1801        },
1802    ))
1803}
1804
1805pub fn analysis_run_study_sweep_op(
1806    spec: &AnalysisStudySweepSpec,
1807    context: OperationContext,
1808) -> Result<OperationEnvelope<AnalysisStudySweepData>, OperationErrorEnvelope> {
1809    let mut issue_codes = Vec::new();
1810    if spec.sweep_id.trim().is_empty() {
1811        issue_codes.push("RM.FEA.STUDY_SWEEP.ID_EMPTY".to_string());
1812    }
1813    if spec.studies.is_empty() {
1814        issue_codes.push("RM.FEA.STUDY_SWEEP.STUDIES_EMPTY".to_string());
1815    }
1816    if !issue_codes.is_empty() {
1817        return Err(operation_error(
1818            ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1819            ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1820            &context,
1821            OperationErrorSpec {
1822                error_code: "RM.FEA.RUN_STUDY_SWEEP.INVALID_SPEC",
1823                error_type: OperationErrorType::Validation,
1824                retryable: false,
1825                severity: OperationErrorSeverity::Error,
1826            },
1827            "study sweep spec is invalid",
1828            BTreeMap::from([("issue_codes".to_string(), issue_codes.join(","))]),
1829        ));
1830    }
1831
1832    let mut run_entries = Vec::with_capacity(spec.studies.len());
1833    let mut failure_entries = Vec::new();
1834    for (index, study) in spec.studies.iter().enumerate() {
1835        let run = match analysis_run_study_op(study, context.clone()) {
1836            Ok(run) => run,
1837            Err(err) => {
1838                if spec.fail_fast {
1839                    return Err(operation_error(
1840                        ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1841                        ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1842                        &context,
1843                        OperationErrorSpec {
1844                            error_code: "RM.FEA.RUN_STUDY_SWEEP.STUDY_FAILED",
1845                            error_type: OperationErrorType::Validation,
1846                            retryable: false,
1847                            severity: OperationErrorSeverity::Error,
1848                        },
1849                        format!(
1850                            "study sweep failed at index {} for study_id {}: {}",
1851                            index, study.study_id, err.error_code
1852                        ),
1853                        BTreeMap::from([
1854                            ("sweep_id".to_string(), spec.sweep_id.clone()),
1855                            ("study_id".to_string(), study.study_id.clone()),
1856                            ("study_index".to_string(), index.to_string()),
1857                            ("cause_error_code".to_string(), err.error_code),
1858                        ]),
1859                    ));
1860                }
1861                failure_entries.push(AnalysisStudySweepFailureEntry {
1862                    study_id: study.study_id.clone(),
1863                    study_index: index,
1864                    error_code: err.error_code,
1865                    message: err.message,
1866                });
1867                continue;
1868            }
1869        };
1870        run_entries.push(AnalysisStudySweepRunEntry {
1871            study_id: run.data.study_id,
1872            run_kind: run.data.run_kind,
1873            run_id: run.data.run_id,
1874            run_status: run.data.run_status,
1875            publishable: run.data.publishable,
1876            run_operation: run.data.run_operation,
1877            run_op_version: run.data.run_op_version,
1878        });
1879    }
1880
1881    let sanitized_sweep_id = sanitize_study_sweep_id(&spec.sweep_id);
1882    let evidence_root = study_evidence_root()
1883        .join("sweeps")
1884        .join(sanitized_sweep_id)
1885        .join("run.json");
1886    if let Some(parent) = evidence_root.parent() {
1887        fs_create_dir_all(parent).map_err(|err| {
1888            operation_error(
1889                ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1890                ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1891                &context,
1892                OperationErrorSpec {
1893                    error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1894                    error_type: OperationErrorType::Internal,
1895                    retryable: true,
1896                    severity: OperationErrorSeverity::Error,
1897                },
1898                format!("failed to create study sweep evidence directory: {err}"),
1899                BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1900            )
1901        })?;
1902    }
1903    let payload = serde_json::json!({
1904        "schema_version": "fea_study_sweep_run_artifact/v1",
1905        "sweep_id": spec.sweep_id.clone(),
1906        "fail_fast": spec.fail_fast,
1907        "study_count": spec.studies.len(),
1908        "success_count": run_entries.len(),
1909        "failed_count": failure_entries.len(),
1910        "failure_entries": failure_entries.clone(),
1911        "run_entries": run_entries.clone(),
1912    });
1913    let payload_bytes = serde_json::to_vec_pretty(&payload).map_err(|err| {
1914        operation_error(
1915            ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1916            ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1917            &context,
1918            OperationErrorSpec {
1919                error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1920                error_type: OperationErrorType::Internal,
1921                retryable: true,
1922                severity: OperationErrorSeverity::Error,
1923            },
1924            format!("failed to encode study sweep evidence payload: {err}"),
1925            BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1926        )
1927    })?;
1928    atomic_write_bytes(&evidence_root, &payload_bytes).map_err(|err| {
1929        operation_error(
1930            ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1931            ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1932            &context,
1933            OperationErrorSpec {
1934                error_code: "RM.FEA.RUN_STUDY_SWEEP.ARTIFACT_STORE_FAILED",
1935                error_type: OperationErrorType::Internal,
1936                retryable: true,
1937                severity: OperationErrorSeverity::Error,
1938            },
1939            err,
1940            BTreeMap::from([("sweep_id".to_string(), spec.sweep_id.clone())]),
1941        )
1942    })?;
1943
1944    let evidence_artifact_path = evidence_root.display().to_string();
1945    Ok(OperationEnvelope::new(
1946        ANALYSIS_RUN_STUDY_SWEEP_OPERATION,
1947        ANALYSIS_RUN_STUDY_SWEEP_OP_VERSION,
1948        &context,
1949        AnalysisStudySweepData {
1950            sweep_id: spec.sweep_id.clone(),
1951            study_count: spec.studies.len(),
1952            success_count: run_entries.len(),
1953            failed_count: failure_entries.len(),
1954            failure_entries,
1955            run_entries,
1956            evidence_artifact_path,
1957        },
1958    ))
1959}
1960
1961pub fn analysis_validate(
1962    model: &AnalysisModel,
1963    geometry_units: UnitSystem,
1964    geometry_frame: &ReferenceFrame,
1965    context: OperationContext,
1966) -> Result<OperationEnvelope<AnalysisValidateResult>, OperationErrorEnvelope> {
1967    validate_model_against_geometry(model, geometry_units, geometry_frame)
1968        .map_err(|err| map_validate_error(err, model, &context))?;
1969
1970    Ok(OperationEnvelope::new(
1971        ANALYSIS_VALIDATE_OPERATION,
1972        ANALYSIS_VALIDATE_OP_VERSION,
1973        &context,
1974        AnalysisValidateResult { valid: true },
1975    ))
1976}
1977
1978pub fn analysis_run_linear_static_op(
1979    model: &AnalysisModel,
1980    backend: ComputeBackend,
1981    context: OperationContext,
1982) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1983    analysis_run_linear_static_with_options(model, backend, AnalysisRunOptions::default(), context)
1984}
1985
1986pub fn analysis_run_modal_op(
1987    model: &AnalysisModel,
1988    backend: ComputeBackend,
1989    context: OperationContext,
1990) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1991    analysis_run_modal_with_options_op(model, backend, AnalysisModalRunOptions::default(), context)
1992}
1993
1994pub fn analysis_run_acoustic_op(
1995    model: &AnalysisModel,
1996    backend: ComputeBackend,
1997    context: OperationContext,
1998) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
1999    analysis_run_acoustic_with_options_op(
2000        model,
2001        backend,
2002        AnalysisAcousticRunOptions::default(),
2003        context,
2004    )
2005}
2006
2007pub fn analysis_run_modal_with_options_op(
2008    model: &AnalysisModel,
2009    backend: ComputeBackend,
2010    options: AnalysisModalRunOptions,
2011    context: OperationContext,
2012) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
2013    let _solver_context = install_fea_solver_context();
2014    let has_modal_step = model
2015        .steps
2016        .iter()
2017        .any(|step| step.kind == AnalysisStepKind::Modal);
2018    if !has_modal_step {
2019        return Err(operation_error(
2020            ANALYSIS_RUN_MODAL_OPERATION,
2021            ANALYSIS_RUN_MODAL_OP_VERSION,
2022            &context,
2023            OperationErrorSpec {
2024                error_code: "RM.FEA.RUN_MODAL.INVALID_MODEL",
2025                error_type: OperationErrorType::Validation,
2026                retryable: false,
2027                severity: OperationErrorSeverity::Error,
2028            },
2029            "FEA model must include at least one modal step for fea.run_modal",
2030            BTreeMap::from([
2031                ("analysis_model_id".to_string(), model.model_id.0.clone()),
2032                ("geometry_id".to_string(), model.geometry_id.clone()),
2033            ]),
2034        ));
2035    }
2036
2037    if options.mode_count == 0 {
2038        return Err(operation_error(
2039            ANALYSIS_RUN_MODAL_OPERATION,
2040            ANALYSIS_RUN_MODAL_OP_VERSION,
2041            &context,
2042            OperationErrorSpec {
2043                error_code: "RM.FEA.RUN_MODAL.INVALID_OPTIONS",
2044                error_type: OperationErrorType::Input,
2045                retryable: false,
2046                severity: OperationErrorSeverity::Error,
2047            },
2048            "fea.run_modal options require mode_count greater than zero",
2049            BTreeMap::from([("mode_count".to_string(), options.mode_count.to_string())]),
2050        ));
2051    }
2052
2053    let thermo_options = resolve_thermo_coupling_options(
2054        model,
2055        model_thermo_coupling_options(model),
2056        ANALYSIS_RUN_MODAL_OPERATION,
2057        ANALYSIS_RUN_MODAL_OP_VERSION,
2058        &context,
2059    )?;
2060    if let Some(thermo_options) = thermo_options.as_ref() {
2061        if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
2062            return Err(operation_error(
2063                ANALYSIS_RUN_MODAL_OPERATION,
2064                ANALYSIS_RUN_MODAL_OP_VERSION,
2065                &context,
2066                OperationErrorSpec {
2067                    error_code: "RM.FEA.RUN_MODAL.INVALID_OPTIONS",
2068                    error_type: OperationErrorType::Input,
2069                    retryable: false,
2070                    severity: OperationErrorSeverity::Error,
2071                },
2072                detail,
2073                metadata,
2074            ));
2075        }
2076    }
2077    let electro_options = model_electro_coupling_options(model);
2078    if let Some(electro_options) = electro_options.as_ref() {
2079        if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
2080            return Err(operation_error(
2081                ANALYSIS_RUN_MODAL_OPERATION,
2082                ANALYSIS_RUN_MODAL_OP_VERSION,
2083                &context,
2084                OperationErrorSpec {
2085                    error_code: electro_thermal_invalid_options_error_code(
2086                        ANALYSIS_RUN_MODAL_OPERATION,
2087                    ),
2088                    error_type: OperationErrorType::Input,
2089                    retryable: false,
2090                    severity: OperationErrorSeverity::Error,
2091                },
2092                detail,
2093                metadata,
2094            ));
2095        }
2096    }
2097
2098    let prep_context = resolve_run_prep_context(
2099        model,
2100        options.prep_artifact_id.as_deref(),
2101        options.prep_context.clone(),
2102        ANALYSIS_RUN_MODAL_OPERATION,
2103        ANALYSIS_RUN_MODAL_OP_VERSION,
2104        &context,
2105    )?;
2106
2107    let modal_run = run_modal_with_options(
2108        model,
2109        backend,
2110        ModalSolveOptions {
2111            mode_count: options.mode_count,
2112            prep_context: to_fea_prep_context(
2113                prep_context.as_ref(),
2114                options.prep_calibration_profile,
2115            ),
2116            thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
2117            electro_thermal_context: to_fea_electro_thermal_context(electro_options),
2118        },
2119    )
2120    .map_err(|err| {
2121        map_fea_run_error(
2122            ANALYSIS_RUN_MODAL_OPERATION,
2123            ANALYSIS_RUN_MODAL_OP_VERSION,
2124            "RM.FEA.RUN_MODAL.SOLVER_MODEL_INVALID",
2125            "RM.FEA.RUN_MODAL.CANCELLED",
2126            model,
2127            &context,
2128            err,
2129        )
2130    })?;
2131
2132    let mut run = modal_run.run;
2133    let mut fallback_events = Vec::new();
2134    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
2135    if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
2136        fallback_events.push(
2137            "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
2138        );
2139    }
2140    let solver_convergence = if run.diagnostics.iter().any(|item| {
2141        item.code == "FEA_MODAL_CONVERGENCE"
2142            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2143    }) {
2144        QualityGate::Pass
2145    } else {
2146        QualityGate::Warn
2147    };
2148    let result_quality = if modal_run.eigenvalues_hz.is_empty() || modal_run.mode_shapes.is_empty()
2149    {
2150        QualityGate::Fail
2151    } else if modal_run
2152        .residual_norms
2153        .iter()
2154        .copied()
2155        .fold(0.0_f64, f64::max)
2156        > options.residual_warn_threshold
2157    {
2158        QualityGate::Warn
2159    } else {
2160        QualityGate::Pass
2161    };
2162    let modal_orthogonality_warn = run.diagnostics.iter().any(|item| {
2163        item.code == "FEA_MODAL_ORTHOGONALITY"
2164            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2165    });
2166    let modal_separation_warn = run.diagnostics.iter().any(|item| {
2167        item.code == "FEA_MODAL_SEPARATION"
2168            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2169    });
2170
2171    let mut quality_reasons = Vec::new();
2172    if solver_convergence == QualityGate::Warn {
2173        quality_reasons.push(QualityReason {
2174            code: QualityReasonCode::SolverNotConverged,
2175            detail: "modal solver convergence gate is warning".to_string(),
2176        });
2177    }
2178    if result_quality == QualityGate::Warn {
2179        quality_reasons.push(QualityReason {
2180            code: QualityReasonCode::ModalResidualExceeded,
2181            detail: format!(
2182                "modal residual exceeds threshold {}",
2183                options.residual_warn_threshold
2184            ),
2185        });
2186    }
2187    if modal_orthogonality_warn {
2188        quality_reasons.push(QualityReason {
2189            code: QualityReasonCode::ModalOrthogonalityExceeded,
2190            detail: "modal M-orthogonality off-diagonal threshold exceeded".to_string(),
2191        });
2192    }
2193    if modal_separation_warn {
2194        quality_reasons.push(QualityReason {
2195            code: QualityReasonCode::ModalSeparationLow,
2196            detail: "modal frequency separation threshold is low".to_string(),
2197        });
2198    }
2199    if fallback_events
2200        .iter()
2201        .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
2202    {
2203        quality_reasons.push(QualityReason {
2204            code: QualityReasonCode::SolverBackendFallback,
2205            detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
2206        });
2207    }
2208    if fallback_events.iter().any(|event| {
2209        event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
2210    }) {
2211        quality_reasons.push(QualityReason {
2212            code: QualityReasonCode::FieldPromotionFallback,
2213            detail: "field promotion fell back to host-backed values".to_string(),
2214        });
2215    }
2216
2217    let frequency_basis = ModalFrequencyBasis::NativeEigenSolve;
2218
2219    let publishable = match options.quality_policy {
2220        QualityPolicy::Strict => {
2221            solver_convergence == QualityGate::Pass
2222                && result_quality == QualityGate::Pass
2223                && quality_reasons.is_empty()
2224        }
2225        QualityPolicy::Balanced => {
2226            solver_convergence == QualityGate::Pass
2227                && result_quality == QualityGate::Pass
2228                && !quality_reasons.iter().any(|r| {
2229                    matches!(
2230                        r.code,
2231                        QualityReasonCode::ModalOrthogonalityExceeded
2232                            | QualityReasonCode::ModalSeparationLow
2233                    )
2234                })
2235        }
2236        QualityPolicy::Exploratory => {
2237            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
2238        }
2239    };
2240    let run_status = if publishable {
2241        RunStatus::Publishable
2242    } else if result_quality == QualityGate::Fail {
2243        RunStatus::Rejected
2244    } else {
2245        RunStatus::Degraded
2246    };
2247    let solver_backend = run.solver_backend.clone();
2248    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
2249    let solver_host_sync_count = run.solver_host_sync_count;
2250    let solver_method = run.solver_method.clone();
2251    let selected_preconditioner = run.preconditioner.clone();
2252
2253    let result = AnalysisRunResult {
2254        run_id: storage::next_run_id(),
2255        run,
2256        render_topology: render_topology_from_prep_context(prep_context.as_ref()),
2257        modal_results: Some(ModalResultsData {
2258            modal_payload_version: "modal_results/v1".to_string(),
2259            eigenvalues_hz: modal_run.eigenvalues_hz,
2260            mode_shapes: modal_run.mode_shapes,
2261            residual_norms: modal_run.residual_norms,
2262            mode_units: ModalFrequencyUnits::Hz,
2263            frequency_basis,
2264        }),
2265        thermal_results: None,
2266        transient_results: None,
2267        nonlinear_results: None,
2268        electromagnetic_results: None,
2269        model_validity: QualityGate::Pass,
2270        solver_convergence,
2271        result_quality,
2272        run_status,
2273        publishable,
2274        quality_reasons,
2275        provenance: RunProvenance {
2276            backend,
2277            solver_backend,
2278            solver_device_apply_k_ratio,
2279            solver_host_sync_count,
2280            precision_mode: contracts::format_precision_mode(options.precision_mode),
2281            deterministic_mode: options.deterministic_mode,
2282            solver_method,
2283            preconditioner: selected_preconditioner,
2284            quality_policy: contracts::format_quality_policy(options.quality_policy),
2285            fallback_events,
2286        },
2287    };
2288
2289    if let Some(nonlinear) = result.nonlinear_results.as_ref() {
2290        let event = format!(
2291            "fea.run_nonlinear outcome run_id={} model_id={} backend={:?} run_status={:?} publishable={} failed_increments={} max_iteration_count={} line_search_backtracks={} tangent_rebuild_count={} max_residual_norm={} max_increment_norm={} max_backtracks_per_increment={} quality_reason_count={}",
2292            result.run_id,
2293            model.model_id.0,
2294            backend,
2295            result.run_status,
2296            result.publishable,
2297            nonlinear.failed_increments,
2298            nonlinear
2299                .iteration_counts
2300                .iter()
2301                .copied()
2302                .max()
2303                .unwrap_or(0),
2304            nonlinear.line_search_backtracks,
2305            nonlinear.tangent_rebuild_count,
2306            nonlinear
2307                .residual_norms
2308                .iter()
2309                .copied()
2310                .reduce(f64::max)
2311                .unwrap_or(0.0),
2312            nonlinear
2313                .increment_norms
2314                .iter()
2315                .copied()
2316                .reduce(f64::max)
2317                .unwrap_or(0.0),
2318            nonlinear.max_line_search_backtracks_per_increment,
2319            result.quality_reasons.len()
2320        );
2321        if matches!(result.run_status, RunStatus::Degraded | RunStatus::Rejected) {
2322            tracing::warn!(target: "runmat_analysis", "{event}");
2323        } else {
2324            tracing::info!(target: "runmat_analysis", "{event}");
2325        }
2326    }
2327
2328    persist_fea_run_result_with_progress(
2329        ANALYSIS_RUN_MODAL_OPERATION,
2330        ANALYSIS_RUN_MODAL_OP_VERSION,
2331        "RM.FEA.RUN_MODAL.ARTIFACT_STORE_FAILED",
2332        &context,
2333        &result,
2334    )?;
2335
2336    Ok(OperationEnvelope::new(
2337        ANALYSIS_RUN_MODAL_OPERATION,
2338        ANALYSIS_RUN_MODAL_OP_VERSION,
2339        &context,
2340        result,
2341    ))
2342}
2343
2344pub fn analysis_run_acoustic_with_options_op(
2345    model: &AnalysisModel,
2346    backend: ComputeBackend,
2347    options: AnalysisAcousticRunOptions,
2348    context: OperationContext,
2349) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
2350    let _solver_context = install_fea_solver_context();
2351    let has_modal_step = model
2352        .steps
2353        .iter()
2354        .any(|step| step.kind == AnalysisStepKind::Modal);
2355    if !has_modal_step {
2356        return Err(operation_error(
2357            ANALYSIS_RUN_ACOUSTIC_OPERATION,
2358            ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2359            &context,
2360            OperationErrorSpec {
2361                error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_MODEL",
2362                error_type: OperationErrorType::Validation,
2363                retryable: false,
2364                severity: OperationErrorSeverity::Error,
2365            },
2366            "FEA model must include an acoustic harmonic step marker for fea.run_acoustic",
2367            BTreeMap::from([
2368                ("analysis_model_id".to_string(), model.model_id.0.clone()),
2369                ("geometry_id".to_string(), model.geometry_id.clone()),
2370            ]),
2371        ));
2372    }
2373    if !model
2374        .materials
2375        .iter()
2376        .any(|material| material.acoustic.is_some())
2377    {
2378        return Err(operation_error(
2379            ANALYSIS_RUN_ACOUSTIC_OPERATION,
2380            ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2381            &context,
2382            OperationErrorSpec {
2383                error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_MATERIAL",
2384                error_type: OperationErrorType::Validation,
2385                retryable: false,
2386                severity: OperationErrorSeverity::Error,
2387            },
2388            "fea.run_acoustic requires at least one acoustic material with density and sound-speed data",
2389            BTreeMap::from([
2390                ("analysis_model_id".to_string(), model.model_id.0.clone()),
2391                (
2392                    "material_count".to_string(),
2393                    model.materials.len().to_string(),
2394                ),
2395            ]),
2396        ));
2397    }
2398    reject_moment_loads_for_run_family(
2399        model,
2400        ANALYSIS_RUN_ACOUSTIC_OPERATION,
2401        ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2402        "RM.FEA.RUN_ACOUSTIC.INVALID_ACOUSTIC_SOURCE",
2403        "acoustic",
2404        &context,
2405    )?;
2406    if !model.loads.iter().any(|load| match &load.kind {
2407        LoadKind::Pressure { magnitude_pa } => magnitude_pa.is_finite() && magnitude_pa.abs() > 0.0,
2408        _ => false,
2409    }) {
2410        return Err(operation_error(
2411            ANALYSIS_RUN_ACOUSTIC_OPERATION,
2412            ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2413            &context,
2414            OperationErrorSpec {
2415                error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_SOURCE",
2416                error_type: OperationErrorType::Validation,
2417                retryable: false,
2418                severity: OperationErrorSeverity::Error,
2419            },
2420            "fea.run_acoustic requires a nonzero acoustic pressure source load",
2421            BTreeMap::from([
2422                ("analysis_model_id".to_string(), model.model_id.0.clone()),
2423                ("load_count".to_string(), model.loads.len().to_string()),
2424            ]),
2425        ));
2426    }
2427    if !model.boundary_conditions.iter().any(|bc| {
2428        matches!(
2429            &bc.kind,
2430            BoundaryConditionKind::AcousticRigidWall
2431                | BoundaryConditionKind::AcousticRadiation
2432                | BoundaryConditionKind::AcousticImpedance { .. }
2433        )
2434    }) {
2435        return Err(operation_error(
2436            ANALYSIS_RUN_ACOUSTIC_OPERATION,
2437            ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2438            &context,
2439            OperationErrorSpec {
2440                error_code: "RM.FEA.RUN_ACOUSTIC.MISSING_ACOUSTIC_BOUNDARY",
2441                error_type: OperationErrorType::Validation,
2442                retryable: false,
2443                severity: OperationErrorSeverity::Error,
2444            },
2445            "fea.run_acoustic requires at least one acoustic boundary condition",
2446            BTreeMap::from([
2447                ("analysis_model_id".to_string(), model.model_id.0.clone()),
2448                (
2449                    "boundary_condition_count".to_string(),
2450                    model.boundary_conditions.len().to_string(),
2451                ),
2452            ]),
2453        ));
2454    }
2455
2456    if options.mode_count == 0 {
2457        return Err(operation_error(
2458            ANALYSIS_RUN_ACOUSTIC_OPERATION,
2459            ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2460            &context,
2461            OperationErrorSpec {
2462                error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_OPTIONS",
2463                error_type: OperationErrorType::Input,
2464                retryable: false,
2465                severity: OperationErrorSeverity::Error,
2466            },
2467            "fea.run_acoustic options require mode_count greater than zero",
2468            BTreeMap::from([("mode_count".to_string(), options.mode_count.to_string())]),
2469        ));
2470    }
2471
2472    let thermo_options = resolve_thermo_coupling_options(
2473        model,
2474        model_thermo_coupling_options(model),
2475        ANALYSIS_RUN_ACOUSTIC_OPERATION,
2476        ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2477        &context,
2478    )?;
2479    if let Some(thermo_options) = thermo_options.as_ref() {
2480        if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
2481            return Err(operation_error(
2482                ANALYSIS_RUN_ACOUSTIC_OPERATION,
2483                ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2484                &context,
2485                OperationErrorSpec {
2486                    error_code: "RM.FEA.RUN_ACOUSTIC.INVALID_OPTIONS",
2487                    error_type: OperationErrorType::Input,
2488                    retryable: false,
2489                    severity: OperationErrorSeverity::Error,
2490                },
2491                detail,
2492                metadata,
2493            ));
2494        }
2495    }
2496    let electro_options = model_electro_coupling_options(model);
2497    if let Some(electro_options) = electro_options.as_ref() {
2498        if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
2499            return Err(operation_error(
2500                ANALYSIS_RUN_ACOUSTIC_OPERATION,
2501                ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2502                &context,
2503                OperationErrorSpec {
2504                    error_code: electro_thermal_invalid_options_error_code(
2505                        ANALYSIS_RUN_ACOUSTIC_OPERATION,
2506                    ),
2507                    error_type: OperationErrorType::Input,
2508                    retryable: false,
2509                    severity: OperationErrorSeverity::Error,
2510                },
2511                detail,
2512                metadata,
2513            ));
2514        }
2515    }
2516
2517    let prep_context = resolve_run_prep_context(
2518        model,
2519        options.prep_artifact_id.as_deref(),
2520        options.prep_context.clone(),
2521        ANALYSIS_RUN_ACOUSTIC_OPERATION,
2522        ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2523        &context,
2524    )?;
2525    let render_topology = render_topology_from_prep_context(prep_context.as_ref());
2526
2527    let solve_start = Instant::now();
2528    let mut run = solve_acoustic_harmonic(
2529        model,
2530        backend,
2531        options.mode_count,
2532        prep_context,
2533        options.residual_warn_threshold,
2534    );
2535    let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
2536    run.diagnostics
2537        .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
2538            code: "FEA_ACOUSTIC_COST".to_string(),
2539            severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
2540            message: format!(
2541                "solve_ms={} mode_count={} residual_warn_threshold={}",
2542                solve_ms, options.mode_count, options.residual_warn_threshold,
2543            ),
2544        });
2545    let acoustic_residual_norm = diagnostic_metric(
2546        &run.diagnostics,
2547        "FEA_ACOUSTIC_HELMHOLTZ_RESIDUAL",
2548        "normalized_residual_norm",
2549    )
2550    .unwrap_or(f64::INFINITY);
2551    let mut fallback_events = Vec::new();
2552    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
2553    if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
2554        fallback_events.push(
2555            "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
2556        );
2557    }
2558    let solver_convergence = if acoustic_residual_norm <= options.residual_warn_threshold {
2559        QualityGate::Pass
2560    } else {
2561        QualityGate::Warn
2562    };
2563    let result_quality = if run.fields_are_empty() {
2564        QualityGate::Fail
2565    } else if acoustic_residual_norm > options.residual_warn_threshold {
2566        QualityGate::Warn
2567    } else {
2568        QualityGate::Pass
2569    };
2570
2571    let mut quality_reasons = Vec::new();
2572    if solver_convergence == QualityGate::Warn {
2573        quality_reasons.push(QualityReason {
2574            code: QualityReasonCode::SolverNotConverged,
2575            detail: "acoustic solver convergence gate is warning".to_string(),
2576        });
2577    }
2578    if result_quality == QualityGate::Warn {
2579        quality_reasons.push(QualityReason {
2580            code: QualityReasonCode::ModalResidualExceeded,
2581            detail: format!(
2582                "acoustic residual exceeds threshold {}",
2583                options.residual_warn_threshold
2584            ),
2585        });
2586    }
2587    if fallback_events
2588        .iter()
2589        .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
2590    {
2591        quality_reasons.push(QualityReason {
2592            code: QualityReasonCode::SolverBackendFallback,
2593            detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
2594        });
2595    }
2596    if fallback_events.iter().any(|event| {
2597        event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
2598    }) {
2599        quality_reasons.push(QualityReason {
2600            code: QualityReasonCode::FieldPromotionFallback,
2601            detail: "field promotion fell back to host-backed values".to_string(),
2602        });
2603    }
2604
2605    let publishable = match options.quality_policy {
2606        QualityPolicy::Strict => {
2607            solver_convergence == QualityGate::Pass
2608                && result_quality == QualityGate::Pass
2609                && quality_reasons.is_empty()
2610        }
2611        QualityPolicy::Balanced => {
2612            solver_convergence == QualityGate::Pass
2613                && result_quality == QualityGate::Pass
2614                && quality_reasons.is_empty()
2615        }
2616        QualityPolicy::Exploratory => {
2617            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
2618        }
2619    };
2620    let run_status = if publishable {
2621        RunStatus::Publishable
2622    } else if result_quality == QualityGate::Fail {
2623        RunStatus::Rejected
2624    } else {
2625        RunStatus::Degraded
2626    };
2627    let solver_backend = run.solver_backend.clone();
2628    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
2629    let solver_host_sync_count = run.solver_host_sync_count;
2630    let solver_method = run.solver_method.clone();
2631    let selected_preconditioner = run.preconditioner.clone();
2632
2633    let result = AnalysisRunResult {
2634        run_id: storage::next_run_id(),
2635        run,
2636        render_topology,
2637        modal_results: None,
2638        thermal_results: None,
2639        transient_results: None,
2640        nonlinear_results: None,
2641        electromagnetic_results: None,
2642        model_validity: QualityGate::Pass,
2643        solver_convergence,
2644        result_quality,
2645        run_status,
2646        publishable,
2647        quality_reasons,
2648        provenance: RunProvenance {
2649            backend,
2650            solver_backend,
2651            solver_device_apply_k_ratio,
2652            solver_host_sync_count,
2653            precision_mode: contracts::format_precision_mode(options.precision_mode),
2654            deterministic_mode: options.deterministic_mode,
2655            solver_method,
2656            preconditioner: selected_preconditioner,
2657            quality_policy: contracts::format_quality_policy(options.quality_policy),
2658            fallback_events,
2659        },
2660    };
2661
2662    persist_fea_run_result_with_progress(
2663        ANALYSIS_RUN_ACOUSTIC_OPERATION,
2664        ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2665        "RM.FEA.RUN_ACOUSTIC.ARTIFACT_STORE_FAILED",
2666        &context,
2667        &result,
2668    )?;
2669
2670    Ok(OperationEnvelope::new(
2671        ANALYSIS_RUN_ACOUSTIC_OPERATION,
2672        ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
2673        &context,
2674        result,
2675    ))
2676}
2677
2678fn solve_acoustic_harmonic(
2679    model: &AnalysisModel,
2680    backend: ComputeBackend,
2681    mode_count: usize,
2682    prep_context: Option<AnalysisRunPrepContext>,
2683    residual_warn_threshold: f64,
2684) -> FeaRunResult {
2685    let node_count = acoustic_node_count(model, prep_context.as_ref());
2686    let material_summary = acoustic_material_summary(model, mode_count);
2687    let boundary_summary = acoustic_boundary_summary(
2688        model,
2689        material_summary.characteristic_impedance_pa_s_per_m(),
2690    );
2691    let speed_of_sound_m_per_s = material_summary.speed_of_sound_m_per_s;
2692    let density_kg_per_m3 = material_summary.density_kg_per_m3;
2693    let drive_frequency_hz = acoustic_drive_frequency_hz(mode_count, node_count);
2694    let damping_ratio = material_summary.damping_ratio;
2695    let source_real = acoustic_source_vector(model, node_count);
2696    let source_imag = vec![0.0; node_count];
2697    let domain = acoustic_domain_topology(node_count, prep_context.as_ref());
2698    let system = acoustic_helmholtz_operator(
2699        domain,
2700        node_count,
2701        drive_frequency_hz,
2702        speed_of_sound_m_per_s,
2703        damping_ratio,
2704        &boundary_summary,
2705    );
2706    let (pressure_real, pressure_imag) =
2707        solve_complex_graph_operator(&system, &source_real, &source_imag);
2708    let normalized_residual_norm = acoustic_residual_norm(
2709        &system,
2710        &pressure_real,
2711        &pressure_imag,
2712        &source_real,
2713        &source_imag,
2714    );
2715    let pressure_magnitude = pressure_real
2716        .iter()
2717        .zip(pressure_imag.iter())
2718        .map(|(real, imag)| real.hypot(*imag))
2719        .collect::<Vec<_>>();
2720    let phase = pressure_real
2721        .iter()
2722        .zip(pressure_imag.iter())
2723        .map(|(real, imag)| imag.atan2(*real))
2724        .collect::<Vec<_>>();
2725    let sound_pressure_level_db = pressure_magnitude
2726        .iter()
2727        .map(|pressure| 20.0 * (pressure.max(2.0e-5) / 2.0e-5).log10())
2728        .collect::<Vec<_>>();
2729    let particle_velocity = recover_acoustic_particle_velocity(
2730        &pressure_real,
2731        domain,
2732        drive_frequency_hz,
2733        density_kg_per_m3,
2734    );
2735    let peak_pressure_pa = pressure_magnitude.iter().copied().fold(0.0_f64, f64::max);
2736    let sweep_frequencies_hz = acoustic_sweep_frequencies_hz(drive_frequency_hz);
2737    let mut frequency_response_fields = Vec::with_capacity(sweep_frequencies_hz.len());
2738    let mut sweep_peak_pressure_pa = 0.0_f64;
2739    let mut sweep_residual_norm = 0.0_f64;
2740    for frequency_hz in &sweep_frequencies_hz {
2741        let sweep_system = acoustic_helmholtz_operator(
2742            domain,
2743            node_count,
2744            *frequency_hz,
2745            speed_of_sound_m_per_s,
2746            damping_ratio,
2747            &boundary_summary,
2748        );
2749        let (sweep_real, sweep_imag) =
2750            solve_complex_graph_operator(&sweep_system, &source_real, &source_imag);
2751        let sweep_magnitude = sweep_real
2752            .iter()
2753            .zip(sweep_imag.iter())
2754            .map(|(real, imag)| real.hypot(*imag))
2755            .collect::<Vec<_>>();
2756        sweep_peak_pressure_pa =
2757            sweep_peak_pressure_pa.max(sweep_magnitude.iter().copied().fold(0.0_f64, f64::max));
2758        sweep_residual_norm = sweep_residual_norm.max(acoustic_residual_norm(
2759            &sweep_system,
2760            &sweep_real,
2761            &sweep_imag,
2762            &source_real,
2763            &source_imag,
2764        ));
2765        frequency_response_fields.push(AnalysisField::host_f64(
2766            fea_acoustic_frequency_response_field_id(*frequency_hz),
2767            vec![node_count],
2768            sweep_magnitude,
2769        ));
2770    }
2771    let sweep_frequency_min_hz = sweep_frequencies_hz
2772        .iter()
2773        .copied()
2774        .fold(f64::INFINITY, f64::min);
2775    let sweep_frequency_max_hz = sweep_frequencies_hz.iter().copied().fold(0.0_f64, f64::max);
2776    let sweep_bandwidth_hz = if sweep_frequency_min_hz.is_finite() {
2777        (sweep_frequency_max_hz - sweep_frequency_min_hz).max(0.0)
2778    } else {
2779        0.0
2780    };
2781    let known_answer = acoustic_known_answer_metrics(
2782        domain,
2783        &pressure_magnitude,
2784        drive_frequency_hz,
2785        speed_of_sound_m_per_s,
2786    );
2787    let mut fields = vec![
2788        AnalysisField::host_f64(
2789            FEA_FIELD_ACOUSTIC_PRESSURE_REAL,
2790            vec![node_count],
2791            pressure_real,
2792        ),
2793        AnalysisField::host_f64(
2794            FEA_FIELD_ACOUSTIC_PRESSURE_IMAG,
2795            vec![node_count],
2796            pressure_imag,
2797        ),
2798        AnalysisField::host_f64(
2799            FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE,
2800            vec![node_count],
2801            pressure_magnitude,
2802        ),
2803        AnalysisField::host_f64(FEA_FIELD_ACOUSTIC_PHASE, vec![node_count], phase),
2804        AnalysisField::host_f64(
2805            FEA_FIELD_ACOUSTIC_SOUND_PRESSURE_LEVEL_DB,
2806            vec![node_count],
2807            sound_pressure_level_db,
2808        ),
2809        AnalysisField::host_f64(
2810            FEA_FIELD_ACOUSTIC_PARTICLE_VELOCITY,
2811            vec![node_count, 3],
2812            particle_velocity,
2813        ),
2814    ];
2815    let acoustic_field_count = fields.len() + frequency_response_fields.len();
2816    fields.extend(frequency_response_fields);
2817    let severity = if normalized_residual_norm <= residual_warn_threshold {
2818        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2819    } else {
2820        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2821    };
2822    FeaRunResult {
2823        backend,
2824        solver_backend: "cpu_reference".to_string(),
2825        solver_device_apply_k_ratio: 0.0,
2826        solver_method: "acoustic_domain_graph_helmholtz_harmonic".to_string(),
2827        preconditioner: "none".to_string(),
2828        solver_host_sync_count: 0,
2829        diagnostics: vec![
2830            runmat_analysis_fea::diagnostics::FeaDiagnostic {
2831                code: "FEA_ACOUSTIC_HELMHOLTZ_RESIDUAL".to_string(),
2832                severity,
2833                message: format!(
2834                    "normalized_residual_norm={} equation_scale={} residual_warn_threshold={}",
2835                    normalized_residual_norm,
2836                    source_norm(&source_real, &source_imag).max(1.0),
2837                    residual_warn_threshold,
2838                ),
2839            },
2840            runmat_analysis_fea::diagnostics::FeaDiagnostic {
2841                code: "FEA_ACOUSTIC_DOMAIN_ASSEMBLY".to_string(),
2842                severity: if domain.edge_count > 0 && domain.active_dimension_count >= 2 {
2843                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2844                } else {
2845                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2846                },
2847                message: format!(
2848                    "domain_node_count={} domain_edge_count={} domain_active_dimension_count={} domain_dim_x={} domain_dim_y={} domain_dim_z={} domain_spacing_x_m={} domain_spacing_y_m={} domain_spacing_z_m={} boundary_node_count={} average_node_degree={} source_node_count={} domain_volume_m3={}",
2849                    domain.node_count,
2850                    domain.edge_count,
2851                    domain.active_dimension_count,
2852                    domain.dims[0],
2853                    domain.dims[1],
2854                    domain.dims[2],
2855                    domain.spacing[0],
2856                    domain.spacing[1],
2857                    domain.spacing[2],
2858                    domain.boundary_node_count,
2859                    domain.average_node_degree(),
2860                    source_real
2861                        .iter()
2862                        .filter(|value| value.abs() > 1.0e-12)
2863                        .count(),
2864                    domain.volume_m3(),
2865                ),
2866            },
2867            runmat_analysis_fea::diagnostics::FeaDiagnostic {
2868                code: "FEA_ACOUSTIC_HARMONIC_RESPONSE".to_string(),
2869                severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
2870                message: format!(
2871                    "drive_frequency_hz={} speed_of_sound_m_per_s={} density_kg_per_m3={} damping_ratio={} acoustic_node_count={} acoustic_field_count={} peak_pressure_pa={} acoustic_material_count={} acoustic_material_coverage_ratio={}",
2872                    drive_frequency_hz,
2873                    speed_of_sound_m_per_s,
2874                    density_kg_per_m3,
2875                    damping_ratio,
2876                    node_count,
2877                    acoustic_field_count,
2878                    peak_pressure_pa,
2879                    material_summary.explicit_material_count,
2880                    material_summary.coverage_ratio,
2881                ),
2882            },
2883            runmat_analysis_fea::diagnostics::FeaDiagnostic {
2884                code: "FEA_ACOUSTIC_BOUNDARY_MODEL".to_string(),
2885                severity: if boundary_summary.has_acoustic_boundary_data() {
2886                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2887                } else {
2888                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2889                },
2890                message: format!(
2891                    "acoustic_boundary_count={} rigid_wall_count={} radiation_boundary_count={} impedance_boundary_count={} acoustic_boundary_coverage_ratio={} mean_specific_impedance_pa_s_per_m={} radiation_loss_factor={} impedance_loss_factor={}",
2892                    boundary_summary.acoustic_boundary_count,
2893                    boundary_summary.rigid_wall_count,
2894                    boundary_summary.radiation_boundary_count,
2895                    boundary_summary.impedance_boundary_count,
2896                    boundary_summary.coverage_ratio,
2897                    boundary_summary.mean_specific_impedance_pa_s_per_m,
2898                    boundary_summary.radiation_loss_factor,
2899                    boundary_summary.impedance_loss_factor,
2900                ),
2901            },
2902            runmat_analysis_fea::diagnostics::FeaDiagnostic {
2903                code: "FEA_ACOUSTIC_FREQUENCY_RESPONSE".to_string(),
2904                severity: if sweep_residual_norm <= residual_warn_threshold {
2905                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2906                } else {
2907                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2908                },
2909                message: format!(
2910                    "sweep_count={} sweep_frequency_min_hz={} sweep_frequency_max_hz={} sweep_bandwidth_hz={} sweep_peak_pressure_pa={} sweep_max_residual_norm={} response_coverage_ratio={}",
2911                    sweep_frequencies_hz.len(),
2912                    sweep_frequency_min_hz,
2913                    sweep_frequency_max_hz,
2914                    sweep_bandwidth_hz,
2915                    sweep_peak_pressure_pa,
2916                    sweep_residual_norm,
2917                    if sweep_frequencies_hz.is_empty() {
2918                        0.0
2919                    } else {
2920                        1.0
2921                    }
2922                ),
2923            },
2924            runmat_analysis_fea::diagnostics::FeaDiagnostic {
2925                code: "FEA_ACOUSTIC_KNOWN_ANSWER".to_string(),
2926                severity: if known_answer.known_answer_coverage_ratio >= 1.0
2927                    && known_answer.tube_mode_alignment_error_ratio <= 0.5
2928                    && known_answer.tube_pressure_variation_ratio > 1.0e-12
2929                    && known_answer.cavity_mode_spacing_ratio.is_finite()
2930                    && known_answer.cavity_mode_spacing_ratio > 0.0
2931                {
2932                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
2933                } else {
2934                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
2935                },
2936                message: format!(
2937                    "tube_mode_alignment_error_ratio={} tube_pressure_variation_ratio={} cavity_mode_spacing_ratio={} cavity_reference_mode_count={} known_answer_coverage_ratio={}",
2938                    known_answer.tube_mode_alignment_error_ratio,
2939                    known_answer.tube_pressure_variation_ratio,
2940                    known_answer.cavity_mode_spacing_ratio,
2941                    known_answer.cavity_reference_mode_count,
2942                    known_answer.known_answer_coverage_ratio,
2943                ),
2944            },
2945        ],
2946        fields,
2947    }
2948}
2949
2950#[derive(Debug, Clone, Copy)]
2951struct AcousticKnownAnswerMetrics {
2952    tube_mode_alignment_error_ratio: f64,
2953    tube_pressure_variation_ratio: f64,
2954    cavity_mode_spacing_ratio: f64,
2955    cavity_reference_mode_count: usize,
2956    known_answer_coverage_ratio: f64,
2957}
2958
2959fn acoustic_known_answer_metrics(
2960    topology: AcousticDomainTopology,
2961    pressure_magnitude: &[f64],
2962    drive_frequency_hz: f64,
2963    speed_of_sound_m_per_s: f64,
2964) -> AcousticKnownAnswerMetrics {
2965    let tube_length_m = topology.spacing[0] * topology.dims[0].saturating_sub(1).max(1) as f64;
2966    let fundamental_hz = speed_of_sound_m_per_s.max(1.0) / (2.0 * tube_length_m.max(1.0e-9));
2967    let nearest_mode = (drive_frequency_hz / fundamental_hz).round().max(1.0);
2968    let nearest_mode_frequency_hz = nearest_mode * fundamental_hz;
2969    let tube_mode_alignment_error_ratio = (drive_frequency_hz - nearest_mode_frequency_hz).abs()
2970        / drive_frequency_hz.abs().max(fundamental_hz);
2971    let (min_pressure, max_pressure) = pressure_magnitude
2972        .iter()
2973        .copied()
2974        .fold((f64::INFINITY, 0.0_f64), |(min_value, max_value), value| {
2975            (min_value.min(value), max_value.max(value))
2976        });
2977    let tube_pressure_variation_ratio = if min_pressure.is_finite() {
2978        (max_pressure - min_pressure).max(0.0) / max_pressure.max(1.0e-12)
2979    } else {
2980        0.0
2981    };
2982
2983    let lengths = [
2984        topology.spacing[0] * topology.dims[0].saturating_sub(1).max(1) as f64,
2985        topology.spacing[1] * topology.dims[1].saturating_sub(1).max(1) as f64,
2986        topology.spacing[2] * topology.dims[2].saturating_sub(1).max(1) as f64,
2987    ];
2988    let mut cavity_reference_modes = Vec::new();
2989    for nx in 0..=1 {
2990        for ny in 0..=1 {
2991            for nz in 0..=1 {
2992                if nx == 0 && ny == 0 && nz == 0 {
2993                    continue;
2994                }
2995                let mode_sum = (nx as f64 / lengths[0].max(1.0e-9)).powi(2)
2996                    + (ny as f64 / lengths[1].max(1.0e-9)).powi(2)
2997                    + (nz as f64 / lengths[2].max(1.0e-9)).powi(2);
2998                cavity_reference_modes
2999                    .push(0.5 * speed_of_sound_m_per_s.max(1.0) * mode_sum.sqrt());
3000            }
3001        }
3002    }
3003    cavity_reference_modes.sort_by(|a, b| a.total_cmp(b));
3004    let cavity_reference_mode_count = cavity_reference_modes.len();
3005    let cavity_mode_spacing_ratio = cavity_reference_modes
3006        .windows(2)
3007        .map(|window| (window[1] - window[0]).abs())
3008        .filter(|spacing| *spacing > 1.0e-9)
3009        .fold(f64::INFINITY, f64::min)
3010        / fundamental_hz.max(1.0e-12);
3011    let cavity_mode_spacing_ratio = if cavity_mode_spacing_ratio.is_finite() {
3012        cavity_mode_spacing_ratio
3013    } else {
3014        0.0
3015    };
3016
3017    AcousticKnownAnswerMetrics {
3018        tube_mode_alignment_error_ratio,
3019        tube_pressure_variation_ratio,
3020        cavity_mode_spacing_ratio,
3021        cavity_reference_mode_count,
3022        known_answer_coverage_ratio: if cavity_reference_mode_count > 0
3023            && !pressure_magnitude.is_empty()
3024            && drive_frequency_hz.is_finite()
3025        {
3026            1.0
3027        } else {
3028            0.0
3029        },
3030    }
3031}
3032
3033fn acoustic_sweep_frequencies_hz(drive_frequency_hz: f64) -> Vec<f64> {
3034    let mut frequencies = Vec::new();
3035    for scale in [0.75, 1.0, 1.25] {
3036        let frequency = (drive_frequency_hz * scale).clamp(50.0, 20_000.0);
3037        if !frequencies
3038            .iter()
3039            .any(|existing| f64::abs(*existing - frequency) <= 1.0e-9)
3040        {
3041            frequencies.push(frequency);
3042        }
3043    }
3044    frequencies
3045}
3046
3047fn acoustic_node_count(
3048    model: &AnalysisModel,
3049    prep_context: Option<&AnalysisRunPrepContext>,
3050) -> usize {
3051    prep_context
3052        .map(|prep| prep.prepared_node_count.max(3))
3053        .unwrap_or_else(|| model.loads.len().saturating_mul(3).max(3))
3054        .min(512)
3055}
3056
3057#[derive(Debug, Clone, Copy)]
3058struct AcousticMaterialSummary {
3059    density_kg_per_m3: f64,
3060    speed_of_sound_m_per_s: f64,
3061    damping_ratio: f64,
3062    explicit_material_count: usize,
3063    coverage_ratio: f64,
3064}
3065
3066impl AcousticMaterialSummary {
3067    fn characteristic_impedance_pa_s_per_m(self) -> f64 {
3068        (self.density_kg_per_m3 * self.speed_of_sound_m_per_s).max(1.0)
3069    }
3070}
3071
3072fn acoustic_material_summary(model: &AnalysisModel, mode_count: usize) -> AcousticMaterialSummary {
3073    let mut density_sum = 0.0;
3074    let mut speed_sum = 0.0;
3075    let mut damping_sum = 0.0;
3076    let mut explicit_material_count = 0usize;
3077    for material in &model.materials {
3078        let Some(acoustic) = &material.acoustic else {
3079            continue;
3080        };
3081        density_sum += acoustic.density_kg_per_m3.max(1.0e-9);
3082        speed_sum += acoustic.speed_of_sound_m_per_s.max(1.0);
3083        damping_sum += acoustic.damping_ratio.max(0.0);
3084        explicit_material_count += 1;
3085    }
3086    if explicit_material_count == 0 {
3087        let reference_temperature_k = acoustic_reference_temperature_k(model);
3088        let speed_of_sound_m_per_s = 331.3 * (reference_temperature_k / 273.15).sqrt();
3089        let density_kg_per_m3 = 1.225 * (293.15 / reference_temperature_k.max(1.0));
3090        return AcousticMaterialSummary {
3091            density_kg_per_m3,
3092            speed_of_sound_m_per_s,
3093            damping_ratio: 0.02 + 0.002 * mode_count.saturating_sub(1).min(12) as f64,
3094            explicit_material_count,
3095            coverage_ratio: 0.0,
3096        };
3097    }
3098    let inv_count = 1.0 / explicit_material_count as f64;
3099    AcousticMaterialSummary {
3100        density_kg_per_m3: density_sum * inv_count,
3101        speed_of_sound_m_per_s: speed_sum * inv_count,
3102        damping_ratio: damping_sum * inv_count,
3103        explicit_material_count,
3104        coverage_ratio: explicit_material_count as f64 / model.materials.len().max(1) as f64,
3105    }
3106}
3107
3108#[derive(Debug, Clone, Copy)]
3109struct AcousticBoundarySummary {
3110    acoustic_boundary_count: usize,
3111    rigid_wall_count: usize,
3112    radiation_boundary_count: usize,
3113    impedance_boundary_count: usize,
3114    coverage_ratio: f64,
3115    mean_specific_impedance_pa_s_per_m: f64,
3116    radiation_loss_factor: f64,
3117    impedance_loss_factor: f64,
3118}
3119
3120impl AcousticBoundarySummary {
3121    fn has_acoustic_boundary_data(self) -> bool {
3122        self.acoustic_boundary_count > 0
3123    }
3124}
3125
3126fn acoustic_boundary_summary(
3127    model: &AnalysisModel,
3128    characteristic_impedance_pa_s_per_m: f64,
3129) -> AcousticBoundarySummary {
3130    let mut rigid_wall_count = 0usize;
3131    let mut radiation_boundary_count = 0usize;
3132    let mut impedance_boundary_count = 0usize;
3133    let mut impedance_sum = 0.0;
3134    for bc in &model.boundary_conditions {
3135        match bc.kind {
3136            BoundaryConditionKind::AcousticRigidWall => rigid_wall_count += 1,
3137            BoundaryConditionKind::AcousticRadiation => radiation_boundary_count += 1,
3138            BoundaryConditionKind::AcousticImpedance {
3139                specific_impedance_pa_s_per_m,
3140            } => {
3141                impedance_boundary_count += 1;
3142                impedance_sum += specific_impedance_pa_s_per_m.max(1.0);
3143            }
3144            _ => {}
3145        }
3146    }
3147    let acoustic_boundary_count =
3148        rigid_wall_count + radiation_boundary_count + impedance_boundary_count;
3149    let mean_specific_impedance_pa_s_per_m = if impedance_boundary_count == 0 {
3150        characteristic_impedance_pa_s_per_m
3151    } else {
3152        impedance_sum / impedance_boundary_count as f64
3153    };
3154    let impedance_ratio =
3155        characteristic_impedance_pa_s_per_m / mean_specific_impedance_pa_s_per_m.max(1.0);
3156    AcousticBoundarySummary {
3157        acoustic_boundary_count,
3158        rigid_wall_count,
3159        radiation_boundary_count,
3160        impedance_boundary_count,
3161        coverage_ratio: acoustic_boundary_count as f64
3162            / model.boundary_conditions.len().max(1) as f64,
3163        mean_specific_impedance_pa_s_per_m,
3164        radiation_loss_factor: 0.05 * radiation_boundary_count as f64,
3165        impedance_loss_factor: 0.025
3166            * impedance_boundary_count as f64
3167            * impedance_ratio.clamp(0.1, 10.0),
3168    }
3169}
3170
3171fn acoustic_reference_temperature_k(model: &AnalysisModel) -> f64 {
3172    if model.materials.is_empty() {
3173        293.15
3174    } else {
3175        model
3176            .materials
3177            .iter()
3178            .map(|material| material.thermal.reference_temperature_k.max(1.0))
3179            .sum::<f64>()
3180            / model.materials.len() as f64
3181    }
3182}
3183
3184fn acoustic_drive_frequency_hz(mode_count: usize, node_count: usize) -> f64 {
3185    (125.0 * mode_count.max(1) as f64 * (node_count as f64).sqrt()).clamp(50.0, 20_000.0)
3186}
3187
3188#[derive(Debug, Clone, Copy)]
3189struct AcousticDomainTopology {
3190    node_count: usize,
3191    dims: [usize; 3],
3192    spacing: [f64; 3],
3193    edge_count: usize,
3194    boundary_node_count: usize,
3195    active_dimension_count: usize,
3196}
3197
3198impl AcousticDomainTopology {
3199    fn coords(self, index: usize) -> [usize; 3] {
3200        let x_dim = self.dims[0].max(1);
3201        let y_dim = self.dims[1].max(1);
3202        let plane = x_dim.saturating_mul(y_dim).max(1);
3203        let z = index / plane;
3204        let rem = index % plane;
3205        let y = rem / x_dim;
3206        let x = rem % x_dim;
3207        [x, y, z]
3208    }
3209
3210    fn index(self, coords: [usize; 3]) -> Option<usize> {
3211        if coords
3212            .iter()
3213            .zip(self.dims.iter())
3214            .any(|(coord, dim)| *coord >= *dim)
3215        {
3216            return None;
3217        }
3218        let index = coords[0]
3219            + coords[1].saturating_mul(self.dims[0])
3220            + coords[2].saturating_mul(self.dims[0].saturating_mul(self.dims[1]));
3221        (index < self.node_count).then_some(index)
3222    }
3223
3224    fn is_boundary_node(self, index: usize) -> bool {
3225        let coords = self.coords(index);
3226        coords
3227            .iter()
3228            .zip(self.dims.iter())
3229            .any(|(coord, dim)| *dim > 1 && (*coord == 0 || *coord + 1 == *dim))
3230    }
3231
3232    fn average_node_degree(self) -> f64 {
3233        if self.node_count == 0 {
3234            0.0
3235        } else {
3236            2.0 * self.edge_count as f64 / self.node_count as f64
3237        }
3238    }
3239
3240    fn volume_m3(self) -> f64 {
3241        self.spacing
3242            .iter()
3243            .zip(self.dims.iter())
3244            .map(|(spacing, dim)| spacing * dim.saturating_sub(1).max(1) as f64)
3245            .product::<f64>()
3246            .max(1.0e-12)
3247    }
3248}
3249
3250#[derive(Debug, Clone, Copy)]
3251struct AcousticGraphEdge {
3252    left: usize,
3253    right: usize,
3254    stiffness: f64,
3255}
3256
3257#[derive(Debug, Clone)]
3258struct AcousticDomainSystem {
3259    diag_real: Vec<f64>,
3260    diag_imag: Vec<f64>,
3261    edges: Vec<AcousticGraphEdge>,
3262}
3263
3264fn acoustic_domain_topology(
3265    node_count: usize,
3266    prep_context: Option<&AnalysisRunPrepContext>,
3267) -> AcousticDomainTopology {
3268    let n = node_count.max(1);
3269    let volume_hint = prep_context
3270        .map(|prep| {
3271            prep.topology_volume_core_ratio
3272                + prep.topology_tetrahedron_family_ratio
3273                + prep.topology_hex_family_ratio
3274        })
3275        .unwrap_or(0.0);
3276    let z_dim = if n >= 8 && volume_hint > 0.05 {
3277        (n as f64).cbrt().round().max(2.0) as usize
3278    } else if n >= 24 {
3279        2
3280    } else {
3281        1
3282    };
3283    let y_dim = if n >= 3 {
3284        ((n as f64 / z_dim as f64).sqrt().ceil() as usize).max(2)
3285    } else {
3286        1
3287    };
3288    let x_dim = n.div_ceil(y_dim * z_dim).max(1);
3289    let dims = [x_dim, y_dim, z_dim];
3290    let spacing = dims.map(|dim| {
3291        if dim <= 1 {
3292            1.0
3293        } else {
3294            1.0 / dim.saturating_sub(1) as f64
3295        }
3296    });
3297    let mut edge_count = 0usize;
3298    let mut boundary_node_count = 0usize;
3299    let topology = AcousticDomainTopology {
3300        node_count: n,
3301        dims,
3302        spacing,
3303        edge_count: 0,
3304        boundary_node_count: 0,
3305        active_dimension_count: dims.iter().filter(|dim| **dim > 1).count(),
3306    };
3307    for node in 0..n {
3308        if topology.is_boundary_node(node) {
3309            boundary_node_count += 1;
3310        }
3311        let coords = topology.coords(node);
3312        for axis in 0..3 {
3313            if coords[axis] + 1 >= topology.dims[axis] {
3314                continue;
3315            }
3316            let mut next = coords;
3317            next[axis] += 1;
3318            if topology.index(next).is_some() {
3319                edge_count += 1;
3320            }
3321        }
3322    }
3323    AcousticDomainTopology {
3324        edge_count,
3325        boundary_node_count,
3326        ..topology
3327    }
3328}
3329
3330fn acoustic_source_vector(model: &AnalysisModel, node_count: usize) -> Vec<f64> {
3331    let mut source = vec![0.0; node_count.max(1)];
3332    for (index, load) in model.loads.iter().enumerate() {
3333        let node = (index * 3 + load.region_id.len()) % source.len();
3334        let amplitude = match &load.kind {
3335            LoadKind::Pressure { magnitude_pa } => *magnitude_pa,
3336            _ => 0.0,
3337        };
3338        source[node] += amplitude;
3339    }
3340    source
3341}
3342
3343fn acoustic_helmholtz_operator(
3344    topology: AcousticDomainTopology,
3345    node_count: usize,
3346    drive_frequency_hz: f64,
3347    speed_of_sound_m_per_s: f64,
3348    damping_ratio: f64,
3349    boundary_summary: &AcousticBoundarySummary,
3350) -> AcousticDomainSystem {
3351    let n = node_count.max(1);
3352    let omega = 2.0 * std::f64::consts::PI * drive_frequency_hz.max(1.0);
3353    let wave_number = omega / speed_of_sound_m_per_s.max(1.0);
3354    let mut diag_real = vec![0.0; n];
3355    let mut diag_imag = vec![0.0; n];
3356    let mut edges = Vec::with_capacity(topology.edge_count);
3357    for node in 0..n {
3358        let coords = topology.coords(node);
3359        for axis in 0..3 {
3360            if coords[axis] + 1 >= topology.dims[axis] {
3361                continue;
3362            }
3363            let mut next = coords;
3364            next[axis] += 1;
3365            let Some(next_index) = topology.index(next) else {
3366                continue;
3367            };
3368            let stiffness = 1.0 / topology.spacing[axis].max(1.0e-9).powi(2);
3369            diag_real[node] += stiffness;
3370            diag_real[next_index] += stiffness;
3371            edges.push(AcousticGraphEdge {
3372                left: node,
3373                right: next_index,
3374                stiffness,
3375            });
3376        }
3377    }
3378    let mass_term =
3379        (wave_number * topology.volume_m3().cbrt()).powi(2) / topology.node_count.max(1) as f64;
3380    let damping = (2.0 * damping_ratio.max(0.0) * mass_term.max(1.0e-9)).max(1.0e-9);
3381    let boundary_loss = (boundary_summary.radiation_loss_factor
3382        + boundary_summary.impedance_loss_factor)
3383        * wave_number.abs().max(1.0e-9)
3384        / topology.boundary_node_count.max(1) as f64;
3385    for node in 0..n {
3386        diag_real[node] -= mass_term;
3387        diag_imag[node] += damping;
3388        if topology.is_boundary_node(node) {
3389            diag_imag[node] += boundary_loss;
3390            diag_real[node] += 0.02 * boundary_summary.rigid_wall_count as f64;
3391        }
3392    }
3393    AcousticDomainSystem {
3394        diag_real,
3395        diag_imag,
3396        edges,
3397    }
3398}
3399
3400fn solve_complex_graph_operator(
3401    system: &AcousticDomainSystem,
3402    source_real: &[f64],
3403    source_imag: &[f64],
3404) -> (Vec<f64>, Vec<f64>) {
3405    let n = system.diag_real.len().max(1);
3406    let mut matrix_real = vec![vec![0.0; n]; n];
3407    let mut matrix_imag = vec![vec![0.0; n]; n];
3408    for row in 0..n {
3409        matrix_real[row][row] = system.diag_real[row];
3410        matrix_imag[row][row] = system.diag_imag[row];
3411    }
3412    for edge in &system.edges {
3413        matrix_real[edge.left][edge.right] -= edge.stiffness;
3414        matrix_real[edge.right][edge.left] -= edge.stiffness;
3415    }
3416    let mut rhs_real = (0..n)
3417        .map(|index| source_real.get(index).copied().unwrap_or(0.0))
3418        .collect::<Vec<_>>();
3419    let mut rhs_imag = (0..n)
3420        .map(|index| source_imag.get(index).copied().unwrap_or(0.0))
3421        .collect::<Vec<_>>();
3422    solve_dense_complex_system(
3423        &mut matrix_real,
3424        &mut matrix_imag,
3425        &mut rhs_real,
3426        &mut rhs_imag,
3427    )
3428}
3429
3430fn acoustic_residual_norm(
3431    system: &AcousticDomainSystem,
3432    pressure_real: &[f64],
3433    pressure_imag: &[f64],
3434    source_real: &[f64],
3435    source_imag: &[f64],
3436) -> f64 {
3437    let mut residual_sq = 0.0_f64;
3438    for i in 0..system.diag_real.len() {
3439        let mut applied_real =
3440            system.diag_real[i] * pressure_real[i] - system.diag_imag[i] * pressure_imag[i];
3441        let mut applied_imag =
3442            system.diag_real[i] * pressure_imag[i] + system.diag_imag[i] * pressure_real[i];
3443        for edge in system
3444            .edges
3445            .iter()
3446            .filter(|edge| edge.left == i || edge.right == i)
3447        {
3448            let neighbor = if edge.left == i {
3449                edge.right
3450            } else {
3451                edge.left
3452            };
3453            applied_real -= edge.stiffness * pressure_real[neighbor];
3454            applied_imag -= edge.stiffness * pressure_imag[neighbor];
3455        }
3456        let real = applied_real - source_real.get(i).copied().unwrap_or(0.0);
3457        let imag = applied_imag - source_imag.get(i).copied().unwrap_or(0.0);
3458        residual_sq += real * real + imag * imag;
3459    }
3460    residual_sq.sqrt() / source_norm(source_real, source_imag).max(1.0)
3461}
3462
3463fn source_norm(source_real: &[f64], source_imag: &[f64]) -> f64 {
3464    source_real
3465        .iter()
3466        .zip(source_imag.iter().chain(std::iter::repeat(&0.0)))
3467        .map(|(real, imag)| real * real + imag * imag)
3468        .sum::<f64>()
3469        .sqrt()
3470}
3471
3472fn solve_dense_complex_system(
3473    matrix_real: &mut [Vec<f64>],
3474    matrix_imag: &mut [Vec<f64>],
3475    rhs_real: &mut [f64],
3476    rhs_imag: &mut [f64],
3477) -> (Vec<f64>, Vec<f64>) {
3478    let n = rhs_real.len();
3479    for pivot in 0..n {
3480        let mut pivot_row = pivot;
3481        let mut pivot_norm = complex_abs_sq(matrix_real[pivot][pivot], matrix_imag[pivot][pivot]);
3482        for candidate in pivot + 1..n {
3483            let candidate_norm =
3484                complex_abs_sq(matrix_real[candidate][pivot], matrix_imag[candidate][pivot]);
3485            if candidate_norm > pivot_norm {
3486                pivot_row = candidate;
3487                pivot_norm = candidate_norm;
3488            }
3489        }
3490        if pivot_row != pivot {
3491            matrix_real.swap(pivot, pivot_row);
3492            matrix_imag.swap(pivot, pivot_row);
3493            rhs_real.swap(pivot, pivot_row);
3494            rhs_imag.swap(pivot, pivot_row);
3495        }
3496        if pivot_norm <= 1.0e-24 {
3497            matrix_real[pivot][pivot] += 1.0e-9;
3498        }
3499        let (pivot_inv_real, pivot_inv_imag) =
3500            complex_recip(matrix_real[pivot][pivot], matrix_imag[pivot][pivot]);
3501        for row in pivot + 1..n {
3502            let (factor_real, factor_imag) = complex_mul(
3503                matrix_real[row][pivot],
3504                matrix_imag[row][pivot],
3505                pivot_inv_real,
3506                pivot_inv_imag,
3507            );
3508            matrix_real[row][pivot] = 0.0;
3509            matrix_imag[row][pivot] = 0.0;
3510            for col in pivot + 1..n {
3511                let (update_real, update_imag) = complex_mul(
3512                    factor_real,
3513                    factor_imag,
3514                    matrix_real[pivot][col],
3515                    matrix_imag[pivot][col],
3516                );
3517                matrix_real[row][col] -= update_real;
3518                matrix_imag[row][col] -= update_imag;
3519            }
3520            let (rhs_update_real, rhs_update_imag) =
3521                complex_mul(factor_real, factor_imag, rhs_real[pivot], rhs_imag[pivot]);
3522            rhs_real[row] -= rhs_update_real;
3523            rhs_imag[row] -= rhs_update_imag;
3524        }
3525    }
3526
3527    let mut solution_real = vec![0.0; n];
3528    let mut solution_imag = vec![0.0; n];
3529    for row in (0..n).rev() {
3530        let mut accum_real = rhs_real[row];
3531        let mut accum_imag = rhs_imag[row];
3532        for col in row + 1..n {
3533            let (update_real, update_imag) = complex_mul(
3534                matrix_real[row][col],
3535                matrix_imag[row][col],
3536                solution_real[col],
3537                solution_imag[col],
3538            );
3539            accum_real -= update_real;
3540            accum_imag -= update_imag;
3541        }
3542        let (inv_real, inv_imag) = complex_recip(matrix_real[row][row], matrix_imag[row][row]);
3543        (solution_real[row], solution_imag[row]) =
3544            complex_mul(accum_real, accum_imag, inv_real, inv_imag);
3545    }
3546    (solution_real, solution_imag)
3547}
3548
3549fn complex_abs_sq(real: f64, imag: f64) -> f64 {
3550    real * real + imag * imag
3551}
3552
3553fn complex_recip(real: f64, imag: f64) -> (f64, f64) {
3554    let denom = (real * real + imag * imag).max(1.0e-24);
3555    (real / denom, -imag / denom)
3556}
3557
3558fn complex_mul(a_real: f64, a_imag: f64, b_real: f64, b_imag: f64) -> (f64, f64) {
3559    (
3560        a_real * b_real - a_imag * b_imag,
3561        a_real * b_imag + a_imag * b_real,
3562    )
3563}
3564
3565fn recover_acoustic_particle_velocity(
3566    pressure_real: &[f64],
3567    topology: AcousticDomainTopology,
3568    drive_frequency_hz: f64,
3569    density_kg_per_m3: f64,
3570) -> Vec<f64> {
3571    let node_count = pressure_real.len().max(1);
3572    let omega = (2.0 * std::f64::consts::PI * drive_frequency_hz).max(1.0e-12);
3573    let impedance_scale = (density_kg_per_m3.max(1.0e-12) * omega).max(1.0e-12);
3574    let mut velocity = vec![0.0; node_count * 3];
3575    for node in 0..pressure_real.len() {
3576        for axis in 0..3 {
3577            velocity[node * 3 + axis] =
3578                -acoustic_axis_derivative(pressure_real, topology, node, axis) / impedance_scale;
3579        }
3580    }
3581    velocity
3582}
3583
3584fn acoustic_axis_derivative(
3585    pressure: &[f64],
3586    topology: AcousticDomainTopology,
3587    index: usize,
3588    axis: usize,
3589) -> f64 {
3590    if topology.dims[axis] <= 1 {
3591        return 0.0;
3592    }
3593    let coords = topology.coords(index);
3594    let mut prev_coords = coords;
3595    let prev_index = if coords[axis] > 0 {
3596        prev_coords[axis] -= 1;
3597        topology.index(prev_coords)
3598    } else {
3599        None
3600    };
3601    let mut next_coords = coords;
3602    let next_index = if coords[axis] + 1 < topology.dims[axis] {
3603        next_coords[axis] += 1;
3604        topology.index(next_coords)
3605    } else {
3606        None
3607    };
3608    let spacing = topology.spacing[axis].max(1.0e-12);
3609    match (prev_index, next_index) {
3610        (Some(prev), Some(next)) => (pressure[next] - pressure[prev]) / (2.0 * spacing),
3611        (Some(prev), None) => (pressure[index] - pressure[prev]) / spacing,
3612        (None, Some(next)) => (pressure[next] - pressure[index]) / spacing,
3613        (None, None) => 0.0,
3614    }
3615}
3616
3617fn cfd_reynolds_number(domain: &runmat_analysis_core::CfdDomain) -> f64 {
3618    cfd_reynolds_number_for_velocity(domain, domain.inlet_velocity_m_per_s)
3619}
3620
3621fn cfd_reynolds_number_for_velocity(
3622    domain: &runmat_analysis_core::CfdDomain,
3623    inlet_velocity_m_per_s: f64,
3624) -> f64 {
3625    domain.reference_density_kg_per_m3 * inlet_velocity_m_per_s.abs()
3626        / domain.dynamic_viscosity_pa_s
3627}
3628
3629fn cfd_profile_scale(domain: &runmat_analysis_core::CfdDomain, step_index: usize) -> f64 {
3630    domain
3631        .time_profile
3632        .get(step_index)
3633        .map(|point| point.inlet_scale)
3634        .filter(|scale| scale.is_finite() && *scale >= 0.0)
3635        .unwrap_or(1.0)
3636}
3637
3638fn cfd_node_count_from_model(
3639    model: &AnalysisModel,
3640    prep_context: Option<&AnalysisRunPrepContext>,
3641) -> usize {
3642    prep_context
3643        .map(|prep| prep.prepared_node_count.max(3))
3644        .unwrap_or_else(|| model.loads.len().saturating_mul(3).max(3))
3645        .min(512)
3646}
3647
3648#[derive(Clone, Debug)]
3649struct CfdDomainTopology {
3650    basis: CfdDomainTopologyBasis,
3651    geometry_source: CfdDomainGeometrySource,
3652    node_count: usize,
3653    control_volume_count: usize,
3654    control_volume_face_count: usize,
3655    control_volume_internal_face_count: usize,
3656    control_volume_boundary_face_count: usize,
3657    control_volume_connectivity_coverage_ratio: f64,
3658    domain_length_m: f64,
3659    hydraulic_diameter_m: f64,
3660    face_area_m2: f64,
3661    dx_m: f64,
3662    active_dimension_count: usize,
3663    element_geometry_node_count: usize,
3664    element_geometry_edge_count: usize,
3665    element_geometry_coverage_ratio: f64,
3666    element_topology_sample_element_count: usize,
3667    element_topology_sample_edge_count: usize,
3668    element_topology_sample_element_edges: [[u32; 3]; 4],
3669    element_topology_edge_nodes: Vec<[u32; 2]>,
3670    element_topology_element_edges: Vec<[u32; 3]>,
3671}
3672
3673impl CfdDomainTopology {
3674    fn from_model(model: &AnalysisModel, prep_context: Option<&AnalysisRunPrepContext>) -> Self {
3675        let node_count = cfd_node_count_from_model(model, prep_context);
3676        match prep_context {
3677            Some(prep) => Self::from_prep(node_count, prep),
3678            None => Self::implicit_channel(node_count),
3679        }
3680    }
3681
3682    fn implicit_channel(node_count: usize) -> Self {
3683        let node_count = node_count.max(2);
3684        let control_volume_count = node_count.saturating_sub(1).max(1);
3685        Self {
3686            basis: CfdDomainTopologyBasis::ImplicitChannel,
3687            geometry_source: CfdDomainGeometrySource::ImplicitChannel,
3688            node_count,
3689            control_volume_count,
3690            control_volume_face_count: control_volume_count.saturating_add(1),
3691            control_volume_internal_face_count: control_volume_count.saturating_sub(1),
3692            control_volume_boundary_face_count: 2,
3693            control_volume_connectivity_coverage_ratio: 0.0,
3694            domain_length_m: 1.0,
3695            hydraulic_diameter_m: 1.0,
3696            face_area_m2: 1.0,
3697            dx_m: 1.0 / control_volume_count as f64,
3698            active_dimension_count: 1,
3699            element_geometry_node_count: 0,
3700            element_geometry_edge_count: 0,
3701            element_geometry_coverage_ratio: 0.0,
3702            element_topology_sample_element_count: 0,
3703            element_topology_sample_edge_count: 0,
3704            element_topology_sample_element_edges: [[0; 3]; 4],
3705            element_topology_edge_nodes: Vec::new(),
3706            element_topology_element_edges: Vec::new(),
3707        }
3708    }
3709
3710    fn from_prep(node_count: usize, prep: &AnalysisRunPrepContext) -> Self {
3711        let has_control_volume_connectivity = prep.control_volume_cell_count > 0
3712            && prep.control_volume_face_count > 0
3713            && prep.control_volume_connectivity_coverage_ratio > 0.0;
3714        let control_volume_count = if has_control_volume_connectivity {
3715            prep.control_volume_cell_count.max(1)
3716        } else {
3717            node_count.max(2).saturating_sub(1).max(1)
3718        };
3719        let node_count = if has_control_volume_connectivity {
3720            control_volume_count.saturating_add(1).max(2)
3721        } else {
3722            node_count.max(2)
3723        };
3724        let fallback_length = finite_positive_or(prep.coordinate_characteristic_length_m, 1.0)
3725            * control_volume_count as f64;
3726        let domain_length_m = finite_positive_or(prep.coordinate_span_x_m, fallback_length);
3727        let transverse_y = finite_positive_or(prep.coordinate_span_y_m, 0.0);
3728        let transverse_z = finite_positive_or(prep.coordinate_span_z_m, 0.0);
3729        let hydraulic_diameter_m = if transverse_y > 0.0 && transverse_z > 0.0 {
3730            (2.0 * transverse_y * transverse_z / (transverse_y + transverse_z)).max(1.0e-12)
3731        } else {
3732            finite_positive_or(prep.coordinate_characteristic_length_m, 1.0)
3733        };
3734        let coordinate_face_area_m2 = hydraulic_diameter_m.max(1.0e-12).powi(2);
3735        let has_element_geometry = prep.element_geometry_coverage_ratio > 0.0
3736            && prep.mean_element_area_m2.is_finite()
3737            && prep.mean_element_area_m2 > 0.0;
3738        let face_area_m2 = if has_element_geometry {
3739            prep.mean_element_area_m2
3740        } else {
3741            coordinate_face_area_m2
3742        };
3743        let hydraulic_diameter_m = if has_element_geometry {
3744            (4.0 * face_area_m2 / std::f64::consts::PI)
3745                .sqrt()
3746                .max(1.0e-12)
3747        } else {
3748            hydraulic_diameter_m
3749        };
3750        Self {
3751            basis: CfdDomainTopologyBasis::PrepControlVolumeConnectivity,
3752            geometry_source: if has_element_geometry {
3753                CfdDomainGeometrySource::PrepElementGeometry
3754            } else {
3755                CfdDomainGeometrySource::CoordinateSpan
3756            },
3757            node_count,
3758            control_volume_count,
3759            control_volume_face_count: if has_control_volume_connectivity {
3760                prep.control_volume_face_count
3761            } else {
3762                control_volume_count.saturating_add(1)
3763            },
3764            control_volume_internal_face_count: if has_control_volume_connectivity {
3765                prep.control_volume_internal_face_count
3766            } else {
3767                control_volume_count.saturating_sub(1)
3768            },
3769            control_volume_boundary_face_count: if has_control_volume_connectivity {
3770                prep.control_volume_boundary_face_count
3771            } else {
3772                2
3773            },
3774            control_volume_connectivity_coverage_ratio: prep
3775                .control_volume_connectivity_coverage_ratio
3776                .clamp(0.0, 1.0),
3777            domain_length_m,
3778            hydraulic_diameter_m,
3779            face_area_m2,
3780            dx_m: domain_length_m / control_volume_count as f64,
3781            active_dimension_count: prep.coordinate_active_dimension_count.max(1),
3782            element_geometry_node_count: prep.element_geometry_node_count,
3783            element_geometry_edge_count: prep.element_geometry_edge_count,
3784            element_geometry_coverage_ratio: prep.element_geometry_coverage_ratio.clamp(0.0, 1.0),
3785            element_topology_sample_element_count: prep.element_topology_sample_element_count,
3786            element_topology_sample_edge_count: prep.element_topology_sample_edge_count,
3787            element_topology_sample_element_edges: prep.element_topology_sample_element_edges,
3788            element_topology_edge_nodes: prep.element_topology_edge_nodes.clone(),
3789            element_topology_element_edges: prep.element_topology_element_edges.clone(),
3790        }
3791    }
3792
3793    fn face_area_m2(&self) -> f64 {
3794        self.face_area_m2.max(1.0e-12)
3795    }
3796
3797    fn control_volume_volume_m3(&self) -> f64 {
3798        self.face_area_m2() * self.dx_m.max(1.0e-12)
3799    }
3800}
3801
3802#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3803enum CfdDomainTopologyBasis {
3804    PrepControlVolumeConnectivity,
3805    ImplicitChannel,
3806}
3807
3808impl CfdDomainTopologyBasis {
3809    fn as_str(self) -> &'static str {
3810        match self {
3811            Self::PrepControlVolumeConnectivity => "prep_control_volume_connectivity",
3812            Self::ImplicitChannel => "implicit_channel",
3813        }
3814    }
3815}
3816
3817#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3818enum CfdDomainGeometrySource {
3819    ImplicitChannel,
3820    CoordinateSpan,
3821    PrepElementGeometry,
3822}
3823
3824impl CfdDomainGeometrySource {
3825    fn as_str(self) -> &'static str {
3826        match self {
3827            Self::ImplicitChannel => "implicit_channel",
3828            Self::CoordinateSpan => "coordinate_span",
3829            Self::PrepElementGeometry => "prep_element_geometry",
3830        }
3831    }
3832}
3833
3834fn finite_positive_or(value: f64, fallback: f64) -> f64 {
3835    if value.is_finite() && value > 0.0 {
3836        value
3837    } else {
3838        fallback
3839    }
3840}
3841
3842#[derive(Clone, Debug)]
3843struct CfdBoundarySummary {
3844    inlet_boundary_count: usize,
3845    outlet_boundary_count: usize,
3846    no_slip_wall_boundary_count: usize,
3847    slip_wall_boundary_count: usize,
3848    symmetry_boundary_count: usize,
3849    authored_boundary_count: usize,
3850    boundary_coverage_ratio: f64,
3851    wall_boundary_coverage_ratio: f64,
3852    nominal_inlet_velocity_m_per_s: f64,
3853    outlet_pressure_pa: f64,
3854}
3855
3856impl CfdBoundarySummary {
3857    fn implicit_channel(domain: &runmat_analysis_core::CfdDomain, node_count: usize) -> Self {
3858        Self {
3859            inlet_boundary_count: 1,
3860            outlet_boundary_count: 1,
3861            no_slip_wall_boundary_count: node_count.saturating_sub(1).max(1),
3862            slip_wall_boundary_count: 0,
3863            symmetry_boundary_count: 0,
3864            authored_boundary_count: 0,
3865            boundary_coverage_ratio: 1.0,
3866            wall_boundary_coverage_ratio: 1.0,
3867            nominal_inlet_velocity_m_per_s: domain.inlet_velocity_m_per_s,
3868            outlet_pressure_pa: 0.0,
3869        }
3870    }
3871
3872    fn from_model(
3873        model: &AnalysisModel,
3874        domain: &runmat_analysis_core::CfdDomain,
3875        node_count: usize,
3876    ) -> Self {
3877        let mut inlet_velocity_sum = 0.0_f64;
3878        let mut outlet_pressure_sum = 0.0_f64;
3879        let mut summary = Self {
3880            inlet_boundary_count: 0,
3881            outlet_boundary_count: 0,
3882            no_slip_wall_boundary_count: 0,
3883            slip_wall_boundary_count: 0,
3884            symmetry_boundary_count: 0,
3885            authored_boundary_count: 0,
3886            boundary_coverage_ratio: 0.0,
3887            wall_boundary_coverage_ratio: 0.0,
3888            nominal_inlet_velocity_m_per_s: domain.inlet_velocity_m_per_s,
3889            outlet_pressure_pa: 0.0,
3890        };
3891
3892        for boundary in &model.boundary_conditions {
3893            match &boundary.kind {
3894                BoundaryConditionKind::CfdInletVelocity { velocity_m_per_s } => {
3895                    summary.inlet_boundary_count += 1;
3896                    summary.authored_boundary_count += 1;
3897                    inlet_velocity_sum += *velocity_m_per_s;
3898                }
3899                BoundaryConditionKind::CfdOutletPressure { pressure_pa } => {
3900                    summary.outlet_boundary_count += 1;
3901                    summary.authored_boundary_count += 1;
3902                    outlet_pressure_sum += *pressure_pa;
3903                }
3904                BoundaryConditionKind::CfdNoSlipWall => {
3905                    summary.no_slip_wall_boundary_count += 1;
3906                    summary.authored_boundary_count += 1;
3907                }
3908                BoundaryConditionKind::CfdSlipWall => {
3909                    summary.slip_wall_boundary_count += 1;
3910                    summary.authored_boundary_count += 1;
3911                }
3912                BoundaryConditionKind::CfdSymmetry => {
3913                    summary.symmetry_boundary_count += 1;
3914                    summary.authored_boundary_count += 1;
3915                }
3916                _ => {}
3917            }
3918        }
3919
3920        if summary.authored_boundary_count == 0 {
3921            return Self::implicit_channel(domain, node_count);
3922        }
3923
3924        if summary.inlet_boundary_count > 0 {
3925            summary.nominal_inlet_velocity_m_per_s =
3926                inlet_velocity_sum / summary.inlet_boundary_count as f64;
3927        }
3928        if summary.outlet_boundary_count > 0 {
3929            summary.outlet_pressure_pa = outlet_pressure_sum / summary.outlet_boundary_count as f64;
3930        }
3931
3932        let wall_like_count = summary.no_slip_wall_boundary_count
3933            + summary.slip_wall_boundary_count
3934            + summary.symmetry_boundary_count;
3935        let required_groups_present = usize::from(summary.inlet_boundary_count > 0)
3936            + usize::from(summary.outlet_boundary_count > 0)
3937            + usize::from(wall_like_count > 0);
3938        summary.boundary_coverage_ratio = required_groups_present as f64 / 3.0;
3939        summary.wall_boundary_coverage_ratio = if wall_like_count > 0 { 1.0 } else { 0.0 };
3940        summary
3941    }
3942
3943    fn wall_boundary_count(&self) -> usize {
3944        self.no_slip_wall_boundary_count + self.slip_wall_boundary_count
3945    }
3946}
3947
3948fn validate_authored_cfd_boundary_conditions(model: &AnalysisModel) -> Result<(), String> {
3949    let mut inlet_boundary_count = 0usize;
3950    let mut outlet_boundary_count = 0usize;
3951    let mut wall_like_boundary_count = 0usize;
3952    let mut authored_boundary_count = 0usize;
3953
3954    for boundary in &model.boundary_conditions {
3955        match &boundary.kind {
3956            BoundaryConditionKind::CfdInletVelocity { velocity_m_per_s } => {
3957                authored_boundary_count += 1;
3958                inlet_boundary_count += 1;
3959                if !velocity_m_per_s.is_finite() || *velocity_m_per_s < 0.0 {
3960                    return Err(format!(
3961                        "cfd inlet boundary {} requires finite non-negative velocity_m_per_s",
3962                        boundary.bc_id
3963                    ));
3964                }
3965            }
3966            BoundaryConditionKind::CfdOutletPressure { pressure_pa } => {
3967                authored_boundary_count += 1;
3968                outlet_boundary_count += 1;
3969                if !pressure_pa.is_finite() {
3970                    return Err(format!(
3971                        "cfd outlet boundary {} requires finite pressure_pa",
3972                        boundary.bc_id
3973                    ));
3974                }
3975            }
3976            BoundaryConditionKind::CfdNoSlipWall
3977            | BoundaryConditionKind::CfdSlipWall
3978            | BoundaryConditionKind::CfdSymmetry => {
3979                authored_boundary_count += 1;
3980                wall_like_boundary_count += 1;
3981            }
3982            _ => {}
3983        }
3984    }
3985
3986    if authored_boundary_count == 0 {
3987        return Ok(());
3988    }
3989    if inlet_boundary_count == 0 || outlet_boundary_count == 0 || wall_like_boundary_count == 0 {
3990        return Err(format!(
3991            "authored cfd boundaries require at least one inlet, outlet, and wall/symmetry boundary; got inlet={} outlet={} wall_like={}",
3992            inlet_boundary_count, outlet_boundary_count, wall_like_boundary_count,
3993        ));
3994    }
3995    Ok(())
3996}
3997
3998#[derive(Clone, Debug)]
3999struct CfdVelocityPressureSolution {
4000    topology: CfdDomainTopology,
4001    velocity: Vec<f64>,
4002    pressure: Vec<f64>,
4003    residual_momentum: Vec<f64>,
4004    residual_continuity: Vec<f64>,
4005    mass_balance_residual: f64,
4006    pressure_drop_pa: f64,
4007    control_volume_count: usize,
4008    inlet_boundary_count: usize,
4009    outlet_boundary_count: usize,
4010    wall_boundary_count: usize,
4011    no_slip_wall_boundary_count: usize,
4012    slip_wall_boundary_count: usize,
4013    symmetry_boundary_count: usize,
4014    authored_boundary_count: usize,
4015    boundary_coverage_ratio: f64,
4016    wall_boundary_coverage_ratio: f64,
4017    inlet_velocity_realization_ratio: f64,
4018    nominal_inlet_velocity_m_per_s: f64,
4019    outlet_pressure_pa: f64,
4020    pressure_correction_iteration_count: usize,
4021    pressure_correction_residual_ratio: f64,
4022    velocity_correction_residual_ratio: f64,
4023    transient_scale_min: f64,
4024    transient_scale_max: f64,
4025    transient_scale_variation: f64,
4026}
4027
4028#[derive(Clone, Debug)]
4029struct CfdKnownAnswerMetrics {
4030    pressure_drop_balance_ratio: f64,
4031    mass_flux_uniformity_ratio: f64,
4032    pressure_monotonic_cell_fraction: f64,
4033    known_answer_coverage_ratio: f64,
4034}
4035
4036fn recover_cfd_velocity_pressure(
4037    domain: &runmat_analysis_core::CfdDomain,
4038    topology: &CfdDomainTopology,
4039    step_index: usize,
4040) -> (Vec<f64>, Vec<f64>) {
4041    let boundary_summary = CfdBoundarySummary::implicit_channel(domain, topology.node_count);
4042    let solution = solve_cfd_velocity_pressure(
4043        domain,
4044        &boundary_summary,
4045        topology,
4046        step_index,
4047        1,
4048        32,
4049        1.0e-8,
4050    );
4051    (solution.velocity, solution.pressure)
4052}
4053
4054fn solve_cfd_velocity_pressure(
4055    domain: &runmat_analysis_core::CfdDomain,
4056    boundary_summary: &CfdBoundarySummary,
4057    topology: &CfdDomainTopology,
4058    step_index: usize,
4059    step_count: usize,
4060    max_linear_iters: usize,
4061    tolerance: f64,
4062) -> CfdVelocityPressureSolution {
4063    let node_count = topology.node_count.max(2);
4064    let profile_scale = cfd_profile_scale(domain, step_index);
4065    let nominal_inlet_velocity = boundary_summary.nominal_inlet_velocity_m_per_s;
4066    let inlet_velocity = nominal_inlet_velocity * profile_scale;
4067    let reynolds = cfd_reynolds_number_for_velocity(domain, nominal_inlet_velocity).max(1.0);
4068    let hydraulic_diameter_m = topology.hydraulic_diameter_m;
4069    let friction_factor = if reynolds <= 2300.0 {
4070        64.0 / reynolds
4071    } else {
4072        0.3164 / reynolds.powf(0.25)
4073    };
4074    let friction_gradient_pa_per_m = 0.5
4075        * domain.reference_density_kg_per_m3
4076        * inlet_velocity
4077        * inlet_velocity.abs()
4078        * friction_factor
4079        / hydraulic_diameter_m;
4080    let pressure_drop_pa = (friction_gradient_pa_per_m * topology.domain_length_m).max(0.0);
4081    let denom = node_count.saturating_sub(1).max(1) as f64;
4082    let mut axial_velocity = vec![inlet_velocity; node_count];
4083    let mut pressure = (0..node_count)
4084        .map(|node| {
4085            let xi = node as f64 / denom;
4086            boundary_summary.outlet_pressure_pa + 0.5 * pressure_drop_pa * (1.0 - xi)
4087        })
4088        .collect::<Vec<_>>();
4089    let target_pressure = (0..node_count)
4090        .map(|node| {
4091            let xi = node as f64 / denom;
4092            boundary_summary.outlet_pressure_pa + pressure_drop_pa * (1.0 - xi)
4093        })
4094        .collect::<Vec<_>>();
4095    let correction_iters = max_linear_iters.max(1);
4096    let correction_tolerance = tolerance.max(1.0e-12);
4097    let mut pressure_correction_residual_ratio = f64::INFINITY;
4098    let mut velocity_correction_residual_ratio = f64::INFINITY;
4099    let mut pressure_correction_iteration_count = 0usize;
4100    for iteration in 0..correction_iters {
4101        let previous_pressure = pressure.clone();
4102        let previous_velocity = axial_velocity.clone();
4103        for node in 0..node_count {
4104            pressure[node] = 0.35 * pressure[node] + 0.65 * target_pressure[node];
4105        }
4106        for node in 0..node_count {
4107            if node == 0 {
4108                axial_velocity[node] = inlet_velocity;
4109                continue;
4110            }
4111            if node + 1 == node_count {
4112                axial_velocity[node] = axial_velocity[node.saturating_sub(1)];
4113                continue;
4114            }
4115            let gradient = (pressure[node + 1] - pressure[node - 1]) / (2.0 * topology.dx_m);
4116            let pressure_driven_speed = ((-2.0 * gradient * hydraulic_diameter_m)
4117                / (domain.reference_density_kg_per_m3.max(1.0e-12) * friction_factor.max(1.0e-12)))
4118            .max(0.0)
4119            .sqrt();
4120            axial_velocity[node] = 0.50 * axial_velocity[node] + 0.50 * pressure_driven_speed;
4121        }
4122        axial_velocity[node_count - 1] = axial_velocity[node_count - 2];
4123
4124        let pressure_correction_norm = pressure
4125            .iter()
4126            .zip(previous_pressure.iter())
4127            .map(|(current, previous)| (current - previous) * (current - previous))
4128            .sum::<f64>()
4129            .sqrt();
4130        let pressure_scale = target_pressure
4131            .iter()
4132            .map(|value| value * value)
4133            .sum::<f64>()
4134            .sqrt()
4135            .max(1.0);
4136        pressure_correction_residual_ratio = pressure_correction_norm / pressure_scale;
4137        let velocity_correction_norm = axial_velocity
4138            .iter()
4139            .zip(previous_velocity.iter())
4140            .map(|(current, previous)| (current - previous) * (current - previous))
4141            .sum::<f64>()
4142            .sqrt();
4143        let velocity_scale = axial_velocity
4144            .iter()
4145            .map(|value| value * value)
4146            .sum::<f64>()
4147            .sqrt()
4148            .max(inlet_velocity.abs())
4149            .max(1.0e-12);
4150        velocity_correction_residual_ratio = velocity_correction_norm / velocity_scale;
4151        pressure_correction_iteration_count = iteration + 1;
4152        if pressure_correction_residual_ratio <= correction_tolerance
4153            && velocity_correction_residual_ratio <= correction_tolerance
4154        {
4155            break;
4156        }
4157    }
4158    pressure = target_pressure;
4159
4160    let mut velocity = Vec::with_capacity(node_count * 3);
4161    for (node, axial) in axial_velocity.iter().copied().enumerate() {
4162        let xi = node as f64 / denom;
4163        let recirculation = (2.0 * std::f64::consts::PI * xi).sin()
4164            * axial
4165            * domain.turbulence_intensity.clamp(0.0, 1.0)
4166            * 0.02;
4167        velocity.extend_from_slice(&[axial, recirculation, 0.0]);
4168    }
4169
4170    let (residual_momentum, residual_continuity) =
4171        cfd_residual_norms(&velocity, &pressure, domain, topology, step_count);
4172    let mass_balance_residual = residual_continuity.iter().copied().fold(0.0_f64, f64::max);
4173    let inlet_velocity_realization_ratio =
4174        inlet_velocity.abs() / nominal_inlet_velocity.abs().max(1.0e-12);
4175    let (transient_scale_min, transient_scale_max) = cfd_transient_scale_bounds(domain);
4176
4177    CfdVelocityPressureSolution {
4178        topology: topology.clone(),
4179        velocity,
4180        pressure,
4181        residual_momentum,
4182        residual_continuity,
4183        mass_balance_residual,
4184        pressure_drop_pa,
4185        control_volume_count: topology.control_volume_count,
4186        inlet_boundary_count: boundary_summary.inlet_boundary_count,
4187        outlet_boundary_count: boundary_summary.outlet_boundary_count,
4188        wall_boundary_count: boundary_summary.wall_boundary_count(),
4189        no_slip_wall_boundary_count: boundary_summary.no_slip_wall_boundary_count,
4190        slip_wall_boundary_count: boundary_summary.slip_wall_boundary_count,
4191        symmetry_boundary_count: boundary_summary.symmetry_boundary_count,
4192        authored_boundary_count: boundary_summary.authored_boundary_count,
4193        boundary_coverage_ratio: boundary_summary.boundary_coverage_ratio,
4194        wall_boundary_coverage_ratio: boundary_summary.wall_boundary_coverage_ratio,
4195        inlet_velocity_realization_ratio,
4196        nominal_inlet_velocity_m_per_s: nominal_inlet_velocity,
4197        outlet_pressure_pa: boundary_summary.outlet_pressure_pa,
4198        pressure_correction_iteration_count,
4199        pressure_correction_residual_ratio,
4200        velocity_correction_residual_ratio,
4201        transient_scale_min,
4202        transient_scale_max,
4203        transient_scale_variation: (transient_scale_max - transient_scale_min).abs(),
4204    }
4205}
4206
4207fn cfd_transient_scale_bounds(domain: &runmat_analysis_core::CfdDomain) -> (f64, f64) {
4208    if domain.time_profile.is_empty() {
4209        return (1.0, 1.0);
4210    }
4211    let (min, max) = domain
4212        .time_profile
4213        .iter()
4214        .filter_map(|point| point.inlet_scale.is_finite().then_some(point.inlet_scale))
4215        .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), scale| {
4216            (min.min(scale), max.max(scale))
4217        });
4218    if min.is_finite() && max.is_finite() {
4219        (min, max)
4220    } else {
4221        (1.0, 1.0)
4222    }
4223}
4224
4225fn cfd_known_answer_metrics(solution: &CfdVelocityPressureSolution) -> CfdKnownAnswerMetrics {
4226    let node_count = solution.pressure.len();
4227    let pressure_drop_observed = match (solution.pressure.first(), solution.pressure.last()) {
4228        (Some(first), Some(last)) => first - last,
4229        _ => 0.0,
4230    };
4231    let pressure_drop_balance_ratio = if solution.pressure_drop_pa.abs() > 1.0e-12 {
4232        pressure_drop_observed / solution.pressure_drop_pa
4233    } else if pressure_drop_observed.abs() <= 1.0e-12 {
4234        1.0
4235    } else {
4236        0.0
4237    };
4238
4239    let axial_values = (0..node_count)
4240        .map(|node| solution.velocity.get(node * 3).copied().unwrap_or(0.0))
4241        .collect::<Vec<_>>();
4242    let mean_axial = if axial_values.is_empty() {
4243        0.0
4244    } else {
4245        axial_values.iter().sum::<f64>() / axial_values.len() as f64
4246    };
4247    let max_axial_deviation = axial_values
4248        .iter()
4249        .map(|value| (value - mean_axial).abs())
4250        .fold(0.0_f64, f64::max);
4251    let mass_flux_uniformity_ratio = max_axial_deviation / mean_axial.abs().max(1.0e-12);
4252
4253    let pressure_edge_count = node_count.saturating_sub(1);
4254    let pressure_monotonic_cell_fraction = if pressure_edge_count == 0 {
4255        1.0
4256    } else {
4257        let tolerance = solution.pressure_drop_pa.abs().max(1.0) * 1.0e-12;
4258        let monotonic_edges = solution
4259            .pressure
4260            .windows(2)
4261            .filter(|pair| pair[0] + tolerance >= pair[1])
4262            .count();
4263        monotonic_edges as f64 / pressure_edge_count as f64
4264    };
4265
4266    let known_answer_coverage_ratio = if node_count >= 2
4267        && solution.control_volume_count == node_count - 1
4268        && solution.velocity.len() == node_count * 3
4269        && solution.pressure_drop_pa.is_finite()
4270        && solution.pressure.iter().all(|value| value.is_finite())
4271        && solution.velocity.iter().all(|value| value.is_finite())
4272    {
4273        1.0
4274    } else {
4275        0.0
4276    };
4277
4278    CfdKnownAnswerMetrics {
4279        pressure_drop_balance_ratio,
4280        mass_flux_uniformity_ratio,
4281        pressure_monotonic_cell_fraction,
4282        known_answer_coverage_ratio,
4283    }
4284}
4285
4286fn cfd_known_answer_diagnostic(
4287    metrics: &CfdKnownAnswerMetrics,
4288    topology: &CfdDomainTopology,
4289) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
4290    let severity = if (metrics.pressure_drop_balance_ratio - 1.0).abs() <= 1.0e-10
4291        && metrics.mass_flux_uniformity_ratio <= 1.0e-10
4292        && metrics.pressure_monotonic_cell_fraction >= 1.0
4293        && metrics.known_answer_coverage_ratio >= 1.0
4294    {
4295        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4296    } else {
4297        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4298    };
4299
4300    runmat_analysis_fea::diagnostics::FeaDiagnostic {
4301        code: "FEA_CFD_KNOWN_ANSWER".to_string(),
4302        severity,
4303        message: format!(
4304            "basis=finite_volume_channel topology_basis={} pressure_drop_balance_ratio={} mass_flux_uniformity_ratio={} pressure_monotonic_cell_fraction={} known_answer_coverage_ratio={}",
4305            topology.basis.as_str(),
4306            metrics.pressure_drop_balance_ratio,
4307            metrics.mass_flux_uniformity_ratio,
4308            metrics.pressure_monotonic_cell_fraction,
4309            metrics.known_answer_coverage_ratio,
4310        ),
4311    }
4312}
4313
4314fn recover_cfd_vorticity(velocity: &[f64], node_count: usize, dx_m: f64) -> Vec<f64> {
4315    let mut vorticity = vec![0.0; node_count * 3];
4316    if node_count < 2 {
4317        return vorticity;
4318    }
4319    let dx_m = dx_m.max(1.0e-12);
4320
4321    for node in 0..node_count {
4322        let prev = node.saturating_sub(1);
4323        let next = (node + 1).min(node_count - 1);
4324        let prev_base = prev * 3;
4325        let next_base = next * 3;
4326        let dvx = velocity.get(next_base).copied().unwrap_or(0.0)
4327            - velocity.get(prev_base).copied().unwrap_or(0.0);
4328        let dvy = velocity.get(next_base + 1).copied().unwrap_or(0.0)
4329            - velocity.get(prev_base + 1).copied().unwrap_or(0.0);
4330        let base = node * 3;
4331        vorticity[base] = 0.0;
4332        vorticity[base + 1] = -dvx / (2.0 * dx_m);
4333        vorticity[base + 2] = dvy / (2.0 * dx_m);
4334    }
4335
4336    vorticity
4337}
4338
4339fn recover_cfd_wall_shear_stress(
4340    domain: &runmat_analysis_core::CfdDomain,
4341    velocity: &[f64],
4342    field_count: usize,
4343) -> Vec<f64> {
4344    let mut shear = vec![0.0; field_count * 3];
4345    let viscosity = domain.dynamic_viscosity_pa_s;
4346    for index in 0..field_count {
4347        let base = index * 3;
4348        shear[base] = viscosity * velocity.get(base).copied().unwrap_or(0.0);
4349        shear[base + 1] = viscosity * velocity.get(base + 1).copied().unwrap_or(0.0);
4350    }
4351    shear
4352}
4353
4354fn cfd_residual_norms(
4355    velocity: &[f64],
4356    pressure: &[f64],
4357    domain: &runmat_analysis_core::CfdDomain,
4358    topology: &CfdDomainTopology,
4359    step_count: usize,
4360) -> (Vec<f64>, Vec<f64>) {
4361    let node_count = pressure.len().max(1);
4362    let reynolds = cfd_reynolds_number(domain).max(1.0);
4363    let hydraulic_diameter_m = topology.hydraulic_diameter_m.max(1.0e-12);
4364    let friction_factor = if reynolds <= 2300.0 {
4365        64.0 / reynolds
4366    } else {
4367        0.3164 / reynolds.powf(0.25)
4368    };
4369    let mean_axial_velocity = (0..node_count)
4370        .map(|node| velocity.get(node * 3).copied().unwrap_or(0.0))
4371        .sum::<f64>()
4372        / node_count as f64;
4373    let friction_gradient_pa_per_m = 0.5
4374        * domain.reference_density_kg_per_m3.max(1.0e-12)
4375        * mean_axial_velocity
4376        * mean_axial_velocity.abs()
4377        * friction_factor
4378        / hydraulic_diameter_m;
4379    let dx = topology.dx_m.max(1.0e-12);
4380    let mut momentum_base = 0.0_f64;
4381    let mut continuity_base = 0.0_f64;
4382    for node in 0..node_count {
4383        let prev = node.saturating_sub(1);
4384        let next = (node + 1).min(node_count - 1);
4385        let velocity_prev = velocity.get(prev * 3).copied().unwrap_or(0.0);
4386        let velocity_next = velocity.get(next * 3).copied().unwrap_or(0.0);
4387        let pressure_prev = pressure.get(prev).copied().unwrap_or(0.0);
4388        let pressure_next = pressure.get(next).copied().unwrap_or(0.0);
4389        let stencil_width = if prev == next {
4390            1.0
4391        } else {
4392            (next - prev) as f64 * dx
4393        };
4394        let divergence = (velocity_next - velocity_prev) / stencil_width.max(1.0e-12);
4395        let pressure_gradient = (pressure_next - pressure_prev) / stencil_width.max(1.0e-12);
4396        continuity_base += divergence.abs();
4397        momentum_base += (pressure_gradient + friction_gradient_pa_per_m).abs();
4398    }
4399    let velocity_scale = mean_axial_velocity
4400        .abs()
4401        .max(domain.inlet_velocity_m_per_s)
4402        .max(1.0e-9);
4403    let pressure_scale = pressure
4404        .iter()
4405        .copied()
4406        .map(f64::abs)
4407        .fold(0.0_f64, f64::max)
4408        .max(1.0);
4409    let continuity_base = (continuity_base / (node_count as f64 * velocity_scale)).clamp(0.0, 1.0);
4410    let momentum_base = (momentum_base / (node_count as f64 * pressure_scale)).clamp(0.0, 1.0);
4411    let residual_count = step_count.max(1);
4412    (
4413        vec![momentum_base; residual_count],
4414        vec![continuity_base; residual_count],
4415    )
4416}
4417
4418fn build_cfd_run_fields(
4419    domain: &runmat_analysis_core::CfdDomain,
4420    solution: &CfdVelocityPressureSolution,
4421) -> Vec<AnalysisField> {
4422    let node_count = solution.pressure.len();
4423    let velocity = cell_centered_vector_from_nodal(&solution.velocity, node_count);
4424    let pressure = cell_centered_scalar_from_nodal(&solution.pressure);
4425    let cell_count = pressure.len().max(1);
4426    let vorticity = recover_cfd_vorticity(&velocity, cell_count, solution.topology.dx_m);
4427    let boundary_face_count = solution.wall_boundary_count.max(1);
4428    let wall_shear_stress = recover_cfd_wall_shear_stress(domain, &velocity, boundary_face_count);
4429    let residual_count = solution.residual_momentum.len();
4430
4431    vec![
4432        AnalysisField::host_f64(FEA_FIELD_CFD_VELOCITY, vec![cell_count, 3], velocity),
4433        AnalysisField::host_f64(FEA_FIELD_CFD_PRESSURE, vec![cell_count], pressure),
4434        AnalysisField::host_f64(FEA_FIELD_CFD_VORTICITY, vec![cell_count, 3], vorticity),
4435        AnalysisField::host_f64(
4436            FEA_FIELD_CFD_WALL_SHEAR_STRESS,
4437            vec![boundary_face_count, 3],
4438            wall_shear_stress,
4439        ),
4440        AnalysisField::host_f64(
4441            FEA_FIELD_CFD_RESIDUAL_MOMENTUM,
4442            vec![residual_count],
4443            solution.residual_momentum.clone(),
4444        ),
4445        AnalysisField::host_f64(
4446            FEA_FIELD_CFD_RESIDUAL_CONTINUITY,
4447            vec![residual_count],
4448            solution.residual_continuity.clone(),
4449        ),
4450        AnalysisField::host_f64(
4451            FEA_FIELD_CFD_REYNOLDS_NUMBER,
4452            vec![1],
4453            vec![cfd_reynolds_number(domain)],
4454        ),
4455    ]
4456}
4457
4458fn cfd_assembly_diagnostic(
4459    topology: &CfdDomainTopology,
4460    domain: &runmat_analysis_core::CfdDomain,
4461    time_step_s: f64,
4462    pressure_drop_pa: f64,
4463    mass_balance_residual: f64,
4464    residual_warn_threshold: f64,
4465) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
4466    let face_area_m2 = topology.face_area_m2();
4467    let control_volume_volume_m3 = topology.control_volume_volume_m3();
4468    let nominal_mass_flow_rate_kg_per_s =
4469        domain.reference_density_kg_per_m3 * domain.inlet_velocity_m_per_s * face_area_m2;
4470    let courant_number =
4471        domain.inlet_velocity_m_per_s.abs() * time_step_s.max(0.0) / topology.dx_m.max(1.0e-12);
4472    runmat_analysis_fea::diagnostics::FeaDiagnostic {
4473        code: "FEA_CFD_ASSEMBLY".to_string(),
4474        severity: if mass_balance_residual <= residual_warn_threshold {
4475            runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4476        } else {
4477            runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4478        },
4479        message: format!(
4480            "basis=finite_volume_velocity_pressure topology_basis={} topology_geometry_source={} control_volume_count={} control_volume_face_count={} control_volume_internal_face_count={} control_volume_boundary_face_count={} control_volume_connectivity_coverage_ratio={} hydraulic_diameter_m={} domain_length_m={} dx_m={} face_area_m2={} control_volume_volume_m3={} nominal_mass_flow_rate_kg_per_s={} courant_number={} active_dimension_count={} element_geometry_node_count={} element_geometry_edge_count={} element_geometry_coverage_ratio={} element_topology_sample_element_count={} element_topology_sample_edge_count={} pressure_drop_pa={} mass_balance_residual={}",
4481            topology.basis.as_str(),
4482            topology.geometry_source.as_str(),
4483            topology.control_volume_count,
4484            topology.control_volume_face_count,
4485            topology.control_volume_internal_face_count,
4486            topology.control_volume_boundary_face_count,
4487            topology.control_volume_connectivity_coverage_ratio,
4488            topology.hydraulic_diameter_m,
4489            topology.domain_length_m,
4490            topology.dx_m,
4491            face_area_m2,
4492            control_volume_volume_m3,
4493            nominal_mass_flow_rate_kg_per_s,
4494            courant_number,
4495            topology.active_dimension_count,
4496            topology.element_geometry_node_count,
4497            topology.element_geometry_edge_count,
4498            topology.element_geometry_coverage_ratio,
4499            topology.element_topology_sample_element_count,
4500            topology.element_topology_sample_edge_count,
4501            pressure_drop_pa,
4502            mass_balance_residual,
4503        ),
4504    }
4505}
4506
4507fn pressure_drop_from_nodal_pressure(pressure: &[f64]) -> f64 {
4508    match (pressure.first(), pressure.last()) {
4509        (Some(first), Some(last)) => first - last,
4510        _ => 0.0,
4511    }
4512}
4513
4514fn cell_centered_vector_from_nodal(nodal: &[f64], node_count: usize) -> Vec<f64> {
4515    let cell_count = node_count.saturating_sub(1).max(1);
4516    let mut cell_values = Vec::with_capacity(cell_count * 3);
4517    for cell in 0..cell_count {
4518        let left = cell.min(node_count.saturating_sub(1));
4519        let right = (cell + 1).min(node_count.saturating_sub(1));
4520        for component in 0..3 {
4521            let left_value = nodal.get(left * 3 + component).copied().unwrap_or(0.0);
4522            let right_value = nodal
4523                .get(right * 3 + component)
4524                .copied()
4525                .unwrap_or(left_value);
4526            cell_values.push(0.5 * (left_value + right_value));
4527        }
4528    }
4529    cell_values
4530}
4531
4532fn cell_centered_scalar_from_nodal(nodal: &[f64]) -> Vec<f64> {
4533    let node_count = nodal.len();
4534    let cell_count = node_count.saturating_sub(1).max(1);
4535    let mut cell_values = Vec::with_capacity(cell_count);
4536    for cell in 0..cell_count {
4537        let left = cell.min(node_count.saturating_sub(1));
4538        let right = (cell + 1).min(node_count.saturating_sub(1));
4539        let left_value = nodal.get(left).copied().unwrap_or(0.0);
4540        let right_value = nodal.get(right).copied().unwrap_or(left_value);
4541        cell_values.push(0.5 * (left_value + right_value));
4542    }
4543    cell_values
4544}
4545
4546fn resample_scalar_profile(values: &[f64], target_count: usize) -> Vec<f64> {
4547    let target_count = target_count.max(1);
4548    if values.is_empty() {
4549        return vec![0.0; target_count];
4550    }
4551    if values.len() == target_count {
4552        return values.to_vec();
4553    }
4554    if target_count == 1 {
4555        return vec![values[0]];
4556    }
4557    let source_max = values.len().saturating_sub(1) as f64;
4558    let target_max = target_count.saturating_sub(1) as f64;
4559    (0..target_count)
4560        .map(|target_index| {
4561            let source_position = target_index as f64 * source_max / target_max.max(1.0);
4562            let left = source_position.floor() as usize;
4563            let right = source_position.ceil() as usize;
4564            if left == right {
4565                values.get(left).copied().unwrap_or(0.0)
4566            } else {
4567                let t = source_position - left as f64;
4568                let left_value = values.get(left).copied().unwrap_or(0.0);
4569                let right_value = values.get(right).copied().unwrap_or(left_value);
4570                left_value * (1.0 - t) + right_value * t
4571            }
4572        })
4573        .collect()
4574}
4575
4576fn fluid_interface_face_count(topology: &CfdDomainTopology) -> usize {
4577    if topology.control_volume_connectivity_coverage_ratio > 0.0
4578        && topology.control_volume_boundary_face_count > 0
4579    {
4580        topology.control_volume_boundary_face_count
4581    } else {
4582        topology.control_volume_count
4583    }
4584    .max(1)
4585}
4586
4587fn coupled_interface_graph_edge_target(
4588    topology: &CfdDomainTopology,
4589    interface_face_count: usize,
4590) -> usize {
4591    if interface_face_count < 2 {
4592        return 0;
4593    }
4594    let line_edge_count = interface_face_count - 1;
4595    let complete_graph_edge_count = interface_face_count * (interface_face_count - 1) / 2;
4596    if topology.control_volume_connectivity_coverage_ratio > 0.0 {
4597        topology
4598            .control_volume_internal_face_count
4599            .max(line_edge_count)
4600            .min(complete_graph_edge_count)
4601    } else {
4602        line_edge_count
4603    }
4604}
4605
4606fn coupled_interface_graph_edges_for_topology(
4607    topology: &CfdDomainTopology,
4608    interface_face_count: usize,
4609) -> Vec<(usize, usize)> {
4610    use std::collections::BTreeSet;
4611
4612    let target = coupled_interface_graph_edge_target(topology, interface_face_count);
4613    if target == 0 {
4614        return Vec::new();
4615    }
4616
4617    let mut seen = BTreeSet::<(usize, usize)>::new();
4618    let mut edges = Vec::with_capacity(target);
4619
4620    let full_edge_count = topology
4621        .element_topology_edge_nodes
4622        .len()
4623        .min(interface_face_count);
4624    if full_edge_count > 0 {
4625        for element_edges in &topology.element_topology_element_edges {
4626            let local_edges = element_edges
4627                .iter()
4628                .map(|edge| *edge as usize)
4629                .filter(|edge| *edge < full_edge_count)
4630                .collect::<Vec<_>>();
4631            if local_edges.len() < 2 {
4632                continue;
4633            }
4634            let pair_count = if local_edges.len() == 2 {
4635                1
4636            } else {
4637                local_edges.len()
4638            };
4639            for offset in 0..pair_count {
4640                let next = if offset + 1 < local_edges.len() {
4641                    offset + 1
4642                } else {
4643                    0
4644                };
4645                let left = local_edges[offset].min(local_edges[next]);
4646                let right = local_edges[offset].max(local_edges[next]);
4647                if left != right && seen.insert((left, right)) {
4648                    edges.push((left, right));
4649                    if edges.len() == target {
4650                        return edges;
4651                    }
4652                }
4653            }
4654        }
4655    }
4656
4657    let sample_edge_count = topology
4658        .element_topology_sample_edge_count
4659        .min(interface_face_count);
4660    if edges.is_empty() {
4661        for element_edges in topology
4662            .element_topology_sample_element_edges
4663            .iter()
4664            .take(topology.element_topology_sample_element_count.min(4))
4665        {
4666            let local_edges = element_edges
4667                .iter()
4668                .map(|edge| *edge as usize)
4669                .filter(|edge| *edge < sample_edge_count)
4670                .collect::<Vec<_>>();
4671            if local_edges.len() < 2 {
4672                continue;
4673            }
4674            let pair_count = if local_edges.len() == 2 {
4675                1
4676            } else {
4677                local_edges.len()
4678            };
4679            for offset in 0..pair_count {
4680                let next = if offset + 1 < local_edges.len() {
4681                    offset + 1
4682                } else {
4683                    0
4684                };
4685                let left = local_edges[offset].min(local_edges[next]);
4686                let right = local_edges[offset].max(local_edges[next]);
4687                if left != right && seen.insert((left, right)) {
4688                    edges.push((left, right));
4689                    if edges.len() == target {
4690                        return edges;
4691                    }
4692                }
4693            }
4694        }
4695    }
4696
4697    for (left, right) in coupled_interface_graph_edges(interface_face_count, target) {
4698        let edge = (left.min(right), left.max(right));
4699        if seen.insert(edge) {
4700            edges.push(edge);
4701            if edges.len() == target {
4702                break;
4703            }
4704        }
4705    }
4706    edges
4707}
4708
4709fn coupled_interface_connectivity_coverage_ratio(
4710    topology: &CfdDomainTopology,
4711    interface_face_count: usize,
4712    edge_count: usize,
4713) -> f64 {
4714    let target = coupled_interface_graph_edge_target(topology, interface_face_count);
4715    if target == 0 {
4716        return 1.0;
4717    }
4718    (edge_count as f64 / target as f64).clamp(0.0, 1.0)
4719}
4720
4721fn coupled_interface_mesh_backed_connectivity_ratio(
4722    topology: &CfdDomainTopology,
4723    edge_count: usize,
4724) -> f64 {
4725    if topology.basis == CfdDomainTopologyBasis::PrepControlVolumeConnectivity
4726        && topology.control_volume_connectivity_coverage_ratio > 0.0
4727        && edge_count > 0
4728    {
4729        1.0
4730    } else {
4731        0.0
4732    }
4733}
4734
4735fn solve_cfd_finite_volume_run(
4736    model: &AnalysisModel,
4737    domain: &runmat_analysis_core::CfdDomain,
4738    backend: ComputeBackend,
4739    options: &AnalysisCfdRunOptions,
4740    prep_context: Option<&AnalysisRunPrepContext>,
4741) -> FeaRunResult {
4742    let topology = CfdDomainTopology::from_model(model, prep_context);
4743    let node_count = topology.node_count;
4744    let step_count = options.step_count.max(1);
4745    let field_step = match domain.solve_family {
4746        runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
4747        runmat_analysis_core::CfdSolveFamily::Transient => step_count.saturating_sub(1),
4748    };
4749    let boundary_summary = CfdBoundarySummary::from_model(model, domain, node_count);
4750    let solution = solve_cfd_velocity_pressure(
4751        domain,
4752        &boundary_summary,
4753        &topology,
4754        field_step,
4755        step_count,
4756        options.max_linear_iters,
4757        options.tolerance,
4758    );
4759    let max_momentum_residual = solution
4760        .residual_momentum
4761        .iter()
4762        .copied()
4763        .fold(0.0_f64, f64::max);
4764    let max_continuity_residual = solution
4765        .residual_continuity
4766        .iter()
4767        .copied()
4768        .fold(0.0_f64, f64::max);
4769    let known_answer_metrics = cfd_known_answer_metrics(&solution);
4770    let fields = build_cfd_run_fields(domain, &solution);
4771    let residual_severity = if max_momentum_residual <= options.residual_warn_threshold
4772        && max_continuity_residual <= options.residual_warn_threshold
4773    {
4774        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4775    } else {
4776        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4777    };
4778    FeaRunResult {
4779        backend,
4780        solver_backend: "cpu_reference".to_string(),
4781        solver_device_apply_k_ratio: 0.0,
4782        solver_method: "cfd_velocity_pressure_finite_volume".to_string(),
4783        preconditioner: "finite_volume_pressure_balance".to_string(),
4784        solver_host_sync_count: 0,
4785        diagnostics: vec![
4786            runmat_analysis_fea::diagnostics::FeaDiagnostic {
4787                code: "FEA_CFD_RESIDUAL".to_string(),
4788                severity: residual_severity,
4789                message: format!(
4790                    "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
4791                    max_momentum_residual,
4792                    max_continuity_residual,
4793                    options.residual_warn_threshold,
4794                    node_count,
4795                    step_count,
4796                ),
4797            },
4798            cfd_assembly_diagnostic(
4799                &solution.topology,
4800                domain,
4801                options.time_step_s,
4802                solution.pressure_drop_pa,
4803                solution.mass_balance_residual,
4804                options.residual_warn_threshold,
4805            ),
4806            runmat_analysis_fea::diagnostics::FeaDiagnostic {
4807                code: "FEA_CFD_BOUNDARY_CONDITIONS".to_string(),
4808                severity: if solution.boundary_coverage_ratio >= 1.0
4809                    && solution.wall_boundary_coverage_ratio >= 1.0
4810                    && solution.inlet_velocity_realization_ratio.is_finite()
4811                {
4812                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4813                } else {
4814                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4815                },
4816                message: format!(
4817                    "boundary_source={} authored_boundary_count={} inlet_boundary_count={} outlet_boundary_count={} wall_boundary_count={} no_slip_wall_boundary_count={} slip_wall_boundary_count={} symmetry_boundary_count={} boundary_coverage_ratio={} wall_boundary_coverage_ratio={} nominal_inlet_velocity_m_per_s={} outlet_pressure_pa={} inlet_velocity_realization_ratio={}",
4818                    if solution.authored_boundary_count > 0 {
4819                        "authored"
4820                    } else {
4821                        "implicit_channel"
4822                    },
4823                    solution.authored_boundary_count,
4824                    solution.inlet_boundary_count,
4825                    solution.outlet_boundary_count,
4826                    solution.wall_boundary_count,
4827                    solution.no_slip_wall_boundary_count,
4828                    solution.slip_wall_boundary_count,
4829                    solution.symmetry_boundary_count,
4830                    solution.boundary_coverage_ratio,
4831                    solution.wall_boundary_coverage_ratio,
4832                    solution.nominal_inlet_velocity_m_per_s,
4833                    solution.outlet_pressure_pa,
4834                    solution.inlet_velocity_realization_ratio,
4835                ),
4836            },
4837            runmat_analysis_fea::diagnostics::FeaDiagnostic {
4838                code: "FEA_CFD_PRESSURE_CORRECTION".to_string(),
4839                severity: if solution.pressure_correction_residual_ratio <= options.tolerance
4840                    && solution.velocity_correction_residual_ratio <= options.tolerance
4841                {
4842                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
4843                } else {
4844                    runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
4845                },
4846                message: format!(
4847                    "iteration_count={} max_linear_iters={} tolerance={} pressure_correction_residual_ratio={} velocity_correction_residual_ratio={}",
4848                    solution.pressure_correction_iteration_count,
4849                    options.max_linear_iters,
4850                    options.tolerance,
4851                    solution.pressure_correction_residual_ratio,
4852                    solution.velocity_correction_residual_ratio,
4853                ),
4854            },
4855            runmat_analysis_fea::diagnostics::FeaDiagnostic {
4856                code: "FEA_CFD_TRANSIENT_EVOLUTION".to_string(),
4857                severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
4858                message: format!(
4859                    "solve_family={} step_count={} time_step_s={} transient_profile_point_count={} transient_scale_min={} transient_scale_max={} transient_scale_variation={}",
4860                    match domain.solve_family {
4861                        runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
4862                        runmat_analysis_core::CfdSolveFamily::Transient => "transient",
4863                    },
4864                    step_count,
4865                    options.time_step_s,
4866                    domain.time_profile.len(),
4867                    solution.transient_scale_min,
4868                    solution.transient_scale_max,
4869                    solution.transient_scale_variation,
4870                ),
4871            },
4872            cfd_known_answer_diagnostic(&known_answer_metrics, &topology),
4873        ],
4874        fields,
4875    }
4876}
4877
4878fn field_scalar_magnitudes(field: &AnalysisField, fallback_len: usize) -> Vec<f64> {
4879    let Some(values) = field.as_host_f64() else {
4880        return vec![0.0; fallback_len.max(1)];
4881    };
4882    match field.shape.as_slice() {
4883        [count, components] if *components > 1 => {
4884            let mut magnitudes = Vec::with_capacity(*count);
4885            for index in 0..*count {
4886                let start = index * *components;
4887                let magnitude = values
4888                    .get(start..start + *components)
4889                    .unwrap_or(&[])
4890                    .iter()
4891                    .map(|value| value * value)
4892                    .sum::<f64>()
4893                    .sqrt();
4894                magnitudes.push(magnitude);
4895            }
4896            magnitudes
4897        }
4898        _ => values.to_vec(),
4899    }
4900}
4901
4902fn build_cht_run_fields(
4903    domain: &runmat_analysis_core::CfdDomain,
4904    topology: &CfdDomainTopology,
4905    thermal_run: &runmat_analysis_fea::FeaThermalRunResult,
4906    authored_interface_conductance_w_per_m2k: Option<f64>,
4907    max_linear_iters: usize,
4908    tolerance: f64,
4909) -> (Vec<AnalysisField>, ChtInterfaceClosure) {
4910    let node_count = topology.node_count;
4911    let (fluid_velocity, fluid_pressure) = recover_cfd_velocity_pressure(domain, topology, 0);
4912    let mean_axial_velocity = mean_cfd_axial_velocity(&fluid_velocity);
4913    let mut fields = vec![
4914        AnalysisField::host_f64(
4915            FEA_FIELD_CHT_FLUID_VELOCITY,
4916            vec![node_count, 3],
4917            fluid_velocity,
4918        ),
4919        AnalysisField::host_f64(
4920            FEA_FIELD_CHT_FLUID_PRESSURE,
4921            vec![node_count],
4922            fluid_pressure,
4923        ),
4924    ];
4925    let mut closure = ChtInterfaceClosure::default();
4926
4927    for (step_index, temperature) in thermal_run.temperature_snapshots.iter().enumerate() {
4928        let fallback_len = temperature.element_count().max(1);
4929        let base_temperature = temperature
4930            .as_host_f64()
4931            .map(|values| values.to_vec())
4932            .unwrap_or_else(|| vec![thermal_run.reference_temperature_k; fallback_len]);
4933        let mut heat_flux = thermal_run
4934            .heat_flux_snapshots
4935            .get(step_index)
4936            .map(|field| field_scalar_magnitudes(field, fallback_len))
4937            .unwrap_or_else(|| vec![0.0; fallback_len]);
4938        if heat_flux.is_empty() {
4939            heat_flux.push(0.0);
4940        }
4941        let target_interface_count = fluid_interface_face_count(topology);
4942        if heat_flux.len() != target_interface_count {
4943            heat_flux = resample_scalar_profile(&heat_flux, target_interface_count);
4944        }
4945        let interface_count = heat_flux.len();
4946        let max_heat_flux = heat_flux
4947            .iter()
4948            .copied()
4949            .map(f64::abs)
4950            .fold(0.0_f64, f64::max);
4951        let target_jump_k = 0.01_f64;
4952        let interface_conductance_w_per_m2k = authored_interface_conductance_w_per_m2k
4953            .unwrap_or_else(|| (max_heat_flux / target_jump_k).max(25.0))
4954            .max(1.0e-12);
4955        let advection_shift_k = cht_advection_shift_k(
4956            domain,
4957            mean_axial_velocity,
4958            &base_temperature,
4959            thermal_run.reference_temperature_k,
4960        );
4961
4962        let interface_solution = solve_cht_conjugate_interface(
4963            &base_temperature,
4964            &heat_flux,
4965            topology,
4966            interface_conductance_w_per_m2k,
4967            advection_shift_k,
4968            thermal_run.reference_temperature_k,
4969            max_linear_iters,
4970            tolerance,
4971        );
4972        let fluid_temperature = interface_solution.fluid_temperature;
4973        let solid_temperature = interface_solution.solid_temperature;
4974        let temperature_jump = interface_solution.temperature_jump;
4975        let coupled_heat_flux = interface_solution.coupled_heat_flux;
4976        fields.push(AnalysisField::host_f64(
4977            fea_cht_fluid_temperature_field_id(step_index),
4978            vec![base_temperature.len().max(1)],
4979            fluid_temperature,
4980        ));
4981        fields.push(AnalysisField::host_f64(
4982            fea_cht_solid_temperature_field_id(step_index),
4983            vec![base_temperature.len().max(1)],
4984            solid_temperature,
4985        ));
4986
4987        closure.interface_face_count = closure.interface_face_count.max(interface_count);
4988        closure.max_temperature_jump_k = closure.max_temperature_jump_k.max(
4989            temperature_jump
4990                .iter()
4991                .copied()
4992                .map(f64::abs)
4993                .fold(0.0_f64, f64::max),
4994        );
4995        closure.max_advection_temperature_shift_k = closure
4996            .max_advection_temperature_shift_k
4997            .max(advection_shift_k.abs());
4998        closure.interface_conductance_w_per_m2k = closure
4999            .interface_conductance_w_per_m2k
5000            .max(interface_conductance_w_per_m2k);
5001        closure.max_flux_temperature_law_residual_ratio = closure
5002            .max_flux_temperature_law_residual_ratio
5003            .max(interface_solution.flux_temperature_law_residual_ratio);
5004        closure.max_heat_flux_realization_residual_ratio = closure
5005            .max_heat_flux_realization_residual_ratio
5006            .max(interface_solution.heat_flux_realization_residual_ratio);
5007        closure.max_coupled_interface_iteration_count = closure
5008            .max_coupled_interface_iteration_count
5009            .max(interface_solution.iteration_count);
5010        closure.max_coupled_interface_residual_ratio = closure
5011            .max_coupled_interface_residual_ratio
5012            .max(interface_solution.coupled_interface_residual_ratio);
5013        closure.thermal_network_edge_count = closure
5014            .thermal_network_edge_count
5015            .max(interface_solution.thermal_network_edge_count);
5016        closure.thermal_network_node_count = closure
5017            .thermal_network_node_count
5018            .max(interface_solution.thermal_network_node_count);
5019        closure.interface_connectivity_coverage_ratio = closure
5020            .interface_connectivity_coverage_ratio
5021            .max(interface_solution.interface_connectivity_coverage_ratio);
5022        closure.mesh_backed_interface_connectivity_ratio = closure
5023            .mesh_backed_interface_connectivity_ratio
5024            .max(interface_solution.mesh_backed_interface_connectivity_ratio);
5025        closure.full_topology_edge_count = closure
5026            .full_topology_edge_count
5027            .max(interface_solution.full_topology_edge_count);
5028        closure.full_topology_element_count = closure
5029            .full_topology_element_count
5030            .max(interface_solution.full_topology_element_count);
5031        closure.max_thermal_network_residual_ratio = closure
5032            .max_thermal_network_residual_ratio
5033            .max(interface_solution.thermal_network_residual_ratio);
5034        let fluid_heat = coupled_heat_flux.iter().sum::<f64>();
5035        let solid_heat = -fluid_heat;
5036        let heat_balance_ratio =
5037            (fluid_heat + solid_heat).abs() / (fluid_heat.abs() + solid_heat.abs() + 1.0e-12);
5038        closure.heat_flux_balance_ratio = closure.heat_flux_balance_ratio.max(heat_balance_ratio);
5039        let thermal_scale_k = base_temperature
5040            .iter()
5041            .map(|value| (value - thermal_run.reference_temperature_k).abs())
5042            .fold(0.0_f64, f64::max)
5043            .max(1.0);
5044        let normalized_temperature_jump =
5045            closure.max_temperature_jump_k / thermal_scale_k.max(1.0e-12);
5046        closure.max_thermal_transport_residual_ratio =
5047            closure.max_thermal_transport_residual_ratio.max(
5048                interface_solution
5049                    .flux_temperature_law_residual_ratio
5050                    .max(heat_balance_ratio)
5051                    .max(interface_solution.heat_flux_realization_residual_ratio)
5052                    .max(interface_solution.coupled_interface_residual_ratio)
5053                    .max(interface_solution.thermal_network_residual_ratio),
5054            );
5055        closure.interface_temperature_continuity_ratio = closure
5056            .interface_temperature_continuity_ratio
5057            .max((1.0 - normalized_temperature_jump).clamp(0.0, 1.0));
5058        let mean_heat_flux = if coupled_heat_flux.is_empty() {
5059            0.0
5060        } else {
5061            coupled_heat_flux.iter().sum::<f64>() / coupled_heat_flux.len() as f64
5062        };
5063        closure.mean_interface_heat_flux_w_per_m2 = closure
5064            .mean_interface_heat_flux_w_per_m2
5065            .max(mean_heat_flux.abs());
5066        fields.push(AnalysisField::host_f64(
5067            fea_cht_interface_heat_flux_field_id(step_index),
5068            vec![interface_count],
5069            coupled_heat_flux,
5070        ));
5071
5072        fields.push(AnalysisField::host_f64(
5073            fea_cht_interface_temperature_jump_field_id(step_index),
5074            vec![interface_count],
5075            temperature_jump,
5076        ));
5077        let energy_residual = heat_balance_ratio
5078            .max(interface_solution.flux_temperature_law_residual_ratio)
5079            .max(interface_solution.heat_flux_realization_residual_ratio)
5080            .max(interface_solution.thermal_network_residual_ratio);
5081        closure.max_energy_residual = closure.max_energy_residual.max(energy_residual);
5082        fields.push(AnalysisField::host_f64(
5083            fea_cht_energy_residual_field_id(step_index),
5084            vec![1],
5085            vec![energy_residual],
5086        ));
5087    }
5088
5089    (fields, closure)
5090}
5091
5092#[derive(Debug, Clone)]
5093struct ChtConjugateInterfaceSolution {
5094    fluid_temperature: Vec<f64>,
5095    solid_temperature: Vec<f64>,
5096    temperature_jump: Vec<f64>,
5097    coupled_heat_flux: Vec<f64>,
5098    iteration_count: usize,
5099    coupled_interface_residual_ratio: f64,
5100    flux_temperature_law_residual_ratio: f64,
5101    heat_flux_realization_residual_ratio: f64,
5102    thermal_network_edge_count: usize,
5103    thermal_network_node_count: usize,
5104    interface_connectivity_coverage_ratio: f64,
5105    mesh_backed_interface_connectivity_ratio: f64,
5106    full_topology_edge_count: usize,
5107    full_topology_element_count: usize,
5108    thermal_network_residual_ratio: f64,
5109}
5110
5111fn solve_cht_conjugate_interface(
5112    base_temperature: &[f64],
5113    heat_flux: &[f64],
5114    topology: &CfdDomainTopology,
5115    interface_conductance_w_per_m2k: f64,
5116    advection_shift_k: f64,
5117    reference_temperature_k: f64,
5118    _max_linear_iters: usize,
5119    _tolerance: f64,
5120) -> ChtConjugateInterfaceSolution {
5121    let temperature_count = base_temperature.len().max(1);
5122    let interface_count = heat_flux.len().max(1);
5123    let interface_denom = interface_count.saturating_sub(1).max(1) as f64;
5124    let conductance = interface_conductance_w_per_m2k.max(1.0e-12);
5125    let axial_conductance = (0.05 * conductance).max(1.0e-12);
5126    let anchor_conductance = conductance;
5127    let base_interface_temperature = resample_scalar_profile(base_temperature, interface_count);
5128    let thermal_network_edges =
5129        coupled_interface_graph_edges_for_topology(topology, interface_count);
5130    let interface_connectivity_coverage_ratio = coupled_interface_connectivity_coverage_ratio(
5131        topology,
5132        interface_count,
5133        thermal_network_edges.len(),
5134    );
5135    let mesh_backed_interface_connectivity_ratio =
5136        coupled_interface_mesh_backed_connectivity_ratio(topology, thermal_network_edges.len());
5137    let mut operator = vec![vec![0.0; interface_count]; interface_count];
5138    for (row, diagonal) in operator.iter_mut().enumerate() {
5139        diagonal[row] = anchor_conductance;
5140    }
5141    for (left, right) in &thermal_network_edges {
5142        operator[*left][*left] += axial_conductance;
5143        operator[*right][*right] += axial_conductance;
5144        operator[*left][*right] -= axial_conductance;
5145        operator[*right][*left] -= axial_conductance;
5146    }
5147    let mut center_rhs = Vec::with_capacity(interface_count);
5148
5149    for index in 0..interface_count {
5150        let base = base_interface_temperature
5151            .get(index)
5152            .copied()
5153            .unwrap_or(reference_temperature_k);
5154        let xi = index as f64 / interface_denom;
5155        let center_temperature = base + advection_shift_k * xi;
5156        center_rhs.push(anchor_conductance * center_temperature);
5157    }
5158
5159    let mut matrix = operator.clone();
5160    let mut rhs = center_rhs.clone();
5161    let interface_center_temperature = solve_dense_real_system(&mut matrix, &mut rhs);
5162    let center_reaction = apply_dense_real_operator(&operator, &interface_center_temperature);
5163    let temperature_scale = interface_center_temperature
5164        .iter()
5165        .map(|value| (*value - reference_temperature_k).abs())
5166        .fold(0.0_f64, f64::max)
5167        .max(1.0);
5168    let thermal_network_residual_ratio = center_reaction
5169        .iter()
5170        .zip(center_rhs.iter())
5171        .map(|(reaction, rhs)| (reaction - rhs).abs())
5172        .fold(0.0_f64, f64::max)
5173        / (anchor_conductance * temperature_scale).max(1.0e-12);
5174    let mut interface_fluid_temperature = Vec::with_capacity(interface_count);
5175    let mut interface_solid_temperature = Vec::with_capacity(interface_count);
5176    for (center, flux) in interface_center_temperature
5177        .iter()
5178        .zip(heat_flux.iter().chain(std::iter::repeat(&0.0)))
5179    {
5180        let jump = *flux / conductance;
5181        interface_fluid_temperature.push(*center + 0.5 * jump);
5182        interface_solid_temperature.push(*center - 0.5 * jump);
5183    }
5184    let fluid_temperature =
5185        resample_scalar_profile(&interface_fluid_temperature, temperature_count);
5186    let solid_temperature =
5187        resample_scalar_profile(&interface_solid_temperature, temperature_count);
5188    let iteration_count = usize::from(temperature_count > 0);
5189    let coupled_interface_residual_ratio = thermal_network_residual_ratio;
5190
5191    let mut temperature_jump = Vec::with_capacity(interface_count);
5192    let mut coupled_heat_flux = Vec::with_capacity(interface_count);
5193    let mut max_flux_temperature_law_residual = 0.0_f64;
5194    let mut max_heat_flux_realization_residual = 0.0_f64;
5195    let heat_flux_scale = heat_flux
5196        .iter()
5197        .copied()
5198        .map(f64::abs)
5199        .fold(0.0_f64, f64::max)
5200        .max(1.0e-12);
5201    for index in 0..interface_count {
5202        let jump = interface_fluid_temperature
5203            .get(index)
5204            .copied()
5205            .unwrap_or(reference_temperature_k)
5206            - interface_solid_temperature
5207                .get(index)
5208                .copied()
5209                .unwrap_or(reference_temperature_k);
5210        let coupled_flux = conductance * jump;
5211        let input_flux = heat_flux.get(index).copied().unwrap_or(0.0);
5212        max_flux_temperature_law_residual = max_flux_temperature_law_residual
5213            .max((conductance * jump - coupled_flux).abs() / heat_flux_scale);
5214        max_heat_flux_realization_residual = max_heat_flux_realization_residual
5215            .max((coupled_flux - input_flux).abs() / heat_flux_scale);
5216        temperature_jump.push(jump);
5217        coupled_heat_flux.push(coupled_flux);
5218    }
5219
5220    ChtConjugateInterfaceSolution {
5221        fluid_temperature,
5222        solid_temperature,
5223        temperature_jump,
5224        coupled_heat_flux,
5225        iteration_count,
5226        coupled_interface_residual_ratio,
5227        flux_temperature_law_residual_ratio: max_flux_temperature_law_residual,
5228        heat_flux_realization_residual_ratio: max_heat_flux_realization_residual,
5229        thermal_network_edge_count: thermal_network_edges.len(),
5230        thermal_network_node_count: interface_count,
5231        interface_connectivity_coverage_ratio,
5232        mesh_backed_interface_connectivity_ratio,
5233        full_topology_edge_count: topology.element_topology_edge_nodes.len(),
5234        full_topology_element_count: topology.element_topology_element_edges.len(),
5235        thermal_network_residual_ratio,
5236    }
5237}
5238
5239fn mean_cfd_axial_velocity(velocity: &[f64]) -> f64 {
5240    let node_count = velocity.len() / 3;
5241    if node_count == 0 {
5242        return 0.0;
5243    }
5244    (0..node_count)
5245        .map(|node| velocity.get(node * 3).copied().unwrap_or(0.0))
5246        .sum::<f64>()
5247        / node_count as f64
5248}
5249
5250fn cht_advection_shift_k(
5251    domain: &runmat_analysis_core::CfdDomain,
5252    mean_axial_velocity_m_per_s: f64,
5253    temperature: &[f64],
5254    reference_temperature_k: f64,
5255) -> f64 {
5256    let thermal_span_k = temperature
5257        .iter()
5258        .map(|value| (value - reference_temperature_k).abs())
5259        .fold(0.0_f64, f64::max)
5260        .max(1.0);
5261    let reynolds_ratio = (cfd_reynolds_number_for_velocity(domain, mean_axial_velocity_m_per_s)
5262        / 1.0e5)
5263        .clamp(0.0, 5.0);
5264    thermal_span_k * reynolds_ratio * domain.turbulence_intensity.clamp(0.0, 1.0) * 2.0e-3
5265}
5266
5267fn cht_interface_conductance_w_per_m2k(model: &AnalysisModel) -> Option<f64> {
5268    model
5269        .interfaces
5270        .iter()
5271        .find_map(|interface| match &interface.kind {
5272            AnalysisInterfaceKind::ConjugateHeatTransfer(interface)
5273                if interface.thermal_conductance_w_per_m2k.is_finite()
5274                    && interface.thermal_conductance_w_per_m2k > 0.0 =>
5275            {
5276                Some(interface.thermal_conductance_w_per_m2k)
5277            }
5278            AnalysisInterfaceKind::ConjugateHeatTransfer(_)
5279            | AnalysisInterfaceKind::FluidStructure(_)
5280            | AnalysisInterfaceKind::Contact(_) => None,
5281        })
5282}
5283
5284#[derive(Debug, Clone, Copy, Default)]
5285struct ChtInterfaceClosure {
5286    interface_face_count: usize,
5287    max_temperature_jump_k: f64,
5288    max_energy_residual: f64,
5289    heat_flux_balance_ratio: f64,
5290    mean_interface_heat_flux_w_per_m2: f64,
5291    max_thermal_transport_residual_ratio: f64,
5292    interface_temperature_continuity_ratio: f64,
5293    max_advection_temperature_shift_k: f64,
5294    interface_conductance_w_per_m2k: f64,
5295    max_flux_temperature_law_residual_ratio: f64,
5296    max_heat_flux_realization_residual_ratio: f64,
5297    max_coupled_interface_iteration_count: usize,
5298    max_coupled_interface_residual_ratio: f64,
5299    thermal_network_edge_count: usize,
5300    thermal_network_node_count: usize,
5301    interface_connectivity_coverage_ratio: f64,
5302    mesh_backed_interface_connectivity_ratio: f64,
5303    full_topology_edge_count: usize,
5304    full_topology_element_count: usize,
5305    max_thermal_network_residual_ratio: f64,
5306}
5307
5308#[derive(Debug, Clone, Copy)]
5309struct ChtKnownAnswerMetrics {
5310    heated_channel_energy_residual_ratio: f64,
5311    conjugate_slab_flux_law_residual_ratio: f64,
5312    interface_temperature_continuity_ratio: f64,
5313    advection_shift_coverage_ratio: f64,
5314    coupled_interface_residual_ratio: f64,
5315    heat_flux_realization_residual_ratio: f64,
5316    interface_connectivity_coverage_ratio: f64,
5317    mesh_backed_interface_connectivity_ratio: f64,
5318    thermal_network_residual_ratio: f64,
5319    known_answer_coverage_ratio: f64,
5320}
5321
5322fn cht_known_answer_metrics(
5323    domain: &runmat_analysis_core::CfdDomain,
5324    closure: &ChtInterfaceClosure,
5325) -> ChtKnownAnswerMetrics {
5326    let reynolds = cfd_reynolds_number(domain);
5327    let advection_shift_coverage_ratio = if reynolds.is_finite()
5328        && reynolds > 0.0
5329        && closure.max_advection_temperature_shift_k.is_finite()
5330        && closure.max_advection_temperature_shift_k >= 0.0
5331    {
5332        1.0
5333    } else {
5334        0.0
5335    };
5336    let known_answer_coverage_ratio = if closure.interface_face_count > 0
5337        && closure.interface_conductance_w_per_m2k.is_finite()
5338        && closure.interface_conductance_w_per_m2k > 0.0
5339        && closure.max_energy_residual.is_finite()
5340        && closure.max_flux_temperature_law_residual_ratio.is_finite()
5341        && closure.max_heat_flux_realization_residual_ratio.is_finite()
5342        && closure.interface_temperature_continuity_ratio.is_finite()
5343        && closure.max_coupled_interface_residual_ratio.is_finite()
5344        && closure.thermal_network_node_count > 0
5345        && closure.interface_connectivity_coverage_ratio.is_finite()
5346        && closure.interface_connectivity_coverage_ratio >= 1.0
5347        && closure.mesh_backed_interface_connectivity_ratio.is_finite()
5348        && closure.max_thermal_network_residual_ratio.is_finite()
5349        && advection_shift_coverage_ratio >= 1.0
5350    {
5351        1.0
5352    } else {
5353        0.0
5354    };
5355
5356    ChtKnownAnswerMetrics {
5357        heated_channel_energy_residual_ratio: closure.max_energy_residual,
5358        conjugate_slab_flux_law_residual_ratio: closure.max_flux_temperature_law_residual_ratio,
5359        interface_temperature_continuity_ratio: closure.interface_temperature_continuity_ratio,
5360        advection_shift_coverage_ratio,
5361        coupled_interface_residual_ratio: closure.max_coupled_interface_residual_ratio,
5362        heat_flux_realization_residual_ratio: closure.max_heat_flux_realization_residual_ratio,
5363        interface_connectivity_coverage_ratio: closure.interface_connectivity_coverage_ratio,
5364        mesh_backed_interface_connectivity_ratio: closure.mesh_backed_interface_connectivity_ratio,
5365        thermal_network_residual_ratio: closure.max_thermal_network_residual_ratio,
5366        known_answer_coverage_ratio,
5367    }
5368}
5369
5370fn cht_known_answer_diagnostic(
5371    metrics: &ChtKnownAnswerMetrics,
5372    residual_threshold: f64,
5373) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
5374    let severity = if metrics.heated_channel_energy_residual_ratio <= residual_threshold
5375        && metrics.conjugate_slab_flux_law_residual_ratio <= residual_threshold
5376        && metrics.interface_temperature_continuity_ratio >= 0.999
5377        && metrics.advection_shift_coverage_ratio >= 1.0
5378        && metrics.coupled_interface_residual_ratio <= residual_threshold
5379        && metrics.heat_flux_realization_residual_ratio <= residual_threshold
5380        && metrics.interface_connectivity_coverage_ratio >= 1.0
5381        && metrics.thermal_network_residual_ratio <= residual_threshold
5382        && metrics.known_answer_coverage_ratio >= 1.0
5383    {
5384        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
5385    } else {
5386        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
5387    };
5388
5389    runmat_analysis_fea::diagnostics::FeaDiagnostic {
5390        code: "FEA_CHT_KNOWN_ANSWER".to_string(),
5391        severity,
5392        message: format!(
5393            "basis=heated_channel_conjugate_slab heated_channel_energy_residual_ratio={} conjugate_slab_flux_law_residual_ratio={} interface_temperature_continuity_ratio={} advection_shift_coverage_ratio={} coupled_interface_residual_ratio={} heat_flux_realization_residual_ratio={} interface_connectivity_coverage_ratio={} mesh_backed_interface_connectivity_ratio={} thermal_network_residual_ratio={} known_answer_coverage_ratio={}",
5394            metrics.heated_channel_energy_residual_ratio,
5395            metrics.conjugate_slab_flux_law_residual_ratio,
5396            metrics.interface_temperature_continuity_ratio,
5397            metrics.advection_shift_coverage_ratio,
5398            metrics.coupled_interface_residual_ratio,
5399            metrics.heat_flux_realization_residual_ratio,
5400            metrics.interface_connectivity_coverage_ratio,
5401            metrics.mesh_backed_interface_connectivity_ratio,
5402            metrics.thermal_network_residual_ratio,
5403            metrics.known_answer_coverage_ratio,
5404        ),
5405    }
5406}
5407
5408fn build_fsi_run_fields(
5409    domain: &runmat_analysis_core::CfdDomain,
5410    topology: &CfdDomainTopology,
5411    step_count: usize,
5412    structural_compliance_per_pa: f64,
5413    max_linear_iters: usize,
5414    tolerance: f64,
5415    residual_momentum: &[f64],
5416    residual_continuity: &[f64],
5417) -> (Vec<AnalysisField>, FsiInterfaceClosure) {
5418    let mut fields = Vec::new();
5419    let mut closure = FsiInterfaceClosure::default();
5420    let step_count = step_count.max(1);
5421    let node_count = topology.node_count;
5422
5423    for step_index in 0..step_count {
5424        let (fluid_velocity, fluid_pressure) =
5425            recover_cfd_velocity_pressure(domain, topology, step_index);
5426        let interface_step = solve_fsi_partitioned_interface(
5427            &fluid_pressure,
5428            topology,
5429            structural_compliance_per_pa,
5430            max_linear_iters,
5431            tolerance,
5432        );
5433        let displacement = interface_step.structural_displacement;
5434        closure.interface_node_count = closure.interface_node_count.max(fluid_pressure.len());
5435        closure.interface_face_count = closure
5436            .interface_face_count
5437            .max(interface_step.interface_pressure.len());
5438        closure.max_coupling_iteration_count = closure
5439            .max_coupling_iteration_count
5440            .max(interface_step.iteration_count);
5441        closure.max_pressure_feedback_residual_ratio = closure
5442            .max_pressure_feedback_residual_ratio
5443            .max(interface_step.pressure_feedback_residual_ratio);
5444        closure.max_two_way_interface_residual_ratio = closure
5445            .max_two_way_interface_residual_ratio
5446            .max(interface_step.two_way_interface_residual_ratio);
5447        closure.max_structural_traction_update_residual_ratio = closure
5448            .max_structural_traction_update_residual_ratio
5449            .max(interface_step.structural_traction_update_residual_ratio);
5450        closure.max_pressure_displacement_law_residual_ratio = closure
5451            .max_pressure_displacement_law_residual_ratio
5452            .max(interface_step.pressure_displacement_law_residual_ratio);
5453        closure.max_structural_solve_residual_ratio = closure
5454            .max_structural_solve_residual_ratio
5455            .max(interface_step.structural_solve_residual_ratio);
5456        closure.max_interface_work_energy_residual_ratio = closure
5457            .max_interface_work_energy_residual_ratio
5458            .max(interface_step.interface_work_energy_residual_ratio);
5459        closure.max_interface_work_j_per_m2 = closure
5460            .max_interface_work_j_per_m2
5461            .max(interface_step.interface_work_j_per_m2.abs());
5462        closure.max_structural_strain_energy_j_per_m2 = closure
5463            .max_structural_strain_energy_j_per_m2
5464            .max(interface_step.structural_strain_energy_j_per_m2.abs());
5465        closure.structural_coupling_edge_count = closure
5466            .structural_coupling_edge_count
5467            .max(interface_step.structural_coupling_edge_count);
5468        closure.interface_connectivity_coverage_ratio = closure
5469            .interface_connectivity_coverage_ratio
5470            .max(interface_step.interface_connectivity_coverage_ratio);
5471        closure.mesh_backed_interface_connectivity_ratio = closure
5472            .mesh_backed_interface_connectivity_ratio
5473            .max(interface_step.mesh_backed_interface_connectivity_ratio);
5474        closure.full_topology_edge_count = closure
5475            .full_topology_edge_count
5476            .max(interface_step.full_topology_edge_count);
5477        closure.full_topology_element_count = closure
5478            .full_topology_element_count
5479            .max(interface_step.full_topology_element_count);
5480        closure.interface_stiffness_pa_per_m = closure
5481            .interface_stiffness_pa_per_m
5482            .max(interface_step.interface_stiffness_pa_per_m);
5483        let fluid_normal_force = interface_step.interface_pressure.iter().sum::<f64>();
5484        let structural_normal_force = -fluid_normal_force;
5485        let force_balance_ratio = (fluid_normal_force + structural_normal_force).abs()
5486            / (fluid_normal_force.abs() + structural_normal_force.abs() + 1.0e-12);
5487        closure.force_balance_ratio = closure.force_balance_ratio.max(force_balance_ratio);
5488        let mean_pressure = if interface_step.interface_pressure.is_empty() {
5489            0.0
5490        } else {
5491            interface_step.interface_pressure.iter().sum::<f64>()
5492                / interface_step.interface_pressure.len() as f64
5493        };
5494        closure.mean_interface_pressure_pa =
5495            closure.mean_interface_pressure_pa.max(mean_pressure.abs());
5496        closure.max_traction_magnitude_pa = closure.max_traction_magnitude_pa.max(
5497            interface_step
5498                .interface_pressure
5499                .iter()
5500                .map(|pressure| pressure.abs())
5501                .fold(0.0_f64, f64::max),
5502        );
5503        let interface_displacement = interface_step.interface_displacement.clone();
5504        let displacement_transfer_residual = displacement
5505            .iter()
5506            .zip(interface_displacement.iter())
5507            .map(|(structural, interface)| (structural - interface).abs())
5508            .fold(0.0_f64, f64::max);
5509        closure.max_displacement_transfer_residual_m = closure
5510            .max_displacement_transfer_residual_m
5511            .max(displacement_transfer_residual);
5512        closure.max_interface_displacement_m = closure.max_interface_displacement_m.max(
5513            interface_displacement
5514                .chunks_exact(3)
5515                .map(|components| {
5516                    (components[0] * components[0]
5517                        + components[1] * components[1]
5518                        + components[2] * components[2])
5519                        .sqrt()
5520                })
5521                .fold(0.0_f64, f64::max),
5522        );
5523        fields.push(AnalysisField::host_f64(
5524            fea_fsi_fluid_velocity_field_id(step_index),
5525            vec![node_count, 3],
5526            fluid_velocity,
5527        ));
5528        fields.push(AnalysisField::host_f64(
5529            fea_fsi_fluid_pressure_field_id(step_index),
5530            vec![node_count],
5531            fluid_pressure.clone(),
5532        ));
5533
5534        fields.push(AnalysisField::host_f64(
5535            fea_fsi_structural_displacement_field_id(step_index),
5536            vec![node_count, 3],
5537            displacement.clone(),
5538        ));
5539        fields.push(AnalysisField::host_f64(
5540            fea_fsi_interface_displacement_field_id(step_index),
5541            vec![node_count, 3],
5542            interface_displacement,
5543        ));
5544
5545        fields.push(AnalysisField::host_f64(
5546            fea_fsi_interface_pressure_field_id(step_index),
5547            vec![interface_step.interface_pressure.len().max(1)],
5548            interface_step.interface_pressure.clone(),
5549        ));
5550        let mut traction = Vec::with_capacity(interface_step.interface_pressure.len() * 3);
5551        for pressure in &interface_step.interface_pressure {
5552            traction.extend_from_slice(&[-*pressure, 0.0, 0.0]);
5553        }
5554        fields.push(AnalysisField::host_f64(
5555            fea_fsi_interface_traction_field_id(step_index),
5556            vec![interface_step.interface_pressure.len().max(1), 3],
5557            traction,
5558        ));
5559
5560        let residual = residual_momentum
5561            .get(step_index)
5562            .copied()
5563            .unwrap_or(0.0)
5564            .max(residual_continuity.get(step_index).copied().unwrap_or(0.0))
5565            .max(interface_step.two_way_interface_residual_ratio);
5566        closure.max_interface_residual = closure.max_interface_residual.max(residual);
5567        fields.push(AnalysisField::host_f64(
5568            fea_fsi_interface_residual_field_id(step_index),
5569            vec![1],
5570            vec![residual],
5571        ));
5572        fields.push(AnalysisField::host_f64(
5573            fea_fsi_coupling_iteration_count_field_id(step_index),
5574            vec![1],
5575            vec![interface_step.iteration_count as f64],
5576        ));
5577    }
5578
5579    (fields, closure)
5580}
5581
5582#[derive(Debug, Clone)]
5583struct FsiPartitionedInterfaceStep {
5584    interface_pressure: Vec<f64>,
5585    structural_displacement: Vec<f64>,
5586    interface_displacement: Vec<f64>,
5587    iteration_count: usize,
5588    pressure_feedback_residual_ratio: f64,
5589    two_way_interface_residual_ratio: f64,
5590    structural_traction_update_residual_ratio: f64,
5591    pressure_displacement_law_residual_ratio: f64,
5592    structural_solve_residual_ratio: f64,
5593    interface_work_j_per_m2: f64,
5594    structural_strain_energy_j_per_m2: f64,
5595    interface_work_energy_residual_ratio: f64,
5596    structural_coupling_edge_count: usize,
5597    interface_connectivity_coverage_ratio: f64,
5598    mesh_backed_interface_connectivity_ratio: f64,
5599    full_topology_edge_count: usize,
5600    full_topology_element_count: usize,
5601    interface_stiffness_pa_per_m: f64,
5602}
5603
5604#[derive(Debug, Clone)]
5605struct FsiStructuralInterfaceResponse {
5606    displacement_x: Vec<f64>,
5607    reaction_pressure: Vec<f64>,
5608    residual_ratio: f64,
5609    coupling_edge_count: usize,
5610}
5611
5612fn solve_fsi_partitioned_interface(
5613    fluid_pressure: &[f64],
5614    topology: &CfdDomainTopology,
5615    structural_compliance_per_pa: f64,
5616    max_linear_iters: usize,
5617    tolerance: f64,
5618) -> FsiPartitionedInterfaceStep {
5619    let max_linear_iters = max_linear_iters.max(1);
5620    let tolerance = tolerance.max(1.0e-12);
5621    let interface_face_count = fluid_interface_face_count(topology);
5622    let face_pressure = resample_scalar_profile(
5623        &cell_centered_scalar_from_nodal(fluid_pressure),
5624        interface_face_count,
5625    );
5626    let pressure_scale = face_pressure
5627        .iter()
5628        .map(|pressure| pressure.abs())
5629        .fold(0.0_f64, f64::max)
5630        .max(1.0);
5631    let compliance = structural_compliance_per_pa.max(1.0e-18);
5632    let pressure_relaxation = 0.65_f64;
5633    let displacement_relaxation = 0.65_f64;
5634    let traction_relaxation = 0.65_f64;
5635    let interface_stiffness_pa_per_m = 1.0 / compliance;
5636    let feedback_stiffness_pa_per_m = 0.25 * interface_stiffness_pa_per_m;
5637    let structural_coupling_edges =
5638        coupled_interface_graph_edges_for_topology(topology, interface_face_count);
5639    let interface_connectivity_coverage_ratio = coupled_interface_connectivity_coverage_ratio(
5640        topology,
5641        interface_face_count,
5642        structural_coupling_edges.len(),
5643    );
5644    let mesh_backed_interface_connectivity_ratio =
5645        coupled_interface_mesh_backed_connectivity_ratio(topology, structural_coupling_edges.len());
5646    let mut interface_pressure = vec![0.0; face_pressure.len()];
5647    let mut structural_traction = vec![0.0; face_pressure.len()];
5648    let mut structural_face_displacement = vec![0.0; face_pressure.len() * 3];
5649    let mut interface_face_displacement = vec![0.0; face_pressure.len() * 3];
5650    let mut iteration_count = 0_usize;
5651    let mut pressure_feedback_residual_ratio = if face_pressure.is_empty() { 0.0 } else { 1.0 };
5652    let mut displacement_transfer_residual_ratio = 0.0_f64;
5653    let mut structural_traction_update_residual_ratio =
5654        if face_pressure.is_empty() { 0.0 } else { 1.0 };
5655    let mut structural_solve_residual_ratio = if face_pressure.is_empty() { 0.0 } else { 1.0 };
5656    let mut structural_coupling_edge_count = 0_usize;
5657
5658    for iteration in 1..=max_linear_iters {
5659        let target_pressure: Vec<f64> = face_pressure
5660            .iter()
5661            .enumerate()
5662            .map(|(face, pressure)| {
5663                let displacement = interface_face_displacement
5664                    .get(face * 3)
5665                    .copied()
5666                    .unwrap_or(0.0);
5667                *pressure - feedback_stiffness_pa_per_m * displacement
5668            })
5669            .collect();
5670        for (interface, target) in interface_pressure.iter_mut().zip(target_pressure.iter()) {
5671            *interface += pressure_relaxation * (*target - *interface);
5672        }
5673        let structural_response = solve_fsi_structural_interface_response(
5674            &interface_pressure,
5675            interface_stiffness_pa_per_m,
5676            &structural_coupling_edges,
5677        );
5678        structural_solve_residual_ratio = structural_response.residual_ratio;
5679        structural_coupling_edge_count =
5680            structural_coupling_edge_count.max(structural_response.coupling_edge_count);
5681        for (face, displacement_x) in structural_response.displacement_x.iter().enumerate() {
5682            structural_face_displacement[face * 3] = *displacement_x;
5683            structural_face_displacement[face * 3 + 1] = 0.0;
5684            structural_face_displacement[face * 3 + 2] = 0.0;
5685        }
5686        let target_structural_traction = structural_response.reaction_pressure;
5687        for (traction, target) in structural_traction
5688            .iter_mut()
5689            .zip(target_structural_traction.iter())
5690        {
5691            *traction += traction_relaxation * (*target - *traction);
5692        }
5693        for (interface, structural) in interface_face_displacement
5694            .iter_mut()
5695            .zip(structural_face_displacement.iter())
5696        {
5697            *interface += displacement_relaxation * (*structural - *interface);
5698        }
5699        let max_feedback_residual = target_pressure
5700            .iter()
5701            .zip(interface_pressure.iter())
5702            .map(|(target, interface)| (target - interface).abs())
5703            .fold(0.0_f64, f64::max);
5704        pressure_feedback_residual_ratio = max_feedback_residual / pressure_scale;
5705        let displacement_scale = structural_face_displacement
5706            .iter()
5707            .copied()
5708            .map(f64::abs)
5709            .fold(0.0_f64, f64::max)
5710            .max(1.0e-18);
5711        displacement_transfer_residual_ratio = structural_face_displacement
5712            .iter()
5713            .zip(interface_face_displacement.iter())
5714            .map(|(structural, interface)| (structural - interface).abs())
5715            .fold(0.0_f64, f64::max)
5716            / displacement_scale;
5717        structural_traction_update_residual_ratio = target_structural_traction
5718            .iter()
5719            .zip(structural_traction.iter())
5720            .map(|(target, traction)| (target - traction).abs())
5721            .fold(0.0_f64, f64::max)
5722            / pressure_scale;
5723        iteration_count = iteration;
5724        if pressure_feedback_residual_ratio <= tolerance
5725            && displacement_transfer_residual_ratio <= tolerance
5726            && structural_traction_update_residual_ratio <= tolerance
5727            && structural_solve_residual_ratio <= tolerance
5728        {
5729            break;
5730        }
5731    }
5732    let structural_response = solve_fsi_structural_interface_response(
5733        &interface_pressure,
5734        interface_stiffness_pa_per_m,
5735        &structural_coupling_edges,
5736    );
5737    structural_solve_residual_ratio = structural_solve_residual_ratio
5738        .max(structural_response.residual_ratio)
5739        .min(1.0);
5740    structural_coupling_edge_count =
5741        structural_coupling_edge_count.max(structural_response.coupling_edge_count);
5742    let pressure_displacement_law_residual_ratio = structural_response
5743        .reaction_pressure
5744        .iter()
5745        .zip(interface_pressure.iter())
5746        .map(|(reaction, pressure)| (reaction - pressure).abs() / pressure.abs().max(1.0))
5747        .fold(0.0_f64, f64::max);
5748    let interface_work_j_per_m2 = interface_pressure
5749        .iter()
5750        .zip(structural_response.displacement_x.iter())
5751        .map(|(pressure, displacement)| pressure * displacement)
5752        .sum::<f64>();
5753    let structural_strain_energy_j_per_m2 = 0.5
5754        * structural_response
5755            .reaction_pressure
5756            .iter()
5757            .zip(structural_response.displacement_x.iter())
5758            .map(|(reaction, displacement)| reaction * displacement)
5759            .sum::<f64>();
5760    let interface_work_energy_residual_ratio =
5761        (interface_work_j_per_m2 - 2.0 * structural_strain_energy_j_per_m2).abs()
5762            / (interface_work_j_per_m2.abs()
5763                + (2.0 * structural_strain_energy_j_per_m2).abs()
5764                + 1.0e-12);
5765    let interface_face_displacement_x = interface_face_displacement
5766        .chunks_exact(3)
5767        .map(|components| components[0])
5768        .collect::<Vec<_>>();
5769    let structural_displacement =
5770        vector_field_from_x_profile(&structural_response.displacement_x, fluid_pressure.len());
5771    let interface_displacement =
5772        vector_field_from_x_profile(&interface_face_displacement_x, fluid_pressure.len());
5773
5774    FsiPartitionedInterfaceStep {
5775        interface_pressure,
5776        structural_displacement,
5777        interface_displacement,
5778        iteration_count,
5779        pressure_feedback_residual_ratio,
5780        two_way_interface_residual_ratio: pressure_feedback_residual_ratio
5781            .max(displacement_transfer_residual_ratio)
5782            .max(structural_traction_update_residual_ratio)
5783            .max(structural_solve_residual_ratio),
5784        structural_traction_update_residual_ratio,
5785        pressure_displacement_law_residual_ratio,
5786        structural_solve_residual_ratio,
5787        interface_work_j_per_m2,
5788        structural_strain_energy_j_per_m2,
5789        interface_work_energy_residual_ratio,
5790        structural_coupling_edge_count,
5791        interface_connectivity_coverage_ratio,
5792        mesh_backed_interface_connectivity_ratio,
5793        full_topology_edge_count: topology.element_topology_edge_nodes.len(),
5794        full_topology_element_count: topology.element_topology_element_edges.len(),
5795        interface_stiffness_pa_per_m,
5796    }
5797}
5798
5799fn solve_fsi_structural_interface_response(
5800    pressure_load: &[f64],
5801    interface_stiffness_pa_per_m: f64,
5802    coupling_edges: &[(usize, usize)],
5803) -> FsiStructuralInterfaceResponse {
5804    let face_count = pressure_load.len();
5805    if face_count == 0 {
5806        return FsiStructuralInterfaceResponse {
5807            displacement_x: Vec::new(),
5808            reaction_pressure: Vec::new(),
5809            residual_ratio: 0.0,
5810            coupling_edge_count: 0,
5811        };
5812    }
5813
5814    let diagonal_stiffness = interface_stiffness_pa_per_m.max(1.0e-9);
5815    let coupling_stiffness = 0.20 * diagonal_stiffness;
5816    let mut operator = vec![vec![0.0; face_count]; face_count];
5817    for (row, diagonal) in operator.iter_mut().enumerate() {
5818        diagonal[row] = diagonal_stiffness;
5819    }
5820    for (left, right) in coupling_edges {
5821        operator[*left][*left] += coupling_stiffness;
5822        operator[*right][*right] += coupling_stiffness;
5823        operator[*left][*right] -= coupling_stiffness;
5824        operator[*right][*left] -= coupling_stiffness;
5825    }
5826
5827    let mut matrix = operator.clone();
5828    let mut rhs = pressure_load.to_vec();
5829    let displacement_x = solve_dense_real_system(&mut matrix, &mut rhs);
5830    let reaction_pressure = apply_dense_real_operator(&operator, &displacement_x);
5831    let pressure_scale = pressure_load
5832        .iter()
5833        .copied()
5834        .map(f64::abs)
5835        .fold(0.0_f64, f64::max)
5836        .max(1.0);
5837    let residual_ratio = reaction_pressure
5838        .iter()
5839        .zip(pressure_load.iter())
5840        .map(|(reaction, pressure)| (reaction - pressure).abs())
5841        .fold(0.0_f64, f64::max)
5842        / pressure_scale;
5843
5844    FsiStructuralInterfaceResponse {
5845        displacement_x,
5846        reaction_pressure,
5847        residual_ratio,
5848        coupling_edge_count: coupling_edges.len(),
5849    }
5850}
5851
5852fn coupled_interface_graph_edges(face_count: usize, edge_target: usize) -> Vec<(usize, usize)> {
5853    if face_count < 2 || edge_target == 0 {
5854        return Vec::new();
5855    }
5856    let complete_graph_edge_count = face_count * (face_count - 1) / 2;
5857    let edge_target = edge_target.min(complete_graph_edge_count);
5858    let mut edges = Vec::with_capacity(edge_target);
5859    for span in 1..face_count {
5860        for left in 0..face_count - span {
5861            edges.push((left, left + span));
5862            if edges.len() == edge_target {
5863                return edges;
5864            }
5865        }
5866    }
5867    edges
5868}
5869
5870fn solve_dense_real_system(matrix: &mut [Vec<f64>], rhs: &mut [f64]) -> Vec<f64> {
5871    let n = rhs.len();
5872    for pivot in 0..n {
5873        let mut pivot_row = pivot;
5874        let mut pivot_abs = matrix[pivot][pivot].abs();
5875        for (candidate, row) in matrix.iter().enumerate().skip(pivot + 1) {
5876            let candidate_abs = row[pivot].abs();
5877            if candidate_abs > pivot_abs {
5878                pivot_row = candidate;
5879                pivot_abs = candidate_abs;
5880            }
5881        }
5882        if pivot_row != pivot {
5883            matrix.swap(pivot, pivot_row);
5884            rhs.swap(pivot, pivot_row);
5885        }
5886        if pivot_abs <= 1.0e-24 {
5887            matrix[pivot][pivot] += 1.0e-9;
5888        }
5889        let pivot_value = matrix[pivot][pivot];
5890        for row in pivot + 1..n {
5891            let factor = matrix[row][pivot] / pivot_value;
5892            matrix[row][pivot] = 0.0;
5893            for col in pivot + 1..n {
5894                matrix[row][col] -= factor * matrix[pivot][col];
5895            }
5896            rhs[row] -= factor * rhs[pivot];
5897        }
5898    }
5899
5900    let mut solution = vec![0.0; n];
5901    for row in (0..n).rev() {
5902        let mut accum = rhs[row];
5903        for (col, value) in solution.iter().enumerate().skip(row + 1) {
5904            accum -= matrix[row][col] * value;
5905        }
5906        solution[row] = accum
5907            / matrix[row][row]
5908                .abs()
5909                .max(1.0e-18)
5910                .copysign(matrix[row][row]);
5911    }
5912    solution
5913}
5914
5915fn apply_dense_real_operator(matrix: &[Vec<f64>], x: &[f64]) -> Vec<f64> {
5916    matrix
5917        .iter()
5918        .map(|row| {
5919            row.iter()
5920                .zip(x.iter())
5921                .map(|(coefficient, value)| coefficient * value)
5922                .sum::<f64>()
5923        })
5924        .collect()
5925}
5926
5927fn vector_field_from_x_profile(x: &[f64], target_count: usize) -> Vec<f64> {
5928    let profile = resample_scalar_profile(x, target_count);
5929    let mut field = Vec::with_capacity(profile.len() * 3);
5930    for displacement_x in profile {
5931        field.extend_from_slice(&[displacement_x, 0.0, 0.0]);
5932    }
5933    field
5934}
5935
5936#[derive(Debug, Clone, Copy, Default)]
5937struct FsiInterfaceClosure {
5938    interface_node_count: usize,
5939    interface_face_count: usize,
5940    max_interface_residual: f64,
5941    force_balance_ratio: f64,
5942    max_displacement_transfer_residual_m: f64,
5943    max_interface_displacement_m: f64,
5944    mean_interface_pressure_pa: f64,
5945    max_traction_magnitude_pa: f64,
5946    max_coupling_iteration_count: usize,
5947    max_pressure_feedback_residual_ratio: f64,
5948    max_two_way_interface_residual_ratio: f64,
5949    max_structural_traction_update_residual_ratio: f64,
5950    max_pressure_displacement_law_residual_ratio: f64,
5951    max_structural_solve_residual_ratio: f64,
5952    max_interface_work_j_per_m2: f64,
5953    max_structural_strain_energy_j_per_m2: f64,
5954    max_interface_work_energy_residual_ratio: f64,
5955    structural_coupling_edge_count: usize,
5956    interface_connectivity_coverage_ratio: f64,
5957    mesh_backed_interface_connectivity_ratio: f64,
5958    full_topology_edge_count: usize,
5959    full_topology_element_count: usize,
5960    interface_stiffness_pa_per_m: f64,
5961}
5962
5963#[derive(Debug, Clone, Copy)]
5964struct FsiKnownAnswerMetrics {
5965    pressure_loaded_wall_displacement_law_residual_ratio: f64,
5966    interface_traction_balance_residual_ratio: f64,
5967    interface_displacement_transfer_residual_m: f64,
5968    partitioned_pressure_feedback_residual_ratio: f64,
5969    two_way_interface_residual_ratio: f64,
5970    structural_traction_update_residual_ratio: f64,
5971    structural_solve_residual_ratio: f64,
5972    interface_work_energy_residual_ratio: f64,
5973    interface_connectivity_coverage_ratio: f64,
5974    mesh_backed_interface_connectivity_ratio: f64,
5975    known_answer_coverage_ratio: f64,
5976}
5977
5978fn fsi_known_answer_metrics(closure: &FsiInterfaceClosure) -> FsiKnownAnswerMetrics {
5979    let known_answer_coverage_ratio = if closure.interface_node_count > 0
5980        && closure.interface_face_count > 0
5981        && closure.mean_interface_pressure_pa.is_finite()
5982        && closure.mean_interface_pressure_pa > 0.0
5983        && closure.max_traction_magnitude_pa.is_finite()
5984        && closure.max_traction_magnitude_pa > 0.0
5985        && closure.max_interface_displacement_m.is_finite()
5986        && closure.max_interface_displacement_m > 0.0
5987        && closure.interface_stiffness_pa_per_m.is_finite()
5988        && closure.interface_stiffness_pa_per_m > 0.0
5989        && closure
5990            .max_pressure_displacement_law_residual_ratio
5991            .is_finite()
5992        && closure.force_balance_ratio.is_finite()
5993        && closure.max_pressure_feedback_residual_ratio.is_finite()
5994        && closure.max_two_way_interface_residual_ratio.is_finite()
5995        && closure
5996            .max_structural_traction_update_residual_ratio
5997            .is_finite()
5998        && closure.max_structural_solve_residual_ratio.is_finite()
5999        && closure.max_interface_work_j_per_m2.is_finite()
6000        && closure.max_interface_work_j_per_m2 > 0.0
6001        && closure.max_structural_strain_energy_j_per_m2.is_finite()
6002        && closure.max_structural_strain_energy_j_per_m2 > 0.0
6003        && closure.max_interface_work_energy_residual_ratio.is_finite()
6004        && closure.structural_coupling_edge_count > 0
6005        && closure.interface_connectivity_coverage_ratio.is_finite()
6006        && closure.interface_connectivity_coverage_ratio >= 1.0
6007        && closure.mesh_backed_interface_connectivity_ratio.is_finite()
6008    {
6009        1.0
6010    } else {
6011        0.0
6012    };
6013
6014    FsiKnownAnswerMetrics {
6015        pressure_loaded_wall_displacement_law_residual_ratio: closure
6016            .max_pressure_displacement_law_residual_ratio,
6017        interface_traction_balance_residual_ratio: closure.force_balance_ratio,
6018        interface_displacement_transfer_residual_m: closure.max_displacement_transfer_residual_m,
6019        partitioned_pressure_feedback_residual_ratio: closure.max_pressure_feedback_residual_ratio,
6020        two_way_interface_residual_ratio: closure.max_two_way_interface_residual_ratio,
6021        structural_traction_update_residual_ratio: closure
6022            .max_structural_traction_update_residual_ratio,
6023        structural_solve_residual_ratio: closure.max_structural_solve_residual_ratio,
6024        interface_work_energy_residual_ratio: closure.max_interface_work_energy_residual_ratio,
6025        interface_connectivity_coverage_ratio: closure.interface_connectivity_coverage_ratio,
6026        mesh_backed_interface_connectivity_ratio: closure.mesh_backed_interface_connectivity_ratio,
6027        known_answer_coverage_ratio,
6028    }
6029}
6030
6031fn fsi_known_answer_diagnostic(
6032    metrics: &FsiKnownAnswerMetrics,
6033    tolerance: f64,
6034) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
6035    let severity = if metrics.pressure_loaded_wall_displacement_law_residual_ratio <= tolerance
6036        && metrics.interface_traction_balance_residual_ratio <= 1.0e-9
6037        && metrics.interface_displacement_transfer_residual_m <= 1.0e-12
6038        && metrics.partitioned_pressure_feedback_residual_ratio <= tolerance
6039        && metrics.two_way_interface_residual_ratio <= tolerance
6040        && metrics.structural_traction_update_residual_ratio <= tolerance
6041        && metrics.structural_solve_residual_ratio <= tolerance
6042        && metrics.interface_work_energy_residual_ratio <= tolerance
6043        && metrics.interface_connectivity_coverage_ratio >= 1.0
6044        && metrics.known_answer_coverage_ratio >= 1.0
6045    {
6046        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
6047    } else {
6048        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
6049    };
6050
6051    runmat_analysis_fea::diagnostics::FeaDiagnostic {
6052        code: "FEA_FSI_KNOWN_ANSWER".to_string(),
6053        severity,
6054        message: format!(
6055            "basis=pressure_loaded_wall_partitioned pressure_loaded_wall_displacement_law_residual_ratio={} interface_traction_balance_residual_ratio={} interface_displacement_transfer_residual_m={} partitioned_pressure_feedback_residual_ratio={} two_way_interface_residual_ratio={} structural_traction_update_residual_ratio={} structural_solve_residual_ratio={} interface_work_energy_residual_ratio={} interface_connectivity_coverage_ratio={} mesh_backed_interface_connectivity_ratio={} known_answer_coverage_ratio={}",
6056            metrics.pressure_loaded_wall_displacement_law_residual_ratio,
6057            metrics.interface_traction_balance_residual_ratio,
6058            metrics.interface_displacement_transfer_residual_m,
6059            metrics.partitioned_pressure_feedback_residual_ratio,
6060            metrics.two_way_interface_residual_ratio,
6061            metrics.structural_traction_update_residual_ratio,
6062            metrics.structural_solve_residual_ratio,
6063            metrics.interface_work_energy_residual_ratio,
6064            metrics.interface_connectivity_coverage_ratio,
6065            metrics.mesh_backed_interface_connectivity_ratio,
6066            metrics.known_answer_coverage_ratio,
6067        ),
6068    }
6069}
6070
6071fn fsi_structural_compliance_per_pa(model: &AnalysisModel) -> f64 {
6072    if let Some(stiffness) = fsi_interface_normal_stiffness_pa_per_m(model) {
6073        return 1.0 / stiffness.max(1.0e-18);
6074    }
6075
6076    let mean_modulus = model
6077        .materials
6078        .iter()
6079        .filter_map(|material| {
6080            let modulus = material.mechanical.youngs_modulus_pa;
6081            (modulus.is_finite() && modulus > 0.0).then_some(modulus)
6082        })
6083        .fold((0.0_f64, 0_usize), |(sum, count), modulus| {
6084            (sum + modulus, count + 1)
6085        });
6086    let youngs_modulus = if mean_modulus.1 > 0 {
6087        mean_modulus.0 / mean_modulus.1 as f64
6088    } else {
6089        200.0e9
6090    };
6091    1.0 / youngs_modulus.max(1.0e6)
6092}
6093
6094fn fsi_interface_normal_stiffness_pa_per_m(model: &AnalysisModel) -> Option<f64> {
6095    model
6096        .interfaces
6097        .iter()
6098        .find_map(|interface| match &interface.kind {
6099            AnalysisInterfaceKind::FluidStructure(fluid_structure)
6100                if fluid_structure.normal_stiffness_pa_per_m.is_finite()
6101                    && fluid_structure.normal_stiffness_pa_per_m > 0.0 =>
6102            {
6103                Some(fluid_structure.normal_stiffness_pa_per_m)
6104            }
6105            AnalysisInterfaceKind::FluidStructure(_)
6106            | AnalysisInterfaceKind::ConjugateHeatTransfer(_)
6107            | AnalysisInterfaceKind::Contact(_) => None,
6108        })
6109}
6110
6111pub fn analysis_run_transient_op(
6112    model: &AnalysisModel,
6113    backend: ComputeBackend,
6114    context: OperationContext,
6115) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6116    analysis_run_transient_with_options_op(
6117        model,
6118        backend,
6119        AnalysisTransientRunOptions::default(),
6120        context,
6121    )
6122}
6123
6124pub fn analysis_run_cfd_op(
6125    model: &AnalysisModel,
6126    backend: ComputeBackend,
6127    context: OperationContext,
6128) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6129    analysis_run_cfd_with_options_op(model, backend, AnalysisCfdRunOptions::default(), context)
6130}
6131
6132pub fn analysis_run_cfd_with_options_op(
6133    model: &AnalysisModel,
6134    backend: ComputeBackend,
6135    options: AnalysisCfdRunOptions,
6136    context: OperationContext,
6137) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6138    let _solver_context = install_fea_solver_context();
6139    let has_cfd_step = model
6140        .steps
6141        .iter()
6142        .any(|step| step.kind == AnalysisStepKind::Cfd);
6143    if !has_cfd_step {
6144        return Err(operation_error(
6145            ANALYSIS_RUN_CFD_OPERATION,
6146            ANALYSIS_RUN_CFD_OP_VERSION,
6147            &context,
6148            OperationErrorSpec {
6149                error_code: "RM.FEA.RUN_CFD.INVALID_MODEL",
6150                error_type: OperationErrorType::Validation,
6151                retryable: false,
6152                severity: OperationErrorSeverity::Error,
6153            },
6154            "FEA model must include at least one cfd step for fea.run_cfd",
6155            BTreeMap::from([
6156                ("analysis_model_id".to_string(), model.model_id.0.clone()),
6157                ("geometry_id".to_string(), model.geometry_id.clone()),
6158            ]),
6159        ));
6160    }
6161
6162    let Some(cfd_domain) = model.cfd.as_ref() else {
6163        return Err(operation_error(
6164            ANALYSIS_RUN_CFD_OPERATION,
6165            ANALYSIS_RUN_CFD_OP_VERSION,
6166            &context,
6167            OperationErrorSpec {
6168                error_code: "RM.FEA.RUN_CFD.INVALID_MODEL",
6169                error_type: OperationErrorType::Validation,
6170                retryable: false,
6171                severity: OperationErrorSeverity::Error,
6172            },
6173            "fea.run_cfd requires model.cfd to be configured",
6174            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6175        ));
6176    };
6177
6178    if !cfd_domain.enabled {
6179        return Err(operation_error(
6180            ANALYSIS_RUN_CFD_OPERATION,
6181            ANALYSIS_RUN_CFD_OP_VERSION,
6182            &context,
6183            OperationErrorSpec {
6184                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6185                error_type: OperationErrorType::Input,
6186                retryable: false,
6187                severity: OperationErrorSeverity::Error,
6188            },
6189            "fea.run_cfd requires cfd domain enabled=true",
6190            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6191        ));
6192    }
6193    if !cfd_domain.reference_density_kg_per_m3.is_finite()
6194        || cfd_domain.reference_density_kg_per_m3 <= 0.0
6195    {
6196        return Err(operation_error(
6197            ANALYSIS_RUN_CFD_OPERATION,
6198            ANALYSIS_RUN_CFD_OP_VERSION,
6199            &context,
6200            OperationErrorSpec {
6201                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6202                error_type: OperationErrorType::Input,
6203                retryable: false,
6204                severity: OperationErrorSeverity::Error,
6205            },
6206            "fea.run_cfd requires finite positive reference_density_kg_per_m3",
6207            BTreeMap::from([(
6208                "reference_density_kg_per_m3".to_string(),
6209                cfd_domain.reference_density_kg_per_m3.to_string(),
6210            )]),
6211        ));
6212    }
6213    if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
6214        return Err(operation_error(
6215            ANALYSIS_RUN_CFD_OPERATION,
6216            ANALYSIS_RUN_CFD_OP_VERSION,
6217            &context,
6218            OperationErrorSpec {
6219                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6220                error_type: OperationErrorType::Input,
6221                retryable: false,
6222                severity: OperationErrorSeverity::Error,
6223            },
6224            "fea.run_cfd requires finite positive dynamic_viscosity_pa_s",
6225            BTreeMap::from([(
6226                "dynamic_viscosity_pa_s".to_string(),
6227                cfd_domain.dynamic_viscosity_pa_s.to_string(),
6228            )]),
6229        ));
6230    }
6231    if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
6232        return Err(operation_error(
6233            ANALYSIS_RUN_CFD_OPERATION,
6234            ANALYSIS_RUN_CFD_OP_VERSION,
6235            &context,
6236            OperationErrorSpec {
6237                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6238                error_type: OperationErrorType::Input,
6239                retryable: false,
6240                severity: OperationErrorSeverity::Error,
6241            },
6242            "fea.run_cfd requires finite non-negative inlet_velocity_m_per_s",
6243            BTreeMap::from([(
6244                "inlet_velocity_m_per_s".to_string(),
6245                cfd_domain.inlet_velocity_m_per_s.to_string(),
6246            )]),
6247        ));
6248    }
6249    if !cfd_domain.turbulence_intensity.is_finite()
6250        || cfd_domain.turbulence_intensity < 0.0
6251        || cfd_domain.turbulence_intensity > 1.0
6252    {
6253        return Err(operation_error(
6254            ANALYSIS_RUN_CFD_OPERATION,
6255            ANALYSIS_RUN_CFD_OP_VERSION,
6256            &context,
6257            OperationErrorSpec {
6258                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6259                error_type: OperationErrorType::Input,
6260                retryable: false,
6261                severity: OperationErrorSeverity::Error,
6262            },
6263            "fea.run_cfd requires turbulence_intensity in [0, 1]",
6264            BTreeMap::from([(
6265                "turbulence_intensity".to_string(),
6266                cfd_domain.turbulence_intensity.to_string(),
6267            )]),
6268        ));
6269    }
6270    reject_moment_loads_for_run_family(
6271        model,
6272        ANALYSIS_RUN_CFD_OPERATION,
6273        ANALYSIS_RUN_CFD_OP_VERSION,
6274        "RM.FEA.RUN_CFD.INVALID_LOAD",
6275        "CFD",
6276        &context,
6277    )?;
6278    if let Err(detail) = validate_authored_cfd_boundary_conditions(model) {
6279        return Err(operation_error(
6280            ANALYSIS_RUN_CFD_OPERATION,
6281            ANALYSIS_RUN_CFD_OP_VERSION,
6282            &context,
6283            OperationErrorSpec {
6284                error_code: "RM.FEA.RUN_CFD.INVALID_BOUNDARY_CONDITIONS",
6285                error_type: OperationErrorType::Validation,
6286                retryable: false,
6287                severity: OperationErrorSeverity::Error,
6288            },
6289            detail.clone(),
6290            BTreeMap::from([
6291                ("analysis_model_id".to_string(), model.model_id.0.clone()),
6292                ("detail".to_string(), detail),
6293            ]),
6294        ));
6295    }
6296
6297    if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
6298        return Err(operation_error(
6299            ANALYSIS_RUN_CFD_OPERATION,
6300            ANALYSIS_RUN_CFD_OP_VERSION,
6301            &context,
6302            OperationErrorSpec {
6303                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6304                error_type: OperationErrorType::Input,
6305                retryable: false,
6306                severity: OperationErrorSeverity::Error,
6307            },
6308            "fea.run_cfd options require finite positive time_step_s",
6309            BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
6310        ));
6311    }
6312    if options.step_count == 0 {
6313        return Err(operation_error(
6314            ANALYSIS_RUN_CFD_OPERATION,
6315            ANALYSIS_RUN_CFD_OP_VERSION,
6316            &context,
6317            OperationErrorSpec {
6318                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6319                error_type: OperationErrorType::Input,
6320                retryable: false,
6321                severity: OperationErrorSeverity::Error,
6322            },
6323            "fea.run_cfd options require step_count greater than zero",
6324            BTreeMap::from([("step_count".to_string(), options.step_count.to_string())]),
6325        ));
6326    }
6327    if options.max_linear_iters == 0 {
6328        return Err(operation_error(
6329            ANALYSIS_RUN_CFD_OPERATION,
6330            ANALYSIS_RUN_CFD_OP_VERSION,
6331            &context,
6332            OperationErrorSpec {
6333                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6334                error_type: OperationErrorType::Input,
6335                retryable: false,
6336                severity: OperationErrorSeverity::Error,
6337            },
6338            "fea.run_cfd options require max_linear_iters greater than zero",
6339            BTreeMap::from([(
6340                "max_linear_iters".to_string(),
6341                options.max_linear_iters.to_string(),
6342            )]),
6343        ));
6344    }
6345    if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
6346        return Err(operation_error(
6347            ANALYSIS_RUN_CFD_OPERATION,
6348            ANALYSIS_RUN_CFD_OP_VERSION,
6349            &context,
6350            OperationErrorSpec {
6351                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6352                error_type: OperationErrorType::Input,
6353                retryable: false,
6354                severity: OperationErrorSeverity::Error,
6355            },
6356            "fea.run_cfd options require finite positive tolerance",
6357            BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
6358        ));
6359    }
6360    if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
6361        return Err(operation_error(
6362            ANALYSIS_RUN_CFD_OPERATION,
6363            ANALYSIS_RUN_CFD_OP_VERSION,
6364            &context,
6365            OperationErrorSpec {
6366                error_code: "RM.FEA.RUN_CFD.INVALID_OPTIONS",
6367                error_type: OperationErrorType::Input,
6368                retryable: false,
6369                severity: OperationErrorSeverity::Error,
6370            },
6371            "fea.run_cfd options require finite positive residual_warn_threshold",
6372            BTreeMap::from([(
6373                "residual_warn_threshold".to_string(),
6374                options.residual_warn_threshold.to_string(),
6375            )]),
6376        ));
6377    }
6378
6379    let prep_context = resolve_run_prep_context(
6380        model,
6381        options.prep_artifact_id.as_deref(),
6382        options.prep_context.clone(),
6383        ANALYSIS_RUN_CFD_OPERATION,
6384        ANALYSIS_RUN_CFD_OP_VERSION,
6385        &context,
6386    )?;
6387
6388    let solve_start = Instant::now();
6389    let mut run =
6390        solve_cfd_finite_volume_run(model, cfd_domain, backend, &options, prep_context.as_ref());
6391    let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
6392    run.diagnostics
6393        .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6394            code: "FEA_CFD_COST".to_string(),
6395            severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6396            message: format!(
6397                "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
6398                solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
6399            ),
6400        });
6401    let mut fallback_events = Vec::new();
6402    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
6403    if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
6404        fallback_events.push(
6405            "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
6406        );
6407    }
6408
6409    let flow_topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
6410    let flow_boundary_summary = CfdBoundarySummary::from_model(model, cfd_domain, 2);
6411    let flow_inlet_velocity = flow_boundary_summary.nominal_inlet_velocity_m_per_s;
6412    let reynolds_number = cfd_reynolds_number_for_velocity(cfd_domain, flow_inlet_velocity);
6413    let solve_family = match cfd_domain.solve_family {
6414        runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
6415        runmat_analysis_core::CfdSolveFamily::Transient => "transient",
6416    };
6417    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6418        code: "FEA_CFD_FLOW".to_string(),
6419        severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6420        message: format!(
6421            "density={} viscosity={} inlet_velocity={} turbulence_intensity={} reynolds_number={} solve_family={} profile_point_count={} topology_basis={} control_volume_count={} control_volume_face_count={} control_volume_internal_face_count={} control_volume_boundary_face_count={} control_volume_connectivity_coverage_ratio={} domain_length_m={} hydraulic_diameter_m={}",
6422            cfd_domain.reference_density_kg_per_m3,
6423            cfd_domain.dynamic_viscosity_pa_s,
6424            flow_inlet_velocity,
6425            cfd_domain.turbulence_intensity,
6426            reynolds_number,
6427            solve_family,
6428            cfd_domain.time_profile.len(),
6429            flow_topology.basis.as_str(),
6430            flow_topology.control_volume_count,
6431            flow_topology.control_volume_face_count,
6432            flow_topology.control_volume_internal_face_count,
6433            flow_topology.control_volume_boundary_face_count,
6434            flow_topology.control_volume_connectivity_coverage_ratio,
6435            flow_topology.domain_length_m,
6436            flow_topology.hydraulic_diameter_m,
6437        ),
6438    });
6439
6440    let max_momentum_residual = diagnostic_metric(
6441        &run.diagnostics,
6442        "FEA_CFD_RESIDUAL",
6443        "max_momentum_residual",
6444    )
6445    .unwrap_or(f64::INFINITY);
6446    let max_continuity_residual = diagnostic_metric(
6447        &run.diagnostics,
6448        "FEA_CFD_RESIDUAL",
6449        "max_continuity_residual",
6450    )
6451    .unwrap_or(f64::INFINITY);
6452    let solver_convergence = if max_momentum_residual <= options.residual_warn_threshold
6453        && max_continuity_residual <= options.residual_warn_threshold
6454    {
6455        QualityGate::Pass
6456    } else {
6457        QualityGate::Warn
6458    };
6459    let result_quality = if run.fields_are_empty()
6460        || !max_momentum_residual.is_finite()
6461        || !max_continuity_residual.is_finite()
6462    {
6463        QualityGate::Fail
6464    } else if max_momentum_residual > options.residual_warn_threshold
6465        || max_continuity_residual > options.residual_warn_threshold
6466    {
6467        QualityGate::Warn
6468    } else {
6469        QualityGate::Pass
6470    };
6471
6472    let mut quality_reasons = Vec::new();
6473    if solver_convergence == QualityGate::Warn {
6474        quality_reasons.push(QualityReason {
6475            code: QualityReasonCode::SolverNotConverged,
6476            detail: "cfd solver convergence gate is warning".to_string(),
6477        });
6478    }
6479    if result_quality == QualityGate::Warn {
6480        quality_reasons.push(QualityReason {
6481            code: QualityReasonCode::TransientResidualExceeded,
6482            detail: format!(
6483                "cfd residual exceeds threshold {}",
6484                options.residual_warn_threshold
6485            ),
6486        });
6487    }
6488    if fallback_events
6489        .iter()
6490        .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
6491    {
6492        quality_reasons.push(QualityReason {
6493            code: QualityReasonCode::SolverBackendFallback,
6494            detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
6495        });
6496    }
6497    if fallback_events.iter().any(|event| {
6498        event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
6499    }) {
6500        quality_reasons.push(QualityReason {
6501            code: QualityReasonCode::FieldPromotionFallback,
6502            detail: "field promotion fell back to host-backed values".to_string(),
6503        });
6504    }
6505
6506    let publishable = match options.quality_policy {
6507        QualityPolicy::Strict => {
6508            solver_convergence == QualityGate::Pass
6509                && result_quality == QualityGate::Pass
6510                && quality_reasons.is_empty()
6511        }
6512        QualityPolicy::Balanced => {
6513            solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
6514        }
6515        QualityPolicy::Exploratory => {
6516            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
6517        }
6518    };
6519    let run_status = if publishable {
6520        RunStatus::Publishable
6521    } else if result_quality == QualityGate::Fail {
6522        RunStatus::Rejected
6523    } else {
6524        RunStatus::Degraded
6525    };
6526    let solver_backend = run.solver_backend.clone();
6527    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
6528    let solver_host_sync_count = run.solver_host_sync_count;
6529    let solver_method = run.solver_method.clone();
6530    let selected_preconditioner = run.preconditioner.clone();
6531
6532    let result = AnalysisRunResult {
6533        run_id: storage::next_run_id(),
6534        run,
6535        render_topology: render_topology_from_prep_context(prep_context.as_ref()),
6536        modal_results: None,
6537        thermal_results: None,
6538        transient_results: None,
6539        nonlinear_results: None,
6540        electromagnetic_results: None,
6541        model_validity: QualityGate::Pass,
6542        solver_convergence,
6543        result_quality,
6544        run_status,
6545        publishable,
6546        quality_reasons,
6547        provenance: RunProvenance {
6548            backend,
6549            solver_backend,
6550            solver_device_apply_k_ratio,
6551            solver_host_sync_count,
6552            precision_mode: contracts::format_precision_mode(options.precision_mode),
6553            deterministic_mode: options.deterministic_mode,
6554            solver_method,
6555            preconditioner: selected_preconditioner,
6556            quality_policy: contracts::format_quality_policy(options.quality_policy),
6557            fallback_events,
6558        },
6559    };
6560
6561    persist_fea_run_result_with_progress(
6562        ANALYSIS_RUN_CFD_OPERATION,
6563        ANALYSIS_RUN_CFD_OP_VERSION,
6564        "RM.FEA.RUN_CFD.ARTIFACT_STORE_FAILED",
6565        &context,
6566        &result,
6567    )?;
6568
6569    Ok(OperationEnvelope::new(
6570        ANALYSIS_RUN_CFD_OPERATION,
6571        ANALYSIS_RUN_CFD_OP_VERSION,
6572        &context,
6573        result,
6574    ))
6575}
6576
6577pub fn analysis_run_thermal_op(
6578    model: &AnalysisModel,
6579    backend: ComputeBackend,
6580    context: OperationContext,
6581) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6582    analysis_run_thermal_with_options_op(
6583        model,
6584        backend,
6585        AnalysisThermalRunOptions::default(),
6586        context,
6587    )
6588}
6589
6590pub fn analysis_run_cht_op(
6591    model: &AnalysisModel,
6592    backend: ComputeBackend,
6593    context: OperationContext,
6594) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6595    analysis_run_cht_with_options_op(model, backend, AnalysisChtRunOptions::default(), context)
6596}
6597
6598pub fn analysis_run_cht_with_options_op(
6599    model: &AnalysisModel,
6600    backend: ComputeBackend,
6601    options: AnalysisChtRunOptions,
6602    context: OperationContext,
6603) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
6604    let _solver_context = install_fea_solver_context();
6605    let has_cfd_step = model
6606        .steps
6607        .iter()
6608        .any(|step| step.kind == AnalysisStepKind::Cfd);
6609    if !has_cfd_step {
6610        return Err(operation_error(
6611            ANALYSIS_RUN_CHT_OPERATION,
6612            ANALYSIS_RUN_CHT_OP_VERSION,
6613            &context,
6614            OperationErrorSpec {
6615                error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6616                error_type: OperationErrorType::Validation,
6617                retryable: false,
6618                severity: OperationErrorSeverity::Error,
6619            },
6620            "FEA model must include at least one cfd step for fea.run_cht",
6621            BTreeMap::from([
6622                ("analysis_model_id".to_string(), model.model_id.0.clone()),
6623                ("geometry_id".to_string(), model.geometry_id.clone()),
6624            ]),
6625        ));
6626    }
6627    let has_thermal_step = model
6628        .steps
6629        .iter()
6630        .any(|step| step.kind == AnalysisStepKind::Thermal);
6631    if !has_thermal_step {
6632        return Err(operation_error(
6633            ANALYSIS_RUN_CHT_OPERATION,
6634            ANALYSIS_RUN_CHT_OP_VERSION,
6635            &context,
6636            OperationErrorSpec {
6637                error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6638                error_type: OperationErrorType::Validation,
6639                retryable: false,
6640                severity: OperationErrorSeverity::Error,
6641            },
6642            "FEA model must include at least one thermal step for fea.run_cht",
6643            BTreeMap::from([
6644                ("analysis_model_id".to_string(), model.model_id.0.clone()),
6645                ("geometry_id".to_string(), model.geometry_id.clone()),
6646            ]),
6647        ));
6648    }
6649    let Some(cfd_domain) = model.cfd.as_ref() else {
6650        return Err(operation_error(
6651            ANALYSIS_RUN_CHT_OPERATION,
6652            ANALYSIS_RUN_CHT_OP_VERSION,
6653            &context,
6654            OperationErrorSpec {
6655                error_code: "RM.FEA.RUN_CHT.INVALID_MODEL",
6656                error_type: OperationErrorType::Validation,
6657                retryable: false,
6658                severity: OperationErrorSeverity::Error,
6659            },
6660            "fea.run_cht requires model.cfd to be configured",
6661            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6662        ));
6663    };
6664    if !cfd_domain.enabled {
6665        return Err(operation_error(
6666            ANALYSIS_RUN_CHT_OPERATION,
6667            ANALYSIS_RUN_CHT_OP_VERSION,
6668            &context,
6669            OperationErrorSpec {
6670                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6671                error_type: OperationErrorType::Input,
6672                retryable: false,
6673                severity: OperationErrorSeverity::Error,
6674            },
6675            "fea.run_cht requires cfd domain enabled=true",
6676            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
6677        ));
6678    }
6679    if !cfd_domain.reference_density_kg_per_m3.is_finite()
6680        || cfd_domain.reference_density_kg_per_m3 <= 0.0
6681    {
6682        return Err(operation_error(
6683            ANALYSIS_RUN_CHT_OPERATION,
6684            ANALYSIS_RUN_CHT_OP_VERSION,
6685            &context,
6686            OperationErrorSpec {
6687                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6688                error_type: OperationErrorType::Input,
6689                retryable: false,
6690                severity: OperationErrorSeverity::Error,
6691            },
6692            "fea.run_cht requires finite positive reference_density_kg_per_m3",
6693            BTreeMap::from([(
6694                "reference_density_kg_per_m3".to_string(),
6695                cfd_domain.reference_density_kg_per_m3.to_string(),
6696            )]),
6697        ));
6698    }
6699    if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
6700        return Err(operation_error(
6701            ANALYSIS_RUN_CHT_OPERATION,
6702            ANALYSIS_RUN_CHT_OP_VERSION,
6703            &context,
6704            OperationErrorSpec {
6705                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6706                error_type: OperationErrorType::Input,
6707                retryable: false,
6708                severity: OperationErrorSeverity::Error,
6709            },
6710            "fea.run_cht requires finite positive dynamic_viscosity_pa_s",
6711            BTreeMap::from([(
6712                "dynamic_viscosity_pa_s".to_string(),
6713                cfd_domain.dynamic_viscosity_pa_s.to_string(),
6714            )]),
6715        ));
6716    }
6717    if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
6718        return Err(operation_error(
6719            ANALYSIS_RUN_CHT_OPERATION,
6720            ANALYSIS_RUN_CHT_OP_VERSION,
6721            &context,
6722            OperationErrorSpec {
6723                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6724                error_type: OperationErrorType::Input,
6725                retryable: false,
6726                severity: OperationErrorSeverity::Error,
6727            },
6728            "fea.run_cht requires finite non-negative inlet_velocity_m_per_s",
6729            BTreeMap::from([(
6730                "inlet_velocity_m_per_s".to_string(),
6731                cfd_domain.inlet_velocity_m_per_s.to_string(),
6732            )]),
6733        ));
6734    }
6735    if !cfd_domain.turbulence_intensity.is_finite()
6736        || cfd_domain.turbulence_intensity < 0.0
6737        || cfd_domain.turbulence_intensity > 1.0
6738    {
6739        return Err(operation_error(
6740            ANALYSIS_RUN_CHT_OPERATION,
6741            ANALYSIS_RUN_CHT_OP_VERSION,
6742            &context,
6743            OperationErrorSpec {
6744                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6745                error_type: OperationErrorType::Input,
6746                retryable: false,
6747                severity: OperationErrorSeverity::Error,
6748            },
6749            "fea.run_cht requires turbulence_intensity in [0, 1]",
6750            BTreeMap::from([(
6751                "turbulence_intensity".to_string(),
6752                cfd_domain.turbulence_intensity.to_string(),
6753            )]),
6754        ));
6755    }
6756    reject_moment_loads_for_run_family(
6757        model,
6758        ANALYSIS_RUN_CHT_OPERATION,
6759        ANALYSIS_RUN_CHT_OP_VERSION,
6760        "RM.FEA.RUN_CHT.INVALID_LOAD",
6761        "CHT",
6762        &context,
6763    )?;
6764    if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
6765        return Err(operation_error(
6766            ANALYSIS_RUN_CHT_OPERATION,
6767            ANALYSIS_RUN_CHT_OP_VERSION,
6768            &context,
6769            OperationErrorSpec {
6770                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6771                error_type: OperationErrorType::Input,
6772                retryable: false,
6773                severity: OperationErrorSeverity::Error,
6774            },
6775            "fea.run_cht options require finite positive time_step_s",
6776            BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
6777        ));
6778    }
6779    if options.step_count == 0 || options.max_linear_iters == 0 {
6780        return Err(operation_error(
6781            ANALYSIS_RUN_CHT_OPERATION,
6782            ANALYSIS_RUN_CHT_OP_VERSION,
6783            &context,
6784            OperationErrorSpec {
6785                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6786                error_type: OperationErrorType::Input,
6787                retryable: false,
6788                severity: OperationErrorSeverity::Error,
6789            },
6790            "fea.run_cht options require step_count/max_linear_iters greater than zero",
6791            BTreeMap::new(),
6792        ));
6793    }
6794    if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
6795        return Err(operation_error(
6796            ANALYSIS_RUN_CHT_OPERATION,
6797            ANALYSIS_RUN_CHT_OP_VERSION,
6798            &context,
6799            OperationErrorSpec {
6800                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6801                error_type: OperationErrorType::Input,
6802                retryable: false,
6803                severity: OperationErrorSeverity::Error,
6804            },
6805            "fea.run_cht options require finite positive tolerance",
6806            BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
6807        ));
6808    }
6809    if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
6810        return Err(operation_error(
6811            ANALYSIS_RUN_CHT_OPERATION,
6812            ANALYSIS_RUN_CHT_OP_VERSION,
6813            &context,
6814            OperationErrorSpec {
6815                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6816                error_type: OperationErrorType::Input,
6817                retryable: false,
6818                severity: OperationErrorSeverity::Error,
6819            },
6820            "fea.run_cht options require finite positive residual_warn_threshold",
6821            BTreeMap::from([(
6822                "residual_warn_threshold".to_string(),
6823                options.residual_warn_threshold.to_string(),
6824            )]),
6825        ));
6826    }
6827
6828    let thermo_options = resolve_thermo_coupling_options(
6829        model,
6830        model_thermo_coupling_options(model),
6831        ANALYSIS_RUN_CHT_OPERATION,
6832        ANALYSIS_RUN_CHT_OP_VERSION,
6833        &context,
6834    )?;
6835    let Some(thermo_options) = thermo_options else {
6836        return Err(operation_error(
6837            ANALYSIS_RUN_CHT_OPERATION,
6838            ANALYSIS_RUN_CHT_OP_VERSION,
6839            &context,
6840            OperationErrorSpec {
6841                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6842                error_type: OperationErrorType::Input,
6843                retryable: false,
6844                severity: OperationErrorSeverity::Error,
6845            },
6846            "fea.run_cht requires model.thermo_mechanical to be configured",
6847            BTreeMap::new(),
6848        ));
6849    };
6850    if let Err((detail, metadata)) = validate_thermo_coupling_options(model, &thermo_options) {
6851        return Err(operation_error(
6852            ANALYSIS_RUN_CHT_OPERATION,
6853            ANALYSIS_RUN_CHT_OP_VERSION,
6854            &context,
6855            OperationErrorSpec {
6856                error_code: "RM.FEA.RUN_CHT.INVALID_OPTIONS",
6857                error_type: OperationErrorType::Input,
6858                retryable: false,
6859                severity: OperationErrorSeverity::Error,
6860            },
6861            detail,
6862            metadata,
6863        ));
6864    }
6865    if let Err((detail, metadata)) = validate_coupled_flow_interfaces(model, "CHT") {
6866        return Err(operation_error(
6867            ANALYSIS_RUN_CHT_OPERATION,
6868            ANALYSIS_RUN_CHT_OP_VERSION,
6869            &context,
6870            OperationErrorSpec {
6871                error_code: "RM.FEA.RUN_CHT.INVALID_INTERFACE_MAPPING",
6872                error_type: OperationErrorType::Validation,
6873                retryable: false,
6874                severity: OperationErrorSeverity::Error,
6875            },
6876            detail,
6877            metadata,
6878        ));
6879    }
6880    let applied_temperature_delta_k = thermo_options.applied_temperature_delta_k;
6881
6882    let prep_context = resolve_run_prep_context(
6883        model,
6884        options.prep_artifact_id.as_deref(),
6885        options.prep_context.clone(),
6886        ANALYSIS_RUN_CHT_OPERATION,
6887        ANALYSIS_RUN_CHT_OP_VERSION,
6888        &context,
6889    )?;
6890
6891    let solve_start = Instant::now();
6892    let thermal_run = run_thermal_with_options(
6893        model,
6894        backend,
6895        ThermalSolveOptions {
6896            step_count: options.step_count,
6897            time_step_s: options.time_step_s,
6898            residual_target: options.residual_warn_threshold,
6899            prep_context: to_fea_prep_context(
6900                prep_context.as_ref(),
6901                options.prep_calibration_profile,
6902            ),
6903            thermo_mechanical_context: to_fea_thermo_mechanical_context(Some(
6904                thermo_options.clone(),
6905            )),
6906        },
6907    )
6908    .map_err(|err| {
6909        map_fea_run_error(
6910            ANALYSIS_RUN_CHT_OPERATION,
6911            ANALYSIS_RUN_CHT_OP_VERSION,
6912            "RM.FEA.RUN_CHT.SOLVER_MODEL_INVALID",
6913            "RM.FEA.RUN_CHT.CANCELLED",
6914            model,
6915            &context,
6916            err,
6917        )
6918    })?;
6919
6920    let topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
6921    let node_count = topology.node_count;
6922    let field_step = match cfd_domain.solve_family {
6923        runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
6924        runmat_analysis_core::CfdSolveFamily::Transient => options.step_count.saturating_sub(1),
6925    };
6926    let (fluid_velocity, fluid_pressure) =
6927        recover_cfd_velocity_pressure(cfd_domain, &topology, field_step);
6928    let (cfd_residual_momentum, cfd_residual_continuity) = cfd_residual_norms(
6929        &fluid_velocity,
6930        &fluid_pressure,
6931        cfd_domain,
6932        &topology,
6933        options.step_count,
6934    );
6935    let max_cfd_momentum_residual = cfd_residual_momentum
6936        .iter()
6937        .copied()
6938        .fold(0.0_f64, f64::max);
6939    let max_cfd_continuity_residual = cfd_residual_continuity
6940        .iter()
6941        .copied()
6942        .fold(0.0_f64, f64::max);
6943    let (cht_fields, cht_interface_closure) = build_cht_run_fields(
6944        cfd_domain,
6945        &topology,
6946        &thermal_run,
6947        cht_interface_conductance_w_per_m2k(model),
6948        options.max_linear_iters,
6949        options.tolerance,
6950    );
6951    let mut run = thermal_run.run.clone();
6952    run.solver_method = "cht_conjugate_projection".to_string();
6953    run.preconditioner = "thermal_cfd_projection".to_string();
6954    run.fields.extend(cht_fields);
6955    let reynolds_number = cfd_reynolds_number(cfd_domain);
6956    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6957        code: "FEA_CFD_FLOW".to_string(),
6958        severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
6959        message: format!(
6960            "density={} viscosity={} inlet_velocity={} turbulence_intensity={} reynolds_number={} solve_family={} profile_point_count={} topology_basis={} control_volume_count={} control_volume_face_count={} control_volume_internal_face_count={} control_volume_boundary_face_count={} control_volume_connectivity_coverage_ratio={} domain_length_m={} hydraulic_diameter_m={}",
6961            cfd_domain.reference_density_kg_per_m3,
6962            cfd_domain.dynamic_viscosity_pa_s,
6963            cfd_domain.inlet_velocity_m_per_s,
6964            cfd_domain.turbulence_intensity,
6965            reynolds_number,
6966            match cfd_domain.solve_family {
6967                runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
6968                runmat_analysis_core::CfdSolveFamily::Transient => "transient",
6969            },
6970            cfd_domain.time_profile.len(),
6971            topology.basis.as_str(),
6972            topology.control_volume_count,
6973            topology.control_volume_face_count,
6974            topology.control_volume_internal_face_count,
6975            topology.control_volume_boundary_face_count,
6976            topology.control_volume_connectivity_coverage_ratio,
6977            topology.domain_length_m,
6978            topology.hydraulic_diameter_m,
6979        ),
6980    });
6981    let cfd_residual_severity = if max_cfd_momentum_residual <= options.residual_warn_threshold
6982        && max_cfd_continuity_residual <= options.residual_warn_threshold
6983    {
6984        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
6985    } else {
6986        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
6987    };
6988    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
6989        code: "FEA_CFD_RESIDUAL".to_string(),
6990        severity: cfd_residual_severity,
6991        message: format!(
6992            "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
6993            max_cfd_momentum_residual,
6994            max_cfd_continuity_residual,
6995            options.residual_warn_threshold,
6996            node_count,
6997            options.step_count,
6998        ),
6999    });
7000    run.diagnostics.push(cfd_assembly_diagnostic(
7001        &topology,
7002        cfd_domain,
7003        options.time_step_s,
7004        pressure_drop_from_nodal_pressure(&fluid_pressure),
7005        max_cfd_continuity_residual,
7006        options.residual_warn_threshold,
7007    ));
7008    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7009        code: "FEA_CHT_COUPLING".to_string(),
7010        severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7011        message: format!(
7012            "reference_temperature_k={} applied_temperature_delta_k={} step_count={} time_step_s={} authored_interface_count={}",
7013            thermal_run.reference_temperature_k,
7014            applied_temperature_delta_k,
7015            options.step_count,
7016            options.time_step_s,
7017            model.interfaces.len(),
7018        ),
7019    });
7020    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7021        code: "FEA_CHT_INTERFACE_CLOSURE".to_string(),
7022        severity: if cht_interface_closure.heat_flux_balance_ratio <= 1.0e-9
7023            && cht_interface_closure.max_energy_residual <= options.residual_warn_threshold
7024            && cht_interface_closure.max_temperature_jump_k <= 0.1
7025            && cht_interface_closure.max_thermal_transport_residual_ratio
7026                <= options.residual_warn_threshold
7027            && cht_interface_closure.max_flux_temperature_law_residual_ratio
7028                <= options.residual_warn_threshold
7029            && cht_interface_closure.max_heat_flux_realization_residual_ratio
7030                <= options.residual_warn_threshold
7031            && cht_interface_closure.max_coupled_interface_residual_ratio
7032                <= options.residual_warn_threshold
7033            && cht_interface_closure.interface_connectivity_coverage_ratio >= 1.0
7034            && cht_interface_closure.max_thermal_network_residual_ratio
7035                <= options.residual_warn_threshold
7036        {
7037            runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
7038        } else {
7039            runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
7040        },
7041        message: format!(
7042            "interface_face_count={} max_temperature_jump_k={} max_energy_residual={} heat_flux_balance_ratio={} mean_interface_heat_flux_w_per_m2={} thermal_transport_residual_ratio={} interface_temperature_continuity_ratio={} max_advection_temperature_shift_k={} interface_conductance_w_per_m2k={} flux_temperature_law_residual_ratio={} heat_flux_realization_residual_ratio={} coupled_interface_iteration_count={} coupled_interface_residual_ratio={} thermal_network_node_count={} thermal_network_edge_count={} interface_connectivity_coverage_ratio={} mesh_backed_interface_connectivity_ratio={} full_topology_edge_count={} full_topology_element_count={} thermal_network_residual_ratio={}",
7043            cht_interface_closure.interface_face_count,
7044            cht_interface_closure.max_temperature_jump_k,
7045            cht_interface_closure.max_energy_residual,
7046            cht_interface_closure.heat_flux_balance_ratio,
7047            cht_interface_closure.mean_interface_heat_flux_w_per_m2,
7048            cht_interface_closure.max_thermal_transport_residual_ratio,
7049            cht_interface_closure.interface_temperature_continuity_ratio,
7050            cht_interface_closure.max_advection_temperature_shift_k,
7051            cht_interface_closure.interface_conductance_w_per_m2k,
7052            cht_interface_closure.max_flux_temperature_law_residual_ratio,
7053            cht_interface_closure.max_heat_flux_realization_residual_ratio,
7054            cht_interface_closure.max_coupled_interface_iteration_count,
7055            cht_interface_closure.max_coupled_interface_residual_ratio,
7056            cht_interface_closure.thermal_network_node_count,
7057            cht_interface_closure.thermal_network_edge_count,
7058            cht_interface_closure.interface_connectivity_coverage_ratio,
7059            cht_interface_closure.mesh_backed_interface_connectivity_ratio,
7060            cht_interface_closure.full_topology_edge_count,
7061            cht_interface_closure.full_topology_element_count,
7062            cht_interface_closure.max_thermal_network_residual_ratio,
7063        ),
7064    });
7065    let cht_known_answer = cht_known_answer_metrics(cfd_domain, &cht_interface_closure);
7066    run.diagnostics.push(cht_known_answer_diagnostic(
7067        &cht_known_answer,
7068        options.residual_warn_threshold,
7069    ));
7070    let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
7071    run.diagnostics
7072        .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7073            code: "FEA_CHT_COST".to_string(),
7074            severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7075            message: format!(
7076                "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
7077                solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
7078            ),
7079        });
7080
7081    let mut fallback_events = Vec::new();
7082    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
7083    if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
7084        fallback_events.push(
7085            "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
7086        );
7087    }
7088
7089    let max_cht_transport_residual = cht_interface_closure
7090        .max_thermal_transport_residual_ratio
7091        .max(
7092            cht_interface_closure
7093                .max_energy_residual
7094                .max(cht_interface_closure.heat_flux_balance_ratio),
7095        );
7096    let solver_convergence = if max_cfd_momentum_residual <= options.residual_warn_threshold
7097        && max_cfd_continuity_residual <= options.residual_warn_threshold
7098        && max_cht_transport_residual <= options.residual_warn_threshold
7099    {
7100        QualityGate::Pass
7101    } else {
7102        QualityGate::Warn
7103    };
7104    let result_quality = if run.fields_are_empty()
7105        || thermal_run.temperature_snapshots.is_empty()
7106        || thermal_run.time_points_s.is_empty()
7107        || thermal_run.residual_norms.iter().any(|r| !r.is_finite())
7108        || !max_cfd_momentum_residual.is_finite()
7109        || !max_cfd_continuity_residual.is_finite()
7110        || !max_cht_transport_residual.is_finite()
7111    {
7112        QualityGate::Fail
7113    } else if max_cfd_momentum_residual > options.residual_warn_threshold
7114        || max_cfd_continuity_residual > options.residual_warn_threshold
7115        || max_cht_transport_residual > options.residual_warn_threshold
7116    {
7117        QualityGate::Warn
7118    } else {
7119        QualityGate::Pass
7120    };
7121
7122    let mut quality_reasons = Vec::new();
7123    if solver_convergence == QualityGate::Warn {
7124        quality_reasons.push(QualityReason {
7125            code: QualityReasonCode::SolverNotConverged,
7126            detail: "cht solver convergence gate is warning".to_string(),
7127        });
7128    }
7129    if max_cfd_momentum_residual > options.residual_warn_threshold
7130        || max_cfd_continuity_residual > options.residual_warn_threshold
7131    {
7132        quality_reasons.push(QualityReason {
7133            code: QualityReasonCode::SolverNotConverged,
7134            detail: format!(
7135                "cht cfd residual exceeds threshold {}",
7136                options.residual_warn_threshold,
7137            ),
7138        });
7139    }
7140    if max_cht_transport_residual > options.residual_warn_threshold {
7141        quality_reasons.push(QualityReason {
7142            code: QualityReasonCode::SolverNotConverged,
7143            detail: format!(
7144                "cht interface transport residual exceeds threshold {}",
7145                options.residual_warn_threshold
7146            ),
7147        });
7148    }
7149    if fallback_events
7150        .iter()
7151        .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
7152    {
7153        quality_reasons.push(QualityReason {
7154            code: QualityReasonCode::SolverBackendFallback,
7155            detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
7156        });
7157    }
7158    if fallback_events.iter().any(|event| {
7159        event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
7160    }) {
7161        quality_reasons.push(QualityReason {
7162            code: QualityReasonCode::FieldPromotionFallback,
7163            detail: "field promotion fell back to host-backed values".to_string(),
7164        });
7165    }
7166
7167    let publishable = match options.quality_policy {
7168        QualityPolicy::Strict => {
7169            solver_convergence == QualityGate::Pass
7170                && result_quality == QualityGate::Pass
7171                && quality_reasons.is_empty()
7172        }
7173        QualityPolicy::Balanced => {
7174            solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
7175        }
7176        QualityPolicy::Exploratory => {
7177            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
7178        }
7179    };
7180    let run_status = if publishable {
7181        RunStatus::Publishable
7182    } else if result_quality == QualityGate::Fail {
7183        RunStatus::Rejected
7184    } else {
7185        RunStatus::Degraded
7186    };
7187    let solver_backend = run.solver_backend.clone();
7188    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
7189    let solver_host_sync_count = run.solver_host_sync_count;
7190    let solver_method = run.solver_method.clone();
7191    let selected_preconditioner = run.preconditioner.clone();
7192
7193    let result = AnalysisRunResult {
7194        run_id: storage::next_run_id(),
7195        run,
7196        render_topology: render_topology_from_prep_context(prep_context.as_ref()),
7197        modal_results: None,
7198        thermal_results: Some(ThermalResultsData {
7199            thermal_payload_version: "thermal_results/v1".to_string(),
7200            time_points_s: thermal_run.time_points_s,
7201            temperature_snapshots: thermal_run.temperature_snapshots,
7202            temperature_gradient_snapshots: thermal_run.temperature_gradient_snapshots,
7203            heat_flux_snapshots: thermal_run.heat_flux_snapshots,
7204            heat_source_snapshots: thermal_run.heat_source_snapshots,
7205            boundary_heat_flux_snapshots: thermal_run.boundary_heat_flux_snapshots,
7206            residual_norms: thermal_run.residual_norms,
7207            reference_temperature_k: thermal_run.reference_temperature_k,
7208        }),
7209        transient_results: None,
7210        nonlinear_results: None,
7211        electromagnetic_results: None,
7212        model_validity: QualityGate::Pass,
7213        solver_convergence,
7214        result_quality,
7215        run_status,
7216        publishable,
7217        quality_reasons,
7218        provenance: RunProvenance {
7219            backend,
7220            solver_backend,
7221            solver_device_apply_k_ratio,
7222            solver_host_sync_count,
7223            precision_mode: contracts::format_precision_mode(options.precision_mode),
7224            deterministic_mode: options.deterministic_mode,
7225            solver_method,
7226            preconditioner: selected_preconditioner,
7227            quality_policy: contracts::format_quality_policy(options.quality_policy),
7228            fallback_events,
7229        },
7230    };
7231
7232    persist_fea_run_result_with_progress(
7233        ANALYSIS_RUN_CHT_OPERATION,
7234        ANALYSIS_RUN_CHT_OP_VERSION,
7235        "RM.FEA.RUN_CHT.ARTIFACT_STORE_FAILED",
7236        &context,
7237        &result,
7238    )?;
7239
7240    Ok(OperationEnvelope::new(
7241        ANALYSIS_RUN_CHT_OPERATION,
7242        ANALYSIS_RUN_CHT_OP_VERSION,
7243        &context,
7244        result,
7245    ))
7246}
7247
7248pub fn analysis_run_fsi_op(
7249    model: &AnalysisModel,
7250    backend: ComputeBackend,
7251    context: OperationContext,
7252) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7253    analysis_run_fsi_with_options_op(model, backend, AnalysisFsiRunOptions::default(), context)
7254}
7255
7256pub fn analysis_run_fsi_with_options_op(
7257    model: &AnalysisModel,
7258    backend: ComputeBackend,
7259    options: AnalysisFsiRunOptions,
7260    context: OperationContext,
7261) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7262    let _solver_context = install_fea_solver_context();
7263    let has_cfd_step = model
7264        .steps
7265        .iter()
7266        .any(|step| step.kind == AnalysisStepKind::Cfd);
7267    if !has_cfd_step {
7268        return Err(operation_error(
7269            ANALYSIS_RUN_FSI_OPERATION,
7270            ANALYSIS_RUN_FSI_OP_VERSION,
7271            &context,
7272            OperationErrorSpec {
7273                error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7274                error_type: OperationErrorType::Validation,
7275                retryable: false,
7276                severity: OperationErrorSeverity::Error,
7277            },
7278            "FEA model must include at least one cfd step for fea.run_fsi",
7279            BTreeMap::from([
7280                ("analysis_model_id".to_string(), model.model_id.0.clone()),
7281                ("geometry_id".to_string(), model.geometry_id.clone()),
7282            ]),
7283        ));
7284    }
7285    let has_transient_step = model
7286        .steps
7287        .iter()
7288        .any(|step| step.kind == AnalysisStepKind::Transient);
7289    if !has_transient_step {
7290        return Err(operation_error(
7291            ANALYSIS_RUN_FSI_OPERATION,
7292            ANALYSIS_RUN_FSI_OP_VERSION,
7293            &context,
7294            OperationErrorSpec {
7295                error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7296                error_type: OperationErrorType::Validation,
7297                retryable: false,
7298                severity: OperationErrorSeverity::Error,
7299            },
7300            "FEA model must include at least one transient step for fea.run_fsi",
7301            BTreeMap::from([
7302                ("analysis_model_id".to_string(), model.model_id.0.clone()),
7303                ("geometry_id".to_string(), model.geometry_id.clone()),
7304            ]),
7305        ));
7306    }
7307    let Some(cfd_domain) = model.cfd.as_ref() else {
7308        return Err(operation_error(
7309            ANALYSIS_RUN_FSI_OPERATION,
7310            ANALYSIS_RUN_FSI_OP_VERSION,
7311            &context,
7312            OperationErrorSpec {
7313                error_code: "RM.FEA.RUN_FSI.INVALID_MODEL",
7314                error_type: OperationErrorType::Validation,
7315                retryable: false,
7316                severity: OperationErrorSeverity::Error,
7317            },
7318            "fea.run_fsi requires model.cfd to be configured",
7319            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
7320        ));
7321    };
7322    if !cfd_domain.enabled {
7323        return Err(operation_error(
7324            ANALYSIS_RUN_FSI_OPERATION,
7325            ANALYSIS_RUN_FSI_OP_VERSION,
7326            &context,
7327            OperationErrorSpec {
7328                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7329                error_type: OperationErrorType::Input,
7330                retryable: false,
7331                severity: OperationErrorSeverity::Error,
7332            },
7333            "fea.run_fsi requires cfd domain enabled=true",
7334            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
7335        ));
7336    }
7337    if !cfd_domain.reference_density_kg_per_m3.is_finite()
7338        || cfd_domain.reference_density_kg_per_m3 <= 0.0
7339    {
7340        return Err(operation_error(
7341            ANALYSIS_RUN_FSI_OPERATION,
7342            ANALYSIS_RUN_FSI_OP_VERSION,
7343            &context,
7344            OperationErrorSpec {
7345                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7346                error_type: OperationErrorType::Input,
7347                retryable: false,
7348                severity: OperationErrorSeverity::Error,
7349            },
7350            "fea.run_fsi requires finite positive reference_density_kg_per_m3",
7351            BTreeMap::from([(
7352                "reference_density_kg_per_m3".to_string(),
7353                cfd_domain.reference_density_kg_per_m3.to_string(),
7354            )]),
7355        ));
7356    }
7357    if !cfd_domain.dynamic_viscosity_pa_s.is_finite() || cfd_domain.dynamic_viscosity_pa_s <= 0.0 {
7358        return Err(operation_error(
7359            ANALYSIS_RUN_FSI_OPERATION,
7360            ANALYSIS_RUN_FSI_OP_VERSION,
7361            &context,
7362            OperationErrorSpec {
7363                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7364                error_type: OperationErrorType::Input,
7365                retryable: false,
7366                severity: OperationErrorSeverity::Error,
7367            },
7368            "fea.run_fsi requires finite positive dynamic_viscosity_pa_s",
7369            BTreeMap::from([(
7370                "dynamic_viscosity_pa_s".to_string(),
7371                cfd_domain.dynamic_viscosity_pa_s.to_string(),
7372            )]),
7373        ));
7374    }
7375    if !cfd_domain.inlet_velocity_m_per_s.is_finite() || cfd_domain.inlet_velocity_m_per_s < 0.0 {
7376        return Err(operation_error(
7377            ANALYSIS_RUN_FSI_OPERATION,
7378            ANALYSIS_RUN_FSI_OP_VERSION,
7379            &context,
7380            OperationErrorSpec {
7381                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7382                error_type: OperationErrorType::Input,
7383                retryable: false,
7384                severity: OperationErrorSeverity::Error,
7385            },
7386            "fea.run_fsi requires finite non-negative inlet_velocity_m_per_s",
7387            BTreeMap::from([(
7388                "inlet_velocity_m_per_s".to_string(),
7389                cfd_domain.inlet_velocity_m_per_s.to_string(),
7390            )]),
7391        ));
7392    }
7393    if !cfd_domain.turbulence_intensity.is_finite()
7394        || cfd_domain.turbulence_intensity < 0.0
7395        || cfd_domain.turbulence_intensity > 1.0
7396    {
7397        return Err(operation_error(
7398            ANALYSIS_RUN_FSI_OPERATION,
7399            ANALYSIS_RUN_FSI_OP_VERSION,
7400            &context,
7401            OperationErrorSpec {
7402                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7403                error_type: OperationErrorType::Input,
7404                retryable: false,
7405                severity: OperationErrorSeverity::Error,
7406            },
7407            "fea.run_fsi requires turbulence_intensity in [0, 1]",
7408            BTreeMap::from([(
7409                "turbulence_intensity".to_string(),
7410                cfd_domain.turbulence_intensity.to_string(),
7411            )]),
7412        ));
7413    }
7414    reject_moment_loads_for_run_family(
7415        model,
7416        ANALYSIS_RUN_FSI_OPERATION,
7417        ANALYSIS_RUN_FSI_OP_VERSION,
7418        "RM.FEA.RUN_FSI.INVALID_LOAD",
7419        "FSI",
7420        &context,
7421    )?;
7422    if !options.time_step_s.is_finite() || options.time_step_s <= 0.0 {
7423        return Err(operation_error(
7424            ANALYSIS_RUN_FSI_OPERATION,
7425            ANALYSIS_RUN_FSI_OP_VERSION,
7426            &context,
7427            OperationErrorSpec {
7428                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7429                error_type: OperationErrorType::Input,
7430                retryable: false,
7431                severity: OperationErrorSeverity::Error,
7432            },
7433            "fea.run_fsi options require finite positive time_step_s",
7434            BTreeMap::from([("time_step_s".to_string(), options.time_step_s.to_string())]),
7435        ));
7436    }
7437    if options.step_count == 0 || options.max_linear_iters == 0 {
7438        return Err(operation_error(
7439            ANALYSIS_RUN_FSI_OPERATION,
7440            ANALYSIS_RUN_FSI_OP_VERSION,
7441            &context,
7442            OperationErrorSpec {
7443                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7444                error_type: OperationErrorType::Input,
7445                retryable: false,
7446                severity: OperationErrorSeverity::Error,
7447            },
7448            "fea.run_fsi options require step_count/max_linear_iters greater than zero",
7449            BTreeMap::new(),
7450        ));
7451    }
7452    if !options.tolerance.is_finite() || options.tolerance <= 0.0 {
7453        return Err(operation_error(
7454            ANALYSIS_RUN_FSI_OPERATION,
7455            ANALYSIS_RUN_FSI_OP_VERSION,
7456            &context,
7457            OperationErrorSpec {
7458                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7459                error_type: OperationErrorType::Input,
7460                retryable: false,
7461                severity: OperationErrorSeverity::Error,
7462            },
7463            "fea.run_fsi options require finite positive tolerance",
7464            BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
7465        ));
7466    }
7467    if !options.residual_warn_threshold.is_finite() || options.residual_warn_threshold <= 0.0 {
7468        return Err(operation_error(
7469            ANALYSIS_RUN_FSI_OPERATION,
7470            ANALYSIS_RUN_FSI_OP_VERSION,
7471            &context,
7472            OperationErrorSpec {
7473                error_code: "RM.FEA.RUN_FSI.INVALID_OPTIONS",
7474                error_type: OperationErrorType::Input,
7475                retryable: false,
7476                severity: OperationErrorSeverity::Error,
7477            },
7478            "fea.run_fsi options require finite positive residual_warn_threshold",
7479            BTreeMap::from([(
7480                "residual_warn_threshold".to_string(),
7481                options.residual_warn_threshold.to_string(),
7482            )]),
7483        ));
7484    }
7485    if let Err((detail, metadata)) = validate_coupled_flow_interfaces(model, "FSI") {
7486        return Err(operation_error(
7487            ANALYSIS_RUN_FSI_OPERATION,
7488            ANALYSIS_RUN_FSI_OP_VERSION,
7489            &context,
7490            OperationErrorSpec {
7491                error_code: "RM.FEA.RUN_FSI.INVALID_INTERFACE_MAPPING",
7492                error_type: OperationErrorType::Validation,
7493                retryable: false,
7494                severity: OperationErrorSeverity::Error,
7495            },
7496            detail,
7497            metadata,
7498        ));
7499    }
7500
7501    let prep_context = resolve_run_prep_context(
7502        model,
7503        options.prep_artifact_id.as_deref(),
7504        options.prep_context.clone(),
7505        ANALYSIS_RUN_FSI_OPERATION,
7506        ANALYSIS_RUN_FSI_OP_VERSION,
7507        &context,
7508    )?;
7509
7510    let solve_start = Instant::now();
7511    let topology = CfdDomainTopology::from_model(model, prep_context.as_ref());
7512    let node_count = topology.node_count;
7513    let field_step = match cfd_domain.solve_family {
7514        runmat_analysis_core::CfdSolveFamily::SteadyState => 0,
7515        runmat_analysis_core::CfdSolveFamily::Transient => options.step_count.saturating_sub(1),
7516    };
7517    let (fluid_velocity, fluid_pressure) =
7518        recover_cfd_velocity_pressure(cfd_domain, &topology, field_step);
7519    let (cfd_residual_momentum, cfd_residual_continuity) = cfd_residual_norms(
7520        &fluid_velocity,
7521        &fluid_pressure,
7522        cfd_domain,
7523        &topology,
7524        options.step_count,
7525    );
7526    let max_cfd_momentum_residual = cfd_residual_momentum
7527        .iter()
7528        .copied()
7529        .fold(0.0_f64, f64::max);
7530    let max_cfd_continuity_residual = cfd_residual_continuity
7531        .iter()
7532        .copied()
7533        .fold(0.0_f64, f64::max);
7534    let structural_compliance_per_pa = fsi_structural_compliance_per_pa(model);
7535    let (fsi_fields, fsi_interface_closure) = build_fsi_run_fields(
7536        cfd_domain,
7537        &topology,
7538        options.step_count,
7539        structural_compliance_per_pa,
7540        options.max_linear_iters,
7541        options.tolerance,
7542        &cfd_residual_momentum,
7543        &cfd_residual_continuity,
7544    );
7545    let max_interface_residual = fsi_interface_closure.max_interface_residual;
7546    let mut run = FeaRunResult {
7547        backend,
7548        solver_backend: "cpu_reference".to_string(),
7549        solver_device_apply_k_ratio: 0.0,
7550        solver_method: "fsi_partitioned_projection".to_string(),
7551        preconditioner: "interface_relaxation".to_string(),
7552        solver_host_sync_count: 0,
7553        diagnostics: Vec::new(),
7554        fields: fsi_fields,
7555    };
7556    let reynolds_number = cfd_reynolds_number(cfd_domain);
7557    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7558        code: "FEA_CFD_FLOW".to_string(),
7559        severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7560        message: format!(
7561            "density={} viscosity={} inlet_velocity={} turbulence_intensity={} reynolds_number={} solve_family={} profile_point_count={} topology_basis={} control_volume_count={} control_volume_face_count={} control_volume_internal_face_count={} control_volume_boundary_face_count={} control_volume_connectivity_coverage_ratio={} domain_length_m={} hydraulic_diameter_m={}",
7562            cfd_domain.reference_density_kg_per_m3,
7563            cfd_domain.dynamic_viscosity_pa_s,
7564            cfd_domain.inlet_velocity_m_per_s,
7565            cfd_domain.turbulence_intensity,
7566            reynolds_number,
7567            match cfd_domain.solve_family {
7568                runmat_analysis_core::CfdSolveFamily::SteadyState => "steady_state",
7569                runmat_analysis_core::CfdSolveFamily::Transient => "transient",
7570            },
7571            cfd_domain.time_profile.len(),
7572            topology.basis.as_str(),
7573            topology.control_volume_count,
7574            topology.control_volume_face_count,
7575            topology.control_volume_internal_face_count,
7576            topology.control_volume_boundary_face_count,
7577            topology.control_volume_connectivity_coverage_ratio,
7578            topology.domain_length_m,
7579            topology.hydraulic_diameter_m,
7580        ),
7581    });
7582    let residual_severity = if max_interface_residual <= options.residual_warn_threshold {
7583        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
7584    } else {
7585        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
7586    };
7587    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7588        code: "FEA_CFD_RESIDUAL".to_string(),
7589        severity: residual_severity,
7590        message: format!(
7591            "max_momentum_residual={} max_continuity_residual={} residual_warn_threshold={} cfd_node_count={} cfd_step_count={}",
7592            max_cfd_momentum_residual,
7593            max_cfd_continuity_residual,
7594            options.residual_warn_threshold,
7595            node_count,
7596            options.step_count,
7597        ),
7598    });
7599    run.diagnostics.push(cfd_assembly_diagnostic(
7600        &topology,
7601        cfd_domain,
7602        options.time_step_s,
7603        pressure_drop_from_nodal_pressure(&fluid_pressure),
7604        max_cfd_continuity_residual,
7605        options.residual_warn_threshold,
7606    ));
7607    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7608        code: "FEA_FSI_INTERFACE_RESIDUAL".to_string(),
7609        severity: residual_severity,
7610        message: format!(
7611            "max_interface_residual={} structural_compliance_per_pa={} residual_warn_threshold={} interface_node_count={} interface_face_count={}",
7612            max_interface_residual,
7613            structural_compliance_per_pa,
7614            options.residual_warn_threshold,
7615            fsi_interface_closure.interface_node_count,
7616            fsi_interface_closure.interface_face_count,
7617        ),
7618    });
7619    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7620        code: "FEA_FSI_INTERFACE_CLOSURE".to_string(),
7621        severity: if fsi_interface_closure.force_balance_ratio <= 1.0e-9
7622            && fsi_interface_closure.max_displacement_transfer_residual_m <= 1.0e-12
7623            && fsi_interface_closure.max_pressure_feedback_residual_ratio <= options.tolerance
7624            && fsi_interface_closure.max_two_way_interface_residual_ratio <= options.tolerance
7625            && fsi_interface_closure.max_structural_traction_update_residual_ratio
7626                <= options.tolerance
7627            && fsi_interface_closure.max_pressure_displacement_law_residual_ratio
7628                <= options.tolerance
7629            && fsi_interface_closure.max_structural_solve_residual_ratio <= options.tolerance
7630            && fsi_interface_closure.max_interface_work_energy_residual_ratio <= options.tolerance
7631            && fsi_interface_closure.structural_coupling_edge_count > 0
7632            && fsi_interface_closure.interface_connectivity_coverage_ratio >= 1.0
7633            && fsi_interface_closure.max_interface_residual <= options.residual_warn_threshold
7634        {
7635            runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
7636        } else {
7637            runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
7638        },
7639        message: format!(
7640            "interface_node_count={} interface_face_count={} max_interface_residual={} force_balance_ratio={} max_displacement_transfer_residual_m={} max_interface_displacement_m={} mean_interface_pressure_pa={} max_traction_magnitude_pa={} max_coupling_iteration_count={} pressure_feedback_residual_ratio={} two_way_interface_residual_ratio={} structural_traction_update_residual_ratio={} pressure_displacement_law_residual_ratio={} structural_solve_residual_ratio={} interface_work_j_per_m2={} structural_strain_energy_j_per_m2={} interface_work_energy_residual_ratio={} structural_coupling_edge_count={} interface_connectivity_coverage_ratio={} mesh_backed_interface_connectivity_ratio={} full_topology_edge_count={} full_topology_element_count={} interface_stiffness_pa_per_m={}",
7641            fsi_interface_closure.interface_node_count,
7642            fsi_interface_closure.interface_face_count,
7643            fsi_interface_closure.max_interface_residual,
7644            fsi_interface_closure.force_balance_ratio,
7645            fsi_interface_closure.max_displacement_transfer_residual_m,
7646            fsi_interface_closure.max_interface_displacement_m,
7647            fsi_interface_closure.mean_interface_pressure_pa,
7648            fsi_interface_closure.max_traction_magnitude_pa,
7649            fsi_interface_closure.max_coupling_iteration_count,
7650            fsi_interface_closure.max_pressure_feedback_residual_ratio,
7651            fsi_interface_closure.max_two_way_interface_residual_ratio,
7652            fsi_interface_closure.max_structural_traction_update_residual_ratio,
7653            fsi_interface_closure.max_pressure_displacement_law_residual_ratio,
7654            fsi_interface_closure.max_structural_solve_residual_ratio,
7655            fsi_interface_closure.max_interface_work_j_per_m2,
7656            fsi_interface_closure.max_structural_strain_energy_j_per_m2,
7657            fsi_interface_closure.max_interface_work_energy_residual_ratio,
7658            fsi_interface_closure.structural_coupling_edge_count,
7659            fsi_interface_closure.interface_connectivity_coverage_ratio,
7660            fsi_interface_closure.mesh_backed_interface_connectivity_ratio,
7661            fsi_interface_closure.full_topology_edge_count,
7662            fsi_interface_closure.full_topology_element_count,
7663            fsi_interface_closure.interface_stiffness_pa_per_m,
7664        ),
7665    });
7666    let fsi_known_answer = fsi_known_answer_metrics(&fsi_interface_closure);
7667    run.diagnostics.push(fsi_known_answer_diagnostic(
7668        &fsi_known_answer,
7669        options.tolerance,
7670    ));
7671    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7672        code: "FEA_FSI_COUPLING".to_string(),
7673        severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7674        message: format!(
7675            "step_count={} time_step_s={} structural_step_count={} cfd_profile_point_count={} authored_interface_count={} interface_node_count={} interface_face_count={} max_linear_iters={} tolerance={}",
7676            options.step_count,
7677            options.time_step_s,
7678            model
7679                .steps
7680                .iter()
7681                .filter(|step| step.kind == AnalysisStepKind::Transient)
7682                .count(),
7683            cfd_domain.time_profile.len(),
7684            model.interfaces.len(),
7685            fsi_interface_closure.interface_node_count,
7686            fsi_interface_closure.interface_face_count,
7687            options.max_linear_iters,
7688            options.tolerance,
7689        ),
7690    });
7691    let solve_ms = solve_start.elapsed().as_secs_f64() * 1000.0;
7692    run.diagnostics
7693        .push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
7694            code: "FEA_FSI_COST".to_string(),
7695            severity: runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info,
7696            message: format!(
7697                "solve_ms={} step_count={} max_linear_iters={} tolerance={}",
7698                solve_ms, options.step_count, options.max_linear_iters, options.tolerance,
7699            ),
7700        });
7701
7702    let mut fallback_events = Vec::new();
7703    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
7704    if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
7705        fallback_events.push(
7706            "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
7707        );
7708    }
7709
7710    let solver_convergence = if max_interface_residual <= options.residual_warn_threshold {
7711        QualityGate::Pass
7712    } else {
7713        QualityGate::Warn
7714    };
7715    let result_quality = if run.fields_are_empty()
7716        || !max_cfd_momentum_residual.is_finite()
7717        || !max_cfd_continuity_residual.is_finite()
7718        || !max_interface_residual.is_finite()
7719    {
7720        QualityGate::Fail
7721    } else if max_interface_residual > options.residual_warn_threshold {
7722        QualityGate::Warn
7723    } else {
7724        QualityGate::Pass
7725    };
7726
7727    let mut quality_reasons = Vec::new();
7728    if solver_convergence == QualityGate::Warn {
7729        quality_reasons.push(QualityReason {
7730            code: QualityReasonCode::SolverNotConverged,
7731            detail: "fsi solver convergence gate is warning".to_string(),
7732        });
7733    }
7734    if max_interface_residual > options.residual_warn_threshold {
7735        quality_reasons.push(QualityReason {
7736            code: QualityReasonCode::SolverNotConverged,
7737            detail: format!(
7738                "fsi interface residual exceeds threshold {}",
7739                options.residual_warn_threshold
7740            ),
7741        });
7742    }
7743    if fallback_events
7744        .iter()
7745        .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
7746    {
7747        quality_reasons.push(QualityReason {
7748            code: QualityReasonCode::SolverBackendFallback,
7749            detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
7750        });
7751    }
7752    if fallback_events.iter().any(|event| {
7753        event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
7754    }) {
7755        quality_reasons.push(QualityReason {
7756            code: QualityReasonCode::FieldPromotionFallback,
7757            detail: "field promotion fell back to host-backed values".to_string(),
7758        });
7759    }
7760
7761    let publishable = match options.quality_policy {
7762        QualityPolicy::Strict => {
7763            solver_convergence == QualityGate::Pass
7764                && result_quality == QualityGate::Pass
7765                && quality_reasons.is_empty()
7766        }
7767        QualityPolicy::Balanced => {
7768            solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
7769        }
7770        QualityPolicy::Exploratory => {
7771            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
7772        }
7773    };
7774    let run_status = if publishable {
7775        RunStatus::Publishable
7776    } else if result_quality == QualityGate::Fail {
7777        RunStatus::Rejected
7778    } else {
7779        RunStatus::Degraded
7780    };
7781    let solver_backend = run.solver_backend.clone();
7782    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
7783    let solver_host_sync_count = run.solver_host_sync_count;
7784    let solver_method = run.solver_method.clone();
7785    let selected_preconditioner = run.preconditioner.clone();
7786
7787    let result = AnalysisRunResult {
7788        run_id: storage::next_run_id(),
7789        run,
7790        render_topology: render_topology_from_prep_context(prep_context.as_ref()),
7791        modal_results: None,
7792        thermal_results: None,
7793        transient_results: None,
7794        nonlinear_results: None,
7795        electromagnetic_results: None,
7796        model_validity: QualityGate::Pass,
7797        solver_convergence,
7798        result_quality,
7799        run_status,
7800        publishable,
7801        quality_reasons,
7802        provenance: RunProvenance {
7803            backend,
7804            solver_backend,
7805            solver_device_apply_k_ratio,
7806            solver_host_sync_count,
7807            precision_mode: contracts::format_precision_mode(options.precision_mode),
7808            deterministic_mode: options.deterministic_mode,
7809            solver_method,
7810            preconditioner: selected_preconditioner,
7811            quality_policy: contracts::format_quality_policy(options.quality_policy),
7812            fallback_events,
7813        },
7814    };
7815
7816    persist_fea_run_result_with_progress(
7817        ANALYSIS_RUN_FSI_OPERATION,
7818        ANALYSIS_RUN_FSI_OP_VERSION,
7819        "RM.FEA.RUN_FSI.ARTIFACT_STORE_FAILED",
7820        &context,
7821        &result,
7822    )?;
7823
7824    Ok(OperationEnvelope::new(
7825        ANALYSIS_RUN_FSI_OPERATION,
7826        ANALYSIS_RUN_FSI_OP_VERSION,
7827        &context,
7828        result,
7829    ))
7830}
7831
7832pub fn analysis_run_thermal_with_options_op(
7833    model: &AnalysisModel,
7834    backend: ComputeBackend,
7835    options: AnalysisThermalRunOptions,
7836    context: OperationContext,
7837) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
7838    let _solver_context = install_fea_solver_context();
7839    let has_thermal_step = model
7840        .steps
7841        .iter()
7842        .any(|step| step.kind == AnalysisStepKind::Thermal);
7843    if !has_thermal_step {
7844        return Err(operation_error(
7845            ANALYSIS_RUN_THERMAL_OPERATION,
7846            ANALYSIS_RUN_THERMAL_OP_VERSION,
7847            &context,
7848            OperationErrorSpec {
7849                error_code: "RM.FEA.RUN_THERMAL.INVALID_MODEL",
7850                error_type: OperationErrorType::Validation,
7851                retryable: false,
7852                severity: OperationErrorSeverity::Error,
7853            },
7854            "FEA model must include at least one thermal step for fea.run_thermal",
7855            BTreeMap::from([
7856                ("analysis_model_id".to_string(), model.model_id.0.clone()),
7857                ("geometry_id".to_string(), model.geometry_id.clone()),
7858            ]),
7859        ));
7860    }
7861    validate_thermal_run_model(model, &context)?;
7862
7863    let thermo_options = resolve_thermo_coupling_options(
7864        model,
7865        model_thermo_coupling_options(model),
7866        ANALYSIS_RUN_THERMAL_OPERATION,
7867        ANALYSIS_RUN_THERMAL_OP_VERSION,
7868        &context,
7869    )?;
7870    let Some(thermo_options) = thermo_options else {
7871        return Err(operation_error(
7872            ANALYSIS_RUN_THERMAL_OPERATION,
7873            ANALYSIS_RUN_THERMAL_OP_VERSION,
7874            &context,
7875            OperationErrorSpec {
7876                error_code: "RM.FEA.RUN_THERMAL.INVALID_OPTIONS",
7877                error_type: OperationErrorType::Input,
7878                retryable: false,
7879                severity: OperationErrorSeverity::Error,
7880            },
7881            "fea.run_thermal requires model.thermo_mechanical to be configured",
7882            BTreeMap::new(),
7883        ));
7884    };
7885    if let Err((detail, metadata)) = validate_thermo_coupling_options(model, &thermo_options) {
7886        return Err(operation_error(
7887            ANALYSIS_RUN_THERMAL_OPERATION,
7888            ANALYSIS_RUN_THERMAL_OP_VERSION,
7889            &context,
7890            OperationErrorSpec {
7891                error_code: "RM.FEA.RUN_THERMAL.INVALID_OPTIONS",
7892                error_type: OperationErrorType::Input,
7893                retryable: false,
7894                severity: OperationErrorSeverity::Error,
7895            },
7896            detail,
7897            metadata,
7898        ));
7899    }
7900
7901    let prep_context = resolve_run_prep_context(
7902        model,
7903        options.prep_artifact_id.as_deref(),
7904        options.prep_context.clone(),
7905        ANALYSIS_RUN_THERMAL_OPERATION,
7906        ANALYSIS_RUN_THERMAL_OP_VERSION,
7907        &context,
7908    )?;
7909
7910    let thermal_run = run_thermal_with_options(
7911        model,
7912        backend,
7913        ThermalSolveOptions {
7914            step_count: options.step_count,
7915            time_step_s: options.time_step_s,
7916            residual_target: options.residual_warn_threshold,
7917            prep_context: to_fea_prep_context(
7918                prep_context.as_ref(),
7919                options.prep_calibration_profile,
7920            ),
7921            thermo_mechanical_context: to_fea_thermo_mechanical_context(Some(thermo_options)),
7922        },
7923    )
7924    .map_err(|err| {
7925        map_fea_run_error(
7926            ANALYSIS_RUN_THERMAL_OPERATION,
7927            ANALYSIS_RUN_THERMAL_OP_VERSION,
7928            "RM.FEA.RUN_THERMAL.SOLVER_MODEL_INVALID",
7929            "RM.FEA.RUN_THERMAL.CANCELLED",
7930            model,
7931            &context,
7932            err,
7933        )
7934    })?;
7935
7936    let mut run = thermal_run.run;
7937    let mut fallback_events = Vec::new();
7938    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
7939    let solver_convergence = if diagnostic_metric(
7940        &run.diagnostics,
7941        "FEA_THERMAL_STABILITY",
7942        "max_residual_norm",
7943    )
7944    .unwrap_or(0.0)
7945        <= options.residual_warn_threshold
7946    {
7947        QualityGate::Pass
7948    } else {
7949        QualityGate::Warn
7950    };
7951    let result_quality = if thermal_run.temperature_snapshots.is_empty() {
7952        QualityGate::Fail
7953    } else {
7954        solver_convergence
7955    };
7956    let mut quality_reasons = Vec::new();
7957    if result_quality == QualityGate::Warn {
7958        quality_reasons.push(QualityReason {
7959            code: QualityReasonCode::ThermalResidualExceeded,
7960            detail: format!(
7961                "thermal residual exceeds threshold {}",
7962                options.residual_warn_threshold
7963            ),
7964        });
7965    }
7966    let thermal_conductivity_spread_ratio = diagnostic_metric(
7967        &run.diagnostics,
7968        "FEA_THERMAL_CONSTITUTIVE",
7969        "conductivity_spread_ratio",
7970    );
7971    let thermal_heat_capacity_spread_ratio = diagnostic_metric(
7972        &run.diagnostics,
7973        "FEA_THERMAL_CONSTITUTIVE",
7974        "heat_capacity_spread_ratio",
7975    );
7976    if thermal_conductivity_spread_ratio.unwrap_or(1.0) > 2.5
7977        || thermal_heat_capacity_spread_ratio.unwrap_or(1.0) > 2.5
7978    {
7979        quality_reasons.push(QualityReason {
7980            code: QualityReasonCode::ThermalConstitutiveSpreadHigh,
7981            detail: format!(
7982                "thermal constitutive spread exceeds limit: conductivity_spread_ratio={} heat_capacity_spread_ratio={}",
7983                thermal_conductivity_spread_ratio.unwrap_or(1.0),
7984                thermal_heat_capacity_spread_ratio.unwrap_or(1.0)
7985            ),
7986        });
7987    }
7988
7989    let publishable = match options.quality_policy {
7990        QualityPolicy::Strict => {
7991            solver_convergence == QualityGate::Pass
7992                && result_quality == QualityGate::Pass
7993                && quality_reasons.is_empty()
7994        }
7995        QualityPolicy::Balanced => {
7996            result_quality != QualityGate::Fail
7997                && !quality_reasons
7998                    .iter()
7999                    .any(|reason| reason.code == QualityReasonCode::ThermalConstitutiveSpreadHigh)
8000        }
8001        QualityPolicy::Exploratory => true,
8002    };
8003    let run_status = if publishable {
8004        RunStatus::Publishable
8005    } else if result_quality == QualityGate::Fail {
8006        RunStatus::Rejected
8007    } else {
8008        RunStatus::Degraded
8009    };
8010
8011    let solver_backend = run.solver_backend.clone();
8012    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
8013    let solver_host_sync_count = run.solver_host_sync_count;
8014    let solver_method = run.solver_method.clone();
8015    let selected_preconditioner = run.preconditioner.clone();
8016
8017    let result = AnalysisRunResult {
8018        run_id: storage::next_run_id(),
8019        run,
8020        render_topology: render_topology_from_prep_context(prep_context.as_ref()),
8021        modal_results: None,
8022        thermal_results: Some(ThermalResultsData {
8023            thermal_payload_version: "thermal_results/v1".to_string(),
8024            time_points_s: thermal_run.time_points_s,
8025            temperature_snapshots: thermal_run.temperature_snapshots,
8026            temperature_gradient_snapshots: thermal_run.temperature_gradient_snapshots,
8027            heat_flux_snapshots: thermal_run.heat_flux_snapshots,
8028            heat_source_snapshots: thermal_run.heat_source_snapshots,
8029            boundary_heat_flux_snapshots: thermal_run.boundary_heat_flux_snapshots,
8030            residual_norms: thermal_run.residual_norms,
8031            reference_temperature_k: thermal_run.reference_temperature_k,
8032        }),
8033        transient_results: None,
8034        nonlinear_results: None,
8035        electromagnetic_results: None,
8036        model_validity: QualityGate::Pass,
8037        solver_convergence,
8038        result_quality,
8039        run_status,
8040        publishable,
8041        quality_reasons,
8042        provenance: RunProvenance {
8043            backend,
8044            solver_backend,
8045            solver_device_apply_k_ratio,
8046            solver_host_sync_count,
8047            precision_mode: contracts::format_precision_mode(options.precision_mode),
8048            deterministic_mode: options.deterministic_mode,
8049            solver_method,
8050            preconditioner: selected_preconditioner,
8051            quality_policy: contracts::format_quality_policy(options.quality_policy),
8052            fallback_events,
8053        },
8054    };
8055
8056    persist_fea_run_result_with_progress(
8057        ANALYSIS_RUN_THERMAL_OPERATION,
8058        ANALYSIS_RUN_THERMAL_OP_VERSION,
8059        "RM.FEA.RUN_THERMAL.ARTIFACT_STORE_FAILED",
8060        &context,
8061        &result,
8062    )?;
8063
8064    Ok(OperationEnvelope::new(
8065        ANALYSIS_RUN_THERMAL_OPERATION,
8066        ANALYSIS_RUN_THERMAL_OP_VERSION,
8067        &context,
8068        result,
8069    ))
8070}
8071
8072fn validate_thermal_run_model(
8073    model: &AnalysisModel,
8074    context: &OperationContext,
8075) -> Result<(), OperationErrorEnvelope> {
8076    if let Some(material) = model.materials.iter().find(|material| {
8077        !material.thermal.conductivity_w_per_mk.is_finite()
8078            || material.thermal.conductivity_w_per_mk <= 0.0
8079            || !material.thermal.specific_heat_j_per_kgk.is_finite()
8080            || material.thermal.specific_heat_j_per_kgk <= 0.0
8081    }) {
8082        return Err(operation_error(
8083            ANALYSIS_RUN_THERMAL_OPERATION,
8084            ANALYSIS_RUN_THERMAL_OP_VERSION,
8085            context,
8086            OperationErrorSpec {
8087                error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_MATERIAL",
8088                error_type: OperationErrorType::Validation,
8089                retryable: false,
8090                severity: OperationErrorSeverity::Error,
8091            },
8092            "fea.run_thermal requires finite positive thermal conductivity and specific heat",
8093            BTreeMap::from([
8094                ("analysis_model_id".to_string(), model.model_id.0.clone()),
8095                ("material_id".to_string(), material.material_id.clone()),
8096                (
8097                    "conductivity_w_per_mk".to_string(),
8098                    material.thermal.conductivity_w_per_mk.to_string(),
8099                ),
8100                (
8101                    "specific_heat_j_per_kgk".to_string(),
8102                    material.thermal.specific_heat_j_per_kgk.to_string(),
8103                ),
8104            ]),
8105        ));
8106    }
8107
8108    reject_moment_loads_for_run_family(
8109        model,
8110        ANALYSIS_RUN_THERMAL_OPERATION,
8111        ANALYSIS_RUN_THERMAL_OP_VERSION,
8112        "RM.FEA.RUN_THERMAL.INVALID_THERMAL_SOURCE",
8113        "thermal",
8114        context,
8115    )?;
8116
8117    if let Some(load) = model.loads.iter().find(|load| {
8118        matches!(
8119            &load.kind,
8120            LoadKind::HeatSource {
8121                volumetric_w_per_m3
8122            } if !volumetric_w_per_m3.is_finite()
8123        )
8124    }) {
8125        return Err(operation_error(
8126            ANALYSIS_RUN_THERMAL_OPERATION,
8127            ANALYSIS_RUN_THERMAL_OP_VERSION,
8128            context,
8129            OperationErrorSpec {
8130                error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_SOURCE",
8131                error_type: OperationErrorType::Validation,
8132                retryable: false,
8133                severity: OperationErrorSeverity::Error,
8134            },
8135            "fea.run_thermal requires finite thermal heat-source values",
8136            BTreeMap::from([
8137                ("analysis_model_id".to_string(), model.model_id.0.clone()),
8138                ("load_id".to_string(), load.load_id.clone()),
8139            ]),
8140        ));
8141    }
8142
8143    if let Some(boundary) = model.boundary_conditions.iter().find(|bc| match &bc.kind {
8144        BoundaryConditionKind::ThermalPrescribedTemperature { temperature_k } => {
8145            !temperature_k.is_finite() || *temperature_k <= 0.0
8146        }
8147        BoundaryConditionKind::ThermalHeatFlux { heat_flux_w_per_m2 } => {
8148            !heat_flux_w_per_m2.is_finite()
8149        }
8150        BoundaryConditionKind::ThermalConvection {
8151            ambient_temperature_k,
8152            coefficient_w_per_m2k,
8153        } => {
8154            !ambient_temperature_k.is_finite()
8155                || *ambient_temperature_k <= 0.0
8156                || !coefficient_w_per_m2k.is_finite()
8157                || *coefficient_w_per_m2k < 0.0
8158        }
8159        _ => false,
8160    }) {
8161        return Err(operation_error(
8162            ANALYSIS_RUN_THERMAL_OPERATION,
8163            ANALYSIS_RUN_THERMAL_OP_VERSION,
8164            context,
8165            OperationErrorSpec {
8166                error_code: "RM.FEA.RUN_THERMAL.INVALID_THERMAL_BOUNDARY",
8167                error_type: OperationErrorType::Validation,
8168                retryable: false,
8169                severity: OperationErrorSeverity::Error,
8170            },
8171            "fea.run_thermal requires finite physically valid thermal boundary condition values",
8172            BTreeMap::from([
8173                ("analysis_model_id".to_string(), model.model_id.0.clone()),
8174                ("boundary_condition_id".to_string(), boundary.bc_id.clone()),
8175            ]),
8176        ));
8177    }
8178
8179    Ok(())
8180}
8181
8182pub fn analysis_run_transient_with_options_op(
8183    model: &AnalysisModel,
8184    backend: ComputeBackend,
8185    options: AnalysisTransientRunOptions,
8186    context: OperationContext,
8187) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
8188    let _solver_context = install_fea_solver_context();
8189    let has_transient_step = model
8190        .steps
8191        .iter()
8192        .any(|step| step.kind == AnalysisStepKind::Transient);
8193    if !has_transient_step {
8194        return Err(operation_error(
8195            ANALYSIS_RUN_TRANSIENT_OPERATION,
8196            ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8197            &context,
8198            OperationErrorSpec {
8199                error_code: "RM.FEA.RUN_TRANSIENT.INVALID_MODEL",
8200                error_type: OperationErrorType::Validation,
8201                retryable: false,
8202                severity: OperationErrorSeverity::Error,
8203            },
8204            "FEA model must include at least one transient step for fea.run_transient",
8205            BTreeMap::from([
8206                ("analysis_model_id".to_string(), model.model_id.0.clone()),
8207                ("geometry_id".to_string(), model.geometry_id.clone()),
8208            ]),
8209        ));
8210    }
8211
8212    let thermo_options = resolve_thermo_coupling_options(
8213        model,
8214        model_thermo_coupling_options(model),
8215        ANALYSIS_RUN_TRANSIENT_OPERATION,
8216        ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8217        &context,
8218    )?;
8219    if let Some(thermo_options) = thermo_options.as_ref() {
8220        if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
8221            return Err(operation_error(
8222                ANALYSIS_RUN_TRANSIENT_OPERATION,
8223                ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8224                &context,
8225                OperationErrorSpec {
8226                    error_code: "RM.FEA.RUN_TRANSIENT.INVALID_OPTIONS",
8227                    error_type: OperationErrorType::Input,
8228                    retryable: false,
8229                    severity: OperationErrorSeverity::Error,
8230                },
8231                detail,
8232                metadata,
8233            ));
8234        }
8235    }
8236    let electro_options = model_electro_coupling_options(model);
8237    if let Some(electro_options) = electro_options.as_ref() {
8238        if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
8239            return Err(operation_error(
8240                ANALYSIS_RUN_TRANSIENT_OPERATION,
8241                ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8242                &context,
8243                OperationErrorSpec {
8244                    error_code: electro_thermal_invalid_options_error_code(
8245                        ANALYSIS_RUN_TRANSIENT_OPERATION,
8246                    ),
8247                    error_type: OperationErrorType::Input,
8248                    retryable: false,
8249                    severity: OperationErrorSeverity::Error,
8250                },
8251                detail,
8252                metadata,
8253            ));
8254        }
8255    }
8256
8257    let prep_context = resolve_run_prep_context(
8258        model,
8259        options.prep_artifact_id.as_deref(),
8260        options.prep_context.clone(),
8261        ANALYSIS_RUN_TRANSIENT_OPERATION,
8262        ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8263        &context,
8264    )?;
8265    let transient_run = run_transient_with_options(
8266        model,
8267        backend,
8268        runmat_analysis_fea::solve::transient::TransientSolveOptions {
8269            time_step_s: options.time_step_s,
8270            min_time_step_s: options.min_time_step_s,
8271            max_time_step_s: options.max_time_step_s,
8272            step_count: options.step_count,
8273            max_linear_iters: options.max_linear_iters,
8274            tolerance: options.tolerance,
8275            residual_target: options.residual_target,
8276            adaptive_time_step: options.adaptive_time_step,
8277            max_step_retries: options.max_step_retries,
8278            adapt_min_scale: options.adapt_min_scale,
8279            adapt_max_scale: options.adapt_max_scale,
8280            adapt_growth_exponent: options.adapt_growth_exponent,
8281            adapt_retry_growth_cap: options.adapt_retry_growth_cap,
8282            adapt_nonconverged_shrink: options.adapt_nonconverged_shrink,
8283            dt_bucket_rel_tolerance: options.dt_bucket_rel_tolerance,
8284            progress_operation: ANALYSIS_RUN_TRANSIENT_OPERATION.to_string(),
8285            prep_context: to_fea_prep_context(
8286                prep_context.as_ref(),
8287                options.prep_calibration_profile,
8288            ),
8289            thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
8290            electro_thermal_context: to_fea_electro_thermal_context(electro_options),
8291        },
8292    )
8293    .map_err(|err| {
8294        map_fea_run_error(
8295            ANALYSIS_RUN_TRANSIENT_OPERATION,
8296            ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8297            "RM.FEA.RUN_TRANSIENT.SOLVER_MODEL_INVALID",
8298            "RM.FEA.RUN_TRANSIENT.CANCELLED",
8299            model,
8300            &context,
8301            err,
8302        )
8303    })?;
8304
8305    let mut run = transient_run.run;
8306    let mut fallback_events = Vec::new();
8307    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
8308    if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
8309        fallback_events.push(
8310            "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
8311        );
8312    }
8313    let solver_convergence = if run.diagnostics.iter().any(|item| {
8314        item.code == "FEA_TRANSIENT_CONVERGENCE"
8315            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
8316    }) {
8317        QualityGate::Pass
8318    } else {
8319        QualityGate::Warn
8320    };
8321    let result_quality = if transient_run.displacement_snapshots.is_empty()
8322        || transient_run.time_points_s.is_empty()
8323        || transient_run
8324            .residual_norms
8325            .iter()
8326            .any(|residual| !residual.is_finite())
8327    {
8328        QualityGate::Fail
8329    } else if transient_run
8330        .residual_norms
8331        .iter()
8332        .copied()
8333        .fold(0.0_f64, f64::max)
8334        > TRANSIENT_RESIDUAL_WARN_THRESHOLD
8335    {
8336        QualityGate::Warn
8337    } else {
8338        QualityGate::Pass
8339    };
8340    let transient_stability_warn = run.diagnostics.iter().any(|item| {
8341        item.code == "FEA_TRANSIENT_STABILITY"
8342            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8343    }) || run.diagnostics.iter().any(|item| {
8344        item.code == "FEA_TRANSIENT_ENERGY"
8345            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8346    });
8347    let transient_step_failure_warn = run.diagnostics.iter().any(|item| {
8348        item.code == "FEA_TRANSIENT_STEP_FAILURE"
8349            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8350    });
8351    let thermo_transient_warn = run.diagnostics.iter().any(|item| {
8352        item.code == "FEA_TM_TRANSIENT"
8353            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8354    });
8355    let electro_transient_warn = run.diagnostics.iter().any(|item| {
8356        item.code == "FEA_ET_TRANSIENT"
8357            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8358    });
8359    let thermo_spatial_gradient_index = diagnostic_metric(
8360        &run.diagnostics,
8361        "FEA_TM_COUPLING",
8362        "spatial_gradient_index",
8363    );
8364    let thermo_spatial_coverage_ratio = diagnostic_metric(
8365        &run.diagnostics,
8366        "FEA_TM_COUPLING",
8367        "spatial_coverage_ratio",
8368    );
8369    let thermo_temporal_variation =
8370        diagnostic_metric(&run.diagnostics, "FEA_TM_TRANSIENT", "temporal_variation");
8371    let thermo_field_extrapolation_ratio = diagnostic_metric(
8372        &run.diagnostics,
8373        "FEA_TM_TRANSIENT",
8374        "field_extrapolation_ratio",
8375    );
8376    let (thermo_gradient_spatial_threshold, thermo_gradient_temporal_threshold) =
8377        thermo_gradient_thresholds_for_policy(options.quality_policy);
8378    let thermo_gradient_instability = thermo_spatial_gradient_index
8379        .map(|value| value > thermo_gradient_spatial_threshold)
8380        .unwrap_or(false)
8381        || thermo_temporal_variation
8382            .map(|value| value > thermo_gradient_temporal_threshold)
8383            .unwrap_or(false);
8384    let thermo_spread_ratio = diagnostic_metric(
8385        &run.diagnostics,
8386        "FEA_TM_COUPLING",
8387        "constitutive_material_spread_ratio",
8388    );
8389    let thermo_heterogeneity_index = diagnostic_metric(
8390        &run.diagnostics,
8391        "FEA_TM_COUPLING",
8392        "assignment_heterogeneity_index",
8393    );
8394    let (thermo_spread_threshold, thermo_heterogeneity_threshold) =
8395        thermo_thresholds_for_policy(options.quality_policy);
8396    let (thermo_field_coverage_min, thermo_field_extrapolation_max) =
8397        thermo_field_quality_thresholds_for_policy(options.quality_policy);
8398    let thermo_spread_breach = thermo_spread_ratio
8399        .map(|value| value > thermo_spread_threshold)
8400        .unwrap_or(false);
8401    let thermo_heterogeneity_breach = thermo_heterogeneity_index
8402        .map(|value| value > thermo_heterogeneity_threshold)
8403        .unwrap_or(false);
8404    let thermo_field_coverage_breach = thermo_spatial_coverage_ratio
8405        .map(|value| value < thermo_field_coverage_min)
8406        .unwrap_or(false);
8407    let thermo_field_extrapolation_breach = thermo_field_extrapolation_ratio
8408        .map(|value| value > thermo_field_extrapolation_max)
8409        .unwrap_or(false);
8410
8411    let mut quality_reasons = Vec::new();
8412    if solver_convergence == QualityGate::Warn {
8413        quality_reasons.push(QualityReason {
8414            code: QualityReasonCode::SolverNotConverged,
8415            detail: "transient solver convergence gate is warning".to_string(),
8416        });
8417    }
8418    if result_quality == QualityGate::Warn {
8419        quality_reasons.push(QualityReason {
8420            code: QualityReasonCode::TransientResidualExceeded,
8421            detail: format!(
8422                "transient residual exceeds threshold {}",
8423                TRANSIENT_RESIDUAL_WARN_THRESHOLD
8424            ),
8425        });
8426    }
8427    if transient_stability_warn {
8428        quality_reasons.push(QualityReason {
8429            code: QualityReasonCode::TransientStabilityExceeded,
8430            detail: "transient stability diagnostic exceeded threshold".to_string(),
8431        });
8432    }
8433    if transient_step_failure_warn {
8434        quality_reasons.push(QualityReason {
8435            code: QualityReasonCode::TransientStepFailure,
8436            detail: "transient step retry budget was exhausted".to_string(),
8437        });
8438    }
8439    if thermo_transient_warn {
8440        quality_reasons.push(QualityReason {
8441            code: QualityReasonCode::ThermoMechanicalTransientStress,
8442            detail: "thermo-mechanical transient coupling severity exceeded balanced threshold"
8443                .to_string(),
8444        });
8445    }
8446    if electro_transient_warn {
8447        quality_reasons.push(QualityReason {
8448            code: QualityReasonCode::ElectroThermalTransientStress,
8449            detail: "electro-thermal transient coupling severity exceeded balanced threshold"
8450                .to_string(),
8451        });
8452    }
8453    if thermo_spread_breach {
8454        quality_reasons.push(QualityReason {
8455            code: QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh,
8456            detail: format!(
8457                "thermo constitutive material spread ratio {} exceeds threshold {}",
8458                thermo_spread_ratio.unwrap_or(0.0),
8459                thermo_spread_threshold
8460            ),
8461        });
8462    }
8463    if thermo_heterogeneity_breach {
8464        quality_reasons.push(QualityReason {
8465            code: QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh,
8466            detail: format!(
8467                "thermo assignment heterogeneity index {} exceeds threshold {}",
8468                thermo_heterogeneity_index.unwrap_or(0.0),
8469                thermo_heterogeneity_threshold
8470            ),
8471        });
8472    }
8473    if thermo_gradient_instability {
8474        quality_reasons.push(QualityReason {
8475            code: QualityReasonCode::ThermoMechanicalGradientInstability,
8476            detail: format!(
8477                "thermo gradient instability spatial_gradient_index={} temporal_variation={} thresholds=({}, {})",
8478                thermo_spatial_gradient_index.unwrap_or(0.0),
8479                thermo_temporal_variation.unwrap_or(0.0),
8480                thermo_gradient_spatial_threshold,
8481                thermo_gradient_temporal_threshold,
8482            ),
8483        });
8484    }
8485    if thermo_field_coverage_breach {
8486        quality_reasons.push(QualityReason {
8487            code: QualityReasonCode::ThermoMechanicalFieldCoverageLow,
8488            detail: format!(
8489                "thermo field spatial coverage ratio {} is below minimum {}",
8490                thermo_spatial_coverage_ratio.unwrap_or(0.0),
8491                thermo_field_coverage_min
8492            ),
8493        });
8494    }
8495    if thermo_field_extrapolation_breach {
8496        quality_reasons.push(QualityReason {
8497            code: QualityReasonCode::ThermoMechanicalFieldExtrapolationHigh,
8498            detail: format!(
8499                "thermo field extrapolation ratio {} exceeds maximum {}",
8500                thermo_field_extrapolation_ratio.unwrap_or(0.0),
8501                thermo_field_extrapolation_max
8502            ),
8503        });
8504    }
8505    if fallback_events
8506        .iter()
8507        .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
8508    {
8509        quality_reasons.push(QualityReason {
8510            code: QualityReasonCode::SolverBackendFallback,
8511            detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
8512        });
8513    }
8514    if fallback_events.iter().any(|event| {
8515        event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
8516    }) {
8517        quality_reasons.push(QualityReason {
8518            code: QualityReasonCode::FieldPromotionFallback,
8519            detail: "field promotion fell back to host-backed values".to_string(),
8520        });
8521    }
8522    let publishable = match options.quality_policy {
8523        QualityPolicy::Strict => {
8524            solver_convergence == QualityGate::Pass
8525                && result_quality == QualityGate::Pass
8526                && quality_reasons.is_empty()
8527        }
8528        QualityPolicy::Balanced => {
8529            solver_convergence == QualityGate::Pass
8530                && result_quality == QualityGate::Pass
8531                && !quality_reasons.iter().any(|r| {
8532                    matches!(
8533                        r.code,
8534                        QualityReasonCode::TransientStabilityExceeded
8535                            | QualityReasonCode::TransientStepFailure
8536                            | QualityReasonCode::ThermoMechanicalTransientStress
8537                            | QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh
8538                            | QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh
8539                            | QualityReasonCode::ThermoMechanicalGradientInstability
8540                    )
8541                })
8542        }
8543        QualityPolicy::Exploratory => {
8544            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
8545        }
8546    };
8547    let run_status = if publishable {
8548        RunStatus::Publishable
8549    } else if result_quality == QualityGate::Fail {
8550        RunStatus::Rejected
8551    } else {
8552        RunStatus::Degraded
8553    };
8554
8555    let solver_backend = run.solver_backend.clone();
8556    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
8557    let solver_host_sync_count = run.solver_host_sync_count;
8558    let solver_method = run.solver_method.clone();
8559    let selected_preconditioner = run.preconditioner.clone();
8560
8561    let result = AnalysisRunResult {
8562        run_id: storage::next_run_id(),
8563        run,
8564        render_topology: render_topology_from_prep_context(prep_context.as_ref()),
8565        modal_results: None,
8566        thermal_results: None,
8567        transient_results: Some(TransientResultsData {
8568            transient_payload_version: "transient_results/v1".to_string(),
8569            time_points_s: transient_run.time_points_s,
8570            displacement_snapshots: transient_run.displacement_snapshots,
8571            rotation_snapshots: transient_run.rotation_snapshots,
8572            velocity_snapshots: transient_run.velocity_snapshots,
8573            angular_velocity_snapshots: transient_run.angular_velocity_snapshots,
8574            acceleration_snapshots: transient_run.acceleration_snapshots,
8575            angular_acceleration_snapshots: transient_run.angular_acceleration_snapshots,
8576            von_mises_snapshots: transient_run.von_mises_snapshots,
8577            kinetic_energy_snapshots: transient_run.kinetic_energy_snapshots,
8578            strain_energy_snapshots: transient_run.strain_energy_snapshots,
8579            residual_norm_snapshots: transient_run.residual_norm_snapshots,
8580            thermo_mechanical_temperature_snapshots: transient_run
8581                .thermo_mechanical_temperature_snapshots,
8582            thermo_mechanical_thermal_strain_snapshots: transient_run
8583                .thermo_mechanical_thermal_strain_snapshots,
8584            thermo_mechanical_thermal_stress_snapshots: transient_run
8585                .thermo_mechanical_thermal_stress_snapshots,
8586            thermo_mechanical_displacement_snapshots: transient_run
8587                .thermo_mechanical_displacement_snapshots,
8588            thermo_mechanical_von_mises_snapshots: transient_run
8589                .thermo_mechanical_von_mises_snapshots,
8590            thermo_mechanical_coupling_residual_snapshots: transient_run
8591                .thermo_mechanical_coupling_residual_snapshots,
8592            electro_thermal_temperature_snapshots: transient_run
8593                .electro_thermal_temperature_snapshots,
8594            electro_thermal_thermal_residual_snapshots: transient_run
8595                .electro_thermal_thermal_residual_snapshots,
8596            residual_norms: transient_run.residual_norms,
8597            integration_method: TransientIntegrationMethod::ImplicitEuler,
8598        }),
8599        nonlinear_results: None,
8600        electromagnetic_results: None,
8601        model_validity: QualityGate::Pass,
8602        solver_convergence,
8603        result_quality,
8604        run_status,
8605        publishable,
8606        quality_reasons,
8607        provenance: RunProvenance {
8608            backend,
8609            solver_backend,
8610            solver_device_apply_k_ratio,
8611            solver_host_sync_count,
8612            precision_mode: contracts::format_precision_mode(options.precision_mode),
8613            deterministic_mode: options.deterministic_mode,
8614            solver_method,
8615            preconditioner: selected_preconditioner,
8616            quality_policy: contracts::format_quality_policy(options.quality_policy),
8617            fallback_events,
8618        },
8619    };
8620
8621    persist_fea_run_result_with_progress(
8622        ANALYSIS_RUN_TRANSIENT_OPERATION,
8623        ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8624        "RM.FEA.RUN_TRANSIENT.ARTIFACT_STORE_FAILED",
8625        &context,
8626        &result,
8627    )?;
8628
8629    Ok(OperationEnvelope::new(
8630        ANALYSIS_RUN_TRANSIENT_OPERATION,
8631        ANALYSIS_RUN_TRANSIENT_OP_VERSION,
8632        &context,
8633        result,
8634    ))
8635}
8636
8637pub fn analysis_run_nonlinear_op(
8638    model: &AnalysisModel,
8639    backend: ComputeBackend,
8640    context: OperationContext,
8641) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
8642    analysis_run_nonlinear_with_options_op(
8643        model,
8644        backend,
8645        AnalysisNonlinearRunOptions::default(),
8646        context,
8647    )
8648}
8649
8650pub fn analysis_run_nonlinear_with_options_op(
8651    model: &AnalysisModel,
8652    backend: ComputeBackend,
8653    options: AnalysisNonlinearRunOptions,
8654    context: OperationContext,
8655) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
8656    let _solver_context = install_fea_solver_context();
8657    let has_nonlinear_step = model
8658        .steps
8659        .iter()
8660        .any(|step| step.kind == AnalysisStepKind::Nonlinear);
8661    if !has_nonlinear_step {
8662        return Err(operation_error(
8663            ANALYSIS_RUN_NONLINEAR_OPERATION,
8664            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8665            &context,
8666            OperationErrorSpec {
8667                error_code: "RM.FEA.RUN_NONLINEAR.INVALID_MODEL",
8668                error_type: OperationErrorType::Validation,
8669                retryable: false,
8670                severity: OperationErrorSeverity::Error,
8671            },
8672            "FEA model must include at least one nonlinear step for fea.run_nonlinear",
8673            BTreeMap::from([
8674                ("analysis_model_id".to_string(), model.model_id.0.clone()),
8675                ("geometry_id".to_string(), model.geometry_id.clone()),
8676            ]),
8677        ));
8678    }
8679
8680    if options.increment_count == 0 {
8681        return Err(operation_error(
8682            ANALYSIS_RUN_NONLINEAR_OPERATION,
8683            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8684            &context,
8685            OperationErrorSpec {
8686                error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8687                error_type: OperationErrorType::Input,
8688                retryable: false,
8689                severity: OperationErrorSeverity::Error,
8690            },
8691            "fea.run_nonlinear options require increment_count greater than zero",
8692            BTreeMap::from([(
8693                "increment_count".to_string(),
8694                options.increment_count.to_string(),
8695            )]),
8696        ));
8697    }
8698    if options.max_newton_iters == 0 {
8699        return Err(operation_error(
8700            ANALYSIS_RUN_NONLINEAR_OPERATION,
8701            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8702            &context,
8703            OperationErrorSpec {
8704                error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8705                error_type: OperationErrorType::Input,
8706                retryable: false,
8707                severity: OperationErrorSeverity::Error,
8708            },
8709            "fea.run_nonlinear options require max_newton_iters greater than zero",
8710            BTreeMap::from([(
8711                "max_newton_iters".to_string(),
8712                options.max_newton_iters.to_string(),
8713            )]),
8714        ));
8715    }
8716    if options.tolerance <= 0.0 || !options.tolerance.is_finite() {
8717        return Err(operation_error(
8718            ANALYSIS_RUN_NONLINEAR_OPERATION,
8719            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8720            &context,
8721            OperationErrorSpec {
8722                error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8723                error_type: OperationErrorType::Input,
8724                retryable: false,
8725                severity: OperationErrorSeverity::Error,
8726            },
8727            "fea.run_nonlinear options require finite positive tolerance",
8728            BTreeMap::from([("tolerance".to_string(), options.tolerance.to_string())]),
8729        ));
8730    }
8731    if options.increment_norm_tolerance <= 0.0 || !options.increment_norm_tolerance.is_finite() {
8732        return Err(operation_error(
8733            ANALYSIS_RUN_NONLINEAR_OPERATION,
8734            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8735            &context,
8736            OperationErrorSpec {
8737                error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8738                error_type: OperationErrorType::Input,
8739                retryable: false,
8740                severity: OperationErrorSeverity::Error,
8741            },
8742            "fea.run_nonlinear options require finite positive increment_norm_tolerance",
8743            BTreeMap::from([(
8744                "increment_norm_tolerance".to_string(),
8745                options.increment_norm_tolerance.to_string(),
8746            )]),
8747        ));
8748    }
8749    if options.residual_convergence_factor < 1.0 || !options.residual_convergence_factor.is_finite()
8750    {
8751        return Err(operation_error(
8752            ANALYSIS_RUN_NONLINEAR_OPERATION,
8753            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8754            &context,
8755            OperationErrorSpec {
8756                error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8757                error_type: OperationErrorType::Input,
8758                retryable: false,
8759                severity: OperationErrorSeverity::Error,
8760            },
8761            "fea.run_nonlinear options require residual_convergence_factor >= 1.0",
8762            BTreeMap::from([(
8763                "residual_convergence_factor".to_string(),
8764                options.residual_convergence_factor.to_string(),
8765            )]),
8766        ));
8767    }
8768    if options.line_search_reduction <= 0.0
8769        || options.line_search_reduction >= 1.0
8770        || !options.line_search_reduction.is_finite()
8771    {
8772        return Err(operation_error(
8773            ANALYSIS_RUN_NONLINEAR_OPERATION,
8774            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8775            &context,
8776            OperationErrorSpec {
8777                error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8778                error_type: OperationErrorType::Input,
8779                retryable: false,
8780                severity: OperationErrorSeverity::Error,
8781            },
8782            "fea.run_nonlinear options require line_search_reduction in (0, 1)",
8783            BTreeMap::from([(
8784                "line_search_reduction".to_string(),
8785                options.line_search_reduction.to_string(),
8786            )]),
8787        ));
8788    }
8789    if options.tangent_refresh_interval == 0 {
8790        return Err(operation_error(
8791            ANALYSIS_RUN_NONLINEAR_OPERATION,
8792            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8793            &context,
8794            OperationErrorSpec {
8795                error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8796                error_type: OperationErrorType::Input,
8797                retryable: false,
8798                severity: OperationErrorSeverity::Error,
8799            },
8800            "fea.run_nonlinear options require tangent_refresh_interval greater than zero",
8801            BTreeMap::from([(
8802                "tangent_refresh_interval".to_string(),
8803                options.tangent_refresh_interval.to_string(),
8804            )]),
8805        ));
8806    }
8807
8808    let thermo_options = resolve_thermo_coupling_options(
8809        model,
8810        model_thermo_coupling_options(model),
8811        ANALYSIS_RUN_NONLINEAR_OPERATION,
8812        ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8813        &context,
8814    )?;
8815    if let Some(thermo_options) = thermo_options.as_ref() {
8816        if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
8817            return Err(operation_error(
8818                ANALYSIS_RUN_NONLINEAR_OPERATION,
8819                ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8820                &context,
8821                OperationErrorSpec {
8822                    error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8823                    error_type: OperationErrorType::Input,
8824                    retryable: false,
8825                    severity: OperationErrorSeverity::Error,
8826                },
8827                detail,
8828                metadata,
8829            ));
8830        }
8831    }
8832    let electro_options = model_electro_coupling_options(model);
8833    if let Some(electro_options) = electro_options.as_ref() {
8834        if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
8835            return Err(operation_error(
8836                ANALYSIS_RUN_NONLINEAR_OPERATION,
8837                ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8838                &context,
8839                OperationErrorSpec {
8840                    error_code: electro_thermal_invalid_options_error_code(
8841                        ANALYSIS_RUN_NONLINEAR_OPERATION,
8842                    ),
8843                    error_type: OperationErrorType::Input,
8844                    retryable: false,
8845                    severity: OperationErrorSeverity::Error,
8846                },
8847                detail,
8848                metadata,
8849            ));
8850        }
8851    }
8852    let plasticity_options = model_plasticity_constitutive_options(model);
8853    if let Some(plasticity_options) = plasticity_options.as_ref() {
8854        if let Err((detail, metadata)) =
8855            validate_plasticity_constitutive_options(plasticity_options)
8856        {
8857            return Err(operation_error(
8858                ANALYSIS_RUN_NONLINEAR_OPERATION,
8859                ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8860                &context,
8861                OperationErrorSpec {
8862                    error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8863                    error_type: OperationErrorType::Input,
8864                    retryable: false,
8865                    severity: OperationErrorSeverity::Error,
8866                },
8867                detail,
8868                metadata,
8869            ));
8870        }
8871    }
8872    let contact_options = model_contact_interface_options(model);
8873    if let Some(contact_options) = contact_options.as_ref() {
8874        if let Err((detail, metadata)) = validate_contact_interface_options(contact_options) {
8875            return Err(operation_error(
8876                ANALYSIS_RUN_NONLINEAR_OPERATION,
8877                ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8878                &context,
8879                OperationErrorSpec {
8880                    error_code: "RM.FEA.RUN_NONLINEAR.INVALID_OPTIONS",
8881                    error_type: OperationErrorType::Input,
8882                    retryable: false,
8883                    severity: OperationErrorSeverity::Error,
8884                },
8885                detail,
8886                metadata,
8887            ));
8888        }
8889    }
8890
8891    let prep_context = resolve_run_prep_context(
8892        model,
8893        options.prep_artifact_id.as_deref(),
8894        options.prep_context.clone(),
8895        ANALYSIS_RUN_NONLINEAR_OPERATION,
8896        ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8897        &context,
8898    )?;
8899    let nonlinear_run = run_nonlinear_with_options(
8900        model,
8901        backend,
8902        runmat_analysis_fea::solve::nonlinear::NonlinearSolveOptions {
8903            increment_count: options.increment_count,
8904            max_newton_iters: options.max_newton_iters,
8905            tolerance: options.tolerance,
8906            residual_convergence_factor: options.residual_convergence_factor,
8907            increment_norm_tolerance: options.increment_norm_tolerance,
8908            line_search: options.line_search,
8909            max_line_search_backtracks: options.max_line_search_backtracks,
8910            line_search_reduction: options.line_search_reduction,
8911            tangent_refresh_interval: options.tangent_refresh_interval,
8912            prep_context: to_fea_prep_context(
8913                prep_context.as_ref(),
8914                options.prep_calibration_profile,
8915            ),
8916            thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
8917            electro_thermal_context: to_fea_electro_thermal_context(electro_options),
8918            plasticity_context: to_fea_plasticity_constitutive_context(plasticity_options),
8919            contact_context: to_fea_contact_interface_context(contact_options),
8920        },
8921    )
8922    .map_err(|err| {
8923        map_fea_run_error(
8924            ANALYSIS_RUN_NONLINEAR_OPERATION,
8925            ANALYSIS_RUN_NONLINEAR_OP_VERSION,
8926            "RM.FEA.RUN_NONLINEAR.SOLVER_MODEL_INVALID",
8927            "RM.FEA.RUN_NONLINEAR.CANCELLED",
8928            model,
8929            &context,
8930            err,
8931        )
8932    })?;
8933
8934    let mut run = nonlinear_run.run;
8935    let mut fallback_events = Vec::new();
8936    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
8937    if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
8938        fallback_events.push(
8939            "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
8940        );
8941    }
8942
8943    let solver_convergence = if run.diagnostics.iter().any(|item| {
8944        item.code == "FEA_NONLINEAR_CONVERGENCE"
8945            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
8946    }) {
8947        QualityGate::Pass
8948    } else {
8949        QualityGate::Warn
8950    };
8951    let max_nonlinear_residual = nonlinear_run
8952        .residual_norms
8953        .iter()
8954        .copied()
8955        .reduce(f64::max)
8956        .unwrap_or(0.0);
8957    let max_nonlinear_increment_norm = nonlinear_run
8958        .increment_norms
8959        .iter()
8960        .copied()
8961        .reduce(f64::max)
8962        .unwrap_or(0.0);
8963    let result_quality = if nonlinear_run.load_factors.is_empty()
8964        || nonlinear_run.displacement_snapshots.is_empty()
8965        || nonlinear_run.residual_norms.iter().any(|r| !r.is_finite())
8966        || nonlinear_run.increment_norms.iter().any(|v| !v.is_finite())
8967    {
8968        QualityGate::Fail
8969    } else if max_nonlinear_residual > options.tolerance * options.residual_convergence_factor * 2.0
8970        || max_nonlinear_increment_norm > options.increment_norm_tolerance * 4.0
8971    {
8972        QualityGate::Warn
8973    } else {
8974        QualityGate::Pass
8975    };
8976    let nonlinear_increment_warn = run.diagnostics.iter().any(|item| {
8977        item.code == "FEA_NONLINEAR_CONVERGENCE"
8978            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8979    });
8980    let max_nonlinear_iteration_count = nonlinear_run
8981        .iteration_counts
8982        .iter()
8983        .copied()
8984        .max()
8985        .unwrap_or(0);
8986    let iteration_cap_hits = nonlinear_run
8987        .iteration_counts
8988        .iter()
8989        .filter(|&&count| count >= options.max_newton_iters.max(1))
8990        .count();
8991    let strict_increment_failure = nonlinear_run.failed_increments > 0;
8992    let strict_iteration_cap_exhausted = iteration_cap_hits > 0;
8993    let thermo_nonlinear_warn = run.diagnostics.iter().any(|item| {
8994        item.code == "FEA_TM_NONLINEAR"
8995            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
8996    });
8997    let electro_nonlinear_warn = run.diagnostics.iter().any(|item| {
8998        item.code == "FEA_ET_NONLINEAR"
8999            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
9000    });
9001    let plastic_nonlinear_warn = run.diagnostics.iter().any(|item| {
9002        item.code == "FEA_PLASTIC_NONLINEAR"
9003            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
9004    });
9005    let contact_nonlinear_warn = run.diagnostics.iter().any(|item| {
9006        item.code == "FEA_CONTACT_NONLINEAR"
9007            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
9008    });
9009    let thermo_spatial_gradient_index = diagnostic_metric(
9010        &run.diagnostics,
9011        "FEA_TM_COUPLING",
9012        "spatial_gradient_index",
9013    );
9014    let thermo_spatial_coverage_ratio = diagnostic_metric(
9015        &run.diagnostics,
9016        "FEA_TM_COUPLING",
9017        "spatial_coverage_ratio",
9018    );
9019    let thermo_temporal_variation =
9020        diagnostic_metric(&run.diagnostics, "FEA_TM_NONLINEAR", "temporal_variation");
9021    let thermo_field_extrapolation_ratio = diagnostic_metric(
9022        &run.diagnostics,
9023        "FEA_TM_NONLINEAR",
9024        "field_extrapolation_ratio",
9025    );
9026    let (thermo_gradient_spatial_threshold, thermo_gradient_temporal_threshold) =
9027        thermo_gradient_thresholds_for_policy(options.quality_policy);
9028    let thermo_gradient_instability = thermo_spatial_gradient_index
9029        .map(|value| value > thermo_gradient_spatial_threshold)
9030        .unwrap_or(false)
9031        || thermo_temporal_variation
9032            .map(|value| value > thermo_gradient_temporal_threshold)
9033            .unwrap_or(false);
9034    let thermo_spread_ratio = diagnostic_metric(
9035        &run.diagnostics,
9036        "FEA_TM_COUPLING",
9037        "constitutive_material_spread_ratio",
9038    );
9039    let thermo_heterogeneity_index = diagnostic_metric(
9040        &run.diagnostics,
9041        "FEA_TM_COUPLING",
9042        "assignment_heterogeneity_index",
9043    );
9044    let (thermo_spread_threshold, thermo_heterogeneity_threshold) =
9045        thermo_thresholds_for_policy(options.quality_policy);
9046    let (thermo_field_coverage_min, thermo_field_extrapolation_max) =
9047        thermo_field_quality_thresholds_for_policy(options.quality_policy);
9048    let thermo_spread_breach = thermo_spread_ratio
9049        .map(|value| value > thermo_spread_threshold)
9050        .unwrap_or(false);
9051    let thermo_heterogeneity_breach = thermo_heterogeneity_index
9052        .map(|value| value > thermo_heterogeneity_threshold)
9053        .unwrap_or(false);
9054    let thermo_field_coverage_breach = thermo_spatial_coverage_ratio
9055        .map(|value| value < thermo_field_coverage_min)
9056        .unwrap_or(false);
9057    let thermo_field_extrapolation_breach = thermo_field_extrapolation_ratio
9058        .map(|value| value > thermo_field_extrapolation_max)
9059        .unwrap_or(false);
9060
9061    let mut quality_reasons = Vec::new();
9062    if solver_convergence == QualityGate::Warn {
9063        quality_reasons.push(QualityReason {
9064            code: QualityReasonCode::SolverNotConverged,
9065            detail: "nonlinear solver convergence gate is warning".to_string(),
9066        });
9067    }
9068    if result_quality == QualityGate::Warn {
9069        quality_reasons.push(QualityReason {
9070            code: QualityReasonCode::NonlinearResidualExceeded,
9071            detail: format!(
9072                "nonlinear residual/increment norm exceeds thresholds residual={} increment_norm={}",
9073                options.tolerance * options.residual_convergence_factor * 2.0,
9074                options.increment_norm_tolerance * 4.0
9075            ),
9076        });
9077    }
9078    if nonlinear_increment_warn || strict_increment_failure || strict_iteration_cap_exhausted {
9079        quality_reasons.push(QualityReason {
9080            code: QualityReasonCode::NonlinearIncrementFailure,
9081            detail: format!(
9082                "nonlinear increment convergence warnings failed_increments={} iteration_cap_hits={} max_iteration_count={}",
9083                nonlinear_run.failed_increments,
9084                iteration_cap_hits,
9085                max_nonlinear_iteration_count
9086            ),
9087        });
9088    }
9089    if thermo_nonlinear_warn {
9090        quality_reasons.push(QualityReason {
9091            code: QualityReasonCode::ThermoMechanicalNonlinearStress,
9092            detail: "thermo-mechanical nonlinear coupling severity exceeded balanced threshold"
9093                .to_string(),
9094        });
9095    }
9096    if electro_nonlinear_warn {
9097        quality_reasons.push(QualityReason {
9098            code: QualityReasonCode::ElectroThermalNonlinearStress,
9099            detail: "electro-thermal nonlinear coupling severity exceeded balanced threshold"
9100                .to_string(),
9101        });
9102    }
9103    if plastic_nonlinear_warn {
9104        quality_reasons.push(QualityReason {
9105            code: QualityReasonCode::PlasticityNonlinearStress,
9106            detail: "plasticity nonlinear severity exceeded balanced threshold".to_string(),
9107        });
9108    }
9109    if contact_nonlinear_warn {
9110        quality_reasons.push(QualityReason {
9111            code: QualityReasonCode::ContactNonlinearStress,
9112            detail: "contact nonlinear severity exceeded balanced threshold".to_string(),
9113        });
9114    }
9115    if thermo_spread_breach {
9116        quality_reasons.push(QualityReason {
9117            code: QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh,
9118            detail: format!(
9119                "thermo constitutive material spread ratio {} exceeds threshold {}",
9120                thermo_spread_ratio.unwrap_or(0.0),
9121                thermo_spread_threshold
9122            ),
9123        });
9124    }
9125    if thermo_heterogeneity_breach {
9126        quality_reasons.push(QualityReason {
9127            code: QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh,
9128            detail: format!(
9129                "thermo assignment heterogeneity index {} exceeds threshold {}",
9130                thermo_heterogeneity_index.unwrap_or(0.0),
9131                thermo_heterogeneity_threshold
9132            ),
9133        });
9134    }
9135    if thermo_gradient_instability {
9136        quality_reasons.push(QualityReason {
9137            code: QualityReasonCode::ThermoMechanicalGradientInstability,
9138            detail: format!(
9139                "thermo gradient instability spatial_gradient_index={} temporal_variation={} thresholds=({}, {})",
9140                thermo_spatial_gradient_index.unwrap_or(0.0),
9141                thermo_temporal_variation.unwrap_or(0.0),
9142                thermo_gradient_spatial_threshold,
9143                thermo_gradient_temporal_threshold,
9144            ),
9145        });
9146    }
9147    if thermo_field_coverage_breach {
9148        quality_reasons.push(QualityReason {
9149            code: QualityReasonCode::ThermoMechanicalFieldCoverageLow,
9150            detail: format!(
9151                "thermo field spatial coverage ratio {} is below minimum {}",
9152                thermo_spatial_coverage_ratio.unwrap_or(0.0),
9153                thermo_field_coverage_min
9154            ),
9155        });
9156    }
9157    if thermo_field_extrapolation_breach {
9158        quality_reasons.push(QualityReason {
9159            code: QualityReasonCode::ThermoMechanicalFieldExtrapolationHigh,
9160            detail: format!(
9161                "thermo field extrapolation ratio {} exceeds maximum {}",
9162                thermo_field_extrapolation_ratio.unwrap_or(0.0),
9163                thermo_field_extrapolation_max
9164            ),
9165        });
9166    }
9167    if fallback_events
9168        .iter()
9169        .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
9170    {
9171        quality_reasons.push(QualityReason {
9172            code: QualityReasonCode::SolverBackendFallback,
9173            detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
9174        });
9175    }
9176    if fallback_events.iter().any(|event| {
9177        event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
9178    }) {
9179        quality_reasons.push(QualityReason {
9180            code: QualityReasonCode::FieldPromotionFallback,
9181            detail: "field promotion fell back to host-backed values".to_string(),
9182        });
9183    }
9184
9185    let publishable = match options.quality_policy {
9186        QualityPolicy::Strict => {
9187            solver_convergence == QualityGate::Pass
9188                && result_quality == QualityGate::Pass
9189                && !strict_increment_failure
9190                && !strict_iteration_cap_exhausted
9191                && quality_reasons.is_empty()
9192        }
9193        QualityPolicy::Balanced => {
9194            solver_convergence == QualityGate::Pass
9195                && result_quality == QualityGate::Pass
9196                && !quality_reasons.iter().any(|r| {
9197                    matches!(
9198                        r.code,
9199                        QualityReasonCode::NonlinearResidualExceeded
9200                            | QualityReasonCode::NonlinearIncrementFailure
9201                            | QualityReasonCode::ThermoMechanicalNonlinearStress
9202                            | QualityReasonCode::PlasticityNonlinearStress
9203                            | QualityReasonCode::ContactNonlinearStress
9204                            | QualityReasonCode::ThermoMechanicalConstitutiveSpreadHigh
9205                            | QualityReasonCode::ThermoMechanicalAssignmentHeterogeneityHigh
9206                            | QualityReasonCode::ThermoMechanicalGradientInstability
9207                    )
9208                })
9209        }
9210        QualityPolicy::Exploratory => {
9211            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
9212        }
9213    };
9214    let run_status = if publishable {
9215        RunStatus::Publishable
9216    } else if result_quality == QualityGate::Fail {
9217        RunStatus::Rejected
9218    } else {
9219        RunStatus::Degraded
9220    };
9221
9222    let solver_backend = run.solver_backend.clone();
9223    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
9224    let solver_host_sync_count = run.solver_host_sync_count;
9225    let solver_method = run.solver_method.clone();
9226    let selected_preconditioner = run.preconditioner.clone();
9227
9228    let result = AnalysisRunResult {
9229        run_id: storage::next_run_id(),
9230        run,
9231        render_topology: render_topology_from_prep_context(prep_context.as_ref()),
9232        modal_results: None,
9233        thermal_results: None,
9234        transient_results: None,
9235        nonlinear_results: Some(NonlinearResultsData {
9236            nonlinear_payload_version: "nonlinear_results/v1".to_string(),
9237            load_factors: nonlinear_run.load_factors,
9238            displacement_snapshots: nonlinear_run.displacement_snapshots,
9239            rotation_snapshots: nonlinear_run.rotation_snapshots,
9240            von_mises_snapshots: nonlinear_run.von_mises_snapshots,
9241            plastic_strain_snapshots: nonlinear_run.plastic_strain_snapshots,
9242            equivalent_plastic_strain_snapshots: nonlinear_run.equivalent_plastic_strain_snapshots,
9243            contact_pressure_snapshots: nonlinear_run.contact_pressure_snapshots,
9244            contact_gap_snapshots: nonlinear_run.contact_gap_snapshots,
9245            load_factor_snapshots: nonlinear_run.load_factor_snapshots,
9246            residual_norm_snapshots: nonlinear_run.residual_norm_snapshots,
9247            thermo_mechanical_temperature_snapshots: nonlinear_run
9248                .thermo_mechanical_temperature_snapshots,
9249            thermo_mechanical_thermal_strain_snapshots: nonlinear_run
9250                .thermo_mechanical_thermal_strain_snapshots,
9251            thermo_mechanical_thermal_stress_snapshots: nonlinear_run
9252                .thermo_mechanical_thermal_stress_snapshots,
9253            thermo_mechanical_displacement_snapshots: nonlinear_run
9254                .thermo_mechanical_displacement_snapshots,
9255            thermo_mechanical_von_mises_snapshots: nonlinear_run
9256                .thermo_mechanical_von_mises_snapshots,
9257            thermo_mechanical_coupling_residual_snapshots: nonlinear_run
9258                .thermo_mechanical_coupling_residual_snapshots,
9259            electro_thermal_temperature_snapshots: nonlinear_run
9260                .electro_thermal_temperature_snapshots,
9261            electro_thermal_thermal_residual_snapshots: nonlinear_run
9262                .electro_thermal_thermal_residual_snapshots,
9263            residual_norms: nonlinear_run.residual_norms,
9264            increment_norms: nonlinear_run.increment_norms,
9265            iteration_counts: nonlinear_run.iteration_counts,
9266            failed_increments: nonlinear_run.failed_increments,
9267            line_search_backtracks: nonlinear_run.line_search_backtracks,
9268            max_line_search_backtracks_per_increment: nonlinear_run
9269                .max_line_search_backtracks_per_increment,
9270            tangent_rebuild_count: nonlinear_run.tangent_rebuild_count,
9271            iteration_spike_count: nonlinear_run.iteration_spike_count,
9272            convergence_stall_count: nonlinear_run.convergence_stall_count,
9273            backtrack_burst_count: nonlinear_run.backtrack_burst_count,
9274            method: NonlinearMethod::IncrementalNewtonRaphson,
9275        }),
9276        electromagnetic_results: None,
9277        model_validity: QualityGate::Pass,
9278        solver_convergence,
9279        result_quality,
9280        run_status,
9281        publishable,
9282        quality_reasons,
9283        provenance: RunProvenance {
9284            backend,
9285            solver_backend,
9286            solver_device_apply_k_ratio,
9287            solver_host_sync_count,
9288            precision_mode: contracts::format_precision_mode(options.precision_mode),
9289            deterministic_mode: options.deterministic_mode,
9290            solver_method,
9291            preconditioner: selected_preconditioner,
9292            quality_policy: contracts::format_quality_policy(options.quality_policy),
9293            fallback_events,
9294        },
9295    };
9296
9297    persist_fea_run_result_with_progress(
9298        ANALYSIS_RUN_NONLINEAR_OPERATION,
9299        ANALYSIS_RUN_NONLINEAR_OP_VERSION,
9300        "RM.FEA.RUN_NONLINEAR.ARTIFACT_STORE_FAILED",
9301        &context,
9302        &result,
9303    )?;
9304
9305    Ok(OperationEnvelope::new(
9306        ANALYSIS_RUN_NONLINEAR_OPERATION,
9307        ANALYSIS_RUN_NONLINEAR_OP_VERSION,
9308        &context,
9309        result,
9310    ))
9311}
9312
9313pub fn analysis_run_linear_static_with_options(
9314    model: &AnalysisModel,
9315    backend: ComputeBackend,
9316    options: AnalysisRunOptions,
9317    context: OperationContext,
9318) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
9319    let _solver_context = install_fea_solver_context();
9320    let thermo_options = resolve_thermo_coupling_options(
9321        model,
9322        model_thermo_coupling_options(model),
9323        ANALYSIS_RUN_OPERATION,
9324        ANALYSIS_RUN_OP_VERSION,
9325        &context,
9326    )?;
9327    if let Some(thermo_options) = thermo_options.as_ref() {
9328        if let Err((detail, metadata)) = validate_thermo_coupling_options(model, thermo_options) {
9329            return Err(operation_error(
9330                ANALYSIS_RUN_OPERATION,
9331                ANALYSIS_RUN_OP_VERSION,
9332                &context,
9333                OperationErrorSpec {
9334                    error_code: "RM.FEA.RUN_LINEAR_STATIC.INVALID_OPTIONS",
9335                    error_type: OperationErrorType::Input,
9336                    retryable: false,
9337                    severity: OperationErrorSeverity::Error,
9338                },
9339                detail,
9340                metadata,
9341            ));
9342        }
9343    }
9344    let electro_options = model_electro_coupling_options(model);
9345    if let Some(electro_options) = electro_options.as_ref() {
9346        if let Err((detail, metadata)) = validate_electro_coupling_options(model, electro_options) {
9347            return Err(operation_error(
9348                ANALYSIS_RUN_OPERATION,
9349                ANALYSIS_RUN_OP_VERSION,
9350                &context,
9351                OperationErrorSpec {
9352                    error_code: electro_thermal_invalid_options_error_code(ANALYSIS_RUN_OPERATION),
9353                    error_type: OperationErrorType::Input,
9354                    retryable: false,
9355                    severity: OperationErrorSeverity::Error,
9356                },
9357                detail,
9358                metadata,
9359            ));
9360        }
9361    }
9362
9363    let requested_preconditioner = match options.preconditioner_mode {
9364        PreconditionerMode::Auto | PreconditionerMode::Jacobi => SpdPreconditionerKind::Jacobi,
9365        PreconditionerMode::Ilu => SpdPreconditionerKind::Ilu0,
9366        PreconditionerMode::Amg => SpdPreconditionerKind::Jacobi,
9367    };
9368    let runtime_tensor_available = runmat_accelerate_api::provider().is_some();
9369    let requested_solver_backend = match backend {
9370        ComputeBackend::Cpu => LinearAlgebraBackendKind::CpuReference,
9371        ComputeBackend::Gpu => {
9372            if runtime_tensor_available {
9373                LinearAlgebraBackendKind::RuntimeTensor
9374            } else {
9375                LinearAlgebraBackendKind::CpuReference
9376            }
9377        }
9378    };
9379    let prep_context = resolve_run_prep_context(
9380        model,
9381        options.prep_artifact_id.as_deref(),
9382        options.prep_context.clone(),
9383        ANALYSIS_RUN_OPERATION,
9384        ANALYSIS_RUN_OP_VERSION,
9385        &context,
9386    )?;
9387    let analysis_mesh = resolve_analysis_mesh_artifact(
9388        options.analysis_mesh_artifact_path.as_deref(),
9389        ANALYSIS_RUN_OPERATION,
9390        ANALYSIS_RUN_OP_VERSION,
9391        &context,
9392    )?;
9393    let analysis_mesh_validation_evidence = resolve_analysis_mesh_validation_evidence_status(
9394        options.analysis_mesh_artifact_path.as_deref(),
9395        ANALYSIS_RUN_OPERATION,
9396        ANALYSIS_RUN_OP_VERSION,
9397        &context,
9398    )?;
9399    let run = run_linear_static_with_options(
9400        model,
9401        backend,
9402        LinearStaticSolveOptions {
9403            preconditioner_kind: requested_preconditioner,
9404            algebra_backend_kind: requested_solver_backend,
9405            prep_context: to_fea_prep_context(
9406                prep_context.as_ref(),
9407                options.prep_calibration_profile,
9408            ),
9409            analysis_mesh_artifact_path: options.analysis_mesh_artifact_path.clone(),
9410            analysis_mesh: analysis_mesh.clone(),
9411            require_analysis_mesh_for_solid: true,
9412            thermo_mechanical_context: to_fea_thermo_mechanical_context(thermo_options),
9413            electro_thermal_context: to_fea_electro_thermal_context(electro_options),
9414        },
9415    )
9416    .map_err(|err| {
9417        map_fea_run_error(
9418            ANALYSIS_RUN_OPERATION,
9419            ANALYSIS_RUN_OP_VERSION,
9420            "RM.FEA.RUN_LINEAR_STATIC.SOLVER_MODEL_INVALID",
9421            "RM.FEA.RUN_LINEAR_STATIC.CANCELLED",
9422            model,
9423            &context,
9424            err,
9425        )
9426    })?;
9427
9428    let mut run = run;
9429    append_solved_adaptive_mesh_summary(
9430        options.analysis_mesh_artifact_path.as_deref(),
9431        &run.fields,
9432    )
9433    .map_err(|err| {
9434        operation_error(
9435            ANALYSIS_RUN_OPERATION,
9436            ANALYSIS_RUN_OP_VERSION,
9437            &context,
9438            OperationErrorSpec {
9439                error_code: "RM.FEA.RUN_LINEAR_STATIC.ARTIFACT_STORE_FAILED",
9440                error_type: OperationErrorType::Internal,
9441                retryable: true,
9442                severity: OperationErrorSeverity::Error,
9443            },
9444            format!("failed to update analysis mesh adaptive summary: {err}"),
9445            BTreeMap::from([(
9446                "analysis_mesh_artifact_path".to_string(),
9447                options
9448                    .analysis_mesh_artifact_path
9449                    .clone()
9450                    .unwrap_or_default(),
9451            )]),
9452        )
9453    })?;
9454    let mut fallback_events = Vec::new();
9455    promotion::promote_run_fields_to_device_refs(&mut run, &mut fallback_events);
9456
9457    match options.preconditioner_mode {
9458        PreconditionerMode::Auto | PreconditionerMode::Jacobi => {}
9459        PreconditionerMode::Ilu => {
9460            if run.preconditioner != "ilu0" {
9461                fallback_events.push(format!(
9462                    "SOLVER_PRECONDITIONER_FALLBACK:requested=ilu0:using={}",
9463                    run.preconditioner
9464                ));
9465            }
9466        }
9467        PreconditionerMode::Amg => {
9468            fallback_events
9469                .push("SOLVER_PRECONDITIONER_FALLBACK:requested=amg:using=jacobi".to_string());
9470        }
9471    }
9472
9473    if backend == ComputeBackend::Gpu && run.solver_backend != "runtime_tensor" {
9474        fallback_events.push(
9475            "SOLVER_BACKEND_FALLBACK:requested=runtime_tensor:using=cpu_reference".to_string(),
9476        );
9477    }
9478
9479    let solver_convergence = if run.diagnostics.iter().any(|item| {
9480        item.code == "FEA_CONVERGENCE"
9481            && item.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
9482    }) {
9483        QualityGate::Pass
9484    } else {
9485        QualityGate::Warn
9486    };
9487
9488    let has_material_assignment_conflict = run.diagnostics.iter().any(|diag| {
9489        diag.code
9490            .starts_with("ANALYSIS_MATERIAL_ASSIGNMENT_CONFLICT_")
9491    });
9492    let field_topology_reasons =
9493        field_topology_quality_reasons(&run.fields, analysis_mesh.as_ref());
9494    let solid_mesh_reasons = solid_mesh_quality_reasons(model, analysis_mesh.as_ref());
9495    let mesh_validation_reasons =
9496        mesh_validation_evidence_quality_reasons(&analysis_mesh_validation_evidence);
9497    let missing_solid_mesh_reasons =
9498        missing_solid_analysis_mesh_reasons(model, analysis_mesh.as_ref());
9499    let solid_mesh_has_failure = solid_mesh_reasons.iter().any(|reason| {
9500        matches!(
9501            reason.code,
9502            QualityReasonCode::SolidMeshNoVolumeElements
9503                | QualityReasonCode::SolidMeshUnsupportedElementKind
9504                | QualityReasonCode::SolidMeshUnmappedLoadRegion
9505                | QualityReasonCode::SolidMeshUnmappedBoundaryConditionRegion
9506                | QualityReasonCode::SolidMeshMaterialCoverageIncomplete
9507                | QualityReasonCode::SolidMeshRenderTopologyIncomplete
9508                | QualityReasonCode::SolidMeshQualityMinJacobianFailed
9509        )
9510    }) || !mesh_validation_reasons.is_empty();
9511    let result_quality =
9512        if run.fields_are_empty() || solid_mesh_has_failure || !field_topology_reasons.is_empty() {
9513            QualityGate::Fail
9514        } else if !missing_solid_mesh_reasons.is_empty()
9515            || !solid_mesh_reasons.is_empty()
9516            || has_material_assignment_conflict
9517        {
9518            QualityGate::Warn
9519        } else {
9520            QualityGate::Pass
9521        };
9522
9523    let mut quality_reasons = Vec::new();
9524    if has_material_assignment_conflict {
9525        quality_reasons.push(QualityReason {
9526            code: QualityReasonCode::MaterialAssignmentConflict,
9527            detail: "material assignment confidence conflict detected".to_string(),
9528        });
9529    }
9530    quality_reasons.extend(solid_mesh_reasons);
9531    quality_reasons.extend(mesh_validation_reasons);
9532    quality_reasons.extend(field_topology_reasons);
9533    quality_reasons.extend(missing_solid_mesh_reasons);
9534    if solver_convergence == QualityGate::Warn {
9535        quality_reasons.push(QualityReason {
9536            code: QualityReasonCode::SolverNotConverged,
9537            detail: "solver convergence gate is warning".to_string(),
9538        });
9539    }
9540    if fallback_events
9541        .iter()
9542        .any(|event| event.starts_with("SOLVER_BACKEND_FALLBACK"))
9543    {
9544        quality_reasons.push(QualityReason {
9545            code: QualityReasonCode::SolverBackendFallback,
9546            detail: "solver backend fell back from runtime_tensor to cpu_reference".to_string(),
9547        });
9548    }
9549    if fallback_events.iter().any(|event| {
9550        event.starts_with("BACKEND_NO_PROVIDER") || event.starts_with("BACKEND_UPLOAD_FAILED")
9551    }) {
9552        quality_reasons.push(QualityReason {
9553            code: QualityReasonCode::FieldPromotionFallback,
9554            detail: "field promotion fell back to host-backed values".to_string(),
9555        });
9556    }
9557    let publishable = match options.quality_policy {
9558        QualityPolicy::Strict => {
9559            solver_convergence == QualityGate::Pass
9560                && result_quality == QualityGate::Pass
9561                && quality_reasons.is_empty()
9562        }
9563        QualityPolicy::Balanced => {
9564            solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
9565        }
9566        QualityPolicy::Exploratory => {
9567            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
9568        }
9569    };
9570    let run_status = if publishable {
9571        RunStatus::Publishable
9572    } else if result_quality == QualityGate::Fail {
9573        RunStatus::Rejected
9574    } else {
9575        RunStatus::Degraded
9576    };
9577    let solver_backend = run.solver_backend.clone();
9578    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
9579    let solver_host_sync_count = run.solver_host_sync_count;
9580    let solver_method = run.solver_method.clone();
9581    let preconditioner = run.preconditioner.clone();
9582
9583    let result = AnalysisRunResult {
9584        run_id: storage::next_run_id(),
9585        run,
9586        render_topology: render_topology_from_analysis_mesh(analysis_mesh.as_ref())
9587            .or_else(|| render_topology_from_prep_context(prep_context.as_ref())),
9588        modal_results: None,
9589        thermal_results: None,
9590        transient_results: None,
9591        nonlinear_results: None,
9592        electromagnetic_results: None,
9593        model_validity: QualityGate::Pass,
9594        solver_convergence,
9595        result_quality,
9596        run_status,
9597        publishable,
9598        quality_reasons,
9599        provenance: RunProvenance {
9600            backend,
9601            solver_backend,
9602            solver_device_apply_k_ratio,
9603            solver_host_sync_count,
9604            precision_mode: contracts::format_precision_mode(options.precision_mode),
9605            deterministic_mode: options.deterministic_mode,
9606            solver_method,
9607            preconditioner,
9608            quality_policy: contracts::format_quality_policy(options.quality_policy),
9609            fallback_events,
9610        },
9611    };
9612
9613    persist_fea_run_result_with_progress(
9614        ANALYSIS_RUN_OPERATION,
9615        ANALYSIS_RUN_OP_VERSION,
9616        "RM.FEA.RUN_LINEAR_STATIC.ARTIFACT_STORE_FAILED",
9617        &context,
9618        &result,
9619    )?;
9620
9621    Ok(OperationEnvelope::new(
9622        ANALYSIS_RUN_OPERATION,
9623        ANALYSIS_RUN_OP_VERSION,
9624        &context,
9625        result,
9626    ))
9627}
9628
9629fn field_topology_quality_reasons(
9630    fields: &[AnalysisField],
9631    analysis_mesh: Option<&AnalysisMeshArtifact>,
9632) -> Vec<QualityReason> {
9633    let Some(mesh) = analysis_mesh else {
9634        return Vec::new();
9635    };
9636    fields
9637        .iter()
9638        .filter_map(|field| primary_solver_mesh_field_expected_count(field, mesh).map(|expected| (field, expected)))
9639        .filter_map(|(field, expected)| {
9640            let descriptor = AnalysisFieldDescriptor::from_field(field);
9641            let actual = descriptor_entity_count(field, &descriptor);
9642            (actual != expected).then(|| QualityReason {
9643                code: QualityReasonCode::FieldTopologyMismatch,
9644                detail: format!(
9645                    "field {} topology mismatch: topology_id={} location={:?} element_kind={} expected_entity_count={} actual_entity_count={}",
9646                    field.field_id,
9647                    descriptor.topology_id.as_deref().unwrap_or("none"),
9648                    descriptor.location,
9649                    descriptor.element_kind.as_deref().unwrap_or("none"),
9650                    expected,
9651                    actual
9652                ),
9653            })
9654        })
9655        .collect()
9656}
9657
9658#[derive(Debug, Clone, PartialEq)]
9659enum MeshValidationEvidenceStatus {
9660    NotRequested,
9661    Missing { detail: String },
9662    Present(Box<MeshValidationEvidence>),
9663}
9664
9665fn mesh_validation_evidence_quality_reasons(
9666    validation: &MeshValidationEvidenceStatus,
9667) -> Vec<QualityReason> {
9668    let validation = match validation {
9669        MeshValidationEvidenceStatus::NotRequested => return Vec::new(),
9670        MeshValidationEvidenceStatus::Missing { detail } => {
9671            return vec![QualityReason {
9672                code: QualityReasonCode::SolidMeshValidationEvidenceMissing,
9673                detail: format!("analysis mesh evidence is missing: {detail}"),
9674            }];
9675        }
9676        MeshValidationEvidenceStatus::Present(validation) => validation,
9677    };
9678    if validation.solve_ready {
9679        return Vec::new();
9680    }
9681    let code = validation
9682        .validation_error_code
9683        .as_deref()
9684        .unwrap_or("unknown");
9685    let message = validation
9686        .validation_error_message
9687        .as_deref()
9688        .unwrap_or("mesh evidence validation did not include a message");
9689    let recovery_detail = mesh_validation_recovery_detail(validation);
9690    vec![QualityReason {
9691        code: QualityReasonCode::SolidMeshValidationEvidenceFailed,
9692        detail: format!(
9693            "analysis mesh evidence is not solve-ready: validation_error_code={code}; {message}{recovery_detail}"
9694        ),
9695    }]
9696}
9697
9698fn mesh_validation_recovery_detail(validation: &MeshValidationEvidence) -> String {
9699    let mut details = Vec::<String>::new();
9700    if validation.unrecovered_tetrahedron_component_count > 0 {
9701        details.push(format!(
9702            "unrecovered_tetrahedron_component_count={}",
9703            validation.unrecovered_tetrahedron_component_count
9704        ));
9705    }
9706    if validation.unrepaired_exact_quality_total_count > 0
9707        || validation.unrepaired_exact_quality_general_cavity_count > 0
9708        || validation.unrepaired_exact_quality_boundary_adjacent_count > 0
9709        || validation.unrepaired_exact_quality_node_adjacent_count > 0
9710        || validation.unrepaired_exact_quality_interior_seed_count > 0
9711        || validation.unrepaired_exact_quality_edge_star_count > 0
9712    {
9713        details.push(format!(
9714            "unrepaired_exact_quality_total_count={}",
9715            validation.unrepaired_exact_quality_total_count
9716        ));
9717        details.push(format!(
9718            "unrepaired_exact_quality_general_cavity_count={}",
9719            validation.unrepaired_exact_quality_general_cavity_count
9720        ));
9721        details.push(format!(
9722            "unrepaired_exact_quality_boundary_adjacent_count={}",
9723            validation.unrepaired_exact_quality_boundary_adjacent_count
9724        ));
9725        details.push(format!(
9726            "unrepaired_exact_quality_node_adjacent_count={}",
9727            validation.unrepaired_exact_quality_node_adjacent_count
9728        ));
9729        details.push(format!(
9730            "unrepaired_exact_quality_interior_seed_count={}",
9731            validation.unrepaired_exact_quality_interior_seed_count
9732        ));
9733        details.push(format!(
9734            "unrepaired_exact_quality_edge_star_count={}",
9735            validation.unrepaired_exact_quality_edge_star_count
9736        ));
9737    }
9738    if details.is_empty() {
9739        String::new()
9740    } else {
9741        format!("; {}", details.join("; "))
9742    }
9743}
9744
9745fn solid_mesh_quality_reasons(
9746    model: &AnalysisModel,
9747    analysis_mesh: Option<&AnalysisMeshArtifact>,
9748) -> Vec<QualityReason> {
9749    let Some(mesh) = analysis_mesh else {
9750        return Vec::new();
9751    };
9752    let mut reasons = Vec::new();
9753    if mesh.volume_elements.is_empty() {
9754        reasons.push(QualityReason {
9755            code: QualityReasonCode::SolidMeshNoVolumeElements,
9756            detail: "analysis mesh has no volume elements".to_string(),
9757        });
9758    }
9759    if let Some(element) = mesh
9760        .volume_elements
9761        .iter()
9762        .find(|element| !element.kind.is_supported_for_solid_solve())
9763    {
9764        reasons.push(QualityReason {
9765            code: QualityReasonCode::SolidMeshUnsupportedElementKind,
9766            detail: format!(
9767                "analysis mesh contains unsupported solid element kind {:?} at element {}",
9768                element.kind, element.element_id
9769            ),
9770        });
9771    }
9772    if let Some(detail) = material_coverage_gap_detail(model, mesh) {
9773        reasons.push(QualityReason {
9774            code: QualityReasonCode::SolidMeshMaterialCoverageIncomplete,
9775            detail,
9776        });
9777    }
9778    if let Some(detail) = render_topology_gap_detail(mesh) {
9779        reasons.push(QualityReason {
9780            code: QualityReasonCode::SolidMeshRenderTopologyIncomplete,
9781            detail,
9782        });
9783    }
9784    reasons.extend(boundary_region_mapping_reasons(model, mesh));
9785    let thresholds = runmat_meshing_core::QualityThresholds::default();
9786    if !mesh.quality.min_scaled_jacobian.is_finite()
9787        || mesh.quality.min_scaled_jacobian < thresholds.min_scaled_jacobian
9788    {
9789        reasons.push(QualityReason {
9790            code: QualityReasonCode::SolidMeshQualityMinJacobianFailed,
9791            detail: format!(
9792                "analysis mesh min_scaled_jacobian={} is below threshold {}",
9793                mesh.quality.min_scaled_jacobian, thresholds.min_scaled_jacobian
9794            ),
9795        });
9796    }
9797    if !mesh.quality.max_aspect_ratio.is_finite()
9798        || mesh.quality.max_aspect_ratio > thresholds.max_aspect_ratio
9799    {
9800        reasons.push(QualityReason {
9801            code: QualityReasonCode::SolidMeshQualityAspectRatioWarn,
9802            detail: format!(
9803                "analysis mesh max_aspect_ratio={} exceeds warning threshold {}",
9804                mesh.quality.max_aspect_ratio, thresholds.max_aspect_ratio
9805            ),
9806        });
9807    }
9808    if let Some(thresholds) = boundary_projection_warning_thresholds_m(mesh) {
9809        if !mesh.quality.max_boundary_projection_error_m.is_finite()
9810            || !mesh.quality.mean_boundary_projection_error_m.is_finite()
9811            || mesh.quality.max_boundary_projection_error_m > thresholds.max_error_m
9812            || mesh.quality.mean_boundary_projection_error_m > thresholds.mean_error_m
9813        {
9814            reasons.push(QualityReason {
9815                code: QualityReasonCode::SolidMeshBoundaryProjectionWarn,
9816                detail: format!(
9817                    "analysis mesh max_boundary_projection_error_m={} or mean_boundary_projection_error_m={} exceeds warning thresholds max={} mean={}; boundary faces are a carved-grid approximation of the source surface",
9818                    mesh.quality.max_boundary_projection_error_m,
9819                    mesh.quality.mean_boundary_projection_error_m,
9820                    thresholds.max_error_m,
9821                    thresholds.mean_error_m
9822                ),
9823            });
9824        }
9825    }
9826    reasons
9827}
9828
9829fn render_topology_gap_detail(mesh: &AnalysisMeshArtifact) -> Option<String> {
9830    if mesh.volume_elements.is_empty() {
9831        return None;
9832    }
9833    let mut renderable_boundary_face_count = 0_usize;
9834    for face in &mesh.boundary_faces {
9835        if face.kind != runmat_meshing_core::BoundaryElementKind::Tri3 || face.node_ids.len() != 3 {
9836            continue;
9837        }
9838        renderable_boundary_face_count += 1;
9839    }
9840    if renderable_boundary_face_count == 0 {
9841        return Some(format!(
9842            "analysis mesh has {} volume elements but no renderable Tri3 boundary faces for solver field visualization",
9843            mesh.volume_elements.len()
9844        ));
9845    }
9846    if let Err(err) = validate_analysis_mesh_solver_field_mapping(mesh) {
9847        return Some(format!(
9848            "analysis mesh render topology is incomplete: renderable_boundary_face_count={} field_mapping_error={}",
9849            renderable_boundary_face_count, err
9850        ));
9851    }
9852    None
9853}
9854
9855fn validate_analysis_mesh_solver_field_mapping(mesh: &AnalysisMeshArtifact) -> Result<(), String> {
9856    let element_values = vec![0.0_f64; mesh.volume_elements.len()];
9857    runmat_meshing::map_volume_scalar_field_to_boundary_faces(mesh, &element_values)
9858        .map_err(|err| err.to_string())?;
9859    let node_values = vec![[0.0_f64; 3]; mesh.nodes.len()];
9860    runmat_meshing::map_nodal_vector_field_to_boundary_nodes(mesh, &node_values)
9861        .map_err(|err| err.to_string())?;
9862    runmat_meshing::map_nodal_vector_field_to_boundary_faces(mesh, &node_values)
9863        .map_err(|err| err.to_string())?;
9864    Ok(())
9865}
9866
9867struct BoundaryProjectionWarningThresholds {
9868    max_error_m: f64,
9869    mean_error_m: f64,
9870}
9871
9872fn boundary_projection_warning_thresholds_m(
9873    mesh: &AnalysisMeshArtifact,
9874) -> Option<BoundaryProjectionWarningThresholds> {
9875    mesh.sizing
9876        .global_target_size_m
9877        .filter(|target_size_m| target_size_m.is_finite() && *target_size_m > 0.0)
9878        .map(|target_size_m| BoundaryProjectionWarningThresholds {
9879            max_error_m: target_size_m * 0.5,
9880            mean_error_m: target_size_m * 0.25,
9881        })
9882}
9883
9884fn missing_solid_analysis_mesh_reasons(
9885    model: &AnalysisModel,
9886    analysis_mesh: Option<&AnalysisMeshArtifact>,
9887) -> Vec<QualityReason> {
9888    if analysis_mesh.is_some() || model_has_explicit_structural_elements(model) {
9889        return Vec::new();
9890    }
9891    vec![QualityReason {
9892        code: QualityReasonCode::MissingSolidAnalysisMesh,
9893        detail: "linear static structural result requires a solver-ready solid analysis mesh"
9894            .to_string(),
9895    }]
9896}
9897
9898fn model_has_explicit_structural_elements(model: &AnalysisModel) -> bool {
9899    model
9900        .structural
9901        .as_ref()
9902        .is_some_and(|structural| !structural.elements.is_empty())
9903}
9904
9905fn boundary_region_mapping_reasons(
9906    model: &AnalysisModel,
9907    mesh: &AnalysisMeshArtifact,
9908) -> Vec<QualityReason> {
9909    let boundary_region_ids = mesh
9910        .boundary_faces
9911        .iter()
9912        .flat_map(|face| face.region_ids.iter().map(String::as_str))
9913        .collect::<HashSet<_>>();
9914    let mut reasons = Vec::new();
9915    for load in &model.loads {
9916        if !load_requires_boundary_region(&load.kind) {
9917            continue;
9918        }
9919        if !boundary_region_ids.contains(load.region_id.as_str()) {
9920            reasons.push(QualityReason {
9921                code: QualityReasonCode::SolidMeshUnmappedLoadRegion,
9922                detail: format!(
9923                    "load `{}` references region `{}` but the analysis mesh has no matching boundary faces",
9924                    load.load_id, load.region_id
9925                ),
9926            });
9927        }
9928    }
9929    for boundary_condition in &model.boundary_conditions {
9930        if !boundary_region_ids.contains(boundary_condition.region_id.as_str()) {
9931            reasons.push(QualityReason {
9932                code: QualityReasonCode::SolidMeshUnmappedBoundaryConditionRegion,
9933                detail: format!(
9934                    "boundary condition `{}` references region `{}` but the analysis mesh has no matching boundary faces",
9935                    boundary_condition.bc_id, boundary_condition.region_id
9936                ),
9937            });
9938        }
9939    }
9940    reasons
9941}
9942
9943fn load_requires_boundary_region(kind: &LoadKind) -> bool {
9944    matches!(
9945        kind,
9946        LoadKind::Force { .. }
9947            | LoadKind::Moment { .. }
9948            | LoadKind::Wrench { .. }
9949            | LoadKind::Pressure { .. }
9950    )
9951}
9952
9953fn material_coverage_gap_detail(
9954    model: &AnalysisModel,
9955    mesh: &AnalysisMeshArtifact,
9956) -> Option<String> {
9957    if mesh.volume_elements.is_empty() {
9958        return None;
9959    }
9960    if model.material_assignments.is_empty() {
9961        return (model.materials.len() != 1).then(|| {
9962            format!(
9963                "analysis mesh has {} material regions but model has {} materials and no material assignments",
9964                mesh.volume_elements
9965                    .iter()
9966                    .map(|element| element.material_region_id.as_str())
9967                    .collect::<HashSet<_>>()
9968                    .len(),
9969                model.materials.len()
9970            )
9971        });
9972    }
9973    if model.materials.len() == 1 {
9974        let material_id = model.materials[0].material_id.as_str();
9975        if model
9976            .material_assignments
9977            .iter()
9978            .all(|assignment| assignment.assigned_material_id == material_id)
9979        {
9980            return None;
9981        }
9982    }
9983    let assigned_region_ids = model
9984        .material_assignments
9985        .iter()
9986        .map(|assignment| assignment.region_id.as_str())
9987        .collect::<HashSet<_>>();
9988    let mut uncovered = mesh
9989        .volume_elements
9990        .iter()
9991        .map(|element| element.material_region_id.as_str())
9992        .filter(|region_id| !assigned_region_ids.contains(region_id))
9993        .collect::<Vec<_>>();
9994    uncovered.sort_unstable();
9995    uncovered.dedup();
9996    if uncovered.is_empty() {
9997        None
9998    } else {
9999        Some(format!(
10000            "analysis mesh volume material regions are not covered by model material assignments: {}",
10001            uncovered.join(",")
10002        ))
10003    }
10004}
10005
10006fn primary_solver_mesh_field_expected_count(
10007    field: &AnalysisField,
10008    mesh: &AnalysisMeshArtifact,
10009) -> Option<usize> {
10010    if matches!(
10011        field.field_id.as_str(),
10012        FEA_FIELD_STRUCTURAL_ROTATION
10013            | FEA_FIELD_STRUCTURAL_REACTION_FORCE
10014            | FEA_FIELD_STRUCTURAL_REACTION_MOMENT
10015    ) {
10016        return None;
10017    }
10018    if let Some(count) = mesh_field_topology_expected_count(field, mesh) {
10019        return Some(count);
10020    }
10021
10022    match field.field_id.as_str() {
10023        FEA_FIELD_STRUCTURAL_DISPLACEMENT | FEA_FIELD_STRUCTURAL_NODAL_VON_MISES => {
10024            Some(mesh.nodes.len())
10025        }
10026        FEA_FIELD_STRUCTURAL_STRAIN
10027        | FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY
10028        | FEA_FIELD_STRUCTURAL_STRESS
10029        | FEA_FIELD_STRUCTURAL_VON_MISES => Some(mesh.volume_elements.len()),
10030        _ => None,
10031    }
10032}
10033
10034fn mesh_field_topology_expected_count(
10035    field: &AnalysisField,
10036    mesh: &AnalysisMeshArtifact,
10037) -> Option<usize> {
10038    let descriptor = AnalysisFieldDescriptor::from_field(field);
10039    let topology_id = descriptor.topology_id.as_deref()?;
10040    let location = mesh_field_topology_location(descriptor.location)?;
10041    let element_kind = descriptor.element_kind.as_deref();
10042
10043    mesh.field_topology
10044        .iter()
10045        .find(|topology| {
10046            topology.topology_id == topology_id
10047                && topology.location == location
10048                && topology_element_kind_matches(topology, element_kind)
10049        })
10050        .map(|topology| topology.entity_count)
10051}
10052
10053fn mesh_field_topology_location(
10054    location: AnalysisFieldLocation,
10055) -> Option<AnalysisFieldTopologyLocation> {
10056    match location {
10057        AnalysisFieldLocation::Node => Some(AnalysisFieldTopologyLocation::Node),
10058        AnalysisFieldLocation::Element => Some(AnalysisFieldTopologyLocation::VolumeElement),
10059        AnalysisFieldLocation::BoundaryFace => Some(AnalysisFieldTopologyLocation::BoundaryFace),
10060        _ => None,
10061    }
10062}
10063
10064fn topology_element_kind_matches(
10065    topology: &AnalysisFieldTopologyDescriptor,
10066    element_kind: Option<&str>,
10067) -> bool {
10068    match (
10069        topology.element_kind.as_deref(),
10070        normalized_mesh_element_kind(element_kind),
10071    ) {
10072        (None, None) => true,
10073        (Some(left), Some(right)) => left == right,
10074        (None, Some(_)) => false,
10075        (Some(_), None) => false,
10076    }
10077}
10078
10079fn normalized_mesh_element_kind(element_kind: Option<&str>) -> Option<&str> {
10080    match element_kind {
10081        Some("tetrahedron4") => Some(TETRAHEDRON4_FIELD_ELEMENT_KIND),
10082        Some(other) => Some(other),
10083        None => None,
10084    }
10085}
10086
10087fn descriptor_entity_count(field: &AnalysisField, descriptor: &AnalysisFieldDescriptor) -> usize {
10088    if descriptor.entity_count > 0 {
10089        return descriptor.entity_count;
10090    }
10091    if matches!(
10092        descriptor.location,
10093        AnalysisFieldLocation::Global | AnalysisFieldLocation::Mode
10094    ) {
10095        return field.element_count();
10096    }
10097    if let Some(first_dim) = field.shape.first().copied() {
10098        if field.shape.len() > 1 || descriptor.component_count.is_some() {
10099            return first_dim;
10100        }
10101    }
10102    field.element_count()
10103}
10104
10105pub fn analysis_run_electromagnetic_op(
10106    model: &AnalysisModel,
10107    backend: ComputeBackend,
10108    context: OperationContext,
10109) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
10110    analysis_run_electromagnetic_with_options_op(
10111        model,
10112        backend,
10113        AnalysisElectromagneticRunOptions::default(),
10114        context,
10115    )
10116}
10117
10118pub fn analysis_run_electromagnetic_with_options_op(
10119    model: &AnalysisModel,
10120    backend: ComputeBackend,
10121    options: AnalysisElectromagneticRunOptions,
10122    context: OperationContext,
10123) -> Result<OperationEnvelope<AnalysisRunResult>, OperationErrorEnvelope> {
10124    let _solver_context = install_fea_solver_context();
10125    let has_electromagnetic_step = model
10126        .steps
10127        .iter()
10128        .any(|step| step.kind == AnalysisStepKind::Electromagnetic);
10129    if !has_electromagnetic_step {
10130        return Err(operation_error(
10131            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10132            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10133            &context,
10134            OperationErrorSpec {
10135                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.REQUIRES_STEP",
10136                error_type: OperationErrorType::Validation,
10137                retryable: false,
10138                severity: OperationErrorSeverity::Error,
10139            },
10140            "FEA model must include at least one electromagnetic step for fea.run_electromagnetic",
10141            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
10142        ));
10143    }
10144
10145    let Some(em_domain) = model.electromagnetic.as_ref() else {
10146        return Err(operation_error(
10147            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10148            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10149            &context,
10150            OperationErrorSpec {
10151                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_MODEL",
10152                error_type: OperationErrorType::Validation,
10153                retryable: false,
10154                severity: OperationErrorSeverity::Error,
10155            },
10156            "fea.run_electromagnetic requires model.electromagnetic to be configured",
10157            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
10158        ));
10159    };
10160    if !em_domain.enabled {
10161        return Err(operation_error(
10162            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10163            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10164            &context,
10165            OperationErrorSpec {
10166                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10167                error_type: OperationErrorType::Input,
10168                retryable: false,
10169                severity: OperationErrorSeverity::Error,
10170            },
10171            "fea.run_electromagnetic requires electromagnetic domain enabled=true",
10172            BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
10173        ));
10174    }
10175    if !em_domain.reference_frequency_hz.is_finite() || em_domain.reference_frequency_hz <= 0.0 {
10176        return Err(operation_error(
10177            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10178            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10179            &context,
10180            OperationErrorSpec {
10181                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10182                error_type: OperationErrorType::Input,
10183                retryable: false,
10184                severity: OperationErrorSeverity::Error,
10185            },
10186            "fea.run_electromagnetic requires finite positive reference_frequency_hz",
10187            BTreeMap::from([(
10188                "reference_frequency_hz".to_string(),
10189                em_domain.reference_frequency_hz.to_string(),
10190            )]),
10191        ));
10192    }
10193    if !em_domain.applied_current_a.is_finite() || em_domain.applied_current_a <= 0.0 {
10194        return Err(operation_error(
10195            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10196            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10197            &context,
10198            OperationErrorSpec {
10199                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10200                error_type: OperationErrorType::Input,
10201                retryable: false,
10202                severity: OperationErrorSeverity::Error,
10203            },
10204            "fea.run_electromagnetic requires finite positive applied_current_a",
10205            BTreeMap::from([(
10206                "applied_current_a".to_string(),
10207                em_domain.applied_current_a.to_string(),
10208            )]),
10209        ));
10210    }
10211    if !options.residual_target.is_finite() || options.residual_target <= 0.0 {
10212        return Err(operation_error(
10213            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10214            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10215            &context,
10216            OperationErrorSpec {
10217                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10218                error_type: OperationErrorType::Input,
10219                retryable: false,
10220                severity: OperationErrorSeverity::Error,
10221            },
10222            "fea.run_electromagnetic requires residual_target to be finite and positive",
10223            BTreeMap::from([(
10224                "residual_target".to_string(),
10225                options.residual_target.to_string(),
10226            )]),
10227        ));
10228    }
10229    if !options.harmonic_tolerance.is_finite() || options.harmonic_tolerance <= 0.0 {
10230        return Err(operation_error(
10231            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10232            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10233            &context,
10234            OperationErrorSpec {
10235                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10236                error_type: OperationErrorType::Input,
10237                retryable: false,
10238                severity: OperationErrorSeverity::Error,
10239            },
10240            "fea.run_electromagnetic requires harmonic_tolerance to be finite and positive",
10241            BTreeMap::from([(
10242                "harmonic_tolerance".to_string(),
10243                options.harmonic_tolerance.to_string(),
10244            )]),
10245        ));
10246    }
10247    if options.harmonic_max_iterations == 0 {
10248        return Err(operation_error(
10249            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10250            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10251            &context,
10252            OperationErrorSpec {
10253                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10254                error_type: OperationErrorType::Input,
10255                retryable: false,
10256                severity: OperationErrorSeverity::Error,
10257            },
10258            "fea.run_electromagnetic requires harmonic_max_iterations greater than zero",
10259            BTreeMap::from([(
10260                "harmonic_max_iterations".to_string(),
10261                options.harmonic_max_iterations.to_string(),
10262            )]),
10263        ));
10264    }
10265    validate_electromagnetic_run_model(model, &context)?;
10266
10267    let prep_context = resolve_run_prep_context(
10268        model,
10269        options.prep_artifact_id.as_deref(),
10270        options.prep_context.clone(),
10271        ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10272        ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10273        &context,
10274    )?;
10275
10276    let sweep_frequency_hz = normalize_em_sweep_frequency_hz(
10277        em_domain.reference_frequency_hz,
10278        options.sweep_enabled,
10279        &options.sweep_frequency_hz,
10280    )
10281    .ok_or_else(|| {
10282        operation_error(
10283            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10284            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10285            &context,
10286            OperationErrorSpec {
10287                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_OPTIONS",
10288                error_type: OperationErrorType::Input,
10289                retryable: false,
10290                severity: OperationErrorSeverity::Error,
10291            },
10292            "fea.run_electromagnetic sweep_frequency_hz must contain finite positive values",
10293            BTreeMap::new(),
10294        )
10295    })?;
10296    let solve_options = ElectromagneticSolveOptions {
10297        prep_context: to_fea_prep_context(prep_context.as_ref(), options.prep_calibration_profile),
10298        residual_target: options.residual_target,
10299        harmonic_tolerance: options.harmonic_tolerance,
10300        harmonic_max_iterations: options.harmonic_max_iterations,
10301    };
10302    let mut sweep_runs = Vec::with_capacity(sweep_frequency_hz.len());
10303    let mut sweep_peak_flux_density = Vec::with_capacity(sweep_frequency_hz.len());
10304    let mut sweep_solve_quality = Vec::with_capacity(sweep_frequency_hz.len());
10305    for frequency_hz in &sweep_frequency_hz {
10306        let mut sweep_model = model.clone();
10307        if let Some(domain) = sweep_model.electromagnetic.as_mut() {
10308            domain.reference_frequency_hz = *frequency_hz;
10309        }
10310        let sweep_run =
10311            run_electromagnetic_with_options(&sweep_model, backend, solve_options.clone())
10312                .map_err(|err| {
10313                    map_fea_run_error(
10314                        ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10315                        ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10316                        "RM.FEA.RUN_ELECTROMAGNETIC.SOLVER_MODEL_INVALID",
10317                        "RM.FEA.RUN_ELECTROMAGNETIC.CANCELLED",
10318                        model,
10319                        &context,
10320                        err,
10321                    )
10322                })?;
10323        sweep_peak_flux_density.push(peak_abs_field_value(
10324            &sweep_run.magnetic_flux_density_magnitude_field,
10325        ));
10326        sweep_solve_quality.push(sweep_run.solve_quality);
10327        sweep_runs.push(sweep_run);
10328    }
10329    let sweep_metrics = summarize_em_sweep(&sweep_frequency_hz, &sweep_peak_flux_density);
10330    let primary_index =
10331        nearest_frequency_index(&sweep_frequency_hz, em_domain.reference_frequency_hz).unwrap_or(0);
10332    let em_run = sweep_runs[primary_index].clone();
10333    let mut run = em_run.run.clone();
10334    run.diagnostics.push(runmat_analysis_fea::diagnostics::FeaDiagnostic {
10335        code: "FEA_EM_SWEEP".to_string(),
10336        severity: if sweep_metrics.sweep_count > 1 {
10337            runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
10338        } else {
10339            runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
10340        },
10341        message: format!(
10342            "sweep_count={} resonance_peak_frequency_hz={} resonance_peak_flux_density={} resonance_bandwidth_hz={} resonance_quality_factor={} resonance_flux_gain={}",
10343            sweep_metrics.sweep_count,
10344            sweep_metrics.resonance_peak_frequency_hz.unwrap_or(0.0),
10345            sweep_metrics.resonance_peak_flux_density.unwrap_or(0.0),
10346            sweep_metrics.resonance_bandwidth_hz.unwrap_or(0.0),
10347            sweep_metrics.resonance_quality_factor.unwrap_or(0.0),
10348            sweep_metrics.resonance_flux_gain.unwrap_or(0.0),
10349        ),
10350    });
10351    if sweep_metrics.sweep_count > 1 {
10352        run.diagnostics.push(em_sweep_known_answer_diagnostic(
10353            em_domain.reference_frequency_hz,
10354            &sweep_frequency_hz,
10355            &sweep_metrics,
10356        ));
10357    }
10358    let solver_convergence = if run.diagnostics.iter().any(|diag| {
10359        diag.code == "FEA_EM_STATIC"
10360            && diag.severity == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
10361    }) {
10362        QualityGate::Pass
10363    } else {
10364        QualityGate::Warn
10365    };
10366    let mut result_quality = if em_run.solve_quality >= 0.85 {
10367        QualityGate::Pass
10368    } else if em_run.solve_quality >= 0.6 {
10369        QualityGate::Warn
10370    } else {
10371        QualityGate::Fail
10372    };
10373    let mut quality_reasons = Vec::new();
10374    let em_conductivity_spread_ratio = diagnostic_metric(
10375        &run.diagnostics,
10376        "FEA_EM_STATIC",
10377        "conductivity_spread_ratio",
10378    );
10379    let em_assignment_heterogeneity_index = diagnostic_metric(
10380        &run.diagnostics,
10381        "FEA_EM_STATIC",
10382        "electromagnetic_material_heterogeneity_index",
10383    );
10384    let em_assignment_coverage_ratio = diagnostic_metric(
10385        &run.diagnostics,
10386        "FEA_EM_STATIC",
10387        "assignment_coverage_ratio",
10388    );
10389    let em_assigned_coefficient_coverage_ratio = diagnostic_metric(
10390        &run.diagnostics,
10391        "FEA_EM_STATIC",
10392        "assigned_coefficient_coverage_ratio",
10393    );
10394    let em_region_contrast_index = diagnostic_metric(
10395        &run.diagnostics,
10396        "FEA_EM_STATIC",
10397        "region_coefficient_contrast_index",
10398    );
10399    let em_condition_number_estimate = diagnostic_metric(
10400        &run.diagnostics,
10401        "FEA_EM_STATIC",
10402        "condition_number_estimate",
10403    );
10404    let em_source_realization_ratio = diagnostic_metric(
10405        &run.diagnostics,
10406        "FEA_EM_SOURCE_ENERGY",
10407        "source_realization_ratio",
10408    );
10409    let em_source_region_coverage_ratio = diagnostic_metric(
10410        &run.diagnostics,
10411        "FEA_EM_SOURCE_ENERGY",
10412        "source_region_coverage_ratio",
10413    );
10414    let em_source_material_alignment_ratio = diagnostic_metric(
10415        &run.diagnostics,
10416        "FEA_EM_SOURCE_ENERGY",
10417        "source_material_alignment_ratio",
10418    );
10419    let em_source_overlap_ratio = diagnostic_metric(
10420        &run.diagnostics,
10421        "FEA_EM_SOURCE_ENERGY",
10422        "source_overlap_ratio",
10423    );
10424    let em_source_interference_index = diagnostic_metric(
10425        &run.diagnostics,
10426        "FEA_EM_SOURCE_ENERGY",
10427        "source_interference_index",
10428    );
10429    let em_boundary_anchor_ratio = diagnostic_metric(
10430        &run.diagnostics,
10431        "FEA_EM_SOURCE_ENERGY",
10432        "boundary_anchor_ratio",
10433    );
10434    let em_boundary_condition_localization_ratio = diagnostic_metric(
10435        &run.diagnostics,
10436        "FEA_EM_SOURCE_ENERGY",
10437        "boundary_condition_localization_ratio",
10438    );
10439    let em_ground_anchor_effectiveness_ratio = diagnostic_metric(
10440        &run.diagnostics,
10441        "FEA_EM_SOURCE_ENERGY",
10442        "ground_anchor_effectiveness_ratio",
10443    );
10444    let em_insulation_leakage_ratio = diagnostic_metric(
10445        &run.diagnostics,
10446        "FEA_EM_SOURCE_ENERGY",
10447        "insulation_leakage_ratio",
10448    );
10449    let em_flux_divergence_ratio =
10450        diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "flux_divergence_ratio");
10451    let em_energy_imbalance_ratio = diagnostic_metric(
10452        &run.diagnostics,
10453        "FEA_EM_SOURCE_ENERGY",
10454        "energy_imbalance_ratio",
10455    );
10456    let em_boundary_energy_ratio = diagnostic_metric(
10457        &run.diagnostics,
10458        "FEA_EM_SOURCE_ENERGY",
10459        "boundary_energy_ratio",
10460    );
10461    let em_boundary_penalty_conditioning_contribution = diagnostic_metric(
10462        &run.diagnostics,
10463        "FEA_EM_SOURCE_ENERGY",
10464        "boundary_penalty_conditioning_contribution",
10465    );
10466    let em_source_region_energy_consistency_ratio = diagnostic_metric(
10467        &run.diagnostics,
10468        "FEA_EM_SOURCE_ENERGY",
10469        "source_region_energy_consistency_ratio",
10470    );
10471    let em_real_residual_norm =
10472        diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "real_residual_norm");
10473    let em_imag_residual_norm =
10474        diagnostic_metric(&run.diagnostics, "FEA_EM_STATIC", "imag_residual_norm");
10475    let em_sweep_count = diagnostic_metric(&run.diagnostics, "FEA_EM_SWEEP", "sweep_count");
10476    let em_resonance_quality_factor =
10477        diagnostic_metric(&run.diagnostics, "FEA_EM_SWEEP", "resonance_quality_factor");
10478    let ElectromagneticQualityThresholds {
10479        em_spread_threshold,
10480        em_heterogeneity_threshold,
10481        em_coverage_min_threshold,
10482        em_contrast_max_threshold,
10483        em_conditioning_max_threshold,
10484        em_source_realization_min_threshold,
10485        em_source_region_coverage_min_threshold,
10486        em_source_material_alignment_min_threshold,
10487        em_source_overlap_max_threshold,
10488        em_source_interference_max_threshold,
10489        em_boundary_anchor_min_threshold,
10490        em_boundary_localization_min_threshold,
10491        em_ground_effectiveness_min_threshold,
10492        em_insulation_leakage_max_threshold,
10493        em_divergence_max_threshold,
10494        em_energy_imbalance_max_threshold,
10495        em_boundary_energy_min_threshold,
10496        em_boundary_penalty_contribution_max_threshold,
10497        em_source_region_energy_consistency_min_threshold,
10498        em_real_residual_max_threshold,
10499        em_imag_residual_max_threshold,
10500    } = electromagnetic_thresholds_for_policy(options.quality_policy);
10501    let (em_sweep_count_min_threshold, em_resonance_q_min_threshold) =
10502        electromagnetic_sweep_thresholds_for_policy(options.quality_policy);
10503    let em_spread_breach = em_conductivity_spread_ratio
10504        .map(|value| value > em_spread_threshold)
10505        .unwrap_or(false);
10506    let em_heterogeneity_breach = em_assignment_heterogeneity_index
10507        .map(|value| value > em_heterogeneity_threshold)
10508        .unwrap_or(false);
10509    let em_coverage_breach = em_assignment_coverage_ratio
10510        .map(|value| value < em_coverage_min_threshold)
10511        .unwrap_or(false);
10512    let em_assigned_coefficient_breach = em_assigned_coefficient_coverage_ratio
10513        .map(|value| value < em_coverage_min_threshold)
10514        .unwrap_or(false);
10515    let em_contrast_breach = em_region_contrast_index
10516        .map(|value| value > em_contrast_max_threshold)
10517        .unwrap_or(false);
10518    let em_conditioning_breach = em_condition_number_estimate
10519        .map(|value| value > em_conditioning_max_threshold)
10520        .unwrap_or(false);
10521    let em_source_realization_breach = em_source_realization_ratio
10522        .map(|value| value < em_source_realization_min_threshold)
10523        .unwrap_or(false);
10524    let em_source_region_coverage_breach = em_source_region_coverage_ratio
10525        .map(|value| value < em_source_region_coverage_min_threshold)
10526        .unwrap_or(false);
10527    let em_source_material_alignment_breach = em_source_material_alignment_ratio
10528        .map(|value| value < em_source_material_alignment_min_threshold)
10529        .unwrap_or(false);
10530    let em_source_overlap_breach = em_source_overlap_ratio
10531        .map(|value| value > em_source_overlap_max_threshold)
10532        .unwrap_or(false);
10533    let em_source_interference_breach = em_source_interference_index
10534        .map(|value| value > em_source_interference_max_threshold)
10535        .unwrap_or(false);
10536    let em_boundary_anchor_breach = em_boundary_anchor_ratio
10537        .map(|value| value < em_boundary_anchor_min_threshold)
10538        .unwrap_or(false);
10539    let em_boundary_localization_breach = em_boundary_condition_localization_ratio
10540        .map(|value| value < em_boundary_localization_min_threshold)
10541        .unwrap_or(false);
10542    let em_ground_effectiveness_breach = em_ground_anchor_effectiveness_ratio
10543        .map(|value| value < em_ground_effectiveness_min_threshold)
10544        .unwrap_or(false);
10545    let em_insulation_leakage_breach = em_insulation_leakage_ratio
10546        .map(|value| value > em_insulation_leakage_max_threshold)
10547        .unwrap_or(false);
10548    let em_divergence_breach = em_flux_divergence_ratio
10549        .map(|value| value > em_divergence_max_threshold)
10550        .unwrap_or(false);
10551    let em_energy_imbalance_breach = em_energy_imbalance_ratio
10552        .map(|value| value > em_energy_imbalance_max_threshold)
10553        .unwrap_or(false);
10554    let em_boundary_energy_breach = em_boundary_energy_ratio
10555        .map(|value| value < em_boundary_energy_min_threshold)
10556        .unwrap_or(false);
10557    let em_boundary_penalty_contribution_breach = em_boundary_penalty_conditioning_contribution
10558        .map(|value| value > em_boundary_penalty_contribution_max_threshold)
10559        .unwrap_or(false);
10560    let em_source_region_energy_consistency_breach = em_source_region_energy_consistency_ratio
10561        .map(|value| value < em_source_region_energy_consistency_min_threshold)
10562        .unwrap_or(false);
10563    let em_real_residual_breach = em_real_residual_norm
10564        .map(|value| value > em_real_residual_max_threshold)
10565        .unwrap_or(false);
10566    let em_imag_residual_breach = em_imag_residual_norm
10567        .map(|value| value > em_imag_residual_max_threshold)
10568        .unwrap_or(false);
10569    let sweep_governance_active = options.sweep_enabled || !options.sweep_frequency_hz.is_empty();
10570    let em_sweep_coverage_breach = sweep_governance_active
10571        && em_sweep_count
10572            .map(|value| value < em_sweep_count_min_threshold)
10573            .unwrap_or(false);
10574    let em_resonance_sharpness_breach = sweep_governance_active
10575        && em_resonance_quality_factor
10576            .map(|value| value < em_resonance_q_min_threshold)
10577            .unwrap_or(false);
10578    if (em_spread_breach
10579        || em_heterogeneity_breach
10580        || em_coverage_breach
10581        || em_assigned_coefficient_breach
10582        || em_contrast_breach
10583        || em_conditioning_breach
10584        || em_source_realization_breach
10585        || em_source_region_coverage_breach
10586        || em_source_material_alignment_breach
10587        || em_source_overlap_breach
10588        || em_source_interference_breach
10589        || em_boundary_anchor_breach
10590        || em_boundary_localization_breach
10591        || em_ground_effectiveness_breach
10592        || em_insulation_leakage_breach
10593        || em_divergence_breach
10594        || em_energy_imbalance_breach
10595        || em_boundary_energy_breach
10596        || em_boundary_penalty_contribution_breach
10597        || em_source_region_energy_consistency_breach
10598        || em_real_residual_breach
10599        || em_imag_residual_breach
10600        || em_sweep_coverage_breach
10601        || em_resonance_sharpness_breach)
10602        && result_quality == QualityGate::Pass
10603    {
10604        result_quality = QualityGate::Warn;
10605    }
10606    if solver_convergence == QualityGate::Warn {
10607        quality_reasons.push(QualityReason {
10608            code: QualityReasonCode::SolverNotConverged,
10609            detail: "electromagnetic solver convergence gate is warning".to_string(),
10610        });
10611    }
10612    if result_quality != QualityGate::Pass {
10613        quality_reasons.push(QualityReason {
10614            code: QualityReasonCode::ElectromagneticSolveQualityLow,
10615            detail: "electromagnetic static solve quality below target".to_string(),
10616        });
10617    }
10618    if em_spread_breach {
10619        quality_reasons.push(QualityReason {
10620            code: QualityReasonCode::ElectromagneticConductivitySpreadHigh,
10621            detail: format!(
10622                "electromagnetic conductivity spread ratio {} exceeds threshold {}",
10623                em_conductivity_spread_ratio.unwrap_or(0.0),
10624                em_spread_threshold
10625            ),
10626        });
10627    }
10628    if em_heterogeneity_breach {
10629        quality_reasons.push(QualityReason {
10630            code: QualityReasonCode::ElectromagneticMaterialHeterogeneityHigh,
10631            detail: format!(
10632                "electromagnetic material heterogeneity index {} exceeds threshold {}",
10633                em_assignment_heterogeneity_index.unwrap_or(0.0),
10634                em_heterogeneity_threshold
10635            ),
10636        });
10637    }
10638    if em_coverage_breach {
10639        quality_reasons.push(QualityReason {
10640            code: QualityReasonCode::ElectromagneticAssignmentCoverageLow,
10641            detail: format!(
10642                "electromagnetic assignment coverage ratio {} is below threshold {}",
10643                em_assignment_coverage_ratio.unwrap_or(0.0),
10644                em_coverage_min_threshold
10645            ),
10646        });
10647    }
10648    if em_assigned_coefficient_breach {
10649        quality_reasons.push(QualityReason {
10650            code: QualityReasonCode::ElectromagneticAssignmentCoverageLow,
10651            detail: format!(
10652                "electromagnetic assigned coefficient coverage ratio {} is below threshold {}",
10653                em_assigned_coefficient_coverage_ratio.unwrap_or(0.0),
10654                em_coverage_min_threshold
10655            ),
10656        });
10657    }
10658    if em_contrast_breach {
10659        quality_reasons.push(QualityReason {
10660            code: QualityReasonCode::ElectromagneticRegionContrastHigh,
10661            detail: format!(
10662                "electromagnetic region coefficient contrast index {} exceeds threshold {}",
10663                em_region_contrast_index.unwrap_or(0.0),
10664                em_contrast_max_threshold
10665            ),
10666        });
10667    }
10668    if em_conditioning_breach {
10669        quality_reasons.push(QualityReason {
10670            code: QualityReasonCode::ElectromagneticConditioningHigh,
10671            detail: format!(
10672                "electromagnetic condition-number estimate {} exceeds threshold {}",
10673                em_condition_number_estimate.unwrap_or(0.0),
10674                em_conditioning_max_threshold
10675            ),
10676        });
10677    }
10678    if em_source_realization_breach {
10679        quality_reasons.push(QualityReason {
10680            code: QualityReasonCode::ElectromagneticSourceRealizationLow,
10681            detail: format!(
10682                "electromagnetic source realization ratio {} is below threshold {}",
10683                em_source_realization_ratio.unwrap_or(0.0),
10684                em_source_realization_min_threshold
10685            ),
10686        });
10687    }
10688    if em_source_region_coverage_breach {
10689        quality_reasons.push(QualityReason {
10690            code: QualityReasonCode::ElectromagneticSourceRegionCoverageLow,
10691            detail: format!(
10692                "electromagnetic source region coverage ratio {} is below threshold {}",
10693                em_source_region_coverage_ratio.unwrap_or(0.0),
10694                em_source_region_coverage_min_threshold
10695            ),
10696        });
10697    }
10698    if em_source_material_alignment_breach {
10699        quality_reasons.push(QualityReason {
10700            code: QualityReasonCode::ElectromagneticSourceMaterialAlignmentLow,
10701            detail: format!(
10702                "electromagnetic source material alignment ratio {} is below threshold {}",
10703                em_source_material_alignment_ratio.unwrap_or(0.0),
10704                em_source_material_alignment_min_threshold
10705            ),
10706        });
10707    }
10708    if em_source_overlap_breach {
10709        quality_reasons.push(QualityReason {
10710            code: QualityReasonCode::ElectromagneticSourceOverlapHigh,
10711            detail: format!(
10712                "electromagnetic source overlap ratio {} exceeds threshold {}",
10713                em_source_overlap_ratio.unwrap_or(0.0),
10714                em_source_overlap_max_threshold
10715            ),
10716        });
10717    }
10718    if em_source_interference_breach {
10719        quality_reasons.push(QualityReason {
10720            code: QualityReasonCode::ElectromagneticSourceInterferenceHigh,
10721            detail: format!(
10722                "electromagnetic source interference index {} exceeds threshold {}",
10723                em_source_interference_index.unwrap_or(0.0),
10724                em_source_interference_max_threshold
10725            ),
10726        });
10727    }
10728    if em_boundary_anchor_breach {
10729        quality_reasons.push(QualityReason {
10730            code: QualityReasonCode::ElectromagneticBoundaryAnchoringLow,
10731            detail: format!(
10732                "electromagnetic boundary anchor ratio {} is below threshold {}",
10733                em_boundary_anchor_ratio.unwrap_or(0.0),
10734                em_boundary_anchor_min_threshold
10735            ),
10736        });
10737    }
10738    if em_boundary_localization_breach {
10739        quality_reasons.push(QualityReason {
10740            code: QualityReasonCode::ElectromagneticBoundaryLocalizationLow,
10741            detail: format!(
10742                "electromagnetic boundary condition localization ratio {} is below threshold {}",
10743                em_boundary_condition_localization_ratio.unwrap_or(0.0),
10744                em_boundary_localization_min_threshold
10745            ),
10746        });
10747    }
10748    if em_ground_effectiveness_breach {
10749        quality_reasons.push(QualityReason {
10750            code: QualityReasonCode::ElectromagneticGroundAnchorEffectivenessLow,
10751            detail: format!(
10752                "electromagnetic ground anchor effectiveness ratio {} is below threshold {}",
10753                em_ground_anchor_effectiveness_ratio.unwrap_or(0.0),
10754                em_ground_effectiveness_min_threshold
10755            ),
10756        });
10757    }
10758    if em_insulation_leakage_breach {
10759        quality_reasons.push(QualityReason {
10760            code: QualityReasonCode::ElectromagneticInsulationLeakageHigh,
10761            detail: format!(
10762                "electromagnetic insulation leakage ratio {} exceeds threshold {}",
10763                em_insulation_leakage_ratio.unwrap_or(0.0),
10764                em_insulation_leakage_max_threshold
10765            ),
10766        });
10767    }
10768    if em_divergence_breach {
10769        quality_reasons.push(QualityReason {
10770            code: QualityReasonCode::ElectromagneticFluxDivergenceHigh,
10771            detail: format!(
10772                "electromagnetic flux divergence ratio {} exceeds threshold {}",
10773                em_flux_divergence_ratio.unwrap_or(0.0),
10774                em_divergence_max_threshold
10775            ),
10776        });
10777    }
10778    if em_energy_imbalance_breach {
10779        quality_reasons.push(QualityReason {
10780            code: QualityReasonCode::ElectromagneticEnergyImbalanceHigh,
10781            detail: format!(
10782                "electromagnetic energy imbalance ratio {} exceeds threshold {}",
10783                em_energy_imbalance_ratio.unwrap_or(0.0),
10784                em_energy_imbalance_max_threshold
10785            ),
10786        });
10787    }
10788    if em_boundary_energy_breach {
10789        quality_reasons.push(QualityReason {
10790            code: QualityReasonCode::ElectromagneticBoundaryEnergyLow,
10791            detail: format!(
10792                "electromagnetic boundary energy ratio {} is below threshold {}",
10793                em_boundary_energy_ratio.unwrap_or(0.0),
10794                em_boundary_energy_min_threshold
10795            ),
10796        });
10797    }
10798    if em_boundary_penalty_contribution_breach {
10799        quality_reasons.push(QualityReason {
10800            code: QualityReasonCode::ElectromagneticBoundaryPenaltyConditioningHigh,
10801            detail: format!(
10802                "electromagnetic boundary penalty conditioning contribution {} exceeds threshold {}",
10803                em_boundary_penalty_conditioning_contribution.unwrap_or(0.0),
10804                em_boundary_penalty_contribution_max_threshold
10805            ),
10806        });
10807    }
10808    if em_source_region_energy_consistency_breach {
10809        quality_reasons.push(QualityReason {
10810            code: QualityReasonCode::ElectromagneticSourceRegionEnergyConsistencyLow,
10811            detail: format!(
10812                "electromagnetic source-region energy consistency ratio {} is below threshold {}",
10813                em_source_region_energy_consistency_ratio.unwrap_or(0.0),
10814                em_source_region_energy_consistency_min_threshold
10815            ),
10816        });
10817    }
10818    if em_real_residual_breach {
10819        quality_reasons.push(QualityReason {
10820            code: QualityReasonCode::ElectromagneticRealResidualHigh,
10821            detail: format!(
10822                "electromagnetic real residual norm {} exceeds threshold {}",
10823                em_real_residual_norm.unwrap_or(0.0),
10824                em_real_residual_max_threshold
10825            ),
10826        });
10827    }
10828    if em_imag_residual_breach {
10829        quality_reasons.push(QualityReason {
10830            code: QualityReasonCode::ElectromagneticImagResidualHigh,
10831            detail: format!(
10832                "electromagnetic imaginary residual norm {} exceeds threshold {}",
10833                em_imag_residual_norm.unwrap_or(0.0),
10834                em_imag_residual_max_threshold
10835            ),
10836        });
10837    }
10838    if em_sweep_coverage_breach {
10839        quality_reasons.push(QualityReason {
10840            code: QualityReasonCode::ElectromagneticSweepCoverageLow,
10841            detail: format!(
10842                "electromagnetic sweep count {} is below threshold {}",
10843                em_sweep_count.unwrap_or(0.0),
10844                em_sweep_count_min_threshold
10845            ),
10846        });
10847    }
10848    if em_resonance_sharpness_breach {
10849        quality_reasons.push(QualityReason {
10850            code: QualityReasonCode::ElectromagneticResonanceSharpnessLow,
10851            detail: format!(
10852                "electromagnetic resonance quality factor {} is below threshold {}",
10853                em_resonance_quality_factor.unwrap_or(0.0),
10854                em_resonance_q_min_threshold
10855            ),
10856        });
10857    }
10858
10859    let publishable = match options.quality_policy {
10860        QualityPolicy::Strict => {
10861            solver_convergence == QualityGate::Pass
10862                && result_quality == QualityGate::Pass
10863                && quality_reasons.is_empty()
10864        }
10865        QualityPolicy::Balanced => {
10866            solver_convergence == QualityGate::Pass && result_quality == QualityGate::Pass
10867        }
10868        QualityPolicy::Exploratory => {
10869            solver_convergence != QualityGate::Fail && result_quality != QualityGate::Fail
10870        }
10871    };
10872    let run_status = if publishable {
10873        RunStatus::Publishable
10874    } else if result_quality == QualityGate::Fail {
10875        RunStatus::Rejected
10876    } else {
10877        RunStatus::Degraded
10878    };
10879    let solver_backend = run.solver_backend.clone();
10880    let solver_device_apply_k_ratio = run.solver_device_apply_k_ratio;
10881    let solver_host_sync_count = run.solver_host_sync_count;
10882    let solver_method = run.solver_method.clone();
10883    let preconditioner = run.preconditioner.clone();
10884
10885    let result = AnalysisRunResult {
10886        run_id: storage::next_run_id(),
10887        run,
10888        render_topology: render_topology_from_prep_context(prep_context.as_ref()),
10889        modal_results: None,
10890        thermal_results: None,
10891        transient_results: None,
10892        nonlinear_results: None,
10893        electromagnetic_results: Some(ElectromagneticResultsData {
10894            electromagnetic_payload_version: "electromagnetic_results/v1".to_string(),
10895            reference_frequency_hz: em_run.reference_frequency_hz,
10896            applied_current_a: em_run.applied_current_a,
10897            vector_potential_real: em_run.vector_potential_real_field,
10898            vector_potential_imag: em_run.vector_potential_imag_field,
10899            magnetic_flux_density_real: em_run.magnetic_flux_density_real_field,
10900            magnetic_flux_density_imag: em_run.magnetic_flux_density_imag_field,
10901            magnetic_flux_density_magnitude: em_run.magnetic_flux_density_magnitude_field,
10902            magnetic_field_real: em_run.magnetic_field_real_field,
10903            magnetic_field_imag: em_run.magnetic_field_imag_field,
10904            current_density_real: em_run.current_density_real_field,
10905            current_density_imag: em_run.current_density_imag_field,
10906            electric_field_real: em_run.electric_field_real_field,
10907            electric_field_imag: em_run.electric_field_imag_field,
10908            power_loss_density: em_run.power_loss_density_field,
10909            energy_density: em_run.energy_density_field,
10910            residual_real: em_run.residual_real_field,
10911            residual_imag: em_run.residual_imag_field,
10912            electric_flux_density_real: em_run.electric_flux_density_real_field,
10913            electric_flux_density_imag: em_run.electric_flux_density_imag_field,
10914            poynting_vector_real: em_run.poynting_vector_real_field,
10915            poynting_vector_imag: em_run.poynting_vector_imag_field,
10916            sweep_frequency_hz,
10917            sweep_peak_flux_density,
10918            sweep_solve_quality,
10919            resonance_peak_frequency_hz: sweep_metrics.resonance_peak_frequency_hz,
10920            resonance_peak_flux_density: sweep_metrics.resonance_peak_flux_density,
10921            resonance_bandwidth_hz: sweep_metrics.resonance_bandwidth_hz,
10922            resonance_quality_factor: sweep_metrics.resonance_quality_factor,
10923            resonance_flux_gain: sweep_metrics.resonance_flux_gain,
10924        }),
10925        model_validity: QualityGate::Pass,
10926        solver_convergence,
10927        result_quality,
10928        run_status,
10929        publishable,
10930        quality_reasons,
10931        provenance: RunProvenance {
10932            backend,
10933            solver_backend,
10934            solver_device_apply_k_ratio,
10935            solver_host_sync_count,
10936            precision_mode: contracts::format_precision_mode(options.precision_mode),
10937            deterministic_mode: options.deterministic_mode,
10938            solver_method,
10939            preconditioner,
10940            quality_policy: contracts::format_quality_policy(options.quality_policy),
10941            fallback_events: Vec::new(),
10942        },
10943    };
10944
10945    persist_fea_run_result_with_progress(
10946        ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10947        ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10948        "RM.FEA.RUN_ELECTROMAGNETIC.ARTIFACT_STORE_FAILED",
10949        &context,
10950        &result,
10951    )?;
10952
10953    Ok(OperationEnvelope::new(
10954        ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10955        ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10956        &context,
10957        result,
10958    ))
10959}
10960
10961fn validate_electromagnetic_run_model(
10962    model: &AnalysisModel,
10963    context: &OperationContext,
10964) -> Result<(), OperationErrorEnvelope> {
10965    if let Some(material) = model.materials.iter().find(|material| {
10966        material.electrical.as_ref().is_some_and(|electrical| {
10967            !electrical.conductivity_s_per_m.is_finite()
10968                || electrical.conductivity_s_per_m <= 0.0
10969                || !electrical.relative_permittivity.is_finite()
10970                || electrical.relative_permittivity <= 0.0
10971                || !electrical.relative_permeability.is_finite()
10972                || electrical.relative_permeability <= 0.0
10973                || electrical
10974                    .conductivity_frequency_response
10975                    .iter()
10976                    .any(|point| {
10977                        !point.frequency_hz.is_finite()
10978                            || point.frequency_hz <= 0.0
10979                            || !point.conductivity_scale.is_finite()
10980                            || point.conductivity_scale <= 0.0
10981                            || point
10982                                .dispersive_loss_scale
10983                                .is_some_and(|value| !value.is_finite() || value < 0.0)
10984                            || point
10985                                .relative_permittivity_scale
10986                                .is_some_and(|value| !value.is_finite() || value <= 0.0)
10987                            || point
10988                                .relative_permeability_scale
10989                                .is_some_and(|value| !value.is_finite() || value <= 0.0)
10990                    })
10991        })
10992    }) {
10993        return Err(operation_error(
10994            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
10995            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
10996            context,
10997            OperationErrorSpec {
10998                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_ELECTROMAGNETIC_MATERIAL",
10999                error_type: OperationErrorType::Validation,
11000                retryable: false,
11001                severity: OperationErrorSeverity::Error,
11002            },
11003            "fea.run_electromagnetic requires finite positive electrical material coefficients",
11004            BTreeMap::from([
11005                ("analysis_model_id".to_string(), model.model_id.0.clone()),
11006                ("material_id".to_string(), material.material_id.clone()),
11007            ]),
11008        ));
11009    }
11010
11011    let electrical_material_count = model
11012        .materials
11013        .iter()
11014        .filter(|material| material.electrical.is_some())
11015        .count();
11016    if electrical_material_count == 0 {
11017        return Err(operation_error(
11018            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11019            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11020            context,
11021            OperationErrorSpec {
11022                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_MATERIAL",
11023                error_type: OperationErrorType::Validation,
11024                retryable: false,
11025                severity: OperationErrorSeverity::Error,
11026            },
11027            "fea.run_electromagnetic requires at least one electrical material",
11028            BTreeMap::from([
11029                ("analysis_model_id".to_string(), model.model_id.0.clone()),
11030                (
11031                    "material_count".to_string(),
11032                    model.materials.len().to_string(),
11033                ),
11034            ]),
11035        ));
11036    }
11037    let electrical_material_by_id = model
11038        .materials
11039        .iter()
11040        .filter(|material| material.electrical.is_some())
11041        .map(|material| material.material_id.as_str())
11042        .collect::<std::collections::BTreeSet<_>>();
11043    if let Some(assignment) = model.material_assignments.iter().find(|assignment| {
11044        !electrical_material_by_id.contains(assignment.assigned_material_id.as_str())
11045    }) {
11046        return Err(operation_error(
11047            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11048            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11049            context,
11050            OperationErrorSpec {
11051                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_MATERIAL",
11052                error_type: OperationErrorType::Validation,
11053                retryable: false,
11054                severity: OperationErrorSeverity::Error,
11055            },
11056            "fea.run_electromagnetic requires every material assignment to reference an assigned electrical material",
11057            BTreeMap::from([
11058                ("analysis_model_id".to_string(), model.model_id.0.clone()),
11059                ("region_id".to_string(), assignment.region_id.clone()),
11060                (
11061                    "assigned_material_id".to_string(),
11062                    assignment.assigned_material_id.clone(),
11063                ),
11064            ]),
11065        ));
11066    }
11067
11068    reject_moment_loads_for_run_family(
11069        model,
11070        ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11071        ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11072        "RM.FEA.RUN_ELECTROMAGNETIC.INVALID_ELECTROMAGNETIC_SOURCE",
11073        "electromagnetic",
11074        context,
11075    )?;
11076
11077    let has_electromagnetic_source = model.loads.iter().any(|load| match &load.kind {
11078        LoadKind::CurrentDensity {
11079            jx,
11080            jy,
11081            jz,
11082            phase_rad,
11083            amplitude_scale,
11084        } => {
11085            jx.is_finite()
11086                && jy.is_finite()
11087                && jz.is_finite()
11088                && phase_rad.is_finite()
11089                && amplitude_scale.is_finite()
11090                && *amplitude_scale > 0.0
11091                && (jx.abs() + jy.abs() + jz.abs()) > 0.0
11092        }
11093        LoadKind::CoilCurrent {
11094            current_a,
11095            phase_rad,
11096            amplitude_scale,
11097        } => {
11098            current_a.is_finite()
11099                && current_a.abs() > 0.0
11100                && phase_rad.is_finite()
11101                && amplitude_scale.is_finite()
11102                && *amplitude_scale > 0.0
11103        }
11104        _ => false,
11105    });
11106    if !has_electromagnetic_source {
11107        return Err(operation_error(
11108            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11109            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11110            context,
11111            OperationErrorSpec {
11112                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_SOURCE",
11113                error_type: OperationErrorType::Validation,
11114                retryable: false,
11115                severity: OperationErrorSeverity::Error,
11116            },
11117            "fea.run_electromagnetic requires a nonzero current-density or coil-current source",
11118            BTreeMap::from([
11119                ("analysis_model_id".to_string(), model.model_id.0.clone()),
11120                ("load_count".to_string(), model.loads.len().to_string()),
11121            ]),
11122        ));
11123    }
11124
11125    let has_electromagnetic_boundary = model.boundary_conditions.iter().any(|bc| {
11126        matches!(
11127            &bc.kind,
11128            BoundaryConditionKind::MagneticInsulation
11129                | BoundaryConditionKind::VectorPotentialGround
11130        )
11131    });
11132    if !has_electromagnetic_boundary {
11133        return Err(operation_error(
11134            ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
11135            ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
11136            context,
11137            OperationErrorSpec {
11138                error_code: "RM.FEA.RUN_ELECTROMAGNETIC.MISSING_ELECTROMAGNETIC_BOUNDARY",
11139                error_type: OperationErrorType::Validation,
11140                retryable: false,
11141                severity: OperationErrorSeverity::Error,
11142            },
11143            "fea.run_electromagnetic requires magnetic insulation or vector-potential ground boundary data",
11144            BTreeMap::from([
11145                ("analysis_model_id".to_string(), model.model_id.0.clone()),
11146                (
11147                    "boundary_condition_count".to_string(),
11148                    model.boundary_conditions.len().to_string(),
11149                ),
11150            ]),
11151        ));
11152    }
11153
11154    Ok(())
11155}
11156
11157fn collect_analysis_result_fields(run_result: &AnalysisRunResult) -> Vec<AnalysisField> {
11158    let mut fields = Vec::new();
11159    let mut seen = HashSet::new();
11160
11161    for field in &run_result.run.fields {
11162        push_analysis_result_field(&mut fields, &mut seen, field);
11163    }
11164
11165    if let Some(modal) = run_result.modal_results.as_ref() {
11166        for field in &modal.mode_shapes {
11167            push_analysis_result_field(&mut fields, &mut seen, field);
11168        }
11169    }
11170
11171    if let Some(thermal) = run_result.thermal_results.as_ref() {
11172        for field in &thermal.temperature_snapshots {
11173            push_analysis_result_field(&mut fields, &mut seen, field);
11174        }
11175        for field in &thermal.temperature_gradient_snapshots {
11176            push_analysis_result_field(&mut fields, &mut seen, field);
11177        }
11178        for field in &thermal.heat_flux_snapshots {
11179            push_analysis_result_field(&mut fields, &mut seen, field);
11180        }
11181        for field in &thermal.heat_source_snapshots {
11182            push_analysis_result_field(&mut fields, &mut seen, field);
11183        }
11184        for field in &thermal.boundary_heat_flux_snapshots {
11185            push_analysis_result_field(&mut fields, &mut seen, field);
11186        }
11187    }
11188
11189    if let Some(transient) = run_result.transient_results.as_ref() {
11190        for field in &transient.displacement_snapshots {
11191            push_analysis_result_field(&mut fields, &mut seen, field);
11192        }
11193        for field in &transient.rotation_snapshots {
11194            push_analysis_result_field(&mut fields, &mut seen, field);
11195        }
11196        for field in &transient.velocity_snapshots {
11197            push_analysis_result_field(&mut fields, &mut seen, field);
11198        }
11199        for field in &transient.angular_velocity_snapshots {
11200            push_analysis_result_field(&mut fields, &mut seen, field);
11201        }
11202        for field in &transient.acceleration_snapshots {
11203            push_analysis_result_field(&mut fields, &mut seen, field);
11204        }
11205        for field in &transient.angular_acceleration_snapshots {
11206            push_analysis_result_field(&mut fields, &mut seen, field);
11207        }
11208        for field in &transient.von_mises_snapshots {
11209            push_analysis_result_field(&mut fields, &mut seen, field);
11210        }
11211        for field in &transient.kinetic_energy_snapshots {
11212            push_analysis_result_field(&mut fields, &mut seen, field);
11213        }
11214        for field in &transient.strain_energy_snapshots {
11215            push_analysis_result_field(&mut fields, &mut seen, field);
11216        }
11217        for field in &transient.residual_norm_snapshots {
11218            push_analysis_result_field(&mut fields, &mut seen, field);
11219        }
11220        for field in &transient.thermo_mechanical_temperature_snapshots {
11221            push_analysis_result_field(&mut fields, &mut seen, field);
11222        }
11223        for field in &transient.thermo_mechanical_thermal_strain_snapshots {
11224            push_analysis_result_field(&mut fields, &mut seen, field);
11225        }
11226        for field in &transient.thermo_mechanical_thermal_stress_snapshots {
11227            push_analysis_result_field(&mut fields, &mut seen, field);
11228        }
11229        for field in &transient.thermo_mechanical_displacement_snapshots {
11230            push_analysis_result_field(&mut fields, &mut seen, field);
11231        }
11232        for field in &transient.thermo_mechanical_von_mises_snapshots {
11233            push_analysis_result_field(&mut fields, &mut seen, field);
11234        }
11235        for field in &transient.thermo_mechanical_coupling_residual_snapshots {
11236            push_analysis_result_field(&mut fields, &mut seen, field);
11237        }
11238        for field in &transient.electro_thermal_temperature_snapshots {
11239            push_analysis_result_field(&mut fields, &mut seen, field);
11240        }
11241        for field in &transient.electro_thermal_thermal_residual_snapshots {
11242            push_analysis_result_field(&mut fields, &mut seen, field);
11243        }
11244    }
11245
11246    if let Some(nonlinear) = run_result.nonlinear_results.as_ref() {
11247        for field in &nonlinear.displacement_snapshots {
11248            push_analysis_result_field(&mut fields, &mut seen, field);
11249        }
11250        for field in &nonlinear.rotation_snapshots {
11251            push_analysis_result_field(&mut fields, &mut seen, field);
11252        }
11253        for field in &nonlinear.von_mises_snapshots {
11254            push_analysis_result_field(&mut fields, &mut seen, field);
11255        }
11256        for field in &nonlinear.plastic_strain_snapshots {
11257            push_analysis_result_field(&mut fields, &mut seen, field);
11258        }
11259        for field in &nonlinear.equivalent_plastic_strain_snapshots {
11260            push_analysis_result_field(&mut fields, &mut seen, field);
11261        }
11262        for field in &nonlinear.contact_pressure_snapshots {
11263            push_analysis_result_field(&mut fields, &mut seen, field);
11264        }
11265        for field in &nonlinear.contact_gap_snapshots {
11266            push_analysis_result_field(&mut fields, &mut seen, field);
11267        }
11268        for field in &nonlinear.load_factor_snapshots {
11269            push_analysis_result_field(&mut fields, &mut seen, field);
11270        }
11271        for field in &nonlinear.residual_norm_snapshots {
11272            push_analysis_result_field(&mut fields, &mut seen, field);
11273        }
11274        for field in &nonlinear.thermo_mechanical_temperature_snapshots {
11275            push_analysis_result_field(&mut fields, &mut seen, field);
11276        }
11277        for field in &nonlinear.thermo_mechanical_thermal_strain_snapshots {
11278            push_analysis_result_field(&mut fields, &mut seen, field);
11279        }
11280        for field in &nonlinear.thermo_mechanical_thermal_stress_snapshots {
11281            push_analysis_result_field(&mut fields, &mut seen, field);
11282        }
11283        for field in &nonlinear.thermo_mechanical_displacement_snapshots {
11284            push_analysis_result_field(&mut fields, &mut seen, field);
11285        }
11286        for field in &nonlinear.thermo_mechanical_von_mises_snapshots {
11287            push_analysis_result_field(&mut fields, &mut seen, field);
11288        }
11289        for field in &nonlinear.thermo_mechanical_coupling_residual_snapshots {
11290            push_analysis_result_field(&mut fields, &mut seen, field);
11291        }
11292        for field in &nonlinear.electro_thermal_temperature_snapshots {
11293            push_analysis_result_field(&mut fields, &mut seen, field);
11294        }
11295        for field in &nonlinear.electro_thermal_thermal_residual_snapshots {
11296            push_analysis_result_field(&mut fields, &mut seen, field);
11297        }
11298    }
11299
11300    if let Some(electromagnetic) = run_result.electromagnetic_results.as_ref() {
11301        for field in [
11302            &electromagnetic.vector_potential_real,
11303            &electromagnetic.vector_potential_imag,
11304            &electromagnetic.magnetic_flux_density_real,
11305            &electromagnetic.magnetic_flux_density_imag,
11306            &electromagnetic.magnetic_flux_density_magnitude,
11307            &electromagnetic.magnetic_field_real,
11308            &electromagnetic.magnetic_field_imag,
11309            &electromagnetic.current_density_real,
11310            &electromagnetic.current_density_imag,
11311            &electromagnetic.electric_field_real,
11312            &electromagnetic.electric_field_imag,
11313            &electromagnetic.power_loss_density,
11314            &electromagnetic.energy_density,
11315            &electromagnetic.residual_real,
11316            &electromagnetic.residual_imag,
11317            &electromagnetic.electric_flux_density_real,
11318            &electromagnetic.electric_flux_density_imag,
11319            &electromagnetic.poynting_vector_real,
11320            &electromagnetic.poynting_vector_imag,
11321        ] {
11322            push_analysis_result_field(&mut fields, &mut seen, field);
11323        }
11324    }
11325
11326    fields
11327}
11328
11329fn push_analysis_result_field(
11330    fields: &mut Vec<AnalysisField>,
11331    seen: &mut HashSet<String>,
11332    field: &AnalysisField,
11333) {
11334    if !seen.insert(field.field_id.clone()) {
11335        return;
11336    }
11337    fields.push(field.clone());
11338}
11339
11340fn filter_analysis_fields_by_indices(
11341    fields: &[AnalysisField],
11342    indices: &[usize],
11343) -> Vec<AnalysisField> {
11344    if fields.is_empty() {
11345        return Vec::new();
11346    }
11347    indices
11348        .iter()
11349        .filter_map(|index| fields.get(*index).cloned())
11350        .collect()
11351}
11352
11353pub(crate) fn analysis_run_field_ids(run_result: &AnalysisRunResult) -> Vec<String> {
11354    collect_analysis_result_fields(run_result)
11355        .into_iter()
11356        .map(|field| field.field_id)
11357        .collect()
11358}
11359
11360pub fn analysis_results_op(
11361    run_result: &AnalysisRunResult,
11362    query: AnalysisResultsQuery,
11363    context: OperationContext,
11364) -> Result<OperationEnvelope<AnalysisResultsData>, OperationErrorEnvelope> {
11365    let mut collected_fields = collect_analysis_result_fields(run_result);
11366
11367    if !query.include_fields.is_empty() {
11368        let mut filtered = Vec::new();
11369        for requested in &query.include_fields {
11370            let Some(field) = collected_fields
11371                .iter()
11372                .find(|field| &field.field_id == requested)
11373            else {
11374                return Err(operation_error(
11375                    ANALYSIS_RESULTS_OPERATION,
11376                    ANALYSIS_RESULTS_OP_VERSION,
11377                    &context,
11378                    OperationErrorSpec {
11379                        error_code: "RM.FEA.RESULTS.FIELD_NOT_FOUND",
11380                        error_type: OperationErrorType::Input,
11381                        retryable: false,
11382                        severity: OperationErrorSeverity::Error,
11383                    },
11384                    format!("requested FEA field '{requested}' was not produced by run"),
11385                    BTreeMap::from([
11386                        ("requested_field".to_string(), requested.clone()),
11387                        (
11388                            "available_fields".to_string(),
11389                            collected_fields
11390                                .iter()
11391                                .map(|field| field.field_id.clone())
11392                                .collect::<Vec<_>>()
11393                                .join(","),
11394                        ),
11395                    ]),
11396                ));
11397            };
11398            filtered.push(field.clone());
11399        }
11400        collected_fields = filtered;
11401    }
11402    let field_descriptors = collected_fields
11403        .iter()
11404        .map(AnalysisFieldDescriptor::from_field)
11405        .collect::<Vec<_>>();
11406
11407    let (
11408        mode_count,
11409        available_mode_indices,
11410        min_frequency_hz,
11411        max_frequency_hz,
11412        max_modal_residual_norm,
11413        first_mode_converged,
11414    ) = if let Some(modal) = run_result.modal_results.as_ref() {
11415        let count = modal.eigenvalues_hz.len().min(modal.mode_shapes.len());
11416        let max_modal_residual_norm = modal.residual_norms.iter().copied().reduce(f64::max);
11417        let first_mode_converged = modal.residual_norms.first().copied().map(|v| v <= 1.0e-6);
11418        let (min_frequency_hz, max_frequency_hz) = if count == 0 {
11419            (None, None)
11420        } else {
11421            let mut min_value = f64::INFINITY;
11422            let mut max_value = f64::NEG_INFINITY;
11423            for value in modal.eigenvalues_hz.iter().copied().take(count) {
11424                min_value = min_value.min(value);
11425                max_value = max_value.max(value);
11426            }
11427            (Some(min_value), Some(max_value))
11428        };
11429        (
11430            count,
11431            (0..count).collect(),
11432            min_frequency_hz,
11433            max_frequency_hz,
11434            max_modal_residual_norm,
11435            first_mode_converged,
11436        )
11437    } else {
11438        (0, Vec::new(), None, None, None, None)
11439    };
11440
11441    let (
11442        snapshot_count,
11443        time_start_s,
11444        time_end_s,
11445        max_transient_residual_norm,
11446        final_step_converged,
11447    ) = if let Some(transient) = run_result.transient_results.as_ref() {
11448        let count = transient
11449            .time_points_s
11450            .len()
11451            .min(transient.displacement_snapshots.len());
11452        let max_residual = transient.residual_norms.iter().copied().reduce(f64::max);
11453        let final_step_converged = max_residual.map(|value| value <= 1.0e-6);
11454        if count == 0 {
11455            (0, None, None, max_residual, final_step_converged)
11456        } else {
11457            (
11458                count,
11459                transient.time_points_s.first().copied(),
11460                transient.time_points_s.get(count - 1).copied(),
11461                max_residual,
11462                final_step_converged,
11463            )
11464        }
11465    } else if let Some(thermal) = run_result.thermal_results.as_ref() {
11466        let count = thermal
11467            .time_points_s
11468            .len()
11469            .min(thermal.temperature_snapshots.len());
11470        let max_residual = thermal.residual_norms.iter().copied().reduce(f64::max);
11471        let final_step_converged = max_residual.map(|value| value <= 1.0e-6);
11472        if count == 0 {
11473            (0, None, None, max_residual, final_step_converged)
11474        } else {
11475            (
11476                count,
11477                thermal.time_points_s.first().copied(),
11478                thermal.time_points_s.get(count - 1).copied(),
11479                max_residual,
11480                final_step_converged,
11481            )
11482        }
11483    } else {
11484        (0, None, None, None, None)
11485    };
11486
11487    let (
11488        increment_count,
11489        failed_increment_count,
11490        max_nonlinear_residual_norm,
11491        max_nonlinear_increment_norm,
11492        max_nonlinear_iteration_count,
11493        final_increment_converged,
11494        nonlinear_line_search_backtracks,
11495        nonlinear_max_backtracks_per_increment,
11496        nonlinear_tangent_rebuild_count,
11497        nonlinear_iteration_spike_count,
11498        nonlinear_convergence_stall_count,
11499        nonlinear_backtrack_burst_count,
11500    ) = if let Some(nonlinear) = run_result.nonlinear_results.as_ref() {
11501        let count = nonlinear.load_factors.len();
11502        let max_residual = nonlinear.residual_norms.iter().copied().reduce(f64::max);
11503        let max_increment_norm = nonlinear.increment_norms.iter().copied().reduce(f64::max);
11504        let max_iteration_count = nonlinear.iteration_counts.iter().copied().max();
11505        let final_converged =
11506            max_residual.map(|value| value <= 1.0e-6 && nonlinear.failed_increments == 0);
11507        (
11508            count,
11509            Some(nonlinear.failed_increments),
11510            max_residual,
11511            max_increment_norm,
11512            max_iteration_count,
11513            final_converged,
11514            Some(nonlinear.line_search_backtracks),
11515            Some(nonlinear.max_line_search_backtracks_per_increment),
11516            Some(nonlinear.tangent_rebuild_count),
11517            Some(nonlinear.iteration_spike_count),
11518            Some(nonlinear.convergence_stall_count),
11519            Some(nonlinear.backtrack_burst_count),
11520        )
11521    } else {
11522        (
11523            0, None, None, None, None, None, None, None, None, None, None, None,
11524        )
11525    };
11526
11527    let prep_calibration_profile = diagnostic_metric_string(
11528        &run_result.run.diagnostics,
11529        "FEA_PREP_CALIBRATION",
11530        "profile",
11531    );
11532    let prep_calibration_fingerprint = diagnostic_metric_u64(
11533        &run_result.run.diagnostics,
11534        "FEA_PREP_CALIBRATION",
11535        "calibration_fingerprint",
11536    );
11537    let prep_acceptance_score = diagnostic_metric(
11538        &run_result.run.diagnostics,
11539        "FEA_PREP_ACCEPTANCE",
11540        "acceptance_score",
11541    );
11542    let prep_acceptance_passed = diagnostic_metric_bool(
11543        &run_result.run.diagnostics,
11544        "FEA_PREP_ACCEPTANCE",
11545        "accepted",
11546    );
11547    let prep_acceptance_fingerprint = diagnostic_metric_u64(
11548        &run_result.run.diagnostics,
11549        "FEA_PREP_ACCEPTANCE",
11550        "acceptance_fingerprint",
11551    );
11552    let thermo_coupling_enabled =
11553        diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_TM_COUPLING", "enabled");
11554    let thermo_coupling_fingerprint = diagnostic_metric_u64(
11555        &run_result.run.diagnostics,
11556        "FEA_TM_COUPLING",
11557        "coupling_fingerprint",
11558    );
11559    let thermo_constitutive_temperature_factor = diagnostic_metric(
11560        &run_result.run.diagnostics,
11561        "FEA_TM_COUPLING",
11562        "constitutive_temperature_factor",
11563    );
11564    let thermo_effective_modulus_scale = diagnostic_metric(
11565        &run_result.run.diagnostics,
11566        "FEA_TM_COUPLING",
11567        "effective_modulus_scale",
11568    );
11569    let thermo_constitutive_material_spread_ratio = diagnostic_metric(
11570        &run_result.run.diagnostics,
11571        "FEA_TM_COUPLING",
11572        "constitutive_material_spread_ratio",
11573    );
11574    let thermo_assignment_heterogeneity_index = diagnostic_metric(
11575        &run_result.run.diagnostics,
11576        "FEA_TM_COUPLING",
11577        "assignment_heterogeneity_index",
11578    );
11579    let thermo_region_delta_count = diagnostic_metric(
11580        &run_result.run.diagnostics,
11581        "FEA_TM_COUPLING",
11582        "region_delta_count",
11583    );
11584    let thermo_spatial_coverage_ratio = diagnostic_metric(
11585        &run_result.run.diagnostics,
11586        "FEA_TM_COUPLING",
11587        "spatial_coverage_ratio",
11588    );
11589    let thermo_field_extrapolation_ratio = diagnostic_metric(
11590        &run_result.run.diagnostics,
11591        "FEA_TM_TRANSIENT",
11592        "field_extrapolation_ratio",
11593    )
11594    .or_else(|| {
11595        diagnostic_metric(
11596            &run_result.run.diagnostics,
11597            "FEA_TM_NONLINEAR",
11598            "field_extrapolation_ratio",
11599        )
11600    });
11601    let thermo_field_clamp_ratio = diagnostic_metric(
11602        &run_result.run.diagnostics,
11603        "FEA_TM_TRANSIENT",
11604        "field_clamp_ratio",
11605    )
11606    .or_else(|| {
11607        diagnostic_metric(
11608            &run_result.run.diagnostics,
11609            "FEA_TM_NONLINEAR",
11610            "field_clamp_ratio",
11611        )
11612    });
11613    let thermo_transient_severity = diagnostic_metric(
11614        &run_result.run.diagnostics,
11615        "FEA_TM_TRANSIENT",
11616        "severity_peak",
11617    )
11618    .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_TM_TRANSIENT", "severity"));
11619    let thermo_nonlinear_severity = diagnostic_metric(
11620        &run_result.run.diagnostics,
11621        "FEA_TM_NONLINEAR",
11622        "severity_peak",
11623    )
11624    .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_TM_NONLINEAR", "severity"));
11625    let electro_thermal_coupling_enabled =
11626        diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_ET_COUPLING", "enabled");
11627    let electro_thermal_coupling_fingerprint = diagnostic_metric_u64(
11628        &run_result.run.diagnostics,
11629        "FEA_ET_COUPLING",
11630        "coupling_fingerprint",
11631    );
11632    let electro_joule_heating_scale = diagnostic_metric(
11633        &run_result.run.diagnostics,
11634        "FEA_ET_COUPLING",
11635        "joule_heating_scale",
11636    );
11637    let electro_conductivity_spread_ratio = diagnostic_metric(
11638        &run_result.run.diagnostics,
11639        "FEA_ET_COUPLING",
11640        "conductivity_spread_ratio",
11641    );
11642    let electro_transient_severity = diagnostic_metric(
11643        &run_result.run.diagnostics,
11644        "FEA_ET_TRANSIENT",
11645        "severity_peak",
11646    )
11647    .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_ET_TRANSIENT", "severity"));
11648    let electro_transient_time_scale_mean = diagnostic_metric(
11649        &run_result.run.diagnostics,
11650        "FEA_ET_TRANSIENT",
11651        "time_scale_mean",
11652    );
11653    let electro_nonlinear_severity = diagnostic_metric(
11654        &run_result.run.diagnostics,
11655        "FEA_ET_NONLINEAR",
11656        "severity_peak",
11657    )
11658    .or_else(|| diagnostic_metric(&run_result.run.diagnostics, "FEA_ET_NONLINEAR", "severity"));
11659    let electro_nonlinear_time_scale_mean = diagnostic_metric(
11660        &run_result.run.diagnostics,
11661        "FEA_ET_NONLINEAR",
11662        "time_scale_mean",
11663    );
11664    let plastic_nonlinear_severity = diagnostic_metric(
11665        &run_result.run.diagnostics,
11666        "FEA_PLASTIC_NONLINEAR",
11667        "severity_peak",
11668    )
11669    .or_else(|| {
11670        diagnostic_metric(
11671            &run_result.run.diagnostics,
11672            "FEA_PLASTIC_NONLINEAR",
11673            "severity",
11674        )
11675    });
11676    let plastic_nonlinear_severity_mean = diagnostic_metric(
11677        &run_result.run.diagnostics,
11678        "FEA_PLASTIC_NONLINEAR",
11679        "severity_mean",
11680    );
11681    let plastic_load_realization_ratio = diagnostic_metric(
11682        &run_result.run.diagnostics,
11683        "FEA_PLASTIC_NONLINEAR",
11684        "load_realization_ratio",
11685    );
11686    let plastic_load_amplification_ratio = diagnostic_metric(
11687        &run_result.run.diagnostics,
11688        "FEA_PLASTIC_NONLINEAR",
11689        "load_amplification_ratio",
11690    );
11691    let contact_nonlinear_severity = diagnostic_metric(
11692        &run_result.run.diagnostics,
11693        "FEA_CONTACT_NONLINEAR",
11694        "severity_peak",
11695    )
11696    .or_else(|| {
11697        diagnostic_metric(
11698            &run_result.run.diagnostics,
11699            "FEA_CONTACT_NONLINEAR",
11700            "severity",
11701        )
11702    });
11703    let contact_nonlinear_severity_mean = diagnostic_metric(
11704        &run_result.run.diagnostics,
11705        "FEA_CONTACT_NONLINEAR",
11706        "severity_mean",
11707    );
11708    let contact_load_realization_ratio = diagnostic_metric(
11709        &run_result.run.diagnostics,
11710        "FEA_CONTACT_NONLINEAR",
11711        "load_realization_ratio",
11712    );
11713    let contact_load_amplification_ratio = diagnostic_metric(
11714        &run_result.run.diagnostics,
11715        "FEA_CONTACT_NONLINEAR",
11716        "load_amplification_ratio",
11717    );
11718    let thermal_max_residual_norm = diagnostic_metric(
11719        &run_result.run.diagnostics,
11720        "FEA_THERMAL_STABILITY",
11721        "max_residual_norm",
11722    );
11723    let thermal_min_temperature_k = diagnostic_metric(
11724        &run_result.run.diagnostics,
11725        "FEA_THERMAL_STABILITY",
11726        "min_temperature_k",
11727    );
11728    let thermal_max_temperature_k = diagnostic_metric(
11729        &run_result.run.diagnostics,
11730        "FEA_THERMAL_STABILITY",
11731        "max_temperature_k",
11732    );
11733    let thermal_conductivity_spread_ratio = diagnostic_metric(
11734        &run_result.run.diagnostics,
11735        "FEA_THERMAL_CONSTITUTIVE",
11736        "conductivity_spread_ratio",
11737    );
11738    let thermal_heat_capacity_spread_ratio = diagnostic_metric(
11739        &run_result.run.diagnostics,
11740        "FEA_THERMAL_CONSTITUTIVE",
11741        "heat_capacity_spread_ratio",
11742    );
11743    let thermal_spatial_gradient_index = diagnostic_metric(
11744        &run_result.run.diagnostics,
11745        "FEA_THERMAL_OUTCOME",
11746        "spatial_gradient_index",
11747    );
11748    let thermal_monotonic_response_fraction = diagnostic_metric(
11749        &run_result.run.diagnostics,
11750        "FEA_THERMAL_OUTCOME",
11751        "monotonic_response_fraction",
11752    );
11753    let thermal_response_realization_ratio = diagnostic_metric(
11754        &run_result.run.diagnostics,
11755        "FEA_THERMAL_OUTCOME",
11756        "thermal_response_realization_ratio",
11757    );
11758    let electromagnetic_enabled =
11759        diagnostic_metric_bool(&run_result.run.diagnostics, "FEA_EM_STATIC", "enabled");
11760    let electromagnetic_formulation_coverage_ratio = diagnostic_metric(
11761        &run_result.run.diagnostics,
11762        "FEA_EM_FORMULATION",
11763        "formulation_coverage_ratio",
11764    );
11765    let electromagnetic_magnetostatic_curl_curl_coverage_ratio = diagnostic_metric(
11766        &run_result.run.diagnostics,
11767        "FEA_EM_FORMULATION",
11768        "magnetostatic_curl_curl_coverage_ratio",
11769    );
11770    let electromagnetic_magnetoquasistatic_eddy_current_coverage_ratio = diagnostic_metric(
11771        &run_result.run.diagnostics,
11772        "FEA_EM_FORMULATION",
11773        "magnetoquasistatic_eddy_current_coverage_ratio",
11774    );
11775    let electromagnetic_full_wave_displacement_current_coverage_ratio = diagnostic_metric(
11776        &run_result.run.diagnostics,
11777        "FEA_EM_FORMULATION",
11778        "full_wave_displacement_current_coverage_ratio",
11779    );
11780    let electromagnetic_displacement_to_conduction_ratio = diagnostic_metric(
11781        &run_result.run.diagnostics,
11782        "FEA_EM_FORMULATION",
11783        "displacement_to_conduction_ratio",
11784    );
11785    let electromagnetic_material_frequency_response_coverage_ratio = diagnostic_metric(
11786        &run_result.run.diagnostics,
11787        "FEA_EM_FORMULATION",
11788        "material_frequency_response_coverage_ratio",
11789    );
11790    let electromagnetic_reference_frequency_hz = diagnostic_metric(
11791        &run_result.run.diagnostics,
11792        "FEA_EM_STATIC",
11793        "reference_frequency_hz",
11794    );
11795    let electromagnetic_applied_current_a = diagnostic_metric(
11796        &run_result.run.diagnostics,
11797        "FEA_EM_STATIC",
11798        "applied_current_a",
11799    );
11800    let electromagnetic_solve_quality = diagnostic_metric(
11801        &run_result.run.diagnostics,
11802        "FEA_EM_STATIC",
11803        "solve_quality",
11804    );
11805    let electromagnetic_conductivity_spread_ratio = diagnostic_metric(
11806        &run_result.run.diagnostics,
11807        "FEA_EM_STATIC",
11808        "conductivity_spread_ratio",
11809    );
11810    let electromagnetic_relative_permittivity_spread_ratio = diagnostic_metric(
11811        &run_result.run.diagnostics,
11812        "FEA_EM_STATIC",
11813        "relative_permittivity_spread_ratio",
11814    );
11815    let electromagnetic_relative_permeability_spread_ratio = diagnostic_metric(
11816        &run_result.run.diagnostics,
11817        "FEA_EM_STATIC",
11818        "relative_permeability_spread_ratio",
11819    );
11820    let electromagnetic_material_heterogeneity_index = diagnostic_metric(
11821        &run_result.run.diagnostics,
11822        "FEA_EM_STATIC",
11823        "electromagnetic_material_heterogeneity_index",
11824    );
11825    let electromagnetic_assignment_coverage_ratio = diagnostic_metric(
11826        &run_result.run.diagnostics,
11827        "FEA_EM_STATIC",
11828        "assignment_coverage_ratio",
11829    );
11830    let electromagnetic_assigned_coefficient_coverage_ratio = diagnostic_metric(
11831        &run_result.run.diagnostics,
11832        "FEA_EM_STATIC",
11833        "assigned_coefficient_coverage_ratio",
11834    );
11835    let electromagnetic_region_coefficient_contrast_index = diagnostic_metric(
11836        &run_result.run.diagnostics,
11837        "FEA_EM_STATIC",
11838        "region_coefficient_contrast_index",
11839    );
11840    let electromagnetic_condition_number_estimate = diagnostic_metric(
11841        &run_result.run.diagnostics,
11842        "FEA_EM_STATIC",
11843        "condition_number_estimate",
11844    );
11845    let electromagnetic_source_realization_ratio = diagnostic_metric(
11846        &run_result.run.diagnostics,
11847        "FEA_EM_SOURCE_ENERGY",
11848        "source_realization_ratio",
11849    );
11850    let electromagnetic_source_region_coverage_ratio = diagnostic_metric(
11851        &run_result.run.diagnostics,
11852        "FEA_EM_SOURCE_ENERGY",
11853        "source_region_coverage_ratio",
11854    );
11855    let electromagnetic_source_material_alignment_ratio = diagnostic_metric(
11856        &run_result.run.diagnostics,
11857        "FEA_EM_SOURCE_ENERGY",
11858        "source_material_alignment_ratio",
11859    );
11860    let electromagnetic_source_localization_ratio = diagnostic_metric(
11861        &run_result.run.diagnostics,
11862        "FEA_EM_SOURCE_ENERGY",
11863        "source_localization_ratio",
11864    );
11865    let electromagnetic_source_overlap_ratio = diagnostic_metric(
11866        &run_result.run.diagnostics,
11867        "FEA_EM_SOURCE_ENERGY",
11868        "source_overlap_ratio",
11869    );
11870    let electromagnetic_source_interference_index = diagnostic_metric(
11871        &run_result.run.diagnostics,
11872        "FEA_EM_SOURCE_ENERGY",
11873        "source_interference_index",
11874    );
11875    let electromagnetic_boundary_anchor_ratio = diagnostic_metric(
11876        &run_result.run.diagnostics,
11877        "FEA_EM_SOURCE_ENERGY",
11878        "boundary_anchor_ratio",
11879    );
11880    let electromagnetic_boundary_condition_localization_ratio = diagnostic_metric(
11881        &run_result.run.diagnostics,
11882        "FEA_EM_SOURCE_ENERGY",
11883        "boundary_condition_localization_ratio",
11884    );
11885    let electromagnetic_ground_anchor_effectiveness_ratio = diagnostic_metric(
11886        &run_result.run.diagnostics,
11887        "FEA_EM_SOURCE_ENERGY",
11888        "ground_anchor_effectiveness_ratio",
11889    );
11890    let electromagnetic_insulation_leakage_ratio = diagnostic_metric(
11891        &run_result.run.diagnostics,
11892        "FEA_EM_SOURCE_ENERGY",
11893        "insulation_leakage_ratio",
11894    );
11895    let electromagnetic_flux_divergence_ratio = diagnostic_metric(
11896        &run_result.run.diagnostics,
11897        "FEA_EM_STATIC",
11898        "flux_divergence_ratio",
11899    );
11900    let electromagnetic_energy_imbalance_ratio = diagnostic_metric(
11901        &run_result.run.diagnostics,
11902        "FEA_EM_SOURCE_ENERGY",
11903        "energy_imbalance_ratio",
11904    );
11905    let electromagnetic_boundary_energy_ratio = diagnostic_metric(
11906        &run_result.run.diagnostics,
11907        "FEA_EM_SOURCE_ENERGY",
11908        "boundary_energy_ratio",
11909    );
11910    let electromagnetic_boundary_penalty_conditioning_contribution = diagnostic_metric(
11911        &run_result.run.diagnostics,
11912        "FEA_EM_SOURCE_ENERGY",
11913        "boundary_penalty_conditioning_contribution",
11914    );
11915    let electromagnetic_source_region_energy_consistency_ratio = diagnostic_metric(
11916        &run_result.run.diagnostics,
11917        "FEA_EM_SOURCE_ENERGY",
11918        "source_region_energy_consistency_ratio",
11919    );
11920    let electromagnetic_real_residual_norm = diagnostic_metric(
11921        &run_result.run.diagnostics,
11922        "FEA_EM_STATIC",
11923        "real_residual_norm",
11924    );
11925    let electromagnetic_imag_residual_norm = diagnostic_metric(
11926        &run_result.run.diagnostics,
11927        "FEA_EM_STATIC",
11928        "imag_residual_norm",
11929    );
11930    let electromagnetic_sweep_count =
11931        diagnostic_metric(&run_result.run.diagnostics, "FEA_EM_SWEEP", "sweep_count");
11932    let electromagnetic_resonance_peak_frequency_hz = diagnostic_metric(
11933        &run_result.run.diagnostics,
11934        "FEA_EM_SWEEP",
11935        "resonance_peak_frequency_hz",
11936    );
11937    let electromagnetic_resonance_peak_flux_density = diagnostic_metric(
11938        &run_result.run.diagnostics,
11939        "FEA_EM_SWEEP",
11940        "resonance_peak_flux_density",
11941    );
11942    let electromagnetic_resonance_bandwidth_hz = diagnostic_metric(
11943        &run_result.run.diagnostics,
11944        "FEA_EM_SWEEP",
11945        "resonance_bandwidth_hz",
11946    );
11947    let electromagnetic_resonance_quality_factor = diagnostic_metric(
11948        &run_result.run.diagnostics,
11949        "FEA_EM_SWEEP",
11950        "resonance_quality_factor",
11951    );
11952    let electromagnetic_resonance_flux_gain = diagnostic_metric(
11953        &run_result.run.diagnostics,
11954        "FEA_EM_SWEEP",
11955        "resonance_flux_gain",
11956    );
11957
11958    let summary = AnalysisResultsSummary {
11959        field_count: field_descriptors.len(),
11960        total_elements: field_descriptors
11961            .iter()
11962            .map(|field| field.element_count)
11963            .sum(),
11964        mode_count,
11965        available_mode_indices,
11966        min_frequency_hz,
11967        max_frequency_hz,
11968        max_modal_residual_norm,
11969        first_mode_converged,
11970        snapshot_count,
11971        time_start_s,
11972        time_end_s,
11973        max_transient_residual_norm,
11974        final_step_converged,
11975        increment_count,
11976        failed_increment_count,
11977        max_nonlinear_residual_norm,
11978        max_nonlinear_increment_norm,
11979        max_nonlinear_iteration_count,
11980        final_increment_converged,
11981        nonlinear_line_search_backtracks,
11982        nonlinear_max_backtracks_per_increment,
11983        nonlinear_tangent_rebuild_count,
11984        nonlinear_iteration_spike_count,
11985        nonlinear_convergence_stall_count,
11986        nonlinear_backtrack_burst_count,
11987        prep_calibration_profile,
11988        prep_calibration_fingerprint,
11989        prep_acceptance_score,
11990        prep_acceptance_passed,
11991        prep_acceptance_fingerprint,
11992        thermo_coupling_enabled,
11993        thermo_coupling_fingerprint,
11994        thermo_constitutive_temperature_factor,
11995        thermo_effective_modulus_scale,
11996        thermo_constitutive_material_spread_ratio,
11997        thermo_assignment_heterogeneity_index,
11998        thermo_region_delta_count,
11999        thermo_spatial_coverage_ratio,
12000        thermo_field_extrapolation_ratio,
12001        thermo_field_clamp_ratio,
12002        thermo_transient_severity,
12003        thermo_nonlinear_severity,
12004        electro_thermal_coupling_enabled,
12005        electro_thermal_coupling_fingerprint,
12006        electro_joule_heating_scale,
12007        electro_conductivity_spread_ratio,
12008        electro_transient_severity,
12009        electro_transient_time_scale_mean,
12010        electro_nonlinear_severity,
12011        electro_nonlinear_time_scale_mean,
12012        plastic_nonlinear_severity,
12013        plastic_nonlinear_severity_mean,
12014        plastic_load_realization_ratio,
12015        plastic_load_amplification_ratio,
12016        contact_nonlinear_severity,
12017        contact_nonlinear_severity_mean,
12018        contact_load_realization_ratio,
12019        contact_load_amplification_ratio,
12020        thermal_max_residual_norm,
12021        thermal_min_temperature_k,
12022        thermal_max_temperature_k,
12023        thermal_conductivity_spread_ratio,
12024        thermal_heat_capacity_spread_ratio,
12025        thermal_spatial_gradient_index,
12026        thermal_monotonic_response_fraction,
12027        thermal_response_realization_ratio,
12028        electromagnetic_enabled,
12029        electromagnetic_formulation_coverage_ratio,
12030        electromagnetic_magnetostatic_curl_curl_coverage_ratio,
12031        electromagnetic_magnetoquasistatic_eddy_current_coverage_ratio,
12032        electromagnetic_full_wave_displacement_current_coverage_ratio,
12033        electromagnetic_displacement_to_conduction_ratio,
12034        electromagnetic_material_frequency_response_coverage_ratio,
12035        electromagnetic_reference_frequency_hz,
12036        electromagnetic_applied_current_a,
12037        electromagnetic_solve_quality,
12038        electromagnetic_conductivity_spread_ratio,
12039        electromagnetic_relative_permittivity_spread_ratio,
12040        electromagnetic_relative_permeability_spread_ratio,
12041        electromagnetic_material_heterogeneity_index,
12042        electromagnetic_assignment_coverage_ratio,
12043        electromagnetic_assigned_coefficient_coverage_ratio,
12044        electromagnetic_region_coefficient_contrast_index,
12045        electromagnetic_condition_number_estimate,
12046        electromagnetic_source_realization_ratio,
12047        electromagnetic_source_region_coverage_ratio,
12048        electromagnetic_source_material_alignment_ratio,
12049        electromagnetic_source_localization_ratio,
12050        electromagnetic_source_overlap_ratio,
12051        electromagnetic_source_interference_index,
12052        electromagnetic_boundary_anchor_ratio,
12053        electromagnetic_boundary_condition_localization_ratio,
12054        electromagnetic_ground_anchor_effectiveness_ratio,
12055        electromagnetic_insulation_leakage_ratio,
12056        electromagnetic_flux_divergence_ratio,
12057        electromagnetic_energy_imbalance_ratio,
12058        electromagnetic_boundary_energy_ratio,
12059        electromagnetic_boundary_penalty_conditioning_contribution,
12060        electromagnetic_source_region_energy_consistency_ratio,
12061        electromagnetic_real_residual_norm,
12062        electromagnetic_imag_residual_norm,
12063        electromagnetic_sweep_count,
12064        electromagnetic_resonance_peak_frequency_hz,
12065        electromagnetic_resonance_peak_flux_density,
12066        electromagnetic_resonance_bandwidth_hz,
12067        electromagnetic_resonance_quality_factor,
12068        electromagnetic_resonance_flux_gain,
12069    };
12070
12071    let modal_results = if query.include_modal_results && query.include_field_values {
12072        if let Some(modal) = run_result.modal_results.as_ref() {
12073            if query.mode_indices.is_empty() {
12074                Some(modal.clone())
12075            } else {
12076                let mut eigenvalues_hz = Vec::with_capacity(query.mode_indices.len());
12077                let mut mode_shapes = Vec::with_capacity(query.mode_indices.len());
12078                let mut residual_norms = Vec::with_capacity(query.mode_indices.len());
12079                for &index in &query.mode_indices {
12080                    let eigenvalue = modal.eigenvalues_hz.get(index).copied().ok_or_else(|| {
12081                        operation_error(
12082                            ANALYSIS_RESULTS_OPERATION,
12083                            ANALYSIS_RESULTS_OP_VERSION,
12084                            &context,
12085                            OperationErrorSpec {
12086                                error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
12087                                error_type: OperationErrorType::Input,
12088                                retryable: false,
12089                                severity: OperationErrorSeverity::Error,
12090                            },
12091                            format!("requested modal mode index '{index}' was not produced by run"),
12092                            BTreeMap::from([
12093                                ("requested_mode_index".to_string(), index.to_string()),
12094                                (
12095                                    "available_mode_count".to_string(),
12096                                    modal.eigenvalues_hz.len().to_string(),
12097                                ),
12098                            ]),
12099                        )
12100                    })?;
12101                    let mode_shape = modal.mode_shapes.get(index).cloned().ok_or_else(|| {
12102                        operation_error(
12103                            ANALYSIS_RESULTS_OPERATION,
12104                            ANALYSIS_RESULTS_OP_VERSION,
12105                            &context,
12106                            OperationErrorSpec {
12107                                error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
12108                                error_type: OperationErrorType::Input,
12109                                retryable: false,
12110                                severity: OperationErrorSeverity::Error,
12111                            },
12112                            format!(
12113                                "requested modal mode index '{index}' is missing mode shape data"
12114                            ),
12115                            BTreeMap::from([
12116                                ("requested_mode_index".to_string(), index.to_string()),
12117                                (
12118                                    "available_shape_count".to_string(),
12119                                    modal.mode_shapes.len().to_string(),
12120                                ),
12121                            ]),
12122                        )
12123                    })?;
12124                    let residual_norm =
12125                        modal.residual_norms.get(index).copied().ok_or_else(|| {
12126                            operation_error(
12127                                ANALYSIS_RESULTS_OPERATION,
12128                                ANALYSIS_RESULTS_OP_VERSION,
12129                                &context,
12130                                OperationErrorSpec {
12131                                    error_code: "RM.FEA.RESULTS.MODE_NOT_FOUND",
12132                                    error_type: OperationErrorType::Input,
12133                                    retryable: false,
12134                                    severity: OperationErrorSeverity::Error,
12135                                },
12136                                format!(
12137                                    "requested modal mode index '{index}' is missing residual data"
12138                                ),
12139                                BTreeMap::from([
12140                                    ("requested_mode_index".to_string(), index.to_string()),
12141                                    (
12142                                        "available_residual_count".to_string(),
12143                                        modal.residual_norms.len().to_string(),
12144                                    ),
12145                                ]),
12146                            )
12147                        })?;
12148                    eigenvalues_hz.push(eigenvalue);
12149                    mode_shapes.push(mode_shape);
12150                    residual_norms.push(residual_norm);
12151                }
12152                Some(ModalResultsData {
12153                    modal_payload_version: modal.modal_payload_version.clone(),
12154                    eigenvalues_hz,
12155                    mode_shapes,
12156                    residual_norms,
12157                    mode_units: modal.mode_units,
12158                    frequency_basis: modal.frequency_basis,
12159                })
12160            }
12161        } else {
12162            None
12163        }
12164    } else {
12165        None
12166    };
12167
12168    let transient_results = if query.include_transient_results && query.include_field_values {
12169        if let Some(transient) = run_result.transient_results.as_ref() {
12170            if query.transient_snapshot_indices.is_empty() {
12171                Some(transient.clone())
12172            } else {
12173                let mut time_points_s = Vec::with_capacity(query.transient_snapshot_indices.len());
12174                let mut displacement_snapshots =
12175                    Vec::with_capacity(query.transient_snapshot_indices.len());
12176                let rotation_snapshots = filter_analysis_fields_by_indices(
12177                    &transient.rotation_snapshots,
12178                    &query.transient_snapshot_indices,
12179                );
12180                let mut velocity_snapshots =
12181                    Vec::with_capacity(query.transient_snapshot_indices.len());
12182                let angular_velocity_snapshots = filter_analysis_fields_by_indices(
12183                    &transient.angular_velocity_snapshots,
12184                    &query.transient_snapshot_indices,
12185                );
12186                let mut acceleration_snapshots =
12187                    Vec::with_capacity(query.transient_snapshot_indices.len());
12188                let angular_acceleration_snapshots = filter_analysis_fields_by_indices(
12189                    &transient.angular_acceleration_snapshots,
12190                    &query.transient_snapshot_indices,
12191                );
12192                let mut von_mises_snapshots =
12193                    Vec::with_capacity(query.transient_snapshot_indices.len());
12194                let mut kinetic_energy_snapshots =
12195                    Vec::with_capacity(query.transient_snapshot_indices.len());
12196                let mut strain_energy_snapshots =
12197                    Vec::with_capacity(query.transient_snapshot_indices.len());
12198                let mut residual_norm_snapshots =
12199                    Vec::with_capacity(query.transient_snapshot_indices.len());
12200                let mut residual_norms = Vec::with_capacity(query.transient_snapshot_indices.len());
12201                let thermo_mechanical_temperature_snapshots = filter_analysis_fields_by_indices(
12202                    &transient.thermo_mechanical_temperature_snapshots,
12203                    &query.transient_snapshot_indices,
12204                );
12205                let thermo_mechanical_thermal_strain_snapshots = filter_analysis_fields_by_indices(
12206                    &transient.thermo_mechanical_thermal_strain_snapshots,
12207                    &query.transient_snapshot_indices,
12208                );
12209                let thermo_mechanical_thermal_stress_snapshots = filter_analysis_fields_by_indices(
12210                    &transient.thermo_mechanical_thermal_stress_snapshots,
12211                    &query.transient_snapshot_indices,
12212                );
12213                let thermo_mechanical_displacement_snapshots = filter_analysis_fields_by_indices(
12214                    &transient.thermo_mechanical_displacement_snapshots,
12215                    &query.transient_snapshot_indices,
12216                );
12217                let thermo_mechanical_von_mises_snapshots = filter_analysis_fields_by_indices(
12218                    &transient.thermo_mechanical_von_mises_snapshots,
12219                    &query.transient_snapshot_indices,
12220                );
12221                let thermo_mechanical_coupling_residual_snapshots =
12222                    filter_analysis_fields_by_indices(
12223                        &transient.thermo_mechanical_coupling_residual_snapshots,
12224                        &query.transient_snapshot_indices,
12225                    );
12226                let electro_thermal_temperature_snapshots = filter_analysis_fields_by_indices(
12227                    &transient.electro_thermal_temperature_snapshots,
12228                    &query.transient_snapshot_indices,
12229                );
12230                let electro_thermal_thermal_residual_snapshots = filter_analysis_fields_by_indices(
12231                    &transient.electro_thermal_thermal_residual_snapshots,
12232                    &query.transient_snapshot_indices,
12233                );
12234
12235                for &index in &query.transient_snapshot_indices {
12236                    let time_point = transient.time_points_s.get(index).copied().ok_or_else(|| {
12237                        operation_error(
12238                            ANALYSIS_RESULTS_OPERATION,
12239                            ANALYSIS_RESULTS_OP_VERSION,
12240                            &context,
12241                            OperationErrorSpec {
12242                                error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12243                                error_type: OperationErrorType::Input,
12244                                retryable: false,
12245                                severity: OperationErrorSeverity::Error,
12246                            },
12247                            format!(
12248                                "requested transient snapshot index '{index}' was not produced by run"
12249                            ),
12250                            BTreeMap::from([
12251                                ("requested_snapshot_index".to_string(), index.to_string()),
12252                                (
12253                                    "available_snapshot_count".to_string(),
12254                                    transient.time_points_s.len().to_string(),
12255                                ),
12256                            ]),
12257                        )
12258                    })?;
12259                    let snapshot = transient
12260                        .displacement_snapshots
12261                        .get(index)
12262                        .cloned()
12263                        .ok_or_else(|| {
12264                            operation_error(
12265                                ANALYSIS_RESULTS_OPERATION,
12266                                ANALYSIS_RESULTS_OP_VERSION,
12267                                &context,
12268                                OperationErrorSpec {
12269                                    error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12270                                    error_type: OperationErrorType::Input,
12271                                    retryable: false,
12272                                    severity: OperationErrorSeverity::Error,
12273                                },
12274                                format!(
12275                                    "requested transient snapshot index '{index}' is missing displacement data"
12276                                ),
12277                                BTreeMap::from([
12278                                    ("requested_snapshot_index".to_string(), index.to_string()),
12279                                    (
12280                                        "available_displacement_snapshot_count".to_string(),
12281                                        transient.displacement_snapshots.len().to_string(),
12282                                    ),
12283                                ]),
12284                            )
12285                        })?;
12286                    let velocity = transient.velocity_snapshots.get(index).cloned().ok_or_else(|| {
12287                        operation_error(
12288                            ANALYSIS_RESULTS_OPERATION,
12289                            ANALYSIS_RESULTS_OP_VERSION,
12290                            &context,
12291                            OperationErrorSpec {
12292                                error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12293                                error_type: OperationErrorType::Input,
12294                                retryable: false,
12295                                severity: OperationErrorSeverity::Error,
12296                            },
12297                            format!(
12298                                "requested transient snapshot index '{index}' is missing velocity data"
12299                            ),
12300                            BTreeMap::from([
12301                                ("requested_snapshot_index".to_string(), index.to_string()),
12302                                (
12303                                    "available_velocity_snapshot_count".to_string(),
12304                                    transient.velocity_snapshots.len().to_string(),
12305                                ),
12306                            ]),
12307                        )
12308                    })?;
12309                    let acceleration =
12310                        transient
12311                            .acceleration_snapshots
12312                            .get(index)
12313                            .cloned()
12314                            .ok_or_else(|| {
12315                                operation_error(
12316                                    ANALYSIS_RESULTS_OPERATION,
12317                                    ANALYSIS_RESULTS_OP_VERSION,
12318                                    &context,
12319                                    OperationErrorSpec {
12320                                        error_code:
12321                                            "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12322                                        error_type: OperationErrorType::Input,
12323                                        retryable: false,
12324                                        severity: OperationErrorSeverity::Error,
12325                                    },
12326                                    format!(
12327                                        "requested transient snapshot index '{index}' is missing acceleration data"
12328                                    ),
12329                                    BTreeMap::from([
12330                                        ("requested_snapshot_index".to_string(), index.to_string()),
12331                                        (
12332                                            "available_acceleration_snapshot_count".to_string(),
12333                                            transient.acceleration_snapshots.len().to_string(),
12334                                        ),
12335                                    ]),
12336                                )
12337                            })?;
12338                    let von_mises =
12339                        transient
12340                            .von_mises_snapshots
12341                            .get(index)
12342                            .cloned()
12343                            .ok_or_else(|| {
12344                                operation_error(
12345                                    ANALYSIS_RESULTS_OPERATION,
12346                                    ANALYSIS_RESULTS_OP_VERSION,
12347                                    &context,
12348                                    OperationErrorSpec {
12349                                        error_code:
12350                                            "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12351                                        error_type: OperationErrorType::Input,
12352                                        retryable: false,
12353                                        severity: OperationErrorSeverity::Error,
12354                                    },
12355                                    format!(
12356                                        "requested transient snapshot index '{index}' is missing von Mises data"
12357                                    ),
12358                                    BTreeMap::from([
12359                                        ("requested_snapshot_index".to_string(), index.to_string()),
12360                                        (
12361                                            "available_von_mises_snapshot_count".to_string(),
12362                                            transient.von_mises_snapshots.len().to_string(),
12363                                        ),
12364                                    ]),
12365                                )
12366                            })?;
12367                    let kinetic_energy =
12368                        transient
12369                            .kinetic_energy_snapshots
12370                            .get(index)
12371                            .cloned()
12372                            .ok_or_else(|| {
12373                                operation_error(
12374                                    ANALYSIS_RESULTS_OPERATION,
12375                                    ANALYSIS_RESULTS_OP_VERSION,
12376                                    &context,
12377                                    OperationErrorSpec {
12378                                        error_code:
12379                                            "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12380                                        error_type: OperationErrorType::Input,
12381                                        retryable: false,
12382                                        severity: OperationErrorSeverity::Error,
12383                                    },
12384                                    format!(
12385                                        "requested transient snapshot index '{index}' is missing kinetic energy data"
12386                                    ),
12387                                    BTreeMap::from([
12388                                        ("requested_snapshot_index".to_string(), index.to_string()),
12389                                        (
12390                                            "available_kinetic_energy_snapshot_count".to_string(),
12391                                            transient.kinetic_energy_snapshots.len().to_string(),
12392                                        ),
12393                                    ]),
12394                                )
12395                            })?;
12396                    let strain_energy =
12397                        transient
12398                            .strain_energy_snapshots
12399                            .get(index)
12400                            .cloned()
12401                            .ok_or_else(|| {
12402                                operation_error(
12403                                    ANALYSIS_RESULTS_OPERATION,
12404                                    ANALYSIS_RESULTS_OP_VERSION,
12405                                    &context,
12406                                    OperationErrorSpec {
12407                                        error_code:
12408                                            "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12409                                        error_type: OperationErrorType::Input,
12410                                        retryable: false,
12411                                        severity: OperationErrorSeverity::Error,
12412                                    },
12413                                    format!(
12414                                        "requested transient snapshot index '{index}' is missing strain energy data"
12415                                    ),
12416                                    BTreeMap::from([
12417                                        ("requested_snapshot_index".to_string(), index.to_string()),
12418                                        (
12419                                            "available_strain_energy_snapshot_count".to_string(),
12420                                            transient.strain_energy_snapshots.len().to_string(),
12421                                        ),
12422                                    ]),
12423                                )
12424                            })?;
12425                    let residual_norm_snapshot = transient
12426                        .residual_norm_snapshots
12427                        .get(index)
12428                        .cloned()
12429                        .ok_or_else(|| {
12430                            operation_error(
12431                                ANALYSIS_RESULTS_OPERATION,
12432                                ANALYSIS_RESULTS_OP_VERSION,
12433                                &context,
12434                                OperationErrorSpec {
12435                                    error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12436                                    error_type: OperationErrorType::Input,
12437                                    retryable: false,
12438                                    severity: OperationErrorSeverity::Error,
12439                                },
12440                                format!(
12441                                    "requested transient snapshot index '{index}' is missing residual field data"
12442                                ),
12443                                BTreeMap::from([
12444                                    ("requested_snapshot_index".to_string(), index.to_string()),
12445                                    (
12446                                        "available_residual_snapshot_count".to_string(),
12447                                        transient.residual_norm_snapshots.len().to_string(),
12448                                    ),
12449                                ]),
12450                            )
12451                        })?;
12452
12453                    if index > 0 {
12454                        let residual = transient.residual_norms.get(index - 1).copied().ok_or_else(|| {
12455                            operation_error(
12456                                ANALYSIS_RESULTS_OPERATION,
12457                                ANALYSIS_RESULTS_OP_VERSION,
12458                                &context,
12459                                OperationErrorSpec {
12460                                    error_code: "RM.FEA.RESULTS.TRANSIENT_SNAPSHOT_NOT_FOUND",
12461                                    error_type: OperationErrorType::Input,
12462                                    retryable: false,
12463                                    severity: OperationErrorSeverity::Error,
12464                                },
12465                                format!(
12466                                    "requested transient snapshot index '{index}' is missing residual data"
12467                                ),
12468                                BTreeMap::from([
12469                                    ("requested_snapshot_index".to_string(), index.to_string()),
12470                                    (
12471                                        "available_residual_count".to_string(),
12472                                        transient.residual_norms.len().to_string(),
12473                                    ),
12474                                ]),
12475                            )
12476                        })?;
12477                        residual_norms.push(residual);
12478                    }
12479
12480                    time_points_s.push(time_point);
12481                    displacement_snapshots.push(snapshot);
12482                    velocity_snapshots.push(velocity);
12483                    acceleration_snapshots.push(acceleration);
12484                    von_mises_snapshots.push(von_mises);
12485                    kinetic_energy_snapshots.push(kinetic_energy);
12486                    strain_energy_snapshots.push(strain_energy);
12487                    residual_norm_snapshots.push(residual_norm_snapshot);
12488                }
12489
12490                Some(TransientResultsData {
12491                    transient_payload_version: transient.transient_payload_version.clone(),
12492                    time_points_s,
12493                    displacement_snapshots,
12494                    rotation_snapshots,
12495                    velocity_snapshots,
12496                    angular_velocity_snapshots,
12497                    acceleration_snapshots,
12498                    angular_acceleration_snapshots,
12499                    von_mises_snapshots,
12500                    kinetic_energy_snapshots,
12501                    strain_energy_snapshots,
12502                    residual_norm_snapshots,
12503                    thermo_mechanical_temperature_snapshots,
12504                    thermo_mechanical_thermal_strain_snapshots,
12505                    thermo_mechanical_thermal_stress_snapshots,
12506                    thermo_mechanical_displacement_snapshots,
12507                    thermo_mechanical_von_mises_snapshots,
12508                    thermo_mechanical_coupling_residual_snapshots,
12509                    electro_thermal_temperature_snapshots,
12510                    electro_thermal_thermal_residual_snapshots,
12511                    residual_norms,
12512                    integration_method: transient.integration_method,
12513                })
12514            }
12515        } else {
12516            None
12517        }
12518    } else {
12519        None
12520    };
12521
12522    let thermal_results = if query.include_field_values {
12523        run_result.thermal_results.clone()
12524    } else {
12525        None
12526    };
12527
12528    let nonlinear_results = if query.include_nonlinear_results && query.include_field_values {
12529        run_result.nonlinear_results.clone()
12530    } else {
12531        None
12532    };
12533    let electromagnetic_results =
12534        if query.include_electromagnetic_results && query.include_field_values {
12535            run_result.electromagnetic_results.clone()
12536        } else {
12537            None
12538        };
12539    let fields = if query.include_field_values {
12540        collected_fields
12541    } else {
12542        Vec::new()
12543    };
12544
12545    let data = AnalysisResultsData {
12546        field_descriptors,
12547        fields,
12548        modal_results,
12549        thermal_results,
12550        transient_results,
12551        nonlinear_results,
12552        electromagnetic_results,
12553        diagnostics: if query.include_diagnostics {
12554            if query.diagnostic_codes.is_empty() {
12555                Some(run_result.run.diagnostics.clone())
12556            } else {
12557                Some(
12558                    run_result
12559                        .run
12560                        .diagnostics
12561                        .iter()
12562                        .filter(|diag| query.diagnostic_codes.iter().any(|code| code == &diag.code))
12563                        .cloned()
12564                        .collect(),
12565                )
12566            }
12567        } else {
12568            None
12569        },
12570        run_status: run_result.run_status,
12571        publishable: run_result.publishable,
12572        quality_reasons: run_result.quality_reasons.clone(),
12573        provenance: run_result.provenance.clone(),
12574        summary,
12575    };
12576
12577    Ok(OperationEnvelope::new(
12578        ANALYSIS_RESULTS_OPERATION,
12579        ANALYSIS_RESULTS_OP_VERSION,
12580        &context,
12581        data,
12582    ))
12583}
12584
12585pub fn analysis_results_by_run_id_op(
12586    run_id: &str,
12587    query: AnalysisResultsQuery,
12588    context: OperationContext,
12589) -> Result<OperationEnvelope<AnalysisResultsData>, OperationErrorEnvelope> {
12590    let run_result = storage::load_run_result(run_id).map_err(|err| {
12591        operation_error(
12592            ANALYSIS_RESULTS_OPERATION,
12593            ANALYSIS_RESULTS_OP_VERSION,
12594            &context,
12595            OperationErrorSpec {
12596                error_code: "RM.FEA.RESULTS.ARTIFACT_STORE_FAILED",
12597                error_type: OperationErrorType::Internal,
12598                retryable: true,
12599                severity: OperationErrorSeverity::Error,
12600            },
12601            format!("failed to load FEA run artifact: {err}"),
12602            BTreeMap::from([("run_id".to_string(), run_id.to_string())]),
12603        )
12604    })?;
12605
12606    let Some(run_result) = run_result else {
12607        return Err(operation_error(
12608            ANALYSIS_RESULTS_OPERATION,
12609            ANALYSIS_RESULTS_OP_VERSION,
12610            &context,
12611            OperationErrorSpec {
12612                error_code: "RM.FEA.RESULTS.RUN_NOT_FOUND",
12613                error_type: OperationErrorType::Input,
12614                retryable: false,
12615                severity: OperationErrorSeverity::Error,
12616            },
12617            format!("FEA run_id '{run_id}' was not found"),
12618            BTreeMap::from([("run_id".to_string(), run_id.to_string())]),
12619        ));
12620    };
12621
12622    analysis_results_op(&run_result, query, context)
12623}
12624
12625pub fn analysis_results_compare_op(
12626    query: AnalysisResultsCompareQuery,
12627    context: OperationContext,
12628) -> Result<OperationEnvelope<AnalysisResultsCompareData>, OperationErrorEnvelope> {
12629    let baseline = storage::load_run_result(&query.baseline_run_id).map_err(|err| {
12630        operation_error(
12631            ANALYSIS_RESULTS_COMPARE_OPERATION,
12632            ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12633            &context,
12634            OperationErrorSpec {
12635                error_code: "RM.FEA.RESULTS_COMPARE.ARTIFACT_STORE_FAILED",
12636                error_type: OperationErrorType::Internal,
12637                retryable: true,
12638                severity: OperationErrorSeverity::Error,
12639            },
12640            format!("failed to load baseline FEA run artifact: {err}"),
12641            BTreeMap::from([("run_id".to_string(), query.baseline_run_id.clone())]),
12642        )
12643    })?;
12644    let Some(baseline) = baseline else {
12645        return Err(operation_error(
12646            ANALYSIS_RESULTS_COMPARE_OPERATION,
12647            ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12648            &context,
12649            OperationErrorSpec {
12650                error_code: "RM.FEA.RESULTS_COMPARE.RUN_NOT_FOUND",
12651                error_type: OperationErrorType::Input,
12652                retryable: false,
12653                severity: OperationErrorSeverity::Error,
12654            },
12655            format!(
12656                "FEA baseline run_id '{}' was not found",
12657                query.baseline_run_id
12658            ),
12659            BTreeMap::from([("run_id".to_string(), query.baseline_run_id.clone())]),
12660        ));
12661    };
12662
12663    let candidate = storage::load_run_result(&query.candidate_run_id).map_err(|err| {
12664        operation_error(
12665            ANALYSIS_RESULTS_COMPARE_OPERATION,
12666            ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12667            &context,
12668            OperationErrorSpec {
12669                error_code: "RM.FEA.RESULTS_COMPARE.ARTIFACT_STORE_FAILED",
12670                error_type: OperationErrorType::Internal,
12671                retryable: true,
12672                severity: OperationErrorSeverity::Error,
12673            },
12674            format!("failed to load candidate FEA run artifact: {err}"),
12675            BTreeMap::from([("run_id".to_string(), query.candidate_run_id.clone())]),
12676        )
12677    })?;
12678    let Some(candidate) = candidate else {
12679        return Err(operation_error(
12680            ANALYSIS_RESULTS_COMPARE_OPERATION,
12681            ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12682            &context,
12683            OperationErrorSpec {
12684                error_code: "RM.FEA.RESULTS_COMPARE.RUN_NOT_FOUND",
12685                error_type: OperationErrorType::Input,
12686                retryable: false,
12687                severity: OperationErrorSeverity::Error,
12688            },
12689            format!(
12690                "FEA candidate run_id '{}' was not found",
12691                query.candidate_run_id
12692            ),
12693            BTreeMap::from([("run_id".to_string(), query.candidate_run_id.clone())]),
12694        ));
12695    };
12696
12697    let baseline_solve_ms = run_solve_ms(&baseline);
12698    let candidate_solve_ms = run_solve_ms(&candidate);
12699    let failed_increment_delta = match (
12700        baseline.nonlinear_results.as_ref(),
12701        candidate.nonlinear_results.as_ref(),
12702    ) {
12703        (Some(a), Some(b)) => Some(b.failed_increments as i64 - a.failed_increments as i64),
12704        _ => None,
12705    };
12706    let max_iteration_delta = match (
12707        baseline.nonlinear_results.as_ref(),
12708        candidate.nonlinear_results.as_ref(),
12709    ) {
12710        (Some(a), Some(b)) => Some(
12711            b.iteration_counts.iter().copied().max().unwrap_or(0) as i64
12712                - a.iteration_counts.iter().copied().max().unwrap_or(0) as i64,
12713        ),
12714        _ => None,
12715    };
12716    let nonlinear_spike_count_delta = match (
12717        baseline.nonlinear_results.as_ref(),
12718        candidate.nonlinear_results.as_ref(),
12719    ) {
12720        (Some(a), Some(b)) => Some(b.iteration_spike_count as i64 - a.iteration_spike_count as i64),
12721        _ => None,
12722    };
12723    let nonlinear_stall_count_delta = match (
12724        baseline.nonlinear_results.as_ref(),
12725        candidate.nonlinear_results.as_ref(),
12726    ) {
12727        (Some(a), Some(b)) => {
12728            Some(b.convergence_stall_count as i64 - a.convergence_stall_count as i64)
12729        }
12730        _ => None,
12731    };
12732
12733    let data = AnalysisResultsCompareData {
12734        baseline_run_id: baseline.run_id,
12735        candidate_run_id: candidate.run_id,
12736        publishable_changed: baseline.publishable != candidate.publishable,
12737        run_status_changed: baseline.run_status != candidate.run_status,
12738        quality_reason_count_delta: candidate.quality_reasons.len() as i64
12739            - baseline.quality_reasons.len() as i64,
12740        failed_increment_delta,
12741        max_iteration_delta,
12742        nonlinear_spike_count_delta,
12743        nonlinear_stall_count_delta,
12744        solve_ms_delta: match (baseline_solve_ms, candidate_solve_ms) {
12745            (Some(a), Some(b)) => Some(b - a),
12746            _ => None,
12747        },
12748    };
12749
12750    Ok(OperationEnvelope::new(
12751        ANALYSIS_RESULTS_COMPARE_OPERATION,
12752        ANALYSIS_RESULTS_COMPARE_OP_VERSION,
12753        &context,
12754        data,
12755    ))
12756}
12757
12758pub fn analysis_trends_op(
12759    query: AnalysisTrendsQuery,
12760    context: OperationContext,
12761) -> Result<OperationEnvelope<AnalysisTrendsData>, OperationErrorEnvelope> {
12762    let runs = storage::list_run_results().map_err(|err| {
12763        operation_error(
12764            ANALYSIS_TRENDS_OPERATION,
12765            ANALYSIS_TRENDS_OP_VERSION,
12766            &context,
12767            OperationErrorSpec {
12768                error_code: "RM.FEA.TRENDS.ARTIFACT_STORE_FAILED",
12769                error_type: OperationErrorType::Internal,
12770                retryable: true,
12771                severity: OperationErrorSeverity::Error,
12772            },
12773            format!("failed to list FEA run artifacts: {err}"),
12774            BTreeMap::new(),
12775        )
12776    })?;
12777
12778    let mut grouped: HashMap<AnalysisRunKind, Vec<AnalysisRunResult>> = HashMap::new();
12779    for run in runs {
12780        grouped.entry(run_kind(&run)).or_default().push(run);
12781    }
12782
12783    let window = query.window_size.max(1);
12784    let mut summaries = Vec::new();
12785    for kind in [
12786        AnalysisRunKind::LinearStatic,
12787        AnalysisRunKind::Modal,
12788        AnalysisRunKind::Acoustic,
12789        AnalysisRunKind::Thermal,
12790        AnalysisRunKind::Transient,
12791        AnalysisRunKind::Cfd,
12792        AnalysisRunKind::Cht,
12793        AnalysisRunKind::Fsi,
12794        AnalysisRunKind::Nonlinear,
12795        AnalysisRunKind::Electromagnetic,
12796    ] {
12797        let Some(mut entries) = grouped.remove(&kind) else {
12798            continue;
12799        };
12800        entries.sort_by(|a, b| b.run_id.cmp(&a.run_id));
12801        if entries.len() > window {
12802            entries.truncate(window);
12803        }
12804        let sample_count = entries.len();
12805        if sample_count == 0 {
12806            continue;
12807        }
12808
12809        let mut solve_samples = entries
12810            .iter()
12811            .filter_map(run_solve_ms)
12812            .filter(|value| value.is_finite())
12813            .collect::<Vec<_>>();
12814        solve_samples.sort_by(|a, b| a.total_cmp(b));
12815        let median_solve_ms = percentile(&solve_samples, 0.5);
12816        let p95_solve_ms = percentile(&solve_samples, 0.95);
12817        let publishable_rate =
12818            entries.iter().filter(|run| run.publishable).count() as f64 / sample_count as f64;
12819
12820        let failed_increment_rate = if kind == AnalysisRunKind::Nonlinear {
12821            let failed = entries
12822                .iter()
12823                .filter_map(|run| run.nonlinear_results.as_ref())
12824                .filter(|nonlinear| nonlinear.failed_increments > 0)
12825                .count();
12826            Some(failed as f64 / sample_count as f64)
12827        } else {
12828            None
12829        };
12830        let mean_spike_count = if kind == AnalysisRunKind::Nonlinear {
12831            let values = entries
12832                .iter()
12833                .filter_map(|run| run.nonlinear_results.as_ref())
12834                .map(|nonlinear| nonlinear.iteration_spike_count as f64)
12835                .collect::<Vec<_>>();
12836            Some(mean(&values))
12837        } else {
12838            None
12839        };
12840        let mean_stall_count = if kind == AnalysisRunKind::Nonlinear {
12841            let values = entries
12842                .iter()
12843                .filter_map(|run| run.nonlinear_results.as_ref())
12844                .map(|nonlinear| nonlinear.convergence_stall_count as f64)
12845                .collect::<Vec<_>>();
12846            Some(mean(&values))
12847        } else {
12848            None
12849        };
12850        let prep_acceptance_rate = {
12851            let values = entries
12852                .iter()
12853                .filter_map(|run| {
12854                    diagnostic_metric_bool(&run.run.diagnostics, "FEA_PREP_ACCEPTANCE", "accepted")
12855                })
12856                .collect::<Vec<_>>();
12857            if values.is_empty() {
12858                None
12859            } else {
12860                Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12861            }
12862        };
12863        let prep_calibration_fast_rate = calibration_profile_rate(&entries, "fast");
12864        let prep_calibration_balanced_rate = calibration_profile_rate(&entries, "balanced");
12865        let prep_calibration_conservative_rate = calibration_profile_rate(&entries, "conservative");
12866        let thermo_coupling_enabled_rate = {
12867            let values = entries
12868                .iter()
12869                .filter_map(|run| {
12870                    diagnostic_metric_bool(&run.run.diagnostics, "FEA_TM_COUPLING", "enabled")
12871                })
12872                .collect::<Vec<_>>();
12873            if values.is_empty() {
12874                None
12875            } else {
12876                Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12877            }
12878        };
12879        let thermo_transient_warn_rate = if kind == AnalysisRunKind::Transient {
12880            diagnostic_warning_rate(&entries, "FEA_TM_TRANSIENT")
12881        } else {
12882            None
12883        };
12884        let thermo_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12885            diagnostic_warning_rate(&entries, "FEA_TM_NONLINEAR")
12886        } else {
12887            None
12888        };
12889        let thermo_spread_breach_rate = {
12890            let values = entries
12891                .iter()
12892                .filter_map(|run| {
12893                    diagnostic_metric(
12894                        &run.run.diagnostics,
12895                        "FEA_TM_COUPLING",
12896                        "constitutive_material_spread_ratio",
12897                    )
12898                })
12899                .collect::<Vec<_>>();
12900            breach_rate_greater_than(&values, THERMO_SPREAD_THRESHOLD_BALANCED)
12901        };
12902        let thermo_heterogeneity_breach_rate = {
12903            let values = entries
12904                .iter()
12905                .filter_map(|run| {
12906                    diagnostic_metric(
12907                        &run.run.diagnostics,
12908                        "FEA_TM_COUPLING",
12909                        "assignment_heterogeneity_index",
12910                    )
12911                })
12912                .collect::<Vec<_>>();
12913            breach_rate_greater_than(&values, THERMO_HETEROGENEITY_THRESHOLD_BALANCED)
12914        };
12915        let electro_thermal_coupling_enabled_rate = {
12916            let values = entries
12917                .iter()
12918                .filter_map(|run| {
12919                    diagnostic_metric_bool(&run.run.diagnostics, "FEA_ET_COUPLING", "enabled")
12920                })
12921                .collect::<Vec<_>>();
12922            if values.is_empty() {
12923                None
12924            } else {
12925                Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
12926            }
12927        };
12928        let electro_transient_warn_rate = if kind == AnalysisRunKind::Transient {
12929            diagnostic_warning_rate(&entries, "FEA_ET_TRANSIENT")
12930        } else {
12931            None
12932        };
12933        let electro_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12934            diagnostic_warning_rate(&entries, "FEA_ET_NONLINEAR")
12935        } else {
12936            None
12937        };
12938        let plastic_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12939            diagnostic_warning_rate(&entries, "FEA_PLASTIC_NONLINEAR")
12940        } else {
12941            None
12942        };
12943        let contact_nonlinear_warn_rate = if kind == AnalysisRunKind::Nonlinear {
12944            diagnostic_warning_rate(&entries, "FEA_CONTACT_NONLINEAR")
12945        } else {
12946            None
12947        };
12948        let thermal_stability_warn_rate = if kind == AnalysisRunKind::Thermal {
12949            diagnostic_warning_rate(&entries, "FEA_THERMAL_STABILITY")
12950        } else {
12951            None
12952        };
12953        let thermal_constitutive_warn_rate = if kind == AnalysisRunKind::Thermal {
12954            diagnostic_warning_rate(&entries, "FEA_THERMAL_CONSTITUTIVE")
12955        } else {
12956            None
12957        };
12958        let thermal_spread_breach_rate = if kind == AnalysisRunKind::Thermal {
12959            let values = entries
12960                .iter()
12961                .filter_map(|run| {
12962                    diagnostic_metric(
12963                        &run.run.diagnostics,
12964                        "FEA_THERMAL_CONSTITUTIVE",
12965                        "conductivity_spread_ratio",
12966                    )
12967                })
12968                .collect::<Vec<_>>();
12969            breach_rate_greater_than(&values, 2.5)
12970        } else {
12971            None
12972        };
12973        let electromagnetic_solve_warn_rate = if kind == AnalysisRunKind::Electromagnetic {
12974            Some(diagnostic_warning_rate(&entries, "FEA_EM_STATIC").unwrap_or(0.0))
12975        } else {
12976            None
12977        };
12978        let electromagnetic_spread_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
12979            let values = entries
12980                .iter()
12981                .filter_map(|run| {
12982                    diagnostic_metric(
12983                        &run.run.diagnostics,
12984                        "FEA_EM_STATIC",
12985                        "conductivity_spread_ratio",
12986                    )
12987                })
12988                .collect::<Vec<_>>();
12989            breach_rate_greater_than(&values, EM_CONDUCTIVITY_SPREAD_THRESHOLD_BALANCED)
12990        } else {
12991            None
12992        };
12993        let electromagnetic_heterogeneity_breach_rate = if kind == AnalysisRunKind::Electromagnetic
12994        {
12995            let values = entries
12996                .iter()
12997                .filter_map(|run| {
12998                    diagnostic_metric(
12999                        &run.run.diagnostics,
13000                        "FEA_EM_STATIC",
13001                        "electromagnetic_material_heterogeneity_index",
13002                    )
13003                })
13004                .collect::<Vec<_>>();
13005            breach_rate_greater_than(&values, EM_HETEROGENEITY_THRESHOLD_BALANCED)
13006        } else {
13007            None
13008        };
13009        let electromagnetic_coverage_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
13010            let values = entries
13011                .iter()
13012                .filter_map(|run| {
13013                    diagnostic_metric(
13014                        &run.run.diagnostics,
13015                        "FEA_EM_STATIC",
13016                        "assignment_coverage_ratio",
13017                    )
13018                })
13019                .collect::<Vec<_>>();
13020            breach_rate_less_than(&values, EM_ASSIGNMENT_COVERAGE_MIN_BALANCED)
13021        } else {
13022            None
13023        };
13024        let electromagnetic_contrast_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
13025            let values = entries
13026                .iter()
13027                .filter_map(|run| {
13028                    diagnostic_metric(
13029                        &run.run.diagnostics,
13030                        "FEA_EM_STATIC",
13031                        "region_coefficient_contrast_index",
13032                    )
13033                })
13034                .collect::<Vec<_>>();
13035            breach_rate_greater_than(&values, EM_REGION_CONTRAST_MAX_BALANCED)
13036        } else {
13037            None
13038        };
13039        let electromagnetic_conditioning_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
13040            let values = entries
13041                .iter()
13042                .filter_map(|run| {
13043                    diagnostic_metric(
13044                        &run.run.diagnostics,
13045                        "FEA_EM_STATIC",
13046                        "condition_number_estimate",
13047                    )
13048                })
13049                .collect::<Vec<_>>();
13050            breach_rate_greater_than(&values, EM_CONDITIONING_MAX_BALANCED)
13051        } else {
13052            None
13053        };
13054        let electromagnetic_source_realization_breach_rate =
13055            if kind == AnalysisRunKind::Electromagnetic {
13056                let values = entries
13057                    .iter()
13058                    .filter_map(|run| {
13059                        diagnostic_metric(
13060                            &run.run.diagnostics,
13061                            "FEA_EM_SOURCE_ENERGY",
13062                            "source_realization_ratio",
13063                        )
13064                    })
13065                    .collect::<Vec<_>>();
13066                breach_rate_less_than(&values, EM_SOURCE_REALIZATION_MIN_BALANCED)
13067            } else {
13068                None
13069            };
13070        let electromagnetic_source_region_coverage_breach_rate =
13071            if kind == AnalysisRunKind::Electromagnetic {
13072                let values = entries
13073                    .iter()
13074                    .filter_map(|run| {
13075                        diagnostic_metric(
13076                            &run.run.diagnostics,
13077                            "FEA_EM_SOURCE_ENERGY",
13078                            "source_region_coverage_ratio",
13079                        )
13080                    })
13081                    .collect::<Vec<_>>();
13082                breach_rate_less_than(&values, EM_SOURCE_REGION_COVERAGE_MIN_BALANCED)
13083            } else {
13084                None
13085            };
13086        let electromagnetic_source_material_alignment_breach_rate =
13087            if kind == AnalysisRunKind::Electromagnetic {
13088                let values = entries
13089                    .iter()
13090                    .filter_map(|run| {
13091                        diagnostic_metric(
13092                            &run.run.diagnostics,
13093                            "FEA_EM_SOURCE_ENERGY",
13094                            "source_material_alignment_ratio",
13095                        )
13096                    })
13097                    .collect::<Vec<_>>();
13098                breach_rate_less_than(&values, EM_SOURCE_MATERIAL_ALIGNMENT_MIN_BALANCED)
13099            } else {
13100                None
13101            };
13102        let electromagnetic_source_overlap_breach_rate = if kind == AnalysisRunKind::Electromagnetic
13103        {
13104            let values = entries
13105                .iter()
13106                .filter_map(|run| {
13107                    diagnostic_metric(
13108                        &run.run.diagnostics,
13109                        "FEA_EM_SOURCE_ENERGY",
13110                        "source_overlap_ratio",
13111                    )
13112                })
13113                .collect::<Vec<_>>();
13114            breach_rate_greater_than(&values, EM_SOURCE_OVERLAP_MAX_BALANCED)
13115        } else {
13116            None
13117        };
13118        let electromagnetic_source_interference_breach_rate =
13119            if kind == AnalysisRunKind::Electromagnetic {
13120                let values = entries
13121                    .iter()
13122                    .filter_map(|run| {
13123                        diagnostic_metric(
13124                            &run.run.diagnostics,
13125                            "FEA_EM_SOURCE_ENERGY",
13126                            "source_interference_index",
13127                        )
13128                    })
13129                    .collect::<Vec<_>>();
13130                breach_rate_greater_than(&values, EM_SOURCE_INTERFERENCE_MAX_BALANCED)
13131            } else {
13132                None
13133            };
13134        let electromagnetic_boundary_anchor_breach_rate =
13135            if kind == AnalysisRunKind::Electromagnetic {
13136                let values = entries
13137                    .iter()
13138                    .filter_map(|run| {
13139                        diagnostic_metric(
13140                            &run.run.diagnostics,
13141                            "FEA_EM_SOURCE_ENERGY",
13142                            "boundary_anchor_ratio",
13143                        )
13144                    })
13145                    .collect::<Vec<_>>();
13146                breach_rate_less_than(&values, EM_BOUNDARY_ANCHOR_MIN_BALANCED)
13147            } else {
13148                None
13149            };
13150        let electromagnetic_boundary_localization_breach_rate =
13151            if kind == AnalysisRunKind::Electromagnetic {
13152                let values = entries
13153                    .iter()
13154                    .filter_map(|run| {
13155                        diagnostic_metric(
13156                            &run.run.diagnostics,
13157                            "FEA_EM_SOURCE_ENERGY",
13158                            "boundary_condition_localization_ratio",
13159                        )
13160                    })
13161                    .collect::<Vec<_>>();
13162                breach_rate_less_than(&values, EM_BOUNDARY_LOCALIZATION_MIN_BALANCED)
13163            } else {
13164                None
13165            };
13166        let electromagnetic_ground_effectiveness_breach_rate =
13167            if kind == AnalysisRunKind::Electromagnetic {
13168                let values = entries
13169                    .iter()
13170                    .filter_map(|run| {
13171                        diagnostic_metric(
13172                            &run.run.diagnostics,
13173                            "FEA_EM_SOURCE_ENERGY",
13174                            "ground_anchor_effectiveness_ratio",
13175                        )
13176                    })
13177                    .collect::<Vec<_>>();
13178                breach_rate_less_than(&values, EM_GROUND_EFFECTIVENESS_MIN_BALANCED)
13179            } else {
13180                None
13181            };
13182        let electromagnetic_insulation_leakage_breach_rate =
13183            if kind == AnalysisRunKind::Electromagnetic {
13184                let values = entries
13185                    .iter()
13186                    .filter_map(|run| {
13187                        diagnostic_metric(
13188                            &run.run.diagnostics,
13189                            "FEA_EM_SOURCE_ENERGY",
13190                            "insulation_leakage_ratio",
13191                        )
13192                    })
13193                    .collect::<Vec<_>>();
13194                breach_rate_greater_than(&values, EM_INSULATION_LEAKAGE_MAX_BALANCED)
13195            } else {
13196                None
13197            };
13198        let electromagnetic_divergence_breach_rate = if kind == AnalysisRunKind::Electromagnetic {
13199            let values = entries
13200                .iter()
13201                .filter_map(|run| {
13202                    diagnostic_metric(
13203                        &run.run.diagnostics,
13204                        "FEA_EM_STATIC",
13205                        "flux_divergence_ratio",
13206                    )
13207                })
13208                .collect::<Vec<_>>();
13209            breach_rate_greater_than(&values, EM_FLUX_DIVERGENCE_MAX_BALANCED)
13210        } else {
13211            None
13212        };
13213        let electromagnetic_energy_imbalance_breach_rate =
13214            if kind == AnalysisRunKind::Electromagnetic {
13215                let values = entries
13216                    .iter()
13217                    .filter_map(|run| {
13218                        diagnostic_metric(
13219                            &run.run.diagnostics,
13220                            "FEA_EM_SOURCE_ENERGY",
13221                            "energy_imbalance_ratio",
13222                        )
13223                    })
13224                    .collect::<Vec<_>>();
13225                breach_rate_greater_than(&values, EM_ENERGY_IMBALANCE_MAX_BALANCED)
13226            } else {
13227                None
13228            };
13229        let electromagnetic_boundary_energy_breach_rate =
13230            if kind == AnalysisRunKind::Electromagnetic {
13231                let values = entries
13232                    .iter()
13233                    .filter_map(|run| {
13234                        diagnostic_metric(
13235                            &run.run.diagnostics,
13236                            "FEA_EM_SOURCE_ENERGY",
13237                            "boundary_energy_ratio",
13238                        )
13239                    })
13240                    .collect::<Vec<_>>();
13241                breach_rate_less_than(&values, EM_BOUNDARY_ENERGY_MIN_BALANCED)
13242            } else {
13243                None
13244            };
13245        let electromagnetic_boundary_penalty_contribution_breach_rate =
13246            if kind == AnalysisRunKind::Electromagnetic {
13247                let values = entries
13248                    .iter()
13249                    .filter_map(|run| {
13250                        diagnostic_metric(
13251                            &run.run.diagnostics,
13252                            "FEA_EM_SOURCE_ENERGY",
13253                            "boundary_penalty_conditioning_contribution",
13254                        )
13255                    })
13256                    .collect::<Vec<_>>();
13257                breach_rate_greater_than(&values, EM_BOUNDARY_PENALTY_CONTRIBUTION_MAX_BALANCED)
13258            } else {
13259                None
13260            };
13261        let electromagnetic_source_region_energy_consistency_breach_rate =
13262            if kind == AnalysisRunKind::Electromagnetic {
13263                let values = entries
13264                    .iter()
13265                    .filter_map(|run| {
13266                        diagnostic_metric(
13267                            &run.run.diagnostics,
13268                            "FEA_EM_SOURCE_ENERGY",
13269                            "source_region_energy_consistency_ratio",
13270                        )
13271                    })
13272                    .collect::<Vec<_>>();
13273                breach_rate_less_than(&values, EM_SOURCE_REGION_ENERGY_CONSISTENCY_MIN_BALANCED)
13274            } else {
13275                None
13276            };
13277        let electromagnetic_real_residual_breach_rate = if kind == AnalysisRunKind::Electromagnetic
13278        {
13279            let values = entries
13280                .iter()
13281                .filter_map(|run| {
13282                    diagnostic_metric(&run.run.diagnostics, "FEA_EM_STATIC", "real_residual_norm")
13283                })
13284                .collect::<Vec<_>>();
13285            breach_rate_greater_than(&values, EM_REAL_RESIDUAL_MAX_BALANCED)
13286        } else {
13287            None
13288        };
13289        let electromagnetic_imag_residual_breach_rate = if kind == AnalysisRunKind::Electromagnetic
13290        {
13291            let values = entries
13292                .iter()
13293                .filter_map(|run| {
13294                    diagnostic_metric(&run.run.diagnostics, "FEA_EM_STATIC", "imag_residual_norm")
13295                })
13296                .collect::<Vec<_>>();
13297            breach_rate_greater_than(&values, EM_IMAG_RESIDUAL_MAX_BALANCED)
13298        } else {
13299            None
13300        };
13301        let electromagnetic_sweep_coverage_breach_rate = if kind == AnalysisRunKind::Electromagnetic
13302        {
13303            let values = entries
13304                .iter()
13305                .filter_map(|run| {
13306                    diagnostic_metric(&run.run.diagnostics, "FEA_EM_SWEEP", "sweep_count")
13307                })
13308                .collect::<Vec<_>>();
13309            breach_rate_less_than(&values, EM_SWEEP_COUNT_MIN_BALANCED)
13310        } else {
13311            None
13312        };
13313        let electromagnetic_resonance_sharpness_breach_rate =
13314            if kind == AnalysisRunKind::Electromagnetic {
13315                let values = entries
13316                    .iter()
13317                    .filter_map(|run| {
13318                        diagnostic_metric(
13319                            &run.run.diagnostics,
13320                            "FEA_EM_SWEEP",
13321                            "resonance_quality_factor",
13322                        )
13323                    })
13324                    .collect::<Vec<_>>();
13325                breach_rate_less_than(&values, EM_RESONANCE_Q_MIN_BALANCED)
13326            } else {
13327                None
13328            };
13329
13330        summaries.push(AnalysisTrendKindSummary {
13331            run_kind: kind,
13332            sample_count,
13333            median_solve_ms,
13334            p95_solve_ms,
13335            publishable_rate,
13336            failed_increment_rate,
13337            mean_spike_count,
13338            mean_stall_count,
13339            prep_acceptance_rate,
13340            prep_calibration_fast_rate,
13341            prep_calibration_balanced_rate,
13342            prep_calibration_conservative_rate,
13343            thermo_coupling_enabled_rate,
13344            thermo_transient_warn_rate,
13345            thermo_nonlinear_warn_rate,
13346            thermo_spread_breach_rate,
13347            thermo_heterogeneity_breach_rate,
13348            electro_thermal_coupling_enabled_rate,
13349            electro_transient_warn_rate,
13350            electro_nonlinear_warn_rate,
13351            plastic_nonlinear_warn_rate,
13352            contact_nonlinear_warn_rate,
13353            thermal_stability_warn_rate,
13354            thermal_constitutive_warn_rate,
13355            thermal_spread_breach_rate,
13356            electromagnetic_solve_warn_rate,
13357            electromagnetic_spread_breach_rate,
13358            electromagnetic_heterogeneity_breach_rate,
13359            electromagnetic_coverage_breach_rate,
13360            electromagnetic_contrast_breach_rate,
13361            electromagnetic_conditioning_breach_rate,
13362            electromagnetic_source_realization_breach_rate,
13363            electromagnetic_source_region_coverage_breach_rate,
13364            electromagnetic_source_material_alignment_breach_rate,
13365            electromagnetic_source_overlap_breach_rate,
13366            electromagnetic_source_interference_breach_rate,
13367            electromagnetic_boundary_anchor_breach_rate,
13368            electromagnetic_boundary_localization_breach_rate,
13369            electromagnetic_ground_effectiveness_breach_rate,
13370            electromagnetic_insulation_leakage_breach_rate,
13371            electromagnetic_divergence_breach_rate,
13372            electromagnetic_energy_imbalance_breach_rate,
13373            electromagnetic_boundary_energy_breach_rate,
13374            electromagnetic_boundary_penalty_contribution_breach_rate,
13375            electromagnetic_source_region_energy_consistency_breach_rate,
13376            electromagnetic_real_residual_breach_rate,
13377            electromagnetic_imag_residual_breach_rate,
13378            electromagnetic_sweep_coverage_breach_rate,
13379            electromagnetic_resonance_sharpness_breach_rate,
13380        });
13381    }
13382
13383    Ok(OperationEnvelope::new(
13384        ANALYSIS_TRENDS_OPERATION,
13385        ANALYSIS_TRENDS_OP_VERSION,
13386        &context,
13387        AnalysisTrendsData {
13388            window_size: window,
13389            summaries,
13390        },
13391    ))
13392}
13393
13394fn run_kind(run: &AnalysisRunResult) -> AnalysisRunKind {
13395    if run
13396        .run
13397        .diagnostics
13398        .iter()
13399        .any(|diag| diag.code == "FEA_ACOUSTIC_HARMONIC_RESPONSE")
13400    {
13401        AnalysisRunKind::Acoustic
13402    } else if run.electromagnetic_results.is_some()
13403        || run
13404            .run
13405            .diagnostics
13406            .iter()
13407            .any(|diag| diag.code == "FEA_EM_STATIC")
13408    {
13409        AnalysisRunKind::Electromagnetic
13410    } else if run
13411        .run
13412        .diagnostics
13413        .iter()
13414        .any(|diag| diag.code == "FEA_CHT_COUPLING")
13415    {
13416        AnalysisRunKind::Cht
13417    } else if run
13418        .run
13419        .diagnostics
13420        .iter()
13421        .any(|diag| diag.code == "FEA_FSI_COUPLING")
13422    {
13423        AnalysisRunKind::Fsi
13424    } else if run
13425        .run
13426        .diagnostics
13427        .iter()
13428        .any(|diag| diag.code == "FEA_CFD_FLOW")
13429    {
13430        AnalysisRunKind::Cfd
13431    } else if run.nonlinear_results.is_some() {
13432        AnalysisRunKind::Nonlinear
13433    } else if run.thermal_results.is_some() {
13434        AnalysisRunKind::Thermal
13435    } else if run.transient_results.is_some() {
13436        AnalysisRunKind::Transient
13437    } else if run.modal_results.is_some() {
13438        AnalysisRunKind::Modal
13439    } else {
13440        AnalysisRunKind::LinearStatic
13441    }
13442}
13443
13444fn run_operation_version_for_kind(kind: AnalysisRunKind) -> &'static str {
13445    match kind {
13446        AnalysisRunKind::LinearStatic => ANALYSIS_RUN_OP_VERSION,
13447        AnalysisRunKind::Modal => ANALYSIS_RUN_MODAL_OP_VERSION,
13448        AnalysisRunKind::Acoustic => ANALYSIS_RUN_ACOUSTIC_OP_VERSION,
13449        AnalysisRunKind::Thermal => ANALYSIS_RUN_THERMAL_OP_VERSION,
13450        AnalysisRunKind::Transient => ANALYSIS_RUN_TRANSIENT_OP_VERSION,
13451        AnalysisRunKind::Cfd => ANALYSIS_RUN_CFD_OP_VERSION,
13452        AnalysisRunKind::Cht => ANALYSIS_RUN_CHT_OP_VERSION,
13453        AnalysisRunKind::Fsi => ANALYSIS_RUN_FSI_OP_VERSION,
13454        AnalysisRunKind::Nonlinear => ANALYSIS_RUN_NONLINEAR_OP_VERSION,
13455        AnalysisRunKind::Electromagnetic => ANALYSIS_RUN_ELECTROMAGNETIC_OP_VERSION,
13456    }
13457}
13458
13459fn run_operation_for_kind(kind: AnalysisRunKind) -> &'static str {
13460    match kind {
13461        AnalysisRunKind::LinearStatic => ANALYSIS_RUN_OPERATION,
13462        AnalysisRunKind::Modal => ANALYSIS_RUN_MODAL_OPERATION,
13463        AnalysisRunKind::Acoustic => ANALYSIS_RUN_ACOUSTIC_OPERATION,
13464        AnalysisRunKind::Thermal => ANALYSIS_RUN_THERMAL_OPERATION,
13465        AnalysisRunKind::Transient => ANALYSIS_RUN_TRANSIENT_OPERATION,
13466        AnalysisRunKind::Cfd => ANALYSIS_RUN_CFD_OPERATION,
13467        AnalysisRunKind::Cht => ANALYSIS_RUN_CHT_OPERATION,
13468        AnalysisRunKind::Fsi => ANALYSIS_RUN_FSI_OPERATION,
13469        AnalysisRunKind::Nonlinear => ANALYSIS_RUN_NONLINEAR_OPERATION,
13470        AnalysisRunKind::Electromagnetic => ANALYSIS_RUN_ELECTROMAGNETIC_OPERATION,
13471    }
13472}
13473
13474fn sanitize_study_sweep_id(sweep_id: &str) -> String {
13475    sweep_id
13476        .chars()
13477        .map(|ch| {
13478            if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
13479                ch
13480            } else {
13481                '_'
13482            }
13483        })
13484        .collect()
13485}
13486
13487fn validate_study_issue_codes(spec: &AnalysisStudySpec) -> Vec<String> {
13488    let mut issue_codes = Vec::new();
13489
13490    if spec.study_id.trim().is_empty() {
13491        issue_codes.push("RM.FEA.STUDY.ID_EMPTY".to_string());
13492    }
13493    if spec.create_model_intent.model_id.trim().is_empty() {
13494        issue_codes.push("RM.FEA.STUDY.MODEL_ID_EMPTY".to_string());
13495    }
13496    if spec.geometry.meshes.is_empty() {
13497        issue_codes.push("RM.FEA.STUDY.GEOMETRY_MESHES_EMPTY".to_string());
13498    }
13499    if spec.geometry.units == UnitSystem::Unspecified {
13500        issue_codes.push("RM.FEA.STUDY.GEOMETRY_UNITS_UNSPECIFIED".to_string());
13501    }
13502    if !profile_supports_run_kind(spec.create_model_intent.profile, spec.run_kind) {
13503        issue_codes.push("RM.FEA.STUDY.RUN_KIND_PROFILE_MISMATCH".to_string());
13504    }
13505    if let Some(model) = &spec.model {
13506        if model.geometry_id != spec.geometry.geometry_id
13507            || model.geometry_revision != spec.geometry.revision
13508        {
13509            issue_codes.push("RM.FEA.STUDY.MODEL_GEOMETRY_MISMATCH".to_string());
13510        }
13511        if validate_model_against_geometry(model, spec.geometry.units, &ReferenceFrame::Global)
13512            .is_err()
13513        {
13514            issue_codes.push("RM.FEA.STUDY.MODEL_INVALID".to_string());
13515        }
13516    }
13517    if spec.electromagnetic_run_options.is_some()
13518        && spec.run_kind != AnalysisRunKind::Electromagnetic
13519    {
13520        issue_codes.push("RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH".to_string());
13521    }
13522    if spec.linear_static_run_options.is_some() && spec.run_kind != AnalysisRunKind::LinearStatic
13523        || spec.modal_run_options.is_some() && spec.run_kind != AnalysisRunKind::Modal
13524        || spec.acoustic_run_options.is_some() && spec.run_kind != AnalysisRunKind::Acoustic
13525        || spec.thermal_run_options.is_some() && spec.run_kind != AnalysisRunKind::Thermal
13526        || spec.transient_run_options.is_some() && spec.run_kind != AnalysisRunKind::Transient
13527        || spec.cfd_run_options.is_some() && spec.run_kind != AnalysisRunKind::Cfd
13528        || spec.cht_run_options.is_some() && spec.run_kind != AnalysisRunKind::Cht
13529        || spec.fsi_run_options.is_some() && spec.run_kind != AnalysisRunKind::Fsi
13530        || spec.nonlinear_run_options.is_some() && spec.run_kind != AnalysisRunKind::Nonlinear
13531    {
13532        issue_codes.push("RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH".to_string());
13533    }
13534    if spec.run_kind == AnalysisRunKind::Electromagnetic {
13535        if let Some(options) = spec.electromagnetic_run_options.as_ref() {
13536            if !options.residual_target.is_finite() || options.residual_target <= 0.0 {
13537                issue_codes
13538                    .push("RM.FEA.STUDY.ELECTROMAGNETIC_RESIDUAL_TARGET_INVALID".to_string());
13539            }
13540            if !options.harmonic_tolerance.is_finite() || options.harmonic_tolerance <= 0.0 {
13541                issue_codes
13542                    .push("RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_TOLERANCE_INVALID".to_string());
13543            }
13544            if options.harmonic_max_iterations == 0 {
13545                issue_codes.push(
13546                    "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_MAX_ITERATIONS_INVALID".to_string(),
13547                );
13548            }
13549            if options.sweep_enabled
13550                && !options
13551                    .sweep_frequency_hz
13552                    .iter()
13553                    .all(|frequency_hz| frequency_hz.is_finite() && *frequency_hz > 0.0)
13554            {
13555                issue_codes
13556                    .push("RM.FEA.STUDY.ELECTROMAGNETIC_SWEEP_FREQUENCY_INVALID".to_string());
13557            }
13558        }
13559    }
13560
13561    issue_codes
13562}
13563
13564fn study_issue_message(code: &str) -> &'static str {
13565    match code {
13566        "RM.FEA.STUDY.ID_EMPTY" => "study_id must be non-empty",
13567        "RM.FEA.STUDY.MODEL_ID_EMPTY" => "create_model_intent.model_id must be non-empty",
13568        "RM.FEA.STUDY.GEOMETRY_MESHES_EMPTY" => "geometry must contain at least one mesh",
13569        "RM.FEA.STUDY.GEOMETRY_UNITS_UNSPECIFIED" => {
13570            "geometry.units must be specified (not unspecified)"
13571        }
13572        "RM.FEA.STUDY.RUN_KIND_PROFILE_MISMATCH" => {
13573            "model.profile selects the solver; run kind must match the selected profile when supplied"
13574        }
13575        "RM.FEA.STUDY.MODEL_GEOMETRY_MISMATCH" => {
13576            "resolved model geometry id or revision does not match the study geometry"
13577        }
13578        "RM.FEA.STUDY.MODEL_INVALID" => "resolved model failed FEA validation",
13579        "RM.FEA.STUDY.RUN_OPTIONS_KIND_MISMATCH" => {
13580            "run options are only valid for the solver selected by model.profile"
13581        }
13582        "RM.FEA.STUDY.ELECTROMAGNETIC_RESIDUAL_TARGET_INVALID" => {
13583            "electromagnetic_run_options.residual_target must be finite and positive"
13584        }
13585        "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_TOLERANCE_INVALID" => {
13586            "electromagnetic_run_options.harmonic_tolerance must be finite and positive"
13587        }
13588        "RM.FEA.STUDY.ELECTROMAGNETIC_HARMONIC_MAX_ITERATIONS_INVALID" => {
13589            "electromagnetic_run_options.harmonic_max_iterations must be greater than zero"
13590        }
13591        "RM.FEA.STUDY.ELECTROMAGNETIC_SWEEP_FREQUENCY_INVALID" => {
13592            "electromagnetic_run_options.sweep_frequency_hz must contain finite positive values when sweep_enabled is true"
13593        }
13594        _ => "unrecognized study validation issue",
13595    }
13596}
13597
13598fn profile_supports_run_kind(
13599    profile: AnalysisCreateModelProfile,
13600    run_kind: AnalysisRunKind,
13601) -> bool {
13602    profile.derived_run_kind() == run_kind
13603}
13604
13605fn study_fingerprint(spec: &AnalysisStudySpec) -> String {
13606    let payload = serde_json::to_vec(spec).unwrap_or_else(|_| format!("{spec:?}").into_bytes());
13607    let mut hasher = Sha256::new();
13608    hasher.update(payload);
13609    format!("sha256:{:x}", hasher.finalize())
13610}
13611
13612fn study_operation_sequence(spec: &AnalysisStudySpec, run_op_version: &str) -> Vec<String> {
13613    let mut operation_sequence = Vec::with_capacity(3);
13614    if spec.model.is_none() {
13615        operation_sequence.push(ANALYSIS_CREATE_MODEL_OP_VERSION.to_string());
13616    }
13617    operation_sequence.push(ANALYSIS_VALIDATE_OP_VERSION.to_string());
13618    operation_sequence.push(run_op_version.to_string());
13619    operation_sequence
13620}
13621
13622fn study_run_options_json(spec: &AnalysisStudySpec) -> serde_json::Value {
13623    match spec.run_kind {
13624        AnalysisRunKind::LinearStatic => serde_json::to_value(&spec.linear_static_run_options),
13625        AnalysisRunKind::Modal => serde_json::to_value(&spec.modal_run_options),
13626        AnalysisRunKind::Acoustic => serde_json::to_value(&spec.acoustic_run_options),
13627        AnalysisRunKind::Thermal => serde_json::to_value(&spec.thermal_run_options),
13628        AnalysisRunKind::Transient => serde_json::to_value(&spec.transient_run_options),
13629        AnalysisRunKind::Cfd => serde_json::to_value(&spec.cfd_run_options),
13630        AnalysisRunKind::Cht => serde_json::to_value(&spec.cht_run_options),
13631        AnalysisRunKind::Fsi => serde_json::to_value(&spec.fsi_run_options),
13632        AnalysisRunKind::Nonlinear => serde_json::to_value(&spec.nonlinear_run_options),
13633        AnalysisRunKind::Electromagnetic => serde_json::to_value(&spec.electromagnetic_run_options),
13634    }
13635    .unwrap_or(serde_json::Value::Null)
13636}
13637
13638fn run_options_to_json<T: Serialize>(options: &T) -> serde_json::Value {
13639    serde_json::to_value(options).unwrap_or(serde_json::Value::Null)
13640}
13641
13642fn attach_prep_artifact_to_run_options(options: &mut AnalysisRunOptions, prep_artifact_id: &str) {
13643    if options.prep_artifact_id.is_none() {
13644        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13645    }
13646}
13647
13648fn attach_analysis_mesh_artifact_to_run_options(
13649    options: &mut AnalysisRunOptions,
13650    analysis_mesh_artifact_path: Option<&str>,
13651) {
13652    if options.analysis_mesh_artifact_path.is_none() {
13653        options.analysis_mesh_artifact_path = analysis_mesh_artifact_path.map(str::to_string);
13654    }
13655}
13656
13657fn attach_prep_artifact_to_modal_options(
13658    options: &mut AnalysisModalRunOptions,
13659    prep_artifact_id: &str,
13660) {
13661    if options.prep_artifact_id.is_none() {
13662        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13663    }
13664}
13665
13666fn attach_prep_artifact_to_acoustic_options(
13667    options: &mut AnalysisAcousticRunOptions,
13668    prep_artifact_id: &str,
13669) {
13670    if options.prep_artifact_id.is_none() {
13671        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13672    }
13673}
13674
13675fn attach_prep_artifact_to_thermal_options(
13676    options: &mut AnalysisThermalRunOptions,
13677    prep_artifact_id: &str,
13678) {
13679    if options.prep_artifact_id.is_none() {
13680        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13681    }
13682}
13683
13684fn attach_prep_artifact_to_transient_options(
13685    options: &mut AnalysisTransientRunOptions,
13686    prep_artifact_id: &str,
13687) {
13688    if options.prep_artifact_id.is_none() {
13689        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13690    }
13691}
13692
13693fn attach_prep_artifact_to_cfd_options(
13694    options: &mut AnalysisCfdRunOptions,
13695    prep_artifact_id: &str,
13696) {
13697    if options.prep_artifact_id.is_none() {
13698        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13699    }
13700}
13701
13702fn attach_prep_artifact_to_cht_options(
13703    options: &mut AnalysisChtRunOptions,
13704    prep_artifact_id: &str,
13705) {
13706    if options.prep_artifact_id.is_none() {
13707        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13708    }
13709}
13710
13711fn attach_prep_artifact_to_fsi_options(
13712    options: &mut AnalysisFsiRunOptions,
13713    prep_artifact_id: &str,
13714) {
13715    if options.prep_artifact_id.is_none() {
13716        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13717    }
13718}
13719
13720fn attach_prep_artifact_to_nonlinear_options(
13721    options: &mut AnalysisNonlinearRunOptions,
13722    prep_artifact_id: &str,
13723) {
13724    if options.prep_artifact_id.is_none() {
13725        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13726    }
13727}
13728
13729fn attach_prep_artifact_to_electromagnetic_options(
13730    options: &mut AnalysisElectromagneticRunOptions,
13731    prep_artifact_id: &str,
13732) {
13733    if options.prep_artifact_id.is_none() {
13734        options.prep_artifact_id = Some(prep_artifact_id.to_string());
13735    }
13736}
13737
13738#[derive(Debug, Clone)]
13739struct StudyAnalysisMeshArtifact {
13740    path: String,
13741    evidence_path: String,
13742    allow_refinement: bool,
13743}
13744
13745fn generate_and_persist_study_analysis_mesh(
13746    spec: &AnalysisStudySpec,
13747    study_fingerprint: &str,
13748    context: &OperationContext,
13749) -> Result<Option<StudyAnalysisMeshArtifact>, OperationErrorEnvelope> {
13750    if let Some(path) = spec.analysis_mesh_artifact_path.as_deref() {
13751        resolve_analysis_mesh_artifact(
13752            Some(path),
13753            ANALYSIS_RUN_STUDY_OPERATION,
13754            ANALYSIS_RUN_STUDY_OP_VERSION,
13755            context,
13756        )?;
13757        let evidence_path = match spec.analysis_mesh_evidence_artifact_path.clone() {
13758            Some(path) => path,
13759            None => analysis_mesh_evidence_path_from_artifact(path, context)?,
13760        };
13761        return Ok(Some(StudyAnalysisMeshArtifact {
13762            path: path.to_string(),
13763            evidence_path,
13764            allow_refinement: false,
13765        }));
13766    }
13767
13768    let Some(options) = spec.mesh_options.clone() else {
13769        return Ok(None);
13770    };
13771    let options = mesh_options_in_si_units(options, spec.geometry.units);
13772    let mut mesh =
13773        generate_study_analysis_mesh_with_initial_boundary_focus(spec, &options, context)?;
13774    attach_requested_boundary_regions_to_analysis_mesh(spec, &mut mesh);
13775    attach_single_material_assignment_to_analysis_mesh(spec, &mut mesh);
13776    attach_initial_adaptive_mesh_summary(spec, &options, &mut mesh);
13777    let validation_options =
13778        analysis_mesh_validation_options_for_generated_mesh(spec, &options, &mesh);
13779    runmat_meshing_core::validate_analysis_mesh_with_options(&mesh, validation_options.clone())
13780        .map_err(|err| {
13781            operation_error(
13782                ANALYSIS_RUN_STUDY_OPERATION,
13783                ANALYSIS_RUN_STUDY_OP_VERSION,
13784                context,
13785                OperationErrorSpec {
13786                    error_code: "RM.FEA.RUN_STUDY.MESH_VALIDATION_FAILED",
13787                    error_type: OperationErrorType::Validation,
13788                    retryable: false,
13789                    severity: OperationErrorSeverity::Error,
13790                },
13791                format!("generated analysis mesh failed validation: {err:?}"),
13792                BTreeMap::from([
13793                    ("study_id".to_string(), spec.study_id.clone()),
13794                    ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
13795                    ("mesh_id".to_string(), mesh.mesh_id.clone()),
13796                    (
13797                        "mesh_validation_code".to_string(),
13798                        runmat_meshing_core::analysis_mesh_validation_error_code(&err).to_string(),
13799                    ),
13800                ]),
13801            )
13802        })?;
13803    let mesh_evidence = build_mesh_evidence_artifact(&mesh, &validation_options);
13804    let mesh_authoring_summary = build_mesh_authoring_summary(&mesh_evidence);
13805    let evidence_path = persist_study_evidence(
13806        study_fingerprint,
13807        "mesh_evidence",
13808        serde_json::json!({
13809            "schema_version": "fea_study_mesh_evidence_artifact/v1",
13810            "study_id": spec.study_id.clone(),
13811            "geometry_id": spec.geometry.geometry_id.clone(),
13812            "geometry_revision": spec.geometry.revision,
13813            "analysis_profile": spec.create_model_intent.profile.as_snake_case(),
13814            "run_kind": spec.run_kind.as_snake_case(),
13815            "refinement_context": analysis_refinement_context(spec),
13816            "mesh_options": options,
13817            "mesh_validation_options": validation_options,
13818            "mesh_authoring_summary": mesh_authoring_summary,
13819            "mesh_evidence": mesh_evidence,
13820        }),
13821    )
13822    .map_err(|err| {
13823        operation_error(
13824            ANALYSIS_RUN_STUDY_OPERATION,
13825            ANALYSIS_RUN_STUDY_OP_VERSION,
13826            context,
13827            OperationErrorSpec {
13828                error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
13829                error_type: OperationErrorType::Internal,
13830                retryable: true,
13831                severity: OperationErrorSeverity::Error,
13832            },
13833            format!("failed to persist mesh evidence artifact: {err}"),
13834            BTreeMap::from([
13835                ("study_id".to_string(), spec.study_id.clone()),
13836                ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
13837            ]),
13838        )
13839    })?;
13840
13841    let mesh_path = persist_study_evidence(
13842        study_fingerprint,
13843        "analysis_mesh",
13844        serde_json::json!({
13845            "schema_version": "fea_study_analysis_mesh_artifact/v1",
13846            "study_id": spec.study_id.clone(),
13847            "geometry_id": spec.geometry.geometry_id.clone(),
13848            "geometry_revision": spec.geometry.revision,
13849            "mesh_evidence_artifact_path": evidence_path.clone(),
13850            "analysis_profile": spec.create_model_intent.profile.as_snake_case(),
13851            "run_kind": spec.run_kind.as_snake_case(),
13852            "refinement_context": analysis_refinement_context(spec),
13853            "mesh_options": options,
13854            "mesh_validation_options": validation_options,
13855            "sizing_applications": sizing_application_summary(&mesh),
13856            "sizing_rejections": sizing_rejection_summary(&mesh),
13857            "mesh": mesh,
13858        }),
13859    )
13860    .map_err(|err| {
13861        operation_error(
13862            ANALYSIS_RUN_STUDY_OPERATION,
13863            ANALYSIS_RUN_STUDY_OP_VERSION,
13864            context,
13865            OperationErrorSpec {
13866                error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
13867                error_type: OperationErrorType::Internal,
13868                retryable: true,
13869                severity: OperationErrorSeverity::Error,
13870            },
13871            format!("failed to persist analysis mesh artifact: {err}"),
13872            BTreeMap::from([
13873                ("study_id".to_string(), spec.study_id.clone()),
13874                ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
13875            ]),
13876        )
13877    })?;
13878    Ok(Some(StudyAnalysisMeshArtifact {
13879        path: mesh_path,
13880        evidence_path,
13881        allow_refinement: true,
13882    }))
13883}
13884
13885fn analysis_mesh_evidence_path_from_artifact(
13886    analysis_mesh_artifact_path: &str,
13887    context: &OperationContext,
13888) -> Result<String, OperationErrorEnvelope> {
13889    let bytes = fs_read(analysis_mesh_artifact_path).map_err(|err| {
13890        operation_error(
13891            ANALYSIS_RUN_STUDY_OPERATION,
13892            ANALYSIS_RUN_STUDY_OP_VERSION,
13893            context,
13894            OperationErrorSpec {
13895                error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_READ_FAILED",
13896                error_type: OperationErrorType::Input,
13897                retryable: false,
13898                severity: OperationErrorSeverity::Error,
13899            },
13900            format!("failed to read analysis mesh artifact: {err}"),
13901            BTreeMap::from([(
13902                "analysis_mesh_artifact_path".to_string(),
13903                analysis_mesh_artifact_path.to_string(),
13904            )]),
13905        )
13906    })?;
13907    let payload = serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|err| {
13908        operation_error(
13909            ANALYSIS_RUN_STUDY_OPERATION,
13910            ANALYSIS_RUN_STUDY_OP_VERSION,
13911            context,
13912            OperationErrorSpec {
13913                error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_PARSE_FAILED",
13914                error_type: OperationErrorType::Input,
13915                retryable: false,
13916                severity: OperationErrorSeverity::Error,
13917            },
13918            format!("failed to parse analysis mesh artifact: {err}"),
13919            BTreeMap::from([(
13920                "analysis_mesh_artifact_path".to_string(),
13921                analysis_mesh_artifact_path.to_string(),
13922            )]),
13923        )
13924    })?;
13925    payload
13926        .get("mesh_evidence_artifact_path")
13927        .and_then(serde_json::Value::as_str)
13928        .map(str::to_string)
13929        .ok_or_else(|| {
13930            operation_error(
13931                ANALYSIS_RUN_STUDY_OPERATION,
13932                ANALYSIS_RUN_STUDY_OP_VERSION,
13933                context,
13934                OperationErrorSpec {
13935                    error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_EVIDENCE_MISSING",
13936                    error_type: OperationErrorType::Input,
13937                    retryable: false,
13938                    severity: OperationErrorSeverity::Error,
13939                },
13940                "analysis mesh artifact does not reference a mesh evidence artifact",
13941                BTreeMap::from([(
13942                    "analysis_mesh_artifact_path".to_string(),
13943                    analysis_mesh_artifact_path.to_string(),
13944                )]),
13945            )
13946        })
13947}
13948
13949fn generate_study_analysis_mesh_with_initial_boundary_focus(
13950    spec: &AnalysisStudySpec,
13951    options: &VolumeMeshingOptions,
13952    context: &OperationContext,
13953) -> Result<AnalysisMeshArtifact, OperationErrorEnvelope> {
13954    let mut mesh = generate_analysis_mesh(&spec.geometry, options.clone()).map_err(|err| {
13955        analysis_mesh_generation_error(
13956            spec,
13957            context,
13958            "RM.FEA.RUN_STUDY.MESH_GENERATION_FAILED",
13959            format!("failed to generate analysis mesh: {err}"),
13960        )
13961    })?;
13962    attach_requested_boundary_regions_to_analysis_mesh(spec, &mut mesh);
13963    attach_single_material_assignment_to_analysis_mesh(spec, &mut mesh);
13964    let Some(sizing) = initial_boundary_focus_sizing_field(spec, options, &mesh) else {
13965        return Ok(mesh);
13966    };
13967    let focused_mesh = generate_analysis_mesh_with_sizing(&spec.geometry, options.clone(), &sizing)
13968        .map_err(|err| {
13969            analysis_mesh_generation_error(
13970                spec,
13971                context,
13972                "RM.FEA.RUN_STUDY.MESH_GENERATION_FAILED",
13973                format!("failed to generate analysis mesh with boundary focus sizing: {err}"),
13974            )
13975        })?;
13976    Ok(focused_mesh)
13977}
13978
13979fn analysis_mesh_generation_error(
13980    spec: &AnalysisStudySpec,
13981    context: &OperationContext,
13982    error_code: &'static str,
13983    message: String,
13984) -> OperationErrorEnvelope {
13985    operation_error(
13986        ANALYSIS_RUN_STUDY_OPERATION,
13987        ANALYSIS_RUN_STUDY_OP_VERSION,
13988        context,
13989        OperationErrorSpec {
13990            error_code,
13991            error_type: OperationErrorType::Validation,
13992            retryable: false,
13993            severity: OperationErrorSeverity::Error,
13994        },
13995        message,
13996        BTreeMap::from([
13997            ("study_id".to_string(), spec.study_id.clone()),
13998            ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
13999        ]),
14000    )
14001}
14002
14003fn mesh_options_in_si_units(
14004    mut options: VolumeMeshingOptions,
14005    geometry_units: UnitSystem,
14006) -> VolumeMeshingOptions {
14007    let scale = geometry_unit_scale_to_meters(geometry_units);
14008    if let MeshTargetSize::LengthM(length) = options.target_size {
14009        options.target_size = MeshTargetSize::LengthM(length * scale);
14010    }
14011    if let Some(min_size_m) = options.min_size_m {
14012        options.min_size_m = Some(min_size_m * scale);
14013    }
14014    if let Some(max_size_m) = options.max_size_m {
14015        options.max_size_m = Some(max_size_m * scale);
14016    }
14017    options
14018}
14019
14020fn analysis_mesh_validation_options_for_study(
14021    spec: &AnalysisStudySpec,
14022    options: &VolumeMeshingOptions,
14023) -> AnalysisMeshValidationOptions {
14024    AnalysisMeshValidationOptions {
14025        quality: options.validation.quality,
14026        max_volume_element_count: Some(options.max_elements),
14027        max_volume_component_count: options.validation.max_volume_component_count,
14028        expected_bounds_m: geometry_surface_bounds_m(&spec.geometry),
14029        expected_volume_m3: geometry_enclosed_volume_m3(&spec.geometry),
14030        expected_boundary_area_m2: geometry_surface_area_m2(&spec.geometry),
14031        min_bounds_coverage_ratio: options.validation.min_bounds_coverage_ratio,
14032        min_volume_coverage_ratio: options.validation.min_volume_coverage_ratio,
14033        min_boundary_area_ratio: options.validation.min_boundary_area_ratio,
14034        min_boundary_face_recovery_ratio: options.validation.min_boundary_face_recovery_ratio,
14035        min_boundary_edge_recovery_ratio: options.validation.min_boundary_edge_recovery_ratio,
14036        required_boundary_region_ids: required_boundary_region_ids_for_study(spec),
14037        required_material_region_ids: required_material_region_ids_for_study(spec),
14038        ..AnalysisMeshValidationOptions::default()
14039    }
14040}
14041
14042fn analysis_mesh_validation_options_for_generated_mesh(
14043    spec: &AnalysisStudySpec,
14044    options: &VolumeMeshingOptions,
14045    mesh: &AnalysisMeshArtifact,
14046) -> AnalysisMeshValidationOptions {
14047    let mut validation = analysis_mesh_validation_options_for_study(spec, options);
14048    if !is_solid_mesh_backend(mesh) {
14049        validation.min_boundary_edge_recovery_ratio = 0.0;
14050    } else if validation.max_volume_component_count.is_none()
14051        && mesh.backend.volume_component_count > 0
14052    {
14053        validation.max_volume_component_count = Some(mesh.backend.volume_component_count);
14054    }
14055    if is_solid_mesh_backend(mesh) {
14056        validation.require_no_unrecovered_tetrahedron_components = true;
14057        validation.require_no_unrepaired_exact_quality = true;
14058        validation.require_boundary_source_edge_provenance =
14059            mesh.backend.plc_input_protected_edge_count > 0;
14060        validation.coverage_sample_points_m = solid_body_coverage_sample_points(mesh);
14061        validation.min_coverage_sample_ratio = 1.0;
14062    }
14063    validation
14064}
14065
14066fn analysis_mesh_validation_options_for_loaded_artifact(
14067    payload: &serde_json::Value,
14068    mesh: &AnalysisMeshArtifact,
14069) -> Result<AnalysisMeshValidationOptions, serde_json::Error> {
14070    if let Some(validation_value) = payload.get("mesh_validation_options") {
14071        return serde_json::from_value::<AnalysisMeshValidationOptions>(validation_value.clone());
14072    }
14073    let Some(options_value) = payload.get("mesh_options") else {
14074        return Ok(AnalysisMeshValidationOptions::default());
14075    };
14076    let options = serde_json::from_value::<VolumeMeshingOptions>(options_value.clone())?;
14077    let mut validation = AnalysisMeshValidationOptions {
14078        quality: options.validation.quality,
14079        max_volume_element_count: Some(options.max_elements),
14080        max_volume_component_count: options.validation.max_volume_component_count,
14081        min_bounds_coverage_ratio: options.validation.min_bounds_coverage_ratio,
14082        min_volume_coverage_ratio: options.validation.min_volume_coverage_ratio,
14083        min_boundary_area_ratio: options.validation.min_boundary_area_ratio,
14084        min_boundary_face_recovery_ratio: options.validation.min_boundary_face_recovery_ratio,
14085        min_boundary_edge_recovery_ratio: options.validation.min_boundary_edge_recovery_ratio,
14086        ..AnalysisMeshValidationOptions::default()
14087    };
14088    if !is_solid_mesh_backend(mesh) {
14089        validation.min_boundary_edge_recovery_ratio = 0.0;
14090    } else if validation.max_volume_component_count.is_none()
14091        && mesh.backend.volume_component_count > 0
14092    {
14093        validation.max_volume_component_count = Some(mesh.backend.volume_component_count);
14094    }
14095    if is_solid_mesh_backend(mesh) {
14096        validation.require_no_unrecovered_tetrahedron_components = true;
14097        validation.require_no_unrepaired_exact_quality = true;
14098        validation.require_boundary_source_edge_provenance =
14099            mesh.backend.plc_input_protected_edge_count > 0;
14100        validation.coverage_sample_points_m = solid_body_coverage_sample_points(mesh);
14101        validation.min_coverage_sample_ratio = 1.0;
14102    }
14103    Ok(validation)
14104}
14105
14106fn is_solid_mesh_backend(mesh: &AnalysisMeshArtifact) -> bool {
14107    mesh.backend.backend == "solid"
14108}
14109
14110fn solid_body_coverage_sample_points(mesh: &AnalysisMeshArtifact) -> Vec<[f64; 3]> {
14111    mesh.nodes
14112        .iter()
14113        .filter(|node| {
14114            node.coordinates_m.iter().all(|value| value.is_finite())
14115                && node
14116                    .provenance
14117                    .iter()
14118                    .any(|provenance| provenance.source_entity_kind == SourceEntityKind::Body)
14119        })
14120        .map(|node| node.coordinates_m)
14121        .take(64)
14122        .collect()
14123}
14124
14125fn geometry_surface_bounds_m(geometry: &GeometryAsset) -> Option<[[f64; 3]; 2]> {
14126    let scale = geometry_unit_scale_to_meters(geometry.units);
14127    let mut bounds = None::<[[f64; 3]; 2]>;
14128    for vertex in geometry
14129        .surface_meshes
14130        .iter()
14131        .flat_map(|surface| surface.vertices.iter())
14132    {
14133        let point = [vertex[0] * scale, vertex[1] * scale, vertex[2] * scale];
14134        if point.iter().any(|coordinate| !coordinate.is_finite()) {
14135            continue;
14136        }
14137        match bounds.as_mut() {
14138            Some(bounds) => {
14139                for axis in 0..3 {
14140                    bounds[0][axis] = bounds[0][axis].min(point[axis]);
14141                    bounds[1][axis] = bounds[1][axis].max(point[axis]);
14142                }
14143            }
14144            None => bounds = Some([point, point]),
14145        }
14146    }
14147    bounds
14148}
14149
14150fn geometry_surface_area_m2(geometry: &GeometryAsset) -> Option<f64> {
14151    let scale = geometry_unit_scale_to_meters(geometry.units);
14152    let mut total_area = 0.0_f64;
14153    for surface in &geometry.surface_meshes {
14154        for triangle in &surface.triangles {
14155            let Some(vertices) = surface_triangle_vertices(surface, *triangle) else {
14156                continue;
14157            };
14158            let area = triangle_area(scale_triangle_vertices(vertices, scale));
14159            if area.is_finite() && area > 0.0 {
14160                total_area += area;
14161            }
14162        }
14163    }
14164    (total_area.is_finite() && total_area > 0.0).then_some(total_area)
14165}
14166
14167fn geometry_enclosed_volume_m3(geometry: &GeometryAsset) -> Option<f64> {
14168    let scale = geometry_unit_scale_to_meters(geometry.units);
14169    let mut signed_volume = 0.0_f64;
14170    for surface in &geometry.surface_meshes {
14171        for triangle in &surface.triangles {
14172            let Some(vertices) = surface_triangle_vertices(surface, *triangle) else {
14173                continue;
14174            };
14175            let contribution =
14176                signed_triangle_volume_from_origin(scale_triangle_vertices(vertices, scale));
14177            if contribution.is_finite() {
14178                signed_volume += contribution;
14179            }
14180        }
14181    }
14182    let volume = signed_volume.abs();
14183    (volume.is_finite() && volume > f64::EPSILON).then_some(volume)
14184}
14185
14186fn required_boundary_region_ids_for_study(spec: &AnalysisStudySpec) -> Vec<String> {
14187    let Some(model) = spec.model.as_ref() else {
14188        return Vec::new();
14189    };
14190    let mut region_ids = model
14191        .loads
14192        .iter()
14193        .filter(|load| load_requires_boundary_region(&load.kind))
14194        .map(|load| load.region_id.clone())
14195        .chain(
14196            model
14197                .boundary_conditions
14198                .iter()
14199                .map(|condition| condition.region_id.clone()),
14200        )
14201        .collect::<Vec<_>>();
14202    region_ids.sort();
14203    region_ids.dedup();
14204    region_ids
14205}
14206
14207fn required_material_region_ids_for_study(spec: &AnalysisStudySpec) -> Vec<String> {
14208    let Some(model) = spec.model.as_ref() else {
14209        return Vec::new();
14210    };
14211    let mut region_ids = model
14212        .material_assignments
14213        .iter()
14214        .map(|assignment| assignment.region_id.clone())
14215        .collect::<Vec<_>>();
14216    region_ids.sort();
14217    region_ids.dedup();
14218    region_ids
14219}
14220
14221fn geometry_unit_scale_to_meters(units: UnitSystem) -> f64 {
14222    match units {
14223        UnitSystem::Meter | UnitSystem::Unspecified => 1.0,
14224        UnitSystem::Millimeter => 0.001,
14225        UnitSystem::Inch => 0.0254,
14226    }
14227}
14228
14229fn attach_requested_boundary_regions_to_analysis_mesh(
14230    spec: &AnalysisStudySpec,
14231    mesh: &mut AnalysisMeshArtifact,
14232) {
14233    let Some(model) = spec.model.as_ref() else {
14234        return;
14235    };
14236    let mut requested_region_ids = model
14237        .loads
14238        .iter()
14239        .filter(|load| load_requires_boundary_region(&load.kind))
14240        .map(|load| load.region_id.clone())
14241        .chain(
14242            model
14243                .boundary_conditions
14244                .iter()
14245                .map(|condition| condition.region_id.clone()),
14246        )
14247        .collect::<Vec<_>>();
14248    requested_region_ids.sort();
14249    requested_region_ids.dedup();
14250
14251    for region_id in requested_region_ids {
14252        if analysis_mesh_has_boundary_region(mesh, &region_id) {
14253            continue;
14254        }
14255        let Some(source_centroid) = source_region_surface_centroid(&spec.geometry, &region_id)
14256        else {
14257            continue;
14258        };
14259        let Some(face_index) = nearest_analysis_boundary_face_index(mesh, source_centroid) else {
14260            continue;
14261        };
14262        let face = &mut mesh.boundary_faces[face_index];
14263        if !face
14264            .region_ids
14265            .iter()
14266            .any(|existing| existing == &region_id)
14267        {
14268            face.region_ids.push(region_id);
14269            face.region_ids.sort();
14270            face.region_ids.dedup();
14271        }
14272    }
14273}
14274
14275fn analysis_mesh_has_boundary_region(mesh: &AnalysisMeshArtifact, region_id: &str) -> bool {
14276    mesh.boundary_faces
14277        .iter()
14278        .any(|face| face.region_ids.iter().any(|existing| existing == region_id))
14279}
14280
14281fn attach_single_material_assignment_to_analysis_mesh(
14282    spec: &AnalysisStudySpec,
14283    mesh: &mut AnalysisMeshArtifact,
14284) {
14285    let Some(model) = spec.model.as_ref() else {
14286        return;
14287    };
14288    let mut assigned_region_ids = model
14289        .material_assignments
14290        .iter()
14291        .map(|assignment| assignment.region_id.clone())
14292        .collect::<Vec<_>>();
14293    assigned_region_ids.sort();
14294    assigned_region_ids.dedup();
14295    let [region_id] = assigned_region_ids.as_slice() else {
14296        return;
14297    };
14298    if mesh
14299        .volume_elements
14300        .iter()
14301        .any(|element| element.material_region_id == *region_id)
14302    {
14303        return;
14304    }
14305    for element in &mut mesh.volume_elements {
14306        element.material_region_id = region_id.clone();
14307    }
14308}
14309
14310fn source_region_surface_centroid(geometry: &GeometryAsset, region_id: &str) -> Option<[f64; 3]> {
14311    let mut weighted = [0.0_f64; 3];
14312    let mut total_area = 0.0_f64;
14313    for mapping in geometry
14314        .region_entity_mappings
14315        .iter()
14316        .filter(|mapping| mapping.region_id == region_id)
14317        .filter(|mapping| matches!(mapping.entity_kind, EntityKind::Face | EntityKind::Element))
14318    {
14319        let Some(surface) = geometry
14320            .surface_meshes
14321            .iter()
14322            .find(|surface| surface.mesh_id == mapping.mesh_id)
14323        else {
14324            continue;
14325        };
14326        for (triangle_index, triangle) in surface.triangles.iter().enumerate() {
14327            if !mapping.contains_entity(triangle_index as u64) {
14328                continue;
14329            }
14330            let Some(vertices) = surface_triangle_vertices(surface, *triangle) else {
14331                continue;
14332            };
14333            let area = triangle_area(vertices);
14334            if !area.is_finite() || area <= 0.0 {
14335                continue;
14336            }
14337            let centroid = triangle_centroid(vertices);
14338            for axis in 0..3 {
14339                weighted[axis] += centroid[axis] * area;
14340            }
14341            total_area += area;
14342        }
14343    }
14344    if total_area <= 0.0 || !total_area.is_finite() {
14345        return None;
14346    }
14347    Some([
14348        weighted[0] / total_area,
14349        weighted[1] / total_area,
14350        weighted[2] / total_area,
14351    ])
14352}
14353
14354fn surface_triangle_vertices(
14355    surface: &runmat_geometry_core::SurfaceMesh,
14356    triangle: [u32; 3],
14357) -> Option<[[f64; 3]; 3]> {
14358    Some([
14359        *surface.vertices.get(triangle[0] as usize)?,
14360        *surface.vertices.get(triangle[1] as usize)?,
14361        *surface.vertices.get(triangle[2] as usize)?,
14362    ])
14363}
14364
14365fn scale_triangle_vertices(vertices: [[f64; 3]; 3], scale: f64) -> [[f64; 3]; 3] {
14366    [
14367        [
14368            vertices[0][0] * scale,
14369            vertices[0][1] * scale,
14370            vertices[0][2] * scale,
14371        ],
14372        [
14373            vertices[1][0] * scale,
14374            vertices[1][1] * scale,
14375            vertices[1][2] * scale,
14376        ],
14377        [
14378            vertices[2][0] * scale,
14379            vertices[2][1] * scale,
14380            vertices[2][2] * scale,
14381        ],
14382    ]
14383}
14384
14385fn triangle_centroid(vertices: [[f64; 3]; 3]) -> [f64; 3] {
14386    [
14387        (vertices[0][0] + vertices[1][0] + vertices[2][0]) / 3.0,
14388        (vertices[0][1] + vertices[1][1] + vertices[2][1]) / 3.0,
14389        (vertices[0][2] + vertices[1][2] + vertices[2][2]) / 3.0,
14390    ]
14391}
14392
14393fn triangle_area(vertices: [[f64; 3]; 3]) -> f64 {
14394    let ab = [
14395        vertices[1][0] - vertices[0][0],
14396        vertices[1][1] - vertices[0][1],
14397        vertices[1][2] - vertices[0][2],
14398    ];
14399    let ac = [
14400        vertices[2][0] - vertices[0][0],
14401        vertices[2][1] - vertices[0][1],
14402        vertices[2][2] - vertices[0][2],
14403    ];
14404    let cross = [
14405        ab[1] * ac[2] - ab[2] * ac[1],
14406        ab[2] * ac[0] - ab[0] * ac[2],
14407        ab[0] * ac[1] - ab[1] * ac[0],
14408    ];
14409    0.5 * (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]).sqrt()
14410}
14411
14412fn signed_triangle_volume_from_origin(vertices: [[f64; 3]; 3]) -> f64 {
14413    let cross = [
14414        vertices[1][1] * vertices[2][2] - vertices[1][2] * vertices[2][1],
14415        vertices[1][2] * vertices[2][0] - vertices[1][0] * vertices[2][2],
14416        vertices[1][0] * vertices[2][1] - vertices[1][1] * vertices[2][0],
14417    ];
14418    (vertices[0][0] * cross[0] + vertices[0][1] * cross[1] + vertices[0][2] * cross[2]) / 6.0
14419}
14420
14421fn nearest_analysis_boundary_face_index(
14422    mesh: &AnalysisMeshArtifact,
14423    point_m: [f64; 3],
14424) -> Option<usize> {
14425    mesh.boundary_faces
14426        .iter()
14427        .enumerate()
14428        .filter_map(|(index, face)| {
14429            let centroid = analysis_boundary_face_centroid(mesh, &face.node_ids)?;
14430            Some((index, vector_distance_m(centroid, point_m)))
14431        })
14432        .filter(|(_, distance)| distance.is_finite())
14433        .min_by(|left, right| left.1.total_cmp(&right.1))
14434        .map(|(index, _)| index)
14435}
14436
14437fn analysis_boundary_face_centroid(
14438    mesh: &AnalysisMeshArtifact,
14439    node_ids: &[u32],
14440) -> Option<[f64; 3]> {
14441    if node_ids.is_empty() {
14442        return None;
14443    }
14444    let mut centroid = [0.0_f64; 3];
14445    for node_id in node_ids {
14446        let node = mesh.nodes.iter().find(|node| node.node_id == *node_id)?;
14447        for (axis, value) in node.coordinates_m.iter().enumerate() {
14448            centroid[axis] += *value;
14449        }
14450    }
14451    for value in &mut centroid {
14452        *value /= node_ids.len() as f64;
14453    }
14454    Some(centroid)
14455}
14456
14457#[derive(Debug, Clone)]
14458struct RefinedAnalysisMeshArtifact {
14459    path: String,
14460    evidence_path: String,
14461    refinement_effect: serde_json::Value,
14462}
14463
14464fn generate_and_persist_refined_study_analysis_mesh(
14465    spec: &AnalysisStudySpec,
14466    study_fingerprint: &str,
14467    analysis_mesh_artifact_path: Option<&str>,
14468    context: &OperationContext,
14469) -> Result<Option<RefinedAnalysisMeshArtifact>, OperationErrorEnvelope> {
14470    let Some(path) = analysis_mesh_artifact_path else {
14471        return Ok(None);
14472    };
14473    let path_buf = PathBuf::from(path);
14474    let bytes = fs_read(path).map_err(|err| {
14475        operation_error(
14476            ANALYSIS_RUN_STUDY_OPERATION,
14477            ANALYSIS_RUN_STUDY_OP_VERSION,
14478            context,
14479            OperationErrorSpec {
14480                error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_READ_FAILED",
14481                error_type: OperationErrorType::Input,
14482                retryable: false,
14483                severity: OperationErrorSeverity::Error,
14484            },
14485            format!("failed to read analysis mesh artifact for refinement: {err}"),
14486            BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
14487        )
14488    })?;
14489    let payload = serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|err| {
14490        operation_error(
14491            ANALYSIS_RUN_STUDY_OPERATION,
14492            ANALYSIS_RUN_STUDY_OP_VERSION,
14493            context,
14494            OperationErrorSpec {
14495                error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_PARSE_FAILED",
14496                error_type: OperationErrorType::Input,
14497                retryable: false,
14498                severity: OperationErrorSeverity::Error,
14499            },
14500            format!("failed to parse analysis mesh artifact for refinement: {err}"),
14501            BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
14502        )
14503    })?;
14504    let options = payload
14505        .get("mesh_options")
14506        .cloned()
14507        .map(serde_json::from_value::<runmat_meshing_core::VolumeMeshingOptions>)
14508        .transpose()
14509        .map_err(|err| {
14510            operation_error(
14511                ANALYSIS_RUN_STUDY_OPERATION,
14512                ANALYSIS_RUN_STUDY_OP_VERSION,
14513                context,
14514                OperationErrorSpec {
14515                    error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_PARSE_FAILED",
14516                    error_type: OperationErrorType::Input,
14517                    retryable: false,
14518                    severity: OperationErrorSeverity::Error,
14519                },
14520                format!("failed to decode analysis mesh options for refinement: {err}"),
14521                BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
14522            )
14523        })?;
14524    let Some(options) = options else {
14525        return Ok(None);
14526    };
14527    let mesh: AnalysisMeshArtifact =
14528        serde_json::from_value(payload["mesh"].clone()).map_err(|err| {
14529            operation_error(
14530                ANALYSIS_RUN_STUDY_OPERATION,
14531                ANALYSIS_RUN_STUDY_OP_VERSION,
14532                context,
14533                OperationErrorSpec {
14534                    error_code: "RM.FEA.RUN_STUDY.ANALYSIS_MESH_PARSE_FAILED",
14535                    error_type: OperationErrorType::Input,
14536                    retryable: false,
14537                    severity: OperationErrorSeverity::Error,
14538                },
14539                format!("failed to decode analysis mesh payload for refinement: {err}"),
14540                BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
14541            )
14542        })?;
14543    let Some(latest_iteration) = mesh.adaptive_iterations.last() else {
14544        return Ok(None);
14545    };
14546    if latest_iteration.convergence_status != AdaptiveConvergenceStatus::Pending
14547        || mesh.sizing.samples.is_empty()
14548        || mesh.adaptive_iterations.len() >= options.refinement.max_iterations
14549    {
14550        return Ok(None);
14551    }
14552
14553    let mut refined_mesh =
14554        generate_analysis_mesh_with_sizing(&spec.geometry, options.clone(), &mesh.sizing).map_err(
14555            |err| {
14556                operation_error(
14557                    ANALYSIS_RUN_STUDY_OPERATION,
14558                    ANALYSIS_RUN_STUDY_OP_VERSION,
14559                    context,
14560                    OperationErrorSpec {
14561                        error_code: "RM.FEA.RUN_STUDY.REFINED_MESH_GENERATION_FAILED",
14562                        error_type: OperationErrorType::Validation,
14563                        retryable: false,
14564                        severity: OperationErrorSeverity::Error,
14565                    },
14566                    format!("failed to generate refined analysis mesh: {err}"),
14567                    BTreeMap::from([
14568                        ("study_id".to_string(), spec.study_id.clone()),
14569                        ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14570                        ("analysis_mesh_artifact_path".to_string(), path.to_string()),
14571                    ]),
14572                )
14573            },
14574        )?;
14575    refined_mesh.mesh_id = format!(
14576        "{}_refined_{}",
14577        refined_mesh.mesh_id,
14578        mesh.adaptive_iterations.len()
14579    );
14580    attach_requested_boundary_regions_to_analysis_mesh(spec, &mut refined_mesh);
14581    attach_single_material_assignment_to_analysis_mesh(spec, &mut refined_mesh);
14582    refined_mesh.adaptive_iterations = mesh.adaptive_iterations.clone();
14583    let refinement_effect = refinement_effect_summary(&mesh, &refined_mesh);
14584    let refinement_convergence = runmat_meshing_core::evaluate_adaptive_convergence(
14585        &options.refinement,
14586        runmat_meshing_core::AdaptiveConvergenceMetrics {
14587            completed_iterations: mesh.adaptive_iterations.len(),
14588            previous_node_count: Some(mesh.nodes.len()),
14589            current_node_count: Some(refined_mesh.nodes.len()),
14590            previous_element_count: Some(mesh.volume_elements.len()),
14591            current_element_count: Some(refined_mesh.volume_elements.len()),
14592            ..runmat_meshing_core::AdaptiveConvergenceMetrics::default()
14593        },
14594    );
14595    if refinement_convergence == AdaptiveConvergenceStatus::Converged
14596        && !refinement_effect_topology_changed(&refinement_effect)
14597    {
14598        mark_latest_adaptive_iteration_converged(
14599            &path_buf,
14600            payload,
14601            mesh,
14602            "adaptive refinement produced no topology growth",
14603        )
14604        .map_err(|err| {
14605            operation_error(
14606                ANALYSIS_RUN_STUDY_OPERATION,
14607                ANALYSIS_RUN_STUDY_OP_VERSION,
14608                context,
14609                OperationErrorSpec {
14610                    error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
14611                    error_type: OperationErrorType::Internal,
14612                    retryable: true,
14613                    severity: OperationErrorSeverity::Error,
14614                },
14615                format!("failed to persist adaptive convergence update: {err}"),
14616                BTreeMap::from([
14617                    ("study_id".to_string(), spec.study_id.clone()),
14618                    ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14619                    ("analysis_mesh_artifact_path".to_string(), path.to_string()),
14620                ]),
14621            )
14622        })?;
14623        return Ok(None);
14624    }
14625    let validation_options =
14626        analysis_mesh_validation_options_for_generated_mesh(spec, &options, &refined_mesh);
14627    runmat_meshing_core::validate_analysis_mesh_with_options(
14628        &refined_mesh,
14629        validation_options.clone(),
14630    )
14631    .map_err(|err| {
14632        operation_error(
14633            ANALYSIS_RUN_STUDY_OPERATION,
14634            ANALYSIS_RUN_STUDY_OP_VERSION,
14635            context,
14636            OperationErrorSpec {
14637                error_code: "RM.FEA.RUN_STUDY.REFINED_MESH_VALIDATION_FAILED",
14638                error_type: OperationErrorType::Validation,
14639                retryable: false,
14640                severity: OperationErrorSeverity::Error,
14641            },
14642            format!("refined analysis mesh failed validation: {err:?}"),
14643            BTreeMap::from([
14644                ("study_id".to_string(), spec.study_id.clone()),
14645                ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14646                ("mesh_id".to_string(), refined_mesh.mesh_id.clone()),
14647                (
14648                    "mesh_validation_code".to_string(),
14649                    runmat_meshing_core::analysis_mesh_validation_error_code(&err).to_string(),
14650                ),
14651            ]),
14652        )
14653    })?;
14654
14655    let refined_mesh_evidence = build_mesh_evidence_artifact(&refined_mesh, &validation_options);
14656    let refined_evidence_path = persist_study_evidence(
14657        study_fingerprint,
14658        "mesh_evidence_refined",
14659        serde_json::json!({
14660            "schema_version": "fea_study_mesh_evidence_artifact/v1",
14661            "study_id": spec.study_id.clone(),
14662            "geometry_id": spec.geometry.geometry_id.clone(),
14663            "geometry_revision": spec.geometry.revision,
14664            "source_analysis_mesh_artifact_path": path,
14665            "analysis_profile": spec.create_model_intent.profile.as_snake_case(),
14666            "run_kind": spec.run_kind.as_snake_case(),
14667            "refinement_context": analysis_refinement_context(spec),
14668            "mesh_options": options,
14669            "mesh_validation_options": validation_options,
14670            "refinement_effect": refinement_effect.clone(),
14671            "mesh_evidence": refined_mesh_evidence,
14672        }),
14673    )
14674    .map_err(|err| {
14675        operation_error(
14676            ANALYSIS_RUN_STUDY_OPERATION,
14677            ANALYSIS_RUN_STUDY_OP_VERSION,
14678            context,
14679            OperationErrorSpec {
14680                error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
14681                error_type: OperationErrorType::Internal,
14682                retryable: true,
14683                severity: OperationErrorSeverity::Error,
14684            },
14685            format!("failed to persist refined mesh evidence artifact: {err}"),
14686            BTreeMap::from([
14687                ("study_id".to_string(), spec.study_id.clone()),
14688                ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14689            ]),
14690        )
14691    })?;
14692
14693    persist_study_evidence(
14694        study_fingerprint,
14695        "analysis_mesh_refined",
14696        serde_json::json!({
14697            "schema_version": "fea_study_analysis_mesh_artifact/v1",
14698            "study_id": spec.study_id.clone(),
14699            "geometry_id": spec.geometry.geometry_id.clone(),
14700            "geometry_revision": spec.geometry.revision,
14701            "source_analysis_mesh_artifact_path": path,
14702            "mesh_evidence_artifact_path": refined_evidence_path.clone(),
14703            "analysis_profile": spec.create_model_intent.profile.as_snake_case(),
14704            "run_kind": spec.run_kind.as_snake_case(),
14705            "refinement_context": analysis_refinement_context(spec),
14706            "mesh_options": options,
14707            "mesh_validation_options": validation_options,
14708            "sizing_applications": sizing_application_summary(&refined_mesh),
14709            "sizing_rejections": sizing_rejection_summary(&refined_mesh),
14710            "refinement_effect": refinement_effect.clone(),
14711            "mesh": refined_mesh,
14712        }),
14713    )
14714    .map(|path| {
14715        Some(RefinedAnalysisMeshArtifact {
14716            path,
14717            evidence_path: refined_evidence_path,
14718            refinement_effect,
14719        })
14720    })
14721    .map_err(|err| {
14722        operation_error(
14723            ANALYSIS_RUN_STUDY_OPERATION,
14724            ANALYSIS_RUN_STUDY_OP_VERSION,
14725            context,
14726            OperationErrorSpec {
14727                error_code: "RM.FEA.RUN_STUDY.ARTIFACT_STORE_FAILED",
14728                error_type: OperationErrorType::Internal,
14729                retryable: true,
14730                severity: OperationErrorSeverity::Error,
14731            },
14732            format!("failed to persist refined analysis mesh artifact: {err}"),
14733            BTreeMap::from([
14734                ("study_id".to_string(), spec.study_id.clone()),
14735                ("geometry_id".to_string(), spec.geometry.geometry_id.clone()),
14736            ]),
14737        )
14738    })
14739}
14740
14741fn refinement_effect_topology_changed(refinement_effect: &serde_json::Value) -> bool {
14742    refinement_effect
14743        .get("topology_changed")
14744        .and_then(serde_json::Value::as_bool)
14745        .unwrap_or(true)
14746}
14747
14748fn mark_latest_adaptive_iteration_converged(
14749    analysis_mesh_artifact_path: &PathBuf,
14750    mut payload: serde_json::Value,
14751    mut mesh: AnalysisMeshArtifact,
14752    detail: &str,
14753) -> Result<(), String> {
14754    let Some(latest_iteration) = mesh.adaptive_iterations.last_mut() else {
14755        return Ok(());
14756    };
14757    latest_iteration.convergence_status = AdaptiveConvergenceStatus::Converged;
14758    for indicator in &mut latest_iteration.indicators {
14759        if indicator.status == runmat_meshing_core::RefinementIndicatorStatus::Used {
14760            indicator.detail = Some(detail.to_string());
14761        }
14762    }
14763    payload["mesh"] = serde_json::to_value(&mesh)
14764        .map_err(|err| format!("failed to encode mesh payload: {err}"))?;
14765    let bytes = serde_json::to_vec_pretty(&payload)
14766        .map_err(|err| format!("failed to encode analysis mesh artifact: {err}"))?;
14767    atomic_write_bytes(analysis_mesh_artifact_path, &bytes)?;
14768    update_mesh_evidence_from_analysis_mesh_payload(&payload, &mesh)
14769}
14770
14771fn attach_initial_adaptive_mesh_summary(
14772    spec: &AnalysisStudySpec,
14773    options: &runmat_meshing_core::VolumeMeshingOptions,
14774    mesh: &mut AnalysisMeshArtifact,
14775) {
14776    let defaults = if matches!(options.refinement.strategy, RefinementStrategy::Uniform) {
14777        Vec::new()
14778    } else {
14779        default_refinement_indicators_for_context(
14780            spec.create_model_intent.profile.as_snake_case(),
14781            spec.run_kind.as_snake_case(),
14782        )
14783    };
14784    if defaults.is_empty()
14785        && options.refinement.indicators.namespaces.is_empty()
14786        && !matches!(options.refinement.strategy, RefinementStrategy::Uniform)
14787    {
14788        return;
14789    }
14790    let availability = defaults
14791        .iter()
14792        .cloned()
14793        .map(|key| RefinementIndicatorAvailability {
14794            key,
14795            applicable: true,
14796            field_available: false,
14797        })
14798        .collect::<Vec<_>>();
14799    let indicators =
14800        plan_refinement_indicators(&options.refinement, &defaults, &availability, false, false);
14801    let convergence_status = if matches!(options.refinement.strategy, RefinementStrategy::None) {
14802        AdaptiveConvergenceStatus::Disabled
14803    } else {
14804        AdaptiveConvergenceStatus::Pending
14805    };
14806    mesh.adaptive_iterations.push(AdaptiveIterationSummary {
14807        iteration_index: 0,
14808        node_count: mesh.nodes.len(),
14809        element_count: mesh.volume_elements.len(),
14810        convergence_status,
14811        indicators,
14812        markers: Vec::new(),
14813        sizing_update: SizingFieldUpdate::default(),
14814    });
14815}
14816
14817fn initial_boundary_focus_sizing_field(
14818    spec: &AnalysisStudySpec,
14819    options: &runmat_meshing_core::VolumeMeshingOptions,
14820    mesh: &AnalysisMeshArtifact,
14821) -> Option<MeshSizingField> {
14822    if matches!(options.refinement.strategy, RefinementStrategy::None)
14823        || options.refinement.max_iterations == 0
14824    {
14825        return None;
14826    }
14827    if runmat_meshing_core::select_volume_backend(options).selected
14828        != runmat_meshing_core::MeshBackendKind::Solid
14829    {
14830        return None;
14831    }
14832    let defaults = if matches!(options.refinement.strategy, RefinementStrategy::Uniform) {
14833        Vec::new()
14834    } else {
14835        default_refinement_indicators_for_context(
14836            spec.create_model_intent.profile.as_snake_case(),
14837            spec.run_kind.as_snake_case(),
14838        )
14839    };
14840    if defaults.is_empty() && options.refinement.indicators.namespaces.is_empty() {
14841        return None;
14842    }
14843    let context = serde_json::json!({
14844        "refinement_context": analysis_refinement_context(spec),
14845    });
14846    let boundary_load_region_ids =
14847        refinement_context_region_ids(&context, "boundary_load_region_ids");
14848    let boundary_constraint_region_ids =
14849        refinement_context_region_ids(&context, "boundary_constraint_region_ids");
14850    let load_focus_options = refinement_marker_options_for_focus(options.refinement.focus.loads);
14851    let constraint_focus_options =
14852        refinement_marker_options_for_focus(options.refinement.focus.constraints);
14853    let has_boundary_load_regions = load_focus_options.is_some()
14854        && has_boundary_faces_for_regions(mesh, boundary_load_region_ids.as_slice());
14855    let has_boundary_constraint_regions = constraint_focus_options.is_some()
14856        && has_boundary_faces_for_regions(mesh, boundary_constraint_region_ids.as_slice());
14857    if !has_boundary_load_regions && !has_boundary_constraint_regions {
14858        return None;
14859    }
14860    let availability = defaults
14861        .iter()
14862        .cloned()
14863        .map(|key| {
14864            let applicable = key.namespace != "structural"
14865                || (key.name != "load_regions" || load_focus_options.is_some())
14866                    && (key.name != "constraint_regions" || constraint_focus_options.is_some());
14867            let field_available = key.namespace == "structural"
14868                && ((key.name == "load_regions" && has_boundary_load_regions)
14869                    || (key.name == "constraint_regions" && has_boundary_constraint_regions));
14870            RefinementIndicatorAvailability {
14871                key,
14872                applicable,
14873                field_available,
14874            }
14875        })
14876        .collect::<Vec<_>>();
14877    let indicators =
14878        plan_refinement_indicators(&options.refinement, &defaults, &availability, false, false);
14879    let mut sizing_update = SizingFieldUpdate::default();
14880    if indicator_was_used(&indicators, "structural", "load_regions") {
14881        if let Some(marker_options) = load_focus_options {
14882            let samples =
14883                structural_boundary_region_samples(mesh, boundary_load_region_ids.as_slice());
14884            if let Ok((_, update)) = build_refinement_markers_from_samples(
14885                &samples,
14886                "structural.load_regions",
14887                marker_options,
14888            ) {
14889                merge_sizing_update(&mut sizing_update, update);
14890            }
14891        }
14892    }
14893    if indicator_was_used(&indicators, "structural", "constraint_regions") {
14894        if let Some(marker_options) = constraint_focus_options {
14895            let samples =
14896                structural_boundary_region_samples(mesh, boundary_constraint_region_ids.as_slice());
14897            if let Ok((_, update)) = build_refinement_markers_from_samples(
14898                &samples,
14899                "structural.constraint_regions",
14900                marker_options,
14901            ) {
14902                merge_sizing_update(&mut sizing_update, update);
14903            }
14904        }
14905    }
14906    if sizing_update.samples.is_empty()
14907        && sizing_update.min_size_m.is_none()
14908        && sizing_update.max_size_m.is_none()
14909    {
14910        return None;
14911    }
14912    let mut sizing = MeshSizingField::default();
14913    sizing_update.apply_to(&mut sizing);
14914    Some(sizing)
14915}
14916
14917fn default_refinement_indicators_for_context(
14918    profile: &str,
14919    run_kind: &str,
14920) -> Vec<runmat_meshing_core::RefinementIndicatorKey> {
14921    runmat_meshing_core::default_refinement_indicators_for_analysis(profile, run_kind)
14922}
14923
14924fn analysis_refinement_context(spec: &AnalysisStudySpec) -> serde_json::Value {
14925    let Some(model) = spec.model.as_ref() else {
14926        return serde_json::json!({
14927            "boundary_load_region_ids": [],
14928            "boundary_constraint_region_ids": [],
14929        });
14930    };
14931
14932    let mut load_region_ids = model
14933        .loads
14934        .iter()
14935        .filter(|load| load_requires_boundary_region(&load.kind))
14936        .map(|load| load.region_id.clone())
14937        .collect::<Vec<_>>();
14938    load_region_ids.sort();
14939    load_region_ids.dedup();
14940
14941    let mut constraint_region_ids = model
14942        .boundary_conditions
14943        .iter()
14944        .map(|boundary_condition| boundary_condition.region_id.clone())
14945        .collect::<Vec<_>>();
14946    constraint_region_ids.sort();
14947    constraint_region_ids.dedup();
14948
14949    serde_json::json!({
14950        "boundary_load_region_ids": load_region_ids,
14951        "boundary_constraint_region_ids": constraint_region_ids,
14952    })
14953}
14954
14955fn append_solved_adaptive_mesh_summary(
14956    analysis_mesh_artifact_path: Option<&str>,
14957    fields: &[AnalysisField],
14958) -> Result<(), String> {
14959    let Some(path) = analysis_mesh_artifact_path else {
14960        return Ok(());
14961    };
14962    let path_buf = PathBuf::from(path);
14963    let mut payload: serde_json::Value = serde_json::from_slice(
14964        &fs_read(&path_buf)
14965            .map_err(|err| format!("failed to read analysis mesh artifact: {err}"))?,
14966    )
14967    .map_err(|err| format!("failed to parse analysis mesh artifact: {err}"))?;
14968    let mut mesh: AnalysisMeshArtifact = serde_json::from_value(payload["mesh"].clone())
14969        .map_err(|err| format!("failed to decode analysis mesh payload: {err}"))?;
14970    let options = payload
14971        .get("mesh_options")
14972        .cloned()
14973        .map(serde_json::from_value::<runmat_meshing_core::VolumeMeshingOptions>)
14974        .transpose()
14975        .map_err(|err| format!("failed to decode analysis mesh options: {err}"))?;
14976    let Some(options) = options.as_ref() else {
14977        return Ok(());
14978    };
14979
14980    let profile_label = payload
14981        .get("analysis_profile")
14982        .and_then(serde_json::Value::as_str)
14983        .ok_or_else(|| {
14984            "analysis mesh artifact is missing analysis_profile; regenerate the mesh from the typed .fea study"
14985                .to_string()
14986        })?;
14987    let run_kind_label = payload
14988        .get("run_kind")
14989        .and_then(serde_json::Value::as_str)
14990        .ok_or_else(|| {
14991            "analysis mesh artifact is missing run_kind; regenerate the mesh from the typed .fea study"
14992                .to_string()
14993        })?;
14994    let defaults = if matches!(options.refinement.strategy, RefinementStrategy::Uniform) {
14995        Vec::new()
14996    } else {
14997        default_refinement_indicators_for_context(profile_label, run_kind_label)
14998    };
14999    if defaults.is_empty()
15000        && options.refinement.indicators.namespaces.is_empty()
15001        && !matches!(options.refinement.strategy, RefinementStrategy::Uniform)
15002    {
15003        return Ok(());
15004    }
15005
15006    let von_mises_values = analysis_field_values(fields, FEA_FIELD_STRUCTURAL_VON_MISES);
15007    let has_von_mises = von_mises_values
15008        .map(|values| values.len() == mesh.volume_elements.len())
15009        .unwrap_or(false);
15010    let strain_energy_density_values =
15011        analysis_field_values(fields, FEA_FIELD_STRUCTURAL_STRAIN_ENERGY_DENSITY);
15012    let has_strain_energy_density = strain_energy_density_values
15013        .map(|values| values.len() == mesh.volume_elements.len())
15014        .unwrap_or(false);
15015    let temperature_gradient_values = latest_prefixed_vector_field_magnitudes(
15016        fields,
15017        "thermal.temperature_gradient.",
15018        mesh.volume_elements.len(),
15019    );
15020    let has_temperature_gradient = temperature_gradient_values.is_some();
15021    let heat_flux_values = latest_prefixed_vector_field_magnitudes(
15022        fields,
15023        "thermal.heat_flux.",
15024        mesh.volume_elements.len(),
15025    );
15026    let has_heat_flux = heat_flux_values.is_some();
15027    let magnetic_flux_density_values = analysis_field_magnitudes(
15028        fields,
15029        "em.magnetic_flux_density_magnitude",
15030        mesh.volume_elements.len(),
15031    );
15032    let has_magnetic_flux_density = magnetic_flux_density_values.is_some();
15033    let electric_field_values =
15034        analysis_field_magnitudes(fields, "em.electric_field_real", mesh.volume_elements.len());
15035    let has_electric_field = electric_field_values.is_some();
15036    let current_density_values = analysis_field_magnitudes(
15037        fields,
15038        "em.current_density_real",
15039        mesh.volume_elements.len(),
15040    );
15041    let has_current_density = current_density_values.is_some();
15042    let electromagnetic_energy_density_values =
15043        analysis_field_magnitudes(fields, "em.energy_density", mesh.volume_elements.len());
15044    let has_electromagnetic_energy_density = electromagnetic_energy_density_values.is_some();
15045    let acoustic_pressure_values = analysis_field_magnitudes(
15046        fields,
15047        FEA_FIELD_ACOUSTIC_PRESSURE_MAGNITUDE,
15048        mesh.volume_elements.len(),
15049    );
15050    let has_acoustic_pressure = acoustic_pressure_values.is_some();
15051    let cfd_velocity_values =
15052        analysis_field_magnitudes(fields, FEA_FIELD_CFD_VELOCITY, mesh.volume_elements.len());
15053    let has_cfd_velocity = cfd_velocity_values.is_some();
15054    let cfd_pressure_values =
15055        analysis_field_magnitudes(fields, FEA_FIELD_CFD_PRESSURE, mesh.volume_elements.len());
15056    let has_cfd_pressure = cfd_pressure_values.is_some();
15057    let cfd_vorticity_values =
15058        analysis_field_magnitudes(fields, FEA_FIELD_CFD_VORTICITY, mesh.volume_elements.len());
15059    let has_cfd_vorticity = cfd_vorticity_values.is_some();
15060    let cfd_wall_shear_values = analysis_field_magnitudes(
15061        fields,
15062        FEA_FIELD_CFD_WALL_SHEAR_STRESS,
15063        mesh.volume_elements.len(),
15064    );
15065    let has_cfd_wall_shear = cfd_wall_shear_values.is_some();
15066    let cht_interface_heat_flux_values = latest_prefixed_vector_field_magnitudes(
15067        fields,
15068        "cht.interface_heat_flux.",
15069        mesh.volume_elements.len(),
15070    );
15071    let has_cht_interface_heat_flux = cht_interface_heat_flux_values.is_some();
15072    let cht_interface_temperature_jump_values = latest_prefixed_vector_field_magnitudes(
15073        fields,
15074        "cht.interface_temperature_jump.",
15075        mesh.volume_elements.len(),
15076    );
15077    let has_cht_interface_temperature_jump = cht_interface_temperature_jump_values.is_some();
15078    let cht_fluid_velocity_values = analysis_field_magnitudes(
15079        fields,
15080        FEA_FIELD_CHT_FLUID_VELOCITY,
15081        mesh.volume_elements.len(),
15082    );
15083    let has_cht_fluid_velocity = cht_fluid_velocity_values.is_some();
15084    let boundary_load_region_ids =
15085        refinement_context_region_ids(&payload, "boundary_load_region_ids");
15086    let boundary_constraint_region_ids =
15087        refinement_context_region_ids(&payload, "boundary_constraint_region_ids");
15088    let load_focus_options = refinement_marker_options_for_focus(options.refinement.focus.loads);
15089    let constraint_focus_options =
15090        refinement_marker_options_for_focus(options.refinement.focus.constraints);
15091    let has_boundary_load_regions = load_focus_options.is_some()
15092        && has_boundary_faces_for_regions(&mesh, boundary_load_region_ids.as_slice());
15093    let has_boundary_constraint_regions = constraint_focus_options.is_some()
15094        && has_boundary_faces_for_regions(&mesh, boundary_constraint_region_ids.as_slice());
15095    let availability = defaults
15096        .iter()
15097        .cloned()
15098        .map(|key| {
15099            let field_available = key.namespace == "structural"
15100                && ((key.name == "stress_gradient" && has_von_mises)
15101                    || (key.name == "strain_energy_density" && has_strain_energy_density));
15102            let applicable = key.namespace != "structural"
15103                || (key.name != "load_regions" || load_focus_options.is_some())
15104                    && (key.name != "constraint_regions" || constraint_focus_options.is_some());
15105            let field_available = field_available
15106                || (key.namespace == "structural"
15107                    && ((key.name == "load_regions" && has_boundary_load_regions)
15108                        || (key.name == "constraint_regions" && has_boundary_constraint_regions)))
15109                || (key.namespace == "thermal"
15110                    && ((key.name == "temperature_gradient" && has_temperature_gradient)
15111                        || (key.name == "heat_flux_gradient" && has_heat_flux)))
15112                || (key.namespace == "electromagnetic"
15113                    && ((key.name == "flux_density_gradient" && has_magnetic_flux_density)
15114                        || (key.name == "electric_field_gradient" && has_electric_field)
15115                        || (key.name == "current_density_gradient" && has_current_density)
15116                        || (key.name == "energy_density" && has_electromagnetic_energy_density)))
15117                || (key.namespace == "acoustic"
15118                    && has_acoustic_pressure
15119                    && matches!(
15120                        key.name.as_str(),
15121                        "pressure_gradient" | "pressure_curvature"
15122                    ))
15123                || (key.namespace == "cfd"
15124                    && ((key.name == "velocity_gradient" && has_cfd_velocity)
15125                        || (key.name == "pressure_gradient" && has_cfd_pressure)
15126                        || (key.name == "vorticity" && has_cfd_vorticity)
15127                        || (key.name == "wall_shear" && has_cfd_wall_shear)))
15128                || (key.namespace == "cht"
15129                    && ((key.name == "interface_heat_flux_jump" && has_cht_interface_heat_flux)
15130                        || (key.name == "interface_temperature_jump"
15131                            && has_cht_interface_temperature_jump)
15132                        || (key.name == "fluid_boundary_layer" && has_cht_fluid_velocity)));
15133            RefinementIndicatorAvailability {
15134                key,
15135                applicable,
15136                field_available,
15137            }
15138        })
15139        .collect::<Vec<_>>();
15140    let element_budget_reached =
15141        options.max_elements > 0 && mesh.volume_elements.len() >= options.max_elements;
15142    let indicators = plan_refinement_indicators(
15143        &options.refinement,
15144        &defaults,
15145        &availability,
15146        element_budget_reached,
15147        false,
15148    );
15149
15150    let mut markers = Vec::new();
15151    let mut sizing_update = SizingFieldUpdate::default();
15152    if !element_budget_reached && matches!(options.refinement.strategy, RefinementStrategy::Uniform)
15153    {
15154        let samples = uniform_refinement_samples(&mesh);
15155        let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15156            &samples,
15157            "mesh.uniform_refinement",
15158            RefinementMarkerOptions::default(),
15159        )
15160        .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15161        markers.extend(new_markers);
15162        merge_sizing_update(&mut sizing_update, new_sizing_update);
15163    }
15164    let stress_gradient_used = indicator_was_used(&indicators, "structural", "stress_gradient");
15165    let strain_energy_density_used =
15166        indicator_was_used(&indicators, "structural", "strain_energy_density");
15167    let load_regions_used = indicator_was_used(&indicators, "structural", "load_regions");
15168    let constraint_regions_used =
15169        indicator_was_used(&indicators, "structural", "constraint_regions");
15170    let temperature_gradient_used =
15171        indicator_was_used(&indicators, "thermal", "temperature_gradient");
15172    let heat_flux_gradient_used = indicator_was_used(&indicators, "thermal", "heat_flux_gradient");
15173    let electromagnetic_flux_density_used =
15174        indicator_was_used(&indicators, "electromagnetic", "flux_density_gradient");
15175    let electromagnetic_electric_field_used =
15176        indicator_was_used(&indicators, "electromagnetic", "electric_field_gradient");
15177    let electromagnetic_current_density_used =
15178        indicator_was_used(&indicators, "electromagnetic", "current_density_gradient");
15179    let electromagnetic_energy_density_used =
15180        indicator_was_used(&indicators, "electromagnetic", "energy_density");
15181    let acoustic_pressure_gradient_used =
15182        indicator_was_used(&indicators, "acoustic", "pressure_gradient");
15183    let acoustic_pressure_curvature_used =
15184        indicator_was_used(&indicators, "acoustic", "pressure_curvature");
15185    let cfd_velocity_gradient_used = indicator_was_used(&indicators, "cfd", "velocity_gradient");
15186    let cfd_pressure_gradient_used = indicator_was_used(&indicators, "cfd", "pressure_gradient");
15187    let cfd_vorticity_used = indicator_was_used(&indicators, "cfd", "vorticity");
15188    let cfd_wall_shear_used = indicator_was_used(&indicators, "cfd", "wall_shear");
15189    let cht_interface_heat_flux_jump_used =
15190        indicator_was_used(&indicators, "cht", "interface_heat_flux_jump");
15191    let cht_interface_temperature_jump_used =
15192        indicator_was_used(&indicators, "cht", "interface_temperature_jump");
15193    let cht_fluid_boundary_layer_used =
15194        indicator_was_used(&indicators, "cht", "fluid_boundary_layer");
15195    if !element_budget_reached && stress_gradient_used {
15196        if let Some(values) = von_mises_values {
15197            let samples = structural_stress_gradient_samples(&mesh, values);
15198            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15199                &samples,
15200                "structural.stress_gradient",
15201                RefinementMarkerOptions::default(),
15202            )
15203            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15204            markers.extend(new_markers);
15205            merge_sizing_update(&mut sizing_update, new_sizing_update);
15206        }
15207    }
15208    if !element_budget_reached && load_regions_used {
15209        let samples =
15210            structural_boundary_region_samples(&mesh, boundary_load_region_ids.as_slice());
15211        if let Some(options) = load_focus_options {
15212            let (new_markers, new_sizing_update) =
15213                build_refinement_markers_from_samples(&samples, "structural.load_regions", options)
15214                    .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15215            markers.extend(new_markers);
15216            merge_sizing_update(&mut sizing_update, new_sizing_update);
15217        }
15218    }
15219    if !element_budget_reached && constraint_regions_used {
15220        let samples =
15221            structural_boundary_region_samples(&mesh, boundary_constraint_region_ids.as_slice());
15222        if let Some(options) = constraint_focus_options {
15223            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15224                &samples,
15225                "structural.constraint_regions",
15226                options,
15227            )
15228            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15229            markers.extend(new_markers);
15230            merge_sizing_update(&mut sizing_update, new_sizing_update);
15231        }
15232    }
15233    if !element_budget_reached && strain_energy_density_used {
15234        if let Some(values) = strain_energy_density_values {
15235            let samples = structural_strain_energy_density_samples(&mesh, values);
15236            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15237                &samples,
15238                "structural.strain_energy_density",
15239                RefinementMarkerOptions::default(),
15240            )
15241            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15242            markers.extend(new_markers);
15243            merge_sizing_update(&mut sizing_update, new_sizing_update);
15244        }
15245    }
15246    if !element_budget_reached && temperature_gradient_used {
15247        if let Some(values) = temperature_gradient_values.as_deref() {
15248            let samples = thermal_element_gradient_samples(&mesh, values);
15249            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15250                &samples,
15251                "thermal.temperature_gradient",
15252                RefinementMarkerOptions::default(),
15253            )
15254            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15255            markers.extend(new_markers);
15256            merge_sizing_update(&mut sizing_update, new_sizing_update);
15257        }
15258    }
15259    if !element_budget_reached && heat_flux_gradient_used {
15260        if let Some(values) = heat_flux_values.as_deref() {
15261            let samples = thermal_element_gradient_samples(&mesh, values);
15262            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15263                &samples,
15264                "thermal.heat_flux_gradient",
15265                RefinementMarkerOptions::default(),
15266            )
15267            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15268            markers.extend(new_markers);
15269            merge_sizing_update(&mut sizing_update, new_sizing_update);
15270        }
15271    }
15272    if !element_budget_reached && electromagnetic_flux_density_used {
15273        if let Some(values) = magnetic_flux_density_values.as_deref() {
15274            let samples = electromagnetic_element_gradient_samples(&mesh, values);
15275            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15276                &samples,
15277                "electromagnetic.flux_density_gradient",
15278                RefinementMarkerOptions::default(),
15279            )
15280            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15281            markers.extend(new_markers);
15282            merge_sizing_update(&mut sizing_update, new_sizing_update);
15283        }
15284    }
15285    if !element_budget_reached && electromagnetic_electric_field_used {
15286        if let Some(values) = electric_field_values.as_deref() {
15287            let samples = electromagnetic_element_gradient_samples(&mesh, values);
15288            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15289                &samples,
15290                "electromagnetic.electric_field_gradient",
15291                RefinementMarkerOptions::default(),
15292            )
15293            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15294            markers.extend(new_markers);
15295            merge_sizing_update(&mut sizing_update, new_sizing_update);
15296        }
15297    }
15298    if !element_budget_reached && electromagnetic_current_density_used {
15299        if let Some(values) = current_density_values.as_deref() {
15300            let samples = electromagnetic_element_gradient_samples(&mesh, values);
15301            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15302                &samples,
15303                "electromagnetic.current_density_gradient",
15304                RefinementMarkerOptions::default(),
15305            )
15306            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15307            markers.extend(new_markers);
15308            merge_sizing_update(&mut sizing_update, new_sizing_update);
15309        }
15310    }
15311    if !element_budget_reached && electromagnetic_energy_density_used {
15312        if let Some(values) = electromagnetic_energy_density_values.as_deref() {
15313            let samples = electromagnetic_element_gradient_samples(&mesh, values);
15314            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15315                &samples,
15316                "electromagnetic.energy_density",
15317                RefinementMarkerOptions::default(),
15318            )
15319            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15320            markers.extend(new_markers);
15321            merge_sizing_update(&mut sizing_update, new_sizing_update);
15322        }
15323    }
15324    if !element_budget_reached && acoustic_pressure_gradient_used {
15325        if let Some(values) = acoustic_pressure_values.as_deref() {
15326            let samples = acoustic_element_gradient_samples(&mesh, values);
15327            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15328                &samples,
15329                "acoustic.pressure_gradient",
15330                RefinementMarkerOptions::default(),
15331            )
15332            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15333            markers.extend(new_markers);
15334            merge_sizing_update(&mut sizing_update, new_sizing_update);
15335        }
15336    }
15337    if !element_budget_reached && acoustic_pressure_curvature_used {
15338        if let Some(values) = acoustic_pressure_values.as_deref() {
15339            let samples = acoustic_element_gradient_samples(&mesh, values);
15340            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15341                &samples,
15342                "acoustic.pressure_curvature",
15343                RefinementMarkerOptions::default(),
15344            )
15345            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15346            markers.extend(new_markers);
15347            merge_sizing_update(&mut sizing_update, new_sizing_update);
15348        }
15349    }
15350    if !element_budget_reached && cfd_velocity_gradient_used {
15351        if let Some(values) = cfd_velocity_values.as_deref() {
15352            let samples = cfd_element_gradient_samples(&mesh, values);
15353            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15354                &samples,
15355                "cfd.velocity_gradient",
15356                RefinementMarkerOptions::default(),
15357            )
15358            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15359            markers.extend(new_markers);
15360            merge_sizing_update(&mut sizing_update, new_sizing_update);
15361        }
15362    }
15363    if !element_budget_reached && cfd_pressure_gradient_used {
15364        if let Some(values) = cfd_pressure_values.as_deref() {
15365            let samples = cfd_element_gradient_samples(&mesh, values);
15366            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15367                &samples,
15368                "cfd.pressure_gradient",
15369                RefinementMarkerOptions::default(),
15370            )
15371            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15372            markers.extend(new_markers);
15373            merge_sizing_update(&mut sizing_update, new_sizing_update);
15374        }
15375    }
15376    if !element_budget_reached && cfd_vorticity_used {
15377        if let Some(values) = cfd_vorticity_values.as_deref() {
15378            let samples = cfd_element_gradient_samples(&mesh, values);
15379            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15380                &samples,
15381                "cfd.vorticity",
15382                RefinementMarkerOptions::default(),
15383            )
15384            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15385            markers.extend(new_markers);
15386            merge_sizing_update(&mut sizing_update, new_sizing_update);
15387        }
15388    }
15389    if !element_budget_reached && cfd_wall_shear_used {
15390        if let Some(values) = cfd_wall_shear_values.as_deref() {
15391            let samples = cfd_element_gradient_samples(&mesh, values);
15392            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15393                &samples,
15394                "cfd.wall_shear",
15395                RefinementMarkerOptions::default(),
15396            )
15397            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15398            markers.extend(new_markers);
15399            merge_sizing_update(&mut sizing_update, new_sizing_update);
15400        }
15401    }
15402    if !element_budget_reached && cht_interface_heat_flux_jump_used {
15403        if let Some(values) = cht_interface_heat_flux_values.as_deref() {
15404            let samples = cht_element_gradient_samples(&mesh, values);
15405            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15406                &samples,
15407                "cht.interface_heat_flux_jump",
15408                RefinementMarkerOptions::default(),
15409            )
15410            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15411            markers.extend(new_markers);
15412            merge_sizing_update(&mut sizing_update, new_sizing_update);
15413        }
15414    }
15415    if !element_budget_reached && cht_interface_temperature_jump_used {
15416        if let Some(values) = cht_interface_temperature_jump_values.as_deref() {
15417            let samples = cht_element_gradient_samples(&mesh, values);
15418            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15419                &samples,
15420                "cht.interface_temperature_jump",
15421                RefinementMarkerOptions::default(),
15422            )
15423            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15424            markers.extend(new_markers);
15425            merge_sizing_update(&mut sizing_update, new_sizing_update);
15426        }
15427    }
15428    if !element_budget_reached && cht_fluid_boundary_layer_used {
15429        if let Some(values) = cht_fluid_velocity_values.as_deref() {
15430            let samples = cht_element_gradient_samples(&mesh, values);
15431            let (new_markers, new_sizing_update) = build_refinement_markers_from_samples(
15432                &samples,
15433                "cht.fluid_boundary_layer",
15434                RefinementMarkerOptions::default(),
15435            )
15436            .map_err(|err| format!("failed to build refinement markers: {err:?}"))?;
15437            markers.extend(new_markers);
15438            merge_sizing_update(&mut sizing_update, new_sizing_update);
15439        }
15440    }
15441    let convergence_status = if matches!(options.refinement.strategy, RefinementStrategy::Uniform) {
15442        if element_budget_reached {
15443            AdaptiveConvergenceStatus::ElementBudgetReached
15444        } else if markers.is_empty() {
15445            AdaptiveConvergenceStatus::Converged
15446        } else {
15447            AdaptiveConvergenceStatus::Pending
15448        }
15449    } else {
15450        let marker_change = markers
15451            .iter()
15452            .map(|marker| marker.weight)
15453            .filter(|weight| weight.is_finite())
15454            .reduce(f64::max)
15455            .unwrap_or(0.0);
15456        runmat_meshing_core::evaluate_adaptive_convergence(
15457            &options.refinement,
15458            runmat_meshing_core::AdaptiveConvergenceMetrics {
15459                completed_iterations: mesh.adaptive_iterations.len(),
15460                element_budget_reached,
15461                field_change: (stress_gradient_used
15462                    || load_regions_used
15463                    || constraint_regions_used
15464                    || temperature_gradient_used
15465                    || heat_flux_gradient_used
15466                    || electromagnetic_flux_density_used
15467                    || electromagnetic_electric_field_used
15468                    || electromagnetic_current_density_used
15469                    || acoustic_pressure_gradient_used
15470                    || acoustic_pressure_curvature_used
15471                    || cfd_velocity_gradient_used
15472                    || cfd_pressure_gradient_used
15473                    || cfd_vorticity_used
15474                    || cfd_wall_shear_used
15475                    || cht_interface_heat_flux_jump_used
15476                    || cht_interface_temperature_jump_used
15477                    || cht_fluid_boundary_layer_used)
15478                    .then_some(marker_change),
15479                energy_change: (strain_energy_density_used || electromagnetic_energy_density_used)
15480                    .then_some(marker_change),
15481                residual: None,
15482                ..runmat_meshing_core::AdaptiveConvergenceMetrics::default()
15483            },
15484        )
15485    };
15486
15487    let mut updated_sizing = mesh.sizing.clone();
15488    sizing_update.clone().apply_to(&mut updated_sizing);
15489    mesh.sizing = updated_sizing;
15490    mesh.adaptive_iterations.push(AdaptiveIterationSummary {
15491        iteration_index: mesh.adaptive_iterations.len(),
15492        node_count: mesh.nodes.len(),
15493        element_count: mesh.volume_elements.len(),
15494        convergence_status,
15495        indicators,
15496        markers,
15497        sizing_update,
15498    });
15499    payload["mesh"] = serde_json::to_value(&mesh)
15500        .map_err(|err| format!("failed to encode mesh payload: {err}"))?;
15501    let bytes = serde_json::to_vec_pretty(&payload)
15502        .map_err(|err| format!("failed to encode analysis mesh artifact: {err}"))?;
15503    atomic_write_bytes(&path_buf, &bytes)?;
15504    update_mesh_evidence_from_analysis_mesh_payload(&payload, &mesh)?;
15505    Ok(())
15506}
15507
15508fn update_mesh_evidence_from_analysis_mesh_payload(
15509    analysis_mesh_payload: &serde_json::Value,
15510    mesh: &AnalysisMeshArtifact,
15511) -> Result<(), String> {
15512    let Some(evidence_path) = analysis_mesh_payload
15513        .get("mesh_evidence_artifact_path")
15514        .and_then(serde_json::Value::as_str)
15515    else {
15516        return Ok(());
15517    };
15518    let evidence_path = PathBuf::from(evidence_path);
15519    let mut evidence_payload: serde_json::Value = serde_json::from_slice(
15520        &fs_read(&evidence_path)
15521            .map_err(|err| format!("failed to read mesh evidence artifact: {err}"))?,
15522    )
15523    .map_err(|err| format!("failed to parse mesh evidence artifact: {err}"))?;
15524    let validation = evidence_payload
15525        .get("mesh_evidence")
15526        .and_then(|evidence| evidence.get("validation"))
15527        .cloned()
15528        .map(serde_json::from_value::<MeshValidationEvidence>)
15529        .transpose()
15530        .map_err(|err| format!("failed to decode mesh evidence validation policy: {err}"))?;
15531    let mesh_evidence = if let Some(validation) = validation {
15532        build_mesh_evidence_artifact_with_validation_evidence(mesh, validation)
15533    } else {
15534        let validation_options =
15535            analysis_mesh_validation_options_for_loaded_artifact(analysis_mesh_payload, mesh)
15536                .map_err(|err| {
15537                    format!("failed to decode analysis mesh validation policy: {err}")
15538                })?;
15539        if let Some(validation_value) = analysis_mesh_payload.get("mesh_validation_options") {
15540            evidence_payload["mesh_validation_options"] = validation_value.clone();
15541        }
15542        build_mesh_evidence_artifact(mesh, &validation_options)
15543    };
15544    let mesh_authoring_summary = build_mesh_authoring_summary(&mesh_evidence);
15545    evidence_payload["mesh_authoring_summary"] = serde_json::to_value(mesh_authoring_summary)
15546        .map_err(|err| format!("failed to encode mesh authoring summary: {err}"))?;
15547    evidence_payload["mesh_evidence"] = serde_json::to_value(mesh_evidence)
15548        .map_err(|err| format!("failed to encode mesh evidence payload: {err}"))?;
15549    let bytes = serde_json::to_vec_pretty(&evidence_payload)
15550        .map_err(|err| format!("failed to encode mesh evidence artifact: {err}"))?;
15551    atomic_write_bytes(&evidence_path, &bytes)
15552}
15553
15554fn analysis_field_values<'a>(fields: &'a [AnalysisField], field_id: &str) -> Option<&'a [f64]> {
15555    fields
15556        .iter()
15557        .find(|field| field.field_id == field_id)
15558        .and_then(AnalysisField::as_host_f64)
15559}
15560
15561fn analysis_field_magnitudes(
15562    fields: &[AnalysisField],
15563    field_id: &str,
15564    entity_count: usize,
15565) -> Option<Vec<f64>> {
15566    let values = analysis_field_values(fields, field_id)?;
15567    vector_field_magnitudes(values, entity_count)
15568}
15569
15570fn latest_prefixed_vector_field_magnitudes(
15571    fields: &[AnalysisField],
15572    field_id_prefix: &str,
15573    entity_count: usize,
15574) -> Option<Vec<f64>> {
15575    fields
15576        .iter()
15577        .filter_map(|field| {
15578            let suffix = field.field_id.strip_prefix(field_id_prefix)?;
15579            let snapshot_index = suffix.parse::<usize>().ok()?;
15580            let values = field.as_host_f64()?;
15581            let magnitudes = vector_field_magnitudes(values, entity_count)?;
15582            Some((snapshot_index, magnitudes))
15583        })
15584        .max_by_key(|(snapshot_index, _)| *snapshot_index)
15585        .map(|(_, magnitudes)| magnitudes)
15586}
15587
15588fn vector_field_magnitudes(values: &[f64], entity_count: usize) -> Option<Vec<f64>> {
15589    if values.len() == entity_count {
15590        return Some(values.to_vec());
15591    }
15592    if entity_count == 0 || values.len() != entity_count * 3 {
15593        return None;
15594    }
15595    Some(
15596        values
15597            .chunks_exact(3)
15598            .map(|chunk| (chunk[0].powi(2) + chunk[1].powi(2) + chunk[2].powi(2)).sqrt())
15599            .collect(),
15600    )
15601}
15602
15603fn refinement_context_region_ids(payload: &serde_json::Value, key: &str) -> Vec<String> {
15604    payload
15605        .get("refinement_context")
15606        .and_then(|context| context.get(key))
15607        .and_then(serde_json::Value::as_array)
15608        .map(|values| {
15609            let mut ids = values
15610                .iter()
15611                .filter_map(serde_json::Value::as_str)
15612                .map(str::to_string)
15613                .collect::<Vec<_>>();
15614            ids.sort();
15615            ids.dedup();
15616            ids
15617        })
15618        .unwrap_or_default()
15619}
15620
15621fn has_boundary_faces_for_regions(mesh: &AnalysisMeshArtifact, region_ids: &[String]) -> bool {
15622    !region_ids.is_empty()
15623        && mesh.boundary_faces.iter().any(|face| {
15624            face.region_ids
15625                .iter()
15626                .any(|region_id| region_ids.iter().any(|target| target == region_id))
15627        })
15628}
15629
15630fn refinement_marker_options_for_focus(
15631    focus: runmat_meshing_core::RefinementFocusLevel,
15632) -> Option<RefinementMarkerOptions> {
15633    match focus {
15634        runmat_meshing_core::RefinementFocusLevel::Off => None,
15635        runmat_meshing_core::RefinementFocusLevel::Normal => {
15636            Some(RefinementMarkerOptions::default())
15637        }
15638        runmat_meshing_core::RefinementFocusLevel::Fine => Some(RefinementMarkerOptions {
15639            target_size_scale: 0.35,
15640            max_markers: 96,
15641            ..RefinementMarkerOptions::default()
15642        }),
15643    }
15644}
15645
15646fn indicator_was_used(
15647    indicators: &[runmat_meshing_core::RefinementIndicatorSummary],
15648    namespace: &str,
15649    name: &str,
15650) -> bool {
15651    indicators.iter().any(|indicator| {
15652        indicator.namespace == namespace
15653            && indicator.name == name
15654            && indicator.status == runmat_meshing_core::RefinementIndicatorStatus::Used
15655    })
15656}
15657
15658fn merge_sizing_update(target: &mut SizingFieldUpdate, update: SizingFieldUpdate) {
15659    target.samples.extend(update.samples);
15660    target.min_size_m = match (target.min_size_m, update.min_size_m) {
15661        (Some(left), Some(right)) => Some(left.min(right)),
15662        (Some(left), None) => Some(left),
15663        (None, Some(right)) => Some(right),
15664        (None, None) => None,
15665    };
15666    target.max_size_m = match (target.max_size_m, update.max_size_m) {
15667        (Some(left), Some(right)) => Some(left.max(right)),
15668        (Some(left), None) => Some(left),
15669        (None, Some(right)) => Some(right),
15670        (None, None) => None,
15671    };
15672}
15673
15674fn sizing_application_summary(mesh: &AnalysisMeshArtifact) -> serde_json::Value {
15675    let mut by_reason = BTreeMap::<String, usize>::new();
15676    let mut inserted_breakpoints_by_reason = BTreeMap::<String, usize>::new();
15677    let mut uninserted_by_reason = BTreeMap::<String, usize>::new();
15678    let mut inserted_breakpoint_count = 0_usize;
15679    for application in &mesh.sizing.applied_samples {
15680        let reason = application
15681            .reason
15682            .clone()
15683            .unwrap_or_else(|| "unspecified".to_string());
15684        *by_reason.entry(reason.clone()).or_default() += 1;
15685        if application.inserted_breakpoint_count > 0 {
15686            *inserted_breakpoints_by_reason.entry(reason).or_default() +=
15687                application.inserted_breakpoint_count;
15688        } else {
15689            *uninserted_by_reason.entry(reason).or_default() += 1;
15690        }
15691        inserted_breakpoint_count += application.inserted_breakpoint_count;
15692    }
15693    let accepted_requested = mesh
15694        .backend
15695        .tetrahedron_accepted_requested_refinement_point_count;
15696    let accepted_location = mesh
15697        .backend
15698        .tetrahedron_accepted_requested_refinement_location_count;
15699    let accepted_interpolated = mesh
15700        .backend
15701        .tetrahedron_accepted_requested_refinement_interpolated_point_count;
15702    let rejected_requested = mesh
15703        .backend
15704        .tetrahedron_rejected_requested_refinement_point_count;
15705    let dropped_requested = mesh
15706        .backend
15707        .tetrahedron_dropped_requested_refinement_point_count;
15708    let mut anisotropic_by_reason = BTreeMap::<String, usize>::new();
15709    let mut invalid_anisotropic_by_reason = BTreeMap::<String, usize>::new();
15710    for sample in &mesh.sizing.anisotropic_samples {
15711        let reason = sample
15712            .reason
15713            .clone()
15714            .unwrap_or_else(|| "unspecified".to_string());
15715        *anisotropic_by_reason.entry(reason.clone()).or_default() += 1;
15716        if !sample.is_valid_metric() {
15717            *invalid_anisotropic_by_reason.entry(reason).or_default() += 1;
15718        }
15719    }
15720    let invalid_anisotropic_count = invalid_anisotropic_by_reason.values().sum::<usize>();
15721    serde_json::json!({
15722        "total": mesh.sizing.applied_samples.len(),
15723        "inserted_breakpoint_count": inserted_breakpoint_count,
15724        "by_reason": by_reason,
15725        "inserted_breakpoints_by_reason": inserted_breakpoints_by_reason,
15726        "uninserted_by_reason": uninserted_by_reason,
15727        "anisotropic": {
15728            "total": mesh.sizing.anisotropic_samples.len(),
15729            "valid_count": mesh.sizing.anisotropic_samples.len().saturating_sub(invalid_anisotropic_count),
15730            "invalid_count": invalid_anisotropic_count,
15731            "by_reason": anisotropic_by_reason,
15732            "invalid_by_reason": invalid_anisotropic_by_reason,
15733        },
15734        "requested_tetrahedron_refinement": {
15735            "requested_count": mesh.backend.tetrahedron_requested_refinement_point_count,
15736            "accepted_location_count": accepted_location,
15737            "accepted_count": accepted_requested,
15738            "accepted_exact_count": accepted_requested.saturating_sub(accepted_interpolated),
15739            "accepted_interpolated_count": accepted_interpolated,
15740            "rejected_count": rejected_requested,
15741            "rejected_by_reason": mesh.backend.tetrahedron_requested_refinement_rejected_by_reason.clone(),
15742            "dropped_count": dropped_requested,
15743            "dropped_by_reason": mesh.backend.tetrahedron_requested_refinement_dropped_by_reason.clone(),
15744            "acceptance_ratio": if mesh.backend.tetrahedron_requested_refinement_point_count > 0 {
15745                Some(accepted_requested as f64 / mesh.backend.tetrahedron_requested_refinement_point_count as f64)
15746            } else {
15747                None
15748            },
15749            "rejection_ratio": if mesh.backend.tetrahedron_requested_refinement_point_count > 0 {
15750                Some(rejected_requested as f64 / mesh.backend.tetrahedron_requested_refinement_point_count as f64)
15751            } else {
15752                None
15753            },
15754            "drop_ratio": if mesh.backend.tetrahedron_requested_refinement_point_count > 0 {
15755                Some(dropped_requested as f64 / mesh.backend.tetrahedron_requested_refinement_point_count as f64)
15756            } else {
15757                None
15758            },
15759            "interpolated_ratio": if accepted_requested > 0 {
15760                Some(accepted_interpolated as f64 / accepted_requested as f64)
15761            } else {
15762                None
15763            },
15764        },
15765    })
15766}
15767
15768fn sizing_rejection_summary(mesh: &AnalysisMeshArtifact) -> serde_json::Value {
15769    let mut by_status = BTreeMap::<String, usize>::new();
15770    let mut by_reason = BTreeMap::<String, usize>::new();
15771    for rejection in &mesh.sizing.rejected_samples {
15772        *by_status.entry(rejection.status.clone()).or_default() += 1;
15773        let reason = rejection
15774            .reason
15775            .clone()
15776            .unwrap_or_else(|| "unspecified".to_string());
15777        *by_reason.entry(reason).or_default() += 1;
15778    }
15779    serde_json::json!({
15780        "total": mesh.sizing.rejected_samples.len(),
15781        "by_status": by_status,
15782        "by_reason": by_reason,
15783    })
15784}
15785
15786fn refinement_effect_summary(
15787    source_mesh: &AnalysisMeshArtifact,
15788    refined_mesh: &AnalysisMeshArtifact,
15789) -> serde_json::Value {
15790    let source_node_count = source_mesh.nodes.len();
15791    let source_element_count = source_mesh.volume_elements.len();
15792    let refined_node_count = refined_mesh.nodes.len();
15793    let refined_element_count = refined_mesh.volume_elements.len();
15794    serde_json::json!({
15795        "source_node_count": source_node_count,
15796        "source_element_count": source_element_count,
15797        "refined_node_count": refined_node_count,
15798        "refined_element_count": refined_element_count,
15799        "node_count_delta": refined_node_count as i64 - source_node_count as i64,
15800        "element_count_delta": refined_element_count as i64 - source_element_count as i64,
15801        "topology_changed": refined_node_count != source_node_count
15802            || refined_element_count != source_element_count,
15803    })
15804}
15805
15806fn structural_stress_gradient_samples(
15807    mesh: &AnalysisMeshArtifact,
15808    von_mises_values: &[f64],
15809) -> Vec<RefinementIndicatorSample> {
15810    structural_element_scalar_gradient_samples(mesh, von_mises_values)
15811}
15812
15813fn structural_strain_energy_density_samples(
15814    mesh: &AnalysisMeshArtifact,
15815    strain_energy_density_values: &[f64],
15816) -> Vec<RefinementIndicatorSample> {
15817    structural_element_scalar_gradient_samples(mesh, strain_energy_density_values)
15818}
15819
15820fn thermal_element_gradient_samples(
15821    mesh: &AnalysisMeshArtifact,
15822    element_values: &[f64],
15823) -> Vec<RefinementIndicatorSample> {
15824    structural_element_scalar_gradient_samples(mesh, element_values)
15825}
15826
15827fn electromagnetic_element_gradient_samples(
15828    mesh: &AnalysisMeshArtifact,
15829    element_values: &[f64],
15830) -> Vec<RefinementIndicatorSample> {
15831    structural_element_scalar_gradient_samples(mesh, element_values)
15832}
15833
15834fn acoustic_element_gradient_samples(
15835    mesh: &AnalysisMeshArtifact,
15836    element_values: &[f64],
15837) -> Vec<RefinementIndicatorSample> {
15838    structural_element_scalar_gradient_samples(mesh, element_values)
15839}
15840
15841fn cfd_element_gradient_samples(
15842    mesh: &AnalysisMeshArtifact,
15843    element_values: &[f64],
15844) -> Vec<RefinementIndicatorSample> {
15845    structural_element_scalar_gradient_samples(mesh, element_values)
15846}
15847
15848fn cht_element_gradient_samples(
15849    mesh: &AnalysisMeshArtifact,
15850    element_values: &[f64],
15851) -> Vec<RefinementIndicatorSample> {
15852    structural_element_scalar_gradient_samples(mesh, element_values)
15853}
15854
15855fn structural_boundary_region_samples(
15856    mesh: &AnalysisMeshArtifact,
15857    region_ids: &[String],
15858) -> Vec<RefinementIndicatorSample> {
15859    if region_ids.is_empty() {
15860        return Vec::new();
15861    }
15862    let region_ids = region_ids
15863        .iter()
15864        .map(String::as_str)
15865        .collect::<HashSet<_>>();
15866    let mut sampled_entity_ids = HashSet::new();
15867    let mut samples = Vec::new();
15868    for face in &mesh.boundary_faces {
15869        if !face
15870            .region_ids
15871            .iter()
15872            .any(|region_id| region_ids.contains(region_id.as_str()))
15873        {
15874            continue;
15875        }
15876        let Some(position_m) = element_centroid_m(mesh, &face.node_ids) else {
15877            continue;
15878        };
15879        let fallback_size = element_characteristic_size_m(mesh, &face.node_ids);
15880        if face.adjacent_volume_element_ids.is_empty() {
15881            if sampled_entity_ids.insert(face.face_id.clone()) {
15882                if let Some(current_size_m) = fallback_size {
15883                    samples.push(RefinementIndicatorSample {
15884                        entity_id: face.face_id.clone(),
15885                        position_m,
15886                        indicator_value: 1.0,
15887                        current_size_m,
15888                    });
15889                }
15890            }
15891            continue;
15892        }
15893        for element_id in &face.adjacent_volume_element_ids {
15894            if !sampled_entity_ids.insert(element_id.clone()) {
15895                continue;
15896            }
15897            let current_size_m = mesh
15898                .volume_elements
15899                .iter()
15900                .find(|element| element.element_id == *element_id)
15901                .and_then(|element| element_characteristic_size_m(mesh, &element.node_ids))
15902                .or(fallback_size);
15903            if let Some(current_size_m) = current_size_m {
15904                samples.push(RefinementIndicatorSample {
15905                    entity_id: element_id.clone(),
15906                    position_m,
15907                    indicator_value: 1.0,
15908                    current_size_m,
15909                });
15910            }
15911        }
15912    }
15913    samples
15914}
15915
15916fn structural_element_scalar_gradient_samples(
15917    mesh: &AnalysisMeshArtifact,
15918    values: &[f64],
15919) -> Vec<RefinementIndicatorSample> {
15920    if values.len() != mesh.volume_elements.len() {
15921        return Vec::new();
15922    }
15923    mesh.volume_elements
15924        .iter()
15925        .enumerate()
15926        .filter_map(|(index, element)| {
15927            let value = *values.get(index)?;
15928            let mut indicator = 0.0_f64;
15929            for (other_index, other) in mesh.volume_elements.iter().enumerate() {
15930                if index == other_index || shared_node_count(&element.node_ids, &other.node_ids) < 3
15931                {
15932                    continue;
15933                }
15934                if let Some(other_value) = values.get(other_index) {
15935                    indicator = indicator.max((value - *other_value).abs());
15936                }
15937            }
15938            if indicator <= 0.0 {
15939                indicator = value.abs();
15940            }
15941            Some(RefinementIndicatorSample {
15942                entity_id: element.element_id.clone(),
15943                position_m: element_centroid_m(mesh, &element.node_ids)?,
15944                indicator_value: indicator,
15945                current_size_m: element_characteristic_size_m(mesh, &element.node_ids)?,
15946            })
15947        })
15948        .collect()
15949}
15950
15951fn uniform_refinement_samples(mesh: &AnalysisMeshArtifact) -> Vec<RefinementIndicatorSample> {
15952    mesh.volume_elements
15953        .iter()
15954        .filter_map(|element| {
15955            Some(RefinementIndicatorSample {
15956                entity_id: element.element_id.clone(),
15957                position_m: element_centroid_m(mesh, &element.node_ids)?,
15958                indicator_value: 1.0,
15959                current_size_m: element_characteristic_size_m(mesh, &element.node_ids)?,
15960            })
15961        })
15962        .collect()
15963}
15964
15965fn shared_node_count(left: &[u32], right: &[u32]) -> usize {
15966    left.iter().filter(|node| right.contains(node)).count()
15967}
15968
15969fn element_centroid_m(mesh: &AnalysisMeshArtifact, node_ids: &[u32]) -> Option<[f64; 3]> {
15970    if node_ids.is_empty() {
15971        return None;
15972    }
15973    let mut centroid = [0.0_f64; 3];
15974    for node_id in node_ids {
15975        let node = mesh.nodes.iter().find(|node| node.node_id == *node_id)?;
15976        for (axis, value) in node.coordinates_m.iter().enumerate() {
15977            centroid[axis] += *value;
15978        }
15979    }
15980    for value in &mut centroid {
15981        *value /= node_ids.len() as f64;
15982    }
15983    Some(centroid)
15984}
15985
15986fn element_characteristic_size_m(mesh: &AnalysisMeshArtifact, node_ids: &[u32]) -> Option<f64> {
15987    let mut max_edge = 0.0_f64;
15988    for left_index in 0..node_ids.len() {
15989        for right_index in (left_index + 1)..node_ids.len() {
15990            let left = mesh
15991                .nodes
15992                .iter()
15993                .find(|node| node.node_id == node_ids[left_index])?
15994                .coordinates_m;
15995            let right = mesh
15996                .nodes
15997                .iter()
15998                .find(|node| node.node_id == node_ids[right_index])?
15999                .coordinates_m;
16000            max_edge = max_edge.max(vector_distance_m(left, right));
16001        }
16002    }
16003    (max_edge > 0.0).then_some(max_edge)
16004}
16005
16006fn vector_distance_m(left: [f64; 3], right: [f64; 3]) -> f64 {
16007    ((left[0] - right[0]).powi(2) + (left[1] - right[1]).powi(2) + (left[2] - right[2]).powi(2))
16008        .sqrt()
16009}
16010
16011fn resolve_analysis_mesh_artifact(
16012    analysis_mesh_artifact_path: Option<&str>,
16013    operation: &'static str,
16014    op_version: &'static str,
16015    context: &OperationContext,
16016) -> Result<Option<AnalysisMeshArtifact>, OperationErrorEnvelope> {
16017    let Some(path) = analysis_mesh_artifact_path else {
16018        return Ok(None);
16019    };
16020    let bytes = fs_read(path).map_err(|err| {
16021        operation_error(
16022            operation,
16023            op_version,
16024            context,
16025            OperationErrorSpec {
16026                error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_READ_FAILED",
16027                error_type: OperationErrorType::Input,
16028                retryable: false,
16029                severity: OperationErrorSeverity::Error,
16030            },
16031            format!("failed to read analysis mesh artifact: {err}"),
16032            BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16033        )
16034    })?;
16035    let payload = serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|err| {
16036        operation_error(
16037            operation,
16038            op_version,
16039            context,
16040            OperationErrorSpec {
16041                error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_PARSE_FAILED",
16042                error_type: OperationErrorType::Input,
16043                retryable: false,
16044                severity: OperationErrorSeverity::Error,
16045            },
16046            format!("failed to parse analysis mesh artifact: {err}"),
16047            BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16048        )
16049    })?;
16050    let mesh_value = payload
16051        .get("mesh")
16052        .cloned()
16053        .unwrap_or_else(|| payload.clone());
16054    let mesh = serde_json::from_value::<AnalysisMeshArtifact>(mesh_value).map_err(|err| {
16055        operation_error(
16056            operation,
16057            op_version,
16058            context,
16059            OperationErrorSpec {
16060                error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_PARSE_FAILED",
16061                error_type: OperationErrorType::Input,
16062                retryable: false,
16063                severity: OperationErrorSeverity::Error,
16064            },
16065            format!("failed to decode analysis mesh artifact: {err}"),
16066            BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16067        )
16068    })?;
16069    let validation_options = analysis_mesh_validation_options_for_loaded_artifact(&payload, &mesh)
16070        .map_err(|err| {
16071            operation_error(
16072                operation,
16073                op_version,
16074                context,
16075                OperationErrorSpec {
16076                    error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_PARSE_FAILED",
16077                    error_type: OperationErrorType::Input,
16078                    retryable: false,
16079                    severity: OperationErrorSeverity::Error,
16080                },
16081                format!("failed to decode analysis mesh options: {err}"),
16082                BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16083            )
16084        })?;
16085    runmat_meshing_core::validate_analysis_mesh_with_options(&mesh, validation_options).map_err(
16086        |err| {
16087            operation_error(
16088                operation,
16089                op_version,
16090                context,
16091                OperationErrorSpec {
16092                    error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_INVALID",
16093                    error_type: OperationErrorType::Validation,
16094                    retryable: false,
16095                    severity: OperationErrorSeverity::Error,
16096                },
16097                format!("analysis mesh artifact failed validation: {err:?}"),
16098                BTreeMap::from([
16099                    ("analysis_mesh_artifact_path".to_string(), path.to_string()),
16100                    (
16101                        "mesh_validation_code".to_string(),
16102                        runmat_meshing_core::analysis_mesh_validation_error_code(&err).to_string(),
16103                    ),
16104                ]),
16105            )
16106        },
16107    )?;
16108    Ok(Some(mesh))
16109}
16110
16111fn resolve_analysis_mesh_validation_evidence_status(
16112    analysis_mesh_artifact_path: Option<&str>,
16113    operation: &'static str,
16114    op_version: &'static str,
16115    context: &OperationContext,
16116) -> Result<MeshValidationEvidenceStatus, OperationErrorEnvelope> {
16117    let Some(path) = analysis_mesh_artifact_path else {
16118        return Ok(MeshValidationEvidenceStatus::NotRequested);
16119    };
16120    let bytes = fs_read(path).map_err(|err| {
16121        operation_error(
16122            operation,
16123            op_version,
16124            context,
16125            OperationErrorSpec {
16126                error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_READ_FAILED",
16127                error_type: OperationErrorType::Input,
16128                retryable: false,
16129                severity: OperationErrorSeverity::Error,
16130            },
16131            format!("failed to read analysis mesh artifact: {err}"),
16132            BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16133        )
16134    })?;
16135    let payload = serde_json::from_slice::<serde_json::Value>(&bytes).map_err(|err| {
16136        operation_error(
16137            operation,
16138            op_version,
16139            context,
16140            OperationErrorSpec {
16141                error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_PARSE_FAILED",
16142                error_type: OperationErrorType::Input,
16143                retryable: false,
16144                severity: OperationErrorSeverity::Error,
16145            },
16146            format!("failed to parse analysis mesh artifact: {err}"),
16147            BTreeMap::from([("analysis_mesh_artifact_path".to_string(), path.to_string())]),
16148        )
16149    })?;
16150    let Some(evidence_path) = payload
16151        .get("mesh_evidence_artifact_path")
16152        .and_then(serde_json::Value::as_str)
16153    else {
16154        return Ok(MeshValidationEvidenceStatus::Missing {
16155            detail: "analysis mesh artifact has no mesh_evidence_artifact_path".to_string(),
16156        });
16157    };
16158    let evidence_bytes = fs_read(evidence_path).map_err(|err| {
16159        operation_error(
16160            operation,
16161            op_version,
16162            context,
16163            OperationErrorSpec {
16164                error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_EVIDENCE_READ_FAILED",
16165                error_type: OperationErrorType::Input,
16166                retryable: false,
16167                severity: OperationErrorSeverity::Error,
16168            },
16169            format!("failed to read analysis mesh evidence artifact: {err}"),
16170            BTreeMap::from([
16171                ("analysis_mesh_artifact_path".to_string(), path.to_string()),
16172                (
16173                    "analysis_mesh_evidence_artifact_path".to_string(),
16174                    evidence_path.to_string(),
16175                ),
16176            ]),
16177        )
16178    })?;
16179    let evidence_payload =
16180        serde_json::from_slice::<serde_json::Value>(&evidence_bytes).map_err(|err| {
16181            operation_error(
16182                operation,
16183                op_version,
16184                context,
16185                OperationErrorSpec {
16186                    error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_EVIDENCE_PARSE_FAILED",
16187                    error_type: OperationErrorType::Input,
16188                    retryable: false,
16189                    severity: OperationErrorSeverity::Error,
16190                },
16191                format!("failed to parse analysis mesh evidence artifact: {err}"),
16192                BTreeMap::from([
16193                    ("analysis_mesh_artifact_path".to_string(), path.to_string()),
16194                    (
16195                        "analysis_mesh_evidence_artifact_path".to_string(),
16196                        evidence_path.to_string(),
16197                    ),
16198                ]),
16199            )
16200        })?;
16201    let Some(validation_value) = evidence_payload
16202        .get("mesh_evidence")
16203        .and_then(|evidence| evidence.get("validation"))
16204        .cloned()
16205    else {
16206        return Ok(MeshValidationEvidenceStatus::Missing {
16207            detail: "mesh evidence artifact has no mesh_evidence.validation payload".to_string(),
16208        });
16209    };
16210    serde_json::from_value::<MeshValidationEvidence>(validation_value)
16211        .map(|validation| MeshValidationEvidenceStatus::Present(Box::new(validation)))
16212        .map_err(|err| {
16213            operation_error(
16214                operation,
16215                op_version,
16216                context,
16217                OperationErrorSpec {
16218                    error_code: "RM.FEA.RUN_LINEAR_STATIC.ANALYSIS_MESH_EVIDENCE_PARSE_FAILED",
16219                    error_type: OperationErrorType::Input,
16220                    retryable: false,
16221                    severity: OperationErrorSeverity::Error,
16222                },
16223                format!("failed to decode analysis mesh evidence validation payload: {err}"),
16224                BTreeMap::from([
16225                    ("analysis_mesh_artifact_path".to_string(), path.to_string()),
16226                    (
16227                        "analysis_mesh_evidence_artifact_path".to_string(),
16228                        evidence_path.to_string(),
16229                    ),
16230                ]),
16231            )
16232        })
16233}
16234
16235fn study_evidence_root() -> PathBuf {
16236    let config = current_fea_runtime_config();
16237    config
16238        .study_artifact_root
16239        .or_else(|| {
16240            std::env::var("RUNMAT_FEA_STUDY_ARTIFACT_ROOT")
16241                .or_else(|_| std::env::var("RUNMAT_ANALYSIS_STUDY_ARTIFACT_ROOT"))
16242                .ok()
16243                .map(PathBuf::from)
16244        })
16245        .unwrap_or_else(|| {
16246            config
16247                .artifact_root
16248                .unwrap_or_else(default_fea_artifact_root)
16249                .join("studies")
16250        })
16251}
16252
16253fn thermo_field_artifact_root() -> PathBuf {
16254    let config = current_fea_runtime_config();
16255    config
16256        .thermo_field_artifact_root
16257        .or_else(|| {
16258            std::env::var("RUNMAT_THERMO_FIELD_ARTIFACT_ROOT")
16259                .ok()
16260                .map(PathBuf::from)
16261        })
16262        .unwrap_or_else(|| {
16263            config
16264                .artifact_root
16265                .unwrap_or_else(default_fea_artifact_root)
16266                .join("thermo-fields")
16267        })
16268}
16269
16270fn persist_study_evidence(
16271    study_fingerprint: &str,
16272    stage: &str,
16273    payload: serde_json::Value,
16274) -> Result<String, String> {
16275    let study_key = study_fingerprint.replace(':', "_");
16276    let root = study_evidence_root().join(study_key);
16277    fs_create_dir_all(&root)
16278        .map_err(|err| format!("failed to create study evidence directory: {err}"))?;
16279    let path = root.join(format!("{stage}.json"));
16280    let bytes = serde_json::to_vec_pretty(&payload)
16281        .map_err(|err| format!("failed to encode study evidence payload: {err}"))?;
16282    atomic_write_bytes(&path, &bytes)?;
16283    Ok(path.display().to_string())
16284}
16285
16286fn atomic_write_bytes(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
16287    let tmp = path.with_extension(format!(
16288        "tmp-{}-{}",
16289        std::process::id(),
16290        Utc::now().timestamp_nanos_opt().unwrap_or_default()
16291    ));
16292    fs_write(&tmp, bytes)
16293        .map_err(|err| format!("failed to write temporary study evidence file: {err}"))?;
16294    fs_rename(&tmp, path).map_err(|err| {
16295        let _ = fs_remove_file(&tmp);
16296        format!("failed to atomically persist study evidence file: {err}")
16297    })
16298}
16299
16300fn fs_create_dir_all(path: impl Into<PathBuf>) -> std::io::Result<()> {
16301    runmat_filesystem::create_dir_all(path.into())
16302}
16303
16304fn fs_read(path: impl Into<PathBuf>) -> std::io::Result<Vec<u8>> {
16305    runmat_filesystem::read(path.into())
16306}
16307
16308fn fs_write(path: impl Into<PathBuf>, bytes: &[u8]) -> std::io::Result<()> {
16309    runmat_filesystem::write(path.into(), bytes)
16310}
16311
16312fn fs_rename(from: impl Into<PathBuf>, to: impl Into<PathBuf>) -> std::io::Result<()> {
16313    runmat_filesystem::rename(from.into(), to.into())
16314}
16315
16316fn fs_remove_file(path: impl Into<PathBuf>) -> std::io::Result<()> {
16317    match runmat_filesystem::remove_file(path.into()) {
16318        Ok(()) => Ok(()),
16319        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
16320        Err(err) => Err(err),
16321    }
16322}
16323
16324fn fs_exists(path: impl Into<PathBuf>) -> std::io::Result<bool> {
16325    match runmat_filesystem::metadata(path.into()) {
16326        Ok(_) => Ok(true),
16327        Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
16328        Err(err) => Err(err),
16329    }
16330}
16331
16332fn fs_read_to_string(path: impl Into<PathBuf>) -> std::io::Result<String> {
16333    runmat_filesystem::read_to_string(path.into())
16334}
16335
16336fn to_fea_prep_context(
16337    context: Option<&AnalysisRunPrepContext>,
16338    calibration_profile: Option<PrepCalibrationProfile>,
16339) -> Option<runmat_analysis_fea::FeaPrepContext> {
16340    context.map(|prep| runmat_analysis_fea::FeaPrepContext {
16341        prepared_mesh_count: prep.prepared_mesh_count,
16342        prepared_node_count: prep.prepared_node_count,
16343        prepared_element_count: prep.prepared_element_count,
16344        mapped_region_count: prep.mapped_region_count,
16345        min_scaled_jacobian: prep.min_scaled_jacobian,
16346        mean_aspect_ratio: prep.mean_aspect_ratio,
16347        inverted_element_count: prep.inverted_element_count,
16348        mapped_load_count: prep.mapped_load_count,
16349        mapped_bc_count: prep.mapped_bc_count,
16350        layout_seed: prep.layout_seed,
16351        topology_dof_multiplier: prep.topology_dof_multiplier,
16352        topology_bandwidth_estimate: prep.topology_bandwidth_estimate,
16353        mapped_region_participation_ratio: prep.mapped_region_participation_ratio,
16354        topology_surface_patch_ratio: prep.topology_surface_patch_ratio,
16355        topology_volume_core_ratio: prep.topology_volume_core_ratio,
16356        topology_mixed_family_ratio: prep.topology_mixed_family_ratio,
16357        topology_region_span_mean: prep.topology_region_span_mean,
16358        topology_region_block_count: prep.topology_region_block_count,
16359        topology_region_mesh_mean: prep.topology_region_mesh_mean,
16360        topology_region_mesh_variance: prep.topology_region_mesh_variance,
16361        topology_triangle_family_ratio: prep.topology_triangle_family_ratio,
16362        topology_quad_family_ratio: prep.topology_quad_family_ratio,
16363        topology_tetrahedron_family_ratio: prep.topology_tetrahedron_family_ratio,
16364        topology_hex_family_ratio: prep.topology_hex_family_ratio,
16365        coordinate_span_x_m: prep.coordinate_span_x_m,
16366        coordinate_span_y_m: prep.coordinate_span_y_m,
16367        coordinate_span_z_m: prep.coordinate_span_z_m,
16368        coordinate_active_dimension_count: prep.coordinate_active_dimension_count,
16369        coordinate_characteristic_length_m: prep.coordinate_characteristic_length_m,
16370        element_geometry_node_count: prep.element_geometry_node_count,
16371        element_geometry_edge_count: prep.element_geometry_edge_count,
16372        mean_element_edge_length_m: prep.mean_element_edge_length_m,
16373        mean_element_area_m2: prep.mean_element_area_m2,
16374        element_geometry_coverage_ratio: prep.element_geometry_coverage_ratio,
16375        reference_element_coordinates_m: prep.reference_element_coordinates_m,
16376        reference_element_area_m2: prep.reference_element_area_m2,
16377        element_topology_sample_element_count: prep.element_topology_sample_element_count,
16378        element_topology_sample_edge_count: prep.element_topology_sample_edge_count,
16379        element_topology_sample_edge_nodes: prep.element_topology_sample_edge_nodes,
16380        element_topology_sample_node_coordinates_m: prep.element_topology_sample_node_coordinates_m,
16381        element_topology_sample_element_edges: prep.element_topology_sample_element_edges,
16382        element_topology_sample_element_orientations: prep
16383            .element_topology_sample_element_orientations,
16384        element_topology_sample_element_areas_m2: prep.element_topology_sample_element_areas_m2,
16385        element_topology_node_coordinates_m: prep.element_topology_node_coordinates_m.clone(),
16386        element_topology_edge_nodes: prep.element_topology_edge_nodes.clone(),
16387        element_topology_element_edges: prep.element_topology_element_edges.clone(),
16388        element_topology_element_orientations: prep.element_topology_element_orientations.clone(),
16389        element_topology_element_areas_m2: prep.element_topology_element_areas_m2.clone(),
16390        calibration_profile_override: calibration_profile.and_then(map_calibration_profile),
16391    })
16392}
16393
16394fn render_topology_from_prep_context(
16395    context: Option<&AnalysisRunPrepContext>,
16396) -> Option<AnalysisRenderTopology> {
16397    let prep = context?;
16398    if prep.element_topology_node_coordinates_m.is_empty()
16399        || prep.element_topology_edge_nodes.is_empty()
16400        || prep.element_topology_element_edges.is_empty()
16401    {
16402        return None;
16403    }
16404
16405    let triangles = prep
16406        .element_topology_element_edges
16407        .iter()
16408        .filter_map(|element_edges| triangle_from_element_edges(element_edges, prep))
16409        .collect::<Vec<_>>();
16410    if triangles.is_empty() {
16411        return None;
16412    }
16413
16414    Some(AnalysisRenderTopology {
16415        schema_version: "analysis_render_topology/v1".to_string(),
16416        source: AnalysisRenderTopologySource::SolverPrep,
16417        meshes: vec![AnalysisRenderMesh {
16418            mesh_id: "solver_surface".to_string(),
16419            vertices: prep.element_topology_node_coordinates_m.clone(),
16420            triangles,
16421            regions: Vec::new(),
16422            vertex_volume_node_indices: (0..prep.element_topology_node_coordinates_m.len())
16423                .map(Some)
16424                .collect(),
16425            triangle_volume_element_indices: Vec::new(),
16426        }],
16427    })
16428}
16429
16430fn render_topology_from_analysis_mesh(
16431    mesh: Option<&AnalysisMeshArtifact>,
16432) -> Option<AnalysisRenderTopology> {
16433    let mesh = mesh?;
16434    if mesh.nodes.is_empty() || mesh.boundary_faces.is_empty() {
16435        return None;
16436    }
16437    if validate_analysis_mesh_solver_field_mapping(mesh).is_err() {
16438        return None;
16439    }
16440
16441    let node_index_by_id = mesh
16442        .nodes
16443        .iter()
16444        .enumerate()
16445        .map(|(index, node)| (node.node_id, index as u32))
16446        .collect::<HashMap<_, _>>();
16447    let volume_index_by_id = mesh
16448        .volume_elements
16449        .iter()
16450        .enumerate()
16451        .map(|(index, element)| (element.element_id.as_str(), index))
16452        .collect::<HashMap<_, _>>();
16453    let face_entries = mesh
16454        .boundary_faces
16455        .iter()
16456        .filter(|face| {
16457            face.kind == runmat_meshing_core::BoundaryElementKind::Tri3 && face.node_ids.len() == 3
16458        })
16459        .filter_map(|face| {
16460            let triangle = [
16461                *node_index_by_id.get(&face.node_ids[0])?,
16462                *node_index_by_id.get(&face.node_ids[1])?,
16463                *node_index_by_id.get(&face.node_ids[2])?,
16464            ];
16465            let volume_element_index = face
16466                .adjacent_volume_element_ids
16467                .iter()
16468                .find_map(|element_id| volume_index_by_id.get(element_id.as_str()).copied());
16469            Some((triangle, volume_element_index, face.region_ids.clone()))
16470        })
16471        .collect::<Vec<_>>();
16472    let mut triangles = Vec::with_capacity(face_entries.len());
16473    let mut triangle_volume_element_indices = Vec::with_capacity(face_entries.len());
16474    let mut region_triangles = BTreeMap::<String, Vec<u32>>::new();
16475    for (triangle_index, (triangle, volume_element_index, region_ids)) in
16476        face_entries.into_iter().enumerate()
16477    {
16478        triangles.push(triangle);
16479        triangle_volume_element_indices.push(volume_element_index);
16480        for region_id in region_ids {
16481            region_triangles
16482                .entry(region_id)
16483                .or_default()
16484                .push(triangle_index as u32);
16485        }
16486    }
16487    if triangles.is_empty() {
16488        return None;
16489    }
16490    let regions = render_regions_from_triangle_indices(region_triangles);
16491
16492    Some(AnalysisRenderTopology {
16493        schema_version: "analysis_render_topology/v1".to_string(),
16494        source: AnalysisRenderTopologySource::AnalysisMesh,
16495        meshes: vec![AnalysisRenderMesh {
16496            mesh_id: "analysis_mesh_boundary".to_string(),
16497            vertices: mesh.nodes.iter().map(|node| node.coordinates_m).collect(),
16498            triangles,
16499            regions,
16500            vertex_volume_node_indices: (0..mesh.nodes.len()).map(Some).collect(),
16501            triangle_volume_element_indices,
16502        }],
16503    })
16504}
16505
16506fn render_regions_from_triangle_indices(
16507    region_triangles: BTreeMap<String, Vec<u32>>,
16508) -> Vec<AnalysisRenderRegion> {
16509    region_triangles
16510        .into_iter()
16511        .filter_map(|(region_id, mut triangle_indices)| {
16512            triangle_indices.sort_unstable();
16513            triangle_indices.dedup();
16514            let mut ranges = Vec::<AnalysisRenderTriangleRange>::new();
16515            for triangle_index in triangle_indices {
16516                if let Some(last) = ranges.last_mut() {
16517                    if last.start.saturating_add(last.count) == triangle_index {
16518                        last.count = last.count.saturating_add(1);
16519                        continue;
16520                    }
16521                }
16522                ranges.push(AnalysisRenderTriangleRange {
16523                    start: triangle_index,
16524                    count: 1,
16525                });
16526            }
16527            if ranges.is_empty() {
16528                return None;
16529            }
16530            Some(AnalysisRenderRegion {
16531                region_id,
16532                label: None,
16533                tag: Some("boundary".to_string()),
16534                triangle_ranges: ranges,
16535            })
16536        })
16537        .collect()
16538}
16539
16540fn triangle_from_element_edges(
16541    element_edges: &[u32; 3],
16542    prep: &AnalysisRunPrepContext,
16543) -> Option<[u32; 3]> {
16544    let mut nodes = Vec::<u32>::with_capacity(3);
16545    for edge_index in element_edges {
16546        let edge = prep.element_topology_edge_nodes.get(*edge_index as usize)?;
16547        for node in edge {
16548            if !nodes.contains(node) {
16549                nodes.push(*node);
16550            }
16551        }
16552    }
16553    if nodes.len() == 3 {
16554        Some([nodes[0], nodes[1], nodes[2]])
16555    } else {
16556        None
16557    }
16558}
16559
16560fn map_calibration_profile(
16561    profile: PrepCalibrationProfile,
16562) -> Option<runmat_analysis_fea::FeaPrepCalibrationProfile> {
16563    match profile {
16564        PrepCalibrationProfile::Auto => None,
16565        PrepCalibrationProfile::Fast => Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Fast),
16566        PrepCalibrationProfile::Balanced => {
16567            Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Balanced)
16568        }
16569        PrepCalibrationProfile::Conservative => {
16570            Some(runmat_analysis_fea::FeaPrepCalibrationProfile::Conservative)
16571        }
16572    }
16573}
16574
16575fn model_thermo_coupling_options(model: &AnalysisModel) -> Option<ThermoMechanicalCouplingOptions> {
16576    let domain = model.thermo_mechanical.as_ref()?;
16577    let expansion = if model.materials.is_empty() {
16578        1.2e-5
16579    } else {
16580        model
16581            .materials
16582            .iter()
16583            .map(|material| material.thermal.expansion_coefficient_per_k.max(0.0))
16584            .sum::<f64>()
16585            / model.materials.len() as f64
16586    };
16587
16588    Some(ThermoMechanicalCouplingOptions {
16589        enabled: domain.enabled,
16590        reference_temperature_k: domain.reference_temperature_k,
16591        applied_temperature_delta_k: domain.applied_temperature_delta_k,
16592        thermal_expansion_coefficient: expansion,
16593        field_artifact_id: domain.field_artifact_id.clone(),
16594        field_source: domain
16595            .field_source
16596            .as_ref()
16597            .map(|source| ThermoFieldSource {
16598                source_id: source.source_id.clone(),
16599                revision: source.revision,
16600                interpolation_mode: source.interpolation_mode.map(|mode| match mode {
16601                    runmat_analysis_core::ThermoFieldInterpolationMode::Linear => {
16602                        ThermoFieldInterpolationMode::Linear
16603                    }
16604                    runmat_analysis_core::ThermoFieldInterpolationMode::Step => {
16605                        ThermoFieldInterpolationMode::Step
16606                    }
16607                }),
16608                expected_region_ids: source.expected_region_ids.clone(),
16609            }),
16610        region_temperature_deltas: domain
16611            .region_temperature_deltas
16612            .iter()
16613            .map(|delta| ThermoRegionTemperatureDelta {
16614                region_id: delta.region_id.clone(),
16615                temperature_delta_k: delta.temperature_delta_k,
16616            })
16617            .collect(),
16618        time_profile: domain
16619            .time_profile
16620            .iter()
16621            .map(|point| ThermoTimeProfilePoint {
16622                normalized_time: point.normalized_time,
16623                scale: point.scale,
16624            })
16625            .collect(),
16626    })
16627}
16628
16629fn model_electro_coupling_options(model: &AnalysisModel) -> Option<ElectroThermalCouplingOptions> {
16630    let domain = model.electro_thermal.as_ref()?;
16631    let electrical_materials: Vec<_> = model
16632        .materials
16633        .iter()
16634        .filter_map(|material| material.electrical.as_ref())
16635        .collect();
16636    let base_conductivity = if electrical_materials.is_empty() {
16637        1.0
16638    } else {
16639        electrical_materials
16640            .iter()
16641            .map(|e| e.conductivity_s_per_m.max(1.0e-12))
16642            .sum::<f64>()
16643            / electrical_materials.len() as f64
16644    };
16645    let resistive_coeff = if electrical_materials.is_empty() {
16646        0.0
16647    } else {
16648        electrical_materials
16649            .iter()
16650            .map(|e| e.resistive_heating_coefficient.max(0.0))
16651            .sum::<f64>()
16652            / electrical_materials.len() as f64
16653    };
16654
16655    Some(ElectroThermalCouplingOptions {
16656        enabled: domain.enabled,
16657        reference_temperature_k: domain.reference_temperature_k,
16658        applied_voltage_v: domain.applied_voltage_v,
16659        base_electrical_conductivity_s_per_m: base_conductivity,
16660        resistive_heating_coefficient: resistive_coeff,
16661        region_conductivity_scales: domain
16662            .region_conductivity_scales
16663            .iter()
16664            .map(|scale| ElectroRegionConductivityScale {
16665                region_id: scale.region_id.clone(),
16666                conductivity_scale: scale.conductivity_scale,
16667            })
16668            .collect(),
16669        time_profile: domain
16670            .time_profile
16671            .iter()
16672            .map(|point| ElectroTimeProfilePoint {
16673                normalized_time: point.normalized_time,
16674                current_scale: point.current_scale,
16675            })
16676            .collect(),
16677    })
16678}
16679
16680fn model_plasticity_constitutive_options(
16681    model: &AnalysisModel,
16682) -> Option<PlasticityConstitutiveOptions> {
16683    let plastic = model
16684        .materials
16685        .iter()
16686        .find_map(|material| material.plastic.as_ref())?;
16687    Some(PlasticityConstitutiveOptions {
16688        enabled: true,
16689        yield_strain: plastic.yield_strain,
16690        hardening_modulus_ratio: plastic.hardening_modulus_ratio,
16691        saturation_exponent: plastic.saturation_exponent,
16692    })
16693}
16694
16695fn model_contact_interface_options(model: &AnalysisModel) -> Option<ContactInterfaceOptions> {
16696    model
16697        .interfaces
16698        .iter()
16699        .filter_map(|interface| match &interface.kind {
16700            AnalysisInterfaceKind::Contact(contact) => Some(ContactInterfaceOptions {
16701                enabled: true,
16702                penalty_stiffness_scale: contact.penalty_stiffness_scale,
16703                max_penetration_ratio: contact.max_penetration_ratio,
16704                friction_coefficient: contact.friction_coefficient,
16705            }),
16706            AnalysisInterfaceKind::FluidStructure(_)
16707            | AnalysisInterfaceKind::ConjugateHeatTransfer(_) => None,
16708        })
16709        .next()
16710}
16711
16712fn to_fea_thermo_mechanical_context(
16713    options: Option<ThermoMechanicalCouplingOptions>,
16714) -> Option<runmat_analysis_fea::FeaThermoMechanicalContext> {
16715    options.map(|tm| runmat_analysis_fea::FeaThermoMechanicalContext {
16716        enabled: tm.enabled,
16717        reference_temperature_k: tm.reference_temperature_k,
16718        applied_temperature_delta_k: tm.applied_temperature_delta_k,
16719        thermal_expansion_coefficient: tm.thermal_expansion_coefficient,
16720        field_source: tm
16721            .field_source
16722            .map(|source| runmat_analysis_fea::FeaThermoFieldSource {
16723                source_id: source.source_id,
16724                revision: source.revision,
16725                interpolation_mode: source.interpolation_mode.map(|mode| match mode {
16726                    contracts::ThermoFieldInterpolationMode::Linear => {
16727                        runmat_analysis_fea::FeaThermoFieldInterpolationMode::Linear
16728                    }
16729                    contracts::ThermoFieldInterpolationMode::Step => {
16730                        runmat_analysis_fea::FeaThermoFieldInterpolationMode::Step
16731                    }
16732                }),
16733                expected_region_ids: source.expected_region_ids,
16734            }),
16735        region_temperature_deltas: tm
16736            .region_temperature_deltas
16737            .into_iter()
16738            .map(
16739                |ThermoRegionTemperatureDelta {
16740                     region_id,
16741                     temperature_delta_k,
16742                 }| runmat_analysis_fea::FeaThermoRegionTemperatureDelta {
16743                    region_id,
16744                    temperature_delta_k,
16745                },
16746            )
16747            .collect(),
16748        time_profile: tm
16749            .time_profile
16750            .into_iter()
16751            .map(
16752                |ThermoTimeProfilePoint {
16753                     normalized_time,
16754                     scale,
16755                 }| runmat_analysis_fea::FeaThermoTimeProfilePoint {
16756                    normalized_time,
16757                    scale,
16758                },
16759            )
16760            .collect(),
16761    })
16762}
16763
16764fn to_fea_electro_thermal_context(
16765    options: Option<ElectroThermalCouplingOptions>,
16766) -> Option<runmat_analysis_fea::FeaElectroThermalContext> {
16767    options.map(|et| runmat_analysis_fea::FeaElectroThermalContext {
16768        enabled: et.enabled,
16769        reference_temperature_k: et.reference_temperature_k,
16770        applied_voltage_v: et.applied_voltage_v,
16771        base_electrical_conductivity_s_per_m: et.base_electrical_conductivity_s_per_m,
16772        resistive_heating_coefficient: et.resistive_heating_coefficient,
16773        region_conductivity_scales: et
16774            .region_conductivity_scales
16775            .into_iter()
16776            .map(
16777                |ElectroRegionConductivityScale {
16778                     region_id,
16779                     conductivity_scale,
16780                 }| runmat_analysis_fea::FeaElectroRegionConductivityScale {
16781                    region_id,
16782                    conductivity_scale,
16783                },
16784            )
16785            .collect(),
16786        time_profile: et
16787            .time_profile
16788            .into_iter()
16789            .map(
16790                |ElectroTimeProfilePoint {
16791                     normalized_time,
16792                     current_scale,
16793                 }| runmat_analysis_fea::FeaElectroTimeProfilePoint {
16794                    normalized_time,
16795                    current_scale,
16796                },
16797            )
16798            .collect(),
16799    })
16800}
16801
16802fn to_fea_plasticity_constitutive_context(
16803    options: Option<PlasticityConstitutiveOptions>,
16804) -> Option<runmat_analysis_fea::FeaPlasticityConstitutiveContext> {
16805    options.map(
16806        |plasticity| runmat_analysis_fea::FeaPlasticityConstitutiveContext {
16807            enabled: plasticity.enabled,
16808            yield_strain: plasticity.yield_strain,
16809            hardening_modulus_ratio: plasticity.hardening_modulus_ratio,
16810            saturation_exponent: plasticity.saturation_exponent,
16811        },
16812    )
16813}
16814
16815fn to_fea_contact_interface_context(
16816    options: Option<ContactInterfaceOptions>,
16817) -> Option<runmat_analysis_fea::FeaContactInterfaceContext> {
16818    options.map(|contact| runmat_analysis_fea::FeaContactInterfaceContext {
16819        enabled: contact.enabled,
16820        penalty_stiffness_scale: contact.penalty_stiffness_scale,
16821        max_penetration_ratio: contact.max_penetration_ratio,
16822        friction_coefficient: contact.friction_coefficient,
16823    })
16824}
16825
16826fn electro_thermal_invalid_options_error_code(operation: &str) -> &'static str {
16827    match operation {
16828        ANALYSIS_RUN_MODAL_OPERATION => "RM.FEA.RUN_MODAL.INVALID_ELECTRO_THERMAL_OPTIONS",
16829        ANALYSIS_RUN_ACOUSTIC_OPERATION => "RM.FEA.RUN_ACOUSTIC.INVALID_ELECTRO_THERMAL_OPTIONS",
16830        ANALYSIS_RUN_TRANSIENT_OPERATION => "RM.FEA.RUN_TRANSIENT.INVALID_ELECTRO_THERMAL_OPTIONS",
16831        ANALYSIS_RUN_NONLINEAR_OPERATION => "RM.FEA.RUN_NONLINEAR.INVALID_ELECTRO_THERMAL_OPTIONS",
16832        ANALYSIS_RUN_OPERATION => "RM.FEA.RUN_LINEAR_STATIC.INVALID_ELECTRO_THERMAL_OPTIONS",
16833        _ => "RM.FEA.RUN.INVALID_ELECTRO_THERMAL_OPTIONS",
16834    }
16835}
16836
16837fn validate_thermo_coupling_options(
16838    model: &AnalysisModel,
16839    options: &ThermoMechanicalCouplingOptions,
16840) -> Result<(), (String, BTreeMap<String, String>)> {
16841    if !options.enabled {
16842        return Ok(());
16843    }
16844    if !options.reference_temperature_k.is_finite() || options.reference_temperature_k <= 0.0 {
16845        return Err((
16846            "thermo coupling requires finite positive reference_temperature_k".to_string(),
16847            BTreeMap::from([(
16848                "reference_temperature_k".to_string(),
16849                options.reference_temperature_k.to_string(),
16850            )]),
16851        ));
16852    }
16853    if !options.applied_temperature_delta_k.is_finite() {
16854        return Err((
16855            "thermo coupling requires finite applied_temperature_delta_k".to_string(),
16856            BTreeMap::from([(
16857                "applied_temperature_delta_k".to_string(),
16858                options.applied_temperature_delta_k.to_string(),
16859            )]),
16860        ));
16861    }
16862    if !options.thermal_expansion_coefficient.is_finite()
16863        || options.thermal_expansion_coefficient < 0.0
16864    {
16865        return Err((
16866            "thermo coupling requires finite non-negative thermal_expansion_coefficient"
16867                .to_string(),
16868            BTreeMap::from([(
16869                "thermal_expansion_coefficient".to_string(),
16870                options.thermal_expansion_coefficient.to_string(),
16871            )]),
16872        ));
16873    }
16874
16875    let mut last_t = -1.0_f64;
16876    for (idx, point) in options.time_profile.iter().enumerate() {
16877        if !point.normalized_time.is_finite()
16878            || point.normalized_time < 0.0
16879            || point.normalized_time > 1.0
16880        {
16881            return Err((
16882                "thermo time_profile normalized_time must be finite and within [0, 1]".to_string(),
16883                BTreeMap::from([
16884                    ("time_profile_index".to_string(), idx.to_string()),
16885                    (
16886                        "normalized_time".to_string(),
16887                        point.normalized_time.to_string(),
16888                    ),
16889                ]),
16890            ));
16891        }
16892        if !point.scale.is_finite() {
16893            return Err((
16894                "thermo time_profile scale must be finite".to_string(),
16895                BTreeMap::from([
16896                    ("time_profile_index".to_string(), idx.to_string()),
16897                    ("scale".to_string(), point.scale.to_string()),
16898                ]),
16899            ));
16900        }
16901        if point.normalized_time + 1.0e-12 < last_t {
16902            return Err((
16903                "thermo time_profile normalized_time must be monotonic non-decreasing".to_string(),
16904                BTreeMap::from([
16905                    ("time_profile_index".to_string(), idx.to_string()),
16906                    (
16907                        "normalized_time".to_string(),
16908                        point.normalized_time.to_string(),
16909                    ),
16910                ]),
16911            ));
16912        }
16913        last_t = point.normalized_time;
16914    }
16915
16916    let model_region_ids = model
16917        .material_assignments
16918        .iter()
16919        .map(|assignment| assignment.region_id.as_str())
16920        .collect::<HashSet<_>>();
16921
16922    for delta in &options.region_temperature_deltas {
16923        if !delta.temperature_delta_k.is_finite() {
16924            return Err((
16925                "thermo region_temperature_deltas must use finite temperature_delta_k".to_string(),
16926                BTreeMap::from([
16927                    ("region_id".to_string(), delta.region_id.clone()),
16928                    (
16929                        "temperature_delta_k".to_string(),
16930                        delta.temperature_delta_k.to_string(),
16931                    ),
16932                ]),
16933            ));
16934        }
16935    }
16936
16937    if let Some(source) = options.field_source.as_ref() {
16938        if source.source_id.trim().is_empty() {
16939            return Err((
16940                "thermo field_source requires a non-empty source_id".to_string(),
16941                BTreeMap::new(),
16942            ));
16943        }
16944        for expected_region in &source.expected_region_ids {
16945            if !model_region_ids.contains(expected_region.as_str()) {
16946                return Err((
16947                    "thermo field_source expected_region_ids must exist in model material assignments"
16948                        .to_string(),
16949                    BTreeMap::from([("region_id".to_string(), expected_region.clone())]),
16950                ));
16951            }
16952        }
16953    }
16954
16955    Ok(())
16956}
16957
16958fn validate_electro_coupling_options(
16959    model: &AnalysisModel,
16960    options: &ElectroThermalCouplingOptions,
16961) -> Result<(), (String, BTreeMap<String, String>)> {
16962    if !options.enabled {
16963        return Ok(());
16964    }
16965    if !options.reference_temperature_k.is_finite() || options.reference_temperature_k <= 0.0 {
16966        return Err((
16967            "electro-thermal coupling requires finite positive reference_temperature_k".to_string(),
16968            BTreeMap::from([(
16969                "reference_temperature_k".to_string(),
16970                options.reference_temperature_k.to_string(),
16971            )]),
16972        ));
16973    }
16974    if !options.applied_voltage_v.is_finite() {
16975        return Err((
16976            "electro-thermal coupling requires finite applied_voltage_v".to_string(),
16977            BTreeMap::from([(
16978                "applied_voltage_v".to_string(),
16979                options.applied_voltage_v.to_string(),
16980            )]),
16981        ));
16982    }
16983    if !options.base_electrical_conductivity_s_per_m.is_finite()
16984        || options.base_electrical_conductivity_s_per_m <= 0.0
16985    {
16986        return Err((
16987            "electro-thermal coupling requires finite positive base_electrical_conductivity_s_per_m"
16988                .to_string(),
16989            BTreeMap::from([(
16990                "base_electrical_conductivity_s_per_m".to_string(),
16991                options.base_electrical_conductivity_s_per_m.to_string(),
16992            )]),
16993        ));
16994    }
16995    if !options.resistive_heating_coefficient.is_finite()
16996        || options.resistive_heating_coefficient < 0.0
16997    {
16998        return Err((
16999            "electro-thermal coupling requires finite non-negative resistive_heating_coefficient"
17000                .to_string(),
17001            BTreeMap::from([(
17002                "resistive_heating_coefficient".to_string(),
17003                options.resistive_heating_coefficient.to_string(),
17004            )]),
17005        ));
17006    }
17007    let mut last_t = -1.0_f64;
17008    for (idx, point) in options.time_profile.iter().enumerate() {
17009        if !point.normalized_time.is_finite()
17010            || point.normalized_time < 0.0
17011            || point.normalized_time > 1.0
17012        {
17013            return Err((
17014                "electro time_profile normalized_time must be finite and within [0, 1]".to_string(),
17015                BTreeMap::from([
17016                    ("time_profile_index".to_string(), idx.to_string()),
17017                    (
17018                        "normalized_time".to_string(),
17019                        point.normalized_time.to_string(),
17020                    ),
17021                ]),
17022            ));
17023        }
17024        if !point.current_scale.is_finite() {
17025            return Err((
17026                "electro time_profile current_scale must be finite".to_string(),
17027                BTreeMap::from([
17028                    ("time_profile_index".to_string(), idx.to_string()),
17029                    ("current_scale".to_string(), point.current_scale.to_string()),
17030                ]),
17031            ));
17032        }
17033        if point.normalized_time + 1.0e-12 < last_t {
17034            return Err((
17035                "electro time_profile normalized_time must be monotonic non-decreasing".to_string(),
17036                BTreeMap::from([
17037                    ("time_profile_index".to_string(), idx.to_string()),
17038                    (
17039                        "normalized_time".to_string(),
17040                        point.normalized_time.to_string(),
17041                    ),
17042                ]),
17043            ));
17044        }
17045        last_t = point.normalized_time;
17046    }
17047    let model_region_ids = model
17048        .material_assignments
17049        .iter()
17050        .map(|assignment| assignment.region_id.as_str())
17051        .collect::<HashSet<_>>();
17052    for scale in &options.region_conductivity_scales {
17053        if !scale.conductivity_scale.is_finite() || scale.conductivity_scale <= 0.0 {
17054            return Err((
17055                "electro region_conductivity_scales must use finite positive conductivity_scale"
17056                    .to_string(),
17057                BTreeMap::from([
17058                    ("region_id".to_string(), scale.region_id.clone()),
17059                    (
17060                        "conductivity_scale".to_string(),
17061                        scale.conductivity_scale.to_string(),
17062                    ),
17063                ]),
17064            ));
17065        }
17066        if !model_region_ids.is_empty() && !model_region_ids.contains(scale.region_id.as_str()) {
17067            return Err((
17068                "electro region_conductivity_scales region_id must exist in model material assignments"
17069                    .to_string(),
17070                BTreeMap::from([("region_id".to_string(), scale.region_id.clone())]),
17071            ));
17072        }
17073    }
17074    Ok(())
17075}
17076
17077fn validate_plasticity_constitutive_options(
17078    options: &PlasticityConstitutiveOptions,
17079) -> Result<(), (String, BTreeMap<String, String>)> {
17080    if !options.enabled {
17081        return Ok(());
17082    }
17083    if !options.yield_strain.is_finite() || options.yield_strain <= 0.0 {
17084        return Err((
17085            "plasticity constitutive model requires finite positive yield_strain".to_string(),
17086            BTreeMap::from([("yield_strain".to_string(), options.yield_strain.to_string())]),
17087        ));
17088    }
17089    if !options.hardening_modulus_ratio.is_finite() || options.hardening_modulus_ratio < 0.0 {
17090        return Err((
17091            "plasticity constitutive model requires finite non-negative hardening_modulus_ratio"
17092                .to_string(),
17093            BTreeMap::from([(
17094                "hardening_modulus_ratio".to_string(),
17095                options.hardening_modulus_ratio.to_string(),
17096            )]),
17097        ));
17098    }
17099    if !options.saturation_exponent.is_finite() || options.saturation_exponent < 0.0 {
17100        return Err((
17101            "plasticity constitutive model requires finite non-negative saturation_exponent"
17102                .to_string(),
17103            BTreeMap::from([(
17104                "saturation_exponent".to_string(),
17105                options.saturation_exponent.to_string(),
17106            )]),
17107        ));
17108    }
17109    Ok(())
17110}
17111
17112fn validate_contact_interface_options(
17113    options: &ContactInterfaceOptions,
17114) -> Result<(), (String, BTreeMap<String, String>)> {
17115    if !options.enabled {
17116        return Ok(());
17117    }
17118    if !options.penalty_stiffness_scale.is_finite() || options.penalty_stiffness_scale <= 0.0 {
17119        return Err((
17120            "contact interface model requires finite positive penalty_stiffness_scale".to_string(),
17121            BTreeMap::from([(
17122                "penalty_stiffness_scale".to_string(),
17123                options.penalty_stiffness_scale.to_string(),
17124            )]),
17125        ));
17126    }
17127    if !options.max_penetration_ratio.is_finite() || options.max_penetration_ratio < 0.0 {
17128        return Err((
17129            "contact interface model requires finite non-negative max_penetration_ratio"
17130                .to_string(),
17131            BTreeMap::from([(
17132                "max_penetration_ratio".to_string(),
17133                options.max_penetration_ratio.to_string(),
17134            )]),
17135        ));
17136    }
17137    if !options.friction_coefficient.is_finite() || options.friction_coefficient < 0.0 {
17138        return Err((
17139            "contact interface model requires finite non-negative friction_coefficient".to_string(),
17140            BTreeMap::from([(
17141                "friction_coefficient".to_string(),
17142                options.friction_coefficient.to_string(),
17143            )]),
17144        ));
17145    }
17146    Ok(())
17147}
17148
17149fn validate_coupled_flow_interfaces(
17150    model: &AnalysisModel,
17151    family: &str,
17152) -> Result<(), (String, BTreeMap<String, String>)> {
17153    for interface in &model.interfaces {
17154        match &interface.kind {
17155            AnalysisInterfaceKind::Contact(_) => {
17156                return Err((
17157                    format!(
17158                        "{family} coupling does not accept structural contact interfaces as fluid/thermal interface mappings"
17159                    ),
17160                    BTreeMap::from([
17161                        ("interface_id".to_string(), interface.interface_id.clone()),
17162                        (
17163                            "primary_region_id".to_string(),
17164                            interface.primary_region_id.clone(),
17165                        ),
17166                        (
17167                            "secondary_region_id".to_string(),
17168                            interface.secondary_region_id.clone(),
17169                        ),
17170                        ("interface_kind".to_string(), "contact".to_string()),
17171                    ]),
17172                ));
17173            }
17174            AnalysisInterfaceKind::FluidStructure(fluid_structure) => {
17175                if family != "FSI" {
17176                    return Err((
17177                        format!(
17178                            "{family} coupling does not accept fluid-structure interfaces as fluid/thermal interface mappings"
17179                        ),
17180                        BTreeMap::from([
17181                            ("interface_id".to_string(), interface.interface_id.clone()),
17182                            (
17183                                "primary_region_id".to_string(),
17184                                interface.primary_region_id.clone(),
17185                            ),
17186                            (
17187                                "secondary_region_id".to_string(),
17188                                interface.secondary_region_id.clone(),
17189                            ),
17190                            ("interface_kind".to_string(), "fluid_structure".to_string()),
17191                        ]),
17192                    ));
17193                }
17194                if !fluid_structure.normal_stiffness_pa_per_m.is_finite()
17195                    || fluid_structure.normal_stiffness_pa_per_m <= 0.0
17196                {
17197                    return Err((
17198                        format!(
17199                            "{family} fluid-structure interface requires finite positive normal_stiffness_pa_per_m"
17200                        ),
17201                        BTreeMap::from([
17202                            ("interface_id".to_string(), interface.interface_id.clone()),
17203                            (
17204                                "normal_stiffness_pa_per_m".to_string(),
17205                                fluid_structure.normal_stiffness_pa_per_m.to_string(),
17206                            ),
17207                        ]),
17208                    ));
17209                }
17210                if !fluid_structure.damping_ratio.is_finite() || fluid_structure.damping_ratio < 0.0
17211                {
17212                    return Err((
17213                        format!(
17214                            "{family} fluid-structure interface requires finite non-negative damping_ratio"
17215                        ),
17216                        BTreeMap::from([
17217                            ("interface_id".to_string(), interface.interface_id.clone()),
17218                            (
17219                                "damping_ratio".to_string(),
17220                                fluid_structure.damping_ratio.to_string(),
17221                            ),
17222                        ]),
17223                    ));
17224                }
17225                if !fluid_structure.relaxation_factor.is_finite()
17226                    || fluid_structure.relaxation_factor <= 0.0
17227                    || fluid_structure.relaxation_factor > 1.0
17228                {
17229                    return Err((
17230                        format!(
17231                            "{family} fluid-structure interface requires relaxation_factor in (0, 1]"
17232                        ),
17233                        BTreeMap::from([
17234                            ("interface_id".to_string(), interface.interface_id.clone()),
17235                            (
17236                                "relaxation_factor".to_string(),
17237                                fluid_structure.relaxation_factor.to_string(),
17238                            ),
17239                        ]),
17240                    ));
17241                }
17242            }
17243            AnalysisInterfaceKind::ConjugateHeatTransfer(cht_interface) => {
17244                if family != "CHT" {
17245                    return Err((
17246                        format!(
17247                            "{family} coupling does not accept conjugate heat-transfer interfaces as fluid/structure interface mappings"
17248                        ),
17249                        BTreeMap::from([
17250                            ("interface_id".to_string(), interface.interface_id.clone()),
17251                            (
17252                                "primary_region_id".to_string(),
17253                                interface.primary_region_id.clone(),
17254                            ),
17255                            (
17256                                "secondary_region_id".to_string(),
17257                                interface.secondary_region_id.clone(),
17258                            ),
17259                            (
17260                                "interface_kind".to_string(),
17261                                "conjugate_heat_transfer".to_string(),
17262                            ),
17263                        ]),
17264                    ));
17265                }
17266                if !cht_interface.thermal_conductance_w_per_m2k.is_finite()
17267                    || cht_interface.thermal_conductance_w_per_m2k <= 0.0
17268                {
17269                    return Err((
17270                        format!(
17271                            "{family} conjugate heat-transfer interface requires finite positive thermal_conductance_w_per_m2k"
17272                        ),
17273                        BTreeMap::from([
17274                            ("interface_id".to_string(), interface.interface_id.clone()),
17275                            (
17276                                "thermal_conductance_w_per_m2k".to_string(),
17277                                cht_interface.thermal_conductance_w_per_m2k.to_string(),
17278                            ),
17279                        ]),
17280                    ));
17281                }
17282                if !cht_interface.contact_resistance_m2k_per_w.is_finite()
17283                    || cht_interface.contact_resistance_m2k_per_w < 0.0
17284                {
17285                    return Err((
17286                        format!(
17287                            "{family} conjugate heat-transfer interface requires finite non-negative contact_resistance_m2k_per_w"
17288                        ),
17289                        BTreeMap::from([
17290                            ("interface_id".to_string(), interface.interface_id.clone()),
17291                            (
17292                                "contact_resistance_m2k_per_w".to_string(),
17293                                cht_interface.contact_resistance_m2k_per_w.to_string(),
17294                            ),
17295                        ]),
17296                    ));
17297                }
17298                if !cht_interface.relaxation_factor.is_finite()
17299                    || cht_interface.relaxation_factor <= 0.0
17300                    || cht_interface.relaxation_factor > 1.0
17301                {
17302                    return Err((
17303                        format!(
17304                            "{family} conjugate heat-transfer interface requires relaxation_factor in (0, 1]"
17305                        ),
17306                        BTreeMap::from([
17307                            ("interface_id".to_string(), interface.interface_id.clone()),
17308                            (
17309                                "relaxation_factor".to_string(),
17310                                cht_interface.relaxation_factor.to_string(),
17311                            ),
17312                        ]),
17313                    ));
17314                }
17315            }
17316        }
17317    }
17318    Ok(())
17319}
17320
17321#[derive(Debug, Clone, Deserialize)]
17322struct ThermoFieldArtifact {
17323    schema_version: String,
17324    source_geometry_id: String,
17325    source_geometry_revision: u32,
17326    #[serde(default)]
17327    artifact_status: Option<String>,
17328    #[serde(default)]
17329    approved_by: Option<String>,
17330    #[serde(default)]
17331    payload_hash: Option<String>,
17332    #[serde(default)]
17333    signature: Option<String>,
17334    #[serde(default)]
17335    field_source: Option<ThermoFieldSource>,
17336    #[serde(default)]
17337    region_temperature_deltas: Vec<ThermoRegionTemperatureDelta>,
17338    #[serde(default)]
17339    time_profile: Vec<ThermoTimeProfilePoint>,
17340}
17341
17342fn thermo_field_payload_hash(artifact: &ThermoFieldArtifact) -> String {
17343    let source = artifact.field_source.as_ref();
17344    let source_id = source.map(|s| s.source_id.as_str()).unwrap_or("");
17345    let source_revision = source.map(|s| s.revision).unwrap_or(0);
17346    let interpolation = source
17347        .and_then(|s| s.interpolation_mode)
17348        .map(|mode| match mode {
17349            ThermoFieldInterpolationMode::Linear => "linear",
17350            ThermoFieldInterpolationMode::Step => "step",
17351        })
17352        .unwrap_or("");
17353    let expected_regions = source
17354        .map(|s| s.expected_region_ids.join(","))
17355        .unwrap_or_default();
17356    let region_terms = artifact
17357        .region_temperature_deltas
17358        .iter()
17359        .map(|delta| {
17360            format!(
17361                "{}:{:016x}",
17362                delta.region_id,
17363                delta.temperature_delta_k.to_bits()
17364            )
17365        })
17366        .collect::<Vec<_>>()
17367        .join(",");
17368    let time_terms = artifact
17369        .time_profile
17370        .iter()
17371        .map(|point| {
17372            format!(
17373                "{:016x}:{:016x}",
17374                point.normalized_time.to_bits(),
17375                point.scale.to_bits()
17376            )
17377        })
17378        .collect::<Vec<_>>()
17379        .join(",");
17380    let canonical = format!(
17381        "{}|{}|{}|{}|{}|{}|{}|{}|{}",
17382        artifact.schema_version,
17383        artifact.source_geometry_id,
17384        artifact.source_geometry_revision,
17385        source_id,
17386        source_revision,
17387        interpolation,
17388        expected_regions,
17389        region_terms,
17390        time_terms
17391    );
17392    let mut hasher = Sha256::new();
17393    hasher.update(canonical.as_bytes());
17394    format!("sha256:{:x}", hasher.finalize())
17395}
17396
17397fn thermo_field_signature(payload_hash: &str, approved_by: &str, signing_key: &str) -> String {
17398    let mut hasher = Sha256::new();
17399    hasher.update(format!("{payload_hash}:{approved_by}:{signing_key}").as_bytes());
17400    format!("sigv1:sha256:{:x}", hasher.finalize())
17401}
17402
17403fn resolve_thermo_coupling_options(
17404    model: &AnalysisModel,
17405    options: Option<ThermoMechanicalCouplingOptions>,
17406    operation: &'static str,
17407    op_version: &'static str,
17408    context: &OperationContext,
17409) -> Result<Option<ThermoMechanicalCouplingOptions>, OperationErrorEnvelope> {
17410    let Some(mut options) = options else {
17411        return Ok(None);
17412    };
17413    let Some(field_artifact_id) = options.field_artifact_id.as_deref() else {
17414        return Ok(Some(options));
17415    };
17416
17417    let root = thermo_field_artifact_root();
17418    let path = root.join(format!("{field_artifact_id}.json"));
17419    if !fs_exists(&path).map_err(|err| {
17420        operation_error(
17421            operation,
17422            op_version,
17423            context,
17424            OperationErrorSpec {
17425                error_code: "RM.FEA.RUN_THERMO_FIELD.STORE_FAILED",
17426                error_type: OperationErrorType::Internal,
17427                retryable: true,
17428                severity: OperationErrorSeverity::Error,
17429            },
17430            format!("failed to inspect thermo field artifact: {err}"),
17431            BTreeMap::from([
17432                (
17433                    "thermo_field_artifact_id".to_string(),
17434                    field_artifact_id.to_string(),
17435                ),
17436                (
17437                    "thermo_field_artifact_path".to_string(),
17438                    path.display().to_string(),
17439                ),
17440            ]),
17441        )
17442    })? {
17443        return Err(operation_error(
17444            operation,
17445            op_version,
17446            context,
17447            OperationErrorSpec {
17448                error_code: "RM.FEA.RUN_THERMO_FIELD.NOT_FOUND",
17449                error_type: OperationErrorType::Input,
17450                retryable: false,
17451                severity: OperationErrorSeverity::Error,
17452            },
17453            format!(
17454                "thermo field artifact '{}' was not found",
17455                field_artifact_id
17456            ),
17457            BTreeMap::from([
17458                (
17459                    "thermo_field_artifact_id".to_string(),
17460                    field_artifact_id.to_string(),
17461                ),
17462                (
17463                    "thermo_field_artifact_path".to_string(),
17464                    path.display().to_string(),
17465                ),
17466            ]),
17467        ));
17468    }
17469
17470    let raw = fs_read_to_string(&path).map_err(|err| {
17471        operation_error(
17472            operation,
17473            op_version,
17474            context,
17475            OperationErrorSpec {
17476                error_code: "RM.FEA.RUN_THERMO_FIELD.STORE_FAILED",
17477                error_type: OperationErrorType::Internal,
17478                retryable: true,
17479                severity: OperationErrorSeverity::Error,
17480            },
17481            format!("failed to read thermo field artifact: {err}"),
17482            BTreeMap::from([(
17483                "thermo_field_artifact_path".to_string(),
17484                path.display().to_string(),
17485            )]),
17486        )
17487    })?;
17488    let artifact: ThermoFieldArtifact = serde_json::from_str(&raw).map_err(|err| {
17489        operation_error(
17490            operation,
17491            op_version,
17492            context,
17493            OperationErrorSpec {
17494                error_code: "RM.FEA.RUN_THERMO_FIELD.INVALID",
17495                error_type: OperationErrorType::Validation,
17496                retryable: false,
17497                severity: OperationErrorSeverity::Error,
17498            },
17499            format!("invalid thermo field artifact payload: {err}"),
17500            BTreeMap::from([(
17501                "thermo_field_artifact_path".to_string(),
17502                path.display().to_string(),
17503            )]),
17504        )
17505    })?;
17506
17507    if artifact.schema_version != "fea_thermo_field_artifact/v1"
17508        && artifact.schema_version != "analysis_thermo_field_artifact/v1"
17509    {
17510        return Err(operation_error(
17511            operation,
17512            op_version,
17513            context,
17514            OperationErrorSpec {
17515                error_code: "RM.FEA.RUN_THERMO_FIELD.SCHEMA_UNSUPPORTED",
17516                error_type: OperationErrorType::Validation,
17517                retryable: false,
17518                severity: OperationErrorSeverity::Error,
17519            },
17520            format!(
17521                "thermo field artifact schema '{}' is not supported",
17522                artifact.schema_version
17523            ),
17524            BTreeMap::from([
17525                (
17526                    "thermo_field_artifact_id".to_string(),
17527                    field_artifact_id.to_string(),
17528                ),
17529                (
17530                    "thermo_field_artifact_schema".to_string(),
17531                    artifact.schema_version.clone(),
17532                ),
17533            ]),
17534        ));
17535    }
17536
17537    if artifact.source_geometry_id != model.geometry_id
17538        || artifact.source_geometry_revision != model.geometry_revision
17539    {
17540        return Err(operation_error(
17541            operation,
17542            op_version,
17543            context,
17544            OperationErrorSpec {
17545                error_code: "RM.FEA.RUN_THERMO_FIELD.MISMATCH",
17546                error_type: OperationErrorType::Validation,
17547                retryable: false,
17548                severity: OperationErrorSeverity::Error,
17549            },
17550            "thermo field artifact geometry lineage does not match FEA model",
17551            BTreeMap::from([
17552                (
17553                    "thermo_field_artifact_id".to_string(),
17554                    field_artifact_id.to_string(),
17555                ),
17556                ("model_geometry_id".to_string(), model.geometry_id.clone()),
17557                (
17558                    "model_geometry_revision".to_string(),
17559                    model.geometry_revision.to_string(),
17560                ),
17561                (
17562                    "artifact_geometry_id".to_string(),
17563                    artifact.source_geometry_id.clone(),
17564                ),
17565                (
17566                    "artifact_geometry_revision".to_string(),
17567                    artifact.source_geometry_revision.to_string(),
17568                ),
17569            ]),
17570        ));
17571    }
17572
17573    let expected_hash = thermo_field_payload_hash(&artifact);
17574    if artifact.payload_hash.as_deref() != Some(expected_hash.as_str()) {
17575        return Err(operation_error(
17576            operation,
17577            op_version,
17578            context,
17579            OperationErrorSpec {
17580                error_code: "RM.FEA.RUN_THERMO_FIELD.DIGEST_MISMATCH",
17581                error_type: OperationErrorType::Validation,
17582                retryable: false,
17583                severity: OperationErrorSeverity::Error,
17584            },
17585            "thermo field artifact payload hash does not match payload contents",
17586            BTreeMap::from([
17587                (
17588                    "thermo_field_artifact_id".to_string(),
17589                    field_artifact_id.to_string(),
17590                ),
17591                ("expected_payload_hash".to_string(), expected_hash),
17592                (
17593                    "artifact_payload_hash".to_string(),
17594                    artifact.payload_hash.clone().unwrap_or_default(),
17595                ),
17596            ]),
17597        ));
17598    }
17599
17600    if matches!(artifact.artifact_status.as_deref(), Some("approved")) {
17601        let Some(approved_by) = artifact.approved_by.as_deref() else {
17602            return Err(operation_error(
17603                operation,
17604                op_version,
17605                context,
17606                OperationErrorSpec {
17607                    error_code: "RM.FEA.RUN_THERMO_FIELD.APPROVER_MISSING",
17608                    error_type: OperationErrorType::Validation,
17609                    retryable: false,
17610                    severity: OperationErrorSeverity::Error,
17611                },
17612                "approved thermo field artifact is missing approved_by",
17613                BTreeMap::from([(
17614                    "thermo_field_artifact_id".to_string(),
17615                    field_artifact_id.to_string(),
17616                )]),
17617            ));
17618        };
17619
17620        let allowed = std::env::var("RUNMAT_THERMO_FIELD_ALLOWED_APPROVERS")
17621            .ok()
17622            .map(|value| {
17623                value
17624                    .split(',')
17625                    .map(|entry| entry.trim().to_string())
17626                    .filter(|entry| !entry.is_empty())
17627                    .collect::<Vec<_>>()
17628            })
17629            .unwrap_or_default();
17630        if !allowed.is_empty() && !allowed.iter().any(|entry| entry == approved_by) {
17631            return Err(operation_error(
17632                operation,
17633                op_version,
17634                context,
17635                OperationErrorSpec {
17636                    error_code: "RM.FEA.RUN_THERMO_FIELD.APPROVER_UNAUTHORIZED",
17637                    error_type: OperationErrorType::Validation,
17638                    retryable: false,
17639                    severity: OperationErrorSeverity::Error,
17640                },
17641                "thermo field artifact approver is not authorized",
17642                BTreeMap::from([
17643                    (
17644                        "thermo_field_artifact_id".to_string(),
17645                        field_artifact_id.to_string(),
17646                    ),
17647                    ("approved_by".to_string(), approved_by.to_string()),
17648                ]),
17649            ));
17650        }
17651
17652        let signing_key = std::env::var("RUNMAT_THERMO_FIELD_SIGNING_KEY")
17653            .unwrap_or_else(|_| "runmat-dev-thermo-signing-key".to_string());
17654        let expected_signature = thermo_field_signature(&expected_hash, approved_by, &signing_key);
17655        if artifact.signature.as_deref() != Some(expected_signature.as_str()) {
17656            return Err(operation_error(
17657                operation,
17658                op_version,
17659                context,
17660                OperationErrorSpec {
17661                    error_code: "RM.FEA.RUN_THERMO_FIELD.SIGNATURE_INVALID",
17662                    error_type: OperationErrorType::Validation,
17663                    retryable: false,
17664                    severity: OperationErrorSeverity::Error,
17665                },
17666                "thermo field artifact signature validation failed",
17667                BTreeMap::from([
17668                    (
17669                        "thermo_field_artifact_id".to_string(),
17670                        field_artifact_id.to_string(),
17671                    ),
17672                    ("expected_signature".to_string(), expected_signature),
17673                    (
17674                        "artifact_signature".to_string(),
17675                        artifact.signature.clone().unwrap_or_default(),
17676                    ),
17677                ]),
17678            ));
17679        }
17680    }
17681
17682    options.field_source = artifact.field_source;
17683    options.region_temperature_deltas = artifact.region_temperature_deltas;
17684    options.time_profile = artifact.time_profile;
17685
17686    Ok(Some(options))
17687}
17688
17689fn resolve_run_prep_context(
17690    model: &AnalysisModel,
17691    prep_artifact_id: Option<&str>,
17692    legacy_prep_context: Option<AnalysisRunPrepContext>,
17693    operation: &'static str,
17694    op_version: &'static str,
17695    context: &OperationContext,
17696) -> Result<Option<AnalysisRunPrepContext>, OperationErrorEnvelope> {
17697    if prep_artifact_id.is_none() {
17698        if legacy_prep_context.is_some() {
17699            return Err(operation_error(
17700                operation,
17701                op_version,
17702                context,
17703                OperationErrorSpec {
17704                    error_code: "RM.FEA.RUN_PREP.UNTRUSTED_CONTEXT",
17705                    error_type: OperationErrorType::Input,
17706                    retryable: false,
17707                    severity: OperationErrorSeverity::Error,
17708                },
17709                "FEA run prep_context must be referenced by prep_artifact_id",
17710                BTreeMap::from([("analysis_model_id".to_string(), model.model_id.0.clone())]),
17711            ));
17712        }
17713        return Ok(None);
17714    }
17715
17716    let prep_artifact_id = prep_artifact_id.expect("checked is_some");
17717    let Some(artifact) = crate::geometry::load_prep_artifact(prep_artifact_id).map_err(|err| {
17718        operation_error(
17719            operation,
17720            op_version,
17721            context,
17722            OperationErrorSpec {
17723                error_code: "RM.FEA.RUN_PREP.STORE_FAILED",
17724                error_type: OperationErrorType::Internal,
17725                retryable: true,
17726                severity: OperationErrorSeverity::Error,
17727            },
17728            format!("failed to load prep artifact: {err}"),
17729            BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
17730        )
17731    })?
17732    else {
17733        return Err(operation_error(
17734            operation,
17735            op_version,
17736            context,
17737            OperationErrorSpec {
17738                error_code: "RM.FEA.RUN_PREP.NOT_FOUND",
17739                error_type: OperationErrorType::Input,
17740                retryable: false,
17741                severity: OperationErrorSeverity::Error,
17742            },
17743            format!("prep artifact '{}' was not found", prep_artifact_id),
17744            BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
17745        ));
17746    };
17747
17748    if artifact.schema_version != "geometry_prep_artifact/v1" {
17749        return Err(operation_error(
17750            operation,
17751            op_version,
17752            context,
17753            OperationErrorSpec {
17754                error_code: "RM.FEA.RUN_PREP.SCHEMA_UNSUPPORTED",
17755                error_type: OperationErrorType::Validation,
17756                retryable: false,
17757                severity: OperationErrorSeverity::Error,
17758            },
17759            format!(
17760                "prep artifact schema '{}' is not supported",
17761                artifact.schema_version
17762            ),
17763            BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
17764        ));
17765    }
17766
17767    if artifact.source_geometry_id != model.geometry_id
17768        || artifact.source_geometry_revision != model.geometry_revision
17769    {
17770        crate::geometry::record_prep_mismatch_reject();
17771        return Err(operation_error(
17772            operation,
17773            op_version,
17774            context,
17775            OperationErrorSpec {
17776                error_code: "RM.FEA.RUN_PREP.MISMATCH",
17777                error_type: OperationErrorType::Validation,
17778                retryable: false,
17779                severity: OperationErrorSeverity::Error,
17780            },
17781            "prep artifact geometry lineage does not match FEA model",
17782            BTreeMap::from([
17783                ("prep_artifact_id".to_string(), prep_artifact_id.to_string()),
17784                ("model_geometry_id".to_string(), model.geometry_id.clone()),
17785                (
17786                    "model_geometry_revision".to_string(),
17787                    model.geometry_revision.to_string(),
17788                ),
17789                (
17790                    "prep_geometry_id".to_string(),
17791                    artifact.source_geometry_id.clone(),
17792                ),
17793                (
17794                    "prep_geometry_revision".to_string(),
17795                    artifact.source_geometry_revision.to_string(),
17796                ),
17797            ]),
17798        ));
17799    }
17800
17801    if crate::geometry::require_latest_prep_revision() {
17802        if let Some(latest_revision) = crate::geometry::latest_prep_revision_for_geometry(
17803            &model.geometry_id,
17804        )
17805        .map_err(|err| {
17806            operation_error(
17807                operation,
17808                op_version,
17809                context,
17810                OperationErrorSpec {
17811                    error_code: "RM.FEA.RUN_PREP.STORE_FAILED",
17812                    error_type: OperationErrorType::Internal,
17813                    retryable: true,
17814                    severity: OperationErrorSeverity::Error,
17815                },
17816                format!("failed to evaluate prep artifact freshness: {err}"),
17817                BTreeMap::from([("prep_artifact_id".to_string(), prep_artifact_id.to_string())]),
17818            )
17819        })? {
17820            if artifact.source_geometry_revision < latest_revision {
17821                crate::geometry::record_prep_stale_reject();
17822                return Err(operation_error(
17823                    operation,
17824                    op_version,
17825                    context,
17826                    OperationErrorSpec {
17827                        error_code: "RM.FEA.RUN_PREP.STALE",
17828                        error_type: OperationErrorType::Validation,
17829                        retryable: false,
17830                        severity: OperationErrorSeverity::Error,
17831                    },
17832                    "prep artifact is stale; a newer geometry revision prep artifact exists",
17833                    BTreeMap::from([
17834                        ("prep_artifact_id".to_string(), prep_artifact_id.to_string()),
17835                        (
17836                            "prep_geometry_revision".to_string(),
17837                            artifact.source_geometry_revision.to_string(),
17838                        ),
17839                        (
17840                            "latest_geometry_revision".to_string(),
17841                            latest_revision.to_string(),
17842                        ),
17843                    ]),
17844                ));
17845            }
17846        }
17847    }
17848
17849    let prepared_mesh_count = artifact.prep.prepared_meshes.len();
17850    let prepared_node_count = artifact
17851        .prep
17852        .prepared_meshes
17853        .iter()
17854        .map(|mesh| mesh.node_count as usize)
17855        .sum::<usize>();
17856    let prepared_element_count = artifact
17857        .prep
17858        .prepared_meshes
17859        .iter()
17860        .map(|mesh| mesh.element_count as usize)
17861        .sum::<usize>();
17862    let mesh_count = prepared_mesh_count.max(1) as f64;
17863    let topology_surface_patch_ratio = artifact
17864        .prep
17865        .prepared_meshes
17866        .iter()
17867        .filter(|mesh| mesh.connectivity_class == MeshConnectivityClass::SurfacePatch)
17868        .count() as f64
17869        / mesh_count;
17870    let topology_volume_core_ratio = artifact
17871        .prep
17872        .prepared_meshes
17873        .iter()
17874        .filter(|mesh| mesh.connectivity_class == MeshConnectivityClass::VolumeCore)
17875        .count() as f64
17876        / mesh_count;
17877    let topology_mixed_family_ratio = artifact
17878        .prep
17879        .prepared_meshes
17880        .iter()
17881        .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Mixed)
17882        .count() as f64
17883        / mesh_count;
17884    let topology_triangle_family_ratio = artifact
17885        .prep
17886        .prepared_meshes
17887        .iter()
17888        .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Triangle)
17889        .count() as f64
17890        / mesh_count;
17891    let topology_quad_family_ratio = artifact
17892        .prep
17893        .prepared_meshes
17894        .iter()
17895        .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Quad)
17896        .count() as f64
17897        / mesh_count;
17898    let topology_tetrahedron_family_ratio = artifact
17899        .prep
17900        .prepared_meshes
17901        .iter()
17902        .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Tetrahedron)
17903        .count() as f64
17904        / mesh_count;
17905    let topology_hex_family_ratio = artifact
17906        .prep
17907        .prepared_meshes
17908        .iter()
17909        .filter(|mesh| mesh.element_family_hint == ElementFamilyHint::Hex)
17910        .count() as f64
17911        / mesh_count;
17912    let topology_region_span_mean = artifact
17913        .prep
17914        .prepared_meshes
17915        .iter()
17916        .map(|mesh| mesh.region_span_hint as f64)
17917        .sum::<f64>()
17918        / mesh_count;
17919    let region_block_count = artifact.prep.region_mappings.len().max(1);
17920    let region_mesh_counts = artifact
17921        .prep
17922        .region_mappings
17923        .iter()
17924        .map(|mapping| mapping.prepared_mesh_ids.len().max(1) as f64)
17925        .collect::<Vec<_>>();
17926    let topology_region_mesh_mean = if region_mesh_counts.is_empty() {
17927        1.0
17928    } else {
17929        region_mesh_counts.iter().sum::<f64>() / region_mesh_counts.len() as f64
17930    };
17931    let topology_region_mesh_variance = if region_mesh_counts.len() <= 1 {
17932        0.0
17933    } else {
17934        region_mesh_counts
17935            .iter()
17936            .map(|count| {
17937                let delta = *count - topology_region_mesh_mean;
17938                delta * delta
17939            })
17940            .sum::<f64>()
17941            / region_mesh_counts.len() as f64
17942    };
17943    let topology_dof_multiplier = if model.loads.is_empty() {
17944        1.0
17945    } else {
17946        ((prepared_node_count as f64 / (model.loads.len() as f64 * 3.0)).clamp(1.0, 4.0) * 0.35
17947            + 1.0)
17948            .min(4.0)
17949    };
17950    let topology_bandwidth_estimate = artifact
17951        .prep
17952        .prepared_meshes
17953        .iter()
17954        .map(|mesh| mesh.region_span_hint)
17955        .sum::<u32>()
17956        .clamp(1, 128);
17957    let mapped_region_participation_ratio = if artifact.prep.region_mappings.is_empty() {
17958        0.0
17959    } else {
17960        (artifact
17961            .prep
17962            .region_mappings
17963            .iter()
17964            .filter(|mapping| {
17965                model
17966                    .loads
17967                    .iter()
17968                    .any(|load| load.region_id == mapping.region_id)
17969                    || model
17970                        .boundary_conditions
17971                        .iter()
17972                        .any(|bc| bc.region_id == mapping.region_id)
17973            })
17974            .count() as f64
17975            / artifact.prep.region_mappings.len() as f64)
17976            .clamp(0.0, 1.0)
17977    };
17978    let coordinate_span_x_m = artifact
17979        .prep
17980        .prepared_meshes
17981        .iter()
17982        .map(|mesh| mesh.coordinate_span_m[0])
17983        .fold(0.0_f64, f64::max)
17984        .max(1.0e-12);
17985    let coordinate_span_y_m = artifact
17986        .prep
17987        .prepared_meshes
17988        .iter()
17989        .map(|mesh| mesh.coordinate_span_m[1])
17990        .fold(0.0_f64, f64::max);
17991    let coordinate_span_z_m = artifact
17992        .prep
17993        .prepared_meshes
17994        .iter()
17995        .map(|mesh| mesh.coordinate_span_m[2])
17996        .fold(0.0_f64, f64::max);
17997    let coordinate_active_dimension_count = artifact
17998        .prep
17999        .prepared_meshes
18000        .iter()
18001        .map(|mesh| mesh.coordinate_active_dimension_count as usize)
18002        .max()
18003        .unwrap_or(1)
18004        .max(1);
18005    let (coordinate_length_sum, coordinate_length_weight) = artifact
18006        .prep
18007        .prepared_meshes
18008        .iter()
18009        .filter_map(|mesh| {
18010            let length = mesh.coordinate_characteristic_length_m;
18011            (length.is_finite() && length > 0.0)
18012                .then_some((length, mesh.element_count.max(1) as f64))
18013        })
18014        .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (length, weight)| {
18015            (sum + length * weight, weight_sum + weight)
18016        });
18017    let coordinate_characteristic_length_m = if coordinate_length_weight > 0.0 {
18018        coordinate_length_sum / coordinate_length_weight
18019    } else {
18020        1.0
18021    };
18022    let element_geometry_node_count = artifact
18023        .prep
18024        .prepared_meshes
18025        .iter()
18026        .map(|mesh| mesh.element_geometry_node_count as usize)
18027        .sum::<usize>();
18028    let element_geometry_edge_count = artifact
18029        .prep
18030        .prepared_meshes
18031        .iter()
18032        .map(|mesh| mesh.element_geometry_edge_count as usize)
18033        .sum::<usize>();
18034    let (edge_length_sum, edge_length_weight) = artifact
18035        .prep
18036        .prepared_meshes
18037        .iter()
18038        .filter_map(|mesh| {
18039            let length = mesh.mean_element_edge_length_m;
18040            (length.is_finite() && length > 0.0)
18041                .then_some((length, mesh.element_count.max(1) as f64))
18042        })
18043        .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (length, weight)| {
18044            (sum + length * weight, weight_sum + weight)
18045        });
18046    let mean_element_edge_length_m = if edge_length_weight > 0.0 {
18047        edge_length_sum / edge_length_weight
18048    } else {
18049        0.0
18050    };
18051    let (area_sum, area_weight) = artifact
18052        .prep
18053        .prepared_meshes
18054        .iter()
18055        .filter_map(|mesh| {
18056            let area = mesh.mean_element_area_m2;
18057            (area.is_finite() && area > 0.0).then_some((area, mesh.element_count.max(1) as f64))
18058        })
18059        .fold((0.0_f64, 0.0_f64), |(sum, weight_sum), (area, weight)| {
18060            (sum + area * weight, weight_sum + weight)
18061        });
18062    let mean_element_area_m2 = if area_weight > 0.0 {
18063        area_sum / area_weight
18064    } else {
18065        0.0
18066    };
18067    let (coverage_sum, coverage_weight) = artifact
18068        .prep
18069        .prepared_meshes
18070        .iter()
18071        .map(|mesh| {
18072            (
18073                mesh.element_geometry_coverage_ratio.clamp(0.0, 1.0),
18074                mesh.element_count.max(1) as f64,
18075            )
18076        })
18077        .fold(
18078            (0.0_f64, 0.0_f64),
18079            |(sum, weight_sum), (coverage, weight)| (sum + coverage * weight, weight_sum + weight),
18080        );
18081    let element_geometry_coverage_ratio = if coverage_weight > 0.0 {
18082        coverage_sum / coverage_weight
18083    } else {
18084        0.0
18085    };
18086    let (reference_element_coordinates_m, reference_element_area_m2) = artifact
18087        .prep
18088        .prepared_meshes
18089        .iter()
18090        .find_map(|mesh| {
18091            let area = mesh.reference_element_area_m2;
18092            (area.is_finite() && area > 0.0).then_some((mesh.reference_element_coordinates_m, area))
18093        })
18094        .unwrap_or(([[0.0; 3]; 3], 0.0));
18095    let (
18096        element_topology_sample_element_count,
18097        element_topology_sample_edge_count,
18098        element_topology_sample_edge_nodes,
18099        element_topology_sample_node_coordinates_m,
18100        element_topology_sample_element_edges,
18101        element_topology_sample_element_orientations,
18102        element_topology_sample_element_areas_m2,
18103    ) = artifact
18104        .prep
18105        .prepared_meshes
18106        .iter()
18107        .find_map(|mesh| {
18108            (mesh.element_topology_sample_element_count > 0
18109                && mesh.element_topology_sample_edge_count > 0)
18110                .then_some((
18111                    mesh.element_topology_sample_element_count as usize,
18112                    mesh.element_topology_sample_edge_count as usize,
18113                    mesh.element_topology_sample_edge_nodes,
18114                    mesh.element_topology_sample_node_coordinates_m,
18115                    mesh.element_topology_sample_element_edges,
18116                    mesh.element_topology_sample_element_orientations,
18117                    mesh.element_topology_sample_element_areas_m2,
18118                ))
18119        })
18120        .unwrap_or((
18121            0,
18122            0,
18123            [[0; 2]; 8],
18124            [[0.0; 3]; 8],
18125            [[0; 3]; 4],
18126            [[0; 3]; 4],
18127            [0.0; 4],
18128        ));
18129    let mut element_topology_node_coordinates_m = Vec::<[f64; 3]>::new();
18130    let mut element_topology_edge_nodes = Vec::<[u32; 2]>::new();
18131    let mut element_topology_element_edges = Vec::<[u32; 3]>::new();
18132    let mut element_topology_element_orientations = Vec::<[i8; 3]>::new();
18133    let mut element_topology_element_areas_m2 = Vec::<f64>::new();
18134    let mut node_offset = 0_u32;
18135    let mut edge_offset = 0_u32;
18136    for mesh in &artifact.prep.prepared_meshes {
18137        let node_count = mesh.element_topology_node_coordinates_m.len();
18138        element_topology_node_coordinates_m
18139            .extend(mesh.element_topology_node_coordinates_m.iter().copied());
18140        for edge in &mesh.element_topology_edge_nodes {
18141            if let (Some(left), Some(right)) = (
18142                edge[0].checked_add(node_offset),
18143                edge[1].checked_add(node_offset),
18144            ) {
18145                element_topology_edge_nodes.push([left, right]);
18146            }
18147        }
18148        for element_edges in &mesh.element_topology_element_edges {
18149            if let (Some(a), Some(b), Some(c)) = (
18150                element_edges[0].checked_add(edge_offset),
18151                element_edges[1].checked_add(edge_offset),
18152                element_edges[2].checked_add(edge_offset),
18153            ) {
18154                element_topology_element_edges.push([a, b, c]);
18155            }
18156        }
18157        element_topology_element_orientations
18158            .extend(mesh.element_topology_element_orientations.iter().copied());
18159        element_topology_element_areas_m2
18160            .extend(mesh.element_topology_element_areas_m2.iter().copied());
18161        node_offset = node_offset.saturating_add(node_count as u32);
18162        edge_offset = edge_offset.saturating_add(mesh.element_topology_edge_nodes.len() as u32);
18163    }
18164    let control_volume_cell_count = artifact
18165        .prep
18166        .prepared_meshes
18167        .iter()
18168        .map(|mesh| mesh.control_volume_cell_count as usize)
18169        .sum::<usize>();
18170    let control_volume_face_count = artifact
18171        .prep
18172        .prepared_meshes
18173        .iter()
18174        .map(|mesh| mesh.control_volume_face_count as usize)
18175        .sum::<usize>();
18176    let control_volume_internal_face_count = artifact
18177        .prep
18178        .prepared_meshes
18179        .iter()
18180        .map(|mesh| mesh.control_volume_internal_face_count as usize)
18181        .sum::<usize>();
18182    let control_volume_boundary_face_count = artifact
18183        .prep
18184        .prepared_meshes
18185        .iter()
18186        .map(|mesh| mesh.control_volume_boundary_face_count as usize)
18187        .sum::<usize>();
18188    let (control_volume_coverage_sum, control_volume_coverage_weight) = artifact
18189        .prep
18190        .prepared_meshes
18191        .iter()
18192        .map(|mesh| {
18193            (
18194                mesh.control_volume_connectivity_coverage_ratio
18195                    .clamp(0.0, 1.0),
18196                mesh.element_count.max(1) as f64,
18197            )
18198        })
18199        .fold(
18200            (0.0_f64, 0.0_f64),
18201            |(sum, weight_sum), (coverage, weight)| (sum + coverage * weight, weight_sum + weight),
18202        );
18203    let control_volume_connectivity_coverage_ratio = if control_volume_coverage_weight > 0.0 {
18204        control_volume_coverage_sum / control_volume_coverage_weight
18205    } else {
18206        0.0
18207    };
18208
18209    Ok(Some(AnalysisRunPrepContext {
18210        prepared_mesh_count,
18211        prepared_node_count,
18212        prepared_element_count,
18213        mapped_region_count: artifact.prep.region_mappings.len(),
18214        min_scaled_jacobian: artifact.prep.quality.min_scaled_jacobian,
18215        mean_aspect_ratio: artifact.prep.quality.mean_aspect_ratio,
18216        inverted_element_count: artifact.prep.quality.inverted_element_count as usize,
18217        mapped_load_count: model
18218            .loads
18219            .iter()
18220            .filter(|load| {
18221                artifact
18222                    .prep
18223                    .region_mappings
18224                    .iter()
18225                    .any(|mapping| mapping.region_id == load.region_id)
18226            })
18227            .count(),
18228        mapped_bc_count: model
18229            .boundary_conditions
18230            .iter()
18231            .filter(|bc| {
18232                artifact
18233                    .prep
18234                    .region_mappings
18235                    .iter()
18236                    .any(|mapping| mapping.region_id == bc.region_id)
18237            })
18238            .count(),
18239        layout_seed: {
18240            let mut seed = 1469598103934665603_u64;
18241            for mapping in &artifact.prep.region_mappings {
18242                for byte in mapping.region_id.as_bytes() {
18243                    seed ^= *byte as u64;
18244                    seed = seed.wrapping_mul(1099511628211_u64);
18245                }
18246            }
18247            seed
18248        },
18249        topology_dof_multiplier,
18250        topology_bandwidth_estimate,
18251        mapped_region_participation_ratio,
18252        topology_surface_patch_ratio,
18253        topology_volume_core_ratio,
18254        topology_mixed_family_ratio,
18255        topology_region_span_mean,
18256        topology_region_block_count: region_block_count,
18257        topology_region_mesh_mean,
18258        topology_region_mesh_variance,
18259        topology_triangle_family_ratio,
18260        topology_quad_family_ratio,
18261        topology_tetrahedron_family_ratio,
18262        topology_hex_family_ratio,
18263        coordinate_span_x_m,
18264        coordinate_span_y_m,
18265        coordinate_span_z_m,
18266        coordinate_active_dimension_count,
18267        coordinate_characteristic_length_m,
18268        element_geometry_node_count,
18269        element_geometry_edge_count,
18270        mean_element_edge_length_m,
18271        mean_element_area_m2,
18272        element_geometry_coverage_ratio,
18273        reference_element_coordinates_m,
18274        reference_element_area_m2,
18275        control_volume_cell_count,
18276        control_volume_face_count,
18277        control_volume_internal_face_count,
18278        control_volume_boundary_face_count,
18279        control_volume_connectivity_coverage_ratio,
18280        element_topology_sample_element_count,
18281        element_topology_sample_edge_count,
18282        element_topology_sample_edge_nodes,
18283        element_topology_sample_node_coordinates_m,
18284        element_topology_sample_element_edges,
18285        element_topology_sample_element_orientations,
18286        element_topology_sample_element_areas_m2,
18287        element_topology_node_coordinates_m,
18288        element_topology_edge_nodes,
18289        element_topology_element_edges,
18290        element_topology_element_orientations,
18291        element_topology_element_areas_m2,
18292    }))
18293}
18294
18295fn run_solve_ms(run: &AnalysisRunResult) -> Option<f64> {
18296    for code in [
18297        "FEA_NONLINEAR_COST",
18298        "FEA_TRANSIENT_COST",
18299        "FEA_MODAL_COST",
18300        "FEA_ACOUSTIC_COST",
18301        "FEA_CFD_COST",
18302        "FEA_CHT_COST",
18303        "FEA_FSI_COST",
18304    ] {
18305        if let Some(value) = diagnostic_metric(&run.run.diagnostics, code, "solve_ms") {
18306            return Some(value);
18307        }
18308    }
18309    None
18310}
18311
18312fn diagnostic_metric(
18313    diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
18314    code: &str,
18315    key: &str,
18316) -> Option<f64> {
18317    diagnostics
18318        .iter()
18319        .find(|diag| diag.code == code)
18320        .and_then(|diag| {
18321            diag.message
18322                .split_whitespace()
18323                .find_map(|token| token.strip_prefix(&format!("{key}=")))
18324        })
18325        .and_then(|value| value.parse::<f64>().ok())
18326}
18327
18328fn diagnostic_metric_u64(
18329    diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
18330    code: &str,
18331    key: &str,
18332) -> Option<u64> {
18333    diagnostics
18334        .iter()
18335        .find(|diag| diag.code == code)
18336        .and_then(|diag| {
18337            diag.message
18338                .split_whitespace()
18339                .find_map(|token| token.strip_prefix(&format!("{key}=")))
18340        })
18341        .and_then(|value| value.parse::<u64>().ok())
18342}
18343
18344fn diagnostic_metric_bool(
18345    diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
18346    code: &str,
18347    key: &str,
18348) -> Option<bool> {
18349    diagnostics
18350        .iter()
18351        .find(|diag| diag.code == code)
18352        .and_then(|diag| {
18353            diag.message
18354                .split_whitespace()
18355                .find_map(|token| token.strip_prefix(&format!("{key}=")))
18356        })
18357        .and_then(|value| value.parse::<bool>().ok())
18358}
18359
18360fn diagnostic_metric_string(
18361    diagnostics: &[runmat_analysis_fea::diagnostics::FeaDiagnostic],
18362    code: &str,
18363    key: &str,
18364) -> Option<String> {
18365    diagnostics
18366        .iter()
18367        .find(|diag| diag.code == code)
18368        .and_then(|diag| {
18369            diag.message
18370                .split_whitespace()
18371                .find_map(|token| token.strip_prefix(&format!("{key}=")))
18372        })
18373        .map(|value| value.to_string())
18374}
18375
18376fn percentile(sorted_samples: &[f64], ratio: f64) -> Option<f64> {
18377    if sorted_samples.is_empty() {
18378        return None;
18379    }
18380    let index = ((sorted_samples.len() - 1) as f64 * ratio.clamp(0.0, 1.0)).round() as usize;
18381    sorted_samples.get(index).copied()
18382}
18383
18384fn mean(values: &[f64]) -> f64 {
18385    if values.is_empty() {
18386        0.0
18387    } else {
18388        values.iter().sum::<f64>() / values.len() as f64
18389    }
18390}
18391
18392fn calibration_profile_rate(entries: &[AnalysisRunResult], profile: &str) -> Option<f64> {
18393    let values = entries
18394        .iter()
18395        .filter_map(|run| {
18396            diagnostic_metric_string(&run.run.diagnostics, "FEA_PREP_CALIBRATION", "profile")
18397        })
18398        .collect::<Vec<_>>();
18399    if values.is_empty() {
18400        return None;
18401    }
18402    Some(
18403        values
18404            .iter()
18405            .filter(|value| value.as_str() == profile)
18406            .count() as f64
18407            / values.len() as f64,
18408    )
18409}
18410
18411fn diagnostic_warning_rate(entries: &[AnalysisRunResult], code: &str) -> Option<f64> {
18412    let values = entries
18413        .iter()
18414        .filter_map(|run| {
18415            run.run
18416                .diagnostics
18417                .iter()
18418                .find(|diag| diag.code == code)
18419                .map(|diag| {
18420                    diag.severity
18421                        == runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
18422                })
18423        })
18424        .collect::<Vec<_>>();
18425    if values.is_empty() {
18426        None
18427    } else {
18428        Some(values.iter().filter(|value| **value).count() as f64 / values.len() as f64)
18429    }
18430}
18431
18432fn infer_material_models(geometry: &GeometryAsset) -> Vec<MaterialModel> {
18433    let mut materials = Vec::new();
18434    for evidence in &geometry.source_geometry.material_evidence {
18435        let value = evidence.value.to_ascii_lowercase();
18436        let (material_id, name, youngs_modulus_pa, poisson_ratio) = if value.contains("aluminum") {
18437            ("mat_aluminum", "Aluminum", 69e9, 0.33)
18438        } else if value.contains("steel") {
18439            ("mat_steel", "Steel", 200e9, 0.30)
18440        } else if value.contains("polymer") || value.contains("plastic") {
18441            ("mat_polymer", "Polymer", 3.2e9, 0.37)
18442        } else {
18443            ("mat_inferred", "Inferred Material", 100e9, 0.32)
18444        };
18445
18446        if materials
18447            .iter()
18448            .any(|m: &MaterialModel| m.material_id == material_id)
18449        {
18450            continue;
18451        }
18452        materials.push(MaterialModel {
18453            material_id: material_id.to_string(),
18454            name: name.to_string(),
18455            mechanical: MaterialMechanicalModel {
18456                youngs_modulus_pa,
18457                poisson_ratio,
18458                density_kg_per_m3: 7850.0,
18459            },
18460            thermal: MaterialThermalModel {
18461                reference_temperature_k: 293.15,
18462                modulus_temp_coeff_per_k: -2.5e-4,
18463                ..MaterialThermalModel::default()
18464            },
18465            acoustic: None,
18466            electrical: None,
18467            plastic: None,
18468        });
18469    }
18470
18471    if materials.is_empty() {
18472        materials.push(MaterialModel {
18473            material_id: "mat_default_steel".to_string(),
18474            name: "Steel (Default)".to_string(),
18475            mechanical: MaterialMechanicalModel {
18476                youngs_modulus_pa: 200e9,
18477                poisson_ratio: 0.3,
18478                density_kg_per_m3: 7850.0,
18479            },
18480            thermal: MaterialThermalModel {
18481                reference_temperature_k: 293.15,
18482                modulus_temp_coeff_per_k: -2.5e-4,
18483                ..MaterialThermalModel::default()
18484            },
18485            acoustic: None,
18486            electrical: None,
18487            plastic: None,
18488        });
18489    }
18490
18491    materials
18492}
18493
18494fn select_fixed_region_id(
18495    geometry: &GeometryAsset,
18496    prep_regions: Option<&HashSet<String>>,
18497) -> Option<String> {
18498    geometry
18499        .regions
18500        .iter()
18501        .filter(|region| {
18502            prep_regions
18503                .map(|mapped| mapped.contains(&region.region_id))
18504                .unwrap_or(true)
18505        })
18506        .find(|region| {
18507            let key = format!(
18508                "{} {}",
18509                region.name.to_ascii_lowercase(),
18510                region
18511                    .tag
18512                    .as_deref()
18513                    .unwrap_or_default()
18514                    .to_ascii_lowercase()
18515            );
18516            key.contains("root")
18517                || key.contains("base")
18518                || key.contains("fixed")
18519                || key.contains("mount")
18520        })
18521        .map(|region| region.region_id.clone())
18522}
18523
18524fn select_load_region_id(
18525    geometry: &GeometryAsset,
18526    prep_regions: Option<&HashSet<String>>,
18527) -> Option<String> {
18528    geometry
18529        .regions
18530        .iter()
18531        .filter(|region| {
18532            prep_regions
18533                .map(|mapped| mapped.contains(&region.region_id))
18534                .unwrap_or(true)
18535        })
18536        .find(|region| {
18537            let key = format!(
18538                "{} {}",
18539                region.name.to_ascii_lowercase(),
18540                region
18541                    .tag
18542                    .as_deref()
18543                    .unwrap_or_default()
18544                    .to_ascii_lowercase()
18545            );
18546            key.contains("tip")
18547                || key.contains("load")
18548                || key.contains("force")
18549                || key.contains("free")
18550        })
18551        .map(|region| region.region_id.clone())
18552}
18553
18554#[derive(Debug, Clone, Default)]
18555struct EmSweepSummary {
18556    sweep_count: usize,
18557    resonance_peak_frequency_hz: Option<f64>,
18558    resonance_peak_flux_density: Option<f64>,
18559    resonance_bandwidth_hz: Option<f64>,
18560    resonance_quality_factor: Option<f64>,
18561    resonance_flux_gain: Option<f64>,
18562}
18563
18564fn normalize_em_sweep_frequency_hz(
18565    reference_frequency_hz: f64,
18566    sweep_enabled: bool,
18567    requested: &[f64],
18568) -> Option<Vec<f64>> {
18569    let mut values = if sweep_enabled {
18570        requested.to_vec()
18571    } else {
18572        Vec::new()
18573    };
18574    if values.is_empty() {
18575        values.push(reference_frequency_hz);
18576    }
18577    if !values
18578        .iter()
18579        .all(|frequency_hz| frequency_hz.is_finite() && *frequency_hz > 0.0)
18580    {
18581        return None;
18582    }
18583    values.sort_by(|a, b| a.total_cmp(b));
18584    values.dedup_by(|a, b| (*a - *b).abs() <= 1.0e-9);
18585    Some(values)
18586}
18587
18588fn nearest_frequency_index(frequencies_hz: &[f64], target_hz: f64) -> Option<usize> {
18589    frequencies_hz
18590        .iter()
18591        .enumerate()
18592        .min_by(|(_, a), (_, b)| (*a - target_hz).abs().total_cmp(&(*b - target_hz).abs()))
18593        .map(|(index, _)| index)
18594}
18595
18596fn peak_abs_field_value(field: &runmat_analysis_core::AnalysisField) -> f64 {
18597    field
18598        .as_host_f64()
18599        .map(|values| values.iter().copied().map(f64::abs).fold(0.0_f64, f64::max))
18600        .unwrap_or(0.0)
18601}
18602
18603fn summarize_em_sweep(frequencies_hz: &[f64], peak_flux_density: &[f64]) -> EmSweepSummary {
18604    if frequencies_hz.is_empty() || frequencies_hz.len() != peak_flux_density.len() {
18605        return EmSweepSummary::default();
18606    }
18607    let sweep_count = frequencies_hz.len();
18608    let (peak_index, peak_flux_density_value) = peak_flux_density
18609        .iter()
18610        .copied()
18611        .enumerate()
18612        .max_by(|(_, a), (_, b)| a.total_cmp(b))
18613        .unwrap_or((0, 0.0));
18614    let peak_frequency_hz = frequencies_hz[peak_index];
18615    let min_flux_density_value = peak_flux_density
18616        .iter()
18617        .copied()
18618        .fold(f64::INFINITY, f64::min);
18619    let resonance_flux_gain =
18620        (peak_flux_density_value / min_flux_density_value.max(1.0e-12)).max(1.0);
18621
18622    let half_power = peak_flux_density_value * std::f64::consts::FRAC_1_SQRT_2;
18623    let mut left = peak_index;
18624    while left > 0 && peak_flux_density[left - 1] >= half_power {
18625        left -= 1;
18626    }
18627    let mut right = peak_index;
18628    while right + 1 < peak_flux_density.len() && peak_flux_density[right + 1] >= half_power {
18629        right += 1;
18630    }
18631    let resonance_bandwidth_hz = if right > left {
18632        Some((frequencies_hz[right] - frequencies_hz[left]).max(0.0))
18633    } else {
18634        None
18635    };
18636    let resonance_quality_factor = resonance_bandwidth_hz
18637        .filter(|bandwidth| *bandwidth > 0.0)
18638        .map(|bandwidth| (peak_frequency_hz / bandwidth).max(0.0));
18639
18640    EmSweepSummary {
18641        sweep_count,
18642        resonance_peak_frequency_hz: Some(peak_frequency_hz),
18643        resonance_peak_flux_density: Some(peak_flux_density_value),
18644        resonance_bandwidth_hz,
18645        resonance_quality_factor,
18646        resonance_flux_gain: Some(resonance_flux_gain),
18647    }
18648}
18649
18650fn em_sweep_known_answer_diagnostic(
18651    reference_frequency_hz: f64,
18652    frequencies_hz: &[f64],
18653    metrics: &EmSweepSummary,
18654) -> runmat_analysis_fea::diagnostics::FeaDiagnostic {
18655    let min_frequency_hz = frequencies_hz.iter().copied().fold(f64::INFINITY, f64::min);
18656    let max_frequency_hz = frequencies_hz.iter().copied().fold(0.0_f64, f64::max);
18657    let reference_scale = reference_frequency_hz.abs().max(1.0e-9);
18658    let reference_frequency_in_sweep_ratio = if frequencies_hz.iter().any(|frequency_hz| {
18659        (*frequency_hz - reference_frequency_hz).abs() <= reference_scale * 1.0e-9
18660    }) {
18661        1.0
18662    } else {
18663        0.0
18664    };
18665    let reference_frequency_bracket_ratio = if min_frequency_hz <= reference_frequency_hz
18666        && reference_frequency_hz <= max_frequency_hz
18667    {
18668        1.0
18669    } else {
18670        0.0
18671    };
18672    let normalized_peak_frequency_error_ratio = metrics
18673        .resonance_peak_frequency_hz
18674        .map(|peak_frequency_hz| {
18675            ((peak_frequency_hz - reference_frequency_hz).abs() / reference_scale).clamp(0.0, 1.0)
18676        })
18677        .unwrap_or(1.0);
18678    let resonance_flux_gain = metrics.resonance_flux_gain.unwrap_or(0.0);
18679    let resonance_quality_factor = metrics.resonance_quality_factor.unwrap_or(0.0);
18680    let sweep_known_answer_coverage_ratio = if metrics.sweep_count >= 3
18681        && reference_frequency_in_sweep_ratio >= 1.0
18682        && reference_frequency_bracket_ratio >= 1.0
18683        && normalized_peak_frequency_error_ratio <= 0.25
18684        && resonance_flux_gain >= 1.0
18685        && resonance_quality_factor >= 1.5
18686    {
18687        1.0
18688    } else {
18689        0.0
18690    };
18691    let severity = if sweep_known_answer_coverage_ratio >= 1.0 {
18692        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Info
18693    } else {
18694        runmat_analysis_fea::diagnostics::FeaDiagnosticSeverity::Warning
18695    };
18696
18697    runmat_analysis_fea::diagnostics::FeaDiagnostic {
18698        code: "FEA_EM_SWEEP_KNOWN_ANSWER".to_string(),
18699        severity,
18700        message: format!(
18701            "basis=bracketed_reference_frequency sweep_count={} reference_frequency_hz={} sweep_frequency_min_hz={} sweep_frequency_max_hz={} reference_frequency_in_sweep_ratio={} reference_frequency_bracket_ratio={} normalized_peak_frequency_error_ratio={} resonance_flux_gain={} resonance_quality_factor={} sweep_known_answer_coverage_ratio={}",
18702            metrics.sweep_count,
18703            reference_frequency_hz,
18704            min_frequency_hz,
18705            max_frequency_hz,
18706            reference_frequency_in_sweep_ratio,
18707            reference_frequency_bracket_ratio,
18708            normalized_peak_frequency_error_ratio,
18709            resonance_flux_gain,
18710            resonance_quality_factor,
18711            sweep_known_answer_coverage_ratio,
18712        ),
18713    }
18714}
18715
18716fn infer_material_assignments(
18717    geometry: &GeometryAsset,
18718    materials: &[MaterialModel],
18719    prep_regions: Option<&HashSet<String>>,
18720) -> Vec<MaterialAssignment> {
18721    let default_material = materials
18722        .first()
18723        .map(|m| m.material_id.clone())
18724        .unwrap_or_else(|| "mat_default_steel".to_string());
18725    let mut assignments = Vec::new();
18726
18727    for region in &geometry.regions {
18728        let key = format!(
18729            "{} {}",
18730            region.name.to_ascii_lowercase(),
18731            region
18732                .tag
18733                .as_deref()
18734                .unwrap_or_default()
18735                .to_ascii_lowercase()
18736        );
18737        let assigned_material = if key.contains("aluminum") {
18738            materials
18739                .iter()
18740                .find(|m| m.material_id.contains("aluminum"))
18741                .map(|m| m.material_id.clone())
18742                .unwrap_or_else(|| default_material.clone())
18743        } else if key.contains("steel") {
18744            materials
18745                .iter()
18746                .find(|m| m.material_id.contains("steel"))
18747                .map(|m| m.material_id.clone())
18748                .unwrap_or_else(|| default_material.clone())
18749        } else if key.contains("polymer") || key.contains("plastic") {
18750            materials
18751                .iter()
18752                .find(|m| m.material_id.contains("polymer"))
18753                .map(|m| m.material_id.clone())
18754                .unwrap_or_else(|| default_material.clone())
18755        } else {
18756            default_material.clone()
18757        };
18758
18759        let evidence_confidence = if geometry
18760            .source_geometry
18761            .material_evidence
18762            .iter()
18763            .any(|e| e.confidence == MaterialEvidenceConfidence::High)
18764        {
18765            EvidenceConfidence::Verified
18766        } else if geometry
18767            .source_geometry
18768            .material_evidence
18769            .iter()
18770            .any(|e| e.confidence == MaterialEvidenceConfidence::Medium)
18771        {
18772            EvidenceConfidence::Probable
18773        } else {
18774            EvidenceConfidence::Inferred
18775        };
18776        let confidence = if prep_regions
18777            .map(|mapped| mapped.contains(&region.region_id))
18778            .unwrap_or(false)
18779        {
18780            EvidenceConfidence::Verified
18781        } else {
18782            evidence_confidence
18783        };
18784
18785        assignments.push(MaterialAssignment {
18786            region_id: region.region_id.clone(),
18787            expected_material_id: assigned_material.clone(),
18788            assigned_material_id: assigned_material,
18789            confidence,
18790        });
18791    }
18792
18793    assignments
18794}
18795
18796fn map_validate_error(
18797    error: AnalysisValidationError,
18798    model: &AnalysisModel,
18799    context: &OperationContext,
18800) -> OperationErrorEnvelope {
18801    let (error_code, message, mut error_context) = match error {
18802        AnalysisValidationError::MissingMaterials => (
18803            "RM.FEA.VALIDATE.MISSING_MATERIALS",
18804            "FEA model must include at least one material".to_string(),
18805            BTreeMap::new(),
18806        ),
18807        AnalysisValidationError::MissingBoundaryConditions => (
18808            "RM.FEA.VALIDATE.MISSING_BCS",
18809            "FEA model must include at least one boundary condition".to_string(),
18810            BTreeMap::new(),
18811        ),
18812        AnalysisValidationError::MissingLoads => (
18813            "RM.FEA.VALIDATE.MISSING_LOADS",
18814            "FEA model must include at least one load".to_string(),
18815            BTreeMap::new(),
18816        ),
18817        AnalysisValidationError::InvalidMomentVector { load_id } => (
18818            "RM.FEA.VALIDATE.INVALID_MOMENT",
18819            format!("moment load {load_id} must have finite components"),
18820            BTreeMap::from([("load_id".to_string(), load_id)]),
18821        ),
18822        AnalysisValidationError::ZeroMomentVector { load_id } => (
18823            "RM.FEA.VALIDATE.ZERO_MOMENT",
18824            format!("moment load {load_id} must have nonzero magnitude"),
18825            BTreeMap::from([("load_id".to_string(), load_id)]),
18826        ),
18827        AnalysisValidationError::InvalidWrench { load_id } => (
18828            "RM.FEA.VALIDATE.INVALID_WRENCH",
18829            format!("wrench load {load_id} must have finite force, moment, and point components"),
18830            BTreeMap::from([("load_id".to_string(), load_id)]),
18831        ),
18832        AnalysisValidationError::ZeroWrench { load_id } => (
18833            "RM.FEA.VALIDATE.ZERO_WRENCH",
18834            format!("wrench load {load_id} must have nonzero force or moment"),
18835            BTreeMap::from([("load_id".to_string(), load_id)]),
18836        ),
18837        AnalysisValidationError::UnitMismatch { model, geometry } => (
18838            "RM.FEA.VALIDATE.UNIT_MISMATCH",
18839            format!("model units {model:?} do not match geometry units {geometry:?}"),
18840            BTreeMap::from([
18841                ("model_units".to_string(), format!("{model:?}")),
18842                ("geometry_units".to_string(), format!("{geometry:?}")),
18843            ]),
18844        ),
18845        AnalysisValidationError::FrameMismatch { model, geometry } => (
18846            "RM.FEA.VALIDATE.FRAME_MISMATCH",
18847            format!("model frame {model:?} does not match geometry frame {geometry:?}"),
18848            BTreeMap::from([
18849                ("model_frame".to_string(), format!("{model:?}")),
18850                ("geometry_frame".to_string(), format!("{geometry:?}")),
18851            ]),
18852        ),
18853    };
18854
18855    error_context.insert("analysis_model_id".to_string(), model.model_id.0.clone());
18856    error_context.insert("geometry_id".to_string(), model.geometry_id.clone());
18857
18858    operation_error(
18859        ANALYSIS_VALIDATE_OPERATION,
18860        ANALYSIS_VALIDATE_OP_VERSION,
18861        context,
18862        OperationErrorSpec {
18863            error_code,
18864            error_type: OperationErrorType::Validation,
18865            retryable: false,
18866            severity: OperationErrorSeverity::Error,
18867        },
18868        message,
18869        error_context,
18870    )
18871}
18872
18873#[cfg(test)]
18874mod tests;