Skip to main content

oxiphysics_io/
material_db.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Material properties database for physics simulations.
5//!
6//! Provides a searchable in-memory database of engineering materials with
7//! mechanical, thermal, and physical properties. Supports JSON serialisation.
8
9/// A single material entry in the database.
10#[derive(Debug, Clone)]
11pub struct MaterialEntry {
12    /// Material name (e.g. `"steel_1020"`).
13    pub name: String,
14    /// Mass density in kg/m³.
15    pub density: f64,
16    /// Young's modulus in Pa.
17    pub youngs_modulus: f64,
18    /// Poisson's ratio (dimensionless).
19    pub poisson_ratio: f64,
20    /// Thermal conductivity in W/(m·K).
21    pub thermal_conductivity: f64,
22    /// Specific heat capacity in J/(kg·K).
23    pub specific_heat: f64,
24    /// Coefficient of thermal expansion in 1/K.
25    pub thermal_expansion: f64,
26}
27
28impl MaterialEntry {
29    /// Create a new material entry with all fields.
30    #[allow(clippy::too_many_arguments)]
31    pub fn new(
32        name: impl Into<String>,
33        density: f64,
34        youngs_modulus: f64,
35        poisson_ratio: f64,
36        thermal_conductivity: f64,
37        specific_heat: f64,
38        thermal_expansion: f64,
39    ) -> Self {
40        Self {
41            name: name.into(),
42            density,
43            youngs_modulus,
44            poisson_ratio,
45            thermal_conductivity,
46            specific_heat,
47            thermal_expansion,
48        }
49    }
50
51    /// Serialise this entry to a JSON-like string fragment (no outer braces).
52    fn to_json_fields(&self) -> String {
53        format!(
54            r#""name":"{name}","density":{density},"youngs_modulus":{ym},"poisson_ratio":{pr},"thermal_conductivity":{tc},"specific_heat":{sh},"thermal_expansion":{te}"#,
55            name = self.name,
56            density = self.density,
57            ym = self.youngs_modulus,
58            pr = self.poisson_ratio,
59            tc = self.thermal_conductivity,
60            sh = self.specific_heat,
61            te = self.thermal_expansion,
62        )
63    }
64}
65
66/// An in-memory database of material entries.
67#[derive(Debug, Default)]
68pub struct MaterialDatabase {
69    entries: Vec<MaterialEntry>,
70}
71
72impl MaterialDatabase {
73    /// Create an empty material database.
74    pub fn new() -> Self {
75        Self::default()
76    }
77
78    /// Create a database pre-populated with common engineering materials.
79    ///
80    /// Includes: steel 1020, aluminium 6061, copper, titanium Ti-6Al-4V,
81    /// PTFE, water (liquid, 20 °C), and dry air (20 °C, 1 atm).
82    pub fn with_defaults() -> Self {
83        let mut db = Self::new();
84        // Steel 1020
85        db.add_material(MaterialEntry::new(
86            "steel_1020",
87            7_870.0,
88            200.0e9,
89            0.29,
90            51.9,
91            486.0,
92            11.7e-6,
93        ));
94        // Aluminium 6061-T6
95        db.add_material(MaterialEntry::new(
96            "aluminum_6061",
97            2_700.0,
98            68.9e9,
99            0.33,
100            167.0,
101            896.0,
102            23.6e-6,
103        ));
104        // Copper (pure, annealed)
105        db.add_material(MaterialEntry::new(
106            "copper", 8_960.0, 117.0e9, 0.34, 385.0, 385.0, 17.0e-6,
107        ));
108        // Titanium Ti-6Al-4V
109        db.add_material(MaterialEntry::new(
110            "titanium_ti6al4v",
111            4_430.0,
112            113.8e9,
113            0.342,
114            6.7,
115            526.3,
116            8.6e-6,
117        ));
118        // PTFE (Teflon)
119        db.add_material(MaterialEntry::new(
120            "ptfe", 2_200.0, 0.5e9, 0.46, 0.25, 1_004.0, 135.0e-6,
121        ));
122        // Water (liquid, 20 °C)
123        db.add_material(MaterialEntry::new(
124            "water", 998.2, 2.2e9, 0.5, 0.598, 4_182.0, 0.207e-3,
125        ));
126        // Dry air (20 °C, 1 atm)
127        db.add_material(MaterialEntry::new(
128            "air", 1.204, 0.0, 0.0, 0.0257, 1_005.0, 3.43e-3,
129        ));
130        db
131    }
132
133    /// Add a material to the database.
134    pub fn add_material(&mut self, entry: MaterialEntry) {
135        self.entries.push(entry);
136    }
137
138    /// Look up a material by name (case-sensitive).
139    ///
140    /// Returns `Some(&MaterialEntry)` if found, `None` otherwise.
141    pub fn get_material(&self, name: &str) -> Option<&MaterialEntry> {
142        self.entries.iter().find(|e| e.name == name)
143    }
144
145    /// Remove a material by name.
146    ///
147    /// Returns `true` if a material was removed, `false` if not found.
148    pub fn remove_material(&mut self, name: &str) -> bool {
149        let before = self.entries.len();
150        self.entries.retain(|e| e.name != name);
151        self.entries.len() < before
152    }
153
154    /// Returns the number of entries in the database.
155    pub fn len(&self) -> usize {
156        self.entries.len()
157    }
158
159    /// Returns `true` if the database has no entries.
160    pub fn is_empty(&self) -> bool {
161        self.entries.is_empty()
162    }
163
164    /// Return all materials whose density falls within `[min_density, max_density]`.
165    pub fn search_by_density(&self, min_density: f64, max_density: f64) -> Vec<&MaterialEntry> {
166        self.entries
167            .iter()
168            .filter(|e| e.density >= min_density && e.density <= max_density)
169            .collect()
170    }
171
172    /// Return all materials whose Young's modulus falls within `[min_e, max_e]`.
173    pub fn search_by_youngs_modulus(&self, min_e: f64, max_e: f64) -> Vec<&MaterialEntry> {
174        self.entries
175            .iter()
176            .filter(|e| e.youngs_modulus >= min_e && e.youngs_modulus <= max_e)
177            .collect()
178    }
179
180    /// Serialise the entire database to a JSON string.
181    ///
182    /// The format is a JSON array of objects: `[{"name":..., ...}, ...]`.
183    pub fn export_json(&self) -> String {
184        let items: Vec<String> = self
185            .entries
186            .iter()
187            .map(|e| format!("{{{}}}", e.to_json_fields()))
188            .collect();
189        format!("[{}]", items.join(","))
190    }
191
192    /// Deserialise a database from a JSON string produced by `export_json`.
193    ///
194    /// Returns `Err` if parsing fails. This is a minimal hand-rolled parser that
195    /// handles the exact format produced by `export_json`.
196    pub fn import_json(json: &str) -> Result<Self, String> {
197        let mut db = Self::new();
198        let trimmed = json.trim();
199        if trimmed == "[]" {
200            return Ok(db);
201        }
202        // Strip outer brackets
203        let inner = trimmed
204            .strip_prefix('[')
205            .and_then(|s| s.strip_suffix(']'))
206            .ok_or("Expected JSON array")?;
207
208        // Split objects: find balanced { } blocks
209        let objects = split_json_objects(inner)?;
210        for obj in objects {
211            let entry = parse_material_json_object(&obj)?;
212            db.add_material(entry);
213        }
214        Ok(db)
215    }
216}
217
218// ── Private JSON helpers ──────────────────────────────────────────────────────
219
220/// Split a comma-delimited sequence of `{...}` objects into individual strings.
221fn split_json_objects(s: &str) -> Result<Vec<String>, String> {
222    let mut objects = Vec::new();
223    let mut depth = 0i32;
224    let mut start = 0usize;
225
226    for (i, c) in s.char_indices() {
227        match c {
228            '{' => {
229                if depth == 0 {
230                    start = i;
231                }
232                depth += 1;
233            }
234            '}' => {
235                depth -= 1;
236                if depth == 0 {
237                    objects.push(s[start..=i].to_string());
238                }
239            }
240            _ => {}
241        }
242    }
243
244    if depth != 0 {
245        return Err("Unbalanced braces in JSON".into());
246    }
247    Ok(objects)
248}
249
250/// Extract the value of a JSON string field `"key":"value"`.
251fn extract_str_field<'a>(obj: &'a str, key: &str) -> Option<&'a str> {
252    let needle = format!("\"{}\":\"", key);
253    let start = obj.find(&needle)? + needle.len();
254    let end = obj[start..].find('"')? + start;
255    Some(&obj[start..end])
256}
257
258/// Extract the value of a JSON numeric field `"key":value`.
259fn extract_f64_field(obj: &str, key: &str) -> Option<f64> {
260    let needle = format!("\"{}\":", key);
261    let start = obj.find(&needle)? + needle.len();
262    let rest = &obj[start..];
263    // Read until comma, closing brace, or end
264    let end = rest.find([',', '}']).unwrap_or(rest.len());
265    rest[..end].trim().parse().ok()
266}
267
268/// Parse a single JSON object `{...}` into a `MaterialEntry`.
269fn parse_material_json_object(obj: &str) -> Result<MaterialEntry, String> {
270    let name = extract_str_field(obj, "name")
271        .ok_or("missing name")?
272        .to_string();
273    let density = extract_f64_field(obj, "density").ok_or("missing density")?;
274    let youngs_modulus =
275        extract_f64_field(obj, "youngs_modulus").ok_or("missing youngs_modulus")?;
276    let poisson_ratio = extract_f64_field(obj, "poisson_ratio").ok_or("missing poisson_ratio")?;
277    let thermal_conductivity =
278        extract_f64_field(obj, "thermal_conductivity").ok_or("missing thermal_conductivity")?;
279    let specific_heat = extract_f64_field(obj, "specific_heat").ok_or("missing specific_heat")?;
280    let thermal_expansion =
281        extract_f64_field(obj, "thermal_expansion").ok_or("missing thermal_expansion")?;
282
283    Ok(MaterialEntry {
284        name,
285        density,
286        youngs_modulus,
287        poisson_ratio,
288        thermal_conductivity,
289        specific_heat,
290        thermal_expansion,
291    })
292}
293
294// ── Tests ─────────────────────────────────────────────────────────────────────
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    fn make_steel() -> MaterialEntry {
301        MaterialEntry::new("steel_test", 7_870.0, 200.0e9, 0.29, 51.9, 486.0, 11.7e-6)
302    }
303
304    // add / get
305    #[test]
306    fn test_add_and_get() {
307        let mut db = MaterialDatabase::new();
308        db.add_material(make_steel());
309        assert!(db.get_material("steel_test").is_some());
310    }
311
312    #[test]
313    fn test_get_missing() {
314        let db = MaterialDatabase::new();
315        assert!(db.get_material("nonexistent").is_none());
316    }
317
318    #[test]
319    fn test_len_empty() {
320        let db = MaterialDatabase::new();
321        assert_eq!(db.len(), 0);
322        assert!(db.is_empty());
323    }
324
325    #[test]
326    fn test_len_after_add() {
327        let mut db = MaterialDatabase::new();
328        db.add_material(make_steel());
329        assert_eq!(db.len(), 1);
330        assert!(!db.is_empty());
331    }
332
333    // remove
334    #[test]
335    fn test_remove_existing() {
336        let mut db = MaterialDatabase::new();
337        db.add_material(make_steel());
338        assert!(db.remove_material("steel_test"));
339        assert!(db.is_empty());
340    }
341
342    #[test]
343    fn test_remove_missing() {
344        let mut db = MaterialDatabase::new();
345        assert!(!db.remove_material("ghost"));
346    }
347
348    // defaults
349    #[test]
350    fn test_defaults_count() {
351        let db = MaterialDatabase::with_defaults();
352        assert_eq!(db.len(), 7);
353    }
354
355    #[test]
356    fn test_defaults_steel_exists() {
357        let db = MaterialDatabase::with_defaults();
358        assert!(db.get_material("steel_1020").is_some());
359    }
360
361    #[test]
362    fn test_defaults_aluminum_density() {
363        let db = MaterialDatabase::with_defaults();
364        let al = db.get_material("aluminum_6061").unwrap();
365        assert!((al.density - 2_700.0).abs() < 1.0);
366    }
367
368    #[test]
369    fn test_defaults_copper_youngs() {
370        let db = MaterialDatabase::with_defaults();
371        let cu = db.get_material("copper").unwrap();
372        assert!((cu.youngs_modulus - 117.0e9).abs() < 1e6);
373    }
374
375    #[test]
376    fn test_defaults_titanium_exists() {
377        let db = MaterialDatabase::with_defaults();
378        assert!(db.get_material("titanium_ti6al4v").is_some());
379    }
380
381    #[test]
382    fn test_defaults_ptfe_exists() {
383        let db = MaterialDatabase::with_defaults();
384        assert!(db.get_material("ptfe").is_some());
385    }
386
387    #[test]
388    fn test_defaults_water_exists() {
389        let db = MaterialDatabase::with_defaults();
390        assert!(db.get_material("water").is_some());
391    }
392
393    #[test]
394    fn test_defaults_air_exists() {
395        let db = MaterialDatabase::with_defaults();
396        assert!(db.get_material("air").is_some());
397    }
398
399    // search_by_density
400    #[test]
401    fn test_search_by_density_finds_metals() {
402        let db = MaterialDatabase::with_defaults();
403        let results = db.search_by_density(2_000.0, 9_000.0);
404        assert!(results.len() >= 4); // Al, Cu, Ti, steel
405    }
406
407    #[test]
408    fn test_search_by_density_excludes_air() {
409        let db = MaterialDatabase::with_defaults();
410        let results = db.search_by_density(2_000.0, 9_000.0);
411        assert!(!results.iter().any(|e| e.name == "air"));
412    }
413
414    #[test]
415    fn test_search_by_density_empty_range() {
416        let db = MaterialDatabase::with_defaults();
417        let results = db.search_by_density(1e12, 2e12);
418        assert!(results.is_empty());
419    }
420
421    // search_by_youngs_modulus
422    #[test]
423    fn test_search_by_youngs_excludes_air() {
424        let db = MaterialDatabase::with_defaults();
425        let results = db.search_by_youngs_modulus(1.0e9, 300.0e9);
426        assert!(!results.iter().any(|e| e.name == "air"));
427    }
428
429    #[test]
430    fn test_search_by_youngs_finds_steel() {
431        let db = MaterialDatabase::with_defaults();
432        let results = db.search_by_youngs_modulus(150.0e9, 250.0e9);
433        assert!(results.iter().any(|e| e.name == "steel_1020"));
434    }
435
436    // JSON roundtrip
437    #[test]
438    fn test_export_import_roundtrip() {
439        let db = MaterialDatabase::with_defaults();
440        let json = db.export_json();
441        let db2 = MaterialDatabase::import_json(&json).unwrap();
442        assert_eq!(db2.len(), db.len());
443    }
444
445    #[test]
446    fn test_export_import_preserves_density() {
447        let db = MaterialDatabase::with_defaults();
448        let json = db.export_json();
449        let db2 = MaterialDatabase::import_json(&json).unwrap();
450        let orig = db.get_material("steel_1020").unwrap();
451        let restored = db2.get_material("steel_1020").unwrap();
452        assert!((orig.density - restored.density).abs() < 1e-3);
453    }
454
455    #[test]
456    fn test_export_import_preserves_youngs_modulus() {
457        let db = MaterialDatabase::with_defaults();
458        let json = db.export_json();
459        let db2 = MaterialDatabase::import_json(&json).unwrap();
460        let orig = db.get_material("copper").unwrap();
461        let restored = db2.get_material("copper").unwrap();
462        assert!((orig.youngs_modulus - restored.youngs_modulus).abs() < 1e4);
463    }
464
465    #[test]
466    fn test_export_import_empty_db() {
467        let db = MaterialDatabase::new();
468        let json = db.export_json();
469        let db2 = MaterialDatabase::import_json(&json).unwrap();
470        assert!(db2.is_empty());
471    }
472
473    #[test]
474    fn test_export_import_single_entry() {
475        let mut db = MaterialDatabase::new();
476        db.add_material(make_steel());
477        let json = db.export_json();
478        let db2 = MaterialDatabase::import_json(&json).unwrap();
479        assert_eq!(db2.len(), 1);
480        let entry = db2.get_material("steel_test").unwrap();
481        assert!((entry.poisson_ratio - 0.29).abs() < 1e-9);
482    }
483
484    #[test]
485    fn test_import_invalid_json() {
486        assert!(MaterialDatabase::import_json("{bad json}").is_err());
487    }
488
489    // material_entry fields
490    #[test]
491    fn test_material_entry_fields() {
492        let entry = make_steel();
493        assert_eq!(entry.name, "steel_test");
494        assert!((entry.density - 7870.0).abs() < 1.0);
495        assert!((entry.youngs_modulus - 200.0e9).abs() < 1e6);
496        assert!((entry.poisson_ratio - 0.29).abs() < 1e-9);
497        assert!((entry.thermal_conductivity - 51.9).abs() < 0.01);
498        assert!((entry.specific_heat - 486.0).abs() < 0.1);
499        assert!((entry.thermal_expansion - 11.7e-6).abs() < 1e-10);
500    }
501}