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