Skip to main content

oxirs_physics/
material_database.rs

1//! # Material Property Database
2//!
3//! Provides a database of common engineering materials with property lookup,
4//! temperature-dependent interpolation, and unit conversion. Used by FEM and
5//! simulation modules to obtain material properties for structural, thermal,
6//! and fluid analyses.
7//!
8//! ## Features
9//!
10//! - **Built-in materials**: Common metals, polymers, ceramics, and composites
11//! - **Property lookup**: Density, Young's modulus, Poisson's ratio, thermal conductivity, etc.
12//! - **Temperature interpolation**: Linear interpolation for temperature-dependent properties
13//! - **Unit conversion**: Convert between SI and common engineering unit systems
14//! - **Custom materials**: Add user-defined materials with arbitrary properties
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19// ─────────────────────────────────────────────
20// Material property types
21// ─────────────────────────────────────────────
22
23/// Categories of materials.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub enum MaterialCategory {
26    Metal,
27    Polymer,
28    Ceramic,
29    Composite,
30    Fluid,
31    Custom,
32}
33
34/// Standard material property identifiers.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum PropertyId {
37    /// Density in kg/m^3.
38    Density,
39    /// Young's modulus (elastic modulus) in Pa.
40    YoungsModulus,
41    /// Poisson's ratio (dimensionless).
42    PoissonsRatio,
43    /// Yield strength in Pa.
44    YieldStrength,
45    /// Ultimate tensile strength in Pa.
46    UltimateTensileStrength,
47    /// Thermal conductivity in W/(m*K).
48    ThermalConductivity,
49    /// Specific heat capacity in J/(kg*K).
50    SpecificHeat,
51    /// Coefficient of thermal expansion in 1/K.
52    ThermalExpansion,
53    /// Melting point in K.
54    MeltingPoint,
55    /// Electrical resistivity in Ohm*m.
56    ElectricalResistivity,
57    /// Hardness (Brinell, HB).
58    Hardness,
59    /// Dynamic viscosity in Pa*s (for fluids).
60    DynamicViscosity,
61}
62
63impl PropertyId {
64    /// SI unit string for this property.
65    pub fn unit(&self) -> &'static str {
66        match self {
67            PropertyId::Density => "kg/m^3",
68            PropertyId::YoungsModulus => "Pa",
69            PropertyId::PoissonsRatio => "-",
70            PropertyId::YieldStrength => "Pa",
71            PropertyId::UltimateTensileStrength => "Pa",
72            PropertyId::ThermalConductivity => "W/(m*K)",
73            PropertyId::SpecificHeat => "J/(kg*K)",
74            PropertyId::ThermalExpansion => "1/K",
75            PropertyId::MeltingPoint => "K",
76            PropertyId::ElectricalResistivity => "Ohm*m",
77            PropertyId::Hardness => "HB",
78            PropertyId::DynamicViscosity => "Pa*s",
79        }
80    }
81}
82
83/// A property value that may be temperature-dependent.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub enum PropertyValue {
86    /// Constant value (temperature-independent).
87    Constant(f64),
88    /// Temperature-dependent: sorted list of (temperature_K, value) pairs.
89    TemperatureDependent(Vec<(f64, f64)>),
90}
91
92impl PropertyValue {
93    /// Get the value at a given temperature (K).
94    /// For constant values, temperature is ignored.
95    pub fn at_temperature(&self, temperature_k: f64) -> f64 {
96        match self {
97            PropertyValue::Constant(v) => *v,
98            PropertyValue::TemperatureDependent(points) => {
99                if points.is_empty() {
100                    return 0.0;
101                }
102                if points.len() == 1 {
103                    return points[0].1;
104                }
105                // Clamp to range
106                if temperature_k <= points[0].0 {
107                    return points[0].1;
108                }
109                if temperature_k >= points[points.len() - 1].0 {
110                    return points[points.len() - 1].1;
111                }
112                // Linear interpolation
113                for window in points.windows(2) {
114                    let (t0, v0) = window[0];
115                    let (t1, v1) = window[1];
116                    if temperature_k >= t0 && temperature_k <= t1 {
117                        let frac = if (t1 - t0).abs() < f64::EPSILON {
118                            0.0
119                        } else {
120                            (temperature_k - t0) / (t1 - t0)
121                        };
122                        return v0 + frac * (v1 - v0);
123                    }
124                }
125                points[points.len() - 1].1
126            }
127        }
128    }
129
130    /// Get the constant value (if constant).
131    pub fn constant_value(&self) -> Option<f64> {
132        match self {
133            PropertyValue::Constant(v) => Some(*v),
134            PropertyValue::TemperatureDependent(_) => None,
135        }
136    }
137}
138
139/// A material definition with its properties.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Material {
142    /// Unique material name.
143    pub name: String,
144    /// Material category.
145    pub category: MaterialCategory,
146    /// Optional description.
147    pub description: Option<String>,
148    /// Material properties.
149    pub properties: HashMap<PropertyId, PropertyValue>,
150}
151
152impl Material {
153    /// Create a new material.
154    pub fn new(name: impl Into<String>, category: MaterialCategory) -> Self {
155        Self {
156            name: name.into(),
157            category,
158            description: None,
159            properties: HashMap::new(),
160        }
161    }
162
163    /// Set a constant property.
164    pub fn with_property(mut self, id: PropertyId, value: f64) -> Self {
165        self.properties.insert(id, PropertyValue::Constant(value));
166        self
167    }
168
169    /// Set a temperature-dependent property.
170    pub fn with_temp_property(mut self, id: PropertyId, points: Vec<(f64, f64)>) -> Self {
171        self.properties
172            .insert(id, PropertyValue::TemperatureDependent(points));
173        self
174    }
175
176    /// Set description.
177    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
178        self.description = Some(desc.into());
179        self
180    }
181
182    /// Get a property value at a given temperature.
183    pub fn get_property(&self, id: PropertyId, temperature_k: f64) -> Option<f64> {
184        self.properties
185            .get(&id)
186            .map(|v| v.at_temperature(temperature_k))
187    }
188
189    /// Get a property at room temperature (293.15 K / 20 C).
190    pub fn get_property_rt(&self, id: PropertyId) -> Option<f64> {
191        self.get_property(id, 293.15)
192    }
193
194    /// Check if this material has a given property.
195    pub fn has_property(&self, id: PropertyId) -> bool {
196        self.properties.contains_key(&id)
197    }
198}
199
200// ─────────────────────────────────────────────
201// Unit conversion
202// ─────────────────────────────────────────────
203
204/// Temperature unit for conversions.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum TemperatureUnit {
207    Kelvin,
208    Celsius,
209    Fahrenheit,
210}
211
212/// Convert temperature between units.
213pub fn convert_temperature(value: f64, from: TemperatureUnit, to: TemperatureUnit) -> f64 {
214    // Convert to Kelvin first
215    let kelvin = match from {
216        TemperatureUnit::Kelvin => value,
217        TemperatureUnit::Celsius => value + 273.15,
218        TemperatureUnit::Fahrenheit => (value - 32.0) * 5.0 / 9.0 + 273.15,
219    };
220    // Convert from Kelvin to target
221    match to {
222        TemperatureUnit::Kelvin => kelvin,
223        TemperatureUnit::Celsius => kelvin - 273.15,
224        TemperatureUnit::Fahrenheit => (kelvin - 273.15) * 9.0 / 5.0 + 32.0,
225    }
226}
227
228/// Pressure unit for conversions.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum PressureUnit {
231    Pascal,
232    MegaPascal,
233    GigaPascal,
234    Psi,
235    Bar,
236}
237
238/// Convert pressure between units.
239pub fn convert_pressure(value: f64, from: PressureUnit, to: PressureUnit) -> f64 {
240    let pa = match from {
241        PressureUnit::Pascal => value,
242        PressureUnit::MegaPascal => value * 1e6,
243        PressureUnit::GigaPascal => value * 1e9,
244        PressureUnit::Psi => value * 6894.757,
245        PressureUnit::Bar => value * 1e5,
246    };
247    match to {
248        PressureUnit::Pascal => pa,
249        PressureUnit::MegaPascal => pa / 1e6,
250        PressureUnit::GigaPascal => pa / 1e9,
251        PressureUnit::Psi => pa / 6894.757,
252        PressureUnit::Bar => pa / 1e5,
253    }
254}
255
256// ─────────────────────────────────────────────
257// MaterialDatabase
258// ─────────────────────────────────────────────
259
260/// A database of materials with built-in common engineering materials.
261pub struct MaterialDatabase {
262    materials: HashMap<String, Material>,
263}
264
265impl MaterialDatabase {
266    /// Create a new empty database.
267    pub fn new() -> Self {
268        Self {
269            materials: HashMap::new(),
270        }
271    }
272
273    /// Create a database pre-loaded with common engineering materials.
274    pub fn with_builtins() -> Self {
275        let mut db = Self::new();
276        db.load_builtins();
277        db
278    }
279
280    /// Load built-in materials.
281    fn load_builtins(&mut self) {
282        // Steel (AISI 1020)
283        self.add_material(
284            Material::new("Steel_AISI_1020", MaterialCategory::Metal)
285                .with_description("Low carbon steel, AISI 1020")
286                .with_property(PropertyId::Density, 7870.0)
287                .with_property(PropertyId::YoungsModulus, 200e9)
288                .with_property(PropertyId::PoissonsRatio, 0.29)
289                .with_property(PropertyId::YieldStrength, 350e6)
290                .with_property(PropertyId::UltimateTensileStrength, 420e6)
291                .with_property(PropertyId::ThermalConductivity, 51.9)
292                .with_property(PropertyId::SpecificHeat, 486.0)
293                .with_property(PropertyId::ThermalExpansion, 11.7e-6)
294                .with_property(PropertyId::MeltingPoint, 1793.0)
295                .with_property(PropertyId::Hardness, 111.0),
296        );
297
298        // Aluminum 6061-T6
299        self.add_material(
300            Material::new("Aluminum_6061_T6", MaterialCategory::Metal)
301                .with_description("Aluminum alloy 6061-T6")
302                .with_property(PropertyId::Density, 2700.0)
303                .with_property(PropertyId::YoungsModulus, 68.9e9)
304                .with_property(PropertyId::PoissonsRatio, 0.33)
305                .with_property(PropertyId::YieldStrength, 276e6)
306                .with_property(PropertyId::UltimateTensileStrength, 310e6)
307                .with_property(PropertyId::ThermalConductivity, 167.0)
308                .with_property(PropertyId::SpecificHeat, 896.0)
309                .with_property(PropertyId::ThermalExpansion, 23.6e-6)
310                .with_property(PropertyId::MeltingPoint, 855.0 + 273.15),
311        );
312
313        // Titanium Ti-6Al-4V
314        self.add_material(
315            Material::new("Titanium_Ti6Al4V", MaterialCategory::Metal)
316                .with_description("Titanium alloy Ti-6Al-4V (Grade 5)")
317                .with_property(PropertyId::Density, 4430.0)
318                .with_property(PropertyId::YoungsModulus, 113.8e9)
319                .with_property(PropertyId::PoissonsRatio, 0.342)
320                .with_property(PropertyId::YieldStrength, 880e6)
321                .with_property(PropertyId::UltimateTensileStrength, 950e6)
322                .with_property(PropertyId::ThermalConductivity, 6.7)
323                .with_property(PropertyId::SpecificHeat, 526.3)
324                .with_property(PropertyId::ThermalExpansion, 8.6e-6)
325                .with_property(PropertyId::MeltingPoint, 1933.0),
326        );
327
328        // Copper (pure)
329        self.add_material(
330            Material::new("Copper_Pure", MaterialCategory::Metal)
331                .with_description("Pure copper (OFHC)")
332                .with_property(PropertyId::Density, 8960.0)
333                .with_property(PropertyId::YoungsModulus, 117e9)
334                .with_property(PropertyId::PoissonsRatio, 0.34)
335                .with_property(PropertyId::YieldStrength, 70e6)
336                .with_property(PropertyId::ThermalConductivity, 401.0)
337                .with_property(PropertyId::SpecificHeat, 385.0)
338                .with_property(PropertyId::ThermalExpansion, 16.5e-6)
339                .with_property(PropertyId::MeltingPoint, 1357.77)
340                .with_property(PropertyId::ElectricalResistivity, 1.678e-8),
341        );
342
343        // Stainless Steel 316L
344        self.add_material(
345            Material::new("Stainless_Steel_316L", MaterialCategory::Metal)
346                .with_description("Austenitic stainless steel 316L")
347                .with_property(PropertyId::Density, 8000.0)
348                .with_property(PropertyId::YoungsModulus, 193e9)
349                .with_property(PropertyId::PoissonsRatio, 0.27)
350                .with_property(PropertyId::YieldStrength, 170e6)
351                .with_property(PropertyId::UltimateTensileStrength, 485e6)
352                .with_property(PropertyId::ThermalConductivity, 16.3)
353                .with_property(PropertyId::SpecificHeat, 500.0)
354                .with_property(PropertyId::ThermalExpansion, 16.0e-6)
355                .with_property(PropertyId::MeltingPoint, 1673.0),
356        );
357
358        // CFRP (Carbon Fiber Reinforced Polymer)
359        self.add_material(
360            Material::new("CFRP", MaterialCategory::Composite)
361                .with_description("Carbon fiber reinforced polymer (unidirectional)")
362                .with_property(PropertyId::Density, 1600.0)
363                .with_property(PropertyId::YoungsModulus, 181e9)
364                .with_property(PropertyId::PoissonsRatio, 0.28)
365                .with_property(PropertyId::UltimateTensileStrength, 1500e6)
366                .with_property(PropertyId::ThermalConductivity, 7.0)
367                .with_property(PropertyId::SpecificHeat, 1130.0)
368                .with_property(PropertyId::ThermalExpansion, -0.2e-6),
369        );
370
371        // Water (at 20C)
372        self.add_material(
373            Material::new("Water", MaterialCategory::Fluid)
374                .with_description("Pure water at atmospheric pressure")
375                .with_property(PropertyId::Density, 998.2)
376                .with_property(PropertyId::ThermalConductivity, 0.598)
377                .with_property(PropertyId::SpecificHeat, 4182.0)
378                .with_property(PropertyId::DynamicViscosity, 1.002e-3)
379                .with_temp_property(
380                    PropertyId::Density,
381                    vec![
382                        (273.15, 999.8),
383                        (293.15, 998.2),
384                        (323.15, 988.1),
385                        (353.15, 971.8),
386                        (373.15, 958.4),
387                    ],
388                ),
389        );
390
391        // Alumina (Al2O3)
392        self.add_material(
393            Material::new("Alumina_Al2O3", MaterialCategory::Ceramic)
394                .with_description("Aluminum oxide ceramic (99.5%)")
395                .with_property(PropertyId::Density, 3950.0)
396                .with_property(PropertyId::YoungsModulus, 370e9)
397                .with_property(PropertyId::PoissonsRatio, 0.22)
398                .with_property(PropertyId::ThermalConductivity, 35.0)
399                .with_property(PropertyId::SpecificHeat, 880.0)
400                .with_property(PropertyId::MeltingPoint, 2345.0)
401                .with_property(PropertyId::Hardness, 1440.0),
402        );
403    }
404
405    /// Add a material to the database.
406    pub fn add_material(&mut self, material: Material) {
407        self.materials.insert(material.name.clone(), material);
408    }
409
410    /// Get a material by name.
411    pub fn get_material(&self, name: &str) -> Option<&Material> {
412        self.materials.get(name)
413    }
414
415    /// Remove a material.
416    pub fn remove_material(&mut self, name: &str) -> bool {
417        self.materials.remove(name).is_some()
418    }
419
420    /// Number of materials in the database.
421    pub fn material_count(&self) -> usize {
422        self.materials.len()
423    }
424
425    /// List all material names.
426    pub fn material_names(&self) -> Vec<String> {
427        let mut names: Vec<String> = self.materials.keys().cloned().collect();
428        names.sort();
429        names
430    }
431
432    /// Find materials by category.
433    pub fn find_by_category(&self, category: MaterialCategory) -> Vec<&Material> {
434        self.materials
435            .values()
436            .filter(|m| m.category == category)
437            .collect()
438    }
439
440    /// Find materials that have a given property within a range.
441    pub fn find_by_property_range(
442        &self,
443        property: PropertyId,
444        min_value: f64,
445        max_value: f64,
446    ) -> Vec<&Material> {
447        self.materials
448            .values()
449            .filter(|m| {
450                if let Some(val) = m.get_property_rt(property) {
451                    val >= min_value && val <= max_value
452                } else {
453                    false
454                }
455            })
456            .collect()
457    }
458
459    /// Compare a property across all materials.
460    pub fn compare_property(&self, property: PropertyId) -> Vec<(String, f64)> {
461        let mut result: Vec<(String, f64)> = self
462            .materials
463            .values()
464            .filter_map(|m| m.get_property_rt(property).map(|v| (m.name.clone(), v)))
465            .collect();
466        result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
467        result
468    }
469}
470
471impl Default for MaterialDatabase {
472    fn default() -> Self {
473        Self::with_builtins()
474    }
475}
476
477// ─────────────────────────────────────────────
478// Tests
479// ─────────────────────────────────────────────
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    fn db() -> MaterialDatabase {
486        MaterialDatabase::with_builtins()
487    }
488
489    // ═══ Database construction tests ═════════════════════
490
491    #[test]
492    fn test_empty_database() {
493        let db = MaterialDatabase::new();
494        assert_eq!(db.material_count(), 0);
495    }
496
497    #[test]
498    fn test_builtins_loaded() {
499        let db = db();
500        assert!(db.material_count() >= 8);
501    }
502
503    #[test]
504    fn test_default_has_builtins() {
505        let db = MaterialDatabase::default();
506        assert!(db.material_count() >= 8);
507    }
508
509    // ═══ Material lookup tests ═══════════════════════════
510
511    #[test]
512    fn test_get_steel() {
513        let db = db();
514        let steel = db.get_material("Steel_AISI_1020");
515        assert!(steel.is_some());
516        let steel = steel.expect("steel should exist");
517        assert_eq!(steel.category, MaterialCategory::Metal);
518    }
519
520    #[test]
521    fn test_get_nonexistent() {
522        let db = db();
523        assert!(db.get_material("Unobtanium").is_none());
524    }
525
526    #[test]
527    fn test_material_names_sorted() {
528        let db = db();
529        let names = db.material_names();
530        let mut sorted = names.clone();
531        sorted.sort();
532        assert_eq!(names, sorted);
533    }
534
535    // ═══ Property lookup tests ═══════════════════════════
536
537    #[test]
538    fn test_density_steel() {
539        let db = db();
540        let steel = db.get_material("Steel_AISI_1020").expect("steel");
541        let density = steel.get_property_rt(PropertyId::Density);
542        assert!(density.is_some());
543        assert!((density.expect("density") - 7870.0).abs() < 1.0);
544    }
545
546    #[test]
547    fn test_youngs_modulus_aluminum() {
548        let db = db();
549        let al = db.get_material("Aluminum_6061_T6").expect("aluminum");
550        let e = al.get_property_rt(PropertyId::YoungsModulus);
551        assert!(e.is_some());
552        assert!((e.expect("E") - 68.9e9).abs() < 1e8);
553    }
554
555    #[test]
556    fn test_poissons_ratio() {
557        let db = db();
558        let ti = db.get_material("Titanium_Ti6Al4V").expect("titanium");
559        let nu = ti.get_property_rt(PropertyId::PoissonsRatio);
560        assert!(nu.is_some());
561        let nu_val = nu.expect("nu");
562        assert!(nu_val > 0.0 && nu_val < 0.5);
563    }
564
565    #[test]
566    fn test_missing_property() {
567        let db = db();
568        let steel = db.get_material("Steel_AISI_1020").expect("steel");
569        // Steel doesn't have DynamicViscosity
570        assert!(steel
571            .get_property_rt(PropertyId::DynamicViscosity)
572            .is_none());
573    }
574
575    #[test]
576    fn test_has_property() {
577        let db = db();
578        let copper = db.get_material("Copper_Pure").expect("copper");
579        assert!(copper.has_property(PropertyId::ElectricalResistivity));
580        assert!(!copper.has_property(PropertyId::DynamicViscosity));
581    }
582
583    // ═══ Temperature interpolation tests ═════════════════
584
585    #[test]
586    fn test_constant_value_temperature_independent() {
587        let pv = PropertyValue::Constant(100.0);
588        assert!((pv.at_temperature(200.0) - 100.0).abs() < 1e-10);
589        assert!((pv.at_temperature(500.0) - 100.0).abs() < 1e-10);
590    }
591
592    #[test]
593    fn test_interpolation_exact_point() {
594        let pv = PropertyValue::TemperatureDependent(vec![
595            (273.15, 999.8),
596            (293.15, 998.2),
597            (373.15, 958.4),
598        ]);
599        assert!((pv.at_temperature(293.15) - 998.2).abs() < 0.1);
600    }
601
602    #[test]
603    fn test_interpolation_midpoint() {
604        let pv = PropertyValue::TemperatureDependent(vec![(200.0, 100.0), (400.0, 200.0)]);
605        assert!((pv.at_temperature(300.0) - 150.0).abs() < 0.1);
606    }
607
608    #[test]
609    fn test_interpolation_below_range() {
610        let pv = PropertyValue::TemperatureDependent(vec![(300.0, 100.0), (400.0, 200.0)]);
611        assert!((pv.at_temperature(200.0) - 100.0).abs() < 1e-10);
612    }
613
614    #[test]
615    fn test_interpolation_above_range() {
616        let pv = PropertyValue::TemperatureDependent(vec![(300.0, 100.0), (400.0, 200.0)]);
617        assert!((pv.at_temperature(500.0) - 200.0).abs() < 1e-10);
618    }
619
620    #[test]
621    fn test_interpolation_empty() {
622        let pv = PropertyValue::TemperatureDependent(vec![]);
623        assert!(pv.at_temperature(300.0).abs() < 1e-10);
624    }
625
626    #[test]
627    fn test_interpolation_single_point() {
628        let pv = PropertyValue::TemperatureDependent(vec![(300.0, 42.0)]);
629        assert!((pv.at_temperature(300.0) - 42.0).abs() < 1e-10);
630    }
631
632    // ═══ Unit conversion tests ═══════════════════════════
633
634    #[test]
635    fn test_celsius_to_kelvin() {
636        let k = convert_temperature(0.0, TemperatureUnit::Celsius, TemperatureUnit::Kelvin);
637        assert!((k - 273.15).abs() < 0.01);
638    }
639
640    #[test]
641    fn test_kelvin_to_celsius() {
642        let c = convert_temperature(373.15, TemperatureUnit::Kelvin, TemperatureUnit::Celsius);
643        assert!((c - 100.0).abs() < 0.01);
644    }
645
646    #[test]
647    fn test_fahrenheit_to_celsius() {
648        let c = convert_temperature(212.0, TemperatureUnit::Fahrenheit, TemperatureUnit::Celsius);
649        assert!((c - 100.0).abs() < 0.1);
650    }
651
652    #[test]
653    fn test_celsius_to_fahrenheit() {
654        let f = convert_temperature(100.0, TemperatureUnit::Celsius, TemperatureUnit::Fahrenheit);
655        assert!((f - 212.0).abs() < 0.1);
656    }
657
658    #[test]
659    fn test_pressure_mpa_to_pa() {
660        let pa = convert_pressure(1.0, PressureUnit::MegaPascal, PressureUnit::Pascal);
661        assert!((pa - 1e6).abs() < 1.0);
662    }
663
664    #[test]
665    fn test_pressure_gpa_to_mpa() {
666        let mpa = convert_pressure(1.0, PressureUnit::GigaPascal, PressureUnit::MegaPascal);
667        assert!((mpa - 1000.0).abs() < 0.1);
668    }
669
670    #[test]
671    fn test_pressure_psi_to_bar() {
672        let bar = convert_pressure(14.5038, PressureUnit::Psi, PressureUnit::Bar);
673        assert!((bar - 1.0).abs() < 0.01);
674    }
675
676    // ═══ Category filter tests ═══════════════════════════
677
678    #[test]
679    fn test_find_by_category_metal() {
680        let db = db();
681        let metals = db.find_by_category(MaterialCategory::Metal);
682        assert!(metals.len() >= 4);
683        assert!(metals.iter().all(|m| m.category == MaterialCategory::Metal));
684    }
685
686    #[test]
687    fn test_find_by_category_composite() {
688        let db = db();
689        let composites = db.find_by_category(MaterialCategory::Composite);
690        assert!(!composites.is_empty());
691    }
692
693    #[test]
694    fn test_find_by_category_empty() {
695        let db = db();
696        let customs = db.find_by_category(MaterialCategory::Custom);
697        assert!(customs.is_empty()); // no custom materials in builtins
698    }
699
700    // ═══ Property range search tests ═════════════════════
701
702    #[test]
703    fn test_find_by_density_range() {
704        let db = db();
705        let light = db.find_by_property_range(PropertyId::Density, 0.0, 3000.0);
706        assert!(!light.is_empty());
707        // Aluminum should be included (density ~2700)
708        assert!(light.iter().any(|m| m.name.contains("Aluminum")));
709    }
710
711    #[test]
712    fn test_find_by_density_range_narrow() {
713        let db = db();
714        let results = db.find_by_property_range(PropertyId::Density, 4400.0, 4500.0);
715        // Titanium ~4430
716        assert!(results.iter().any(|m| m.name.contains("Titanium")));
717    }
718
719    // ═══ Property comparison tests ═══════════════════════
720
721    #[test]
722    fn test_compare_density() {
723        let db = db();
724        let comparison = db.compare_property(PropertyId::Density);
725        assert!(!comparison.is_empty());
726        // Should be sorted by value
727        for window in comparison.windows(2) {
728            assert!(window[0].1 <= window[1].1);
729        }
730    }
731
732    // ═══ Custom material tests ═══════════════════════════
733
734    #[test]
735    fn test_add_custom_material() {
736        let mut db = MaterialDatabase::new();
737        let material = Material::new("Unobtanium", MaterialCategory::Custom)
738            .with_property(PropertyId::Density, 1.0)
739            .with_description("Fictional material");
740        db.add_material(material);
741        assert!(db.get_material("Unobtanium").is_some());
742    }
743
744    #[test]
745    fn test_remove_material() {
746        let mut db = db();
747        let initial = db.material_count();
748        assert!(db.remove_material("Copper_Pure"));
749        assert_eq!(db.material_count(), initial - 1);
750        assert!(!db.remove_material("Nonexistent"));
751    }
752
753    // ═══ PropertyId unit tests ═══════════════════════════
754
755    #[test]
756    fn test_property_id_units() {
757        assert_eq!(PropertyId::Density.unit(), "kg/m^3");
758        assert_eq!(PropertyId::YoungsModulus.unit(), "Pa");
759        assert_eq!(PropertyId::PoissonsRatio.unit(), "-");
760        assert_eq!(PropertyId::ThermalConductivity.unit(), "W/(m*K)");
761    }
762
763    // ═══ PropertyValue constant_value test ═══════════════
764
765    #[test]
766    fn test_constant_value() {
767        let pv = PropertyValue::Constant(42.0);
768        assert_eq!(pv.constant_value(), Some(42.0));
769    }
770
771    #[test]
772    fn test_temp_dependent_no_constant() {
773        let pv = PropertyValue::TemperatureDependent(vec![(300.0, 42.0)]);
774        assert_eq!(pv.constant_value(), None);
775    }
776
777    // ═══ Material builder tests ══════════════════════════
778
779    #[test]
780    fn test_material_builder() {
781        let mat = Material::new("Test", MaterialCategory::Metal)
782            .with_property(PropertyId::Density, 8000.0)
783            .with_property(PropertyId::YoungsModulus, 200e9)
784            .with_description("Test material");
785        assert_eq!(mat.name, "Test");
786        assert_eq!(mat.properties.len(), 2);
787        assert!(mat.description.is_some());
788    }
789}