Skip to main content

nucleide_material/
compendium.rs

1//! DOE/PNNL Materials Compendium ingestion (Revision 2, 411 materials).
2//!
3//! Parses the official `MaterialsCompendium.json` of the DOE/PNNL
4//! Materials Compendium, Revision 2 (companion license vendored under
5//! `fixtures/data/`) into typed entries convertible to [`Material`]s with
6//! full isotopic weight fractions.
7//!
8//! Schema notes (verified against the dataset):
9//! - top level `{siteVersion: String, data: [entry; 411]}`;
10//! - every entry carries `Elements[].Isotopes[]` — no degenerate cases;
11//! - `Isotopes[].WeightFraction` is the material-level mass fraction
12//!   (element-level fractions live in `IsotopicWeightFraction`);
13//! - `ZAID` is numeric (`1001`, `95242`-style metastables absent here);
14//! - names and MatNum values are unique across all 411 entries.
15
16use std::collections::BTreeMap;
17use std::path::Path;
18
19use serde::Deserialize;
20
21use crate::material::Material;
22use nucleide_nuclei::NuclideId;
23
24/// Errors from compendium loading.
25#[derive(Debug, Clone, PartialEq)]
26#[non_exhaustive]
27pub enum Error {
28    /// Reading the compendium file failed.
29    Io(String),
30    /// The text was not valid compendium JSON.
31    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
44/// Accept either a JSON string or an array of strings (the upstream dataset
45/// is inconsistent: most entries use arrays, some a single bare string).
46fn 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/// One isotope row of an element inside a compendium material.
74#[derive(Debug, Clone, Deserialize)]
75pub struct CompendiumIsotope {
76    /// Isotope name as given in the dataset (`"H1"`-style).
77    #[serde(rename = "Isotope")]
78    pub isotope: String,
79    /// MCNP-style ZAID; serialized as a JSON string upstream ("1001").
80    #[serde(rename = "ZAID")]
81    pub zaid: String,
82    /// Natural abundance of the isotope within its element (isotopic atom
83    /// fraction as tabulated upstream).
84    #[serde(rename = "Abundance")]
85    pub abundance: f64,
86    /// Mass fraction within the parent element.
87    #[serde(rename = "IsotopicWeightFraction")]
88    pub isotopic_weight_fraction: f64,
89    /// Mass fraction of the whole material.
90    #[serde(rename = "WeightFraction")]
91    pub weight_fraction: f64,
92}
93
94/// One elemental constituent.
95#[derive(Debug, Clone, Deserialize)]
96pub struct CompendiumElement {
97    /// Element symbol (`"H"`, `"Fe"`).
98    #[serde(rename = "Element")]
99    pub element: String,
100    /// Atom fraction of the element in the whole material.
101    #[serde(rename = "AtomFraction")]
102    pub atom_fraction: f64,
103    /// Isotope rows of this element.
104    #[serde(rename = "Isotopes")]
105    pub isotopes: Vec<CompendiumIsotope>,
106}
107
108/// One compendium material entry.
109#[derive(Debug, Clone, Deserialize)]
110pub struct CompendiumEntry {
111    /// Display name (`"Bone Equivalent Plastic, B-110"`).
112    #[serde(rename = "Name")]
113    pub name: String,
114    /// Acronym(s) of the material; the upstream field mixes bare strings and
115    /// arrays, so this is always a vector.
116    #[serde(rename = "Acronym", default, deserialize_with = "string_or_vec")]
117    pub acronym: Vec<String>,
118    /// Compendium material number (unique across the dataset).
119    #[serde(rename = "MatNum")]
120    pub mat_num: u32,
121    /// Nominal density [g/cm³].
122    #[serde(rename = "Density")]
123    pub density: f64,
124    /// Nominal atom density [atoms/barn·cm]; 0.0 when the field is absent.
125    #[serde(rename = "MaterialAtomDensity", default)]
126    pub atom_density: f64,
127    /// Provenance string (e.g. `"PNNL"`); empty when absent.
128    #[serde(rename = "Source", default)]
129    pub source: String,
130    /// Free-form comment lines; upstream mixes bare strings and arrays, so
131    /// this is always a vector.
132    #[serde(rename = "Comment", default, deserialize_with = "string_or_vec")]
133    pub comment: Vec<String>,
134    /// Elemental constituents of the material.
135    #[serde(rename = "Elements")]
136    pub elements: Vec<CompendiumElement>,
137}
138
139impl CompendiumEntry {
140    /// All (isotope ZAID → material-level weight fraction) pairs.
141    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    /// Convert to a [`Material`] whose composition holds relative masses
156    /// equal to the isotopic weight fractions (normalized to 1 g total).
157    ///
158    /// Density and provenance are attached as metadata; set the real mass or
159    /// density separately when building transport inputs.
160    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            // Natural-element zaids (AAA == 0) cannot map to a single
168            // nuclide; keep them as placeholder ground-state ids like the
169            // mcnp-io inp convention.
170            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/// The full parsed compendium with fast lookups.
187#[derive(Debug, Clone)]
188pub struct MaterialsLibrary {
189    /// Dataset version tag (top-level `siteVersion`, e.g. `"0.1.1"`).
190    pub site_version: String,
191    /// All material entries in file order.
192    pub entries: Vec<CompendiumEntry>,
193    by_name: BTreeMap<String, usize>,
194    by_matnum: BTreeMap<u32, usize>,
195}
196
197impl MaterialsLibrary {
198    /// Parse compendium JSON text.
199    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    /// Read and parse a compendium JSON file.
221    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    /// Number of materials.
229    pub fn len(&self) -> usize {
230        self.entries.len()
231    }
232
233    /// True when the library holds no entries.
234    pub fn is_empty(&self) -> bool {
235        self.entries.is_empty()
236    }
237
238    /// Case-insensitive lookup by display name ("Air (dry, near sea level)").
239    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    /// Lookup by compendium material number (1..=411).
246    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    /// All display names in file order.
251    pub fn names(&self) -> Vec<&str> {
252        self.entries.iter().map(|e| e.name.as_str()).collect()
253    }
254}
255
256// Borrow helper keeping the index maps and storage in one struct without
257// split borrows surfacing in the public API.
258fn 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        // Dataset order: B-110 Bone Equivalent Plastic is entry zero.
289        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); // early compendium entry
297        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        // From the dataset: B-110 Bone Equivalent Plastic, H1 wf = 0.035491
323        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        // Fractions are relative masses; atom_fractions must resolve via AME2020.
338        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}