Skip to main content

runmat_runtime/analysis/
contracts.rs

1use runmat_analysis_core::{AnalysisField, AnalysisFieldValues, AnalysisModel};
2use runmat_analysis_fea::diagnostics::FeaDiagnostic;
3use runmat_analysis_fea::{ComputeBackend, FeaProgressEvent, FeaRunResult};
4use runmat_geometry_core::GeometryAsset;
5use runmat_meshing::RegionMeshMapping;
6use runmat_meshing_core::VolumeMeshingOptions;
7use runmat_meshing_evidence::{MeshAuthoringNestedTetrahedronShellSummary, MeshAuthoringSummary};
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10
11fn default_prep_coordinate_span_m() -> f64 {
12    1.0
13}
14
15fn default_prep_coordinate_secondary_span_m() -> f64 {
16    0.0
17}
18
19fn default_prep_coordinate_active_dimension_count() -> usize {
20    1
21}
22
23fn default_prep_coordinate_characteristic_length_m() -> f64 {
24    1.0
25}
26
27fn default_zero_usize() -> usize {
28    0
29}
30
31fn default_zero_f64() -> f64 {
32    0.0
33}
34
35fn default_reference_element_coordinates_m() -> [[f64; 3]; 3] {
36    [[0.0; 3]; 3]
37}
38
39fn default_element_topology_sample_edge_nodes() -> [[u32; 2]; 8] {
40    [[0; 2]; 8]
41}
42
43fn default_element_topology_sample_node_coordinates_m() -> [[f64; 3]; 8] {
44    [[0.0; 3]; 8]
45}
46
47fn default_element_topology_sample_element_edges() -> [[u32; 3]; 4] {
48    [[0; 3]; 4]
49}
50
51fn default_element_topology_sample_element_orientations() -> [[i8; 3]; 4] {
52    [[0; 3]; 4]
53}
54
55fn default_element_topology_sample_element_areas_m2() -> [f64; 4] {
56    [0.0; 4]
57}
58
59fn default_element_topology_node_coordinates_m() -> Vec<[f64; 3]> {
60    Vec::new()
61}
62
63fn default_element_topology_edge_nodes() -> Vec<[u32; 2]> {
64    Vec::new()
65}
66
67fn default_element_topology_element_edges() -> Vec<[u32; 3]> {
68    Vec::new()
69}
70
71fn default_element_topology_element_orientations() -> Vec<[i8; 3]> {
72    Vec::new()
73}
74
75fn default_element_topology_element_areas_m2() -> Vec<f64> {
76    Vec::new()
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct AnalysisValidateResult {
81    pub valid: bool,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct AnalysisCreateModelIntentSpec {
86    pub model_id: String,
87    pub profile: AnalysisCreateModelProfile,
88    #[serde(default)]
89    pub prep_context: Option<AnalysisCreateModelPrepContext>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct AnalysisCreateModelPrepContext {
94    pub source_geometry_id: String,
95    pub source_geometry_revision: u32,
96    pub region_mappings: Vec<RegionMeshMapping>,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct AnalysisRunPrepContext {
101    pub prepared_mesh_count: usize,
102    pub prepared_node_count: usize,
103    pub prepared_element_count: usize,
104    pub mapped_region_count: usize,
105    pub min_scaled_jacobian: f64,
106    pub mean_aspect_ratio: f64,
107    pub inverted_element_count: usize,
108    pub mapped_load_count: usize,
109    pub mapped_bc_count: usize,
110    pub layout_seed: u64,
111    pub topology_dof_multiplier: f64,
112    pub topology_bandwidth_estimate: u32,
113    pub mapped_region_participation_ratio: f64,
114    pub topology_surface_patch_ratio: f64,
115    pub topology_volume_core_ratio: f64,
116    pub topology_mixed_family_ratio: f64,
117    pub topology_region_span_mean: f64,
118    pub topology_region_block_count: usize,
119    pub topology_region_mesh_mean: f64,
120    pub topology_region_mesh_variance: f64,
121    pub topology_triangle_family_ratio: f64,
122    pub topology_quad_family_ratio: f64,
123    pub topology_tetrahedron_family_ratio: f64,
124    pub topology_hex_family_ratio: f64,
125    #[serde(default = "default_prep_coordinate_span_m")]
126    pub coordinate_span_x_m: f64,
127    #[serde(default = "default_prep_coordinate_secondary_span_m")]
128    pub coordinate_span_y_m: f64,
129    #[serde(default = "default_prep_coordinate_secondary_span_m")]
130    pub coordinate_span_z_m: f64,
131    #[serde(default = "default_prep_coordinate_active_dimension_count")]
132    pub coordinate_active_dimension_count: usize,
133    #[serde(default = "default_prep_coordinate_characteristic_length_m")]
134    pub coordinate_characteristic_length_m: f64,
135    #[serde(default = "default_zero_usize")]
136    pub element_geometry_node_count: usize,
137    #[serde(default = "default_zero_usize")]
138    pub element_geometry_edge_count: usize,
139    #[serde(default = "default_zero_f64")]
140    pub mean_element_edge_length_m: f64,
141    #[serde(default = "default_zero_f64")]
142    pub mean_element_area_m2: f64,
143    #[serde(default = "default_zero_f64")]
144    pub element_geometry_coverage_ratio: f64,
145    #[serde(default = "default_reference_element_coordinates_m")]
146    pub reference_element_coordinates_m: [[f64; 3]; 3],
147    #[serde(default = "default_zero_f64")]
148    pub reference_element_area_m2: f64,
149    #[serde(default = "default_zero_usize")]
150    pub control_volume_cell_count: usize,
151    #[serde(default = "default_zero_usize")]
152    pub control_volume_face_count: usize,
153    #[serde(default = "default_zero_usize")]
154    pub control_volume_internal_face_count: usize,
155    #[serde(default = "default_zero_usize")]
156    pub control_volume_boundary_face_count: usize,
157    #[serde(default = "default_zero_f64")]
158    pub control_volume_connectivity_coverage_ratio: f64,
159    #[serde(default = "default_zero_usize")]
160    pub element_topology_sample_element_count: usize,
161    #[serde(default = "default_zero_usize")]
162    pub element_topology_sample_edge_count: usize,
163    #[serde(default = "default_element_topology_sample_edge_nodes")]
164    pub element_topology_sample_edge_nodes: [[u32; 2]; 8],
165    #[serde(default = "default_element_topology_sample_node_coordinates_m")]
166    pub element_topology_sample_node_coordinates_m: [[f64; 3]; 8],
167    #[serde(default = "default_element_topology_sample_element_edges")]
168    pub element_topology_sample_element_edges: [[u32; 3]; 4],
169    #[serde(default = "default_element_topology_sample_element_orientations")]
170    pub element_topology_sample_element_orientations: [[i8; 3]; 4],
171    #[serde(default = "default_element_topology_sample_element_areas_m2")]
172    pub element_topology_sample_element_areas_m2: [f64; 4],
173    #[serde(default = "default_element_topology_node_coordinates_m")]
174    pub element_topology_node_coordinates_m: Vec<[f64; 3]>,
175    #[serde(default = "default_element_topology_edge_nodes")]
176    pub element_topology_edge_nodes: Vec<[u32; 2]>,
177    #[serde(default = "default_element_topology_element_edges")]
178    pub element_topology_element_edges: Vec<[u32; 3]>,
179    #[serde(default = "default_element_topology_element_orientations")]
180    pub element_topology_element_orientations: Vec<[i8; 3]>,
181    #[serde(default = "default_element_topology_element_areas_m2")]
182    pub element_topology_element_areas_m2: Vec<f64>,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case")]
187pub enum AnalysisCreateModelProfile {
188    LinearStaticStructural,
189    ThermoMechanicalCoupled,
190    ElectroThermalCoupled,
191    ThermalStandalone,
192    ModalStructural,
193    AcousticHarmonic,
194    TransientStructural,
195    NonlinearStructural,
196    ElectromagneticStatic,
197    CfdSteadyState,
198    CfdTransient,
199    ChtCoupled,
200    FsiCoupled,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub struct AnalysisPhysicsProfileCatalogEntry {
205    pub profile: AnalysisCreateModelProfile,
206    pub label: &'static str,
207    pub family: &'static str,
208    pub target: &'static str,
209    pub value: &'static str,
210    pub default_outputs: &'static [AnalysisPhysicsProfileDefaultOutput],
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub struct AnalysisPhysicsProfileDefaultOutput {
215    pub field: &'static str,
216    pub location: &'static str,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct AnalysisRuntimePhysicsProfileCatalogEntry {
222    pub profile: AnalysisCreateModelProfile,
223    pub label: String,
224    pub family: String,
225    pub target: String,
226    pub value: String,
227    pub default_outputs: Vec<AnalysisRuntimePhysicsProfileDefaultOutput>,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct AnalysisRuntimePhysicsProfileDefaultOutput {
233    pub field: String,
234    pub location: String,
235}
236
237pub const ANALYSIS_PHYSICS_PROFILE_CATALOG: &[AnalysisPhysicsProfileCatalogEntry] = &[
238    AnalysisPhysicsProfileCatalogEntry {
239        profile: AnalysisCreateModelProfile::LinearStaticStructural,
240        label: "Linear static structural",
241        family: "structural",
242        target: "solid mechanics",
243        value: "static stress and displacement",
244        default_outputs: &[
245            AnalysisPhysicsProfileDefaultOutput {
246                field: "structural.displacement",
247                location: "nodes",
248            },
249            AnalysisPhysicsProfileDefaultOutput {
250                field: "structural.von_mises",
251                location: "elements",
252            },
253        ],
254    },
255    AnalysisPhysicsProfileCatalogEntry {
256        profile: AnalysisCreateModelProfile::ThermoMechanicalCoupled,
257        label: "Thermo-mechanical",
258        family: "coupled physics",
259        target: "coupled solid mechanics and heat transfer",
260        value: "coupled temperature, stress, and displacement",
261        default_outputs: &[
262            AnalysisPhysicsProfileDefaultOutput {
263                field: "thermal.temperature",
264                location: "nodes",
265            },
266            AnalysisPhysicsProfileDefaultOutput {
267                field: "structural.displacement",
268                location: "nodes",
269            },
270            AnalysisPhysicsProfileDefaultOutput {
271                field: "structural.von_mises",
272                location: "elements",
273            },
274        ],
275    },
276    AnalysisPhysicsProfileCatalogEntry {
277        profile: AnalysisCreateModelProfile::ElectroThermalCoupled,
278        label: "Electro-thermal",
279        family: "coupled physics",
280        target: "coupled electromagnetics and heat transfer",
281        value: "resistive heating, temperature, and electrical fields",
282        default_outputs: &[
283            AnalysisPhysicsProfileDefaultOutput {
284                field: "electro_thermal.temperature",
285                location: "nodes",
286            },
287            AnalysisPhysicsProfileDefaultOutput {
288                field: "electro_thermal.joule_heat",
289                location: "elements",
290            },
291            AnalysisPhysicsProfileDefaultOutput {
292                field: "electro_thermal.electric_potential",
293                location: "nodes",
294            },
295            AnalysisPhysicsProfileDefaultOutput {
296                field: "electro_thermal.current_density",
297                location: "elements",
298            },
299        ],
300    },
301    AnalysisPhysicsProfileCatalogEntry {
302        profile: AnalysisCreateModelProfile::ThermalStandalone,
303        label: "Thermal",
304        family: "thermal",
305        target: "heat transfer",
306        value: "temperature and heat flux",
307        default_outputs: &[
308            AnalysisPhysicsProfileDefaultOutput {
309                field: "thermal.temperature",
310                location: "nodes",
311            },
312            AnalysisPhysicsProfileDefaultOutput {
313                field: "thermal.heat_flux",
314                location: "elements",
315            },
316        ],
317    },
318    AnalysisPhysicsProfileCatalogEntry {
319        profile: AnalysisCreateModelProfile::ModalStructural,
320        label: "Modal structural",
321        family: "modal",
322        target: "solid mechanics",
323        value: "natural frequencies and modes",
324        default_outputs: &[
325            AnalysisPhysicsProfileDefaultOutput {
326                field: "structural.mode_shapes",
327                location: "nodes",
328            },
329            AnalysisPhysicsProfileDefaultOutput {
330                field: "structural.natural_frequencies",
331                location: "modes",
332            },
333        ],
334    },
335    AnalysisPhysicsProfileCatalogEntry {
336        profile: AnalysisCreateModelProfile::AcousticHarmonic,
337        label: "Acoustic harmonic",
338        family: "acoustic",
339        target: "acoustics",
340        value: "frequency-domain pressure",
341        default_outputs: &[
342            AnalysisPhysicsProfileDefaultOutput {
343                field: "acoustic.pressure",
344                location: "nodes",
345            },
346            AnalysisPhysicsProfileDefaultOutput {
347                field: "acoustic.sound_pressure_level",
348                location: "nodes",
349            },
350        ],
351    },
352    AnalysisPhysicsProfileCatalogEntry {
353        profile: AnalysisCreateModelProfile::TransientStructural,
354        label: "Transient structural",
355        family: "structural",
356        target: "solid mechanics",
357        value: "time response",
358        default_outputs: &[
359            AnalysisPhysicsProfileDefaultOutput {
360                field: "structural.displacement",
361                location: "nodes",
362            },
363            AnalysisPhysicsProfileDefaultOutput {
364                field: "structural.von_mises",
365                location: "elements",
366            },
367        ],
368    },
369    AnalysisPhysicsProfileCatalogEntry {
370        profile: AnalysisCreateModelProfile::NonlinearStructural,
371        label: "Nonlinear structural",
372        family: "structural",
373        target: "solid mechanics",
374        value: "large deformation or nonlinear material",
375        default_outputs: &[
376            AnalysisPhysicsProfileDefaultOutput {
377                field: "structural.displacement",
378                location: "nodes",
379            },
380            AnalysisPhysicsProfileDefaultOutput {
381                field: "structural.von_mises",
382                location: "elements",
383            },
384        ],
385    },
386    AnalysisPhysicsProfileCatalogEntry {
387        profile: AnalysisCreateModelProfile::ElectromagneticStatic,
388        label: "Electromagnetic",
389        family: "electromagnetic",
390        target: "electromagnetics",
391        value: "electric and magnetic fields",
392        default_outputs: &[
393            AnalysisPhysicsProfileDefaultOutput {
394                field: "em.electric_field",
395                location: "elements",
396            },
397            AnalysisPhysicsProfileDefaultOutput {
398                field: "em.magnetic_flux_density",
399                location: "elements",
400            },
401        ],
402    },
403    AnalysisPhysicsProfileCatalogEntry {
404        profile: AnalysisCreateModelProfile::CfdSteadyState,
405        label: "CFD steady state",
406        family: "CFD",
407        target: "fluid flow",
408        value: "steady flow fields",
409        default_outputs: &[
410            AnalysisPhysicsProfileDefaultOutput {
411                field: "fluid.velocity",
412                location: "nodes",
413            },
414            AnalysisPhysicsProfileDefaultOutput {
415                field: "fluid.pressure",
416                location: "nodes",
417            },
418        ],
419    },
420    AnalysisPhysicsProfileCatalogEntry {
421        profile: AnalysisCreateModelProfile::CfdTransient,
422        label: "CFD transient",
423        family: "CFD",
424        target: "fluid flow",
425        value: "time-dependent flow fields",
426        default_outputs: &[
427            AnalysisPhysicsProfileDefaultOutput {
428                field: "fluid.velocity",
429                location: "nodes",
430            },
431            AnalysisPhysicsProfileDefaultOutput {
432                field: "fluid.pressure",
433                location: "nodes",
434            },
435        ],
436    },
437    AnalysisPhysicsProfileCatalogEntry {
438        profile: AnalysisCreateModelProfile::ChtCoupled,
439        label: "Conjugate heat transfer",
440        family: "coupled physics",
441        target: "coupled fluid and solid heat transfer",
442        value: "fluid and solid heat transfer",
443        default_outputs: &[
444            AnalysisPhysicsProfileDefaultOutput {
445                field: "thermal.temperature",
446                location: "nodes",
447            },
448            AnalysisPhysicsProfileDefaultOutput {
449                field: "fluid.velocity",
450                location: "nodes",
451            },
452            AnalysisPhysicsProfileDefaultOutput {
453                field: "fluid.pressure",
454                location: "nodes",
455            },
456        ],
457    },
458    AnalysisPhysicsProfileCatalogEntry {
459        profile: AnalysisCreateModelProfile::FsiCoupled,
460        label: "Fluid-structure interaction",
461        family: "coupled physics",
462        target: "coupled fluid and structural response",
463        value: "fluid loads and structural response",
464        default_outputs: &[
465            AnalysisPhysicsProfileDefaultOutput {
466                field: "structural.displacement",
467                location: "nodes",
468            },
469            AnalysisPhysicsProfileDefaultOutput {
470                field: "structural.von_mises",
471                location: "elements",
472            },
473            AnalysisPhysicsProfileDefaultOutput {
474                field: "fluid.pressure",
475                location: "nodes",
476            },
477        ],
478    },
479];
480
481pub fn analysis_runtime_physics_profile_catalog() -> Vec<AnalysisRuntimePhysicsProfileCatalogEntry>
482{
483    ANALYSIS_PHYSICS_PROFILE_CATALOG
484        .iter()
485        .map(|entry| AnalysisRuntimePhysicsProfileCatalogEntry {
486            profile: entry.profile,
487            label: entry.label.to_string(),
488            family: entry.family.to_string(),
489            target: entry.target.to_string(),
490            value: entry.value.to_string(),
491            default_outputs: entry
492                .default_outputs
493                .iter()
494                .map(|output| AnalysisRuntimePhysicsProfileDefaultOutput {
495                    field: output.field.to_string(),
496                    location: output.location.to_string(),
497                })
498                .collect(),
499        })
500        .collect()
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(rename_all = "snake_case")]
505pub enum PrecisionMode {
506    Fp32,
507    Fp64,
508    Mixed,
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513pub enum PreconditionerMode {
514    Auto,
515    Jacobi,
516    Amg,
517    Ilu,
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(rename_all = "snake_case")]
522pub enum QualityPolicy {
523    Strict,
524    Balanced,
525    Exploratory,
526}
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
529#[serde(rename_all = "snake_case")]
530pub enum PrepCalibrationProfile {
531    Auto,
532    Fast,
533    Balanced,
534    Conservative,
535}
536
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
538pub struct ThermoRegionTemperatureDelta {
539    pub region_id: String,
540    pub temperature_delta_k: f64,
541}
542
543#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
544pub struct ThermoTimeProfilePoint {
545    pub normalized_time: f64,
546    pub scale: f64,
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
550#[serde(rename_all = "snake_case")]
551pub enum ThermoFieldInterpolationMode {
552    Linear,
553    Step,
554}
555
556#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
557pub struct ThermoFieldSource {
558    pub source_id: String,
559    pub revision: u32,
560    #[serde(default)]
561    pub interpolation_mode: Option<ThermoFieldInterpolationMode>,
562    #[serde(default)]
563    pub expected_region_ids: Vec<String>,
564}
565
566#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
567pub struct ThermoMechanicalCouplingOptions {
568    pub enabled: bool,
569    pub reference_temperature_k: f64,
570    pub applied_temperature_delta_k: f64,
571    pub thermal_expansion_coefficient: f64,
572    #[serde(default)]
573    pub field_artifact_id: Option<String>,
574    #[serde(default)]
575    pub field_source: Option<ThermoFieldSource>,
576    #[serde(default)]
577    pub region_temperature_deltas: Vec<ThermoRegionTemperatureDelta>,
578    #[serde(default)]
579    pub time_profile: Vec<ThermoTimeProfilePoint>,
580}
581
582#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
583pub struct ElectroRegionConductivityScale {
584    pub region_id: String,
585    pub conductivity_scale: f64,
586}
587
588#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
589pub struct ElectroTimeProfilePoint {
590    pub normalized_time: f64,
591    pub current_scale: f64,
592}
593
594#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
595pub struct ElectroThermalCouplingOptions {
596    pub enabled: bool,
597    pub reference_temperature_k: f64,
598    pub applied_voltage_v: f64,
599    pub base_electrical_conductivity_s_per_m: f64,
600    pub resistive_heating_coefficient: f64,
601    #[serde(default)]
602    pub region_conductivity_scales: Vec<ElectroRegionConductivityScale>,
603    #[serde(default)]
604    pub time_profile: Vec<ElectroTimeProfilePoint>,
605}
606
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
608pub struct PlasticityConstitutiveOptions {
609    pub enabled: bool,
610    pub yield_strain: f64,
611    pub hardening_modulus_ratio: f64,
612    pub saturation_exponent: f64,
613}
614
615#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
616pub struct ContactInterfaceOptions {
617    pub enabled: bool,
618    pub penalty_stiffness_scale: f64,
619    pub max_penetration_ratio: f64,
620    pub friction_coefficient: f64,
621}
622
623#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case")]
625pub enum QualityReasonCode {
626    MaterialAssignmentConflict,
627    SolverNotConverged,
628    SolverBackendFallback,
629    FieldPromotionFallback,
630    FieldTopologyMismatch,
631    SolidMeshNoVolumeElements,
632    SolidMeshUnsupportedElementKind,
633    SolidMeshUnmappedLoadRegion,
634    SolidMeshUnmappedBoundaryConditionRegion,
635    SolidMeshMaterialCoverageIncomplete,
636    SolidMeshRenderTopologyIncomplete,
637    SolidMeshValidationEvidenceMissing,
638    SolidMeshValidationEvidenceFailed,
639    SolidMeshQualityMinJacobianFailed,
640    SolidMeshQualityAspectRatioWarn,
641    SolidMeshBoundaryProjectionWarn,
642    MissingSolidAnalysisMesh,
643    ModalResidualExceeded,
644    ModalOrthogonalityExceeded,
645    ModalSeparationLow,
646    TransientResidualExceeded,
647    TransientStabilityExceeded,
648    TransientStepFailure,
649    ThermoMechanicalTransientStress,
650    ThermoMechanicalConstitutiveSpreadHigh,
651    ThermoMechanicalAssignmentHeterogeneityHigh,
652    ThermoMechanicalGradientInstability,
653    ThermoMechanicalFieldCoverageLow,
654    ThermoMechanicalFieldExtrapolationHigh,
655    ElectroThermalTransientStress,
656    ElectroThermalNonlinearStress,
657    ElectromagneticSolveQualityLow,
658    ElectromagneticConductivitySpreadHigh,
659    ElectromagneticMaterialHeterogeneityHigh,
660    ElectromagneticAssignmentCoverageLow,
661    ElectromagneticRegionContrastHigh,
662    ElectromagneticConditioningHigh,
663    ElectromagneticSourceRealizationLow,
664    ElectromagneticSourceRegionCoverageLow,
665    ElectromagneticSourceMaterialAlignmentLow,
666    ElectromagneticSourceOverlapHigh,
667    ElectromagneticSourceInterferenceHigh,
668    ElectromagneticBoundaryLocalizationLow,
669    ElectromagneticGroundAnchorEffectivenessLow,
670    ElectromagneticInsulationLeakageHigh,
671    ElectromagneticBoundaryAnchoringLow,
672    ElectromagneticFluxDivergenceHigh,
673    ElectromagneticEnergyImbalanceHigh,
674    ElectromagneticBoundaryEnergyLow,
675    ElectromagneticBoundaryPenaltyConditioningHigh,
676    ElectromagneticSourceRegionEnergyConsistencyLow,
677    ElectromagneticRealResidualHigh,
678    ElectromagneticImagResidualHigh,
679    ElectromagneticSweepCoverageLow,
680    ElectromagneticResonanceSharpnessLow,
681    PlasticityNonlinearStress,
682    ContactNonlinearStress,
683    NonlinearResidualExceeded,
684    NonlinearIncrementFailure,
685    ThermoMechanicalNonlinearStress,
686    ThermalResidualExceeded,
687    ThermalConstitutiveSpreadHigh,
688}
689
690#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
691pub struct QualityReason {
692    pub code: QualityReasonCode,
693    pub detail: String,
694}
695
696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
697pub struct AnalysisRunOptions {
698    pub deterministic_mode: bool,
699    pub precision_mode: PrecisionMode,
700    pub preconditioner_mode: PreconditionerMode,
701    pub quality_policy: QualityPolicy,
702    #[serde(default)]
703    pub prep_context: Option<AnalysisRunPrepContext>,
704    #[serde(default)]
705    pub prep_artifact_id: Option<String>,
706    #[serde(default)]
707    pub analysis_mesh_artifact_path: Option<String>,
708    #[serde(default)]
709    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
710}
711
712#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
713pub struct AnalysisElectromagneticRunOptions {
714    pub deterministic_mode: bool,
715    pub precision_mode: PrecisionMode,
716    pub quality_policy: QualityPolicy,
717    pub residual_target: f64,
718    pub harmonic_tolerance: f64,
719    pub harmonic_max_iterations: usize,
720    #[serde(default)]
721    pub prep_context: Option<AnalysisRunPrepContext>,
722    #[serde(default)]
723    pub prep_artifact_id: Option<String>,
724    #[serde(default)]
725    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
726    #[serde(default)]
727    pub sweep_enabled: bool,
728    #[serde(default)]
729    pub sweep_frequency_hz: Vec<f64>,
730}
731
732impl Default for AnalysisElectromagneticRunOptions {
733    fn default() -> Self {
734        Self {
735            deterministic_mode: false,
736            precision_mode: PrecisionMode::Fp64,
737            quality_policy: QualityPolicy::Balanced,
738            residual_target: 1.0e-6,
739            harmonic_tolerance: 1.0e-7,
740            harmonic_max_iterations: 96,
741            prep_context: None,
742            prep_artifact_id: None,
743            prep_calibration_profile: None,
744            sweep_enabled: false,
745            sweep_frequency_hz: Vec::new(),
746        }
747    }
748}
749
750impl Default for AnalysisRunOptions {
751    fn default() -> Self {
752        Self {
753            deterministic_mode: false,
754            precision_mode: PrecisionMode::Fp64,
755            preconditioner_mode: PreconditionerMode::Auto,
756            quality_policy: QualityPolicy::Balanced,
757            prep_context: None,
758            prep_artifact_id: None,
759            analysis_mesh_artifact_path: None,
760            prep_calibration_profile: None,
761        }
762    }
763}
764
765#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
766pub struct AnalysisTransientRunOptions {
767    pub deterministic_mode: bool,
768    pub precision_mode: PrecisionMode,
769    pub quality_policy: QualityPolicy,
770    pub time_step_s: f64,
771    pub min_time_step_s: f64,
772    pub max_time_step_s: f64,
773    pub step_count: usize,
774    pub max_linear_iters: usize,
775    pub tolerance: f64,
776    pub residual_target: f64,
777    pub adaptive_time_step: bool,
778    pub max_step_retries: usize,
779    pub adapt_min_scale: f64,
780    pub adapt_max_scale: f64,
781    pub adapt_growth_exponent: f64,
782    pub adapt_retry_growth_cap: f64,
783    pub adapt_nonconverged_shrink: f64,
784    pub dt_bucket_rel_tolerance: f64,
785    #[serde(default)]
786    pub prep_context: Option<AnalysisRunPrepContext>,
787    #[serde(default)]
788    pub prep_artifact_id: Option<String>,
789    #[serde(default)]
790    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
791}
792
793impl Default for AnalysisTransientRunOptions {
794    fn default() -> Self {
795        Self {
796            deterministic_mode: false,
797            precision_mode: PrecisionMode::Fp64,
798            quality_policy: QualityPolicy::Balanced,
799            time_step_s: 1.0e-3,
800            min_time_step_s: 1.0e-6,
801            max_time_step_s: 2.0e-2,
802            step_count: 10,
803            max_linear_iters: 128,
804            tolerance: 1.0e-8,
805            residual_target: 1.0e-6,
806            adaptive_time_step: true,
807            max_step_retries: 4,
808            adapt_min_scale: 0.8,
809            adapt_max_scale: 1.25,
810            adapt_growth_exponent: 0.35,
811            adapt_retry_growth_cap: 1.05,
812            adapt_nonconverged_shrink: 0.75,
813            dt_bucket_rel_tolerance: 0.0,
814            prep_context: None,
815            prep_artifact_id: None,
816            prep_calibration_profile: None,
817        }
818    }
819}
820
821#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
822pub struct AnalysisAcousticRunOptions {
823    pub deterministic_mode: bool,
824    pub precision_mode: PrecisionMode,
825    pub quality_policy: QualityPolicy,
826    pub mode_count: usize,
827    pub residual_warn_threshold: f64,
828    #[serde(default)]
829    pub prep_context: Option<AnalysisRunPrepContext>,
830    #[serde(default)]
831    pub prep_artifact_id: Option<String>,
832    #[serde(default)]
833    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
834}
835
836impl Default for AnalysisAcousticRunOptions {
837    fn default() -> Self {
838        Self {
839            deterministic_mode: false,
840            precision_mode: PrecisionMode::Fp64,
841            quality_policy: QualityPolicy::Balanced,
842            mode_count: 3,
843            residual_warn_threshold: 1.0e-3,
844            prep_context: None,
845            prep_artifact_id: None,
846            prep_calibration_profile: None,
847        }
848    }
849}
850
851#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
852pub struct AnalysisCfdRunOptions {
853    pub deterministic_mode: bool,
854    pub precision_mode: PrecisionMode,
855    pub quality_policy: QualityPolicy,
856    pub time_step_s: f64,
857    pub step_count: usize,
858    pub max_linear_iters: usize,
859    pub tolerance: f64,
860    pub residual_warn_threshold: f64,
861    #[serde(default)]
862    pub prep_context: Option<AnalysisRunPrepContext>,
863    #[serde(default)]
864    pub prep_artifact_id: Option<String>,
865    #[serde(default)]
866    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
867}
868
869impl Default for AnalysisCfdRunOptions {
870    fn default() -> Self {
871        Self {
872            deterministic_mode: false,
873            precision_mode: PrecisionMode::Fp64,
874            quality_policy: QualityPolicy::Balanced,
875            time_step_s: 1.0e-3,
876            step_count: 12,
877            max_linear_iters: 128,
878            tolerance: 1.0e-8,
879            residual_warn_threshold: 1.0e-5,
880            prep_context: None,
881            prep_artifact_id: None,
882            prep_calibration_profile: None,
883        }
884    }
885}
886
887#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
888pub struct AnalysisChtRunOptions {
889    pub deterministic_mode: bool,
890    pub precision_mode: PrecisionMode,
891    pub quality_policy: QualityPolicy,
892    pub time_step_s: f64,
893    pub step_count: usize,
894    pub max_linear_iters: usize,
895    pub tolerance: f64,
896    pub residual_warn_threshold: f64,
897    #[serde(default)]
898    pub prep_context: Option<AnalysisRunPrepContext>,
899    #[serde(default)]
900    pub prep_artifact_id: Option<String>,
901    #[serde(default)]
902    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
903}
904
905impl Default for AnalysisChtRunOptions {
906    fn default() -> Self {
907        Self {
908            deterministic_mode: false,
909            precision_mode: PrecisionMode::Fp64,
910            quality_policy: QualityPolicy::Balanced,
911            time_step_s: 1.0e-3,
912            step_count: 12,
913            max_linear_iters: 128,
914            tolerance: 1.0e-8,
915            residual_warn_threshold: 1.0e-4,
916            prep_context: None,
917            prep_artifact_id: None,
918            prep_calibration_profile: None,
919        }
920    }
921}
922
923#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
924pub struct AnalysisFsiRunOptions {
925    pub deterministic_mode: bool,
926    pub precision_mode: PrecisionMode,
927    pub quality_policy: QualityPolicy,
928    pub time_step_s: f64,
929    pub step_count: usize,
930    pub max_linear_iters: usize,
931    pub tolerance: f64,
932    pub residual_warn_threshold: f64,
933    #[serde(default)]
934    pub prep_context: Option<AnalysisRunPrepContext>,
935    #[serde(default)]
936    pub prep_artifact_id: Option<String>,
937    #[serde(default)]
938    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
939}
940
941impl Default for AnalysisFsiRunOptions {
942    fn default() -> Self {
943        Self {
944            deterministic_mode: false,
945            precision_mode: PrecisionMode::Fp64,
946            quality_policy: QualityPolicy::Balanced,
947            time_step_s: 1.0e-3,
948            step_count: 12,
949            max_linear_iters: 128,
950            tolerance: 1.0e-8,
951            residual_warn_threshold: 1.0e-4,
952            prep_context: None,
953            prep_artifact_id: None,
954            prep_calibration_profile: None,
955        }
956    }
957}
958
959#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
960pub struct AnalysisThermalRunOptions {
961    pub deterministic_mode: bool,
962    pub precision_mode: PrecisionMode,
963    pub quality_policy: QualityPolicy,
964    pub step_count: usize,
965    pub time_step_s: f64,
966    pub residual_warn_threshold: f64,
967    #[serde(default)]
968    pub prep_context: Option<AnalysisRunPrepContext>,
969    #[serde(default)]
970    pub prep_artifact_id: Option<String>,
971    #[serde(default)]
972    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
973}
974
975impl Default for AnalysisThermalRunOptions {
976    fn default() -> Self {
977        Self {
978            deterministic_mode: false,
979            precision_mode: PrecisionMode::Fp64,
980            quality_policy: QualityPolicy::Balanced,
981            step_count: 10,
982            time_step_s: 1.0e-2,
983            residual_warn_threshold: 1.0e-4,
984            prep_context: None,
985            prep_artifact_id: None,
986            prep_calibration_profile: None,
987        }
988    }
989}
990
991impl AnalysisTransientRunOptions {
992    pub fn coarse() -> Self {
993        Self {
994            deterministic_mode: false,
995            precision_mode: PrecisionMode::Fp32,
996            quality_policy: QualityPolicy::Exploratory,
997            time_step_s: 5.0e-3,
998            min_time_step_s: 5.0e-4,
999            max_time_step_s: 2.0e-2,
1000            step_count: 6,
1001            max_linear_iters: 64,
1002            tolerance: 1.0e-6,
1003            residual_target: 1.0e-4,
1004            adaptive_time_step: true,
1005            max_step_retries: 2,
1006            adapt_min_scale: 0.75,
1007            adapt_max_scale: 1.3,
1008            adapt_growth_exponent: 0.3,
1009            adapt_retry_growth_cap: 1.02,
1010            adapt_nonconverged_shrink: 0.7,
1011            dt_bucket_rel_tolerance: 0.02,
1012            prep_context: None,
1013            prep_artifact_id: None,
1014            prep_calibration_profile: None,
1015        }
1016    }
1017
1018    pub fn balanced() -> Self {
1019        Self::default()
1020    }
1021
1022    pub fn solid_recommended() -> Self {
1023        Self {
1024            quality_policy: QualityPolicy::Balanced,
1025            deterministic_mode: true,
1026            precision_mode: PrecisionMode::Fp64,
1027            dt_bucket_rel_tolerance: 0.01,
1028            ..Self::balanced()
1029        }
1030    }
1031
1032    pub fn high_accuracy() -> Self {
1033        Self {
1034            deterministic_mode: true,
1035            precision_mode: PrecisionMode::Fp64,
1036            quality_policy: QualityPolicy::Strict,
1037            time_step_s: 5.0e-4,
1038            min_time_step_s: 5.0e-6,
1039            max_time_step_s: 2.0e-3,
1040            step_count: 24,
1041            max_linear_iters: 256,
1042            tolerance: 1.0e-10,
1043            residual_target: 1.0e-7,
1044            adaptive_time_step: true,
1045            max_step_retries: 8,
1046            adapt_min_scale: 0.85,
1047            adapt_max_scale: 1.2,
1048            adapt_growth_exponent: 0.45,
1049            adapt_retry_growth_cap: 1.03,
1050            adapt_nonconverged_shrink: 0.8,
1051            dt_bucket_rel_tolerance: 0.005,
1052            prep_context: None,
1053            prep_artifact_id: None,
1054            prep_calibration_profile: None,
1055        }
1056    }
1057}
1058
1059#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1060pub struct AnalysisModalRunOptions {
1061    pub deterministic_mode: bool,
1062    pub precision_mode: PrecisionMode,
1063    pub quality_policy: QualityPolicy,
1064    pub mode_count: usize,
1065    pub residual_warn_threshold: f64,
1066    #[serde(default)]
1067    pub prep_context: Option<AnalysisRunPrepContext>,
1068    #[serde(default)]
1069    pub prep_artifact_id: Option<String>,
1070    #[serde(default)]
1071    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
1072}
1073
1074#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1075pub struct AnalysisNonlinearRunOptions {
1076    pub deterministic_mode: bool,
1077    pub precision_mode: PrecisionMode,
1078    pub quality_policy: QualityPolicy,
1079    pub increment_count: usize,
1080    pub max_newton_iters: usize,
1081    pub tolerance: f64,
1082    pub residual_convergence_factor: f64,
1083    pub increment_norm_tolerance: f64,
1084    pub line_search: bool,
1085    pub max_line_search_backtracks: usize,
1086    pub line_search_reduction: f64,
1087    pub tangent_refresh_interval: usize,
1088    #[serde(default)]
1089    pub prep_context: Option<AnalysisRunPrepContext>,
1090    #[serde(default)]
1091    pub prep_artifact_id: Option<String>,
1092    #[serde(default)]
1093    pub prep_calibration_profile: Option<PrepCalibrationProfile>,
1094}
1095
1096impl Default for AnalysisNonlinearRunOptions {
1097    fn default() -> Self {
1098        Self {
1099            deterministic_mode: false,
1100            precision_mode: PrecisionMode::Fp64,
1101            quality_policy: QualityPolicy::Balanced,
1102            increment_count: 12,
1103            max_newton_iters: 24,
1104            tolerance: 1.0e-6,
1105            residual_convergence_factor: 5.0,
1106            increment_norm_tolerance: 1.0e-7,
1107            line_search: true,
1108            max_line_search_backtracks: 6,
1109            line_search_reduction: 0.5,
1110            tangent_refresh_interval: 2,
1111            prep_context: None,
1112            prep_artifact_id: None,
1113            prep_calibration_profile: None,
1114        }
1115    }
1116}
1117
1118impl AnalysisNonlinearRunOptions {
1119    pub fn coarse() -> Self {
1120        Self {
1121            deterministic_mode: false,
1122            precision_mode: PrecisionMode::Fp32,
1123            quality_policy: QualityPolicy::Exploratory,
1124            increment_count: 8,
1125            max_newton_iters: 16,
1126            tolerance: 5.0e-6,
1127            residual_convergence_factor: 8.0,
1128            increment_norm_tolerance: 5.0e-7,
1129            line_search: false,
1130            max_line_search_backtracks: 0,
1131            line_search_reduction: 0.6,
1132            tangent_refresh_interval: 4,
1133            prep_context: None,
1134            prep_artifact_id: None,
1135            prep_calibration_profile: None,
1136        }
1137    }
1138
1139    pub fn balanced() -> Self {
1140        Self::default()
1141    }
1142
1143    pub fn high_accuracy() -> Self {
1144        Self {
1145            deterministic_mode: true,
1146            precision_mode: PrecisionMode::Fp64,
1147            quality_policy: QualityPolicy::Strict,
1148            increment_count: 24,
1149            max_newton_iters: 40,
1150            tolerance: 1.0e-7,
1151            residual_convergence_factor: 3.0,
1152            increment_norm_tolerance: 5.0e-8,
1153            line_search: true,
1154            max_line_search_backtracks: 10,
1155            line_search_reduction: 0.5,
1156            tangent_refresh_interval: 1,
1157            prep_context: None,
1158            prep_artifact_id: None,
1159            prep_calibration_profile: None,
1160        }
1161    }
1162
1163    pub fn solid_recommended() -> Self {
1164        Self {
1165            deterministic_mode: true,
1166            precision_mode: PrecisionMode::Fp64,
1167            quality_policy: QualityPolicy::Balanced,
1168            increment_count: 24,
1169            max_newton_iters: 28,
1170            tolerance: 1.0e-6,
1171            residual_convergence_factor: 4.0,
1172            increment_norm_tolerance: 8.0e-8,
1173            line_search: true,
1174            max_line_search_backtracks: 8,
1175            line_search_reduction: 0.5,
1176            tangent_refresh_interval: 2,
1177            prep_context: None,
1178            prep_artifact_id: None,
1179            prep_calibration_profile: None,
1180        }
1181    }
1182}
1183
1184impl Default for AnalysisModalRunOptions {
1185    fn default() -> Self {
1186        Self {
1187            deterministic_mode: false,
1188            precision_mode: PrecisionMode::Fp64,
1189            quality_policy: QualityPolicy::Balanced,
1190            mode_count: 3,
1191            residual_warn_threshold: 1.0e-3,
1192            prep_context: None,
1193            prep_artifact_id: None,
1194            prep_calibration_profile: None,
1195        }
1196    }
1197}
1198
1199impl AnalysisModalRunOptions {
1200    pub fn coarse() -> Self {
1201        Self {
1202            deterministic_mode: false,
1203            precision_mode: PrecisionMode::Fp32,
1204            quality_policy: QualityPolicy::Exploratory,
1205            mode_count: 2,
1206            residual_warn_threshold: 5.0e-3,
1207            prep_context: None,
1208            prep_artifact_id: None,
1209            prep_calibration_profile: None,
1210        }
1211    }
1212
1213    pub fn balanced() -> Self {
1214        Self::default()
1215    }
1216
1217    pub fn high_accuracy() -> Self {
1218        Self {
1219            deterministic_mode: true,
1220            precision_mode: PrecisionMode::Fp64,
1221            quality_policy: QualityPolicy::Strict,
1222            mode_count: 8,
1223            residual_warn_threshold: 5.0e-4,
1224            prep_context: None,
1225            prep_artifact_id: None,
1226            prep_calibration_profile: None,
1227        }
1228    }
1229}
1230
1231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1232#[serde(rename_all = "snake_case")]
1233pub enum QualityGate {
1234    Pass,
1235    Warn,
1236    Fail,
1237}
1238
1239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1240#[serde(rename_all = "snake_case")]
1241pub enum RunStatus {
1242    Publishable,
1243    Degraded,
1244    Rejected,
1245}
1246
1247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1248pub struct RunProvenance {
1249    pub backend: ComputeBackend,
1250    pub solver_backend: String,
1251    pub solver_device_apply_k_ratio: f64,
1252    pub solver_host_sync_count: u32,
1253    pub precision_mode: String,
1254    pub deterministic_mode: bool,
1255    pub solver_method: String,
1256    pub preconditioner: String,
1257    pub quality_policy: String,
1258    pub fallback_events: Vec<String>,
1259}
1260
1261#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1262pub struct AnalysisRenderTopology {
1263    pub schema_version: String,
1264    pub source: AnalysisRenderTopologySource,
1265    pub meshes: Vec<AnalysisRenderMesh>,
1266}
1267
1268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1269#[serde(rename_all = "snake_case")]
1270pub enum AnalysisRenderTopologySource {
1271    SolverPrep,
1272    AnalysisMesh,
1273}
1274
1275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1276pub struct AnalysisRenderMesh {
1277    pub mesh_id: String,
1278    pub vertices: Vec<[f64; 3]>,
1279    pub triangles: Vec<[u32; 3]>,
1280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1281    pub regions: Vec<AnalysisRenderRegion>,
1282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1283    pub vertex_volume_node_indices: Vec<Option<usize>>,
1284    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1285    pub triangle_volume_element_indices: Vec<Option<usize>>,
1286}
1287
1288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1289pub struct AnalysisRenderRegion {
1290    pub region_id: String,
1291    #[serde(default, skip_serializing_if = "Option::is_none")]
1292    pub label: Option<String>,
1293    #[serde(default, skip_serializing_if = "Option::is_none")]
1294    pub tag: Option<String>,
1295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1296    pub triangle_ranges: Vec<AnalysisRenderTriangleRange>,
1297}
1298
1299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1300pub struct AnalysisRenderTriangleRange {
1301    pub start: u32,
1302    pub count: u32,
1303}
1304
1305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1306pub struct AnalysisRunResult {
1307    pub run_id: String,
1308    pub run: FeaRunResult,
1309    #[serde(default, skip_serializing_if = "Option::is_none")]
1310    pub render_topology: Option<AnalysisRenderTopology>,
1311    pub modal_results: Option<ModalResultsData>,
1312    #[serde(default)]
1313    pub thermal_results: Option<ThermalResultsData>,
1314    pub transient_results: Option<TransientResultsData>,
1315    pub nonlinear_results: Option<NonlinearResultsData>,
1316    #[serde(default)]
1317    pub electromagnetic_results: Option<ElectromagneticResultsData>,
1318    pub model_validity: QualityGate,
1319    pub solver_convergence: QualityGate,
1320    pub result_quality: QualityGate,
1321    pub run_status: RunStatus,
1322    pub publishable: bool,
1323    pub quality_reasons: Vec<QualityReason>,
1324    pub provenance: RunProvenance,
1325}
1326
1327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1328pub struct AnalysisArtifactRecord {
1329    pub run_id: String,
1330    pub created_at: String,
1331    pub op_version: String,
1332    pub field_ids: Vec<String>,
1333}
1334
1335#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1336pub struct AnalysisResultsQuery {
1337    pub include_fields: Vec<String>,
1338    pub include_field_values: bool,
1339    pub include_diagnostics: bool,
1340    pub diagnostic_codes: Vec<String>,
1341    pub include_modal_results: bool,
1342    pub mode_indices: Vec<usize>,
1343    pub include_transient_results: bool,
1344    pub transient_snapshot_indices: Vec<usize>,
1345    pub include_nonlinear_results: bool,
1346    pub include_electromagnetic_results: bool,
1347}
1348
1349impl Default for AnalysisResultsQuery {
1350    fn default() -> Self {
1351        Self {
1352            include_fields: Vec::new(),
1353            include_field_values: true,
1354            include_diagnostics: true,
1355            diagnostic_codes: Vec::new(),
1356            include_modal_results: true,
1357            mode_indices: Vec::new(),
1358            include_transient_results: true,
1359            transient_snapshot_indices: Vec::new(),
1360            include_nonlinear_results: true,
1361            include_electromagnetic_results: true,
1362        }
1363    }
1364}
1365
1366impl AnalysisResultsQuery {
1367    pub fn metadata_only() -> Self {
1368        Self {
1369            include_field_values: false,
1370            include_modal_results: false,
1371            include_transient_results: false,
1372            include_nonlinear_results: false,
1373            include_electromagnetic_results: false,
1374            ..Self::default()
1375        }
1376    }
1377
1378    pub fn field_values(field_id: impl Into<String>) -> Self {
1379        Self {
1380            include_fields: vec![field_id.into()],
1381            include_field_values: true,
1382            include_diagnostics: false,
1383            include_modal_results: false,
1384            include_transient_results: false,
1385            include_nonlinear_results: false,
1386            include_electromagnetic_results: false,
1387            ..Self::default()
1388        }
1389    }
1390}
1391
1392#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1393#[serde(rename_all = "snake_case")]
1394pub enum AnalysisFieldKind {
1395    Scalar,
1396    Vector,
1397    Tensor,
1398    Unknown,
1399}
1400
1401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1402#[serde(rename_all = "snake_case")]
1403pub enum AnalysisFieldStorage {
1404    HostF64,
1405    DeviceRef,
1406}
1407
1408#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1409#[serde(rename_all = "snake_case")]
1410pub enum AnalysisFieldLocation {
1411    Node,
1412    Element,
1413    Edge,
1414    BoundaryFace,
1415    InterfaceFace,
1416    Mode,
1417    Global,
1418    #[default]
1419    Unknown,
1420}
1421
1422pub const ANALYSIS_FIELD_DEFAULT_PAGE_SIZE: usize = 4096;
1423pub const ANALYSIS_FIELD_DEFAULT_MATERIALIZE_LIMIT: usize = 256;
1424pub const ANALYSIS_RUN_DATASET_SCHEMA_VERSION: u32 = 1;
1425pub const ANALYSIS_FIELD_DESCRIPTORS_SCHEMA_VERSION: u32 = 1;
1426pub const ANALYSIS_DIAGNOSTICS_SCHEMA_VERSION: u32 = 1;
1427pub const ANALYSIS_OBJECT_ARTIFACT_METADATA_SCHEMA_VERSION: u32 = 1;
1428pub const ANALYSIS_RUN_DATASET_KIND: &str = "finite_element_run_dataset";
1429pub const ANALYSIS_DATASET_ARTIFACT_KIND: &str = "finite_element_dataset";
1430pub const ANALYSIS_FIELD_DESCRIPTORS_ARTIFACT_KIND: &str = "finite_element_field_descriptors";
1431pub const ANALYSIS_DIAGNOSTICS_ARTIFACT_KIND: &str = "finite_element_diagnostics";
1432pub const ANALYSIS_ARTIFACT_MANIFEST_KIND: &str = "finite_element_artifact_manifest";
1433
1434#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1435#[serde(rename_all = "snake_case")]
1436pub enum AnalysisDocumentKind {
1437    Study,
1438    Sweep,
1439}
1440
1441#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1442#[serde(rename_all = "camelCase")]
1443pub struct AnalysisRuntimeCapabilities {
1444    pub supported_document_extensions: Vec<String>,
1445    pub supported_geometry_extensions: Vec<String>,
1446    #[serde(default)]
1447    pub physics_profiles: Vec<AnalysisRuntimePhysicsProfileCatalogEntry>,
1448    pub supports_check: bool,
1449    pub supports_run: bool,
1450    pub supports_results: bool,
1451    pub supports_live_progress: bool,
1452    pub visualization_backend: String,
1453}
1454
1455#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1456#[serde(rename_all = "camelCase")]
1457pub struct AnalysisDocumentCheckResult {
1458    pub path: String,
1459    pub document_kind: AnalysisDocumentKind,
1460    pub valid: bool,
1461    pub validation: JsonValue,
1462    #[serde(skip_serializing_if = "Option::is_none")]
1463    pub plan: Option<JsonValue>,
1464    pub diagnostics: Vec<JsonValue>,
1465    pub evidence_artifact_paths: Vec<String>,
1466}
1467
1468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1469#[serde(rename_all = "camelCase")]
1470pub struct AnalysisDocumentRunResult {
1471    pub path: String,
1472    pub document_kind: AnalysisDocumentKind,
1473    pub run: JsonValue,
1474    #[serde(skip_serializing_if = "Option::is_none")]
1475    pub results: Option<JsonValue>,
1476    pub field_descriptors: Vec<AnalysisFieldDescriptor>,
1477    #[serde(skip_serializing_if = "Option::is_none")]
1478    pub result_summary: Option<JsonValue>,
1479    pub figure_handles: Vec<u32>,
1480    #[serde(skip_serializing_if = "Option::is_none")]
1481    pub artifact_manifest: Option<JsonValue>,
1482    pub diagnostics: Vec<JsonValue>,
1483    pub progress_events: Vec<FeaProgressEvent>,
1484}
1485
1486#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1487#[serde(rename_all = "camelCase")]
1488pub struct AnalysisFieldRequestOptions {
1489    #[serde(default)]
1490    #[serde(skip_serializing_if = "Option::is_none")]
1491    pub offset: Option<usize>,
1492    #[serde(default)]
1493    #[serde(skip_serializing_if = "Option::is_none")]
1494    pub limit: Option<usize>,
1495}
1496
1497#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1498#[serde(rename_all = "camelCase")]
1499pub struct AnalysisFieldPageResult {
1500    pub run_id: String,
1501    pub field_id: String,
1502    pub descriptor: AnalysisFieldDescriptor,
1503    pub values: Vec<f64>,
1504    pub offset: usize,
1505    pub count: usize,
1506    pub total_count: usize,
1507    pub truncated: bool,
1508}
1509
1510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1511#[serde(rename_all = "camelCase")]
1512pub struct AnalysisRunDatasetStudyRef {
1513    pub path: String,
1514    pub document_kind: Option<AnalysisDocumentKind>,
1515    pub valid: Option<bool>,
1516    pub evidence_artifact_paths: Vec<String>,
1517}
1518
1519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1520#[serde(rename_all = "camelCase")]
1521pub struct AnalysisRunDatasetFieldPagingPolicy {
1522    pub mode: String,
1523    pub policy: String,
1524}
1525
1526#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1527#[serde(rename_all = "camelCase")]
1528pub struct AnalysisRunDatasetPayload {
1529    pub schema_version: u32,
1530    pub kind: String,
1531    pub run_id: String,
1532    pub doc_path: String,
1533    pub study: AnalysisRunDatasetStudyRef,
1534    #[serde(default)]
1535    #[serde(skip_serializing_if = "Option::is_none")]
1536    pub analysis_profile: Option<AnalysisCreateModelProfile>,
1537    #[serde(default)]
1538    #[serde(skip_serializing_if = "Option::is_none")]
1539    pub physics_family: Option<String>,
1540    #[serde(default)]
1541    #[serde(skip_serializing_if = "Option::is_none")]
1542    pub analysis_run_kind: Option<AnalysisRunKind>,
1543    #[serde(default)]
1544    #[serde(skip_serializing_if = "Option::is_none")]
1545    pub available_output_summary: Option<String>,
1546    pub run: JsonValue,
1547    pub result_summary: JsonValue,
1548    pub fields: Vec<AnalysisFieldDescriptor>,
1549    pub field_paging: AnalysisRunDatasetFieldPagingPolicy,
1550    pub diagnostics: Vec<JsonValue>,
1551    pub artifact_manifest: JsonValue,
1552}
1553
1554#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1555#[serde(rename_all = "camelCase")]
1556pub struct AnalysisFieldDescriptorsArtifactPayload {
1557    pub schema_version: u32,
1558    pub run_id: String,
1559    pub fields: Vec<AnalysisFieldDescriptor>,
1560}
1561
1562#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1563#[serde(rename_all = "camelCase")]
1564pub struct AnalysisDiagnosticsArtifactPayload {
1565    pub schema_version: u32,
1566    pub run_id: String,
1567    pub diagnostics: Vec<JsonValue>,
1568}
1569
1570#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1571#[serde(rename_all = "camelCase")]
1572pub struct AnalysisObjectArtifactMetadata {
1573    pub schema_version: u32,
1574    pub artifact_id: String,
1575    pub src: String,
1576    pub bytes: u64,
1577    pub mime: String,
1578    pub kind: String,
1579    #[serde(skip_serializing_if = "Option::is_none")]
1580    pub handle: Option<u32>,
1581}
1582
1583#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1584#[serde(rename_all = "camelCase")]
1585pub struct AnalysisFieldPagingDescriptor {
1586    pub mode: String,
1587    pub value_unit: String,
1588    pub total_count: Option<usize>,
1589    pub page_size: usize,
1590    pub page_count: Option<usize>,
1591    pub default_materialize_limit: usize,
1592}
1593
1594impl AnalysisFieldPagingDescriptor {
1595    pub fn for_value_count(value_count: usize) -> Self {
1596        Self {
1597            mode: "paged".to_string(),
1598            value_unit: "scalar_component".to_string(),
1599            total_count: Some(value_count),
1600            page_size: ANALYSIS_FIELD_DEFAULT_PAGE_SIZE,
1601            page_count: Some(value_count.div_ceil(ANALYSIS_FIELD_DEFAULT_PAGE_SIZE)),
1602            default_materialize_limit: ANALYSIS_FIELD_DEFAULT_MATERIALIZE_LIMIT,
1603        }
1604    }
1605}
1606
1607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1608#[serde(rename_all = "camelCase")]
1609pub struct AnalysisFieldStorageRef {
1610    pub kind: String,
1611    pub field_id: String,
1612    pub storage: AnalysisFieldStorage,
1613    pub size_bytes: Option<u64>,
1614}
1615
1616#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1617pub struct AnalysisFieldDescriptor {
1618    pub field_id: String,
1619    #[serde(default)]
1620    pub family: String,
1621    #[serde(default)]
1622    pub quantity: String,
1623    #[serde(default)]
1624    pub topology_id: Option<String>,
1625    #[serde(default)]
1626    pub element_kind: Option<String>,
1627    pub class_name: String,
1628    pub kind: AnalysisFieldKind,
1629    pub dtype: String,
1630    #[serde(default)]
1631    pub unit: Option<String>,
1632    #[serde(default)]
1633    pub location: AnalysisFieldLocation,
1634    pub shape: Vec<usize>,
1635    pub element_count: usize,
1636    #[serde(default)]
1637    pub entity_count: usize,
1638    #[serde(default)]
1639    pub value_count: usize,
1640    pub component_count: Option<usize>,
1641    pub residency: String,
1642    pub storage: AnalysisFieldStorage,
1643    pub size_bytes: Option<u64>,
1644    pub paging: AnalysisFieldPagingDescriptor,
1645    #[serde(rename = "storageRef")]
1646    pub storage_ref: AnalysisFieldStorageRef,
1647}
1648
1649impl AnalysisFieldDescriptor {
1650    pub fn from_field(field: &AnalysisField) -> Self {
1651        let storage = match &field.values {
1652            AnalysisFieldValues::HostF64(_) => AnalysisFieldStorage::HostF64,
1653            AnalysisFieldValues::DeviceRef(_) => AnalysisFieldStorage::DeviceRef,
1654        };
1655        let kind = infer_field_kind(&field.field_id, &field.shape);
1656        let class_name = match kind {
1657            AnalysisFieldKind::Scalar => "fea.ScalarField",
1658            AnalysisFieldKind::Vector => "fea.VectorField",
1659            AnalysisFieldKind::Tensor => "fea.TensorField",
1660            AnalysisFieldKind::Unknown => "fea.Field",
1661        }
1662        .to_string();
1663        let residency = match storage {
1664            AnalysisFieldStorage::HostF64 => "cpu",
1665            AnalysisFieldStorage::DeviceRef => "gpu",
1666        }
1667        .to_string();
1668        let location = infer_field_location(&field.field_id);
1669        let component_count = infer_component_count(&field.field_id, &field.shape);
1670        let value_count = field.element_count();
1671        let entity_count = infer_entity_count(&field.shape, location, component_count);
1672        let size_bytes = value_count
1673            .checked_mul(std::mem::size_of::<f64>())
1674            .map(|bytes| bytes as u64);
1675        Self {
1676            field_id: field.field_id.clone(),
1677            family: infer_field_family(&field.field_id).to_string(),
1678            quantity: infer_field_quantity(&field.field_id).to_string(),
1679            topology_id: infer_field_topology_id(&field.field_id, location).map(str::to_string),
1680            element_kind: infer_field_element_kind(&field.field_id, location).map(str::to_string),
1681            class_name,
1682            kind,
1683            dtype: "double".to_string(),
1684            unit: infer_field_unit(&field.field_id).map(str::to_string),
1685            location,
1686            shape: field.shape.clone(),
1687            element_count: value_count,
1688            entity_count,
1689            value_count,
1690            component_count,
1691            residency,
1692            storage,
1693            size_bytes,
1694            paging: AnalysisFieldPagingDescriptor::for_value_count(value_count),
1695            storage_ref: AnalysisFieldStorageRef {
1696                kind: "runtime_field".to_string(),
1697                field_id: field.field_id.clone(),
1698                storage,
1699                size_bytes,
1700            },
1701        }
1702    }
1703}
1704
1705fn infer_entity_count(
1706    shape: &[usize],
1707    location: AnalysisFieldLocation,
1708    component_count: Option<usize>,
1709) -> usize {
1710    if matches!(
1711        location,
1712        AnalysisFieldLocation::Global | AnalysisFieldLocation::Mode
1713    ) {
1714        return shape.iter().product();
1715    }
1716    if let Some(first_dim) = shape.first().copied() {
1717        if shape.len() > 1 || component_count.is_some() {
1718            return first_dim;
1719        }
1720    }
1721    shape.iter().product()
1722}
1723
1724fn infer_field_family(field_id: &str) -> &str {
1725    field_id
1726        .split_once('.')
1727        .map_or("unknown", |(family, _)| family)
1728}
1729
1730fn infer_field_quantity(field_id: &str) -> &str {
1731    let Some((_, rest)) = field_id.split_once('.') else {
1732        return field_id;
1733    };
1734    let Some((quantity, suffix)) = rest.rsplit_once('.') else {
1735        return rest;
1736    };
1737    if suffix.chars().all(|ch| ch.is_ascii_digit()) {
1738        quantity
1739    } else {
1740        rest
1741    }
1742}
1743
1744fn infer_field_unit(field_id: &str) -> Option<&'static str> {
1745    let normalized = field_id.to_ascii_lowercase();
1746    if normalized.contains("frequency_hz") || normalized.contains("frequency_response") {
1747        return Some("Hz");
1748    }
1749    if normalized.contains("eigenvalue") {
1750        return Some("rad^2/s^2");
1751    }
1752    if normalized.contains("sound_pressure_level_db") {
1753        return Some("dB");
1754    }
1755    if normalized.contains("temperature_gradient") {
1756        return Some("K/m");
1757    }
1758    if normalized.contains("temperature") || normalized.contains("temperature_jump") {
1759        return Some("K");
1760    }
1761    if normalized.contains("electric_potential") {
1762        return Some("V");
1763    }
1764    if normalized.contains("electric_field") {
1765        return Some("V/m");
1766    }
1767    if normalized.contains("electric_flux_density") {
1768        return Some("C/m^2");
1769    }
1770    if normalized.contains("current_density") {
1771        return Some("A/m^2");
1772    }
1773    if normalized.contains("vector_potential") {
1774        return Some("Wb/m");
1775    }
1776    if normalized.contains("magnetic_flux_density") {
1777        return Some("T");
1778    }
1779    if normalized.contains("magnetic_field") {
1780        return Some("A/m");
1781    }
1782    if normalized.contains("poynting_vector")
1783        || normalized.contains("boundary_heat_flux")
1784        || normalized.contains("interface_heat_flux")
1785        || normalized.ends_with(".heat_flux")
1786        || normalized.contains(".heat_flux.")
1787    {
1788        return Some("W/m^2");
1789    }
1790    if normalized.contains("power_loss_density")
1791        || normalized.contains("joule_heat")
1792        || normalized.contains("heat_source")
1793    {
1794        return Some("W/m^3");
1795    }
1796    if normalized.contains("energy_density") {
1797        return Some("J/m^3");
1798    }
1799    if normalized.contains("kinetic_energy")
1800        || normalized.contains("strain_energy")
1801        || normalized.contains("total_strain_energy")
1802    {
1803        return Some("J");
1804    }
1805    if normalized.contains("modal_mass") {
1806        return Some("kg");
1807    }
1808    if normalized.contains("modal_stiffness") {
1809        return Some("N/m");
1810    }
1811    if normalized.contains("wall_shear_stress")
1812        || normalized.contains("contact_pressure")
1813        || normalized.contains("interface_pressure")
1814        || normalized.contains("fluid_pressure")
1815        || normalized.contains(".pressure")
1816        || normalized.contains("stress")
1817        || normalized.contains("traction")
1818        || normalized.contains("von_mises")
1819    {
1820        return Some("Pa");
1821    }
1822    if normalized.contains("reaction_force")
1823        || normalized.contains("beam_axial_force")
1824        || normalized.contains("beam_shear_force")
1825        || normalized.contains("shell_membrane_force")
1826        || normalized.contains("shell_transverse_shear")
1827    {
1828        return Some("N");
1829    }
1830    if normalized.contains("reaction_moment")
1831        || normalized.contains("beam_torsion_moment")
1832        || normalized.contains("beam_bending_moment")
1833        || normalized.contains("shell_bending_moment")
1834    {
1835        return Some("N*m");
1836    }
1837    if normalized.contains("rotation") {
1838        return Some("rad");
1839    }
1840    if normalized.contains("contact_gap")
1841        || normalized.contains("displacement")
1842        || normalized.contains("mode_shape")
1843    {
1844        return Some("m");
1845    }
1846    if normalized.contains("velocity") {
1847        return Some("m/s");
1848    }
1849    if normalized.contains("acceleration") {
1850        return Some("m/s^2");
1851    }
1852    if normalized.contains("strain")
1853        || normalized.contains("residual")
1854        || normalized.contains("equation_scale")
1855        || normalized.contains("load_factor")
1856        || normalized.contains("coupling_iteration_count")
1857        || normalized.contains("reynolds_number")
1858        || normalized.contains("phase")
1859        || normalized.contains("orthogonality")
1860        || normalized.contains("participation_factor")
1861        || normalized.contains("relative_frequency_separation")
1862    {
1863        return Some("1");
1864    }
1865    None
1866}
1867
1868fn infer_field_location(field_id: &str) -> AnalysisFieldLocation {
1869    let normalized = field_id.to_ascii_lowercase();
1870    if normalized.contains("nodal_von_mises") {
1871        return AnalysisFieldLocation::Node;
1872    }
1873    if normalized.contains("vector_potential") {
1874        return AnalysisFieldLocation::Edge;
1875    }
1876    if normalized.contains("wall_shear_stress") || normalized.contains("boundary_heat_flux") {
1877        return AnalysisFieldLocation::BoundaryFace;
1878    }
1879    if normalized.contains("interface_")
1880        || normalized.contains("contact_pressure")
1881        || normalized.contains("contact_gap")
1882    {
1883        return AnalysisFieldLocation::InterfaceFace;
1884    }
1885    if normalized.starts_with("modal.")
1886        && !normalized.starts_with("modal.mode_shape.")
1887        && !normalized.contains("orthogonality")
1888    {
1889        return AnalysisFieldLocation::Mode;
1890    }
1891    if normalized.contains("energy_density") {
1892        return AnalysisFieldLocation::Element;
1893    }
1894    if normalized.contains("residual")
1895        || normalized.contains("equation_scale")
1896        || normalized.contains("energy")
1897        || normalized.contains("load_factor")
1898        || normalized.contains("coupling_iteration_count")
1899        || normalized.contains("orthogonality")
1900    {
1901        return AnalysisFieldLocation::Global;
1902    }
1903    if normalized.starts_with("acoustic.") {
1904        return AnalysisFieldLocation::Node;
1905    }
1906    if normalized.contains("beam_") || normalized.contains("shell_") {
1907        return AnalysisFieldLocation::Element;
1908    }
1909    if normalized.contains("temperature_gradient")
1910        || normalized.contains("heat_flux")
1911        || normalized.contains("heat_source")
1912        || normalized.contains("joule_heat")
1913        || normalized.contains("magnetic_flux_density")
1914        || normalized.contains("magnetic_field")
1915        || normalized.contains("electric_field")
1916        || normalized.contains("electric_flux_density")
1917        || normalized.contains("current_density")
1918        || normalized.contains("poynting_vector")
1919        || normalized.contains("vorticity")
1920        || normalized.contains("pressure")
1921        || normalized.contains("stress")
1922        || normalized.contains("strain")
1923        || normalized.contains("von_mises")
1924    {
1925        return AnalysisFieldLocation::Element;
1926    }
1927    if normalized.contains("displacement")
1928        || normalized.contains("mode_shape")
1929        || normalized.contains("temperature")
1930        || normalized.starts_with("acoustic.")
1931        || normalized.contains("electric_potential")
1932    {
1933        return AnalysisFieldLocation::Node;
1934    }
1935    AnalysisFieldLocation::Element
1936}
1937
1938fn infer_field_topology_id(
1939    field_id: &str,
1940    location: AnalysisFieldLocation,
1941) -> Option<&'static str> {
1942    let normalized = field_id.to_ascii_lowercase();
1943    if location == AnalysisFieldLocation::Global || location == AnalysisFieldLocation::Mode {
1944        return None;
1945    }
1946    if normalized.starts_with("structural.")
1947        || normalized.starts_with("thermo_mechanical.")
1948        || normalized.starts_with("transient.")
1949        || normalized.starts_with("nonlinear.")
1950    {
1951        return Some("analysis_mesh");
1952    }
1953    if normalized.starts_with("thermal.")
1954        || normalized.starts_with("electro_thermal.")
1955        || normalized.starts_with("em.")
1956        || normalized.starts_with("acoustic.")
1957        || normalized.starts_with("cfd.")
1958        || normalized.starts_with("cht.")
1959        || normalized.starts_with("fsi.")
1960    {
1961        return Some("analysis_topology");
1962    }
1963    None
1964}
1965
1966fn infer_field_element_kind(
1967    field_id: &str,
1968    location: AnalysisFieldLocation,
1969) -> Option<&'static str> {
1970    let normalized = field_id.to_ascii_lowercase();
1971    if normalized.contains("beam_") {
1972        return Some("beam2");
1973    }
1974    if normalized.contains("shell_") {
1975        return Some("tri3");
1976    }
1977    if normalized.starts_with("structural.")
1978        && matches!(
1979            location,
1980            AnalysisFieldLocation::Element | AnalysisFieldLocation::BoundaryFace
1981        )
1982    {
1983        return Some("tetrahedron4");
1984    }
1985    None
1986}
1987
1988fn infer_field_kind(field_id: &str, shape: &[usize]) -> AnalysisFieldKind {
1989    let normalized = field_id.to_ascii_lowercase();
1990    if normalized.contains("magnitude") {
1991        return AnalysisFieldKind::Scalar;
1992    }
1993    if normalized.contains("heat_flux") {
1994        return match shape {
1995            [_, components] if (2..=3).contains(components) => AnalysisFieldKind::Vector,
1996            _ => AnalysisFieldKind::Scalar,
1997        };
1998    }
1999    if normalized.contains("beam_shear_force")
2000        || normalized.contains("beam_bending_moment")
2001        || normalized.contains("beam_bending_stress")
2002        || normalized.contains("shell_membrane_force")
2003        || normalized.contains("shell_bending_moment")
2004        || normalized.contains("shell_transverse_shear")
2005    {
2006        return AnalysisFieldKind::Vector;
2007    }
2008    if normalized.contains("shell_von_mises") {
2009        return AnalysisFieldKind::Scalar;
2010    }
2011    if normalized.contains("beam_axial_force")
2012        || normalized.contains("beam_torsion_moment")
2013        || normalized.contains("beam_torsion_stress")
2014    {
2015        return AnalysisFieldKind::Scalar;
2016    }
2017    if normalized.contains("temperature_gradient")
2018        || normalized.contains("wall_shear_stress")
2019        || normalized.contains("vorticity")
2020        || normalized.contains("velocity")
2021        || normalized.contains("traction")
2022        || normalized.contains("magnetic_field")
2023        || normalized.contains("electric_field")
2024        || normalized.contains("current_density")
2025    {
2026        return AnalysisFieldKind::Vector;
2027    }
2028    if normalized.contains("von_mises")
2029        || normalized.contains("equivalent_plastic_strain")
2030        || normalized.contains("pressure")
2031        || normalized.contains("phase")
2032        || normalized.contains("reynolds_number")
2033        || normalized.contains("temperature")
2034        || normalized.contains("energy")
2035        || normalized.contains("residual")
2036    {
2037        return AnalysisFieldKind::Scalar;
2038    }
2039    if normalized.contains("stress") || normalized.contains("strain") {
2040        return AnalysisFieldKind::Tensor;
2041    }
2042    if normalized.contains("orthogonality") {
2043        return AnalysisFieldKind::Tensor;
2044    }
2045    if normalized.contains("displacement")
2046        || normalized.contains("rotation")
2047        || normalized.contains("mode_shape")
2048        || normalized.contains("reaction_force")
2049        || normalized.contains("reaction_moment")
2050        || normalized.contains("vector")
2051        || normalized.contains("flux")
2052    {
2053        return AnalysisFieldKind::Vector;
2054    }
2055    match shape {
2056        [] | [_] => AnalysisFieldKind::Scalar,
2057        [_, 1] => AnalysisFieldKind::Scalar,
2058        [_, 2] | [_, 3] => AnalysisFieldKind::Vector,
2059        [_, _, ..] => AnalysisFieldKind::Tensor,
2060    }
2061}
2062
2063fn infer_component_count(field_id: &str, shape: &[usize]) -> Option<usize> {
2064    let normalized = field_id.to_ascii_lowercase();
2065    if normalized.contains("magnitude") {
2066        return None;
2067    }
2068    if normalized.contains("equivalent_plastic_strain") {
2069        return None;
2070    }
2071    if normalized.contains("heat_flux") {
2072        return match shape {
2073            [_, components] if (2..=3).contains(components) => Some(*components),
2074            _ => None,
2075        };
2076    }
2077    if normalized.contains("beam_shear_force")
2078        || normalized.contains("beam_bending_moment")
2079        || normalized.contains("beam_bending_stress")
2080    {
2081        return Some(2);
2082    }
2083    if normalized.contains("shell_membrane_force") || normalized.contains("shell_bending_moment") {
2084        return Some(3);
2085    }
2086    if normalized.contains("shell_transverse_shear") {
2087        return Some(2);
2088    }
2089    if normalized.contains("shell_von_mises") {
2090        return None;
2091    }
2092    if normalized.contains("beam_axial_force")
2093        || normalized.contains("beam_torsion_moment")
2094        || normalized.contains("beam_torsion_stress")
2095    {
2096        return None;
2097    }
2098    if normalized.contains("temperature_gradient")
2099        || normalized.contains("wall_shear_stress")
2100        || normalized.contains("vorticity")
2101        || normalized.contains("velocity")
2102        || normalized.contains("traction")
2103        || normalized.contains("magnetic_field")
2104        || normalized.contains("electric_field")
2105        || normalized.contains("current_density")
2106    {
2107        return Some(
2108            shape
2109                .last()
2110                .copied()
2111                .filter(|value| (2..=3).contains(value))
2112                .unwrap_or(3),
2113        );
2114    }
2115    if normalized.contains("von_mises")
2116        || normalized.contains("equivalent_plastic_strain")
2117        || normalized.contains("pressure")
2118        || normalized.contains("phase")
2119        || normalized.contains("reynolds_number")
2120        || normalized.contains("temperature")
2121        || normalized.contains("energy")
2122        || normalized.contains("residual")
2123    {
2124        return None;
2125    }
2126    if normalized.contains("displacement")
2127        || normalized.contains("rotation")
2128        || normalized.contains("mode_shape")
2129        || normalized.contains("reaction_force")
2130        || normalized.contains("reaction_moment")
2131        || normalized.contains("particle_velocity")
2132        || normalized.contains("vector")
2133        || normalized.contains("flux")
2134    {
2135        return Some(
2136            shape
2137                .last()
2138                .copied()
2139                .filter(|value| (2..=6).contains(value))
2140                .unwrap_or(3),
2141        );
2142    }
2143    if normalized.contains("stress") || normalized.contains("strain") {
2144        return Some(
2145            shape
2146                .last()
2147                .copied()
2148                .filter(|value| *value == 6)
2149                .unwrap_or(6),
2150        );
2151    }
2152    if normalized.contains("orthogonality") {
2153        return None;
2154    }
2155    match shape {
2156        [_, count] if (1..=6).contains(count) => Some(*count),
2157        _ => None,
2158    }
2159}
2160
2161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2162pub struct AnalysisResultsSummary {
2163    pub field_count: usize,
2164    pub total_elements: usize,
2165    pub mode_count: usize,
2166    pub available_mode_indices: Vec<usize>,
2167    pub min_frequency_hz: Option<f64>,
2168    pub max_frequency_hz: Option<f64>,
2169    pub max_modal_residual_norm: Option<f64>,
2170    pub first_mode_converged: Option<bool>,
2171    pub snapshot_count: usize,
2172    pub time_start_s: Option<f64>,
2173    pub time_end_s: Option<f64>,
2174    pub max_transient_residual_norm: Option<f64>,
2175    pub final_step_converged: Option<bool>,
2176    pub increment_count: usize,
2177    pub failed_increment_count: Option<usize>,
2178    pub max_nonlinear_residual_norm: Option<f64>,
2179    pub max_nonlinear_increment_norm: Option<f64>,
2180    pub max_nonlinear_iteration_count: Option<usize>,
2181    pub final_increment_converged: Option<bool>,
2182    pub nonlinear_line_search_backtracks: Option<usize>,
2183    pub nonlinear_max_backtracks_per_increment: Option<usize>,
2184    pub nonlinear_tangent_rebuild_count: Option<usize>,
2185    pub nonlinear_iteration_spike_count: Option<usize>,
2186    pub nonlinear_convergence_stall_count: Option<usize>,
2187    pub nonlinear_backtrack_burst_count: Option<usize>,
2188    pub prep_calibration_profile: Option<String>,
2189    pub prep_calibration_fingerprint: Option<u64>,
2190    pub prep_acceptance_score: Option<f64>,
2191    pub prep_acceptance_passed: Option<bool>,
2192    pub prep_acceptance_fingerprint: Option<u64>,
2193    pub thermo_coupling_enabled: Option<bool>,
2194    pub thermo_coupling_fingerprint: Option<u64>,
2195    pub thermo_constitutive_temperature_factor: Option<f64>,
2196    pub thermo_effective_modulus_scale: Option<f64>,
2197    pub thermo_constitutive_material_spread_ratio: Option<f64>,
2198    pub thermo_assignment_heterogeneity_index: Option<f64>,
2199    pub thermo_region_delta_count: Option<f64>,
2200    pub thermo_spatial_coverage_ratio: Option<f64>,
2201    pub thermo_field_extrapolation_ratio: Option<f64>,
2202    pub thermo_field_clamp_ratio: Option<f64>,
2203    pub thermo_transient_severity: Option<f64>,
2204    pub thermo_nonlinear_severity: Option<f64>,
2205    pub electro_thermal_coupling_enabled: Option<bool>,
2206    pub electro_thermal_coupling_fingerprint: Option<u64>,
2207    pub electro_joule_heating_scale: Option<f64>,
2208    pub electro_conductivity_spread_ratio: Option<f64>,
2209    pub electro_transient_severity: Option<f64>,
2210    pub electro_transient_time_scale_mean: Option<f64>,
2211    pub electro_nonlinear_severity: Option<f64>,
2212    pub electro_nonlinear_time_scale_mean: Option<f64>,
2213    pub plastic_nonlinear_severity: Option<f64>,
2214    pub plastic_nonlinear_severity_mean: Option<f64>,
2215    pub plastic_load_realization_ratio: Option<f64>,
2216    pub plastic_load_amplification_ratio: Option<f64>,
2217    pub contact_nonlinear_severity: Option<f64>,
2218    pub contact_nonlinear_severity_mean: Option<f64>,
2219    pub contact_load_realization_ratio: Option<f64>,
2220    pub contact_load_amplification_ratio: Option<f64>,
2221    pub thermal_max_residual_norm: Option<f64>,
2222    pub thermal_min_temperature_k: Option<f64>,
2223    pub thermal_max_temperature_k: Option<f64>,
2224    pub thermal_conductivity_spread_ratio: Option<f64>,
2225    pub thermal_heat_capacity_spread_ratio: Option<f64>,
2226    pub thermal_spatial_gradient_index: Option<f64>,
2227    pub thermal_monotonic_response_fraction: Option<f64>,
2228    pub thermal_response_realization_ratio: Option<f64>,
2229    pub electromagnetic_enabled: Option<bool>,
2230    pub electromagnetic_formulation_coverage_ratio: Option<f64>,
2231    pub electromagnetic_magnetostatic_curl_curl_coverage_ratio: Option<f64>,
2232    pub electromagnetic_magnetoquasistatic_eddy_current_coverage_ratio: Option<f64>,
2233    pub electromagnetic_full_wave_displacement_current_coverage_ratio: Option<f64>,
2234    pub electromagnetic_displacement_to_conduction_ratio: Option<f64>,
2235    pub electromagnetic_material_frequency_response_coverage_ratio: Option<f64>,
2236    pub electromagnetic_reference_frequency_hz: Option<f64>,
2237    pub electromagnetic_applied_current_a: Option<f64>,
2238    pub electromagnetic_solve_quality: Option<f64>,
2239    pub electromagnetic_conductivity_spread_ratio: Option<f64>,
2240    pub electromagnetic_relative_permittivity_spread_ratio: Option<f64>,
2241    pub electromagnetic_relative_permeability_spread_ratio: Option<f64>,
2242    pub electromagnetic_material_heterogeneity_index: Option<f64>,
2243    pub electromagnetic_assignment_coverage_ratio: Option<f64>,
2244    pub electromagnetic_assigned_coefficient_coverage_ratio: Option<f64>,
2245    pub electromagnetic_region_coefficient_contrast_index: Option<f64>,
2246    pub electromagnetic_condition_number_estimate: Option<f64>,
2247    pub electromagnetic_source_realization_ratio: Option<f64>,
2248    pub electromagnetic_source_region_coverage_ratio: Option<f64>,
2249    pub electromagnetic_source_material_alignment_ratio: Option<f64>,
2250    pub electromagnetic_source_localization_ratio: Option<f64>,
2251    pub electromagnetic_source_overlap_ratio: Option<f64>,
2252    pub electromagnetic_source_interference_index: Option<f64>,
2253    pub electromagnetic_boundary_anchor_ratio: Option<f64>,
2254    pub electromagnetic_boundary_condition_localization_ratio: Option<f64>,
2255    pub electromagnetic_ground_anchor_effectiveness_ratio: Option<f64>,
2256    pub electromagnetic_insulation_leakage_ratio: Option<f64>,
2257    pub electromagnetic_flux_divergence_ratio: Option<f64>,
2258    pub electromagnetic_energy_imbalance_ratio: Option<f64>,
2259    pub electromagnetic_boundary_energy_ratio: Option<f64>,
2260    pub electromagnetic_boundary_penalty_conditioning_contribution: Option<f64>,
2261    pub electromagnetic_source_region_energy_consistency_ratio: Option<f64>,
2262    pub electromagnetic_real_residual_norm: Option<f64>,
2263    pub electromagnetic_imag_residual_norm: Option<f64>,
2264    pub electromagnetic_sweep_count: Option<f64>,
2265    pub electromagnetic_resonance_peak_frequency_hz: Option<f64>,
2266    pub electromagnetic_resonance_peak_flux_density: Option<f64>,
2267    pub electromagnetic_resonance_bandwidth_hz: Option<f64>,
2268    pub electromagnetic_resonance_quality_factor: Option<f64>,
2269    pub electromagnetic_resonance_flux_gain: Option<f64>,
2270}
2271
2272#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2273pub struct AnalysisResultsData {
2274    #[serde(default)]
2275    pub field_descriptors: Vec<AnalysisFieldDescriptor>,
2276    pub fields: Vec<AnalysisField>,
2277    pub modal_results: Option<ModalResultsData>,
2278    #[serde(default)]
2279    pub thermal_results: Option<ThermalResultsData>,
2280    pub transient_results: Option<TransientResultsData>,
2281    pub nonlinear_results: Option<NonlinearResultsData>,
2282    #[serde(default)]
2283    pub electromagnetic_results: Option<ElectromagneticResultsData>,
2284    pub diagnostics: Option<Vec<FeaDiagnostic>>,
2285    pub run_status: RunStatus,
2286    pub publishable: bool,
2287    pub quality_reasons: Vec<QualityReason>,
2288    pub provenance: RunProvenance,
2289    pub summary: AnalysisResultsSummary,
2290}
2291
2292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2293pub struct AnalysisResultsCompareQuery {
2294    pub baseline_run_id: String,
2295    pub candidate_run_id: String,
2296}
2297
2298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2299pub struct AnalysisResultsCompareData {
2300    pub baseline_run_id: String,
2301    pub candidate_run_id: String,
2302    pub publishable_changed: bool,
2303    pub run_status_changed: bool,
2304    pub quality_reason_count_delta: i64,
2305    pub failed_increment_delta: Option<i64>,
2306    pub max_iteration_delta: Option<i64>,
2307    pub nonlinear_spike_count_delta: Option<i64>,
2308    pub nonlinear_stall_count_delta: Option<i64>,
2309    pub solve_ms_delta: Option<f64>,
2310}
2311
2312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2313pub struct AnalysisTrendsQuery {
2314    pub window_size: usize,
2315}
2316
2317impl Default for AnalysisTrendsQuery {
2318    fn default() -> Self {
2319        Self { window_size: 16 }
2320    }
2321}
2322
2323#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2324pub struct AnalysisOutputRequest {
2325    pub id: String,
2326    pub field_id: String,
2327    #[serde(default, skip_serializing_if = "Option::is_none")]
2328    pub location: Option<String>,
2329    #[serde(default, skip_serializing_if = "Option::is_none")]
2330    pub kind: Option<String>,
2331}
2332
2333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2334pub struct AnalysisStudySpec {
2335    pub study_id: String,
2336    pub geometry: GeometryAsset,
2337    pub create_model_intent: AnalysisCreateModelIntentSpec,
2338    #[serde(default)]
2339    pub model: Option<AnalysisModel>,
2340    pub run_kind: AnalysisRunKind,
2341    pub backend: ComputeBackend,
2342    #[serde(default)]
2343    pub mesh_options: Option<VolumeMeshingOptions>,
2344    #[serde(default)]
2345    pub outputs: Vec<AnalysisOutputRequest>,
2346    #[serde(default, skip_serializing_if = "Option::is_none")]
2347    pub analysis_mesh_artifact_path: Option<String>,
2348    #[serde(default, skip_serializing_if = "Option::is_none")]
2349    pub analysis_mesh_evidence_artifact_path: Option<String>,
2350    #[serde(default)]
2351    pub linear_static_run_options: Option<AnalysisRunOptions>,
2352    #[serde(default)]
2353    pub modal_run_options: Option<AnalysisModalRunOptions>,
2354    #[serde(default)]
2355    pub acoustic_run_options: Option<AnalysisAcousticRunOptions>,
2356    #[serde(default)]
2357    pub thermal_run_options: Option<AnalysisThermalRunOptions>,
2358    #[serde(default)]
2359    pub transient_run_options: Option<AnalysisTransientRunOptions>,
2360    #[serde(default)]
2361    pub cfd_run_options: Option<AnalysisCfdRunOptions>,
2362    #[serde(default)]
2363    pub cht_run_options: Option<AnalysisChtRunOptions>,
2364    #[serde(default)]
2365    pub fsi_run_options: Option<AnalysisFsiRunOptions>,
2366    #[serde(default)]
2367    pub nonlinear_run_options: Option<AnalysisNonlinearRunOptions>,
2368    #[serde(default)]
2369    pub electromagnetic_run_options: Option<AnalysisElectromagneticRunOptions>,
2370}
2371
2372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
2373pub struct AnalysisStudyDiagramObservation {
2374    #[serde(default, skip_serializing_if = "Option::is_none")]
2375    pub artifact_path: Option<String>,
2376    #[serde(default, skip_serializing_if = "Option::is_none")]
2377    pub source_mime_type: Option<String>,
2378    #[serde(default, skip_serializing_if = "Option::is_none")]
2379    pub summary: Option<String>,
2380    #[serde(default, skip_serializing_if = "Option::is_none")]
2381    pub material_region_id: Option<String>,
2382    #[serde(default, skip_serializing_if = "Option::is_none")]
2383    pub boundary_condition_region_id: Option<String>,
2384    #[serde(default, skip_serializing_if = "Option::is_none")]
2385    pub driving_condition_region_id: Option<String>,
2386    #[serde(default, skip_serializing_if = "Option::is_none")]
2387    pub structural_force_n: Option<[f64; 3]>,
2388    #[serde(default, skip_serializing_if = "Option::is_none")]
2389    pub confidence: Option<f64>,
2390}
2391
2392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2393pub struct AnalysisStudyAuthoringIntent {
2394    pub study_id: String,
2395    #[serde(default)]
2396    pub model_id: Option<String>,
2397    pub geometry: GeometryAsset,
2398    pub mesh_authoring_summary: MeshAuthoringSummary,
2399    pub profile: AnalysisCreateModelProfile,
2400    pub run_kind: AnalysisRunKind,
2401    pub backend: ComputeBackend,
2402    #[serde(default)]
2403    pub analysis_mesh_artifact_path: Option<String>,
2404    #[serde(default)]
2405    pub analysis_mesh_evidence_artifact_path: Option<String>,
2406    #[serde(default)]
2407    pub material_region_id: Option<String>,
2408    #[serde(default)]
2409    pub boundary_condition_region_id: Option<String>,
2410    #[serde(default)]
2411    pub driving_condition_region_id: Option<String>,
2412    #[serde(default)]
2413    pub structural_force_n: Option<[f64; 3]>,
2414    #[serde(default, skip_serializing_if = "Option::is_none")]
2415    pub diagram_observation: Option<AnalysisStudyDiagramObservation>,
2416}
2417
2418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2419pub struct AnalysisStudyAuthoringData {
2420    pub study: AnalysisStudySpec,
2421    pub evidence: AnalysisStudyAuthoringEvidence,
2422    pub evidence_artifact_path: String,
2423}
2424
2425#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2426pub struct AnalysisStudyAuthoringEvidence {
2427    pub schema_version: String,
2428    pub mesh_id: String,
2429    pub mesh_authoring_summary_schema_version: String,
2430    #[serde(default)]
2431    pub tetrahedron_generation_family: String,
2432    #[serde(default)]
2433    pub tetrahedron_generation_attempted_family_count: usize,
2434    #[serde(default)]
2435    pub tetrahedron_generation_rejected_family_count: usize,
2436    #[serde(default)]
2437    pub tetrahedron_generation_selected_family_index: usize,
2438    #[serde(default)]
2439    pub tetrahedron_generation_interior_support_candidate_count: usize,
2440    #[serde(default)]
2441    pub tetrahedron_generation_interior_support_accepted_count: usize,
2442    #[serde(
2443        default,
2444        skip_serializing_if = "MeshAuthoringNestedTetrahedronShellSummary::is_empty"
2445    )]
2446    pub nested_tetrahedron_shell: MeshAuthoringNestedTetrahedronShellSummary,
2447    pub selected_material_region_id: String,
2448    #[serde(default, skip_serializing_if = "Option::is_none")]
2449    pub selected_boundary_condition_region_id: Option<String>,
2450    #[serde(default, skip_serializing_if = "Option::is_none")]
2451    pub selected_driving_condition_region_id: Option<String>,
2452    #[serde(default, skip_serializing_if = "Option::is_none")]
2453    pub selected_driving_condition_kind: Option<String>,
2454    #[serde(default, skip_serializing_if = "Option::is_none")]
2455    pub selected_structural_force_n: Option<[f64; 3]>,
2456    #[serde(default, skip_serializing_if = "Option::is_none")]
2457    pub diagram_artifact_path: Option<String>,
2458    #[serde(default, skip_serializing_if = "Option::is_none")]
2459    pub diagram_source_mime_type: Option<String>,
2460    #[serde(default, skip_serializing_if = "Option::is_none")]
2461    pub diagram_summary: Option<String>,
2462    #[serde(default, skip_serializing_if = "Option::is_none")]
2463    pub diagram_confidence: Option<f64>,
2464    #[serde(default, skip_serializing_if = "Option::is_none")]
2465    pub analysis_mesh_artifact_path: Option<String>,
2466    #[serde(default, skip_serializing_if = "Option::is_none")]
2467    pub analysis_mesh_evidence_artifact_path: Option<String>,
2468    pub material_region_source: String,
2469    #[serde(default, skip_serializing_if = "Option::is_none")]
2470    pub boundary_condition_region_source: Option<String>,
2471    #[serde(default, skip_serializing_if = "Option::is_none")]
2472    pub driving_condition_region_source: Option<String>,
2473}
2474
2475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2476pub struct AnalysisStudyValidateResult {
2477    pub valid: bool,
2478    pub issue_codes: Vec<String>,
2479    #[serde(default)]
2480    pub issues: Vec<AnalysisStudyIssue>,
2481    pub evidence_artifact_path: String,
2482}
2483
2484#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2485pub struct AnalysisStudyIssue {
2486    pub code: String,
2487    pub message: String,
2488}
2489
2490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2491pub struct AnalysisStudyPlanData {
2492    pub study_id: String,
2493    pub model_id: String,
2494    pub run_kind: AnalysisRunKind,
2495    pub backend: ComputeBackend,
2496    #[serde(default)]
2497    pub electromagnetic_run_options: Option<AnalysisElectromagneticRunOptions>,
2498    #[serde(default)]
2499    pub run_options: serde_json::Value,
2500    pub operation_sequence: Vec<String>,
2501    pub run_operation: String,
2502    pub run_op_version: String,
2503    pub study_fingerprint: String,
2504    pub evidence_artifact_path: String,
2505}
2506
2507#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2508pub struct AnalysisStudyRunData {
2509    pub study_id: String,
2510    pub model_id: String,
2511    pub model_profile: AnalysisCreateModelProfile,
2512    pub run_kind: AnalysisRunKind,
2513    pub backend: ComputeBackend,
2514    #[serde(default)]
2515    pub electromagnetic_run_options: Option<AnalysisElectromagneticRunOptions>,
2516    #[serde(default)]
2517    pub run_options: serde_json::Value,
2518    #[serde(default)]
2519    pub prep_artifact_id: Option<String>,
2520    #[serde(default)]
2521    pub analysis_mesh_artifact_path: Option<String>,
2522    #[serde(default)]
2523    pub analysis_mesh_evidence_artifact_path: Option<String>,
2524    #[serde(default)]
2525    pub refined_analysis_mesh_artifact_path: Option<String>,
2526    #[serde(default)]
2527    pub refined_analysis_mesh_evidence_artifact_path: Option<String>,
2528    pub study_fingerprint: String,
2529    pub operation_sequence: Vec<String>,
2530    pub run_operation: String,
2531    pub run_op_version: String,
2532    pub run_id: String,
2533    pub run_status: RunStatus,
2534    pub publishable: bool,
2535    pub solver_convergence: QualityGate,
2536    pub result_quality: QualityGate,
2537    #[serde(default)]
2538    pub quality_reasons: Vec<QualityReason>,
2539    pub provenance: RunProvenance,
2540    pub evidence_artifact_path: String,
2541}
2542
2543#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2544pub struct AnalysisStudySweepSpec {
2545    pub sweep_id: String,
2546    pub studies: Vec<AnalysisStudySpec>,
2547    #[serde(default = "default_study_sweep_fail_fast")]
2548    pub fail_fast: bool,
2549}
2550
2551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2552pub struct AnalysisStudySweepValidateEntry {
2553    pub study_id: String,
2554    pub valid: bool,
2555    pub issue_codes: Vec<String>,
2556    #[serde(default)]
2557    pub issues: Vec<AnalysisStudyIssue>,
2558}
2559
2560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2561pub struct AnalysisStudySweepValidateData {
2562    pub sweep_id: String,
2563    pub valid: bool,
2564    pub issue_codes: Vec<String>,
2565    pub study_entries: Vec<AnalysisStudySweepValidateEntry>,
2566    pub evidence_artifact_path: String,
2567}
2568
2569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2570pub struct AnalysisStudySweepPlanEntry {
2571    pub study_id: String,
2572    pub model_id: String,
2573    pub run_kind: AnalysisRunKind,
2574    pub backend: ComputeBackend,
2575    #[serde(default)]
2576    pub electromagnetic_run_options: Option<AnalysisElectromagneticRunOptions>,
2577    #[serde(default)]
2578    pub run_options: serde_json::Value,
2579    pub operation_sequence: Vec<String>,
2580    pub run_operation: String,
2581    pub run_op_version: String,
2582    pub study_fingerprint: String,
2583}
2584
2585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2586pub struct AnalysisStudySweepPlanData {
2587    pub sweep_id: String,
2588    pub study_count: usize,
2589    pub planned_count: usize,
2590    pub failed_count: usize,
2591    #[serde(default)]
2592    pub failure_entries: Vec<AnalysisStudySweepFailureEntry>,
2593    pub plan_entries: Vec<AnalysisStudySweepPlanEntry>,
2594    pub evidence_artifact_path: String,
2595}
2596
2597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2598pub struct AnalysisStudySweepRunEntry {
2599    pub study_id: String,
2600    pub run_kind: AnalysisRunKind,
2601    pub run_id: String,
2602    pub run_status: RunStatus,
2603    pub publishable: bool,
2604    pub run_operation: String,
2605    pub run_op_version: String,
2606}
2607
2608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2609pub struct AnalysisStudySweepData {
2610    pub sweep_id: String,
2611    pub study_count: usize,
2612    pub success_count: usize,
2613    pub failed_count: usize,
2614    #[serde(default)]
2615    pub failure_entries: Vec<AnalysisStudySweepFailureEntry>,
2616    pub run_entries: Vec<AnalysisStudySweepRunEntry>,
2617    pub evidence_artifact_path: String,
2618}
2619
2620#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2621pub struct AnalysisStudySweepFailureEntry {
2622    pub study_id: String,
2623    pub study_index: usize,
2624    pub error_code: String,
2625    pub message: String,
2626}
2627
2628fn default_study_sweep_fail_fast() -> bool {
2629    true
2630}
2631
2632#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2633#[serde(rename_all = "snake_case")]
2634pub enum AnalysisRunKind {
2635    LinearStatic,
2636    Modal,
2637    Acoustic,
2638    Thermal,
2639    Transient,
2640    Cfd,
2641    Cht,
2642    Fsi,
2643    Nonlinear,
2644    Electromagnetic,
2645}
2646
2647impl AnalysisCreateModelProfile {
2648    pub fn from_snake_case(value: &str) -> Option<Self> {
2649        serde_json::from_value(JsonValue::String(value.trim().to_ascii_lowercase())).ok()
2650    }
2651
2652    pub fn as_snake_case(self) -> &'static str {
2653        match self {
2654            AnalysisCreateModelProfile::LinearStaticStructural => "linear_static_structural",
2655            AnalysisCreateModelProfile::ThermoMechanicalCoupled => "thermo_mechanical_coupled",
2656            AnalysisCreateModelProfile::ElectroThermalCoupled => "electro_thermal_coupled",
2657            AnalysisCreateModelProfile::ThermalStandalone => "thermal_standalone",
2658            AnalysisCreateModelProfile::ModalStructural => "modal_structural",
2659            AnalysisCreateModelProfile::AcousticHarmonic => "acoustic_harmonic",
2660            AnalysisCreateModelProfile::TransientStructural => "transient_structural",
2661            AnalysisCreateModelProfile::NonlinearStructural => "nonlinear_structural",
2662            AnalysisCreateModelProfile::ElectromagneticStatic => "electromagnetic_static",
2663            AnalysisCreateModelProfile::CfdSteadyState => "cfd_steady_state",
2664            AnalysisCreateModelProfile::CfdTransient => "cfd_transient",
2665            AnalysisCreateModelProfile::ChtCoupled => "cht_coupled",
2666            AnalysisCreateModelProfile::FsiCoupled => "fsi_coupled",
2667        }
2668    }
2669
2670    pub fn catalog_entry(self) -> &'static AnalysisPhysicsProfileCatalogEntry {
2671        ANALYSIS_PHYSICS_PROFILE_CATALOG
2672            .iter()
2673            .find(|entry| entry.profile == self)
2674            .expect("analysis profile catalog must contain every profile variant")
2675    }
2676
2677    pub fn derived_run_kind(self) -> AnalysisRunKind {
2678        debug_assert!(
2679            ANALYSIS_PHYSICS_PROFILE_CATALOG
2680                .iter()
2681                .any(|entry| entry.profile == self),
2682            "analysis profile catalog is missing {:?}",
2683            self
2684        );
2685        match self {
2686            AnalysisCreateModelProfile::LinearStaticStructural => AnalysisRunKind::LinearStatic,
2687            AnalysisCreateModelProfile::ThermoMechanicalCoupled => AnalysisRunKind::Transient,
2688            AnalysisCreateModelProfile::ElectroThermalCoupled => AnalysisRunKind::Transient,
2689            AnalysisCreateModelProfile::ThermalStandalone => AnalysisRunKind::Thermal,
2690            AnalysisCreateModelProfile::ModalStructural => AnalysisRunKind::Modal,
2691            AnalysisCreateModelProfile::AcousticHarmonic => AnalysisRunKind::Acoustic,
2692            AnalysisCreateModelProfile::TransientStructural => AnalysisRunKind::Transient,
2693            AnalysisCreateModelProfile::NonlinearStructural => AnalysisRunKind::Nonlinear,
2694            AnalysisCreateModelProfile::ElectromagneticStatic => AnalysisRunKind::Electromagnetic,
2695            AnalysisCreateModelProfile::CfdSteadyState
2696            | AnalysisCreateModelProfile::CfdTransient => AnalysisRunKind::Cfd,
2697            AnalysisCreateModelProfile::ChtCoupled => AnalysisRunKind::Cht,
2698            AnalysisCreateModelProfile::FsiCoupled => AnalysisRunKind::Fsi,
2699        }
2700    }
2701}
2702
2703impl AnalysisRunKind {
2704    pub const fn as_snake_case(self) -> &'static str {
2705        match self {
2706            AnalysisRunKind::LinearStatic => "linear_static",
2707            AnalysisRunKind::Modal => "modal",
2708            AnalysisRunKind::Acoustic => "acoustic",
2709            AnalysisRunKind::Thermal => "thermal",
2710            AnalysisRunKind::Transient => "transient",
2711            AnalysisRunKind::Cfd => "cfd",
2712            AnalysisRunKind::Cht => "cht",
2713            AnalysisRunKind::Fsi => "fsi",
2714            AnalysisRunKind::Nonlinear => "nonlinear",
2715            AnalysisRunKind::Electromagnetic => "electromagnetic",
2716        }
2717    }
2718}
2719
2720#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2721pub struct AnalysisTrendKindSummary {
2722    pub run_kind: AnalysisRunKind,
2723    pub sample_count: usize,
2724    pub median_solve_ms: Option<f64>,
2725    pub p95_solve_ms: Option<f64>,
2726    pub publishable_rate: f64,
2727    pub failed_increment_rate: Option<f64>,
2728    pub mean_spike_count: Option<f64>,
2729    pub mean_stall_count: Option<f64>,
2730    pub prep_acceptance_rate: Option<f64>,
2731    pub prep_calibration_fast_rate: Option<f64>,
2732    pub prep_calibration_balanced_rate: Option<f64>,
2733    pub prep_calibration_conservative_rate: Option<f64>,
2734    pub thermo_coupling_enabled_rate: Option<f64>,
2735    pub thermo_transient_warn_rate: Option<f64>,
2736    pub thermo_nonlinear_warn_rate: Option<f64>,
2737    pub thermo_spread_breach_rate: Option<f64>,
2738    pub thermo_heterogeneity_breach_rate: Option<f64>,
2739    pub electro_thermal_coupling_enabled_rate: Option<f64>,
2740    pub electro_transient_warn_rate: Option<f64>,
2741    pub electro_nonlinear_warn_rate: Option<f64>,
2742    pub plastic_nonlinear_warn_rate: Option<f64>,
2743    pub contact_nonlinear_warn_rate: Option<f64>,
2744    pub thermal_stability_warn_rate: Option<f64>,
2745    pub thermal_constitutive_warn_rate: Option<f64>,
2746    pub thermal_spread_breach_rate: Option<f64>,
2747    pub electromagnetic_solve_warn_rate: Option<f64>,
2748    pub electromagnetic_spread_breach_rate: Option<f64>,
2749    pub electromagnetic_heterogeneity_breach_rate: Option<f64>,
2750    pub electromagnetic_coverage_breach_rate: Option<f64>,
2751    pub electromagnetic_contrast_breach_rate: Option<f64>,
2752    pub electromagnetic_conditioning_breach_rate: Option<f64>,
2753    pub electromagnetic_source_realization_breach_rate: Option<f64>,
2754    pub electromagnetic_source_region_coverage_breach_rate: Option<f64>,
2755    pub electromagnetic_source_material_alignment_breach_rate: Option<f64>,
2756    pub electromagnetic_source_overlap_breach_rate: Option<f64>,
2757    pub electromagnetic_source_interference_breach_rate: Option<f64>,
2758    pub electromagnetic_boundary_anchor_breach_rate: Option<f64>,
2759    pub electromagnetic_boundary_localization_breach_rate: Option<f64>,
2760    pub electromagnetic_ground_effectiveness_breach_rate: Option<f64>,
2761    pub electromagnetic_insulation_leakage_breach_rate: Option<f64>,
2762    pub electromagnetic_divergence_breach_rate: Option<f64>,
2763    pub electromagnetic_energy_imbalance_breach_rate: Option<f64>,
2764    pub electromagnetic_boundary_energy_breach_rate: Option<f64>,
2765    pub electromagnetic_boundary_penalty_contribution_breach_rate: Option<f64>,
2766    pub electromagnetic_source_region_energy_consistency_breach_rate: Option<f64>,
2767    pub electromagnetic_real_residual_breach_rate: Option<f64>,
2768    pub electromagnetic_imag_residual_breach_rate: Option<f64>,
2769    pub electromagnetic_sweep_coverage_breach_rate: Option<f64>,
2770    pub electromagnetic_resonance_sharpness_breach_rate: Option<f64>,
2771}
2772
2773#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2774pub struct AnalysisTrendsData {
2775    pub window_size: usize,
2776    pub summaries: Vec<AnalysisTrendKindSummary>,
2777}
2778
2779#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2780pub struct ModalResultsData {
2781    pub modal_payload_version: String,
2782    pub eigenvalues_hz: Vec<f64>,
2783    pub mode_shapes: Vec<AnalysisField>,
2784    pub residual_norms: Vec<f64>,
2785    pub mode_units: ModalFrequencyUnits,
2786    pub frequency_basis: ModalFrequencyBasis,
2787}
2788
2789#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2790pub struct ThermalResultsData {
2791    pub thermal_payload_version: String,
2792    pub time_points_s: Vec<f64>,
2793    pub temperature_snapshots: Vec<AnalysisField>,
2794    #[serde(default)]
2795    pub temperature_gradient_snapshots: Vec<AnalysisField>,
2796    #[serde(default)]
2797    pub heat_flux_snapshots: Vec<AnalysisField>,
2798    #[serde(default)]
2799    pub heat_source_snapshots: Vec<AnalysisField>,
2800    #[serde(default)]
2801    pub boundary_heat_flux_snapshots: Vec<AnalysisField>,
2802    pub residual_norms: Vec<f64>,
2803    pub reference_temperature_k: f64,
2804}
2805
2806#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2807pub struct TransientResultsData {
2808    pub transient_payload_version: String,
2809    pub time_points_s: Vec<f64>,
2810    pub displacement_snapshots: Vec<AnalysisField>,
2811    #[serde(default)]
2812    pub rotation_snapshots: Vec<AnalysisField>,
2813    #[serde(default)]
2814    pub velocity_snapshots: Vec<AnalysisField>,
2815    #[serde(default)]
2816    pub angular_velocity_snapshots: Vec<AnalysisField>,
2817    #[serde(default)]
2818    pub acceleration_snapshots: Vec<AnalysisField>,
2819    #[serde(default)]
2820    pub angular_acceleration_snapshots: Vec<AnalysisField>,
2821    #[serde(default)]
2822    pub von_mises_snapshots: Vec<AnalysisField>,
2823    #[serde(default)]
2824    pub kinetic_energy_snapshots: Vec<AnalysisField>,
2825    #[serde(default)]
2826    pub strain_energy_snapshots: Vec<AnalysisField>,
2827    #[serde(default)]
2828    pub residual_norm_snapshots: Vec<AnalysisField>,
2829    #[serde(default)]
2830    pub thermo_mechanical_temperature_snapshots: Vec<AnalysisField>,
2831    #[serde(default)]
2832    pub thermo_mechanical_thermal_strain_snapshots: Vec<AnalysisField>,
2833    #[serde(default)]
2834    pub thermo_mechanical_thermal_stress_snapshots: Vec<AnalysisField>,
2835    #[serde(default)]
2836    pub thermo_mechanical_displacement_snapshots: Vec<AnalysisField>,
2837    #[serde(default)]
2838    pub thermo_mechanical_von_mises_snapshots: Vec<AnalysisField>,
2839    #[serde(default)]
2840    pub thermo_mechanical_coupling_residual_snapshots: Vec<AnalysisField>,
2841    #[serde(default)]
2842    pub electro_thermal_temperature_snapshots: Vec<AnalysisField>,
2843    #[serde(default)]
2844    pub electro_thermal_thermal_residual_snapshots: Vec<AnalysisField>,
2845    pub residual_norms: Vec<f64>,
2846    pub integration_method: TransientIntegrationMethod,
2847}
2848
2849#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2850pub struct NonlinearResultsData {
2851    pub nonlinear_payload_version: String,
2852    pub load_factors: Vec<f64>,
2853    pub displacement_snapshots: Vec<AnalysisField>,
2854    #[serde(default)]
2855    pub rotation_snapshots: Vec<AnalysisField>,
2856    #[serde(default)]
2857    pub von_mises_snapshots: Vec<AnalysisField>,
2858    #[serde(default)]
2859    pub plastic_strain_snapshots: Vec<AnalysisField>,
2860    #[serde(default)]
2861    pub equivalent_plastic_strain_snapshots: Vec<AnalysisField>,
2862    #[serde(default)]
2863    pub contact_pressure_snapshots: Vec<AnalysisField>,
2864    #[serde(default)]
2865    pub contact_gap_snapshots: Vec<AnalysisField>,
2866    #[serde(default)]
2867    pub load_factor_snapshots: Vec<AnalysisField>,
2868    #[serde(default)]
2869    pub residual_norm_snapshots: Vec<AnalysisField>,
2870    #[serde(default)]
2871    pub thermo_mechanical_temperature_snapshots: Vec<AnalysisField>,
2872    #[serde(default)]
2873    pub thermo_mechanical_thermal_strain_snapshots: Vec<AnalysisField>,
2874    #[serde(default)]
2875    pub thermo_mechanical_thermal_stress_snapshots: Vec<AnalysisField>,
2876    #[serde(default)]
2877    pub thermo_mechanical_displacement_snapshots: Vec<AnalysisField>,
2878    #[serde(default)]
2879    pub thermo_mechanical_von_mises_snapshots: Vec<AnalysisField>,
2880    #[serde(default)]
2881    pub thermo_mechanical_coupling_residual_snapshots: Vec<AnalysisField>,
2882    #[serde(default)]
2883    pub electro_thermal_temperature_snapshots: Vec<AnalysisField>,
2884    #[serde(default)]
2885    pub electro_thermal_thermal_residual_snapshots: Vec<AnalysisField>,
2886    pub residual_norms: Vec<f64>,
2887    #[serde(default)]
2888    pub increment_norms: Vec<f64>,
2889    #[serde(default)]
2890    pub iteration_counts: Vec<usize>,
2891    #[serde(default)]
2892    pub failed_increments: usize,
2893    #[serde(default)]
2894    pub line_search_backtracks: usize,
2895    #[serde(default)]
2896    pub max_line_search_backtracks_per_increment: usize,
2897    #[serde(default)]
2898    pub tangent_rebuild_count: usize,
2899    #[serde(default)]
2900    pub iteration_spike_count: usize,
2901    #[serde(default)]
2902    pub convergence_stall_count: usize,
2903    #[serde(default)]
2904    pub backtrack_burst_count: usize,
2905    pub method: NonlinearMethod,
2906}
2907
2908#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2909pub struct ElectromagneticResultsData {
2910    pub electromagnetic_payload_version: String,
2911    pub reference_frequency_hz: f64,
2912    pub applied_current_a: f64,
2913    pub vector_potential_real: AnalysisField,
2914    pub vector_potential_imag: AnalysisField,
2915    pub magnetic_flux_density_real: AnalysisField,
2916    pub magnetic_flux_density_imag: AnalysisField,
2917    pub magnetic_flux_density_magnitude: AnalysisField,
2918    pub magnetic_field_real: AnalysisField,
2919    pub magnetic_field_imag: AnalysisField,
2920    pub current_density_real: AnalysisField,
2921    pub current_density_imag: AnalysisField,
2922    pub electric_field_real: AnalysisField,
2923    pub electric_field_imag: AnalysisField,
2924    pub power_loss_density: AnalysisField,
2925    pub energy_density: AnalysisField,
2926    pub residual_real: AnalysisField,
2927    pub residual_imag: AnalysisField,
2928    pub electric_flux_density_real: AnalysisField,
2929    pub electric_flux_density_imag: AnalysisField,
2930    pub poynting_vector_real: AnalysisField,
2931    pub poynting_vector_imag: AnalysisField,
2932    #[serde(default)]
2933    pub sweep_frequency_hz: Vec<f64>,
2934    #[serde(default)]
2935    pub sweep_peak_flux_density: Vec<f64>,
2936    #[serde(default)]
2937    pub sweep_solve_quality: Vec<f64>,
2938    #[serde(default)]
2939    pub resonance_peak_frequency_hz: Option<f64>,
2940    #[serde(default)]
2941    pub resonance_peak_flux_density: Option<f64>,
2942    #[serde(default)]
2943    pub resonance_bandwidth_hz: Option<f64>,
2944    #[serde(default)]
2945    pub resonance_quality_factor: Option<f64>,
2946    #[serde(default)]
2947    pub resonance_flux_gain: Option<f64>,
2948}
2949
2950#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2951#[serde(rename_all = "snake_case")]
2952pub enum TransientIntegrationMethod {
2953    ImplicitEuler,
2954}
2955
2956#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2957#[serde(rename_all = "snake_case")]
2958pub enum NonlinearMethod {
2959    IncrementalNewtonRaphson,
2960}
2961
2962#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2963#[serde(rename_all = "snake_case")]
2964pub enum ModalFrequencyUnits {
2965    Hz,
2966}
2967
2968#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2969#[serde(rename_all = "snake_case")]
2970pub enum ModalFrequencyBasis {
2971    NativeEigenSolve,
2972}
2973
2974pub(crate) fn format_precision_mode(mode: PrecisionMode) -> String {
2975    match mode {
2976        PrecisionMode::Fp32 => "fp32".to_string(),
2977        PrecisionMode::Fp64 => "fp64".to_string(),
2978        PrecisionMode::Mixed => "mixed".to_string(),
2979    }
2980}
2981
2982pub(crate) fn format_quality_policy(mode: QualityPolicy) -> String {
2983    match mode {
2984        QualityPolicy::Strict => "strict".to_string(),
2985        QualityPolicy::Balanced => "balanced".to_string(),
2986        QualityPolicy::Exploratory => "exploratory".to_string(),
2987    }
2988}
2989
2990#[cfg(test)]
2991mod tests {
2992    use super::*;
2993    use std::collections::HashSet;
2994
2995    #[test]
2996    fn physics_profile_catalog_covers_every_supported_profile_once() {
2997        let expected_profiles = [
2998            AnalysisCreateModelProfile::LinearStaticStructural,
2999            AnalysisCreateModelProfile::ThermoMechanicalCoupled,
3000            AnalysisCreateModelProfile::ElectroThermalCoupled,
3001            AnalysisCreateModelProfile::ThermalStandalone,
3002            AnalysisCreateModelProfile::ModalStructural,
3003            AnalysisCreateModelProfile::AcousticHarmonic,
3004            AnalysisCreateModelProfile::TransientStructural,
3005            AnalysisCreateModelProfile::NonlinearStructural,
3006            AnalysisCreateModelProfile::ElectromagneticStatic,
3007            AnalysisCreateModelProfile::CfdSteadyState,
3008            AnalysisCreateModelProfile::CfdTransient,
3009            AnalysisCreateModelProfile::ChtCoupled,
3010            AnalysisCreateModelProfile::FsiCoupled,
3011        ];
3012
3013        assert_eq!(
3014            ANALYSIS_PHYSICS_PROFILE_CATALOG.len(),
3015            expected_profiles.len()
3016        );
3017
3018        let mut seen = HashSet::new();
3019        for entry in ANALYSIS_PHYSICS_PROFILE_CATALOG {
3020            let serialized_profile = serde_json::to_value(entry.profile)
3021                .expect("profile should serialize")
3022                .as_str()
3023                .expect("profile should serialize as string")
3024                .to_string();
3025            assert!(
3026                expected_profiles.contains(&entry.profile),
3027                "catalog contains unsupported profile {:?}",
3028                entry.profile
3029            );
3030            assert_eq!(entry.profile.as_snake_case(), serialized_profile);
3031            assert_eq!(
3032                AnalysisCreateModelProfile::from_snake_case(entry.profile.as_snake_case()),
3033                Some(entry.profile)
3034            );
3035            assert!(
3036                seen.insert(entry.profile as u8),
3037                "catalog contains duplicate profile {:?}",
3038                entry.profile
3039            );
3040            assert!(!entry.label.trim().is_empty());
3041            assert!(!entry.family.trim().is_empty());
3042            assert!(!entry.target.trim().is_empty());
3043            assert!(!entry.value.trim().is_empty());
3044            assert!(
3045                !entry.default_outputs.is_empty(),
3046                "catalog profile {:?} needs at least one default output",
3047                entry.profile
3048            );
3049            for output in entry.default_outputs {
3050                assert!(!output.field.trim().is_empty());
3051                assert!(!output.location.trim().is_empty());
3052            }
3053        }
3054
3055        for run_kind in [
3056            AnalysisRunKind::LinearStatic,
3057            AnalysisRunKind::Modal,
3058            AnalysisRunKind::Acoustic,
3059            AnalysisRunKind::Thermal,
3060            AnalysisRunKind::Transient,
3061            AnalysisRunKind::Cfd,
3062            AnalysisRunKind::Cht,
3063            AnalysisRunKind::Fsi,
3064            AnalysisRunKind::Nonlinear,
3065            AnalysisRunKind::Electromagnetic,
3066        ] {
3067            let serialized_run_kind = serde_json::to_value(run_kind)
3068                .expect("run kind should serialize")
3069                .as_str()
3070                .expect("run kind should serialize as string")
3071                .to_string();
3072            assert_eq!(run_kind.as_snake_case(), serialized_run_kind);
3073        }
3074    }
3075
3076    #[test]
3077    fn runtime_physics_profile_catalog_exposes_full_family_set() {
3078        let runtime_catalog = analysis_runtime_physics_profile_catalog();
3079
3080        assert_eq!(
3081            runtime_catalog.len(),
3082            ANALYSIS_PHYSICS_PROFILE_CATALOG.len(),
3083            "runtime profile catalog must serialize every supported physics profile"
3084        );
3085
3086        let families: HashSet<&str> = runtime_catalog
3087            .iter()
3088            .map(|entry| entry.family.as_str())
3089            .collect();
3090        for family in [
3091            "structural",
3092            "modal",
3093            "thermal",
3094            "coupled physics",
3095            "electromagnetic",
3096            "acoustic",
3097            "CFD",
3098        ] {
3099            assert!(
3100                families.contains(family),
3101                "runtime profile catalog is missing {family}"
3102            );
3103        }
3104
3105        for entry in runtime_catalog {
3106            assert!(!entry.label.trim().is_empty());
3107            assert!(!entry.target.trim().is_empty());
3108            assert!(!entry.value.trim().is_empty());
3109            assert!(!entry.default_outputs.is_empty());
3110        }
3111    }
3112}