Skip to main content

oxiphysics_io/
material_db_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3//! Material database I/O: CES-style material properties, JSON material libraries.
4//!
5//! Provides typed property storage, a searchable material database, manual JSON
6//! serialization/deserialization, temperature-dependent property interpolation,
7//! and CSV import (Matweb-style).
8
9use std::collections::HashMap;
10
11// ── MaterialProperty ──────────────────────────────────────────────────────────
12
13/// A typed material property value.
14///
15/// Each variant carries the numeric content; metadata (units, source) lives in
16/// the parent `MaterialRecord`.
17#[allow(dead_code)]
18#[derive(Debug, Clone, PartialEq)]
19pub enum MaterialProperty {
20    /// A single scalar value.
21    Scalar(f64),
22    /// A min/max range (e.g. yield strength from 200 to 350 MPa).
23    Range(f64, f64),
24    /// A flat row-major matrix or tensor (e.g. stiffness tensor).
25    Matrix(Vec<f64>),
26}
27
28impl MaterialProperty {
29    /// Return the representative (mid-point or scalar) value for comparisons.
30    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    /// Check whether a value falls within this property (scalar exact, range inclusive).
45    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// ── MaterialRecord ────────────────────────────────────────────────────────────
55
56/// A single material entry: name, category, and a map of named properties.
57///
58/// Typical property keys: `"density"`, `"elastic_modulus"`, `"yield_strength"`,
59/// `"thermal_conductivity"`, `"poisson_ratio"`.
60#[allow(dead_code)]
61#[derive(Debug, Clone)]
62pub struct MaterialRecord {
63    /// Human-readable material name (e.g. `"AISI 304 Stainless Steel"`).
64    pub name: String,
65    /// Hierarchical category path separated by `>` (e.g. `"Metal>Ferrous>Steel"`).
66    pub category: String,
67    /// Named properties map.
68    pub properties: HashMap<String, MaterialProperty>,
69    /// Optional source/reference string.
70    pub source: String,
71}
72
73impl MaterialRecord {
74    /// Create a new empty `MaterialRecord`.
75    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    /// Insert a scalar property.
85    pub fn set_scalar(&mut self, key: &str, value: f64) {
86        self.properties
87            .insert(key.to_string(), MaterialProperty::Scalar(value));
88    }
89
90    /// Insert a range property.
91    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    /// Retrieve the representative value of a named property, or `None`.
97    pub fn get_value(&self, key: &str) -> Option<f64> {
98        self.properties.get(key).map(|p| p.representative())
99    }
100}
101
102// ── MaterialDatabase ──────────────────────────────────────────────────────────
103
104/// An in-memory collection of `MaterialRecord` entries with search capabilities.
105#[allow(dead_code)]
106#[derive(Debug, Clone, Default)]
107pub struct MaterialDatabase {
108    /// All stored records.
109    pub records: Vec<MaterialRecord>,
110}
111
112impl MaterialDatabase {
113    /// Create a new empty database.
114    pub fn new() -> Self {
115        Self {
116            records: Vec::new(),
117        }
118    }
119
120    /// Add a record to the database.
121    pub fn add(&mut self, record: MaterialRecord) {
122        self.records.push(record);
123    }
124
125    /// Search for records whose `category` starts with the given prefix.
126    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    /// Filter records where a named property's representative value falls in `[lo, hi]`.
134    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    /// Generate Ashby chart data: pairs of (prop_x, prop_y) for each record that
148    /// has both properties.
149    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// ── MaterialCategories ────────────────────────────────────────────────────────
162
163/// Hierarchical material category tree node.
164///
165/// Each node has a name and optional children, representing the tree:
166/// `Metal > Ferrous > Steel > AISI 304`.
167#[allow(dead_code)]
168#[derive(Debug, Clone)]
169pub struct MaterialCategories {
170    /// Category name (leaf or node).
171    pub name: String,
172    /// Child subcategories.
173    pub children: Vec<MaterialCategories>,
174}
175
176impl MaterialCategories {
177    /// Create a leaf category node.
178    pub fn leaf(name: &str) -> Self {
179        Self {
180            name: name.to_string(),
181            children: Vec::new(),
182        }
183    }
184
185    /// Create a node with children.
186    pub fn node(name: &str, children: Vec<MaterialCategories>) -> Self {
187        Self {
188            name: name.to_string(),
189            children,
190        }
191    }
192
193    /// Check whether this node or any descendant has the given name.
194    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    /// Collect all leaf names under this node.
202    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// ── MaterialComparison ────────────────────────────────────────────────────────
212
213/// Compare materials using performance indices and radar chart data.
214#[allow(dead_code)]
215#[derive(Debug, Clone)]
216pub struct MaterialComparison {
217    /// Property keys used for comparison.
218    pub axes: Vec<String>,
219}
220
221impl MaterialComparison {
222    /// Create a `MaterialComparison` for the given set of property axes.
223    pub fn new(axes: Vec<String>) -> Self {
224        Self { axes }
225    }
226
227    /// Compute a performance index for a record.
228    ///
229    /// The index is the ratio of two properties: `numerator / denominator`.
230    /// Returns `None` if either property is missing or denominator is zero.
231    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    /// Rank materials by performance index (descending, highest first).
246    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    /// Produce radar chart data for a single material: normalized values on each axis.
264    ///
265    /// Each value is divided by `reference_values[i]` for normalization.
266    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// ── TemperatureDependence ─────────────────────────────────────────────────────
288
289/// Temperature-dependent material property via piecewise linear interpolation.
290///
291/// Tabulated as `(temperature, value)` pairs sorted by temperature.
292#[allow(dead_code)]
293#[derive(Debug, Clone)]
294pub struct TemperatureDependence {
295    /// Sorted (temperature, value) table.
296    pub table: Vec<(f64, f64)>,
297    /// Property name this table describes.
298    pub property_name: String,
299}
300
301impl TemperatureDependence {
302    /// Create a new `TemperatureDependence` table.
303    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    /// Interpolate property value at the given temperature.
312    ///
313    /// Clamps to the first/last value outside the tabulated range.
314    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        // Binary search for the bracket
330        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// ── MaterialFilter ────────────────────────────────────────────────────────────
348
349/// Chainable filter for material databases.
350///
351/// Filters can be composed: call `filter_category`, `filter_min`, `filter_max`
352/// in sequence and collect results with `apply`.
353#[allow(dead_code)]
354#[derive(Debug, Clone, Default)]
355pub struct MaterialFilter {
356    /// Optional category prefix filter.
357    pub category_prefix: Option<String>,
358    /// Per-property minimum value constraints.
359    pub min_values: HashMap<String, f64>,
360    /// Per-property maximum value constraints.
361    pub max_values: HashMap<String, f64>,
362}
363
364impl MaterialFilter {
365    /// Create an empty filter (passes everything).
366    pub fn new() -> Self {
367        Self::default()
368    }
369
370    /// Restrict to materials whose category starts with `prefix`.
371    pub fn filter_category(mut self, prefix: &str) -> Self {
372        self.category_prefix = Some(prefix.to_string());
373        self
374    }
375
376    /// Require property `key` >= `min`.
377    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    /// Require property `key` <= `max`.
383    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    /// Apply the filter to a database and return matching records.
389    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// ── JsonMaterialDb ─────────────────────────────────────────────────────────────
423
424/// Manual JSON serialization/deserialization for a `MaterialDatabase`.
425///
426/// Does not use `serde`; produces and parses a simple human-readable JSON format.
427#[allow(dead_code)]
428pub struct JsonMaterialDb;
429
430impl JsonMaterialDb {
431    /// Serialize a `MaterialRecord` to a JSON object string.
432    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    /// Parse a single material record from a simplified JSON string.
484    ///
485    /// Supports the format produced by `write_json_material`.
486    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        // Extract the properties block between "properties": { ... }
495        let props_start = json.find("\"properties\":")?;
496        let block_start = json[props_start..].find('{')? + props_start + 1;
497        // Find the matching closing brace
498        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        // Parse each "key": {...} pair
520        let mut pos = 0usize;
521        let pb_chars: Vec<char> = props_block.chars().collect();
522        while pos < pb_chars.len() {
523            // Find next quoted key
524            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                    // Find the opening brace of the value object
529                    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                        // Find matching closing brace
533                        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                        // Determine type
550                        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
581/// Escape special characters for JSON strings.
582fn escape_json(s: &str) -> String {
583    s.replace('\\', "\\\\").replace('"', "\\\"")
584}
585
586/// Extract a string value from a flat JSON object for a given key.
587fn 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    // Skip whitespace and the opening quote
592    let after = after.trim_start();
593    if !after.starts_with('"') {
594        return None;
595    }
596    let inner = &after[1..];
597    // Find the closing quote (skip escaped quotes)
598    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
613/// Extract a f64 value from a flat JSON object for a given key.
614fn 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    // Read until comma, }, or whitespace
619    let end = after.find([',', '}', '\n']).unwrap_or(after.len());
620    after[..end].trim().parse().ok()
621}
622
623/// Extract a JSON array of f64 values: `"key": [1.0, 2.0, ...]`.
624fn 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// ── CsvMaterialDb ─────────────────────────────────────────────────────────────
643
644/// Matweb-style CSV material database reader.
645///
646/// Expected CSV format: first row is headers, subsequent rows are materials.
647/// At minimum, columns `Name` and `Category` must be present.
648/// All other columns are treated as scalar properties.
649#[allow(dead_code)]
650pub struct CsvMaterialDb;
651
652impl CsvMaterialDb {
653    /// Parse a Matweb-style CSV string into a `MaterialDatabase`.
654    pub fn parse(csv: &str) -> MaterialDatabase {
655        let mut db = MaterialDatabase::new();
656        let mut lines = csv.lines();
657
658        // Parse header row
659        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// ── Tests ─────────────────────────────────────────────────────────────────────
701
702#[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    // --- MaterialProperty ---
726
727    #[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    // --- MaterialRecord ---
760
761    #[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    // --- MaterialDatabase ---
775
776    #[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        // density between 2000 and 5000 → only aluminum
792        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    // --- JSON round-trip ---
819
820    #[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    // --- Performance index ---
871
872    #[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        // Al should have higher specific stiffness (69e9/2700 ≈ 25.6 MN·m/kg)
885        // vs steel (200e9/8000 = 25 MN·m/kg) — approximately equal but Al slightly higher
886        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        // Both should appear
898        assert_eq!(ranked.len(), 2);
899        // Highest index first
900        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    // --- Temperature interpolation ---
915
916    #[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    // --- MaterialFilter ---
949
950    #[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        // density >= 5000 → only steel
967        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        // density <= 5000 → only aluminum
979        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        // category Metal, density >= 5000 → steel only
991        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    // --- Category hierarchy ---
1008
1009    #[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    // --- CSV parse ---
1047
1048    #[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}