Skip to main content

nucleide_nuclei/
dialects.rs

1//! Naming-dialect conversions: MCNP ZAID, zzllaaam, Serpent, FLUKA, NIST,
2//! Cinder, ALARA, sza.
3//!
4//! Design rule: free functions (or a local extension trait) defined here —
5//! do NOT add methods to `NuclideId` in `lib.rs`; do NOT edit `lib.rs`.
6//!
7//! # Dialect conventions
8//!
9//! - MCNP ZAID: `Z*1000 + A`, metastable states add `300 + 100*S`
10//!   (U-236m → 92636). Am-242/Am-242m are special-cased with swapped
11//!   meanings: Am-242m → 95242, Am-242 → 95642. `from_zaid` applies the
12//!   standard heuristic distributing `A - 400` excess into successive
13//!   metastable states while `A/Z > 3.0` (95942 → Am-242 state 4).
14//! - Isomer designator letters use the legacy sequence
15//!   `"mnopqrstuvxyz"` (note: no `w`); state `S` maps to letter `S-1`.
16//!   PyNE emits `m` for every metastable state (lossy for state ≥ 2);
17//!   Nucleide preserves the state index (`m`, `n`, `o`, ...). Parsing
18//!   accepts lowercase `m-z` and uppercase `M` (state 1) for PyNE-style input.
19//! - zzllaaam: `"ZZ-LL-AAAM"` + lowercase isomer letter
20//!   (`"94-Pu-239"`, `"95-Am-242m"`, `"73-Ta-182n"`).
21//! - Serpent: `"Ll-AAAM"` + lowercase isomer letter
22//!   (`"Pu-239"`, `"Am-242m"`, `"U-236m"`).
23//! - FLUKA: 8-character element/isotope names from the FLUKA translation
24//!   table (e.g. `"HYDROG-1"`, `"235-U"`).
25//!   Upstream quirks preserved: `TRITIUM` maps to H-4 (10040000) and the
26//!   `LITHIUM` C literal is octal `030000000`; here it is read as decimal
27//!   30_000_000 (Li), which is unreachable either way.
28//! - NIST: mass number before symbol, no metastable flag (`"239Pu"`,
29//!   `"242Am"`); parsing always yields the ground state.
30//! - Cinder: `AAA*10_000 + Z*10 + S` (`aaazzzm`, U-235 → 2350920).
31//! - ALARA: lowercase `"ll:AAA"`, no metastable flag (`"pu:239"`).
32//! - SZA: `S*1_000_000 + Z*1_000 + A` (SSSZZZAAA; Am-242m → 1095242).
33//!
34//! # Intentionally skipped corners
35//!
36//! - Natural elemental nuclides (A = 0, e.g. `"U"` → 920000000) cannot be
37//!   represented by [`crate::NuclideId`], which requires `A >= Z >= 1`;
38//!   parsers report [`DialectError::NaturalElement`] there and formatters
39//!   never emit the `"-nat"` / bare-symbol forms.
40//! - Elemental group sets (LAN/ACT/TRU/MA/FP), `abun` tables, `zzzaaa`,
41//!   GND, ENSDF, and the ENSDF state-id maps are out of scope.
42
43use std::collections::HashMap;
44use std::fmt;
45use std::sync::OnceLock;
46
47use crate::{element_symbol, Error, NuclideId, ELEMENTS};
48
49/// Errors raised by the dialect converters in this module.
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum DialectError {
53    /// Numeric input admits no interpretation in the source dialect.
54    NotANuclide(u32),
55    /// Input is empty or carries no mass number where one is required.
56    MissingMassNumber(String),
57    /// Element symbol portion is not recognized.
58    UnknownElement(String),
59    /// Trailing metastable designator is not one of `mnopqrstuvxyz`.
60    BadIsomerLetter(char),
61    /// Input denotes a natural element, unrepresentable as a [`NuclideId`].
62    NaturalElement(String),
63    /// Name is absent from the vendored FLUKA table.
64    UnknownFlukaName(String),
65    /// Leading `ZZ` block disagrees with the element symbol.
66    ZzSymbolMismatch {
67        /// Leading `ZZ` block of the input.
68        zz: u32,
69        /// Element symbol parsed from the name.
70        symbol: String,
71    },
72    /// Component values were rejected by canonical validation.
73    BadComponents(Error),
74}
75
76impl fmt::Display for DialectError {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::NotANuclide(v) => {
80                write!(f, "value {v} is not interpretable in the source dialect")
81            }
82            Self::MissingMassNumber(s) => write!(f, "no mass number in `{s}`"),
83            Self::UnknownElement(s) => write!(f, "unknown element symbol `{s}`"),
84            Self::BadIsomerLetter(c) => write!(f, "invalid metastable designator `{c}`"),
85            Self::NaturalElement(s) => {
86                write!(f, "natural element `{s}` unrepresentable as a NuclideId")
87            }
88            Self::UnknownFlukaName(s) => write!(f, "unknown FLUKA name `{s}`"),
89            Self::ZzSymbolMismatch { zz, symbol } => {
90                write!(
91                    f,
92                    "leading zz {zz} disagrees with element symbol `{symbol}`"
93                )
94            }
95            Self::BadComponents(e) => write!(f, "invalid nuclide components: {e}"),
96        }
97    }
98}
99
100impl std::error::Error for DialectError {
101    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
102        match self {
103            Self::BadComponents(e) => Some(e),
104            _ => None,
105        }
106    }
107}
108
109impl From<Error> for DialectError {
110    fn from(e: Error) -> Self {
111        Self::BadComponents(e)
112    }
113}
114
115/// Metastable-state designators (`"mnopqrstuvxyz"`).
116/// State `S` (1-based) maps to the letter at index `S - 1`; `w` is absent
117/// upstream as well.
118const ISOMER_LETTERS: &[u8] = b"mnopqrstuvxyz";
119
120/// FLUKA name table (names truncated to FLUKA's 8-character limit; values
121/// are canonical nucids).
122/// See the module docs for the two preserved upstream quirks.
123const FLUKA_NAMES: &[(&str, u32)] = &[
124    ("BERYLLIU", 40_000_000),
125    ("BARIUM", 560_000_000),
126    ("BOHRIUM", 1_070_000_000),
127    ("BISMUTH", 830_000_000),
128    ("BERKELIU", 970_000_000),
129    ("BROMINE", 350_000_000),
130    ("RUTHENIU", 440_000_000),
131    ("RHENIUM", 750_000_000),
132    ("RUTHERFO", 1_040_000_000),
133    ("ROENTGEN", 1_110_000_000),
134    ("RADIUM", 880_000_000),
135    ("RUBIDIUM", 370_000_000),
136    ("RADON", 860_000_000),
137    ("RHODIUM", 450_000_000),
138    ("THULIUM", 690_000_000),
139    ("HYDROGEN", 10_000_000),
140    ("PHOSPHO", 150_000_000),
141    ("GERMANIU", 320_000_000),
142    ("GADOLINI", 640_000_000),
143    ("GALLIUM", 310_000_000),
144    ("OSMIUM", 760_000_000),
145    ("HASSIUM", 1_080_000_000),
146    ("ZINC", 300_000_000),
147    ("HOLMIUM", 670_000_000),
148    ("HAFNIUM", 720_000_000),
149    ("MERCURY", 800_000_000),
150    ("HELIUM", 20_000_000),
151    ("PRASEODY", 590_000_000),
152    ("PLATINUM", 780_000_000),
153    ("239-PU", 940_000_000),
154    ("LEAD", 820_000_000),
155    ("PROTACTI", 910_000_000),
156    ("PALLADIU", 460_000_000),
157    ("POLONIUM", 840_000_000),
158    ("PROMETHI", 610_000_000),
159    ("CARBON", 60_000_000),
160    ("POTASSIU", 190_000_000),
161    ("OXYGEN", 80_000_000),
162    ("SULFUR", 160_000_000),
163    ("TUNGSTEN", 740_000_000),
164    ("EUROPIUM", 630_000_000),
165    ("EINSTEIN", 990_000_000),
166    ("ERBIUM", 680_000_000),
167    ("MENDELEV", 1_010_000_000),
168    ("MAGNESIU", 120_000_000),
169    ("MOLYBDEN", 420_000_000),
170    ("MANGANES", 250_000_000),
171    ("MEITNERI", 1_090_000_000),
172    ("URANIUM", 920_000_000),
173    ("FRANCIUM", 870_000_000),
174    ("IRON", 260_000_000),
175    ("FERMIUM", 1_000_000_000),
176    ("NICKEL", 280_000_000),
177    ("NITROGEN", 70_000_000),
178    ("NOBELIUM", 1_020_000_000),
179    ("SODIUM", 110_000_000),
180    ("NIOBIUM", 410_000_000),
181    ("NEODYMIU", 600_000_000),
182    ("NEON", 100_000_000),
183    ("ZIRCONIU", 400_000_000),
184    ("NEPTUNIU", 930_000_000),
185    ("BORON", 50_000_000),
186    ("COBALT", 270_000_000),
187    ("CURIUM", 960_000_000),
188    ("FLUORINE", 90_000_000),
189    ("CALCIUM", 200_000_000),
190    ("CALIFORN", 980_000_000),
191    ("CERIUM", 580_000_000),
192    ("CADMIUM", 480_000_000),
193    ("VANADIUM", 230_000_000),
194    ("CESIUM", 550_000_000),
195    ("CHROMIUM", 240_000_000),
196    ("COPPER", 290_000_000),
197    ("STRONTIU", 380_000_000),
198    ("KRYPTON", 360_000_000),
199    ("SILICON", 140_000_000),
200    ("TIN", 500_000_000),
201    ("SAMARIUM", 620_000_000),
202    ("SCANDIUM", 210_000_000),
203    ("ANTIMONY", 510_000_000),
204    ("SEABORGI", 1_060_000_000),
205    ("SELENIUM", 340_000_000),
206    ("YTTERBIU", 700_000_000),
207    ("DUBNIUM", 1_050_000_000),
208    ("DYSPROSI", 660_000_000),
209    ("DARMSTAD", 1_100_000_000),
210    ("LANTHANU", 570_000_000),
211    ("CHLORINE", 170_000_000),
212    ("LITHIUM", 30_000_000),
213    ("THALLIUM", 810_000_000),
214    ("LUTETIUM", 710_000_000),
215    ("LAWRENCI", 1_030_000_000),
216    ("THORIUM", 900_000_000),
217    ("TITANIUM", 220_000_000),
218    ("TELLURIU", 520_000_000),
219    ("TERBIUM", 650_000_000),
220    ("99-TC", 430_000_000),
221    ("TANTALUM", 730_000_000),
222    ("ACTINIUM", 890_000_000),
223    ("SILVER", 470_000_000),
224    ("IODINE", 530_000_000),
225    ("IRIDIUM", 770_000_000),
226    ("241-AM", 950_000_000),
227    ("ALUMINUM", 130_000_000),
228    ("ARSENIC", 330_000_000),
229    ("ARGON", 180_000_000),
230    ("GOLD", 790_000_000),
231    ("ASTATINE", 850_000_000),
232    ("INDIUM", 490_000_000),
233    ("YTTRIUM", 390_000_000),
234    ("XENON", 540_000_000),
235    ("COPERNIC", 1_120_000_000),
236    ("UNUNQUAD", 1_140_000_000),
237    ("UNUNHEXI", 1_160_000_000),
238    ("HYDROG-1", 10_010_000),
239    ("DEUTERIU", 10_020_000),
240    ("TRITIUM", 10_040_000),
241    ("HELIUM-3", 20_030_000),
242    ("HELIUM-4", 20_040_000),
243    ("LITHIU-6", 30_060_000),
244    ("LITHIU-7", 30_070_000),
245    ("BORON-10", 50_100_000),
246    ("BORON-11", 50_110_000),
247    ("90-SR", 380_900_000),
248    ("129-I", 531_290_000),
249    ("124-XE", 541_240_000),
250    ("126-XE", 541_260_000),
251    ("128-XE", 541_280_000),
252    ("130-XE", 541_300_000),
253    ("131-XE", 541_310_000),
254    ("132-XE", 541_320_000),
255    ("134-XE", 541_340_000),
256    ("135-XE", 541_350_000),
257    ("136-XE", 541_360_000),
258    ("135-CS", 551_350_000),
259    ("137-CS", 551_370_000),
260    ("230-TH", 902_300_000),
261    ("232-TH", 902_320_000),
262    ("233-U", 922_330_000),
263    ("234-U", 922_340_000),
264    ("235-U", 922_350_000),
265    ("238-U", 922_380_000),
266];
267
268static FLUKA_BY_ID: OnceLock<HashMap<u32, &'static str>> = OnceLock::new();
269static FLUKA_BY_NAME: OnceLock<HashMap<&'static str, u32>> = OnceLock::new();
270
271fn fluka_by_id() -> &'static HashMap<u32, &'static str> {
272    FLUKA_BY_ID.get_or_init(|| FLUKA_NAMES.iter().map(|&(name, id)| (id, name)).collect())
273}
274
275fn fluka_by_name() -> &'static HashMap<&'static str, u32> {
276    FLUKA_BY_NAME.get_or_init(|| FLUKA_NAMES.iter().copied().collect())
277}
278
279/// Lowercase isomer designator letter for state `s` (`1 → 'm'`), or `None`.
280pub(crate) fn isomer_letter(state: u32) -> Option<char> {
281    let idx = state.checked_sub(1)? as usize;
282    ISOMER_LETTERS.get(idx).map(|&b| b as char)
283}
284
285/// State index for an isomer designator letter (`'m' → 1`), or `None`.
286/// Accepts lowercase `m-z` and uppercase `M-Z` (case-normalized).
287pub(crate) fn isomer_state(letter: char) -> Option<u32> {
288    let needle = letter.to_ascii_lowercase() as u8;
289    ISOMER_LETTERS
290        .iter()
291        .position(|&b| b == needle)
292        .map(|p| p as u32 + 1)
293}
294
295/// Element symbol for a validated atomic number.
296fn symbol_of(z: u32) -> &'static str {
297    element_symbol(z).expect("NuclideId carries a validated atomic number")
298}
299
300/// Canonical (first letter upper, remainder lower) element lookup.
301fn z_of_canonical_symbol(sym: &str) -> Option<u32> {
302    if sym.is_empty() {
303        return None;
304    }
305    let mut chars = sym.chars();
306    let first = chars.next()?;
307    let rest = chars.as_str();
308    ELEMENTS
309        .iter()
310        .position(|&s| {
311            if s.is_empty() {
312                return false;
313            }
314            let mut el_chars = s.chars();
315            let el_first = el_chars.next().unwrap();
316            first.eq_ignore_ascii_case(&el_first) && rest.eq_ignore_ascii_case(el_chars.as_str())
317        })
318        .map(|z| z as u32)
319}
320
321/// Split `s` into its digit and alphabetic runs.
322fn digit_letter_runs(s: &str) -> (String, String) {
323    let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
324    let letters: String = s.chars().filter(|c| c.is_ascii_alphabetic()).collect();
325    (digits, letters)
326}
327
328/// Shared parser for dialects written as digits adjacent to an element
329/// symbol with no metastable information (NIST, ALARA). Expects separators
330/// already removed and `s` uppercased by the caller.
331fn id_from_mass_symbol(raw: &str, s: &str) -> Result<NuclideId, DialectError> {
332    let (digits, letters) = digit_letter_runs(s);
333    if digits.is_empty() {
334        return if z_of_canonical_symbol(&letters).is_some() {
335            Err(DialectError::NaturalElement(raw.to_string()))
336        } else {
337            Err(DialectError::UnknownElement(raw.to_string()))
338        };
339    }
340    let z = z_of_canonical_symbol(&letters)
341        .ok_or_else(|| DialectError::UnknownElement(raw.to_string()))?;
342    let a = digits
343        .parse::<u32>()
344        .map_err(|_| Error::BadNumber(digits.clone()))?;
345    NuclideId::new(z, a, 0).map_err(DialectError::from)
346}
347
348/// Convert to the MCNP ZAID form (`to_zaid(U235) == 922350`).
349///
350/// Metastable states add `300 + 100*S`; Am-242m → 95242 and Am-242 → 95642
351/// per the MCNP special case.
352pub fn to_zaid(nuc: NuclideId) -> u32 {
353    let mut state = nuc.state();
354    let mut zaid = nuc.z() * 1_000 + nuc.a();
355    if zaid == 95_242 && state < 2 {
356        state = (state + 1) % 2;
357    }
358    if state != 0 {
359        zaid += 300 + state * 100;
360    }
361    zaid
362}
363
364/// Interpret a MCNP ZAID (`from_zaid(95642)` → Am-242 ground state).
365///
366/// Parses MCNP ZAIDs, including the Am-242/242m swap and the
367/// `A/Z > 3` metastable redistribution heuristic. Natural elements
368/// (`AAA == 0`) are rejected as unrepresentable.
369pub fn from_zaid(zaid: u32) -> Result<NuclideId, DialectError> {
370    let z = zaid / 1_000;
371    let a = zaid % 1_000;
372    if z == 0 {
373        return Err(DialectError::NotANuclide(zaid));
374    }
375    if z <= a {
376        if a < 400 {
377            return if zaid == 95_242 {
378                NuclideId::new(95, 242, 1).map_err(DialectError::from)
379            } else {
380                NuclideId::new(z, a, 0).map_err(DialectError::from)
381            };
382        }
383        if zaid == 95_642 {
384            return NuclideId::new(95, 242, 0).map_err(DialectError::from);
385        }
386        let mut n = ((zaid - 400) * 10_000) + 1;
387        loop {
388            let aaa = (n / 10_000) % 1_000;
389            let zzz = n / 10_000_000;
390            if (aaa as f32) / (zzz as f32) <= 3.0 {
391                break;
392            }
393            n -= 999_999;
394        }
395        return NuclideId::new(n / 10_000_000, (n / 10_000) % 1_000, n % 10)
396            .map_err(DialectError::from);
397    }
398    if a == 0 {
399        return Err(DialectError::NaturalElement(symbol_of(z).to_string()));
400    }
401    Err(DialectError::NotANuclide(zaid))
402}
403
404/// zzllaaam form: `"ZZ-LL-AAAM"` plus a lowercase isomer letter
405/// (`zzllaaam(Am242m) == "95-Am-242m"`).
406pub fn zzllaaam(nuc: NuclideId) -> String {
407    let mut out = format!("{}-{}-{}", nuc.z(), symbol_of(nuc.z()), nuc.a());
408    if let Some(c) = isomer_letter(nuc.state()) {
409        out.push(c);
410    }
411    out
412}
413
414/// Parse `"ZZ-LL-AAAM"` (+ optional isomer letter, case-insensitive)
415/// produced by [`zzllaaam`].
416pub fn from_zzllaaam(name: &str) -> Result<NuclideId, DialectError> {
417    let trimmed = name.trim();
418    let parts: Vec<&str> = trimmed.split('-').collect();
419    if parts.len() != 3 || trimmed.is_empty() {
420        return Err(DialectError::MissingMassNumber(trimmed.to_string()));
421    }
422    let zz: u32 = parts[0]
423        .parse()
424        .map_err(|_| Error::BadNumber(parts[0].to_string()))?;
425    let body = parts[2];
426    if parts[1].eq_ignore_ascii_case("NAT") || body.eq_ignore_ascii_case("NAT") || body.is_empty() {
427        return Err(DialectError::NaturalElement(trimmed.to_string()));
428    }
429    let (state, head) = split_isomer_suffix(body)?;
430    if head.is_empty() {
431        return Err(DialectError::MissingMassNumber(trimmed.to_string()));
432    }
433    let a = head
434        .parse::<u32>()
435        .map_err(|_| Error::BadNumber(head.to_string()))?;
436    let z = z_of_canonical_symbol(parts[1])
437        .ok_or_else(|| DialectError::UnknownElement(trimmed.to_string()))?;
438    if z != zz {
439        return Err(DialectError::ZzSymbolMismatch {
440            zz,
441            symbol: symbol_of(z).to_string(),
442        });
443    }
444    NuclideId::new(z, a, state).map_err(DialectError::from)
445}
446
447/// Serpent form: `"Ll-AAAM"` plus a lowercase isomer letter
448/// (`serpent(Am242m) == "Am-242m"`).
449pub fn serpent(nuc: NuclideId) -> String {
450    let mut out = format!("{}-{}", symbol_of(nuc.z()), nuc.a());
451    if let Some(c) = isomer_letter(nuc.state()) {
452        out.push(c);
453    }
454    out
455}
456
457/// Best-effort parse of a Serpent-style name (`"Am-242m"`, `"He-4"`).
458///
459/// Parses Serpent names: dashes are ignored, a trailing
460/// isomer letter sets the state, and natural-element names are rejected.
461pub fn from_serpent(name: &str) -> Result<NuclideId, DialectError> {
462    let trimmed = name.trim();
463    let s: String = trimmed
464        .chars()
465        .filter(|c| *c != '-')
466        .map(|c| c.to_ascii_uppercase())
467        .collect();
468    if s.is_empty() {
469        return Err(DialectError::MissingMassNumber(trimmed.to_string()));
470    }
471    let (digits, letters) = digit_letter_runs(&s);
472    if digits.is_empty() {
473        let base = letters.strip_suffix("NAT").unwrap_or(&letters);
474        return if z_of_canonical_symbol(base).is_some() {
475            Err(DialectError::NaturalElement(trimmed.to_string()))
476        } else {
477            Err(DialectError::UnknownElement(trimmed.to_string()))
478        };
479    }
480    let (state, head) = split_isomer_suffix(&s)?;
481    let (a_digits, sym) = digit_letter_runs(head);
482    if a_digits.is_empty() {
483        return Err(DialectError::MissingMassNumber(trimmed.to_string()));
484    }
485    let z = z_of_canonical_symbol(&sym)
486        .ok_or_else(|| DialectError::UnknownElement(trimmed.to_string()))?;
487    let a = a_digits
488        .parse::<u32>()
489        .map_err(|_| Error::BadNumber(a_digits.clone()))?;
490    NuclideId::new(z, a, state).map_err(DialectError::from)
491}
492
493/// FLUKA material name for `nuc` (e.g. `id_to_fluka(U235) == "235-U"`).
494///
495/// Only nuclides with an explicit entry in the vendored table resolve;
496/// the natural-element rows can never match a [`NuclideId`].
497pub fn id_to_fluka(nuc: NuclideId) -> Result<&'static str, DialectError> {
498    fluka_by_id()
499        .get(&nuc.nucid())
500        .copied()
501        .ok_or_else(|| DialectError::UnknownFlukaName(nuc.to_name()))
502}
503
504/// Resolve a FLUKA name from the vendored table to a [`NuclideId`]
505/// (`fluka_to_id("LITHIU-7")` → Li-7).
506///
507/// Rows denoting natural elements (A = 0) yield
508/// [`DialectError::NaturalElement`] rather than an id.
509pub fn fluka_to_id(name: &str) -> Result<NuclideId, DialectError> {
510    let &nucid = fluka_by_name()
511        .get(name)
512        .ok_or_else(|| DialectError::UnknownFlukaName(name.to_string()))?;
513    let z = nucid / 10_000_000;
514    let a = (nucid / 10_000) % 1_000;
515    if a == 0 {
516        return Err(DialectError::NaturalElement(name.to_string()));
517    }
518    NuclideId::new(z, a, nucid % 10).map_err(DialectError::from)
519}
520
521/// NIST form: mass number followed by the element symbol, metastable state
522/// dropped (`nist(Am242m) == "242Am"`).
523pub fn nist(nuc: NuclideId) -> String {
524    format!("{}{}", nuc.a(), symbol_of(nuc.z()))
525}
526
527/// Parse a NIST-style name (`"239Pu"`, `"4He"`); the result is always a
528/// ground state because the dialect carries no state information.
529pub fn nist_to_id(name: &str) -> Result<NuclideId, DialectError> {
530    let trimmed = name.trim();
531    let upper = trimmed.to_ascii_uppercase();
532    id_from_mass_symbol(trimmed, &upper)
533}
534
535/// Cinder `AAAZZZM` form: `A*10_000 + Z*10 + S` (`to_cinder(U235) == 2350920`).
536pub fn to_cinder(nuc: NuclideId) -> u32 {
537    nuc.a() * 10_000 + nuc.z() * 10 + nuc.state()
538}
539
540/// Interpret a Cinder integer (`from_cinder(2420951)` → Am-242m).
541pub fn from_cinder(value: u32) -> Result<NuclideId, DialectError> {
542    let state = value % 10;
543    let aaazzz = value / 10;
544    let z = aaazzz % 1_000;
545    let a = aaazzz / 1_000;
546    NuclideId::new(z, a, state).map_err(DialectError::from)
547}
548
549/// ALARA form: lowercase `"ll:AAA"` with no metastable flag
550/// (`alara(Pu239) == "pu:239"`).
551pub fn alara(nuc: NuclideId) -> String {
552    format!("{}:{}", symbol_of(nuc.z()).to_ascii_lowercase(), nuc.a())
553}
554
555/// Parse an ALARA-style name (`"pu:239"`, `"he:4"`); the result is always a
556/// ground state because the dialect carries no state information.
557pub fn alara_to_id(name: &str) -> Result<NuclideId, DialectError> {
558    let trimmed = name.trim();
559    let cleaned: String = trimmed
560        .chars()
561        .filter(|c| *c != ':')
562        .map(|c| c.to_ascii_uppercase())
563        .collect();
564    id_from_mass_symbol(trimmed, &cleaned)
565}
566
567/// SZA form: `S*1_000_000 + Z*1_000 + A` (`to_sza(Am242m) == 1095242`).
568pub fn to_sza(nuc: NuclideId) -> u32 {
569    nuc.state() * 1_000_000 + nuc.z() * 1_000 + nuc.a()
570}
571
572/// Interpret an SZA integer (`from_sza(1095242)` → Am-242m).
573pub fn from_sza(value: u32) -> Result<NuclideId, DialectError> {
574    let state = value / 1_000_000;
575    let zzzaaa = value % 1_000_000;
576    let z = zzzaaa / 1_000;
577    let a = zzzaaa % 1_000;
578    NuclideId::new(z, a, state).map_err(DialectError::from)
579}
580
581/// Split a trailing isomer designator off `body`, returning the state and
582/// the remaining prefix. A trailing digit means the ground state.
583fn split_isomer_suffix(body: &str) -> Result<(u32, &str), DialectError> {
584    let last = body
585        .chars()
586        .next_back()
587        .ok_or_else(|| DialectError::MissingMassNumber(body.to_string()))?;
588    if last.is_ascii_digit() {
589        return Ok((0, body));
590    }
591    match isomer_state(last) {
592        Some(state) => {
593            let head = &body[..body.len() - last.len_utf8()];
594            Ok((state, head))
595        }
596        None => Err(DialectError::BadIsomerLetter(last)),
597    }
598}
599
600/// Normalize a free-form nuclide name to its canonical [`NuclideId`].
601///
602/// Accepts symbol-first (`U235`, `Ba137m`, `Ba-137m`, `Ir-192n`),
603/// mass-first (`241Pu`, `40K`), and bare ZAID integers (`92235` → U235).
604/// while `_mN`/`MN` numeric forms keep their existing `from_name` semantics.
605/// Bare element symbols (`U`) are rejected: a mass number is required.
606///
607/// Resolution order is symbol-first ([`NuclideId::from_name`], which already
608/// covers `_mN`, trailing-`M`, and dash-tolerant forms), then — for inputs
609/// `from_name` rejects — mass-first via `digit_letter_runs` (so `N15`
610/// stays nitrogen-15 rather than parsing as an element), with all-digit
611/// inputs read as MCNP ZAIDs via [`from_zaid`].
612///
613/// # Examples
614///
615/// ```rust
616/// # use nucleide_nuclei::dialects::normalize_nuclide_name;
617/// # use nucleide_nuclei::NuclideId;
618/// assert_eq!(normalize_nuclide_name("241Pu").unwrap(), NuclideId::from_name("Pu241").unwrap());
619/// assert_eq!(normalize_nuclide_name("40K").unwrap(), NuclideId::from_name("K40").unwrap());
620/// assert_eq!(normalize_nuclide_name("Ba-137m").unwrap(), NuclideId::from_name("Ba137_m1").unwrap());
621/// assert_eq!(normalize_nuclide_name("Ir-192n").unwrap().state(), 2);
622/// ```
623pub fn normalize_nuclide_name(input: &str) -> Result<NuclideId, DialectError> {
624    let trimmed = input.trim();
625    if trimmed.is_empty() {
626        return Err(DialectError::MissingMassNumber(input.to_string()));
627    }
628    // Symbol-first (plus `_mN` / `MN` numerics and dash tolerance): the
629    // canonical parser covers `U235`, `Ba137m`, `Ba-137m`, `N15`, ....
630    if let Ok(id) = NuclideId::from_name(trimmed) {
631        return Ok(id);
632    }
633    let compact: String = trimmed
634        .chars()
635        .filter(|c| *c != '-' && !c.is_whitespace())
636        .collect();
637    if compact.is_empty() || !compact.is_ascii() {
638        return Err(DialectError::MissingMassNumber(trimmed.to_string()));
639    }
640    let upper = compact.to_ascii_uppercase();
641    // All-digit inputs are ZAIDs (`92235` → U235), not mass numbers.
642    if upper.chars().all(|c| c.is_ascii_digit()) {
643        let zaid: u32 = upper
644            .parse()
645            .map_err(|_| crate::Error::BadNumber(upper.clone()))?;
646        return from_zaid(zaid);
647    }
648    // Symbol-first with an extended isomer letter (`Ir192n` → state 2;
649    // trailing-`M` forms never reach here: `from_name` takes them).
650    if let Some((z, rest)) = split_leading_symbol(&upper) {
651        if rest.is_empty() {
652            return Err(DialectError::NaturalElement(trimmed.to_string()));
653        }
654        let digit_end = rest
655            .find(|c: char| !c.is_ascii_digit())
656            .unwrap_or(rest.len());
657        let (a_str, suffix) = rest.split_at(digit_end);
658        if a_str.is_empty() {
659            return Err(DialectError::MissingMassNumber(trimmed.to_string()));
660        }
661        let a = a_str
662            .parse::<u32>()
663            .map_err(|_| crate::Error::BadNumber(a_str.to_string()))?;
664        let state = match suffix.len() {
665            0 => 0,
666            1 => isomer_state(suffix.chars().next().unwrap())
667                .ok_or_else(|| DialectError::BadIsomerLetter(suffix.chars().next().unwrap()))?,
668            _ => return Err(DialectError::MissingMassNumber(trimmed.to_string())),
669        };
670        return NuclideId::new(z, a, state).map_err(DialectError::from);
671    }
672    // Mass-first (`241Pu`, `40K`) via `digit_letter_runs`: the digits must
673    // lead and the remainder must be letters only. The full remainder is
674    // tried as a symbol before any isomer-letter strip, so `Pu` in `241Pu`
675    // is never misread as state 8 (`u`).
676    let (digits, letters) = digit_letter_runs(&upper);
677    if digits.is_empty() {
678        return if z_of_canonical_symbol(&letters).is_some() {
679            Err(DialectError::NaturalElement(trimmed.to_string()))
680        } else {
681            Err(DialectError::UnknownElement(trimmed.to_string()))
682        };
683    }
684    if upper.len() < digits.len() || &upper[..digits.len()] != digits.as_str() {
685        return Err(DialectError::MissingMassNumber(trimmed.to_string()));
686    }
687    let remainder = &upper[digits.len()..];
688    if remainder.is_empty() || remainder != letters.as_str() {
689        return Err(DialectError::MissingMassNumber(trimmed.to_string()));
690    }
691    let (z, state) = match z_of_canonical_symbol(letters.as_str()) {
692        Some(z) => (z, 0),
693        None => {
694            let (head, tail) = letters.split_at(letters.len() - 1);
695            let letter = tail.chars().next().unwrap();
696            match (z_of_canonical_symbol(head), isomer_state(letter)) {
697                (Some(z), Some(state)) => (z, state),
698                _ => return Err(DialectError::UnknownElement(trimmed.to_string())),
699            }
700        }
701    };
702    let a = digits
703        .parse::<u32>()
704        .map_err(|_| crate::Error::BadNumber(digits.to_string()))?;
705    NuclideId::new(z, a, state).map_err(DialectError::from)
706}
707
708/// Split a leading element symbol off `s` (two-letter symbols preferred),
709/// returning the atomic number and the remainder.
710fn split_leading_symbol(s: &str) -> Option<(u32, &str)> {
711    if s.len() >= 2 {
712        if let Some(z) = z_of_canonical_symbol(&s[..2]) {
713            return Some((z, &s[2..]));
714        }
715    }
716    if !s.is_empty() {
717        if let Some(z) = z_of_canonical_symbol(&s[..1]) {
718            return Some((z, &s[1..]));
719        }
720    }
721    None
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    fn nid(z: u32, a: u32, s: u32) -> NuclideId {
729        NuclideId::new(z, a, s).unwrap()
730    }
731
732    #[test]
733    fn zaid_matches_mcnp_convention() {
734        assert_eq!(to_zaid(nid(1, 1, 0)), 1001);
735        assert_eq!(to_zaid(nid(92, 235, 0)), 92_235);
736        assert_eq!(to_zaid(nid(92, 236, 1)), 92_636);
737        assert_eq!(to_zaid(nid(95, 242, 1)), 95_242);
738        assert_eq!(to_zaid(nid(95, 242, 0)), 95_642);
739        assert_eq!(to_zaid(nid(2, 4, 0)), 2004);
740    }
741
742    #[test]
743    fn zaid_edge_cases() {
744        assert_eq!(from_zaid(2004).unwrap(), nid(2, 4, 0));
745        assert_eq!(from_zaid(1001).unwrap(), nid(1, 1, 0));
746        assert_eq!(from_zaid(95_242).unwrap(), nid(95, 242, 1));
747        assert_eq!(from_zaid(95_642).unwrap(), nid(95, 242, 0));
748        assert_eq!(from_zaid(92_636).unwrap(), nid(92, 236, 1));
749        assert_eq!(from_zaid(95_942).unwrap(), nid(95, 242, 4));
750        assert_eq!(from_zaid(96_644).unwrap(), nid(96, 244, 1));
751    }
752
753    #[test]
754    fn zaid_round_trips() {
755        for nuc in [
756            nid(1, 1, 0),
757            nid(92, 235, 0),
758            nid(92, 236, 1),
759            nid(95, 242, 0),
760            nid(95, 242, 1),
761            nid(95, 242, 4),
762            nid(56, 137, 1),
763            nid(73, 182, 2),
764        ] {
765            assert_eq!(from_zaid(to_zaid(nuc)).unwrap(), nuc);
766        }
767    }
768
769    #[test]
770    fn zaid_error_paths() {
771        assert_eq!(from_zaid(92), Err(DialectError::NotANuclide(92)));
772        assert_eq!(
773            from_zaid(92_000),
774            Err(DialectError::NaturalElement("U".to_string()))
775        );
776        assert_eq!(from_zaid(50_003), Err(DialectError::NotANuclide(50_003)));
777    }
778
779    #[test]
780    fn zzllaaam_round_trip() {
781        assert_eq!(zzllaaam(nid(94, 239, 0)), "94-Pu-239");
782        assert_eq!(zzllaaam(nid(95, 242, 1)), "95-Am-242m");
783        assert_eq!(zzllaaam(nid(95, 242, 0)), "95-Am-242");
784        assert_eq!(zzllaaam(nid(92, 236, 1)), "92-U-236m");
785        assert_eq!(zzllaaam(nid(73, 182, 2)), "73-Ta-182n");
786    }
787
788    #[test]
789    fn zzllaaam_parses() {
790        assert_eq!(from_zzllaaam("94-Pu-239").unwrap(), nid(94, 239, 0));
791        assert_eq!(from_zzllaaam("95-Am-242m").unwrap(), nid(95, 242, 1));
792        assert_eq!(from_zzllaaam("73-Ta-182n").unwrap(), nid(73, 182, 2));
793        assert_eq!(from_zzllaaam("95-am-242m").unwrap(), nid(95, 242, 1));
794        assert_eq!(from_zzllaaam("95-Am-242M").unwrap(), nid(95, 242, 1));
795        assert_eq!(from_zzllaaam("73-Ta-182N").unwrap(), nid(73, 182, 2));
796    }
797
798    #[test]
799    fn zzllaaam_error_paths() {
800        assert_eq!(
801            from_zzllaaam("Ta-182b"),
802            Err(DialectError::MissingMassNumber("Ta-182b".to_string()))
803        );
804        assert_eq!(
805            from_zzllaaam("95-Pu-239"),
806            Err(DialectError::ZzSymbolMismatch {
807                zz: 95,
808                symbol: "Pu".to_string()
809            })
810        );
811        assert_eq!(
812            from_zzllaaam("92-U-nat"),
813            Err(DialectError::NaturalElement("92-U-nat".to_string()))
814        );
815        assert!(matches!(
816            from_zzllaaam("94-Xx-239"),
817            Err(DialectError::UnknownElement(_))
818        ));
819    }
820
821    #[test]
822    fn serpent_dialect_round_trip() {
823        assert_eq!(serpent(nid(94, 239, 0)), "Pu-239");
824        assert_eq!(serpent(nid(95, 242, 1)), "Am-242m");
825        assert_eq!(serpent(nid(95, 242, 0)), "Am-242");
826        assert_eq!(serpent(nid(92, 236, 1)), "U-236m");
827        assert_eq!(serpent(nid(73, 182, 2)), "Ta-182n");
828    }
829
830    #[test]
831    fn serpent_parses() {
832        assert_eq!(from_serpent("Pu-239").unwrap(), nid(94, 239, 0));
833        assert_eq!(from_serpent("Am-242m").unwrap(), nid(95, 242, 1));
834        assert_eq!(from_serpent("He-4").unwrap(), nid(2, 4, 0));
835        assert_eq!(from_serpent("U-236m").unwrap(), nid(92, 236, 1));
836        assert_eq!(from_serpent("Cm-244m").unwrap(), nid(96, 244, 1));
837        assert_eq!(from_serpent("Ta-182n").unwrap(), nid(73, 182, 2));
838        assert_eq!(from_serpent("Am-242M").unwrap(), nid(95, 242, 1));
839        assert_eq!(from_serpent("Ta-182N").unwrap(), nid(73, 182, 2));
840    }
841
842    #[test]
843    fn serpent_error_paths() {
844        assert_eq!(
845            from_serpent("U-nat"),
846            Err(DialectError::NaturalElement("U-nat".to_string()))
847        );
848        assert_eq!(
849            from_serpent("Am-242j"),
850            Err(DialectError::BadIsomerLetter('J'))
851        );
852        assert!(matches!(
853            from_serpent("Xx-12"),
854            Err(DialectError::UnknownElement(_))
855        ));
856    }
857
858    #[test]
859    fn fluka_to_id_isotopes() {
860        assert_eq!(fluka_to_id("LITHIU-7").unwrap(), nid(3, 7, 0));
861        assert_eq!(fluka_to_id("HYDROG-1").unwrap(), nid(1, 1, 0));
862        assert_eq!(fluka_to_id("235-U").unwrap(), nid(92, 235, 0));
863        assert_eq!(fluka_to_id("BORON-10").unwrap(), nid(5, 10, 0));
864        assert_eq!(fluka_to_id("HELIUM-4").unwrap(), nid(2, 4, 0));
865    }
866
867    #[test]
868    fn fluka_to_id_error_paths() {
869        assert_eq!(
870            fluka_to_id("NOPE"),
871            Err(DialectError::UnknownFlukaName("NOPE".to_string()))
872        );
873        assert_eq!(
874            fluka_to_id("URANIUM"),
875            Err(DialectError::NaturalElement("URANIUM".to_string()))
876        );
877    }
878
879    #[test]
880    fn id_to_fluka_names() {
881        assert_eq!(id_to_fluka(nid(3, 7, 0)), Ok("LITHIU-7"));
882        assert_eq!(id_to_fluka(nid(92, 235, 0)), Ok("235-U"));
883        assert_eq!(id_to_fluka(nid(1, 1, 0)), Ok("HYDROG-1"));
884        assert_eq!(id_to_fluka(nid(2, 4, 0)), Ok("HELIUM-4"));
885        assert_eq!(
886            id_to_fluka(nid(26, 56, 0)),
887            Err(DialectError::UnknownFlukaName("Fe56".to_string()))
888        );
889    }
890
891    #[test]
892    fn nist_dialect_drops_state() {
893        assert_eq!(nist(nid(94, 239, 0)), "239Pu");
894        assert_eq!(nist(nid(95, 242, 1)), "242Am");
895        assert_eq!(nist(nid(2, 4, 0)), "4He");
896    }
897
898    #[test]
899    fn nist_parses_ground_states() {
900        assert_eq!(nist_to_id("4He").unwrap(), nid(2, 4, 0));
901        assert_eq!(nist_to_id("244Cm").unwrap(), nid(96, 244, 0));
902        assert_eq!(nist_to_id("239Pu").unwrap(), nid(94, 239, 0));
903        assert_eq!(nist_to_id("242Am").unwrap(), nid(95, 242, 0));
904        assert_eq!(
905            nist_to_id("U"),
906            Err(DialectError::NaturalElement("U".to_string()))
907        );
908        assert!(matches!(
909            nist_to_id("242Xx"),
910            Err(DialectError::UnknownElement(_))
911        ));
912    }
913
914    #[test]
915    fn cinder_dialect_round_trip() {
916        assert_eq!(to_cinder(nid(1, 2, 0)), 20_010);
917        assert_eq!(to_cinder(nid(95, 242, 1)), 2_420_951);
918        assert_eq!(to_cinder(nid(92, 236, 1)), 2_360_921);
919        assert_eq!(from_cinder(2_420_951).unwrap(), nid(95, 242, 1));
920        assert_eq!(from_cinder(2_360_921).unwrap(), nid(92, 236, 1));
921        assert_eq!(from_cinder(2_440_961).unwrap(), nid(96, 244, 1));
922        assert!(matches!(
923            from_cinder(20),
924            Err(DialectError::BadComponents(Error::BadA { .. }))
925        ));
926    }
927
928    #[test]
929    fn alara_dialect_round_trip() {
930        assert_eq!(alara(nid(94, 239, 0)), "pu:239");
931        assert_eq!(alara(nid(95, 242, 1)), "am:242");
932        assert_eq!(alara(nid(2, 4, 0)), "he:4");
933        assert_eq!(alara(nid(92, 236, 1)), "u:236");
934    }
935
936    #[test]
937    fn alara_parses_ground_states() {
938        assert_eq!(alara_to_id("pu:239").unwrap(), nid(94, 239, 0));
939        assert_eq!(alara_to_id("cm:244").unwrap(), nid(96, 244, 0));
940        assert_eq!(alara_to_id("he:4").unwrap(), nid(2, 4, 0));
941        assert_eq!(
942            alara_to_id("u"),
943            Err(DialectError::NaturalElement("u".to_string()))
944        );
945        assert!(matches!(
946            alara_to_id("zz:10"),
947            Err(DialectError::UnknownElement(_))
948        ));
949    }
950
951    #[test]
952    fn sza_dialect_round_trip() {
953        assert_eq!(to_sza(nid(2, 4, 0)), 2004);
954        assert_eq!(to_sza(nid(95, 242, 1)), 1_095_242);
955        assert_eq!(to_sza(nid(92, 236, 1)), 1_092_236);
956        assert_eq!(to_sza(nid(95, 242, 4)), 4_095_242);
957        assert_eq!(from_sza(1_095_242).unwrap(), nid(95, 242, 1));
958        assert_eq!(from_sza(2004).unwrap(), nid(2, 4, 0));
959        assert_eq!(from_sza(1_096_244).unwrap(), nid(96, 244, 1));
960        assert!(matches!(
961            from_sza(20),
962            Err(DialectError::BadComponents(Error::BadZ(0)))
963        ));
964    }
965
966    #[test]
967    fn normalize_symbol_first_forms() {
968        assert_eq!(
969            normalize_nuclide_name("U235").unwrap(),
970            NuclideId::from_name("U235").unwrap()
971        );
972        assert_eq!(
973            normalize_nuclide_name("Ba137m").unwrap(),
974            NuclideId::from_name("Ba137_m1").unwrap()
975        );
976        assert_eq!(
977            normalize_nuclide_name("Ba-137m").unwrap(),
978            NuclideId::from_name("Ba137_m1").unwrap()
979        );
980        // Extended isomer letters via isomer_state (case-insensitive).
981        assert_eq!(normalize_nuclide_name("Ir-192n").unwrap().state(), 2);
982        assert_eq!(normalize_nuclide_name("Ir192n").unwrap(), nid(77, 192, 2));
983        assert_eq!(normalize_nuclide_name("Ta-182N").unwrap(), nid(73, 182, 2));
984        // `_mN` / `MN` numerics keep from_name semantics untouched.
985        assert_eq!(normalize_nuclide_name("Am242_m1").unwrap(), nid(95, 242, 1));
986        assert_eq!(normalize_nuclide_name("Am242M").unwrap(), nid(95, 242, 1));
987    }
988
989    #[test]
990    fn normalize_mass_first_and_zaid() {
991        // Mass-first: digits lead, symbol follows.
992        assert_eq!(normalize_nuclide_name("241Pu").unwrap(), nid(94, 241, 0));
993        assert_eq!(normalize_nuclide_name("40K").unwrap(), nid(19, 40, 0));
994        // N15 stays nitrogen-15 (symbol-first wins, never an element trap).
995        assert_eq!(normalize_nuclide_name("N15").unwrap(), nid(7, 15, 0));
996        // Bare ZAIDs stay ZAIDs (all-digit inputs read via from_zaid).
997        assert_eq!(normalize_nuclide_name("92235").unwrap(), nid(92, 235, 0));
998        assert_eq!(normalize_nuclide_name("1001").unwrap(), nid(1, 1, 0));
999    }
1000
1001    #[test]
1002    fn normalize_rejects_bare_symbols_and_garbage() {
1003        assert!(matches!(
1004            normalize_nuclide_name("U"),
1005            Err(DialectError::MissingMassNumber(_)) | Err(DialectError::NaturalElement(_))
1006        ));
1007        assert!(matches!(
1008            normalize_nuclide_name("Pu"),
1009            Err(DialectError::NaturalElement(_))
1010        ));
1011        assert!(matches!(
1012            normalize_nuclide_name(""),
1013            Err(DialectError::MissingMassNumber(_))
1014        ));
1015        assert!(matches!(
1016            normalize_nuclide_name("Xx99"),
1017            Err(DialectError::UnknownElement(_)) | Err(DialectError::MissingMassNumber(_))
1018        ));
1019        assert!(matches!(
1020            normalize_nuclide_name("99Xx"),
1021            Err(DialectError::UnknownElement(_))
1022        ));
1023    }
1024
1025    #[test]
1026    fn dialect_error_display_smoke() {
1027        let cases = [
1028            DialectError::NotANuclide(7),
1029            DialectError::MissingMassNumber("U".into()),
1030            DialectError::UnknownElement("Xx".into()),
1031            DialectError::BadIsomerLetter('b'),
1032            DialectError::NaturalElement("U".into()),
1033            DialectError::UnknownFlukaName("NOPE".into()),
1034            DialectError::ZzSymbolMismatch {
1035                zz: 95,
1036                symbol: "Pu".into(),
1037            },
1038            DialectError::BadComponents(Error::BadZ(0)),
1039        ];
1040        for e in cases {
1041            assert!(!e.to_string().is_empty());
1042        }
1043    }
1044}