Skip to main content

runmat_analysis_core/
lib.rs

1//! Solver-agnostic analysis problem model and validation contracts.
2
3pub mod problem {
4    pub mod bc;
5    pub mod domains;
6    pub mod interfaces;
7    pub mod loads;
8    pub mod material_assignment;
9    pub mod materials;
10    pub mod model;
11    pub mod steps;
12    pub mod structure;
13}
14pub mod field;
15pub mod validate;
16
17pub use field::{AnalysisField, AnalysisFieldValues, DeviceFieldRef};
18pub use problem::bc::{BoundaryCondition, BoundaryConditionKind};
19pub use problem::domains::{
20    CfdDomain, CfdSolveFamily, CfdTimeProfilePoint, ElectroRegionConductivityScale,
21    ElectroThermalDomain, ElectroTimeProfilePoint, ElectromagneticDomain,
22    ThermoFieldInterpolationMode, ThermoFieldSource, ThermoMechanicalDomain,
23    ThermoRegionTemperatureDelta, ThermoTimeProfilePoint,
24};
25pub use problem::interfaces::{
26    AnalysisInterface, AnalysisInterfaceKind, ConjugateHeatTransferInterfaceModel,
27    ContactInterfaceModel, FluidStructureInterfaceModel,
28};
29pub use problem::loads::{LoadCase, LoadKind};
30pub use problem::material_assignment::{EvidenceConfidence, MaterialAssignment};
31pub use problem::materials::{
32    ConductivityFrequencyPoint, MaterialAcousticModel, MaterialElectricalModel,
33    MaterialMechanicalModel, MaterialModel, MaterialPlasticModel, MaterialThermalModel,
34};
35pub use problem::model::{AnalysisModel, AnalysisModelId, ReferenceFrame};
36pub use problem::steps::{AnalysisStep, AnalysisStepKind};
37pub use problem::structure::{
38    BeamElementModel, BeamSectionModel, ShellElementModel, ShellSectionModel, StructuralElement,
39    StructuralElementKind, StructuralModel, StructuralNode,
40};
41pub use validate::{validate_model, validate_model_against_geometry, AnalysisValidationError};
42
43#[cfg(test)]
44mod tests {
45    use runmat_geometry_core::UnitSystem;
46
47    use super::*;
48
49    fn valid_model() -> AnalysisModel {
50        AnalysisModel {
51            model_id: AnalysisModelId("analysis_model_1".to_string()),
52            geometry_id: "geo:model_1".to_string(),
53            geometry_revision: 1,
54            units: UnitSystem::Meter,
55            frame: ReferenceFrame::Global,
56            materials: vec![MaterialModel {
57                material_id: "mat_steel".to_string(),
58                name: "Steel".to_string(),
59                mechanical: MaterialMechanicalModel {
60                    youngs_modulus_pa: 200e9,
61                    poisson_ratio: 0.3,
62                    density_kg_per_m3: 7850.0,
63                },
64                thermal: MaterialThermalModel::default(),
65                acoustic: None,
66                electrical: None,
67                plastic: None,
68            }],
69            material_assignments: Vec::new(),
70            structural: None,
71            thermo_mechanical: None,
72            electro_thermal: None,
73            electromagnetic: None,
74            cfd: None,
75            interfaces: Vec::new(),
76            boundary_conditions: vec![BoundaryCondition {
77                bc_id: "bc_fixed_root".to_string(),
78                region_id: "root".to_string(),
79                kind: BoundaryConditionKind::Fixed,
80            }],
81            loads: vec![LoadCase {
82                load_id: "load_tip".to_string(),
83                region_id: "tip".to_string(),
84                kind: LoadKind::Force {
85                    fx: 0.0,
86                    fy: -1000.0,
87                    fz: 0.0,
88                },
89            }],
90            steps: vec![AnalysisStep {
91                step_id: "step_static".to_string(),
92                kind: AnalysisStepKind::Static,
93            }],
94        }
95    }
96
97    #[test]
98    fn missing_material_bc_load_validation_failures() {
99        let mut model = valid_model();
100        model.materials.clear();
101        assert_eq!(
102            validate_model(&model).expect_err("expected material validation failure"),
103            AnalysisValidationError::MissingMaterials
104        );
105
106        let mut model = valid_model();
107        model.boundary_conditions.clear();
108        assert_eq!(
109            validate_model(&model).expect_err("expected boundary condition validation failure"),
110            AnalysisValidationError::MissingBoundaryConditions
111        );
112
113        let mut model = valid_model();
114        model.loads.clear();
115        assert_eq!(
116            validate_model(&model).expect_err("expected load validation failure"),
117            AnalysisValidationError::MissingLoads
118        );
119    }
120
121    #[test]
122    fn invalid_moment_vectors_fail_validation() {
123        let mut model = valid_model();
124        model.loads[0].kind = LoadKind::Moment {
125            mx: f64::NAN,
126            my: 0.0,
127            mz: 1.0,
128        };
129        assert_eq!(
130            validate_model(&model).expect_err("expected nonfinite moment validation failure"),
131            AnalysisValidationError::InvalidMomentVector {
132                load_id: "load_tip".to_string()
133            }
134        );
135
136        let mut model = valid_model();
137        model.loads[0].kind = LoadKind::Moment {
138            mx: 0.0,
139            my: 0.0,
140            mz: 0.0,
141        };
142        assert_eq!(
143            validate_model(&model).expect_err("expected zero moment validation failure"),
144            AnalysisValidationError::ZeroMomentVector {
145                load_id: "load_tip".to_string()
146            }
147        );
148    }
149
150    #[test]
151    fn invalid_wrench_vectors_fail_validation() {
152        let mut model = valid_model();
153        model.loads[0].kind = LoadKind::Wrench {
154            fx: 0.0,
155            fy: 1.0,
156            fz: 0.0,
157            mx: f64::INFINITY,
158            my: 0.0,
159            mz: 0.0,
160            px: 0.0,
161            py: 0.0,
162            pz: 0.0,
163        };
164        assert_eq!(
165            validate_model(&model).expect_err("expected nonfinite wrench validation failure"),
166            AnalysisValidationError::InvalidWrench {
167                load_id: "load_tip".to_string()
168            }
169        );
170
171        let mut model = valid_model();
172        model.loads[0].kind = LoadKind::Wrench {
173            fx: 0.0,
174            fy: 0.0,
175            fz: 0.0,
176            mx: 0.0,
177            my: 0.0,
178            mz: 0.0,
179            px: 0.0,
180            py: 0.0,
181            pz: 0.0,
182        };
183        assert_eq!(
184            validate_model(&model).expect_err("expected zero wrench validation failure"),
185            AnalysisValidationError::ZeroWrench {
186                load_id: "load_tip".to_string()
187            }
188        );
189    }
190
191    #[test]
192    fn unit_frame_mismatch_rejection() {
193        let mut model = valid_model();
194        model.units = UnitSystem::Inch;
195        let err =
196            validate_model_against_geometry(&model, UnitSystem::Meter, &ReferenceFrame::Global)
197                .expect_err("expected unit mismatch");
198        assert!(matches!(
199            err,
200            AnalysisValidationError::UnitMismatch {
201                model: UnitSystem::Inch,
202                geometry: UnitSystem::Meter
203            }
204        ));
205
206        let mut model = valid_model();
207        model.frame = ReferenceFrame::Local("fixture_frame".to_string());
208        let err =
209            validate_model_against_geometry(&model, UnitSystem::Meter, &ReferenceFrame::Global)
210                .expect_err("expected frame mismatch");
211        assert!(matches!(
212            err,
213            AnalysisValidationError::FrameMismatch {
214                model: ReferenceFrame::Local(_),
215                geometry: ReferenceFrame::Global
216            }
217        ));
218    }
219
220    #[test]
221    fn valid_model_is_accepted() {
222        let model = valid_model();
223        validate_model(&model).expect("model should be valid");
224        validate_model_against_geometry(&model, UnitSystem::Meter, &ReferenceFrame::Global)
225            .expect("model should match geometry context");
226    }
227
228    #[test]
229    fn moment_load_kind_serializes_as_snake_case() {
230        let load = LoadCase {
231            load_id: "tip_moment".to_string(),
232            region_id: "tip".to_string(),
233            kind: LoadKind::Moment {
234                mx: 1.0,
235                my: 2.0,
236                mz: 3.0,
237            },
238        };
239
240        let json = serde_json::to_value(&load).expect("load should serialize");
241        assert_eq!(json["kind"]["moment"]["mx"], 1.0);
242        assert_eq!(json["kind"]["moment"]["my"], 2.0);
243        assert_eq!(json["kind"]["moment"]["mz"], 3.0);
244
245        let decoded: LoadCase = serde_json::from_value(json).expect("load should deserialize");
246        assert_eq!(decoded, load);
247    }
248
249    #[test]
250    fn wrench_load_kind_serializes_as_snake_case() {
251        let load = LoadCase {
252            load_id: "tip_wrench".to_string(),
253            region_id: "tip".to_string(),
254            kind: LoadKind::Wrench {
255                fx: 1.0,
256                fy: 2.0,
257                fz: 3.0,
258                mx: 4.0,
259                my: 5.0,
260                mz: 6.0,
261                px: 0.1,
262                py: 0.2,
263                pz: 0.3,
264            },
265        };
266
267        let json = serde_json::to_value(&load).expect("load should serialize");
268        assert_eq!(json["kind"]["wrench"]["fx"], 1.0);
269        assert_eq!(json["kind"]["wrench"]["mz"], 6.0);
270        assert_eq!(json["kind"]["wrench"]["pz"], 0.3);
271
272        let decoded: LoadCase = serde_json::from_value(json).expect("load should deserialize");
273        assert_eq!(decoded, load);
274    }
275
276    #[test]
277    fn prescribed_rotation_bc_serializes_as_snake_case() {
278        let bc = BoundaryCondition {
279            bc_id: "root_rotation".to_string(),
280            region_id: "root".to_string(),
281            kind: BoundaryConditionKind::PrescribedRotation {
282                rx: 0.0,
283                ry: 0.0,
284                rz: 0.125,
285            },
286        };
287
288        let json = serde_json::to_value(&bc).expect("bc should serialize");
289        assert_eq!(json["kind"]["prescribed_rotation"]["rx"], 0.0);
290        assert_eq!(json["kind"]["prescribed_rotation"]["ry"], 0.0);
291        assert_eq!(json["kind"]["prescribed_rotation"]["rz"], 0.125);
292
293        let decoded: BoundaryCondition =
294            serde_json::from_value(json).expect("bc should deserialize");
295        assert_eq!(decoded, bc);
296    }
297
298    #[test]
299    fn structural_beam_model_round_trips() {
300        let mut model = valid_model();
301        model.structural = Some(StructuralModel {
302            nodes: vec![
303                StructuralNode {
304                    node_id: 1,
305                    coordinates_m: [0.0, 0.0, 0.0],
306                },
307                StructuralNode {
308                    node_id: 2,
309                    coordinates_m: [1.0, 0.0, 0.0],
310                },
311            ],
312            elements: vec![StructuralElement {
313                element_id: "beam_1".to_string(),
314                region_id: "beam_span".to_string(),
315                kind: StructuralElementKind::Beam(BeamElementModel {
316                    node_ids: [1, 2],
317                    section_id: "section_1".to_string(),
318                    reference_axis: [0.0, 0.0, 1.0],
319                }),
320            }],
321            beam_sections: vec![BeamSectionModel {
322                section_id: "section_1".to_string(),
323                area_m2: 2.0e-4,
324                iy_m4: 1.6e-9,
325                iz_m4: 6.4e-9,
326                torsion_j_m4: 2.4e-9,
327                outer_fiber_y_m: 0.01,
328                outer_fiber_z_m: 0.005,
329                torsion_outer_radius_m: 0.011_180_339_887_498_949,
330            }],
331            shell_sections: Vec::new(),
332        });
333
334        let json = serde_json::to_value(&model).expect("model should serialize");
335        assert_eq!(
336            json["structural"]["elements"][0]["kind"]["beam"]["node_ids"][1],
337            2
338        );
339
340        let decoded: AnalysisModel =
341            serde_json::from_value(json).expect("model should deserialize");
342        assert_eq!(decoded, model);
343    }
344
345    #[test]
346    fn structural_shell_model_round_trips() {
347        let mut model = valid_model();
348        model.structural = Some(StructuralModel {
349            nodes: vec![
350                StructuralNode {
351                    node_id: 1,
352                    coordinates_m: [0.0, 0.0, 0.0],
353                },
354                StructuralNode {
355                    node_id: 2,
356                    coordinates_m: [1.0, 0.0, 0.0],
357                },
358                StructuralNode {
359                    node_id: 3,
360                    coordinates_m: [0.0, 1.0, 0.0],
361                },
362            ],
363            elements: vec![StructuralElement {
364                element_id: "shell_1".to_string(),
365                region_id: "shell_panel".to_string(),
366                kind: StructuralElementKind::Shell(ShellElementModel {
367                    node_ids: [1, 2, 3],
368                    section_id: "panel_2mm".to_string(),
369                    reference_axis: [1.0, 0.0, 0.0],
370                }),
371            }],
372            beam_sections: Vec::new(),
373            shell_sections: vec![ShellSectionModel {
374                section_id: "panel_2mm".to_string(),
375                thickness_m: 0.002,
376                shear_correction: 5.0 / 6.0,
377                drilling_stiffness_scale: 1.0e-4,
378            }],
379        });
380
381        let json = serde_json::to_value(&model).expect("model should serialize");
382        assert_eq!(
383            json["structural"]["elements"][0]["kind"]["shell"]["node_ids"][2],
384            3
385        );
386
387        let decoded: AnalysisModel =
388            serde_json::from_value(json).expect("model should deserialize");
389        assert_eq!(decoded, model);
390    }
391
392    #[test]
393    fn electrical_model_defaults_frequency_response() {
394        let electrical = MaterialElectricalModel::default();
395        assert!(electrical.conductivity_frequency_response.is_empty());
396    }
397}