Skip to main content

nucleide_material/
check.rs

1//! Composition label-collision and conservation checks.
2//!
3//! Pure functions over [`Material`]; no I/O. [`check_labels`] truncates
4//! every member's cross-code labels to DIF3D/MC2-style field widths and
5//! reports the truncated forms claimed by more than one nuclide.
6//! [`audit`] validates mass-fraction normalization, sign, key uniqueness,
7//! and atomic-mass availability.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use nucleide_nuclei::{armi, dialects, NuclideId};
12
13use crate::{MassProvider, Material};
14
15/// Default truncation widths probed by [`check_labels`]: 6 (DIF3D label
16/// limit) and 8 (MC2-3 label limit).
17pub const DEFAULT_WIDTHS: [usize; 2] = [6, 8];
18
19/// Tolerance for the mass-fraction-sum check in [`audit`].
20const FRACTION_TOL: f64 = 1e-9;
21
22/// One truncated label form claimed by more than one nuclide.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Collision {
25    /// The shared truncated form.
26    pub truncated: String,
27    /// The field width it was truncated to.
28    pub width: usize,
29    /// The colliding nuclides, sorted.
30    pub members: Vec<NuclideId>,
31}
32
33/// The class of a conservation problem found by [`audit`].
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AuditKind {
36    /// Normalized mass fractions do not sum to 1.0 (or no total exists).
37    FractionsDontSum,
38    /// A stored mass is negative or NaN.
39    NegativeMass,
40    /// The same nuclide id appears more than once.
41    DuplicateNuclide,
42    /// No atomic mass is available for a nuclide.
43    UnknownMass,
44}
45
46/// One conservation problem found by [`audit`].
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct AuditIssue {
49    /// The class of problem.
50    pub kind: AuditKind,
51    /// Human-readable detail (nuclide, offending value).
52    pub detail: String,
53}
54
55/// Cross-code labels emitted for one nuclide: GNDS name, MCNP ZAID,
56/// Serpent, ALARA, and the ARMI database label.
57fn cross_code_labels(id: NuclideId) -> [String; 5] {
58    [
59        id.to_name(),
60        dialects::to_zaid(id).to_string(),
61        dialects::serpent(id),
62        dialects::alara(id),
63        armi::nucid_to_armi_label(id),
64    ]
65}
66
67/// Truncate every member's cross-code labels to each of `widths` and report
68/// every truncated form claimed by more than one nuclide.
69///
70/// Results are ordered by `(width, truncated)`. Width `0` is skipped: it
71/// maps every label to the empty string.
72pub fn check_labels(mat: &Material, widths: &[usize]) -> Vec<Collision> {
73    let mut claims: BTreeMap<(usize, String), BTreeSet<NuclideId>> = BTreeMap::new();
74    for &id in mat.comp.keys() {
75        for label in cross_code_labels(id) {
76            for &width in widths {
77                if width == 0 {
78                    continue;
79                }
80                let truncated: String = label.chars().take(width).collect();
81                claims.entry((width, truncated)).or_default().insert(id);
82            }
83        }
84    }
85    claims
86        .into_iter()
87        .filter(|(_, members)| members.len() > 1)
88        .map(|((width, truncated), members)| Collision {
89            truncated,
90            width,
91            members: members.into_iter().collect(),
92        })
93        .collect()
94}
95
96/// Validate mass-fraction normalization, mass signs, key uniqueness, and
97/// atomic-mass availability.
98///
99/// - [`AuditKind::FractionsDontSum`]: the total mass is not positive-finite
100///   (empty, zero, NaN, or infinite total, so no fractions exist) or the
101///   normalized fractions sum outside `1.0 ± 1e-9`.
102/// - [`AuditKind::NegativeMass`]: one issue per nuclide with a negative or
103///   NaN stored mass.
104/// - [`AuditKind::DuplicateNuclide`]: defensive only — `comp` is keyed by
105///   [`NuclideId`], so duplicate keys cannot occur in memory; the variant
106///   exists for file/FFI consumers sharing [`AuditKind`].
107/// - [`AuditKind::UnknownMass`]: one issue per nuclide missing from
108///   `masses`.
109pub fn audit(mat: &Material, masses: &impl MassProvider) -> Vec<AuditIssue> {
110    let mut issues = Vec::new();
111    for (&id, &mass) in &mat.comp {
112        if mass.is_nan() || mass < 0.0 {
113            issues.push(AuditIssue {
114                kind: AuditKind::NegativeMass,
115                detail: format!("non-negative mass expected for {id}: {mass}"),
116            });
117        }
118    }
119    {
120        let mut seen = BTreeSet::new();
121        for &id in mat.comp.keys() {
122            if !seen.insert(id.nucid()) {
123                issues.push(AuditIssue {
124                    kind: AuditKind::DuplicateNuclide,
125                    detail: format!("duplicate nuclide {id}"),
126                });
127            }
128        }
129    }
130    let total: f64 = mat.comp.values().sum();
131    if !total.is_finite() || total <= 0.0 {
132        issues.push(AuditIssue {
133            kind: AuditKind::FractionsDontSum,
134            detail: format!("no mass fractions: total mass is {total}"),
135        });
136    } else {
137        let sum: f64 = mat.comp.values().map(|mass| mass / total).sum();
138        if (sum - 1.0).abs() > FRACTION_TOL {
139            issues.push(AuditIssue {
140                kind: AuditKind::FractionsDontSum,
141                detail: format!("mass fractions sum to {sum}, expected 1.0"),
142            });
143        }
144    }
145    for &id in mat.comp.keys() {
146        if masses.mass(id.nucid()).is_none() {
147            issues.push(AuditIssue {
148                kind: AuditKind::UnknownMass,
149                detail: format!("no atomic mass available for {id}"),
150            });
151        }
152    }
153    issues
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use std::collections::HashMap;
160
161    fn id(name: &str) -> NuclideId {
162        NuclideId::from_name(name).unwrap()
163    }
164
165    struct Table(HashMap<u32, f64>);
166
167    impl Table {
168        fn new(pairs: &[(&str, f64)]) -> Self {
169            Self(
170                pairs
171                    .iter()
172                    .map(|&(name, m)| (id(name).nucid(), m))
173                    .collect(),
174            )
175        }
176    }
177
178    impl MassProvider for Table {
179        fn mass(&self, nucid: u32) -> Option<f64> {
180            self.0.get(&nucid).copied()
181        }
182    }
183
184    fn full_table() -> Table {
185        Table::new(&[
186            ("U235", 235.0),
187            ("U238", 238.0),
188            ("Pu239", 239.0),
189            ("Am242_m1", 242.0),
190            ("Am242_m2", 242.0),
191        ])
192    }
193
194    #[test]
195    fn default_widths_cover_dif3d_and_mc2_limits() {
196        assert_eq!(DEFAULT_WIDTHS, [6, 8]);
197    }
198
199    #[test]
200    fn isomer_pair_collides_at_width_six() {
201        let mut mat = Material::new();
202        mat.add_nuclide(id("Am242_m1"), 1.0);
203        mat.add_nuclide(id("Am242_m2"), 1.0);
204
205        let hits = check_labels(&mat, &[6]);
206        let gnds = hits
207            .iter()
208            .find(|c| c.truncated == "Am242_" && c.width == 6)
209            .expect("Am242_m1/Am242_m2 share 6-char GNDS prefix Am242_");
210        assert_eq!(gnds.members, vec![id("Am242_m1"), id("Am242_m2")]);
211    }
212
213    #[test]
214    fn collisions_cover_default_widths_and_stay_ordered() {
215        let mut mat = Material::new();
216        mat.add_nuclide(id("Am242_m1"), 1.0);
217        mat.add_nuclide(id("Am242_m2"), 1.0);
218
219        let hits = check_labels(&mat, &DEFAULT_WIDTHS);
220        assert!(!hits.is_empty());
221        assert!(hits.iter().all(|c| c.members.len() > 1));
222        let keys: Vec<(usize, &str)> = hits
223            .iter()
224            .map(|c| (c.width, c.truncated.as_str()))
225            .collect();
226        let mut sorted = keys.clone();
227        sorted.sort();
228        assert_eq!(keys, sorted, "collisions ordered by (width, truncated)");
229        // ALARA drops state, so both isomers are literally "am:242".
230        assert!(hits.iter().any(|c| c.truncated == "am:242"));
231    }
232
233    #[test]
234    fn distinct_nuclides_do_not_collide() {
235        let mut mat = Material::new();
236        mat.add_nuclide(id("U235"), 19.0);
237        mat.add_nuclide(id("U238"), 1.0);
238        assert_eq!(check_labels(&mat, &DEFAULT_WIDTHS), vec![]);
239        assert_eq!(check_labels(&Material::new(), &DEFAULT_WIDTHS), vec![]);
240
241        let mut single = Material::new();
242        single.add_nuclide(id("U235"), 1.0);
243        assert_eq!(check_labels(&single, &[6, 8]), vec![]);
244        assert_eq!(check_labels(&single, &[0]), vec![]);
245    }
246
247    #[test]
248    fn clean_material_audits_empty() {
249        let mut mat = Material::new();
250        mat.add_nuclide(id("U235"), 19.0);
251        mat.add_nuclide(id("U238"), 1.0);
252        assert_eq!(audit(&mat, &full_table()), vec![]);
253    }
254
255    #[test]
256    fn negative_and_nan_masses_are_flagged() {
257        let mut mat = Material::new();
258        mat.add_nuclide(id("U235"), -1.0);
259        mat.add_nuclide(id("U238"), 2.0);
260        let issues = audit(&mat, &full_table());
261        assert_eq!(issues.len(), 1);
262        assert_eq!(issues[0].kind, AuditKind::NegativeMass);
263        assert!(issues[0].detail.contains("U235"));
264
265        let mut nan = Material::new();
266        nan.add_nuclide(id("Pu239"), f64::NAN);
267        nan.add_nuclide(id("U235"), 1.0);
268        let kinds: Vec<AuditKind> = audit(&nan, &full_table()).iter().map(|i| i.kind).collect();
269        assert!(kinds.contains(&AuditKind::NegativeMass));
270        assert!(kinds.contains(&AuditKind::FractionsDontSum));
271    }
272
273    #[test]
274    fn empty_material_fractions_do_not_sum() {
275        let issues = audit(&Material::new(), &full_table());
276        assert_eq!(issues.len(), 1);
277        assert_eq!(issues[0].kind, AuditKind::FractionsDontSum);
278    }
279
280    #[test]
281    fn missing_masses_are_flagged() {
282        let mut mat = Material::new();
283        mat.add_nuclide(id("U235"), 1.0);
284        mat.add_nuclide(id("Pu239"), 1.0);
285        let partial = Table::new(&[("U235", 235.0)]);
286        let issues = audit(&mat, &partial);
287        assert_eq!(issues.len(), 1);
288        assert_eq!(issues[0].kind, AuditKind::UnknownMass);
289        assert!(issues[0].detail.contains("Pu239"));
290    }
291
292    #[test]
293    fn duplicate_variant_unreachable_for_map_backed_material() {
294        // `comp` is a BTreeMap: re-adding accumulates instead of duplicating.
295        let mut mat = Material::new();
296        mat.add_nuclide(id("U235"), 1.0);
297        mat.add_nuclide(id("U235"), 2.0);
298        assert_eq!(mat.comp.len(), 1);
299        assert!(audit(&mat, &full_table())
300            .iter()
301            .all(|i| i.kind != AuditKind::DuplicateNuclide));
302    }
303}