1use std::collections::BTreeMap;
17use std::path::Path;
18
19use serde::Deserialize;
20
21use crate::material::Material;
22use nucleide_nuclei::NuclideId;
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum Error {
27 Io(String),
29 Json(String),
31}
32
33impl std::fmt::Display for Error {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 match self {
36 Error::Io(m) => write!(f, "io error: {m}"),
37 Error::Json(m) => write!(f, "compendium JSON error: {m}"),
38 }
39 }
40}
41impl std::error::Error for Error {}
42
43fn string_or_vec<'de, D>(de: D) -> Result<Vec<String>, D::Error>
46where
47 D: serde::Deserializer<'de>,
48{
49 struct V;
50 impl<'de2> serde::de::Visitor<'de2> for V {
51 type Value = Vec<String>;
52 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.write_str("string or list of strings")
54 }
55 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
56 Ok(vec![v.to_string()])
57 }
58 fn visit_seq<S: serde::de::SeqAccess<'de2>>(
59 self,
60 mut seq: S,
61 ) -> Result<Self::Value, S::Error> {
62 let mut out = Vec::new();
63 while let Some(s) = seq.next_element::<String>()? {
64 out.push(s);
65 }
66 Ok(out)
67 }
68 }
69 de.deserialize_any(V)
70}
71
72#[derive(Debug, Clone, Deserialize)]
74pub struct CompendiumIsotope {
75 #[serde(rename = "Isotope")]
77 pub isotope: String,
78 #[serde(rename = "ZAID")]
80 pub zaid: String,
81 #[serde(rename = "Abundance")]
84 pub abundance: f64,
85 #[serde(rename = "IsotopicWeightFraction")]
87 pub isotopic_weight_fraction: f64,
88 #[serde(rename = "WeightFraction")]
90 pub weight_fraction: f64,
91}
92
93#[derive(Debug, Clone, Deserialize)]
95pub struct CompendiumElement {
96 #[serde(rename = "Element")]
98 pub element: String,
99 #[serde(rename = "AtomFraction")]
101 pub atom_fraction: f64,
102 #[serde(rename = "Isotopes")]
104 pub isotopes: Vec<CompendiumIsotope>,
105}
106
107#[derive(Debug, Clone, Deserialize)]
109pub struct CompendiumEntry {
110 #[serde(rename = "Name")]
112 pub name: String,
113 #[serde(rename = "Acronym", default, deserialize_with = "string_or_vec")]
116 pub acronym: Vec<String>,
117 #[serde(rename = "MatNum")]
119 pub mat_num: u32,
120 #[serde(rename = "Density")]
122 pub density: f64,
123 #[serde(rename = "MaterialAtomDensity", default)]
125 pub atom_density: f64,
126 #[serde(rename = "Source", default)]
128 pub source: String,
129 #[serde(rename = "Comment", default, deserialize_with = "string_or_vec")]
132 pub comment: Vec<String>,
133 #[serde(rename = "Elements")]
135 pub elements: Vec<CompendiumElement>,
136}
137
138impl CompendiumEntry {
139 pub fn weight_fractions(&self) -> BTreeMap<u32, f64> {
141 let mut out = BTreeMap::new();
142 for el in &self.elements {
143 for iso in &el.isotopes {
144 let zaid: u32 = match iso.zaid.parse() {
145 Ok(v) => v,
146 Err(_) => continue,
147 };
148 *out.entry(zaid).or_insert(0.0) += iso.weight_fraction;
149 }
150 }
151 out
152 }
153
154 pub fn to_material(&self) -> Result<Material, Error> {
160 use nucleide_nuclei::dialects;
161 let mut mat = Material::new();
162 for (zaid, wf) in self.weight_fractions() {
163 if wf <= 0.0 {
164 continue;
165 }
166 let id = dialects::from_zaid(zaid)
170 .unwrap_or_else(|_| NuclideId::from_nucid((zaid / 1000) * 10_000_000));
171 mat.add_nuclide(id, wf);
172 }
173 mat.set_metadata(Some(serde_json::json!({
174 "source": "DOE/PNNL Materials Compendium Rev.2",
175 "acronym": self.acronym,
176 "mat_num": self.mat_num,
177 "density_g_cm3": self.density,
178 "atom_density": self.atom_density,
179 "reference": self.source,
180 })));
181 Ok(mat)
182 }
183}
184
185#[derive(Debug, Clone)]
187pub struct MaterialsLibrary {
188 pub site_version: String,
190 pub entries: Vec<CompendiumEntry>,
192 by_name: BTreeMap<String, usize>,
193 by_matnum: BTreeMap<u32, usize>,
194}
195
196impl MaterialsLibrary {
197 pub fn from_json(text: &str) -> Result<Self, Error> {
199 #[derive(Deserialize)]
200 struct Top {
201 #[serde(rename = "siteVersion")]
202 site_version: String,
203 data: Vec<CompendiumEntry>,
204 }
205 let top: Top = serde_json::from_str(text).map_err(|e| Error::Json(e.to_string()))?;
206 let mut lib = Self {
207 site_version: top.site_version,
208 entries: top.data,
209 by_name: BTreeMap::new(),
210 by_matnum: BTreeMap::new(),
211 };
212 for (i, e) in lib.entries.iter().enumerate() {
213 lib.by_name.insert(e.name.to_ascii_lowercase(), i);
214 lib.by_matnum.insert(e.mat_num, i);
215 }
216 Ok(lib)
217 }
218
219 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, Error> {
221 let path = path.as_ref();
222 let text = std::fs::read_to_string(path)
223 .map_err(|e| Error::Io(format!("{}: {}", path.display(), e)))?;
224 Self::from_json(&text)
225 }
226
227 pub fn len(&self) -> usize {
229 self.entries.len()
230 }
231
232 pub fn is_empty(&self) -> bool {
234 self.entries.is_empty()
235 }
236
237 pub fn get(&self, name: &str) -> Option<&CompendiumEntry> {
239 self.by_name
240 .get(&name.to_ascii_lowercase())
241 .map(|&i| &lib_entries(self)[i])
242 }
243
244 pub fn get_by_matnum(&self, num: u32) -> Option<&CompendiumEntry> {
246 self.by_matnum.get(&num).map(|&i| &lib_entries(self)[i])
247 }
248
249 pub fn names(&self) -> Vec<&str> {
251 self.entries.iter().map(|e| e.name.as_str()).collect()
252 }
253}
254
255fn lib_entries(lib: &MaterialsLibrary) -> &Vec<CompendiumEntry> {
258 &lib.entries
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn library() -> MaterialsLibrary {
266 let path = concat!(
267 env!("CARGO_MANIFEST_DIR"),
268 "/../../fixtures/data/MaterialsCompendium.json"
269 );
270 MaterialsLibrary::from_file(path).unwrap()
271 }
272
273 #[test]
274 fn loads_all_411_materials() {
275 let lib = library();
276 assert_eq!(lib.len(), 411);
277 assert!(!lib.is_empty());
278 assert!(!lib.site_version.is_empty());
279 }
280
281 #[test]
282 fn names_unique_and_ordered() {
283 let lib = library();
284 let names = lib.names();
285 let unique: std::collections::BTreeSet<&str> = names.iter().copied().collect();
286 assert_eq!(unique.len(), 411);
287 assert_eq!(names[0], "Bone Equivalent Plastic, B-110");
289 }
290
291 #[test]
292 fn lookup_by_name_case_insensitive() {
293 let lib = library();
294 let air = lib.get("air (DRY, near sea level)").expect("air present");
295 assert_eq!(air.mat_num, 4); assert!(air.density > 0.001);
297 }
298
299 #[test]
300 fn lookup_by_matnum() {
301 let lib = library();
302 assert!(lib.get_by_matnum(1).is_some());
303 assert!(lib.get_by_matnum(412).is_none());
304 }
305
306 #[test]
307 fn weight_fractions_sum_near_one() {
308 let lib = library();
309 for entry in &lib.entries {
310 let total: f64 = entry.weight_fractions().values().sum();
311 assert!(
312 (total - 1.0).abs() < 1e-2,
313 "material `{}` fractions sum to {total}",
314 entry.name
315 );
316 }
317 }
318
319 #[test]
320 fn bone_plastic_h1_spot_value() {
321 let lib = library();
323 let bone = lib.get("Bone Equivalent Plastic, B-110").unwrap();
324 let wf = bone.weight_fractions();
325 assert!((wf[&1_001] - 0.035491).abs() < 1e-6);
326 }
327
328 #[test]
329 fn to_material_builds_named_composition_with_metadata() {
330 let lib = library();
331 let entry = lib.get("Acetone").unwrap();
332 let mat = entry.to_material().unwrap();
333 assert!(!mat.comp.is_empty());
334 let meta = mat.metadata().expect("metadata attached");
335 assert_eq!(meta["mat_num"], entry.mat_num);
336 let af = mat.atom_fractions(&crate::Ame2020).unwrap();
338 assert_eq!(af.len(), mat.comp.len());
339 }
340
341 #[test]
342 fn missing_file_errors() {
343 let err = MaterialsLibrary::from_file("/nonexistent/compendium.json");
344 assert!(matches!(err, Err(Error::Io(_))));
345 }
346}