Skip to main content

nucleide_material/
expansion.rs

1//! Chemical-formula parsing and element ↔ nuclide expansion.
2//!
3//! This module bridges the nuclide-level [`Material`] model and the
4//! chemistry-level world of formulas such as `"H2O"` or `"Fe2(SO4)3"`, and
5//! the MCNP-style natural-element placeholder ids (`z * 10_000_000`,
6//! i.e. zaid `z*1000` with `AAA == 0`) produced by `mcnp-io`.
7//!
8//! Like [`crate::MassProvider`], isotope data is injected through a provider
9//! trait ([`AbundanceProvider`]) so nothing here hard-depends on data
10//! availability: [`NoAbundances`] fails explicitly with
11//! [`FormulaError::NoAbundanceData`], while [`NaturalAbundances`] serves
12//! the tabulated natural abundances from `nucleide_nuclei::data`.
13//!
14//! ```
15//! use nucleide_material::{Ame2020, Material, NaturalAbundances};
16//!
17//! let water = Material::from_formula(
18//!     "H2O",
19//!     &Ame2020,
20//!     &NaturalAbundances,
21//!     Some(1.0),
22//! )
23//! .unwrap();
24//! let atoms = water.atom_fractions(&Ame2020).unwrap();
25//! let h = atoms.iter().filter(|(id, _)| id.z() == 1).map(|(_, f)| f).sum::<f64>();
26//! assert!((h - 2.0 / 3.0).abs() < 1e-12);
27//! ```
28
29use std::collections::BTreeMap;
30use std::sync::OnceLock;
31
32use nucleide_nuclei::{element_z, NuclideId};
33use thiserror::Error;
34
35use crate::Material;
36/// Result alias for formula parsing and expansion.
37pub type FormulaResult<T> = std::result::Result<T, FormulaError>;
38
39/// Errors from formula parsing and element expansion.
40///
41/// Distinct from the crate-level [`enum@crate::Error`] so that the parser can
42/// report byte positions and unknown symbols without polluting the shared
43/// composition-error set; mass-table failures are wrapped in [`Self::Core`].
44#[derive(Debug, Error)]
45pub enum FormulaError {
46    /// The formula is not valid under the supported grammar (see
47    /// [`parse_formula`]).
48    #[error("formula syntax error at byte {pos}: {message}")]
49    ParseError {
50        /// Byte offset of the offending character.
51        pos: usize,
52        /// Human-readable explanation.
53        message: String,
54    },
55    /// An element symbol was not recognized (case-sensitive).
56    #[error("unknown element symbol `{0}`")]
57    UnknownElement(String),
58    /// An element has no tabulated natural isotopes to expand into.
59    #[error("no natural abundance data for element Z={0}")]
60    NoAbundanceData(u32),
61    /// A composition-level failure (e.g. missing atomic mass).
62    #[error(transparent)]
63    Core(#[from] crate::Error),
64}
65
66// ---------------------------------------------------------------------------
67// Formula grammar
68//
69//   formula := segment ("." | "·") segment*        hydrate parts
70//   segment := integer? unit*                      leading int only for
71//                                                  non-first segments
72//   unit     := element integer? | "(" segment ")" integer?
73//   element := [A-Z][a-z]?
74//
75// Examples: H2O, C6H12O6, Ca(OH)2, Fe2(SO4)3, CH3(CH2)6CH3,
76// CuSO4·5H2O == CuSO4.5H2O.
77// ---------------------------------------------------------------------------
78
79struct Parser<'a> {
80    src: &'a [u8],
81    pos: usize,
82}
83
84impl Parser<'_> {
85    fn parse_error(&self, pos: usize, message: impl Into<String>) -> FormulaError {
86        FormulaError::ParseError {
87            pos,
88            message: message.into(),
89        }
90    }
91
92    fn error_here(&self, message: impl Into<String>) -> FormulaError {
93        self.parse_error(self.pos, message)
94    }
95
96    fn peek(&self) -> Option<u8> {
97        self.src.get(self.pos).copied()
98    }
99
100    /// True when the cursor sits on a hydrate separator (`.` ASCII or `·`
101    /// U+00B7, encoded as `C2 B7` in UTF-8).
102    fn at_hyphen_dot(&self) -> bool {
103        match self.peek() {
104            Some(b'.') => true,
105            Some(0xC2) => self.src.get(self.pos + 1) == Some(&0xB7),
106            _ => false,
107        }
108    }
109
110    fn advance_over_separator(&mut self) {
111        self.pos += if self.src[self.pos] == b'.' { 1 } else { 2 };
112    }
113
114    /// Consume a run of ASCII digits as a count, or `None` if absent.
115    fn take_count(&mut self) -> FormulaResult<Option<f64>> {
116        let start = self.pos;
117        while self.peek().is_some_and(|c| c.is_ascii_digit()) {
118            self.pos += 1;
119        }
120        if start == self.pos {
121            return Ok(None);
122        }
123        let text = std::str::from_utf8(&self.src[start..self.pos]).unwrap_or_default();
124        text.parse::<u64>()
125            .map(|n| Some(n as f64))
126            .map_err(|_| self.parse_error(start, "count too large"))
127    }
128
129    /// Consume an element symbol starting at an uppercase byte and its
130    /// optional trailing count; returns `(Z, count)`.
131    fn take_element(&mut self) -> FormulaResult<(u32, f64)> {
132        let start = self.pos;
133        self.pos += 1;
134        // Prefer a two-letter symbol when a lowercase letter follows.
135        let two_letter = self
136            .src
137            .get(start..start + 2)
138            .filter(|bytes| bytes[1].is_ascii_lowercase())
139            .and_then(|bytes| std::str::from_utf8(bytes).ok());
140        let one_letter = std::str::from_utf8(&self.src[start..start + 1]).ok();
141        let (z, matched_len) = match two_letter.and_then(element_z) {
142            Some(z) => (Some(z), 2),
143            None => (one_letter.and_then(element_z), 1),
144        };
145        let z = z.ok_or_else(|| {
146            let candidate = two_letter.unwrap_or_else(|| one_letter.unwrap_or("?"));
147            FormulaError::UnknownElement(candidate.to_string())
148        })?;
149        self.pos = start + matched_len;
150        let count = self.take_count()?.unwrap_or(1.0);
151        Ok((z, count))
152    }
153
154    /// Consume units until end-of-input, a closing parenthesis, or a hydrate
155    /// separator, accumulating counts scaled by `scale` into `out`.
156    fn take_units(&mut self, out: &mut BTreeMap<u32, f64>, scale: f64) -> FormulaResult<()> {
157        while let Some(c) = self.peek() {
158            match c {
159                b')' | b'.' => break,
160                0xC2 if self.at_hyphen_dot() => break,
161                b'(' => {
162                    self.pos += 1;
163                    let mut inner = BTreeMap::new();
164                    self.take_units(&mut inner, 1.0)?;
165                    if self.peek() != Some(b')') {
166                        return Err(self.error_here("unbalanced parenthesis: expected `)`"));
167                    }
168                    self.pos += 1;
169                    let mult = self.take_count()?.unwrap_or(1.0);
170                    for (z, count) in inner {
171                        *out.entry(z).or_insert(0.0) += count * mult * scale;
172                    }
173                }
174                b'A'..=b'Z' => {
175                    let (z, count) = self.take_element()?;
176                    if count > 0.0 {
177                        *out.entry(z).or_insert(0.0) += count * scale;
178                    }
179                }
180                b'0'..=b'9' => {
181                    return Err(self.error_here("count without a preceding element"));
182                }
183                _ => {
184                    let ch = std::str::from_utf8(&self.src[self.pos..])
185                        .unwrap_or("?")
186                        .chars()
187                        .next()
188                        .unwrap_or('?');
189                    return Err(self.error_here(format!("unexpected character `{ch}`")));
190                }
191            }
192        }
193        Ok(())
194    }
195}
196
197/// Parse a chemical formula into per-element atom counts.
198///
199/// Returns `(Z, count)` pairs sorted by atomic number with duplicate
200/// elements aggregated (so `"CH3(CH2)6CH3"` yields `[(6, 4.0), (1, 18.0)]`).
201///
202/// Supported grammar: nested parenthesized groups with multi-digit group
203/// counts, one/two-letter case-sensitive element symbols, multi-digit atom
204/// counts, and trailing hydrate parts joined by `·` (or plain `.`), each
205/// optionally prefixed by an integer multiplier (`CuSO4·5H2O`). Surrounding
206/// whitespace is tolerated; embedded whitespace, a leading digit (bare
207/// stoichiometric coefficients are not formulas), stray parentheses, and
208/// unrecognized symbols are errors.
209pub fn parse_formula(formula: &str) -> FormulaResult<Vec<(u32, f64)>> {
210    let trimmed = formula.trim();
211    let mut p = Parser {
212        src: trimmed.as_bytes(),
213        pos: 0,
214    };
215    let mut acc = BTreeMap::new();
216    let mut first = true;
217    while p.pos < trimmed.len() {
218        if !first {
219            if !p.at_hyphen_dot() {
220                if p.peek() == Some(b')') {
221                    return Err(p.error_here("unbalanced parenthesis: unexpected `)`"));
222                }
223                return Err(p.error_here("expected `.` or `·` hydrate separator"));
224            }
225            p.advance_over_separator();
226        }
227        if p.peek().is_some_and(|c| c.is_ascii_digit()) {
228            if first {
229                return Err(p.parse_error(p.pos, "formula must not begin with a digit"));
230            }
231            let mult = p.take_count()?.unwrap_or(1.0);
232            p.take_units(&mut acc, mult)?;
233        } else {
234            p.take_units(&mut acc, 1.0)?;
235        }
236        first = false;
237    }
238    if first {
239        return Err(FormulaError::ParseError {
240            pos: 0,
241            message: "empty formula".to_string(),
242        });
243    }
244    Ok(acc.into_iter().collect())
245}
246
247// ---------------------------------------------------------------------------
248// Abundance providers
249// ---------------------------------------------------------------------------
250
251/// Source of natural-isotope composition data, element by element.
252///
253/// Mirrors [`crate::MassProvider`]: expansion code is generic over this so
254/// it never depends on the nuclear-data tables being present.
255pub trait AbundanceProvider {
256    /// Naturally occurring isotopes of element `z` with their abundance
257    /// fractions, or `None` when nothing is tabulated for that element.
258    fn natural_isotopes(&self, z: u32) -> Option<Vec<(NuclideId, f64)>>;
259}
260
261/// An [`AbundanceProvider`] that knows no isotopes.
262///
263/// Every lookup returns `None`, so expansions fail explicitly with
264/// [`FormulaError::NoAbundanceData`].
265#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
266pub struct NoAbundances;
267
268impl AbundanceProvider for NoAbundances {
269    fn natural_isotopes(&self, _z: u32) -> Option<Vec<(NuclideId, f64)>> {
270        None
271    }
272}
273
274/// [`AbundanceProvider`] backed by the natural-abundance table in
275/// `nucleide_nuclei::data`.
276#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
277pub struct NaturalAbundances;
278
279impl NaturalAbundances {
280    /// Abundance table grouped by atomic number (built once, lazily).
281    fn groups() -> &'static BTreeMap<u32, Vec<(NuclideId, f64)>> {
282        static GROUPS: OnceLock<BTreeMap<u32, Vec<(NuclideId, f64)>>> = OnceLock::new();
283        GROUPS.get_or_init(|| {
284            let mut groups: BTreeMap<u32, Vec<(NuclideId, f64)>> = BTreeMap::new();
285            for (&nucid, &frac) in nucleide_nuclei::data::abundance_table() {
286                if frac > 0.0 {
287                    let id = NuclideId::from_nucid(nucid);
288                    groups.entry(id.z()).or_default().push((id, frac));
289                }
290            }
291            groups
292        })
293    }
294}
295
296impl AbundanceProvider for NaturalAbundances {
297    fn natural_isotopes(&self, z: u32) -> Option<Vec<(NuclideId, f64)>> {
298        Self::groups().get(&z).filter(|v| !v.is_empty()).cloned()
299    }
300}
301
302// ---------------------------------------------------------------------------
303// Expansion helpers
304// ---------------------------------------------------------------------------
305
306/// True for natural-element placeholder ids: `z * 10_000_000`, i.e. the
307/// nucid of zaid `z*1000` (`AAA == 0`) used throughout `mcnp-io` for bare
308/// elemental zaids.
309fn is_elemental(id: NuclideId) -> bool {
310    id.a() == 0 && id.state() == 0
311}
312
313/// Atomic mass of `id`, falling back to the ground state for metastable ids
314/// (the AME2020 table stores one mass per (Z, A); ground state and isomers
315/// share it).
316fn ground_mass(masses: &impl crate::MassProvider, id: NuclideId) -> Option<f64> {
317    masses
318        .mass(id.nucid())
319        .or_else(|| masses.mass(id.nucid() - id.state()))
320}
321
322impl Material {
323    /// Build a material from a chemical formula, expanding each element into
324    /// its naturally occurring isotopes.
325    ///
326    /// Mirrors [`Material::from_atom_frac`]: atom counts come from the
327    /// parsed formula weighted by natural-abundance fractions, stored masses
328    /// are `n_i * M_i` using `masses`, and `density` is attached unchanged.
329    /// Fails with the parse/abundance variants of [`FormulaError`] for bad
330    /// input and with [`FormulaError::Core`] wrapping
331    /// [`crate::Error::MissingMass`] when an isotope's mass is unknown.
332    pub fn from_formula(
333        formula: &str,
334        masses: &impl crate::MassProvider,
335        abundances: &impl AbundanceProvider,
336        density: Option<f64>,
337    ) -> FormulaResult<Self> {
338        let elements = parse_formula(formula)?;
339        let mut atoms = Vec::new();
340        for &(z, count) in &elements {
341            let isotopes = abundances
342                .natural_isotopes(z)
343                .ok_or(FormulaError::NoAbundanceData(z))?;
344            let total: f64 = isotopes.iter().map(|(_, x)| x).sum();
345            if total <= 0.0 {
346                return Err(FormulaError::NoAbundanceData(z));
347            }
348            for (id, frac) in isotopes {
349                atoms.push((id, count * frac / total));
350            }
351        }
352        Ok(Material::from_atom_frac(&atoms, masses, density)?)
353    }
354
355    /// Replace natural-element placeholder entries with their isotopic
356    /// breakdown, preserving each entry's stored mass.
357    ///
358    /// Placeholders follow the `mcnp-io` inp convention: a bare elemental
359    /// zaid (`z*1000`, `AAA == 0`) becomes the nucid `z * 10_000_000`
360    /// (`is_elemental`). Each placeholder of element `z` holding `g`
361    /// grams is replaced by isotope masses `g * x_i * M_i / M̄`, where `x_i`
362    /// are the (normalized) natural-abundance fractions and `M̄` the
363    /// abundance-weighted mean atomic mass — i.e. the same number of atoms
364    /// of each isotope as the elemental entry implied. Explicitly named
365    /// nuclides are left untouched, so mixed elemental + isotopic
366    /// compositions are supported.
367    ///
368    /// Fails with [`FormulaError::NoAbundanceData`] when the provider has no
369    /// isotopes for an element, or [`FormulaError::Core`] wrapping
370    /// [`crate::Error::MissingMass`] when an isotope mass is unknown.
371    pub fn expand_elements(
372        &mut self,
373        masses: &impl crate::MassProvider,
374        abundances: &impl AbundanceProvider,
375    ) -> FormulaResult<()> {
376        let mut expanded = BTreeMap::new();
377        for (&id, &grams) in &self.comp {
378            if !is_elemental(id) {
379                expanded.insert(id, grams);
380                continue;
381            }
382            let z = id.z();
383            let isotopes = abundances
384                .natural_isotopes(z)
385                .ok_or(FormulaError::NoAbundanceData(z))?;
386            let total: f64 = isotopes.iter().map(|(_, x)| x).sum();
387            if total <= 0.0 {
388                return Err(FormulaError::NoAbundanceData(z));
389            }
390            // Mean atomic mass of the natural element.
391            let mean_mass = isotopes
392                .iter()
393                .map(|&(iso, x)| {
394                    ground_mass(masses, iso)
395                        .ok_or(crate::Error::MissingMass(iso))
396                        .map(|m| x / total * m)
397                })
398                .sum::<crate::Result<f64>>()
399                .map_err(FormulaError::from)?;
400            for (iso, x) in isotopes {
401                let m = ground_mass(masses, iso).expect("checked by mean_mass loop above");
402                expanded.insert(iso, grams * (x / total) * m / mean_mass);
403            }
404        }
405        self.comp = expanded;
406        Ok(())
407    }
408
409    /// Inverse grouping of [`Material::expand_elements`]: fold every nuclide
410    /// into its element's placeholder row keyed by the natural-element id
411    /// `z * 10_000_000` (zaid `z*1000`). Placeholder entries already carry
412    /// that key and simply accumulate alongside collapsed nuclides. Density
413    /// and metadata are preserved; masses are summed exactly.
414    pub fn collapse_elements(&self) -> Self {
415        let mut comp = BTreeMap::new();
416        for (&id, &grams) in &self.comp {
417            let key = if is_elemental(id) {
418                id
419            } else {
420                NuclideId::from_nucid(id.z() * 10_000_000)
421            };
422            *comp.entry(key).or_insert(0.0) += grams;
423        }
424        let mut out = Material::new();
425        out.comp = comp;
426        out.set_density(self.density());
427        out.set_metadata(self.metadata().cloned());
428        out
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use crate::Ame2020;
436
437    fn counts(formula: &str) -> Vec<(u32, f64)> {
438        parse_formula(formula).unwrap()
439    }
440
441    fn assert_counts(formula: &str, expected: &[(u32, f64)]) {
442        assert_eq!(counts(formula), expected.to_vec(), "for `{formula}`");
443    }
444
445    #[test]
446    fn parses_water_and_glucose() {
447        assert_counts("H2O", &[(1, 2.0), (8, 1.0)]);
448        assert_counts("C6H12O6", &[(1, 12.0), (6, 6.0), (8, 6.0)]);
449    }
450
451    #[test]
452    fn parses_grouped_and_nested_formulas() {
453        // Ca(OH)2 → Ca1 O2 H2
454        assert_counts("Ca(OH)2", &[(1, 2.0), (8, 2.0), (20, 1.0)]);
455        // Fe2(SO4)3 → Fe2 S3 O12
456        assert_counts("Fe2(SO4)3", &[(8, 12.0), (16, 3.0), (26, 2.0)]);
457        // Nested groups.
458        assert_counts("Mg(NO2)2", &[(7, 2.0), (8, 4.0), (12, 1.0)]);
459        assert_counts("U((C)3)2", &[(6, 6.0), (92, 1.0)]);
460    }
461
462    #[test]
463    fn parses_chained_groups_as_single_elements() {
464        // CH3(CH2)6CH3 → C8H18 (n-octane written chain-style)
465        assert_counts("CH3(CH2)6CH3", &[(1, 18.0), (6, 8.0)]);
466    }
467
468    #[test]
469    fn parses_multi_digit_counts_and_hydrates() {
470        assert_counts("C12H22O11", &[(1, 22.0), (6, 12.0), (8, 11.0)]);
471        let dot = counts("CuSO4·5H2O");
472        let ascii = counts("CuSO4.5H2O");
473        assert_eq!(dot, ascii);
474        assert_eq!(dot, vec![(1, 10.0), (8, 9.0), (16, 1.0), (29, 1.0)]);
475        // Multiplier without parentheses after the dot.
476        assert_counts("H2O.2H2O", &[(1, 6.0), (8, 3.0)]);
477    }
478
479    #[test]
480    fn tolerates_surrounding_whitespace_only() {
481        assert_eq!(counts("  H2O "), counts("H2O"));
482    }
483
484    #[test]
485    fn rejects_unbalanced_parentheses() {
486        let err = parse_formula("(H2O").unwrap_err();
487        assert!(
488            matches!(err, FormulaError::ParseError { pos: 4, .. }),
489            "{err}"
490        );
491        let err = parse_formula("H2O)").unwrap_err();
492        assert!(matches!(err, FormulaError::ParseError { .. }), "{err}");
493    }
494
495    #[test]
496    fn rejects_unknown_symbol() {
497        match parse_formula("Xx2O").unwrap_err() {
498            FormulaError::UnknownElement(s) => assert_eq!(s, "Xx"),
499            other => panic!("{other:?}"),
500        }
501        match parse_formula("Q").unwrap_err() {
502            FormulaError::UnknownElement(s) => assert_eq!(s, "Q"),
503            other => panic!("{other:?}"),
504        }
505    }
506
507    #[test]
508    fn rejects_leading_digit_empty_and_stray_chars() {
509        let err = parse_formula("2H2O").unwrap_err();
510        assert!(
511            matches!(err, FormulaError::ParseError { pos: 0, .. }),
512            "{err}"
513        );
514        assert!(matches!(
515            parse_formula("").unwrap_err(),
516            FormulaError::ParseError { .. }
517        ));
518        let err = parse_formula("H2 O").unwrap_err();
519        assert!(
520            matches!(err, FormulaError::ParseError { pos: 2, .. }),
521            "{err}"
522        );
523        let err = parse_formula("H1O-1").unwrap_err();
524        assert!(matches!(err, FormulaError::ParseError { .. }));
525    }
526
527    #[test]
528    fn from_formula_water_has_natural_isotopes_and_two_thirds_hydrogen() {
529        let water = Material::from_formula("H2O", &Ame2020, &NaturalAbundances, Some(1.0)).unwrap();
530        // Natural hydrogen is pure H-1; oxygen carries all three isotopes.
531        assert!(water
532            .comp
533            .contains_key(&NuclideId::from_name("H1").unwrap()));
534        for o in ["O16", "O17", "O18"] {
535            assert!(
536                water.comp.contains_key(&NuclideId::from_name(o).unwrap()),
537                "missing {o}"
538            );
539        }
540        // Atom fractions: hydrogen contributes exactly 2 mol per mol water.
541        let af = water.atom_fractions(&Ame2020).unwrap();
542        let h: f64 = af
543            .iter()
544            .filter(|(id, _)| id.z() == 1)
545            .map(|(_, f)| f)
546            .sum();
547        assert!((h - 2.0 / 3.0).abs() < 1e-12, "{h}");
548        let o: f64 = af
549            .iter()
550            .filter(|(id, _)| id.z() == 8)
551            .map(|(_, f)| f)
552            .sum();
553        assert!((o - 1.0 / 3.0).abs() < 1e-12);
554        // Density passes through untouched.
555        assert_eq!(water.density(), Some(1.0));
556    }
557
558    #[test]
559    fn from_formula_h2so4() {
560        let mat = Material::from_formula("H2SO4", &Ame2020, &NaturalAbundances, None).unwrap();
561        let af = mat.atom_fractions(&Ame2020).unwrap();
562        assert!(!af.is_empty(), "H2SO4 atom fractions should not be empty");
563        for nuc in ["H1", "H2", "O16", "O17", "O18", "S32", "S33", "S34", "S36"] {
564            assert!(
565                af.contains_key(&NuclideId::from_name(nuc).unwrap()),
566                "missing {nuc}"
567            );
568        }
569    }
570
571    #[test]
572    fn from_formula_rejects_bad_input_and_missing_data() {
573        match Material::from_formula("Xx", &Ame2020, &NaturalAbundances, None).unwrap_err() {
574            FormulaError::UnknownElement(s) => assert_eq!(s, "Xx"),
575            other => panic!("{other:?}"),
576        }
577        assert!(matches!(
578            Material::from_formula("U", &Ame2020, &NoAbundances, None).unwrap_err(),
579            FormulaError::NoAbundanceData(92)
580        ));
581    }
582
583    #[test]
584    fn expand_then_collapse_round_trips_an_elemental_material() {
585        // Elemental rows via mcnp-io placeholder ids.
586        let mut mat = Material::new();
587        mat.add_nuclide(NuclideId::from_nucid(10_000_000), 2.0); // H
588        mat.add_nuclide(NuclideId::from_nucid(80_000_000), 16.0); // O
589        let original = mat.clone();
590
591        mat.expand_elements(&Ame2020, &NaturalAbundances).unwrap();
592        // Isotopes appeared and placeholders vanished.
593        assert!(!mat.comp.contains_key(&NuclideId::from_nucid(80_000_000)));
594        assert!(mat.comp.contains_key(&NuclideId::from_name("H1").unwrap()));
595        assert!(mat.comp.contains_key(&NuclideId::from_name("H2").unwrap()));
596        assert!(mat.comp.contains_key(&NuclideId::from_name("O18").unwrap()));
597
598        let back = mat.collapse_elements();
599        assert_eq!(
600            back.comp.keys().copied().collect::<Vec<_>>(),
601            original.comp.keys().copied().collect::<Vec<_>>()
602        );
603        for (id, m0) in &original.comp {
604            let m1 = back.comp[id];
605            assert!((m0 - m1).abs() < 1e-9 * m0.abs(), "{id}: {m0} vs {m1}");
606        }
607    }
608
609    #[test]
610    fn expand_preserves_entry_masses_and_leaves_named_nuclides_alone() {
611        let mut mat = Material::new();
612        mat.add_nuclide(NuclideId::from_nucid(10_000_000), 18.0); // H, 18 g
613        mat.add_nuclide(NuclideId::from_name("Fe56").unwrap(), 5.0);
614
615        mat.expand_elements(&Ame2020, &NaturalAbundances).unwrap();
616        close(mat.mass(), 23.0);
617        close(
618            mat.remove_nuclide(NuclideId::from_name("Fe56").unwrap())
619                .unwrap(),
620            5.0,
621        );
622        let h: f64 = mat.comp.values().sum();
623        close(h, 18.0);
624    }
625
626    #[test]
627    fn expand_without_abundances_errors_with_z() {
628        let mut mat = Material::new();
629        mat.add_nuclide(NuclideId::from_nucid(920_000_000), 1.0);
630        match mat.expand_elements(&Ame2020, &NoAbundances).unwrap_err() {
631            FormulaError::NoAbundanceData(z) => assert_eq!(z, 92),
632            other => panic!("{other:?}"),
633        }
634    }
635
636    #[test]
637    fn collapse_folds_named_nuclides_into_placeholder_keys() {
638        let mut mat = Material::new();
639        mat.add_nuclide(NuclideId::from_name("U235").unwrap(), 3.0);
640        mat.add_nuclide(NuclideId::from_name("U238").unwrap(), 1.0);
641        mat.set_density(Some(19.1));
642        let collapsed = mat.collapse_elements();
643
644        let key = NuclideId::from_nucid(920_000_000);
645        assert_eq!(collapsed.comp.len(), 1);
646        close(collapsed.comp[&key], 4.0);
647        // The key really is the z*10_000_000 placeholder form (zaid 92000).
648        assert_eq!(
649            key.nucid(),
650            nucleide_nuclei::element_z("U").unwrap() * 10_000_000
651        );
652        assert_eq!(collapsed.density(), Some(19.1));
653    }
654
655    fn close(a: f64, b: f64) {
656        assert!((a - b).abs() < 1e-12, "{a} != {b}");
657    }
658}