1use std::collections::{BTreeMap, BTreeSet};
10
11use nucleide_nuclei::{armi, dialects, NuclideId};
12
13use crate::{MassProvider, Material};
14
15pub const DEFAULT_WIDTHS: [usize; 2] = [6, 8];
18
19const FRACTION_TOL: f64 = 1e-9;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Collision {
25 pub truncated: String,
27 pub width: usize,
29 pub members: Vec<NuclideId>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AuditKind {
36 FractionsDontSum,
38 NegativeMass,
40 DuplicateNuclide,
42 UnknownMass,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct AuditIssue {
49 pub kind: AuditKind,
51 pub detail: String,
53}
54
55fn 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
67pub 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
96pub 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 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 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}