Skip to main content

nucleide_nuclei/
lib.rs

1#![warn(missing_docs)]
2//! Nuclide identification and naming conventions.
3//!
4//! Canonical representation is the `nucid`: a single
5//! `u32` of the form `(Z*1000 + A) * 10_000 + state`, i.e. the zero-padded
6//! six-digit ZZAAAM block followed by a four-digit tail holding the
7//! metastable state (e.g. U-235 → 922350000, Am-242m → 952420001).
8//! Chosen for compactness, hashing, and direct compatibility with the
9//! integer ids used across legacy codes.
10//!
11//! Scope:
12//! - id ↔ name ("U235", "Am242_m1") conversions
13//! - id ↔ zzaaam (922350) conversions
14//! - element symbol/number tables
15//! - naming dialects (MCNP ZAID, Serpent, FLUKA, NIST, Cinder, ALARA), reaction names
16
17use std::fmt;
18
19pub mod armi;
20pub mod data;
21pub mod dialects;
22pub mod fgr15;
23pub mod irdff;
24pub mod particles;
25pub mod rxname;
26
27pub use dialects::DialectError;
28pub use particles::Error as ParticlesError;
29pub use rxname::Error as RxnameError;
30
31/// Element symbols indexed by atomic number (`ELEMENTS[z]`); index 0 is unused.
32pub const ELEMENTS: [&str; 119] = [
33    "", "H", "He", "Li", "Be", "B", "C", "N", "O", "F", "Ne", "Na", "Mg", "Al", "Si", "P", "S",
34    "Cl", "Ar", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni", "Cu", "Zn", "Ga", "Ge",
35    "As", "Se", "Br", "Kr", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd",
36    "In", "Sn", "Sb", "Te", "I", "Xe", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd",
37    "Tb", "Dy", "Ho", "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg",
38    "Tl", "Pb", "Bi", "Po", "At", "Rn", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu", "Am", "Cm",
39    "Bk", "Cf", "Es", "Fm", "Md", "No", "Lr", "Rf", "Db", "Sg", "Bh", "Hs", "Mt", "Ds", "Rg", "Cn",
40    "Nh", "Fl", "Mc", "Lv", "Ts", "Og",
41];
42
43/// Result alias for the `nuclei` crate.
44pub type Result<T> = std::result::Result<T, Error>;
45
46/// Errors from nuclide parsing/validation.
47#[derive(Debug, Clone, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum Error {
50    /// Atomic number outside 1..=118.
51    BadZ(u32),
52    /// Mass number smaller than the atomic number.
53    BadA {
54        /// Atomic number.
55        z: u32,
56        /// Mass number.
57        a: u32,
58    },
59    /// Mass number above the 3-digit AAA limit (> 999).
60    MassNumberTooLarge(u32),
61    /// Metastable state index above the supported range (> 9).
62    BadState(u32),
63    /// Name contained no digits (no mass number).
64    MissingMassNumber(String),
65    /// Mass number or state component failed to parse as an integer.
66    BadNumber(String),
67    /// Element symbol not recognized.
68    UnknownElement(String),
69}
70
71impl fmt::Display for Error {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            Error::BadZ(z) => write!(f, "atomic number {z} out of range 1..=118"),
75            Error::BadA { z, a } => write!(f, "mass number {a} < atomic number {z}"),
76            Error::MassNumberTooLarge(a) => write!(f, "mass number {a} > 999 unsupported"),
77            Error::BadState(s) => write!(f, "metastable state {s} > 9 unsupported"),
78            Error::MissingMassNumber(s) => write!(f, "no mass number in name `{s}`"),
79            Error::BadNumber(s) => write!(f, "invalid numeric component `{s}`"),
80            Error::UnknownElement(s) => write!(f, "unknown element symbol `{s}`"),
81        }
82    }
83}
84
85impl std::error::Error for Error {}
86
87/// A canonical nuclide identifier.
88///
89/// Layout (`nucid = (Z*1000 + A) * 10_000 + state`):
90/// - H-1   → 10010000
91/// - U-235 → 922350000
92/// - Am-242m → 952420001
93#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
94pub struct NuclideId(u32);
95
96impl NuclideId {
97    /// Construct and validate a [`NuclideId`] from components.
98    ///
99    /// Enforces `1 <= Z <= 118`, `Z <= A <= 999`, and `S <= 9`. The
100    /// `A <= 999` bound keeps the packed value within `u32` and preserves
101    /// the 3-digit AAA invariant used by the zzaaam/zzllaaam dialects.
102    pub const fn new(z: u32, a: u32, state: u32) -> Result<Self> {
103        if z == 0 || z > 118 {
104            return Err(Error::BadZ(z));
105        }
106        if a < z {
107            return Err(Error::BadA { z, a });
108        }
109        if a > 999 {
110            return Err(Error::MassNumberTooLarge(a));
111        }
112        if state > 9 {
113            return Err(Error::BadState(state));
114        }
115        Ok(Self((z * 1000 + a) * 10_000 + state))
116    }
117
118    /// Reconstruct from an existing nucid integer without validation.
119    ///
120    /// This is a raw bit-cast: invalid bit patterns yield meaningless
121    /// components from [`z`](Self::z), [`a`](Self::a), and [`state`](Self::state).
122    /// Use [`new`](Self::new) for validated construction.
123    ///
124    /// The id stays usable: [`to_name`](Self::to_name) and
125    /// [`armi::nucid_to_armi_label`] render
126    /// such ids with a diagnostic `Z{z}A{a}[m{s}]` fallback instead of
127    /// panicking. Use [`try_from_nucid`](Self::try_from_nucid) (or
128    /// [`is_valid`](Self::is_valid)) when the integer comes from untrusted
129    /// input.
130    pub const fn from_nucid(nucid: u32) -> Self {
131        Self(nucid)
132    }
133
134    /// Reconstruct from a nucid integer with validation.
135    ///
136    /// Decomposes the integer into `(Z, A, state)` and applies the same
137    /// `1 <= Z <= 118`, `Z <= A <= 999`, `S <= 9` checks as
138    /// [`new`](Self::new) (kept in sync by inspection; the checks are
139    /// inlined because `const fn` cannot match on the `Result`); integers
140    /// with a non-canonical tail (the four low digits above 9, so no
141    /// single-digit state can explain them) fail with [`Error::BadState`].
142    /// Out-of-domain integers fail with the matching [`Error`] instead of
143    /// producing an id whose name rendering falls back to the diagnostic form.
144    pub const fn try_from_nucid(nucid: u32) -> Result<Self> {
145        let tail = nucid % 10_000;
146        if tail > 9 {
147            return Err(Error::BadState(tail));
148        }
149        let z = nucid / 10_000_000;
150        let a = (nucid % 10_000_000) / 10_000;
151        let state = nucid % 10;
152        if z == 0 || z > 118 {
153            return Err(Error::BadZ(z));
154        }
155        if a < z {
156            return Err(Error::BadA { z, a });
157        }
158        if a > 999 {
159            return Err(Error::MassNumberTooLarge(a));
160        }
161        if state > 9 {
162            return Err(Error::BadState(state));
163        }
164        Ok(Self(nucid))
165    }
166
167    /// Whether this id decomposes into validated `(Z, A, state)` components
168    /// (`1 <= Z <= 118`, `Z <= A <= 999`, `S <= 9`, canonical tail).
169    ///
170    /// Raw ids built by [`from_nucid`](Self::from_nucid) may fail this; every
171    /// other constructor guarantees it.
172    pub const fn is_valid(&self) -> bool {
173        if self.0 % 10_000 > 9 {
174            return false;
175        }
176        let z = self.z();
177        let a = self.a();
178        let state = self.state();
179        z != 0 && z <= 118 && a >= z && a <= 999 && state <= 9
180    }
181
182    /// Raw nucid integer (`(Z*1000 + A)*10_000 + state`).
183    pub const fn nucid(&self) -> u32 {
184        self.0
185    }
186
187    /// Atomic number.
188    pub const fn z(&self) -> u32 {
189        self.0 / 10_000_000
190    }
191
192    /// Mass number.
193    pub const fn a(&self) -> u32 {
194        (self.0 % 10_000_000) / 10_000
195    }
196
197    /// Metastable state index (0 = ground).
198    pub const fn state(&self) -> u32 {
199        self.0 % 10
200    }
201
202    /// Six-digit ZZAAAM form (U-235 → 922350, Ba-137m → 561371).
203    pub const fn zzaaam(&self) -> u32 {
204        self.z() * 10_000 + self.a() * 10 + self.state()
205    }
206
207    /// Build from a six-digit ZZAAAM integer.
208    pub fn from_zzaaam(v: u32) -> Result<Self> {
209        let state = v % 10;
210        let rest = v / 10;
211        let a = rest % 1_000;
212        let z = rest / 1_000;
213        Self::new(z, a, state)
214    }
215
216    /// Parse a name such as `"U235"`, `"U-235"`, `"u235"`, `"Am242_m1"`,
217    /// `"Am-242m"`, `"Am242M"`, or `"Ba137m"`.
218    ///
219    /// Dashes are ignored and metastable markers are case-insensitive, so
220    /// this matches PyNE's `name_to_id` normalization for the common forms.
221    pub fn from_name(name: &str) -> Result<Self> {
222        let trimmed = name.trim();
223        let cleaned: String = trimmed.chars().filter(|&c| c != '-').collect();
224        let upper = cleaned.to_ascii_uppercase();
225        let digit_start = upper
226            .find(|c: char| c.is_ascii_digit())
227            .ok_or_else(|| Error::MissingMassNumber(trimmed.to_string()))?;
228        let sym_upper = &upper[..digit_start];
229        let rest = &upper[digit_start..];
230
231        let sym = canonicalize_symbol(sym_upper);
232        let z = element_z(&sym).ok_or_else(|| Error::UnknownElement(sym_upper.to_string()))?;
233
234        // Split mass number from an optional state suffix:
235        // "235" | "242_M1" | "242M" | "137M"
236        let (a_str, state_str) = if let Some((head, tail)) = rest.split_once('_') {
237            // underscore form; tail may start with 'M'
238            let tail = tail.strip_prefix('M').unwrap_or(tail);
239            (head, Some(tail))
240        } else if let Some((head, tail)) = rest.split_once('M') {
241            // bare trailing-M form ("137M"); tail may hold the state index
242            (head, Some(tail))
243        } else {
244            (rest, None)
245        };
246
247        let a: u32 = a_str
248            .parse()
249            .map_err(|_| Error::BadNumber(a_str.to_string()))?;
250        let state = match state_str {
251            None => 0,
252            Some("") => 1,
253            Some(n) => n.parse().map_err(|_| Error::BadNumber(format!("M{n}")))?,
254        };
255
256        Self::new(z, a, state)
257    }
258
259    /// GNDS-style name: `"U235"`, `"Am242_m1"`.
260    ///
261    /// Total over every raw id: validated ids render the canonical name
262    /// (unchanged historical spelling, re-parseable by [`from_name`](Self::from_name));
263    /// raw ids outside the validated `(Z, A, state)` domain render the
264    /// diagnostic fallback `"Z{z}A{a}[m{s}]"`, which `from_name` does not
265    /// parse. The fallback exists so display paths over unchecked integers
266    /// (decay-table progeny, FFI) can never index `ELEMENTS` out of bounds.
267    pub fn to_name(&self) -> String {
268        if self.is_valid() {
269            // `is_valid` pins `1 <= Z <= 118`, so this index is in bounds.
270            let sym = ELEMENTS[self.z() as usize];
271            match self.state() {
272                0 => format!("{}{}", sym, self.a()),
273                s => format!("{}{}_m{}", sym, self.a(), s),
274            }
275        } else {
276            format!("Z{}A{}[m{}]", self.z(), self.a(), self.state())
277        }
278    }
279}
280
281impl fmt::Display for NuclideId {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        f.write_str(&self.to_name())
284    }
285}
286
287impl std::str::FromStr for NuclideId {
288    type Err = Error;
289    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
290        NuclideId::from_name(s)
291    }
292}
293
294/// Element symbol for atomic number `z`, or `None`.
295pub fn element_symbol(z: u32) -> Option<&'static str> {
296    ELEMENTS
297        .get(z as usize)
298        .and_then(|s| if s.is_empty() { None } else { Some(*s) })
299}
300
301/// Atomic number for an element symbol (case-sensitive), or `None`.
302pub fn element_z(symbol: &str) -> Option<u32> {
303    ELEMENTS.iter().position(|s| *s == symbol).map(|z| z as u32)
304}
305
306/// Convert a free-form element symbol to canonical case for lookup.
307fn canonicalize_symbol(sym: &str) -> String {
308    let mut chars = sym.chars();
309    let mut out = String::with_capacity(sym.len());
310    if let Some(first) = chars.next() {
311        out.extend(first.to_uppercase());
312    }
313    out.push_str(&chars.as_str().to_lowercase());
314    out
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn parse_ground_states() {
323        assert_eq!(NuclideId::from_name("U235").unwrap().nucid(), 922_350_000);
324        assert_eq!(NuclideId::from_name("H1").unwrap().nucid(), 10_010_000);
325        assert_eq!(
326            NuclideId::from_name("Og294").unwrap().nucid(),
327            1_182_940_000
328        );
329    }
330
331    #[test]
332    fn parse_metastables() {
333        let am = NuclideId::from_name("Am242_m1").unwrap();
334        assert_eq!((am.z(), am.a(), am.state()), (95, 242, 1));
335        assert_eq!(am.nucid(), 952_420_001);
336
337        let ba = NuclideId::from_name("Ba137m").unwrap();
338        assert_eq!((ba.z(), ba.a(), ba.state()), (56, 137, 1));
339        assert_eq!(ba.zzaaam(), 561_371);
340    }
341
342    #[test]
343    fn round_trip_display() {
344        for name in ["U235", "H1", "Am242_m1", "Pu239"] {
345            assert_eq!(NuclideId::from_name(name).unwrap().to_name(), name);
346        }
347    }
348
349    #[test]
350    fn zzaaam_round_trip() {
351        let u5 = NuclideId::from_name("U235").unwrap();
352        assert_eq!(u5.zzaaam(), 922_350);
353        assert_eq!(
354            NuclideId::from_zzaaam(922_350).map(|n| n.to_name()),
355            Ok("U235".to_string())
356        );
357    }
358
359    #[test]
360    fn rejects_bad_input() {
361        assert!(matches!(
362            NuclideId::from_name("Xx999"),
363            Err(Error::UnknownElement(_))
364        ));
365        assert!(matches!(
366            NuclideId::from_name("U"),
367            Err(Error::MissingMassNumber(_))
368        ));
369        assert!(matches!(NuclideId::new(0, 1, 0), Err(Error::BadZ(0))));
370        assert!(matches!(NuclideId::new(6, 3, 0), Err(Error::BadA { .. })));
371    }
372
373    #[test]
374    fn elements_table_sanity() {
375        assert_eq!(element_z("U"), Some(92));
376        assert_eq!(element_symbol(92), Some("U"));
377        assert_eq!(element_z("Xx"), None);
378    }
379
380    #[test]
381    fn rejects_mass_number_overflow() {
382        assert!(matches!(
383            NuclideId::new(92, 999_999, 0),
384            Err(Error::MassNumberTooLarge(999_999))
385        ));
386        assert!(matches!(
387            NuclideId::from_name("U999999"),
388            Err(Error::MassNumberTooLarge(999_999))
389        ));
390    }
391
392    #[test]
393    fn parses_pyne_normalized_forms() {
394        assert_eq!(NuclideId::from_name("U-235").unwrap().nucid(), 922_350_000);
395        assert_eq!(NuclideId::from_name("u235").unwrap().nucid(), 922_350_000);
396        assert_eq!(NuclideId::from_name("Am242M").unwrap().nucid(), 952_420_001);
397        assert_eq!(
398            NuclideId::from_name("Am-242M").unwrap().nucid(),
399            952_420_001
400        );
401    }
402
403    #[test]
404    fn try_from_nucid_validates_raw_integers() {
405        // Valid integers pass through untouched.
406        assert_eq!(
407            NuclideId::try_from_nucid(922_350_000).unwrap(),
408            NuclideId::from_nucid(922_350_000)
409        );
410        assert!(NuclideId::try_from_nucid(10_010_000).unwrap().is_valid());
411        assert!(NuclideId::from_nucid(922_350_000).is_valid());
412        // Z out of ELEMENTS range (the `to_name` OOB family).
413        assert!(!NuclideId::from_nucid(0).is_valid());
414        assert!(matches!(NuclideId::try_from_nucid(0), Err(Error::BadZ(0))));
415        assert!(matches!(
416            NuclideId::try_from_nucid(u32::MAX),
417            Err(Error::BadState(7295))
418        ));
419        assert!(matches!(
420            NuclideId::try_from_nucid(1_190_000_000),
421            Err(Error::BadZ(119))
422        ));
423        // A below Z, non-canonical tail (no single-digit state), Z=0 tail.
424        assert!(matches!(
425            NuclideId::try_from_nucid(920_050_000),
426            Err(Error::BadA { z: 92, a: 5 })
427        ));
428        assert!(matches!(
429            NuclideId::try_from_nucid(922_350_010),
430            Err(Error::BadState(10))
431        ));
432        assert!(!NuclideId::from_nucid(920_050_000).is_valid());
433        assert!(!NuclideId::from_nucid(922_350_010).is_valid());
434    }
435
436    #[test]
437    fn to_name_falls_back_for_invalid_raw_ids() {
438        // Formerly `ELEMENTS[z]` out-of-bounds panics; now diagnostics.
439        assert_eq!(NuclideId::from_nucid(0).to_name(), "Z0A0[m0]");
440        assert_eq!(
441            NuclideId::from_nucid(u32::MAX).to_name(),
442            format!(
443                "Z{}A{}[m{}]",
444                NuclideId::from_nucid(u32::MAX).z(),
445                NuclideId::from_nucid(u32::MAX).a(),
446                NuclideId::from_nucid(u32::MAX).state()
447            )
448        );
449        assert_eq!(NuclideId::from_nucid(920_050_000).to_name(), "Z92A5[m0]");
450        assert_eq!(NuclideId::from_nucid(922_350_010).to_name(), "Z92A235[m0]");
451        // The fallback is diagnostic-only: `from_name` rejects it.
452        for raw in [0, u32::MAX, 920_050_000, 1_190_000_000] {
453            let name = NuclideId::from_nucid(raw).to_name();
454            assert!(NuclideId::from_name(&name).is_err(), "{name}");
455            assert_eq!(
456                NuclideId::from_nucid(raw).to_string(),
457                name,
458                "Display follows to_name"
459            );
460        }
461    }
462
463    #[test]
464    fn every_validated_id_round_trips_through_name() {
465        // Canonical construction paths (incl. the checked raw-integer path).
466        let mut ids = vec![
467            NuclideId::new(1, 1, 0).unwrap(),
468            NuclideId::new(92, 235, 0).unwrap(),
469            NuclideId::new(95, 242, 9).unwrap(),
470            NuclideId::new(118, 294, 0).unwrap(),
471            NuclideId::from_name("Am242_m1").unwrap(),
472            NuclideId::from_zzaaam(922_350).unwrap(),
473            NuclideId::try_from_nucid(922_350_000).unwrap(),
474        ];
475        for z in [1, 2, 26, 92, 95, 118] {
476            for a in [z, z + 1, 999] {
477                for s in [0, 1, 9] {
478                    if let Ok(id) = NuclideId::new(z, a.min(999), s) {
479                        ids.push(id);
480                    }
481                }
482            }
483        }
484        for id in ids {
485            assert!(id.is_valid());
486            assert_eq!(NuclideId::from_name(&id.to_name()).unwrap(), id);
487        }
488    }
489
490    #[test]
491    fn error_arms_construct_and_display() {
492        assert!(matches!(
493            NuclideId::new(92, 235, 10),
494            Err(Error::BadState(10))
495        ));
496        assert!(matches!(
497            NuclideId::from_name("U235_mX"),
498            Err(Error::BadNumber(_))
499        ));
500        assert!(NuclideId::from_name("U235_mX")
501            .unwrap_err()
502            .to_string()
503            .contains("MX"));
504        assert!(matches!(
505            NuclideId::from_name("U23X5"),
506            Err(Error::BadNumber(_))
507        ));
508        assert!(!Error::BadState(10).to_string().is_empty());
509        assert!(!Error::BadNumber("MX".to_string()).to_string().is_empty());
510    }
511}