1use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum PropertyId {
37 Density,
39 YoungsModulus,
41 PoissonsRatio,
43 YieldStrength,
45 UltimateTensileStrength,
47 ThermalConductivity,
49 SpecificHeat,
51 ThermalExpansion,
53 MeltingPoint,
55 ElectricalResistivity,
57 Hardness,
59 DynamicViscosity,
61}
62
63impl PropertyId {
64 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#[derive(Debug, Clone, Serialize, Deserialize)]
85pub enum PropertyValue {
86 Constant(f64),
88 TemperatureDependent(Vec<(f64, f64)>),
90}
91
92impl PropertyValue {
93 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct Material {
142 pub name: String,
144 pub category: MaterialCategory,
146 pub description: Option<String>,
148 pub properties: HashMap<PropertyId, PropertyValue>,
150}
151
152impl Material {
153 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 pub fn with_property(mut self, id: PropertyId, value: f64) -> Self {
165 self.properties.insert(id, PropertyValue::Constant(value));
166 self
167 }
168
169 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 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
178 self.description = Some(desc.into());
179 self
180 }
181
182 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 pub fn get_property_rt(&self, id: PropertyId) -> Option<f64> {
191 self.get_property(id, 293.15)
192 }
193
194 pub fn has_property(&self, id: PropertyId) -> bool {
196 self.properties.contains_key(&id)
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum TemperatureUnit {
207 Kelvin,
208 Celsius,
209 Fahrenheit,
210}
211
212pub fn convert_temperature(value: f64, from: TemperatureUnit, to: TemperatureUnit) -> f64 {
214 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum PressureUnit {
231 Pascal,
232 MegaPascal,
233 GigaPascal,
234 Psi,
235 Bar,
236}
237
238pub 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
256pub struct MaterialDatabase {
262 materials: HashMap<String, Material>,
263}
264
265impl MaterialDatabase {
266 pub fn new() -> Self {
268 Self {
269 materials: HashMap::new(),
270 }
271 }
272
273 pub fn with_builtins() -> Self {
275 let mut db = Self::new();
276 db.load_builtins();
277 db
278 }
279
280 fn load_builtins(&mut self) {
282 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 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 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 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 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 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 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 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 pub fn add_material(&mut self, material: Material) {
407 self.materials.insert(material.name.clone(), material);
408 }
409
410 pub fn get_material(&self, name: &str) -> Option<&Material> {
412 self.materials.get(name)
413 }
414
415 pub fn remove_material(&mut self, name: &str) -> bool {
417 self.materials.remove(name).is_some()
418 }
419
420 pub fn material_count(&self) -> usize {
422 self.materials.len()
423 }
424
425 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 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 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 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#[cfg(test)]
482mod tests {
483 use super::*;
484
485 fn db() -> MaterialDatabase {
486 MaterialDatabase::with_builtins()
487 }
488
489 #[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 #[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 #[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 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 #[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 #[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 #[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()); }
699
700 #[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 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 assert!(results.iter().any(|m| m.name.contains("Titanium")));
717 }
718
719 #[test]
722 fn test_compare_density() {
723 let db = db();
724 let comparison = db.compare_property(PropertyId::Density);
725 assert!(!comparison.is_empty());
726 for window in comparison.windows(2) {
728 assert!(window[0].1 <= window[1].1);
729 }
730 }
731
732 #[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 #[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 #[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 #[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}