1use std::collections::HashMap;
10
11#[allow(dead_code)]
18#[derive(Debug, Clone, PartialEq)]
19pub enum MaterialProperty {
20 Scalar(f64),
22 Range(f64, f64),
24 Matrix(Vec<f64>),
26}
27
28impl MaterialProperty {
29 pub fn representative(&self) -> f64 {
31 match self {
32 MaterialProperty::Scalar(v) => *v,
33 MaterialProperty::Range(lo, hi) => 0.5 * (lo + hi),
34 MaterialProperty::Matrix(v) => {
35 if v.is_empty() {
36 0.0
37 } else {
38 v[0]
39 }
40 }
41 }
42 }
43
44 pub fn contains(&self, value: f64) -> bool {
46 match self {
47 MaterialProperty::Scalar(v) => (v - value).abs() < 1e-12,
48 MaterialProperty::Range(lo, hi) => value >= *lo && value <= *hi,
49 MaterialProperty::Matrix(_) => false,
50 }
51 }
52}
53
54#[allow(dead_code)]
61#[derive(Debug, Clone)]
62pub struct MaterialRecord {
63 pub name: String,
65 pub category: String,
67 pub properties: HashMap<String, MaterialProperty>,
69 pub source: String,
71}
72
73impl MaterialRecord {
74 pub fn new(name: &str, category: &str) -> Self {
76 Self {
77 name: name.to_string(),
78 category: category.to_string(),
79 properties: HashMap::new(),
80 source: String::new(),
81 }
82 }
83
84 pub fn set_scalar(&mut self, key: &str, value: f64) {
86 self.properties
87 .insert(key.to_string(), MaterialProperty::Scalar(value));
88 }
89
90 pub fn set_range(&mut self, key: &str, lo: f64, hi: f64) {
92 self.properties
93 .insert(key.to_string(), MaterialProperty::Range(lo, hi));
94 }
95
96 pub fn get_value(&self, key: &str) -> Option<f64> {
98 self.properties.get(key).map(|p| p.representative())
99 }
100}
101
102#[allow(dead_code)]
106#[derive(Debug, Clone, Default)]
107pub struct MaterialDatabase {
108 pub records: Vec<MaterialRecord>,
110}
111
112impl MaterialDatabase {
113 pub fn new() -> Self {
115 Self {
116 records: Vec::new(),
117 }
118 }
119
120 pub fn add(&mut self, record: MaterialRecord) {
122 self.records.push(record);
123 }
124
125 pub fn by_category(&self, prefix: &str) -> Vec<&MaterialRecord> {
127 self.records
128 .iter()
129 .filter(|r| r.category.starts_with(prefix))
130 .collect()
131 }
132
133 pub fn filter_by_property(&self, key: &str, lo: f64, hi: f64) -> Vec<&MaterialRecord> {
135 self.records
136 .iter()
137 .filter(|r| {
138 if let Some(v) = r.get_value(key) {
139 v >= lo && v <= hi
140 } else {
141 false
142 }
143 })
144 .collect()
145 }
146
147 pub fn ashby_chart(&self, prop_x: &str, prop_y: &str) -> Vec<(f64, f64, &str)> {
150 self.records
151 .iter()
152 .filter_map(|r| {
153 let x = r.get_value(prop_x)?;
154 let y = r.get_value(prop_y)?;
155 Some((x, y, r.name.as_str()))
156 })
157 .collect()
158 }
159}
160
161#[allow(dead_code)]
168#[derive(Debug, Clone)]
169pub struct MaterialCategories {
170 pub name: String,
172 pub children: Vec<MaterialCategories>,
174}
175
176impl MaterialCategories {
177 pub fn leaf(name: &str) -> Self {
179 Self {
180 name: name.to_string(),
181 children: Vec::new(),
182 }
183 }
184
185 pub fn node(name: &str, children: Vec<MaterialCategories>) -> Self {
187 Self {
188 name: name.to_string(),
189 children,
190 }
191 }
192
193 pub fn contains_name(&self, target: &str) -> bool {
195 if self.name == target {
196 return true;
197 }
198 self.children.iter().any(|c| c.contains_name(target))
199 }
200
201 pub fn leaf_names(&self) -> Vec<String> {
203 if self.children.is_empty() {
204 vec![self.name.clone()]
205 } else {
206 self.children.iter().flat_map(|c| c.leaf_names()).collect()
207 }
208 }
209}
210
211#[allow(dead_code)]
215#[derive(Debug, Clone)]
216pub struct MaterialComparison {
217 pub axes: Vec<String>,
219}
220
221impl MaterialComparison {
222 pub fn new(axes: Vec<String>) -> Self {
224 Self { axes }
225 }
226
227 pub fn performance_index(
232 &self,
233 record: &MaterialRecord,
234 numerator: &str,
235 denominator: &str,
236 ) -> Option<f64> {
237 let num = record.get_value(numerator)?;
238 let den = record.get_value(denominator)?;
239 if den.abs() < 1e-30 {
240 return None;
241 }
242 Some(num / den)
243 }
244
245 pub fn rank_by_index<'a>(
247 &self,
248 records: &[&'a MaterialRecord],
249 numerator: &str,
250 denominator: &str,
251 ) -> Vec<(&'a MaterialRecord, f64)> {
252 let mut scored: Vec<_> = records
253 .iter()
254 .filter_map(|&r| {
255 let idx = self.performance_index(r, numerator, denominator)?;
256 Some((r, idx))
257 })
258 .collect();
259 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
260 scored
261 }
262
263 pub fn radar_data(&self, record: &MaterialRecord, reference_values: &[f64]) -> Vec<f64> {
267 self.axes
268 .iter()
269 .enumerate()
270 .map(|(i, key)| {
271 let val = record.get_value(key).unwrap_or(0.0);
272 let ref_val = if i < reference_values.len() {
273 reference_values[i]
274 } else {
275 1.0
276 };
277 if ref_val.abs() < 1e-30 {
278 0.0
279 } else {
280 val / ref_val
281 }
282 })
283 .collect()
284 }
285}
286
287#[allow(dead_code)]
293#[derive(Debug, Clone)]
294pub struct TemperatureDependence {
295 pub table: Vec<(f64, f64)>,
297 pub property_name: String,
299}
300
301impl TemperatureDependence {
302 pub fn new(property_name: &str, mut table: Vec<(f64, f64)>) -> Self {
304 table.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
305 Self {
306 table,
307 property_name: property_name.to_string(),
308 }
309 }
310
311 pub fn value_at(&self, temperature: f64) -> f64 {
315 if self.table.is_empty() {
316 return 0.0;
317 }
318 if self.table.len() == 1 {
319 return self.table[0].1;
320 }
321 let first = &self.table[0];
322 let last = &self.table[self.table.len() - 1];
323 if temperature <= first.0 {
324 return first.1;
325 }
326 if temperature >= last.0 {
327 return last.1;
328 }
329 let mut lo = 0usize;
331 let mut hi = self.table.len() - 1;
332 while hi - lo > 1 {
333 let mid = (lo + hi) / 2;
334 if self.table[mid].0 <= temperature {
335 lo = mid;
336 } else {
337 hi = mid;
338 }
339 }
340 let (t0, v0) = self.table[lo];
341 let (t1, v1) = self.table[hi];
342 let u = (temperature - t0) / (t1 - t0);
343 v0 + u * (v1 - v0)
344 }
345}
346
347#[allow(dead_code)]
354#[derive(Debug, Clone, Default)]
355pub struct MaterialFilter {
356 pub category_prefix: Option<String>,
358 pub min_values: HashMap<String, f64>,
360 pub max_values: HashMap<String, f64>,
362}
363
364impl MaterialFilter {
365 pub fn new() -> Self {
367 Self::default()
368 }
369
370 pub fn filter_category(mut self, prefix: &str) -> Self {
372 self.category_prefix = Some(prefix.to_string());
373 self
374 }
375
376 pub fn filter_min(mut self, key: &str, min: f64) -> Self {
378 self.min_values.insert(key.to_string(), min);
379 self
380 }
381
382 pub fn filter_max(mut self, key: &str, max: f64) -> Self {
384 self.max_values.insert(key.to_string(), max);
385 self
386 }
387
388 pub fn apply<'a>(&self, db: &'a MaterialDatabase) -> Vec<&'a MaterialRecord> {
390 db.records
391 .iter()
392 .filter(|r| {
393 if let Some(prefix) = &self.category_prefix
394 && !r.category.starts_with(prefix.as_str())
395 {
396 return false;
397 }
398 for (key, &min_v) in &self.min_values {
399 if let Some(v) = r.get_value(key) {
400 if v < min_v {
401 return false;
402 }
403 } else {
404 return false;
405 }
406 }
407 for (key, &max_v) in &self.max_values {
408 if let Some(v) = r.get_value(key) {
409 if v > max_v {
410 return false;
411 }
412 } else {
413 return false;
414 }
415 }
416 true
417 })
418 .collect()
419 }
420}
421
422#[allow(dead_code)]
428pub struct JsonMaterialDb;
429
430impl JsonMaterialDb {
431 pub fn write_json_material(record: &MaterialRecord) -> String {
433 let mut out = String::from("{\n");
434 out.push_str(&format!(" \"name\": \"{}\",\n", escape_json(&record.name)));
435 out.push_str(&format!(
436 " \"category\": \"{}\",\n",
437 escape_json(&record.category)
438 ));
439 out.push_str(&format!(
440 " \"source\": \"{}\",\n",
441 escape_json(&record.source)
442 ));
443 out.push_str(" \"properties\": {\n");
444 let mut prop_iter = record.properties.iter().peekable();
445 while let Some((k, v)) = prop_iter.next() {
446 let comma = if prop_iter.peek().is_some() { "," } else { "" };
447 let prop_str = match v {
448 MaterialProperty::Scalar(val) => {
449 format!(
450 " \"{}\": {{\"type\": \"scalar\", \"value\": {:.6}}}{}",
451 escape_json(k),
452 val,
453 comma
454 )
455 }
456 MaterialProperty::Range(lo, hi) => {
457 format!(
458 " \"{}\": {{\"type\": \"range\", \"lo\": {:.6}, \"hi\": {:.6}}}{}",
459 escape_json(k),
460 lo,
461 hi,
462 comma
463 )
464 }
465 MaterialProperty::Matrix(vals) => {
466 let vals_str: Vec<String> = vals.iter().map(|x| format!("{:.6}", x)).collect();
467 format!(
468 " \"{}\": {{\"type\": \"matrix\", \"values\": [{}]}}{}",
469 escape_json(k),
470 vals_str.join(", "),
471 comma
472 )
473 }
474 };
475 out.push_str(&prop_str);
476 out.push('\n');
477 }
478 out.push_str(" }\n");
479 out.push('}');
480 out
481 }
482
483 pub fn parse_json_material(json: &str) -> Option<MaterialRecord> {
487 let name = extract_json_str(json, "name")?;
488 let category = extract_json_str(json, "category").unwrap_or_default();
489 let source = extract_json_str(json, "source").unwrap_or_default();
490
491 let mut record = MaterialRecord::new(&name, &category);
492 record.source = source;
493
494 let props_start = json.find("\"properties\":")?;
496 let block_start = json[props_start..].find('{')? + props_start + 1;
497 let block = &json[block_start..];
499 let mut depth = 1i32;
500 let mut end = 0usize;
501 let chars: Vec<char> = block.chars().collect();
502 let mut i = 0;
503 while i < chars.len() {
504 match chars[i] {
505 '{' => depth += 1,
506 '}' => {
507 depth -= 1;
508 if depth == 0 {
509 end = i;
510 break;
511 }
512 }
513 _ => {}
514 }
515 i += 1;
516 }
517 let props_block = &block[..end];
518
519 let mut pos = 0usize;
521 let pb_chars: Vec<char> = props_block.chars().collect();
522 while pos < pb_chars.len() {
523 if let Some(rel) = props_block[pos..].find('"') {
525 let key_start = pos + rel + 1;
526 if let Some(key_end_rel) = props_block[key_start..].find('"') {
527 let key = &props_block[key_start..key_start + key_end_rel];
528 let after_key = key_start + key_end_rel + 1;
530 if let Some(brace_rel) = props_block[after_key..].find('{') {
531 let val_start = after_key + brace_rel + 1;
532 let mut d = 1i32;
534 let mut vend = val_start;
535 while vend < props_block.len() {
536 match props_block.chars().nth(vend) {
537 Some('{') => d += 1,
538 Some('}') => {
539 d -= 1;
540 if d == 0 {
541 break;
542 }
543 }
544 _ => {}
545 }
546 vend += 1;
547 }
548 let val_block = &props_block[val_start..vend];
549 if val_block.contains("\"scalar\"") {
551 if let Some(v) = extract_json_f64(val_block, "value") {
552 record.set_scalar(key, v);
553 }
554 } else if val_block.contains("\"range\"") {
555 let lo = extract_json_f64(val_block, "lo");
556 let hi = extract_json_f64(val_block, "hi");
557 if let (Some(lo), Some(hi)) = (lo, hi) {
558 record.set_range(key, lo, hi);
559 }
560 } else if val_block.contains("\"matrix\"") {
561 let vals = extract_json_f64_array(val_block, "values");
562 record
563 .properties
564 .insert(key.to_string(), MaterialProperty::Matrix(vals));
565 }
566 pos = vend + 1;
567 } else {
568 pos = after_key;
569 }
570 } else {
571 break;
572 }
573 } else {
574 break;
575 }
576 }
577 Some(record)
578 }
579}
580
581fn escape_json(s: &str) -> String {
583 s.replace('\\', "\\\\").replace('"', "\\\"")
584}
585
586fn extract_json_str(json: &str, key: &str) -> Option<String> {
588 let pattern = format!("\"{}\":", key);
589 let pos = json.find(&pattern)?;
590 let after = &json[pos + pattern.len()..];
591 let after = after.trim_start();
593 if !after.starts_with('"') {
594 return None;
595 }
596 let inner = &after[1..];
597 let mut end = 0usize;
599 let chars: Vec<char> = inner.chars().collect();
600 while end < chars.len() {
601 if chars[end] == '\\' {
602 end += 2;
603 continue;
604 }
605 if chars[end] == '"' {
606 break;
607 }
608 end += 1;
609 }
610 Some(inner[..end].replace("\\\"", "\"").replace("\\\\", "\\"))
611}
612
613fn extract_json_f64(json: &str, key: &str) -> Option<f64> {
615 let pattern = format!("\"{}\":", key);
616 let pos = json.find(&pattern)?;
617 let after = json[pos + pattern.len()..].trim_start();
618 let end = after.find([',', '}', '\n']).unwrap_or(after.len());
620 after[..end].trim().parse().ok()
621}
622
623fn extract_json_f64_array(json: &str, key: &str) -> Vec<f64> {
625 let pattern = format!("\"{}\":", key);
626 let pos = match json.find(&pattern) {
627 Some(p) => p,
628 None => return Vec::new(),
629 };
630 let after = json[pos + pattern.len()..].trim_start();
631 if !after.starts_with('[') {
632 return Vec::new();
633 }
634 let inner_end = after.find(']').unwrap_or(after.len());
635 let inner = &after[1..inner_end];
636 inner
637 .split(',')
638 .filter_map(|s| s.trim().parse::<f64>().ok())
639 .collect()
640}
641
642#[allow(dead_code)]
650pub struct CsvMaterialDb;
651
652impl CsvMaterialDb {
653 pub fn parse(csv: &str) -> MaterialDatabase {
655 let mut db = MaterialDatabase::new();
656 let mut lines = csv.lines();
657
658 let header_line = match lines.next() {
660 Some(h) => h,
661 None => return db,
662 };
663 let headers: Vec<&str> = header_line.split(',').map(|s| s.trim()).collect();
664 let name_idx = headers.iter().position(|&h| h.eq_ignore_ascii_case("Name"));
665 let cat_idx = headers
666 .iter()
667 .position(|&h| h.eq_ignore_ascii_case("Category"));
668
669 for line in lines {
670 if line.trim().is_empty() {
671 continue;
672 }
673 let fields: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
674 let name = name_idx
675 .and_then(|i| fields.get(i))
676 .copied()
677 .unwrap_or("Unknown");
678 let category = cat_idx
679 .and_then(|i| fields.get(i))
680 .copied()
681 .unwrap_or("Uncategorized");
682 let mut record = MaterialRecord::new(name, category);
683
684 for (i, header) in headers.iter().enumerate() {
685 if Some(i) == name_idx || Some(i) == cat_idx {
686 continue;
687 }
688 if let Some(val_str) = fields.get(i)
689 && let Ok(val) = val_str.parse::<f64>()
690 {
691 record.set_scalar(header, val);
692 }
693 }
694 db.add(record);
695 }
696 db
697 }
698}
699
700#[cfg(test)]
703mod tests {
704 use super::*;
705
706 fn make_steel() -> MaterialRecord {
707 let mut r = MaterialRecord::new("AISI 304", "Metal>Ferrous>Steel");
708 r.set_scalar("density", 8000.0);
709 r.set_scalar("elastic_modulus", 200e9);
710 r.set_scalar("yield_strength", 215e6);
711 r.set_scalar("thermal_conductivity", 16.0);
712 r.source = "Matweb".to_string();
713 r
714 }
715
716 fn make_aluminum() -> MaterialRecord {
717 let mut r = MaterialRecord::new("Al 6061", "Metal>NonFerrous>Aluminum");
718 r.set_scalar("density", 2700.0);
719 r.set_scalar("elastic_modulus", 69e9);
720 r.set_scalar("yield_strength", 276e6);
721 r.set_scalar("thermal_conductivity", 167.0);
722 r
723 }
724
725 #[test]
728 fn test_scalar_representative() {
729 let p = MaterialProperty::Scalar(42.0);
730 assert!((p.representative() - 42.0).abs() < 1e-10);
731 }
732
733 #[test]
734 fn test_range_representative_midpoint() {
735 let p = MaterialProperty::Range(100.0, 200.0);
736 assert!((p.representative() - 150.0).abs() < 1e-10);
737 }
738
739 #[test]
740 fn test_matrix_representative_first() {
741 let p = MaterialProperty::Matrix(vec![7.0, 8.0, 9.0]);
742 assert!((p.representative() - 7.0).abs() < 1e-10);
743 }
744
745 #[test]
746 fn test_scalar_contains() {
747 let p = MaterialProperty::Scalar(5.0);
748 assert!(p.contains(5.0));
749 assert!(!p.contains(6.0));
750 }
751
752 #[test]
753 fn test_range_contains() {
754 let p = MaterialProperty::Range(10.0, 20.0);
755 assert!(p.contains(15.0));
756 assert!(!p.contains(25.0));
757 }
758
759 #[test]
762 fn test_record_get_value() {
763 let r = make_steel();
764 let d = r.get_value("density").unwrap();
765 assert!((d - 8000.0).abs() < 1e-6);
766 }
767
768 #[test]
769 fn test_record_missing_key() {
770 let r = make_steel();
771 assert!(r.get_value("nonexistent").is_none());
772 }
773
774 #[test]
777 fn test_database_by_category() {
778 let mut db = MaterialDatabase::new();
779 db.add(make_steel());
780 db.add(make_aluminum());
781 let metals = db.by_category("Metal>Ferrous");
782 assert_eq!(metals.len(), 1);
783 assert_eq!(metals[0].name, "AISI 304");
784 }
785
786 #[test]
787 fn test_filter_by_property_range() {
788 let mut db = MaterialDatabase::new();
789 db.add(make_steel());
790 db.add(make_aluminum());
791 let results = db.filter_by_property("density", 2000.0, 5000.0);
793 assert_eq!(results.len(), 1);
794 assert_eq!(results[0].name, "Al 6061");
795 }
796
797 #[test]
798 fn test_filter_returns_nothing_outside_range() {
799 let mut db = MaterialDatabase::new();
800 db.add(make_steel());
801 let results = db.filter_by_property("density", 1000.0, 2000.0);
802 assert!(results.is_empty());
803 }
804
805 #[test]
806 fn test_ashby_chart_generates_pairs() {
807 let mut db = MaterialDatabase::new();
808 db.add(make_steel());
809 db.add(make_aluminum());
810 let pairs = db.ashby_chart("density", "elastic_modulus");
811 assert_eq!(pairs.len(), 2);
812 for (x, y, _name) in &pairs {
813 assert!(*x > 0.0);
814 assert!(*y > 0.0);
815 }
816 }
817
818 #[test]
821 fn test_json_roundtrip_scalar() {
822 let record = make_steel();
823 let json = JsonMaterialDb::write_json_material(&record);
824 let parsed = JsonMaterialDb::parse_json_material(&json).expect("parse failed");
825 assert_eq!(parsed.name, record.name);
826 assert_eq!(parsed.category, record.category);
827 let d_orig = record.get_value("density").unwrap();
828 let d_parsed = parsed.get_value("density").unwrap();
829 assert!(
830 (d_orig - d_parsed).abs() < 1e-3,
831 "orig={d_orig} parsed={d_parsed}"
832 );
833 }
834
835 #[test]
836 fn test_json_roundtrip_source() {
837 let record = make_steel();
838 let json = JsonMaterialDb::write_json_material(&record);
839 let parsed = JsonMaterialDb::parse_json_material(&json).unwrap();
840 assert_eq!(parsed.source, "Matweb");
841 }
842
843 #[test]
844 fn test_json_roundtrip_range_property() {
845 let mut r = MaterialRecord::new("Ti-6Al-4V", "Metal>NonFerrous>Titanium");
846 r.set_range("yield_strength", 800e6, 1000e6);
847 let json = JsonMaterialDb::write_json_material(&r);
848 let parsed = JsonMaterialDb::parse_json_material(&json).unwrap();
849 let v = parsed.get_value("yield_strength").unwrap();
850 assert!((v - 900e6).abs() < 1.0, "v={v}");
851 }
852
853 #[test]
854 fn test_json_roundtrip_matrix_property() {
855 let mut r = MaterialRecord::new("Composite", "Polymer>Composite");
856 r.properties.insert(
857 "stiffness_tensor".to_string(),
858 MaterialProperty::Matrix(vec![1.0, 2.0, 3.0]),
859 );
860 let json = JsonMaterialDb::write_json_material(&r);
861 let parsed = JsonMaterialDb::parse_json_material(&json).unwrap();
862 if let Some(MaterialProperty::Matrix(v)) = parsed.properties.get("stiffness_tensor") {
863 assert_eq!(v.len(), 3);
864 assert!((v[0] - 1.0).abs() < 1e-4);
865 } else {
866 panic!("matrix property not found");
867 }
868 }
869
870 #[test]
873 fn test_performance_index_stiffness_per_weight() {
874 let cmp =
875 MaterialComparison::new(vec!["elastic_modulus".to_string(), "density".to_string()]);
876 let steel = make_steel();
877 let al = make_aluminum();
878 let idx_steel = cmp
879 .performance_index(&steel, "elastic_modulus", "density")
880 .unwrap();
881 let idx_al = cmp
882 .performance_index(&al, "elastic_modulus", "density")
883 .unwrap();
884 assert!(idx_al > 0.0 && idx_steel > 0.0);
887 }
888
889 #[test]
890 fn test_rank_by_index_order() {
891 let mut db = MaterialDatabase::new();
892 db.add(make_steel());
893 db.add(make_aluminum());
894 let cmp = MaterialComparison::new(vec!["density".to_string()]);
895 let refs: Vec<&MaterialRecord> = db.records.iter().collect();
896 let ranked = cmp.rank_by_index(&refs, "elastic_modulus", "density");
897 assert_eq!(ranked.len(), 2);
899 assert!(ranked[0].1 >= ranked[1].1);
901 }
902
903 #[test]
904 fn test_radar_data_normalized() {
905 let cmp =
906 MaterialComparison::new(vec!["density".to_string(), "elastic_modulus".to_string()]);
907 let steel = make_steel();
908 let radar = cmp.radar_data(&steel, &[8000.0, 200e9]);
909 assert_eq!(radar.len(), 2);
910 assert!((radar[0] - 1.0).abs() < 1e-9);
911 assert!((radar[1] - 1.0).abs() < 1e-9);
912 }
913
914 #[test]
917 fn test_temperature_exact_at_table_point() {
918 let td = TemperatureDependence::new(
919 "yield_strength",
920 vec![(20.0, 215e6), (200.0, 185e6), (400.0, 140e6)],
921 );
922 assert!((td.value_at(20.0) - 215e6).abs() < 1e-3);
923 assert!((td.value_at(200.0) - 185e6).abs() < 1e-3);
924 assert!((td.value_at(400.0) - 140e6).abs() < 1e-3);
925 }
926
927 #[test]
928 fn test_temperature_interpolation_midpoint() {
929 let td = TemperatureDependence::new("yield_strength", vec![(0.0, 0.0), (100.0, 100.0)]);
930 let v = td.value_at(50.0);
931 assert!((v - 50.0).abs() < 1e-9, "v={v}");
932 }
933
934 #[test]
935 fn test_temperature_clamp_below_range() {
936 let td = TemperatureDependence::new("E", vec![(100.0, 200e9), (300.0, 180e9)]);
937 let v = td.value_at(0.0);
938 assert!((v - 200e9).abs() < 1.0, "v={v}");
939 }
940
941 #[test]
942 fn test_temperature_clamp_above_range() {
943 let td = TemperatureDependence::new("E", vec![(100.0, 200e9), (300.0, 180e9)]);
944 let v = td.value_at(500.0);
945 assert!((v - 180e9).abs() < 1.0, "v={v}");
946 }
947
948 #[test]
951 fn test_filter_category_prefix() {
952 let mut db = MaterialDatabase::new();
953 db.add(make_steel());
954 db.add(make_aluminum());
955 let flt = MaterialFilter::new().filter_category("Metal>Ferrous");
956 let res = flt.apply(&db);
957 assert_eq!(res.len(), 1);
958 assert_eq!(res[0].name, "AISI 304");
959 }
960
961 #[test]
962 fn test_filter_min_property() {
963 let mut db = MaterialDatabase::new();
964 db.add(make_steel());
965 db.add(make_aluminum());
966 let flt = MaterialFilter::new().filter_min("density", 5000.0);
968 let res = flt.apply(&db);
969 assert_eq!(res.len(), 1);
970 assert_eq!(res[0].name, "AISI 304");
971 }
972
973 #[test]
974 fn test_filter_max_property() {
975 let mut db = MaterialDatabase::new();
976 db.add(make_steel());
977 db.add(make_aluminum());
978 let flt = MaterialFilter::new().filter_max("density", 5000.0);
980 let res = flt.apply(&db);
981 assert_eq!(res.len(), 1);
982 assert_eq!(res[0].name, "Al 6061");
983 }
984
985 #[test]
986 fn test_filter_combined() {
987 let mut db = MaterialDatabase::new();
988 db.add(make_steel());
989 db.add(make_aluminum());
990 let flt = MaterialFilter::new()
992 .filter_category("Metal")
993 .filter_min("density", 5000.0);
994 let res = flt.apply(&db);
995 assert_eq!(res.len(), 1);
996 }
997
998 #[test]
999 fn test_filter_nothing_passes_impossible_constraint() {
1000 let mut db = MaterialDatabase::new();
1001 db.add(make_steel());
1002 let flt = MaterialFilter::new().filter_min("density", 1e10);
1003 let res = flt.apply(&db);
1004 assert!(res.is_empty());
1005 }
1006
1007 #[test]
1010 fn test_category_hierarchy_contains_leaf() {
1011 let tree = MaterialCategories::node(
1012 "Metal",
1013 vec![MaterialCategories::node(
1014 "Ferrous",
1015 vec![MaterialCategories::leaf("Steel")],
1016 )],
1017 );
1018 assert!(tree.contains_name("Steel"));
1019 assert!(tree.contains_name("Ferrous"));
1020 assert!(tree.contains_name("Metal"));
1021 assert!(!tree.contains_name("Polymer"));
1022 }
1023
1024 #[test]
1025 fn test_category_leaf_names() {
1026 let tree = MaterialCategories::node(
1027 "Metal",
1028 vec![
1029 MaterialCategories::node(
1030 "Ferrous",
1031 vec![
1032 MaterialCategories::leaf("Steel"),
1033 MaterialCategories::leaf("Cast Iron"),
1034 ],
1035 ),
1036 MaterialCategories::leaf("Aluminum"),
1037 ],
1038 );
1039 let leaves = tree.leaf_names();
1040 assert_eq!(leaves.len(), 3);
1041 assert!(leaves.contains(&"Steel".to_string()));
1042 assert!(leaves.contains(&"Cast Iron".to_string()));
1043 assert!(leaves.contains(&"Aluminum".to_string()));
1044 }
1045
1046 #[test]
1049 fn test_csv_parse_basic() {
1050 let csv = "Name,Category,density,elastic_modulus\n\
1051 Steel,Metal>Ferrous,7850,210000000000\n\
1052 Aluminum,Metal>NonFerrous,2700,69000000000\n";
1053 let db = CsvMaterialDb::parse(csv);
1054 assert_eq!(db.records.len(), 2);
1055 assert_eq!(db.records[0].name, "Steel");
1056 let d = db.records[0].get_value("density").unwrap();
1057 assert!((d - 7850.0).abs() < 1e-3, "density={d}");
1058 }
1059
1060 #[test]
1061 fn test_csv_category_mapping() {
1062 let csv = "Name,Category,density\nAluminum,Metal>NonFerrous,2700\n";
1063 let db = CsvMaterialDb::parse(csv);
1064 assert_eq!(db.records[0].category, "Metal>NonFerrous");
1065 }
1066
1067 #[test]
1068 fn test_csv_empty_input() {
1069 let db = CsvMaterialDb::parse("");
1070 assert!(db.records.is_empty());
1071 }
1072
1073 #[test]
1074 fn test_csv_skips_empty_lines() {
1075 let csv = "Name,Category,density\n\nSteel,Metal,7850\n\n";
1076 let db = CsvMaterialDb::parse(csv);
1077 assert_eq!(db.records.len(), 1);
1078 }
1079}