Skip to main content

nucleide_nuclei/
data.rs

1//! Static nuclear reference data: atomic masses (AME2020 plus ENDF-derived
2//! isomer masses), natural abundances, radioactive half-lives, decay branches,
3//! screening-level cross sections, neutron scattering lengths, mean decay
4//! energies, and dose factors.
5//!
6//! # Provenance
7//!
8//! - Atomic masses: the [AME2020 atomic mass evaluation][ame] (Huang et al.,
9//!   *Chinese Physics C* **45**, 030002/030003, 2021; data courtesy the
10//!   IAEA-supported AMDC), condensed into the compact [`crate::data`]
11//!   tables below. Isomer rows add the ENDF/B-VIII.0 File-1 MT451 `ELIS`
12//!   excitation energy: `m = m_ground + E*/931.49410242 u` (ENDF excitation
13//!   energies only; no NUBASE import; isomer tapes with unset `ELIS` carry
14//!   the ground-state mass).
15//! - Natural abundances: standard isotopic compositions from the
16//!   ENDF/B-VIII.0 evaluation, expressed as fractions in 0..1.
17//! - Half-lives: ENDF/B-VIII.0 decay evaluations (distributed via the IAEA
18//!   and BNL/NNDC), expressed in seconds.
19//! - Decay branches (`decay_branches.tsv`): per-branch daughters from the
20//!   ENDF/B-VIII.0 decay sublibrary (MF8/MT457 NDK records: RTYP decay-mode
21//!   code, RFS daughter state flag, BR branching fraction). RTYP digits
22//!   apply in emission order (ENDF-102 §8.4; OpenMC `decay.py` digit table):
23//!   beta- gives Z+1, EC/beta+ gives Z-1, alpha gives Z-2/A-4, IT is
24//!   unchanged, delayed neutrons/protons subtract the emitted nucleons.
25//!   Spontaneous-fission and fission-family branches are dropped (depletion
26//!   matrices skip `sf` gains); zero-half-life and stable-flagged tapes
27//!   yield no rows, so effectively-stable entries stay absent, matching the
28//!   half-life table's stable-absent convention.
29//!   *unchanged carries of ENDF/B-VII.1 evaluations* per their README.txt —
30//!   mostly England et al. ENDF-349 (EVAL-JUL89), plus the Chadwick–Kawano
31//!   Pu-239 evaluation — with the Mattera–Sonzogni cumulative-yield
32//!   correction **not** applied; values are verbatim from the tapes.
33//! - Simple cross sections (`simple_xs.tsv`): **total** microscopic cross
34//!   sections in barns. Thermal values combine NIST NCNR 2200 m/s bound
35//!   scattering/absorption converted to free-atom totals via
36//!   `xs·(A/(A+1))² + xs_a` (ENDF File 3 alone is background-only inside
37//!   resonance ranges); 14-MeV values are ENDF/B-VII.1 MF3/MT1 interpolated
38//!   per the tape INT laws. Screening-level; use evaluated libraries for
39//!   transport, never for safety calculations.
40//! - Scattering lengths (`scattering_lengths.tsv`): bound coherent /
41//!   incoherent lengths in femtometres from the NIST NCNR tabulation of
42//!   Sears, *Neutron News* **3**(3) (1992) and its update; complex
43//!   (absorbing) lengths enter by real part, incoherent lengths otherwise
44//!   derive from the tabulated spin-incoherent cross section via
45//!   `b_inc = 10·sqrt(σ_i/4π)` fm. Isotopes the table does not tabulate
46//!   (and non-monoisotopic element rows) are absent.
47//! - Decay energies (`decay_energy.tsv`): mean *prompt* recoverable energy
48//!   per decay in MeV from ENDF/B-VII.1 decay tapes (MF8/MT457 summary
49//!   components, paired uncertainties skipped; delayed-neutron kinetic
50//!   energy included, neutrinos never appear). Daughter gammas belong to
51//!   the daughter row — chain codes must sum members (e.g. Cs137 prompt
52//!   plus Ba137_m1 662 keV). Not for spectroscopy or safety use.
53//! - Dose factors (`dose_factors.tsv`): external-air/soil, ingestion, and
54//!   inhalation factors from the BSD-3 PyNE `dbgen/dosefactors*.csv` tables
55//!   (HNF-SD-WM-TI-707 Rev.1 / HNF-5636 App. O; GENII/EPA/DOE are 3 parallel
56//!   evaluations). `+D` folds into the parent. Air is EPA-only: GENII/DOE
57//!   air rows are `-1` sentinels (PyNE convention); accessors return the
58//!   stored `-1` and dose analytics treat negative factors as missing.
59//!   Not for safety decisions (upstream PyNE disclaimer).
60//!
61//! The screening tables are generated by
62//! `scripts/gen-nuclear-data.py` (stdlib-only; run `--help` for the
63//! upstream download URLs) — never hand-edit a value, regenerate.
64//!
65//! All tables are vendored as tab-separated text under `src/data/` and
66//! embedded with `include_str!`; no runtime dependencies beyond `std`.
67//! Parsing happens lazily on first lookup into fixed static maps.
68//!
69//! Table contents:
70//!
71//! - `data/ame2020.tsv`: one row per ground-state nuclide (`nucid`,
72//!   `mass_u`, `uncertainty_u`), covering all 3 557 nuclides with `Z >= 1`
73//!   (the free-neutron row of the source file is dropped), plus one row per
74//!   ENDF/B-VIII.0 isomer tape (full state-bearing nucid, 738 rows):
75//!   `m = m_ground + ELIS/931.49410242 u` (4 295 rows total).
76//! - `data/natural_abundance.tsv`: `GNDS name` → `fraction`, including the
77//!   lone naturally occurring isomer Ta180_m1.
78//! - `data/half_life.tsv`: `GNDS name` → `half_life_seconds`, for every
79//!   radionuclide in the evaluation (stable nuclides are simply absent).
80//! - `data/decay_branches.tsv`: `parent_GNDS` → (`progeny_GNDS`, `bf`,
81//!   `mode`) per kept branch (5 068 rows over 3 541 parents; SF/fission
82//!   branches dropped, so strong SF emitters sum to `1 - BR(SF)`).
83//! - `data/fission_yields.tsv`: (`parent_GNDS`, `origin`, `kind`,
84//!   `energy_eV`) → per-product (`daughter_GNDS`, `Y`, `dY`) rows from the
85//!   ENDF/B-VIII.0 neutron-induced and spontaneous fission-yield
86//!   sublibraries (151 490 rows over 36 parents and 122 incident-energy
87//!   sets; MF8/MT454 independent + MF8/MT459 cumulative, values verbatim —
88//!   both sublibraries are unchanged carries of ENDF/B-VII.1 evaluations
89//!   per the tapes' README.txt). `dY = 0` is the no-uncertainty sentinel
90//!   and occurs exactly on the zero-yield rows of this sublibrary.
91//! - `data/simple_xs.tsv`: `GNDS name` → (`thermal_barn`, `fast14mev_barn`)
92//!   total cross sections (241 rows; resonance nuclides without a NIST row
93//!   and isomers are absent by construction).
94//! - `data/scattering_lengths.tsv`: `GNDS name` → (`b_coherent_fm`,
95//!   `b_incoherent_fm`) bound scattering lengths (267 rows, NIST-tabulated
96//!   isotopes plus monoisotopic element attributions).
97//! - `data/decay_energy.tsv`: `GNDS name` → `mev_per_decay` mean prompt
98//!   recoverable decay energy (3 557 rows covering every ENDF/B-VII.1 decay
99//!   tape with nonzero heat data, isomers with prompt gammas such as
100//!   Ba137_m1 included). Basis note: this table stays on ENDF/B-VII.1
101//!   while `half_life.tsv` and `decay_branches.tsv` use ENDF/B-VIII.0.
102//! - `data/dose_factors.tsv`: (`GNDS name`, `pathway`, `source`) → (`factor`,
103//!   `f1`, `lung_model`) dose factors (1 116 rows: 93 folded nuclides × 4
104//!   pathways × 3 sources; `f1` set only on ingest rows, `lung_model` only
105//!   on inhale rows).
106//!
107//! Masses are keyed by the canonical [`NuclideId`] nucid layout, including
108//! the metastable state: isomers with their own ENDF/B-VIII.0 tape resolve
109//! to `m_ground + E*/931.49410242 u`, while isomers without a tape fall back
110//! to the ground-state mass (the missing excitation energy is at most MeV
111//! against a GeV-scale mass). Q-value helpers stay ground-state-only by
112//! design (see [`q_value_neutron_capture`]): they combine tabulated atomic
113//! masses per the textbook formulas, and isomer-resolved Q-values would need
114//! excitation bookkeeping beyond this screening-level scope.
115//!
116//! [ame]: https://doi.org/10.1088/1674-1137/abddaf
117//!
118//! Ownership note: this module + its data files only — do NOT edit `lib.rs`.
119
120use std::collections::BTreeMap;
121use std::sync::OnceLock;
122
123use crate::NuclideId;
124
125const AME2020_TSV: &str = include_str!("data/ame2020.tsv");
126const NATURAL_ABUNDANCE_TSV: &str = include_str!("data/natural_abundance.tsv");
127const HALF_LIFE_TSV: &str = include_str!("data/half_life.tsv");
128const SIMPLE_XS_TSV: &str = include_str!("data/simple_xs.tsv");
129const SCATTERING_LENGTHS_TSV: &str = include_str!("data/scattering_lengths.tsv");
130const DECAY_ENERGY_TSV: &str = include_str!("data/decay_energy.tsv");
131const DECAY_BRANCHES_TSV: &str = include_str!("data/decay_branches.tsv");
132const DOSE_FACTORS_TSV: &str = include_str!("data/dose_factors.tsv");
133const FISSION_YIELDS_TSV: &str = include_str!("data/fission_yields.tsv");
134
135static MASSES: OnceLock<BTreeMap<u32, f64>> = OnceLock::new();
136static ABUNDANCES: OnceLock<BTreeMap<u32, f64>> = OnceLock::new();
137static HALF_LIVES: OnceLock<BTreeMap<u32, f64>> = OnceLock::new();
138static SIMPLE_XS: OnceLock<BTreeMap<u32, (f64, f64)>> = OnceLock::new();
139static SCATTERING_LENGTHS: OnceLock<BTreeMap<u32, (f64, f64)>> = OnceLock::new();
140static DECAY_ENERGIES: OnceLock<BTreeMap<u32, f64>> = OnceLock::new();
141static DECAY_BRANCHES: OnceLock<BTreeMap<u32, Vec<DecayBranch>>> = OnceLock::new();
142static DOSE_FACTORS: OnceLock<BTreeMap<(u32, DosePathway, DoseSource), DoseEntry>> =
143    OnceLock::new();
144static FISSION_YIELDS: OnceLock<BTreeMap<FissionYieldKey, Vec<FissionYieldSet>>> = OnceLock::new();
145
146/// MeV per unified atomic mass unit `c²` (2022 CODATA consistent with
147/// AME2020 usage).
148pub const MEV_PER_U: f64 = 931.494_102_42;
149
150/// Free-neutron atomic mass in u (AME2020: 1.00866491595 u).
151pub const NEUTRON_MASS_U: f64 = 1.008_664_915_95;
152
153/// Helium-4 atomic mass in u (AME2020), for alpha-decay Q-values.
154pub const HELIUM4_MASS_U: f64 = 4.002_603_254_13;
155
156/// The free neutron's mass, in u.
157///
158/// See [`NEUTRON_MASS_U`].
159pub const fn neutron_mass_u() -> f64 {
160    NEUTRON_MASS_U
161}
162
163/// Parse `nucid \t mass_u [\t uncertainty_u]` rows, skipping comment lines.
164fn parse_masses(tsv: &str) -> BTreeMap<u32, f64> {
165    tsv.lines()
166        .filter(|line| !line.is_empty() && !line.starts_with('#'))
167        .filter_map(|line| {
168            let mut cols = line.split('\t');
169            let nucid = cols.next()?.parse().ok()?;
170            let mass = cols.next()?.parse().ok()?;
171            Some((nucid, mass))
172        })
173        .collect()
174}
175
176/// Parse `GNDS name \t fraction` rows into nucid-keyed fractions.
177///
178/// Malformed rows are skipped silently, matching [`parse_masses`]; the
179/// embedded files are compile-time constants and a corrupt row should not
180/// panic library consumers.
181fn parse_abundances(tsv: &str) -> BTreeMap<u32, f64> {
182    tsv.lines()
183        .filter(|line| !line.is_empty() && !line.starts_with('#'))
184        .filter_map(|line| {
185            let mut cols = line.split('\t');
186            let name = cols.next()?;
187            let fraction: f64 = cols.next()?.parse().ok()?;
188            let nucid = NuclideId::from_name(name).ok()?;
189            Some((nucid.nucid(), fraction))
190        })
191        .collect()
192}
193
194/// Parse `GNDS name \t half_life_seconds` rows into nucid-keyed seconds.
195///
196/// Malformed rows are skipped silently, matching [`parse_masses`]; the
197/// embedded files are compile-time constants and a corrupt row should not
198/// panic library consumers.
199fn parse_half_lives(tsv: &str) -> BTreeMap<u32, f64> {
200    tsv.lines()
201        .filter(|line| !line.is_empty() && !line.starts_with('#'))
202        .filter_map(|line| {
203            let mut cols = line.split('\t');
204            let name = cols.next()?;
205            let seconds: f64 = cols.next()?.parse().ok()?;
206            let nucid = NuclideId::from_name(name).ok()?;
207            Some((nucid.nucid(), seconds))
208        })
209        .collect()
210}
211
212fn masses() -> &'static BTreeMap<u32, f64> {
213    MASSES.get_or_init(|| parse_masses(AME2020_TSV))
214}
215
216fn abundances() -> &'static BTreeMap<u32, f64> {
217    ABUNDANCES.get_or_init(|| parse_abundances(NATURAL_ABUNDANCE_TSV))
218}
219
220fn half_lives() -> &'static BTreeMap<u32, f64> {
221    HALF_LIVES.get_or_init(|| parse_half_lives(HALF_LIFE_TSV))
222}
223
224/// Parse `GNDS name \t thermal_barn \t fast14mev_barn` rows into
225/// nucid-keyed `(thermal, fast)` pairs.
226///
227/// Malformed rows are skipped silently, matching [`parse_masses`].
228fn parse_simple_xs(tsv: &str) -> BTreeMap<u32, (f64, f64)> {
229    tsv.lines()
230        .filter(|line| !line.is_empty() && !line.starts_with('#'))
231        .filter_map(|line| {
232            let mut cols = line.split('\t');
233            let name = cols.next()?;
234            let thermal: f64 = cols.next()?.parse().ok()?;
235            let fast: f64 = cols.next()?.parse().ok()?;
236            let nucid = NuclideId::from_name(name).ok()?;
237            Some((nucid.nucid(), (thermal, fast)))
238        })
239        .collect()
240}
241
242/// Parse `GNDS name \t b_coherent_fm \t b_incoherent_fm` rows into
243/// nucid-keyed `(coherent, incoherent)` pairs.
244///
245/// Malformed rows are skipped silently, matching [`parse_masses`].
246fn parse_scattering_lengths(tsv: &str) -> BTreeMap<u32, (f64, f64)> {
247    tsv.lines()
248        .filter(|line| !line.is_empty() && !line.starts_with('#'))
249        .filter_map(|line| {
250            let mut cols = line.split('\t');
251            let name = cols.next()?;
252            let coherent: f64 = cols.next()?.parse().ok()?;
253            let incoherent: f64 = cols.next()?.parse().ok()?;
254            let nucid = NuclideId::from_name(name).ok()?;
255            Some((nucid.nucid(), (coherent, incoherent)))
256        })
257        .collect()
258}
259
260/// Parse `GNDS name \t mev_per_decay` rows into nucid-keyed MeV.
261///
262/// Malformed rows are skipped silently, matching [`parse_masses`].
263fn parse_decay_energies(tsv: &str) -> BTreeMap<u32, f64> {
264    tsv.lines()
265        .filter(|line| !line.is_empty() && !line.starts_with('#'))
266        .filter_map(|line| {
267            let mut cols = line.split('\t');
268            let name = cols.next()?;
269            let mev: f64 = cols.next()?.parse().ok()?;
270            let nucid = NuclideId::from_name(name).ok()?;
271            Some((nucid.nucid(), mev))
272        })
273        .collect()
274}
275
276fn simple_xs_map() -> &'static BTreeMap<u32, (f64, f64)> {
277    SIMPLE_XS.get_or_init(|| parse_simple_xs(SIMPLE_XS_TSV))
278}
279
280fn scattering_length_map() -> &'static BTreeMap<u32, (f64, f64)> {
281    SCATTERING_LENGTHS.get_or_init(|| parse_scattering_lengths(SCATTERING_LENGTHS_TSV))
282}
283
284fn decay_energy_map() -> &'static BTreeMap<u32, f64> {
285    DECAY_ENERGIES.get_or_init(|| parse_decay_energies(DECAY_ENERGY_TSV))
286}
287
288/// The full AME2020 ground-state mass table, keyed by nucid (in u).
289pub fn mass_table() -> &'static BTreeMap<u32, f64> {
290    masses()
291}
292
293/// The full natural-abundance table, keyed by nucid (fraction in 0..1).
294pub fn abundance_table() -> &'static BTreeMap<u32, f64> {
295    abundances()
296}
297
298/// The full half-life table in seconds, keyed by nucid.
299pub fn half_life_table() -> &'static BTreeMap<u32, f64> {
300    half_lives()
301}
302
303/// The full simple cross-section table, keyed by nucid:
304/// `(thermal_barn, fast14mev_barn)` total cross sections.
305pub fn simple_xs_table() -> &'static BTreeMap<u32, (f64, f64)> {
306    simple_xs_map()
307}
308
309/// The full bound scattering-length table, keyed by nucid:
310/// `(b_coherent_fm, b_incoherent_fm)`.
311pub fn scattering_length_table() -> &'static BTreeMap<u32, (f64, f64)> {
312    scattering_length_map()
313}
314
315/// The full mean-recoverable-decay-energy table in MeV, keyed by nucid.
316pub fn decay_energy_table() -> &'static BTreeMap<u32, f64> {
317    decay_energy_map()
318}
319
320/// Total microscopic cross sections `(thermal_barn, fast14mev_barn)` for the
321/// given `nucid`.
322///
323/// Screening-level values (see [`simple_xs_table`]); returns `None` for
324/// nuclides outside the table — notably resonance absorbers without a NIST
325/// row (Cs137, Co60, I135, Xe135) and isomers.
326pub fn simple_xs(nucid: u32) -> Option<(f64, f64)> {
327    simple_xs_map().get(&nucid).copied()
328}
329
330/// Approximate total microscopic cross sections of a named nuclide (see
331/// [`NuclideId::from_name`]): `(thermal_barn, fast14mev_barn)`.
332pub fn simple_xs_by_name(name: &str) -> Option<(f64, f64)> {
333    simple_xs(NuclideId::from_name(name).ok()?.nucid())
334}
335
336/// Bound neutron scattering lengths `(b_coherent_fm, b_incoherent_fm)` for
337/// the given `nucid`.
338///
339/// Returns `None` for nuclides outside the NIST-tabulated set (see
340/// [`scattering_length_table`]).
341pub fn scattering_length(nucid: u32) -> Option<(f64, f64)> {
342    scattering_length_map().get(&nucid).copied()
343}
344
345/// Bound neutron scattering lengths of a named nuclide (see
346/// [`NuclideId::from_name`]): `(b_coherent_fm, b_incoherent_fm)`.
347pub fn scattering_length_by_name(name: &str) -> Option<(f64, f64)> {
348    scattering_length(NuclideId::from_name(name).ok()?.nucid())
349}
350
351/// Mean prompt recoverable decay energy per decay of the given `nucid`, in
352/// MeV (see [`decay_energy_table`]).
353///
354/// Returns `None` for nuclides outside the table, including stable nuclides
355/// such as O16 and Fe56.
356pub fn decay_energy_mev(nucid: u32) -> Option<f64> {
357    decay_energy_map().get(&nucid).copied()
358}
359
360/// Mean recoverable decay energy per decay of a named nuclide (see
361/// [`NuclideId::from_name`]), in MeV.
362pub fn decay_energy_mev_by_name(name: &str) -> Option<f64> {
363    decay_energy_mev(NuclideId::from_name(name).ok()?.nucid())
364}
365
366// ---------------------------------------------------------------------------
367// Decay branches
368// ---------------------------------------------------------------------------
369
370/// One decay mode token, matching the depletion-chain vocabulary (`beta-`,
371/// `ec/beta+`, `alpha`, `IT`, `sf`) plus the direct nucleon-emission modes
372/// (`n`, `p`) for particle-unbound light nuclei.
373///
374/// EC-only and beta+/EC evaluations both fold into [`Self::EcBetaPlus`].
375/// `sf` never appears in the vendored table (fission branches are dropped
376/// at generation) but parses for robustness.
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
378pub enum DecayBranchMode {
379    /// Negative beta decay (Z+1 daughter, incl. delayed-neutron branches).
380    BetaMinus,
381    /// Electron capture and/or positron emission (Z-1 daughter).
382    EcBetaPlus,
383    /// Alpha decay (Z-2/A-4 daughter).
384    Alpha,
385    /// Isomeric transition (same Z/A daughter).
386    It,
387    /// Spontaneous fission (dropped at generation; parses only).
388    Sf,
389    /// Direct neutron emission (A-1 daughter).
390    Neutron,
391    /// Direct proton emission (Z-1/A-1 daughter).
392    Proton,
393}
394
395impl DecayBranchMode {
396    /// Canonical table token (`beta-`/`ec/beta+`/`alpha`/`IT`/`sf`/`n`/`p`).
397    pub fn as_str(self) -> &'static str {
398        match self {
399            Self::BetaMinus => "beta-",
400            Self::EcBetaPlus => "ec/beta+",
401            Self::Alpha => "alpha",
402            Self::It => "IT",
403            Self::Sf => "sf",
404            Self::Neutron => "n",
405            Self::Proton => "p",
406        }
407    }
408
409    /// Parse a mode token (case-insensitive).
410    pub fn parse(s: &str) -> Option<Self> {
411        match s.trim().to_ascii_lowercase().as_str() {
412            "beta-" | "beta" | "b-" => Some(Self::BetaMinus),
413            "ec/beta+" | "ec" | "beta+" => Some(Self::EcBetaPlus),
414            "alpha" | "a" => Some(Self::Alpha),
415            "it" => Some(Self::It),
416            "sf" => Some(Self::Sf),
417            "n" => Some(Self::Neutron),
418            "p" => Some(Self::Proton),
419            _ => None,
420        }
421    }
422}
423
424/// One evaluated decay branch: daughter nuclide, branching fraction, mode.
425#[derive(Debug, Clone, Copy, PartialEq)]
426pub struct DecayBranch {
427    /// Daughter nucid (full state-bearing id; RFS sets the state).
428    pub progeny: u32,
429    /// Branching fraction in 0..1 (evaluated value, verbatim).
430    pub branching_fraction: f64,
431    /// Decay mode token (initial event for multi-particle branches).
432    pub mode: DecayBranchMode,
433}
434
435/// Parse `parent_GNDS \t progeny_GNDS \t bf \t mode` rows into
436/// parent-nucid-keyed branch lists.
437///
438/// Malformed rows are skipped silently, matching [`parse_masses`].
439fn parse_decay_branches(tsv: &str) -> BTreeMap<u32, Vec<DecayBranch>> {
440    let mut map: BTreeMap<u32, Vec<DecayBranch>> = BTreeMap::new();
441    for line in tsv
442        .lines()
443        .filter(|line| !line.is_empty() && !line.starts_with('#'))
444    {
445        let mut cols = line.split('\t');
446        let (Some(parent), Some(progeny), Some(bf), Some(mode)) =
447            (cols.next(), cols.next(), cols.next(), cols.next())
448        else {
449            continue;
450        };
451        let (Ok(p), Ok(d), Ok(b), Some(m)) = (
452            NuclideId::from_name(parent).map(|id| id.nucid()),
453            NuclideId::from_name(progeny).map(|id| id.nucid()),
454            bf.parse::<f64>(),
455            DecayBranchMode::parse(mode),
456        ) else {
457            continue;
458        };
459        map.entry(p).or_default().push(DecayBranch {
460            progeny: d,
461            branching_fraction: b,
462            mode: m,
463        });
464    }
465    for branches in map.values_mut() {
466        branches.sort_by_key(|b| (b.progeny, b.mode));
467    }
468    map
469}
470
471fn decay_branch_map() -> &'static BTreeMap<u32, Vec<DecayBranch>> {
472    DECAY_BRANCHES.get_or_init(|| parse_decay_branches(DECAY_BRANCHES_TSV))
473}
474
475/// The full decay-branch table, keyed by parent nucid.
476///
477/// 5 068 rows over 3 541 parents from ENDF/B-VIII.0; SF/fission branches
478/// are dropped, so strong SF emitters sum to `1 - BR(SF)`.
479pub fn decay_branch_table() -> &'static BTreeMap<u32, Vec<DecayBranch>> {
480    decay_branch_map()
481}
482
483/// Evaluated decay branches of the given parent `nucid`.
484///
485/// Returns `None` for nuclides with no branch rows (stable nuclides and
486/// zero-half-life evaluation dummies such as Te123).
487pub fn decay_branches(nucid: u32) -> Option<Vec<DecayBranch>> {
488    decay_branch_map().get(&nucid).cloned()
489}
490
491/// Evaluated decay branches of a named nuclide (see [`NuclideId::from_name`]).
492pub fn decay_branches_by_name(name: &str) -> Option<Vec<DecayBranch>> {
493    decay_branches(NuclideId::from_name(name).ok()?.nucid())
494}
495
496/// Branching fraction from `parent` to `progeny` (both nucids), if tabulated.
497pub fn branching_fraction(parent: u32, progeny: u32) -> Option<f64> {
498    decay_branch_map()
499        .get(&parent)?
500        .iter()
501        .find(|b| b.progeny == progeny)
502        .map(|b| b.branching_fraction)
503}
504
505/// Branching fraction from `parent` to `progeny` (GNDS names), if tabulated.
506pub fn branching_fraction_by_name(parent: &str, progeny: &str) -> Option<f64> {
507    branching_fraction(
508        NuclideId::from_name(parent).ok()?.nucid(),
509        NuclideId::from_name(progeny).ok()?.nucid(),
510    )
511}
512
513// ---------------------------------------------------------------------------
514// Fission product yields
515// ---------------------------------------------------------------------------
516
517/// Fission-yield origin: neutron-induced or spontaneous fission.
518///
519/// Tokens (`n`/`sf`) match the depletion-chain decay vocabulary (`sf` is the
520/// spontaneous-fission mode token).
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
522pub enum FissionYieldOrigin {
523    /// Neutron-induced fission (the ENDF `nfy` tapes).
524    #[default]
525    NeutronInduced,
526    /// Spontaneous fission (the ENDF `sfy` tapes).
527    Spontaneous,
528}
529
530impl FissionYieldOrigin {
531    /// Canonical table token (`n`/`sf`).
532    pub fn as_str(self) -> &'static str {
533        match self {
534            Self::NeutronInduced => "n",
535            Self::Spontaneous => "sf",
536        }
537    }
538
539    /// Parse an origin token (case-insensitive; `n`/`neutron`/`sf`/
540    /// `spontaneous`).
541    pub fn parse(s: &str) -> Option<Self> {
542        match s.trim().to_ascii_lowercase().as_str() {
543            "n" | "neutron" | "neutron-induced" => Some(Self::NeutronInduced),
544            "sf" | "spontaneous" => Some(Self::Spontaneous),
545            _ => None,
546        }
547    }
548}
549
550/// Fission-yield kind: independent or cumulative product yields.
551///
552/// Independent yields (MF8/MT454) are what depletion matrices consume;
553/// cumulative yields (MF8/MT459) add the precursor decay-chain feed.
554#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
555pub enum FissionYieldKind {
556    /// Independent fission product yields (MF8/MT454).
557    #[default]
558    Independent,
559    /// Cumulative fission product yields (MF8/MT459).
560    Cumulative,
561}
562
563impl FissionYieldKind {
564    /// Canonical table token (`independent`/`cumulative`).
565    pub fn as_str(self) -> &'static str {
566        match self {
567            Self::Independent => "independent",
568            Self::Cumulative => "cumulative",
569        }
570    }
571
572    /// Parse a kind token (case-insensitive).
573    pub fn parse(s: &str) -> Option<Self> {
574        match s.trim().to_ascii_lowercase().as_str() {
575            "independent" | "i" => Some(Self::Independent),
576            "cumulative" | "c" => Some(Self::Cumulative),
577            _ => None,
578        }
579    }
580}
581
582/// One evaluated fission-product row: daughter, yield fraction, uncertainty.
583#[derive(Debug, Clone, Copy, PartialEq)]
584pub struct FissionYieldProduct {
585    /// Daughter nucid (full state-bearing id; the tape's FPS flag sets the
586    /// state, so isomeric products carry `_m1`/`_m2` ids).
587    pub progeny: u32,
588    /// Yield fraction Y (evaluated value, verbatim from the tape).
589    pub yield_fraction: f64,
590    /// Evaluated 1-sigma uncertainty dY \[fraction\].
591    ///
592    /// `0.0` is the no-uncertainty sentinel: in this sublibrary it occurs
593    /// exactly on the zero-yield rows, so consumers needing an uncertainty
594    /// should read `0.0` as "not evaluated", never as a zero-width
595    /// distribution.
596    pub uncertainty: f64,
597}
598
599/// Fission product yields at one incident neutron energy.
600#[derive(Debug, Clone, PartialEq)]
601pub struct FissionYieldSet {
602    /// Incident neutron energy \[eV\]; `0.0` for spontaneous fission.
603    pub energy_ev: f64,
604    /// Product rows of this energy set, in tape (ZAFP, then FPS) order.
605    pub products: Vec<FissionYieldProduct>,
606}
607
608/// Table key of [`fission_yield_table`]: parent nucid, origin, kind.
609type FissionYieldKey = (u32, FissionYieldOrigin, FissionYieldKind);
610
611/// Parse `parent_GNDS \t origin \t kind \t energy_eV \t daughter_GNDS \t Y \t dY`
612/// rows into `(parent, origin, kind)`-keyed energy-set lists.
613///
614/// Malformed rows are skipped silently, matching [`parse_masses`].  Sets are
615/// sorted by ascending incident energy (stable: tape product order is kept
616/// within a set).
617fn parse_fission_yields(tsv: &str) -> BTreeMap<FissionYieldKey, Vec<FissionYieldSet>> {
618    let mut sets: BTreeMap<FissionYieldKey, Vec<(f64, usize, FissionYieldProduct)>> =
619        BTreeMap::new();
620    for line in tsv
621        .lines()
622        .filter(|line| !line.is_empty() && !line.starts_with('#'))
623    {
624        let mut cols = line.split('\t');
625        let (
626            Some(parent),
627            Some(origin),
628            Some(kind),
629            Some(energy),
630            Some(daughter),
631            Some(y),
632            Some(dy),
633        ) = (
634            cols.next(),
635            cols.next(),
636            cols.next(),
637            cols.next(),
638            cols.next(),
639            cols.next(),
640            cols.next(),
641        )
642        else {
643            continue;
644        };
645        let (Ok(p), Some(o), Some(k), Ok(e), Ok(d), Ok(y), Ok(dy)) = (
646            NuclideId::from_name(parent).map(|id| id.nucid()),
647            FissionYieldOrigin::parse(origin),
648            FissionYieldKind::parse(kind),
649            energy.parse::<f64>(),
650            NuclideId::from_name(daughter).map(|id| id.nucid()),
651            y.parse::<f64>(),
652            dy.parse::<f64>(),
653        ) else {
654            continue;
655        };
656        let entry = sets.entry((p, o, k)).or_default();
657        entry.push((
658            e,
659            entry.len(),
660            FissionYieldProduct {
661                progeny: d,
662                yield_fraction: y,
663                uncertainty: dy,
664            },
665        ));
666    }
667    sets.into_iter()
668        .map(|(key, mut rows)| {
669            rows.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
670            (
671                key,
672                rows.into_iter()
673                    .fold(Vec::<FissionYieldSet>::new(), |mut acc, (e, _i, prod)| {
674                        match acc.last_mut() {
675                            Some(set) if set.energy_ev == e => set.products.push(prod),
676                            _ => acc.push(FissionYieldSet {
677                                energy_ev: e,
678                                products: vec![prod],
679                            }),
680                        }
681                        acc
682                    }),
683            )
684        })
685        .collect()
686}
687
688fn fission_yield_map() -> &'static BTreeMap<FissionYieldKey, Vec<FissionYieldSet>> {
689    FISSION_YIELDS.get_or_init(|| parse_fission_yields(FISSION_YIELDS_TSV))
690}
691
692/// The full fission-yield table, keyed by `(parent nucid, origin, kind)`.
693///
694/// 151 490 rows over 36 parents and 122 incident-energy sets from the
695/// ENDF/B-VIII.0 fission-yield sublibraries (unchanged carries of
696/// ENDF/B-VII.1 evaluations per the tapes' README.txt); each value is the
697/// list of energy sets, sorted by ascending incident energy.
698pub fn fission_yield_table() -> &'static BTreeMap<FissionYieldKey, Vec<FissionYieldSet>> {
699    fission_yield_map()
700}
701
702/// Evaluated fission product yields of the given parent `nucid`.
703///
704/// Returns the energy-set list for the requested `origin` and `kind`, or
705/// `None` when the parent has no such evaluation (e.g. non-fissionable
706/// nuclides).  The lowest-energy set of the `Independent` × `NeutronInduced`
707/// block is the depletion convention — see [`default_fission_yields`].
708pub fn fission_yields(
709    parent: u32,
710    origin: FissionYieldOrigin,
711    kind: FissionYieldKind,
712) -> Option<Vec<FissionYieldSet>> {
713    fission_yield_map().get(&(parent, origin, kind)).cloned()
714}
715
716/// Evaluated fission product yields of a named nuclide (see
717/// [`NuclideId::from_name`]).
718pub fn fission_yields_by_name(
719    parent: &str,
720    origin: FissionYieldOrigin,
721    kind: FissionYieldKind,
722) -> Option<Vec<FissionYieldSet>> {
723    fission_yields(NuclideId::from_name(parent).ok()?.nucid(), origin, kind)
724}
725
726/// Lowest-energy independent neutron-induced yield set of the given parent
727/// `nucid`.
728///
729/// This is the depletion-chain convention — OpenMC's
730/// `get_default_fission_yields` drives fission production with the yield
731/// set at the lowest incident neutron energy.  Returns `None` when the
732/// parent has no neutron-induced independent evaluation (e.g. `Cm247`).
733pub fn default_fission_yields(parent: u32) -> Option<FissionYieldSet> {
734    fission_yield_map()
735        .get(&(
736            parent,
737            FissionYieldOrigin::NeutronInduced,
738            FissionYieldKind::Independent,
739        ))?
740        .first()
741        .cloned()
742}
743
744/// Lowest-energy independent neutron-induced yield set of a named nuclide
745/// (see [`default_fission_yields`]).
746pub fn default_fission_yields_by_name(parent: &str) -> Option<FissionYieldSet> {
747    default_fission_yields(NuclideId::from_name(parent).ok()?.nucid())
748}
749
750/// Independent neutron-induced yield of one daughter at the parent's
751/// lowest-energy set (see [`default_fission_yields`]).
752///
753/// Mirrors [`branching_fraction`]: the bare fraction; the uncertainty and
754/// the other energy sets are available through [`fission_yields`].
755/// Returns `None` when either nuclide is outside the table.
756pub fn fission_yield(parent: u32, progeny: u32) -> Option<f64> {
757    default_fission_yields(parent)?
758        .products
759        .iter()
760        .find(|p| p.progeny == progeny)
761        .map(|p| p.yield_fraction)
762}
763
764/// Independent neutron-induced yield of one daughter (GNDS names) at the
765/// parent's lowest-energy set.
766pub fn fission_yield_by_name(parent: &str, progeny: &str) -> Option<f64> {
767    fission_yield(
768        NuclideId::from_name(parent).ok()?.nucid(),
769        NuclideId::from_name(progeny).ok()?.nucid(),
770    )
771}
772
773// ---------------------------------------------------------------------------
774// Dose factors
775// ---------------------------------------------------------------------------
776
777/// Dose pathway: external air/soil, ingestion, or inhalation.
778///
779/// `Air` is `ext_air` in PyNE (`mrem/h per Ci/m^3`); `Soil` is `ext_soil`
780/// (`mrem/h per Ci/m^2`, 15 cm slab); `Ingest`/`Inhale` are `mrem/pCi`.
781#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
782pub enum DosePathway {
783    /// External dose from air submersion.
784    Air,
785    /// External dose from a 15 cm soil slab.
786    Soil,
787    /// Committed dose from ingestion.
788    Ingest,
789    /// Committed dose from inhalation.
790    Inhale,
791}
792
793impl DosePathway {
794    /// Canonical lowercase name (`air`/`soil`/`ingest`/`inhale`).
795    pub fn as_str(self) -> &'static str {
796        match self {
797            Self::Air => "air",
798            Self::Soil => "soil",
799            Self::Ingest => "ingest",
800            Self::Inhale => "inhale",
801        }
802    }
803
804    /// Parse a pathway name (case-insensitive; `ext_air`/`ext_soil` aliases
805    /// accepted for PyNE compatibility).
806    pub fn parse(s: &str) -> Option<Self> {
807        match s.trim().to_ascii_lowercase().as_str() {
808            "air" | "ext_air" | "ext-air" => Some(Self::Air),
809            "soil" | "ext_soil" | "ext-soil" => Some(Self::Soil),
810            "ingest" | "ingestion" => Some(Self::Ingest),
811            "inhale" | "inhalation" => Some(Self::Inhale),
812            _ => None,
813        }
814    }
815}
816
817/// Dose-factor evaluation source: the three parallel HNF-5636 evaluations.
818///
819/// PyNE source ids are 0 = EPA (default), 1 = DOE, 2 = GENII.
820#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
821pub enum DoseSource {
822    /// EPA evaluation (PyNE source id 0, the default).
823    Epa,
824    /// DOE evaluation (PyNE source id 1).
825    Doe,
826    /// GENII evaluation (PyNE source id 2).
827    Genii,
828}
829
830impl DoseSource {
831    /// Canonical uppercase name (`EPA`/`DOE`/`GENII`).
832    pub fn as_str(self) -> &'static str {
833        match self {
834            Self::Epa => "EPA",
835            Self::Doe => "DOE",
836            Self::Genii => "GENII",
837        }
838    }
839
840    /// Parse a source name (case-insensitive).
841    pub fn parse(s: &str) -> Option<Self> {
842        match s.trim().to_ascii_uppercase().as_str() {
843            "EPA" => Some(Self::Epa),
844            "DOE" => Some(Self::Doe),
845            "GENII" => Some(Self::Genii),
846            _ => None,
847        }
848    }
849
850    /// PyNE source id (0 = EPA default, 1 = DOE, 2 = GENII).
851    pub fn to_int(self) -> u8 {
852        match self {
853            Self::Epa => 0,
854            Self::Doe => 1,
855            Self::Genii => 2,
856        }
857    }
858
859    /// Inverse of [`DoseSource::to_int`]; anything else is `None`.
860    pub fn from_int(v: u8) -> Option<Self> {
861        match v {
862            0 => Some(Self::Epa),
863            1 => Some(Self::Doe),
864            2 => Some(Self::Genii),
865            _ => None,
866        }
867    }
868}
869
870/// One dose-factor row: the table factor plus aux columns.
871///
872/// `factor` is the raw table value (`-1` for GENII/DOE air, matching PyNE's
873/// missing-air sentinel); `f1` (fraction to body fluids) is set only on
874/// ingest rows; `lung_model` (`D`/`W`/`Y`/`V`/`O`) only on inhale rows.
875#[derive(Debug, Clone, Copy, PartialEq)]
876pub struct DoseEntry {
877    /// Raw table factor (see [`DosePathway`] for units).
878    pub factor: f64,
879    /// `f1` aux column (ingest rows only).
880    pub f1: Option<f64>,
881    /// Lung-model aux column (inhale rows only).
882    pub lung_model: Option<char>,
883}
884
885/// Parse `GNDS name \t pathway \t source \t factor [\t f1 [\t lung]]` rows.
886///
887/// Malformed rows are skipped silently, matching [`parse_masses`].
888fn parse_dose_factors(tsv: &str) -> BTreeMap<(u32, DosePathway, DoseSource), DoseEntry> {
889    tsv.lines()
890        .filter(|line| !line.is_empty() && !line.starts_with('#'))
891        .filter_map(|line| {
892            let mut cols = line.split('\t');
893            let name = cols.next()?;
894            let pathway = DosePathway::parse(cols.next()?)?;
895            let source = DoseSource::parse(cols.next()?)?;
896            let factor: f64 = cols.next()?.parse().ok()?;
897            // Trailing aux columns may be absent after `strip`-style writes;
898            // missing means None.
899            let f1_txt = cols.next().unwrap_or("");
900            let lung_txt = cols.next().unwrap_or("");
901            let f1 = if f1_txt.trim().is_empty() {
902                None
903            } else {
904                Some(f1_txt.parse().ok()?)
905            };
906            let lung_model = {
907                let t = lung_txt.trim();
908                if t.is_empty() {
909                    None
910                } else {
911                    t.chars().next()
912                }
913            };
914            let nucid = NuclideId::from_name(name).ok()?;
915            Some((
916                (nucid.nucid(), pathway, source),
917                DoseEntry {
918                    factor,
919                    f1,
920                    lung_model,
921                },
922            ))
923        })
924        .collect()
925}
926
927fn dose_factor_map() -> &'static BTreeMap<(u32, DosePathway, DoseSource), DoseEntry> {
928    DOSE_FACTORS.get_or_init(|| parse_dose_factors(DOSE_FACTORS_TSV))
929}
930
931/// The full dose-factor table, keyed by `(nucid, pathway, source)`.
932///
933/// 1 116 rows (93 folded nuclides × 4 pathways × 3 sources).
934pub fn dose_table() -> &'static BTreeMap<(u32, DosePathway, DoseSource), DoseEntry> {
935    dose_factor_map()
936}
937
938/// Full dose-factor entry for the given `nucid`, `pathway`, and `source`.
939///
940/// Returns the stored `-1` sentinel for GENII/DOE air (PyNE convention for
941/// missing air data); `None` only when the nuclide has no row at all.
942pub fn dose_entry(nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<DoseEntry> {
943    dose_factor_map().get(&(nucid, pathway, source)).copied()
944}
945
946/// Raw dose factor for the given `nucid`, `pathway`, and `source`.
947///
948/// See [`dose_entry`]: GENII/DOE air resolve to `Some(-1.0)` (missing-air
949/// sentinel); unknown nuclides resolve to `None` (PyNE's `-1` unknown
950/// sentinel maps to `None` here).
951pub fn dose_factor(nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
952    dose_entry(nucid, pathway, source).map(|e| e.factor)
953}
954
955/// Raw dose factor for a named nuclide (see [`NuclideId::from_name`]).
956pub fn dose_factor_by_name(name: &str, pathway: DosePathway, source: DoseSource) -> Option<f64> {
957    dose_factor(NuclideId::from_name(name).ok()?.nucid(), pathway, source)
958}
959
960/// `f1` (fraction to body fluids) for an ingest row, if present.
961pub fn dose_f1(nucid: u32, source: DoseSource) -> Option<f64> {
962    dose_entry(nucid, DosePathway::Ingest, source)?.f1
963}
964
965/// `f1` for a named nuclide's ingest row.
966pub fn dose_f1_by_name(name: &str, source: DoseSource) -> Option<f64> {
967    dose_f1(NuclideId::from_name(name).ok()?.nucid(), source)
968}
969
970/// Lung model (`D`/`W`/`Y`/`V`/`O`) for an inhale row, if present.
971pub fn dose_lung_model(nucid: u32, source: DoseSource) -> Option<char> {
972    dose_entry(nucid, DosePathway::Inhale, source)?.lung_model
973}
974
975/// Lung model for a named nuclide's inhale row.
976pub fn dose_lung_model_by_name(name: &str, source: DoseSource) -> Option<char> {
977    dose_lung_model(NuclideId::from_name(name).ok()?.nucid(), source)
978}
979
980/// Atomic mass of the nuclide with the given `nucid`, in u.
981///
982/// Ground states resolve to the AME2020 value. Metastable states resolve to
983/// their ENDF-derived isomer mass (`m_ground + E*/931.49410242 u`) when the
984/// isomer has its own tape, and fall back to the ground-state mass otherwise
985/// (the missing excitation energy is at most MeV against a GeV-scale mass).
986/// Returns `None` for unknown nuclides and non-nuclide ids (`Z = 0`).
987pub fn atomic_mass(nucid: u32) -> Option<f64> {
988    if let Some(mass) = masses().get(&nucid) {
989        return Some(*mass);
990    }
991    let id = NuclideId::from_nucid(nucid);
992    if id.state() != 0 {
993        let ground = (id.z() * 1000 + id.a()) * 10_000;
994        return masses().get(&ground).copied();
995    }
996    None
997}
998
999/// Atomic mass of a named nuclide (see [`NuclideId::from_name`]), in u.
1000pub fn atomic_mass_by_name(name: &str) -> Option<f64> {
1001    atomic_mass(NuclideId::from_name(name).ok()?.nucid())
1002}
1003
1004/// Natural abundance of the nuclide with the given `nucid`, as a fraction
1005/// in 0..1.
1006///
1007/// Only naturally occurring nuclides have entries, including the isomer
1008/// Ta180_m1; everything else returns `None`.
1009pub fn natural_abundance(nucid: u32) -> Option<f64> {
1010    abundances().get(&nucid).copied()
1011}
1012
1013/// Natural abundance of a named nuclide, as a fraction in 0..1.
1014pub fn natural_abundance_by_name(name: &str) -> Option<f64> {
1015    natural_abundance(NuclideId::from_name(name).ok()?.nucid())
1016}
1017
1018/// Radioactive half-life of the nuclide with the given `nucid`, in seconds.
1019///
1020/// Values come from the ENDF/B-VIII.0-derived evaluation; stable
1021/// nuclides and unknown ids have no entry and return `None`.
1022pub fn half_life(nucid: u32) -> Option<f64> {
1023    half_lives().get(&nucid).copied()
1024}
1025
1026/// Half-life in seconds of a named nuclide (see [`NuclideId::from_name`]).
1027pub fn half_life_by_name(name: &str) -> Option<f64> {
1028    half_life(NuclideId::from_name(name).ok()?.nucid())
1029}
1030
1031/// Decay constant λ = ln(2) / t½ of the given `nucid`, in inverse seconds.
1032///
1033/// Computed from [`half_life`]; `None` wherever the half-life is unknown.
1034pub fn decay_constant(nucid: u32) -> Option<f64> {
1035    half_life(nucid).map(|t_half| std::f64::consts::LN_2 / t_half)
1036}
1037
1038/// Decay constant λ = ln(2) / t½ (inverse seconds) of a named nuclide.
1039pub fn decay_constant_by_name(name: &str) -> Option<f64> {
1040    decay_constant(NuclideId::from_name(name).ok()?.nucid())
1041}
1042
1043/// Q-value of neutron radiative capture X(n,γ)X', in MeV.
1044///
1045/// Ground-state-only by design: the formula combines tabulated atomic
1046/// masses per the textbook definition, and isomer-resolved Q-values would
1047/// need explicit excitation-energy bookkeeping beyond this
1048/// screening-level scope (metastable-state and non-nuclide inputs return
1049/// `None` even though [`atomic_mass`] now resolves isomers).
1050///
1051/// From atomic masses (AME2020, in u):
1052///
1053/// ```text
1054/// Q = [m(X + A) + m_n − m(X' = Z, A+1)] · c²   (MeV/u × 931.49410242)
1055/// ```
1056///
1057/// using the AME2020 free-neutron mass ([`NEUTRON_MASS_U`]). Electron
1058/// binding differences are neglected (standard for capture Q-values).
1059/// Returns `None` when either ground-state mass is missing, or for
1060/// metastable-state or non-nuclide inputs.
1061pub fn q_value_neutron_capture(nucid: u32) -> Option<f64> {
1062    let id = NuclideId::from_nucid(nucid);
1063    if id.state() != 0 || id.z() == 0 {
1064        return None;
1065    }
1066    let product = (id.z() * 1000 + id.a() + 1) * 10_000;
1067    let q_u = atomic_mass(nucid)? + NEUTRON_MASS_U - atomic_mass(product)?;
1068    Some(q_u * MEV_PER_U)
1069}
1070
1071/// Q-value of neutron capture on a named nuclide, in MeV.
1072pub fn q_value_neutron_capture_by_name(name: &str) -> Option<f64> {
1073    q_value_neutron_capture(NuclideId::from_name(name).ok()?.nucid())
1074}
1075
1076/// Q-value of alpha decay X → Y + He4, in MeV.
1077///
1078/// Ground-state-only by design, like [`q_value_neutron_capture`]:
1079/// metastable and non-nuclide inputs return `None`.
1080///
1081/// From atomic masses (AME2020, in u):
1082///
1083/// ```text
1084/// Q = [m(Z,A) − m(Z−2,A−4) − m(He4)] · c²   (MeV/u × 931.49410242)
1085/// ```
1086///
1087/// with [`HELIUM4_MASS_U`] = 4.00260325413 u. Using neutral atomic masses,
1088/// the two released electrons cancel exactly to first order. Returns `None`
1089/// when the daughter mass is absent from the table, or for metastable or
1090/// non-nuclide inputs.
1091pub fn q_value_alpha(nucid: u32) -> Option<f64> {
1092    let id = NuclideId::from_nucid(nucid);
1093    if id.state() != 0 || id.z() <= 2 || id.a() <= 4 {
1094        return None;
1095    }
1096    let daughter = ((id.z() - 2) * 1000 + (id.a() - 4)) * 10_000;
1097    let q_u = atomic_mass(nucid)? - atomic_mass(daughter)? - HELIUM4_MASS_U;
1098    Some(q_u * MEV_PER_U)
1099}
1100
1101/// Q-value of alpha decay on a named nuclide, in MeV.
1102pub fn q_value_alpha_by_name(name: &str) -> Option<f64> {
1103    q_value_alpha(NuclideId::from_name(name).ok()?.nucid())
1104}
1105
1106/// Zero-sized façade over this module's lookups.
1107///
1108/// Standalone today; intended to satisfy the material crate's future
1109/// `MassProvider` trait once that integration lands (kept free of cross-crate
1110/// coupling for now).
1111#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1112pub struct AmeMasses;
1113
1114impl AmeMasses {
1115    /// See [`atomic_mass`].
1116    pub fn atomic_mass(&self, nucid: u32) -> Option<f64> {
1117        atomic_mass(nucid)
1118    }
1119
1120    /// See [`atomic_mass_by_name`].
1121    pub fn atomic_mass_by_name(&self, name: &str) -> Option<f64> {
1122        atomic_mass_by_name(name)
1123    }
1124
1125    /// See [`natural_abundance`].
1126    pub fn natural_abundance(&self, nucid: u32) -> Option<f64> {
1127        natural_abundance(nucid)
1128    }
1129
1130    /// See [`natural_abundance_by_name`].
1131    pub fn natural_abundance_by_name(&self, name: &str) -> Option<f64> {
1132        natural_abundance_by_name(name)
1133    }
1134}
1135
1136/// Zero-sized façade over the decay-data lookups ([`half_life`],
1137/// [`decay_constant`], [`decay_energy_mev`], [`decay_branches`],
1138/// [`branching_fraction`]).
1139///
1140/// Mirrors [`AmeMasses`]; standalone today, kept free of cross-crate
1141/// coupling for future provider-trait integration.
1142#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1143pub struct DecayData;
1144
1145impl DecayData {
1146    /// See [`half_life`].
1147    pub fn half_life(&self, nucid: u32) -> Option<f64> {
1148        half_life(nucid)
1149    }
1150
1151    /// See [`half_life_by_name`].
1152    pub fn half_life_by_name(&self, name: &str) -> Option<f64> {
1153        half_life_by_name(name)
1154    }
1155
1156    /// See [`decay_constant`].
1157    pub fn decay_constant(&self, nucid: u32) -> Option<f64> {
1158        decay_constant(nucid)
1159    }
1160
1161    /// See [`decay_constant_by_name`].
1162    pub fn decay_constant_by_name(&self, name: &str) -> Option<f64> {
1163        decay_constant_by_name(name)
1164    }
1165
1166    /// See [`decay_energy_mev`].
1167    pub fn decay_energy_mev(&self, nucid: u32) -> Option<f64> {
1168        decay_energy_mev(nucid)
1169    }
1170
1171    /// See [`decay_energy_mev_by_name`].
1172    pub fn decay_energy_mev_by_name(&self, name: &str) -> Option<f64> {
1173        decay_energy_mev_by_name(name)
1174    }
1175
1176    /// See [`decay_branches`].
1177    pub fn decay_branches(&self, nucid: u32) -> Option<Vec<DecayBranch>> {
1178        decay_branches(nucid)
1179    }
1180
1181    /// See [`decay_branches_by_name`].
1182    pub fn decay_branches_by_name(&self, name: &str) -> Option<Vec<DecayBranch>> {
1183        decay_branches_by_name(name)
1184    }
1185
1186    /// See [`branching_fraction`].
1187    pub fn branching_fraction(&self, parent: u32, progeny: u32) -> Option<f64> {
1188        branching_fraction(parent, progeny)
1189    }
1190
1191    /// See [`branching_fraction_by_name`].
1192    pub fn branching_fraction_by_name(&self, parent: &str, progeny: &str) -> Option<f64> {
1193        branching_fraction_by_name(parent, progeny)
1194    }
1195}
1196
1197/// Zero-sized façade over the dose-factor lookups ([`dose_factor`],
1198/// [`dose_f1`], [`dose_lung_model`]).
1199///
1200/// Mirrors [`DecayData`]; the material crate implements its `DoseProvider`
1201/// trait for this façade so analytics get dose factors with no circular deps.
1202#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1203pub struct DoseData;
1204
1205impl DoseData {
1206    /// See [`dose_entry`].
1207    pub fn dose_entry(
1208        &self,
1209        nucid: u32,
1210        pathway: DosePathway,
1211        source: DoseSource,
1212    ) -> Option<DoseEntry> {
1213        dose_entry(nucid, pathway, source)
1214    }
1215
1216    /// See [`dose_factor`].
1217    pub fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
1218        dose_factor(nucid, pathway, source)
1219    }
1220
1221    /// See [`dose_factor_by_name`].
1222    pub fn dose_factor_by_name(
1223        &self,
1224        name: &str,
1225        pathway: DosePathway,
1226        source: DoseSource,
1227    ) -> Option<f64> {
1228        dose_factor_by_name(name, pathway, source)
1229    }
1230
1231    /// See [`dose_f1`].
1232    pub fn dose_f1(&self, nucid: u32, source: DoseSource) -> Option<f64> {
1233        dose_f1(nucid, source)
1234    }
1235
1236    /// See [`dose_f1_by_name`].
1237    pub fn dose_f1_by_name(&self, name: &str, source: DoseSource) -> Option<f64> {
1238        dose_f1_by_name(name, source)
1239    }
1240
1241    /// See [`dose_lung_model`].
1242    pub fn dose_lung_model(&self, nucid: u32, source: DoseSource) -> Option<char> {
1243        dose_lung_model(nucid, source)
1244    }
1245
1246    /// See [`dose_lung_model_by_name`].
1247    pub fn dose_lung_model_by_name(&self, name: &str, source: DoseSource) -> Option<char> {
1248        dose_lung_model_by_name(name, source)
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255
1256    const H1: u32 = 10_010_000;
1257    const O16: u32 = 80_160_000;
1258    const FE56: u32 = 260_560_000;
1259    const U235: u32 = 922_350_000;
1260
1261    #[test]
1262    fn h1_exact_ame2020_value() {
1263        assert_eq!(atomic_mass(H1), Some(1.007825031_898));
1264        assert_eq!(atomic_mass_by_name("H1"), Some(1.007825031_898));
1265    }
1266
1267    #[test]
1268    fn heavy_nuclide_spot_values() {
1269        assert_eq!(atomic_mass(U235), Some(235.043_928_117));
1270        assert_eq!(atomic_mass(FE56), Some(55.934_935_537));
1271        assert_eq!(atomic_mass(O16), Some(15.994_914_619_26));
1272    }
1273
1274    #[test]
1275    fn non_nuclides_and_unknown_ids_return_none() {
1276        // Free neutron row (Z = 0) is excluded from the vendored table.
1277        assert_eq!(atomic_mass(10_000), None);
1278        assert_eq!(atomic_mass(999_999_999), None);
1279        // Isomers with their own ENDF tape resolve to the isomer mass;
1280        // isomers without one fall back to the ground-state mass.
1281        let ba_m1 = NuclideId::from_name("Ba137_m1").unwrap().nucid();
1282        assert!(atomic_mass(ba_m1).unwrap() > atomic_mass(561_370_000).unwrap());
1283        assert!(atomic_mass_by_name("U235_m1").unwrap() > atomic_mass(U235).unwrap());
1284        assert_eq!(
1285            atomic_mass_by_name("Pm137_m1"),
1286            atomic_mass_by_name("Pm137")
1287        );
1288    }
1289
1290    #[test]
1291    fn by_name_agrees_with_nucid_lookup() {
1292        for name in ["H1", "O16", "Fe56", "U235", "Og294"] {
1293            let nucid = NuclideId::from_name(name).unwrap().nucid();
1294            assert_eq!(atomic_mass_by_name(name), atomic_mass(nucid), "{name}");
1295        }
1296    }
1297
1298    #[test]
1299    fn natural_abundance_spot_values() {
1300        assert_eq!(natural_abundance(U235), Some(0.007_204));
1301        assert_eq!(natural_abundance_by_name("U235"), Some(0.007_204));
1302        assert_eq!(natural_abundance(O16), Some(0.997_620_6));
1303        assert_eq!(natural_abundance_by_name("H1"), Some(0.999_844_26));
1304        // The lone naturally occurring isomer.
1305        assert_eq!(natural_abundance_by_name("Ta180_m1"), Some(0.000_120_1));
1306    }
1307
1308    #[test]
1309    fn natural_abundance_unknown_returns_none() {
1310        assert_eq!(natural_abundance(999_999_999), None);
1311        assert_eq!(natural_abundance_by_name("C14"), None);
1312        assert_eq!(natural_abundance_by_name("Xx999"), None);
1313    }
1314
1315    #[test]
1316    fn abundances_sum_to_one_per_element() {
1317        let mut totals = [0.0_f64; 119];
1318        for (nucid, frac) in abundance_table() {
1319            totals[NuclideId::from_nucid(*nucid).z() as usize] += frac;
1320        }
1321        for (z, total) in totals.iter().enumerate() {
1322            if *total > 0.0 {
1323                assert!(
1324                    (total - 1.0).abs() < 1e-6,
1325                    "Z={z} abundances sum to {total}"
1326                );
1327            }
1328        }
1329    }
1330
1331    #[test]
1332    fn mass_sanity_sweep() {
1333        let table = mass_table();
1334        for (nucid, mass) in table {
1335            let id = NuclideId::from_nucid(*nucid);
1336            // Ground states plus ENDF-taped isomers (state 1-9); the
1337            // excitation energy is at most MeV against a GeV-scale mass.
1338            assert!(id.state() <= 9, "state out of range: {nucid}");
1339            let (lo, hi) = (0.9 * f64::from(id.a()), 1.2 * f64::from(id.a()));
1340            assert!(*mass > lo && *mass < hi, "{} mass {mass}", id.to_name());
1341            assert!(*mass > 0.0);
1342        }
1343    }
1344
1345    #[test]
1346    fn vendored_row_counts_match_tables() {
1347        let mass_rows = AME2020_TSV
1348            .lines()
1349            .filter(|l| !l.is_empty() && !l.starts_with('#'))
1350            .count();
1351        let abundance_rows = NATURAL_ABUNDANCE_TSV
1352            .lines()
1353            .filter(|l| !l.is_empty() && !l.starts_with('#'))
1354            .count();
1355        assert_eq!(mass_table().len(), mass_rows);
1356        assert_eq!(mass_rows, 3557 + 738);
1357        assert_eq!(abundance_table().len(), abundance_rows);
1358        assert_eq!(abundance_rows, 289);
1359    }
1360
1361    #[test]
1362    fn isomer_masses_follow_ground_plus_excitation() {
1363        // Ba137_m1: ELIS 661659 eV on a 136.905827207 u ground state.
1364        let ba = NuclideId::from_name("Ba137").unwrap().nucid();
1365        let ba_m1 = NuclideId::from_name("Ba137_m1").unwrap().nucid();
1366        let expected = atomic_mass(ba).unwrap() + 0.661_659 / MEV_PER_U;
1367        assert!((atomic_mass(ba_m1).unwrap() - expected).abs() < 1e-9);
1368        // Second isomers resolve too (ENDF m2 tapes, e.g. Cu70_m2).
1369        let cu_m2 = NuclideId::from_name("Cu70_m2").unwrap().nucid();
1370        assert!(atomic_mass(cu_m2).unwrap() >= atomic_mass_by_name("Cu70").unwrap());
1371        // Pm137m has no ENDF/B-VIII.0 isomer tape: absent, no fill-in, so
1372        // the lookup falls back to the ground-state mass.
1373        assert_eq!(
1374            atomic_mass_by_name("Pm137_m1"),
1375            atomic_mass_by_name("Pm137")
1376        );
1377        // Stable Te123 keeps its ground mass; its isomer has its own row.
1378        assert!(atomic_mass_by_name("Te123_m1").unwrap() > atomic_mass_by_name("Te123").unwrap());
1379    }
1380
1381    #[test]
1382    fn ame_masses_facade_delegates() {
1383        let provider = AmeMasses;
1384        assert_eq!(provider.atomic_mass(U235), atomic_mass(U235));
1385        assert_eq!(provider.atomic_mass_by_name("Fe56"), Some(55.934_935_537));
1386        assert_eq!(provider.natural_abundance(O16), Some(0.997_620_6));
1387        assert_eq!(provider.natural_abundance_by_name("Nope1"), None);
1388    }
1389
1390    const U238: u32 = 922_380_000;
1391    const I135: u32 = 531_350_000;
1392    const CS137: u32 = 551_370_000;
1393
1394    #[test]
1395    fn half_life_spot_values() {
1396        // U-238: 4.468e9 yr × 3.1557e7 s/yr ≈ 1.4100e17 s.
1397        let t_u238 = half_life(U238).unwrap();
1398        assert!((t_u238 - 1.409_99e17).abs() / t_u238 < 1e-9);
1399        // I-135: chain fixture value 2.3652e4 s.
1400        assert_eq!(half_life(I135), Some(23_652.0));
1401        // Cs-135: 7.25825e13 s (≈ 2.3 Myr).
1402        let t_cs135 = half_life_by_name("Cs135").unwrap();
1403        assert!((t_cs135 - 7.258_25e13).abs() / t_cs135 < 1e-12);
1404        // Cs-137: 30.08 yr ≈ 9.49e8 s, in the production Julian-year
1405        // convention (AD-11: 31,557,600 s/yr; the Gregorian divisor it
1406        // replaces differed by ~6e-4 yr against this 0.01 yr tolerance).
1407        let t_cs137 = half_life(CS137).unwrap();
1408        assert!((t_cs137 / 3.155_76e7 - 30.08).abs() < 0.01, "{t_cs137}");
1409        // Isomers carry their own rows.
1410        assert_eq!(half_life_by_name("Am242_m1"), Some(4_449_622_000.0));
1411    }
1412
1413    #[test]
1414    fn stable_and_unknown_nuclides_have_no_half_life() {
1415        // Stable nuclides are absent from the vendored table.
1416        assert_eq!(half_life(O16), None);
1417        assert_eq!(half_life_by_name("Fe56"), None);
1418        assert_eq!(decay_constant(H1), None);
1419        assert_eq!(half_life(999_999_999), None);
1420        assert_eq!(half_life_by_name("Xx999"), None);
1421    }
1422
1423    #[test]
1424    fn decay_constant_is_ln2_over_half_life() {
1425        for nucid in [
1426            U238,
1427            I135,
1428            CS137,
1429            NuclideId::from_name("Te132").unwrap().nucid(),
1430        ] {
1431            let t_half = half_life(nucid).unwrap();
1432            let lambda = decay_constant(nucid).unwrap();
1433            let rel = (lambda * t_half - std::f64::consts::LN_2).abs() / std::f64::consts::LN_2;
1434            assert!(rel < 1e-12, "nucid {nucid}: rel err {rel}");
1435        }
1436        let lam = decay_constant_by_name("I135").unwrap();
1437        assert!((lam - std::f64::consts::LN_2 / 23_652.0).abs() < 1e-18);
1438    }
1439
1440    #[test]
1441    fn shorter_half_life_gives_larger_decay_constant() {
1442        // Te132 (3.2 d) vs I135 (6.57 h) vs Xe135 (9.14 h) chain ordering.
1443        let te = NuclideId::from_name("Te132").unwrap().nucid();
1444        let xe = NuclideId::from_name("Xe135").unwrap().nucid();
1445        let (t_te, t_i, t_xe) = (
1446            half_life(te).unwrap(),
1447            half_life(I135).unwrap(),
1448            half_life(xe).unwrap(),
1449        );
1450        assert!(t_te > t_xe && t_i < t_xe);
1451        assert!(decay_constant(te).unwrap() < decay_constant(xe).unwrap());
1452        assert!(decay_constant(xe).unwrap() < decay_constant(I135).unwrap());
1453    }
1454
1455    #[test]
1456    fn half_lives_are_positive_and_finite() {
1457        for (nucid, t_half) in half_life_table() {
1458            assert!(*t_half > 0.0 && t_half.is_finite(), "{nucid}: {t_half}");
1459            let id = NuclideId::from_nucid(*nucid);
1460            assert!(id.a() >= id.z(), "{}", id.to_name());
1461        }
1462    }
1463
1464    #[test]
1465    fn vendored_half_life_row_count_matches_table() {
1466        let rows = HALF_LIFE_TSV
1467            .lines()
1468            .filter(|l| !l.is_empty() && !l.starts_with('#'))
1469            .count();
1470        assert_eq!(half_life_table().len(), rows);
1471        assert_eq!(rows, 3561);
1472    }
1473
1474    #[test]
1475    fn neutron_capture_q_value_anchors() {
1476        // H1(n,γ): textbook 2.224566 MeV.
1477        let q_h = q_value_neutron_capture(H1).unwrap();
1478        assert!((q_h - 2.224_566).abs() < 1e-3, "{q_h}");
1479        // U238(n,γ): ≈ 4.8 MeV.
1480        let q_u238 = q_value_neutron_capture(U238).unwrap();
1481        assert!((q_u238 - 4.806_382).abs() < 1e-3, "{q_u238}");
1482        assert!(q_u238 > 4.79 && q_u238 < 4.81);
1483        // O16(n,γ): 4.143 MeV.
1484        let q_o16 = q_value_neutron_capture(O16).unwrap();
1485        assert!((q_o16 - 4.143_080).abs() < 1e-3, "{q_o16}");
1486    }
1487
1488    #[test]
1489    fn capture_q_value_matches_manual_formula() {
1490        let expected = (atomic_mass(U238).unwrap() + NEUTRON_MASS_U
1491            - atomic_mass(922_390_000).unwrap())
1492            * MEV_PER_U;
1493        let q = q_value_neutron_capture(U238).unwrap();
1494        assert!((q - expected).abs() < 1e-9);
1495        assert_eq!(q_value_neutron_capture_by_name("U238"), Some(q));
1496        assert_eq!(neutron_mass_u(), NEUTRON_MASS_U);
1497        assert_eq!(neutron_mass_u(), 1.008_664_915_95);
1498    }
1499
1500    #[test]
1501    fn capture_q_value_missing_or_bad_targets_return_none() {
1502        // Product nuclide beyond the chart of nuclides (no A+1 mass row).
1503        let he10 = NuclideId::from_name("He10").unwrap().nucid();
1504        assert_eq!(atomic_mass(he10 + 10_000), None);
1505        assert_eq!(q_value_neutron_capture(he10), None);
1506        // Metastable states and non-nuclides are rejected outright.
1507        assert_eq!(q_value_neutron_capture(922_350_001), None);
1508        assert_eq!(q_value_neutron_capture(10_000), None);
1509        assert_eq!(q_value_neutron_capture_by_name("U235_m1"), None);
1510        assert_eq!(q_value_neutron_capture_by_name("Nope1"), None);
1511    }
1512
1513    #[test]
1514    fn alpha_q_value_anchors() {
1515        // Literature: U238 α → 4.2698 MeV, Po210 → 5.4075 MeV,
1516        // Ra226 → 4.8706 MeV; atomic-mass formula reproduces all three.
1517        for (name, lit) in [
1518            ("U238", 4.269_858),
1519            ("Po210", 5.407_530),
1520            ("Ra226", 4.870_703),
1521        ] {
1522            let q = q_value_alpha_by_name(name).unwrap();
1523            assert!((q - lit).abs() < 1e-3, "{name}: {q} vs {lit}");
1524        }
1525        assert_eq!(q_value_alpha(U238), q_value_alpha_by_name("U238"));
1526    }
1527
1528    #[test]
1529    fn alpha_q_value_rejects_light_and_metastable() {
1530        // Too light to alpha-decay within the table's Z/A domain.
1531        assert_eq!(q_value_alpha(H1), None);
1532        assert_eq!(q_value_alpha_by_name("He4"), None);
1533        assert_eq!(q_value_alpha(922_350_001), None);
1534        assert_eq!(q_value_alpha_by_name("Am242_m1"), None);
1535        // Endothermic "decay" still yields the (negative) Q-value:
1536        // O16 → C12 + He4 costs ≈ 7.162 MeV.
1537        let q_o16 = q_value_alpha_by_name("O16").unwrap();
1538        assert!((q_o16 + 7.162).abs() < 1e-3, "{q_o16}");
1539    }
1540
1541    #[test]
1542    fn decay_data_facade_delegates() {
1543        let provider = DecayData;
1544        assert_eq!(provider.half_life(I135), Some(23_652.0));
1545        assert_eq!(provider.half_life_by_name("I135"), Some(23_652.0));
1546        assert_eq!(provider.decay_constant(U238), decay_constant(U238));
1547        assert_eq!(provider.decay_constant_by_name("Fe56"), None);
1548    }
1549
1550    #[test]
1551    fn simple_xs_h1_anchors_within_ten_percent() {
1552        // Generated values: H free-atom thermal ~20.84 b, ~0.69 b at 14 MeV.
1553        let (thermal, fast) = simple_xs_by_name("H1").unwrap();
1554        assert!((thermal - 20.84).abs() / 20.84 < 0.05, "{thermal}");
1555        assert!((fast - 0.687).abs() / 0.687 < 0.05, "{fast}");
1556        assert_eq!(simple_xs(H1), Some((thermal, fast)));
1557    }
1558
1559    #[test]
1560    fn simple_xs_absorber_and_actinide_bands() {
1561        // Strong absorbers dominate at thermal; actinide totals cluster ~6 b
1562        // at 14 MeV (ENDF/B-VII.1 MF3/MT1).
1563        let (b10_th, _) = simple_xs_by_name("B10").unwrap();
1564        assert!(b10_th > 3700.0 && b10_th < 3950.0, "{b10_th}");
1565        let (u235_th, u235_fast) = simple_xs_by_name("U235").unwrap();
1566        assert!(u235_th > 680.0 && u235_th < 710.0, "{u235_th}");
1567        assert!(u235_fast > 5.0 && u235_fast < 7.0, "{u235_fast}");
1568        let (pu239_th, _) = simple_xs_by_name("Pu239").unwrap();
1569        assert!(pu239_th > 1000.0 && pu239_th < 1050.0, "{pu239_th}");
1570        let (o16_th, _) = simple_xs_by_name("O16").unwrap();
1571        assert!(o16_th > 3.5 && o16_th < 4.0, "{o16_th}");
1572    }
1573
1574    #[test]
1575    fn simple_xs_coverage_gaps_are_documented() {
1576        // Resonance absorbers without NIST rows, and isomers, are absent.
1577        for name in ["Cs137", "Co60", "I135", "Xe135", "Am242_m1"] {
1578            assert_eq!(simple_xs_by_name(name), None, "{name}");
1579        }
1580    }
1581
1582    #[test]
1583    fn simple_xs_unknown_returns_none() {
1584        assert_eq!(simple_xs(999_999_999), None);
1585        assert_eq!(simple_xs_by_name("Og294"), None);
1586        assert_eq!(simple_xs_by_name("Xx999"), None);
1587    }
1588
1589    #[test]
1590    fn scattering_length_nist_anchors() {
1591        // NIST NCNR bound lengths (Sears 1992): H1, D, O16.
1592        let (h_coh, h_inc) = scattering_length_by_name("H1").unwrap();
1593        assert!((h_coh - -3.7406).abs() < 1e-3, "{h_coh}");
1594        assert!((h_inc - 25.274).abs() < 1e-3, "{h_inc}");
1595        let (d_coh, d_inc) = scattering_length_by_name("H2").unwrap();
1596        assert!((d_coh - 6.671).abs() < 1e-2, "{d_coh}");
1597        assert!((d_inc - 4.04).abs() < 1e-2, "{d_inc}");
1598        let (o_coh, o_inc) = scattering_length_by_name("O16").unwrap();
1599        assert!((o_coh - 5.803).abs() < 1e-2, "{o_coh}");
1600        assert_eq!(o_inc, 0.0);
1601    }
1602
1603    #[test]
1604    fn scattering_length_unknown_returns_none() {
1605        assert_eq!(scattering_length(999_999_999), None);
1606        assert_eq!(scattering_length_by_name("Og294"), None);
1607        assert_eq!(scattering_length_by_name("Xx999"), None);
1608    }
1609
1610    #[test]
1611    fn decay_energy_anchors_within_tolerance() {
1612        // ENDF/B-VII.1 MF8/MT457 prompt means: Cs137 excludes the 662 keV
1613        // daughter gamma (Ba137_m1's row), Co60 ~2.60, H3 ~0.0057 MeV.
1614        let cs = decay_energy_mev_by_name("Cs137").unwrap();
1615        assert!((cs - 0.1794).abs() / 0.1794 < 0.05, "{cs}");
1616        let co = decay_energy_mev_by_name("Co60").unwrap();
1617        assert!((co - 2.6006).abs() / 2.6006 < 0.05, "{co}");
1618        let h3 = decay_energy_mev_by_name("H3").unwrap();
1619        assert!((h3 - 0.00569).abs() / 0.00569 < 0.05, "{h3}");
1620    }
1621
1622    #[test]
1623    fn decay_energy_stable_and_unknown_return_none() {
1624        assert_eq!(decay_energy_mev(O16), None);
1625        assert_eq!(decay_energy_mev(FE56), None);
1626        assert_eq!(decay_energy_mev(999_999_999), None);
1627        assert_eq!(decay_energy_mev_by_name("Fe56"), None);
1628        assert_eq!(decay_energy_mev_by_name("Xx999"), None);
1629    }
1630
1631    #[test]
1632    fn decay_energy_isomers_carry_own_rows() {
1633        // Ba137m prompt 661 keV gamma (×0.9 branching) vs Cs137 prompt only.
1634        let ba_m1 = decay_energy_mev_by_name("Ba137_m1").unwrap();
1635        assert!((ba_m1 - 0.6614).abs() / 0.6614 < 0.05, "{ba_m1}");
1636        assert!(decay_energy_mev_by_name("Ba137").is_none());
1637        // Second isomers resolve too (ENDF m2 tapes, e.g. Am242_m2).
1638        let am_m2 = NuclideId::from_name("Am242_m2").unwrap().nucid();
1639        assert_eq!(
1640            decay_energy_mev_by_name("Am242_m2"),
1641            decay_energy_mev(am_m2)
1642        );
1643        assert!(decay_energy_mev(am_m2).is_some());
1644    }
1645
1646    #[test]
1647    fn vendored_generated_row_counts_match_tables() {
1648        for (tsv, table_len, expected) in [
1649            (SIMPLE_XS_TSV, simple_xs_table().len(), 241),
1650            (SCATTERING_LENGTHS_TSV, scattering_length_table().len(), 267),
1651            (DECAY_ENERGY_TSV, decay_energy_table().len(), 3557),
1652        ] {
1653            let rows = tsv
1654                .lines()
1655                .filter(|l| !l.is_empty() && !l.starts_with('#'))
1656                .count();
1657            assert_eq!(table_len, rows);
1658            assert_eq!(rows, expected);
1659        }
1660    }
1661
1662    #[test]
1663    fn generated_by_name_agrees_with_nucid_lookup() {
1664        for name in ["H1", "B10", "O16", "Fe56", "U235", "Pu239"] {
1665            let nucid = NuclideId::from_name(name).unwrap().nucid();
1666            assert_eq!(simple_xs_by_name(name), simple_xs(nucid), "{name}");
1667            assert_eq!(
1668                scattering_length_by_name(name),
1669                scattering_length(nucid),
1670                "{name}"
1671            );
1672        }
1673        for name in ["H3", "Co60", "Cs137"] {
1674            let nucid = NuclideId::from_name(name).unwrap().nucid();
1675            assert_eq!(
1676                decay_energy_mev_by_name(name),
1677                decay_energy_mev(nucid),
1678                "{name}"
1679            );
1680        }
1681    }
1682
1683    #[test]
1684    fn decay_data_facade_delegates_decay_energy() {
1685        let provider = DecayData;
1686        assert_eq!(provider.decay_energy_mev(CS137), decay_energy_mev(CS137));
1687        let co60 = provider.decay_energy_mev_by_name("Co60").unwrap();
1688        assert!((co60 - 2.6006).abs() / 2.6006 < 0.05, "{co60}");
1689        assert_eq!(provider.decay_energy_mev_by_name("Fe56"), None);
1690    }
1691
1692    #[test]
1693    fn decay_branch_k40_two_branches_sum_to_one() {
1694        // ENDF/B-VIII.0 NDK values, verbatim: beta- 0.8914 to Ca40 and
1695        // EC/beta+ 0.1086 to Ar40.
1696        let k40 = NuclideId::from_name("K40").unwrap().nucid();
1697        let branches = decay_branches(k40).unwrap();
1698        assert_eq!(branches.len(), 2);
1699        let ca40 = NuclideId::from_name("Ca40").unwrap().nucid();
1700        let ar40 = NuclideId::from_name("Ar40").unwrap().nucid();
1701        assert!(branches.contains(&DecayBranch {
1702            progeny: ca40,
1703            branching_fraction: 0.8914,
1704            mode: DecayBranchMode::BetaMinus,
1705        }));
1706        assert!(branches.contains(&DecayBranch {
1707            progeny: ar40,
1708            branching_fraction: 0.1086,
1709            mode: DecayBranchMode::EcBetaPlus,
1710        }));
1711        let total: f64 = branches.iter().map(|b| b.branching_fraction).sum();
1712        assert!((total - 1.0).abs() < 1e-9, "{total}");
1713        assert_eq!(branching_fraction(k40, ca40), Some(0.8914));
1714        assert_eq!(branching_fraction_by_name("K40", "Ar40"), Some(0.1086));
1715        assert_eq!(branching_fraction_by_name("K40", "K40"), None);
1716    }
1717
1718    #[test]
1719    fn decay_branch_spot_modes() {
1720        use DecayBranchMode as M;
1721        // Single alpha branch with unit BR.
1722        let es254 = decay_branches_by_name("Es254").unwrap();
1723        assert_eq!(es254.len(), 1);
1724        assert_eq!(es254[0].mode, M::Alpha);
1725        assert_eq!(es254[0].branching_fraction, 1.0);
1726        assert_eq!(NuclideId::from_nucid(es254[0].progeny).to_name(), "Bk250");
1727        // Isomeric transition keeps Z/A.
1728        let ba = decay_branches_by_name("Ba137_m1").unwrap();
1729        assert_eq!(ba.len(), 1);
1730        assert_eq!(ba[0].mode, M::It);
1731        assert_eq!(NuclideId::from_nucid(ba[0].progeny).to_name(), "Ba137");
1732        // Delayed-neutron branch collapses to the beta- initial event with
1733        // the emitted neutron subtracted from the progeny.
1734        let he8 = decay_branches_by_name("He8").unwrap();
1735        assert_eq!(he8.len(), 2);
1736        let names: Vec<String> = he8
1737            .iter()
1738            .map(|b| NuclideId::from_nucid(b.progeny).to_name())
1739            .collect();
1740        assert!(names.contains(&"Li8".to_string()), "{names:?}");
1741        assert!(names.contains(&"Li7".to_string()), "{names:?}");
1742        assert!(he8.iter().all(|b| b.mode == M::BetaMinus));
1743        // Es254m mixes alpha, beta-, EC, and IT; the SF branch is dropped.
1744        let es_m1 = decay_branches_by_name("Es254_m1").unwrap();
1745        assert_eq!(es_m1.len(), 4);
1746        assert!(es_m1.iter().all(|b| b.mode != M::Sf));
1747        assert_eq!(branching_fraction_by_name("Es254_m1", "Fm254"), Some(0.98));
1748    }
1749
1750    #[test]
1751    fn decay_branch_stable_and_unknown_have_no_rows() {
1752        assert_eq!(decay_branches_by_name("Fe56"), None);
1753        assert_eq!(decay_branches_by_name("O16"), None);
1754        // Zero-half-life evaluation dummies stay absent (stable-absent rule).
1755        assert_eq!(decay_branches_by_name("Te123"), None);
1756        assert_eq!(decay_branches_by_name("Ca46"), None);
1757        assert_eq!(branching_fraction_by_name("Fe56", "Fe56"), None);
1758        assert_eq!(decay_branches_by_name("Xx999"), None);
1759    }
1760
1761    #[test]
1762    fn decay_branch_parents_agree_with_half_life_table() {
1763        // Every branch parent carries a half-life row (same VIII.0 basis);
1764        // progeny may be stable (absent) — only names must parse.
1765        for (parent, branches) in decay_branch_table() {
1766            assert!(
1767                half_life(*parent).is_some(),
1768                "parent without half-life: {parent}"
1769            );
1770            assert!(!branches.is_empty());
1771            for b in branches {
1772                assert!(
1773                    (0.0..=1.0).contains(&b.branching_fraction),
1774                    "BF range: {}",
1775                    b.branching_fraction
1776                );
1777                let id = NuclideId::from_nucid(b.progeny);
1778                assert!(id.z() >= 1 && id.a() >= id.z(), "{}", id.to_name());
1779            }
1780        }
1781    }
1782
1783    #[test]
1784    fn decay_branch_row_count_matches_table() {
1785        let rows = DECAY_BRANCHES_TSV
1786            .lines()
1787            .filter(|l| !l.is_empty() && !l.starts_with('#'))
1788            .count();
1789        let table_rows: usize = decay_branch_table().values().map(Vec::len).sum();
1790        assert_eq!(table_rows, rows);
1791        assert_eq!(rows, 5068);
1792        assert_eq!(decay_branch_table().len(), 3541);
1793    }
1794
1795    #[test]
1796    fn fission_yield_row_count_matches_table() {
1797        let rows = FISSION_YIELDS_TSV
1798            .lines()
1799            .filter(|l| !l.is_empty() && !l.starts_with('#'))
1800            .count();
1801        let table = fission_yield_table();
1802        let table_rows: usize = table.values().map(Vec::len).sum();
1803        let set_products: usize = table
1804            .values()
1805            .flat_map(|sets| sets.iter())
1806            .map(|s| s.products.len())
1807            .sum();
1808        assert_eq!(set_products, rows);
1809        assert_eq!(rows, 151_490);
1810        assert_eq!(table.len(), 80);
1811        assert_eq!(table_rows, 122);
1812    }
1813
1814    #[test]
1815    fn fission_yield_u235_thermal_spot_values() {
1816        // Hard values read off the ENDF/B-VIII.0 U-235 tape (England
1817        // ENDF-349 evaluation), thermal (0.0253 eV) set.
1818        let u235 = NuclideId::from_name("U235").unwrap().nucid();
1819        let xe135 = NuclideId::from_name("Xe135").unwrap().nucid();
1820        let sets = fission_yields(
1821            u235,
1822            FissionYieldOrigin::NeutronInduced,
1823            FissionYieldKind::Independent,
1824        )
1825        .unwrap();
1826        assert_eq!(sets.len(), 3); // 0.0253, 5e5, 1.4e7 eV
1827        let thermal = &sets[0];
1828        assert_eq!(thermal.energy_ev, 0.0253);
1829        let xe = thermal
1830            .products
1831            .iter()
1832            .find(|p| p.progeny == xe135)
1833            .unwrap();
1834        assert_eq!(xe.yield_fraction, 0.000_785_125);
1835        assert_eq!(xe.uncertainty, 4.710_75e-05);
1836        // The independent set sums to ~2.0: two fragments per fission.
1837        let total: f64 = thermal.products.iter().map(|p| p.yield_fraction).sum();
1838        assert!((total - 2.0).abs() < 1e-6, "{total}");
1839        // Asymmetric split: the A <= 116 light peak carries ~1.0.
1840        let light: f64 = thermal
1841            .products
1842            .iter()
1843            .filter(|p| NuclideId::from_nucid(p.progeny).a() <= 116)
1844            .map(|p| p.yield_fraction)
1845            .sum();
1846        assert!((light - 1.0).abs() < 1e-3, "{light}");
1847    }
1848
1849    #[test]
1850    fn fission_yield_isomer_and_cumulative_spots() {
1851        use FissionYieldKind as K;
1852        use FissionYieldOrigin as O;
1853        // FPS = 1 maps to the GNDS _m1 suffix (Xe135_m1 thermal row).
1854        let xe_m1 = fission_yield_by_name("U235", "Xe135_m1");
1855        assert_eq!(xe_m1, Some(0.001_781_22));
1856        // Cumulative Xe135 (MT459) is the classic ~6.6% value.
1857        let cum = fission_yields_by_name("U235", O::NeutronInduced, K::Cumulative).unwrap();
1858        assert_eq!(cum[0].energy_ev, 0.0253);
1859        let xe135 = NuclideId::from_name("Xe135").unwrap().nucid();
1860        let row = cum[0].products.iter().find(|p| p.progeny == xe135).unwrap();
1861        assert_eq!(row.yield_fraction, 0.065_385);
1862        assert_eq!(row.uncertainty, 0.000_457_695);
1863        // Cumulative sets do NOT sum to 2.0 (precursor chains feed in).
1864        let total: f64 = cum[0].products.iter().map(|p| p.yield_fraction).sum();
1865        assert!(total > 4.0, "{total}");
1866    }
1867
1868    #[test]
1869    fn fission_yield_parents_origins_and_energies() {
1870        use FissionYieldKind as K;
1871        use FissionYieldOrigin as O;
1872        // Pu-239 thermal (Chadwick-Kawano evaluation): 4 energy sets.
1873        let pu = fission_yields_by_name("Pu239", O::NeutronInduced, K::Independent).unwrap();
1874        assert_eq!(pu.len(), 4);
1875        assert_eq!(pu[0].energy_ev, 0.0253);
1876        let xe135 = NuclideId::from_name("Xe135").unwrap().nucid();
1877        let row = pu[0].products.iter().find(|p| p.progeny == xe135).unwrap();
1878        assert_eq!(row.yield_fraction, 0.003_141_31);
1879        assert_eq!(row.uncertainty, 0.000_125_652);
1880        // Cf-252 spontaneous: single E = 0 set.
1881        let cf = fission_yields_by_name("Cf252", O::Spontaneous, K::Independent).unwrap();
1882        assert_eq!(cf.len(), 1);
1883        assert_eq!(cf[0].energy_ev, 0.0);
1884        let row = cf[0].products.iter().find(|p| p.progeny == xe135).unwrap();
1885        assert_eq!(row.yield_fraction, 0.001_861_45);
1886        // U-238 lives in BOTH sublibraries: its neutron-induced default is
1887        // the 5e5 eV set (there is no thermal nfy evaluation), never the
1888        // spontaneous E = 0 set.
1889        let u238 = NuclideId::from_name("U238").unwrap().nucid();
1890        let default = default_fission_yields(u238).unwrap();
1891        assert_eq!(default.energy_ev, 500_000.0);
1892        assert!(fission_yields(u238, O::Spontaneous, K::Independent).is_some());
1893        // U-238 fast set from the tape (14 MeV; the 5e5 spot pins fast[0]).
1894        let fast = fission_yields(u238, O::NeutronInduced, K::Independent).unwrap();
1895        assert_eq!(fast.len(), 2);
1896        assert_eq!(fast[0].energy_ev, 500_000.0);
1897        assert_eq!(fast[1].energy_ev, 1.4e7);
1898        let row = fast[1]
1899            .products
1900            .iter()
1901            .find(|p| p.progeny == xe135)
1902            .unwrap();
1903        assert_eq!(row.yield_fraction, 0.001_329_1);
1904        assert_eq!(row.uncertainty, 1.462_01e-04);
1905    }
1906
1907    #[test]
1908    fn fission_yield_default_and_singular_lookups() {
1909        let u235 = NuclideId::from_name("U235").unwrap().nucid();
1910        let xe135 = NuclideId::from_name("Xe135").unwrap().nucid();
1911        let default = default_fission_yields(u235).unwrap();
1912        assert_eq!(default.energy_ev, 0.0253);
1913        assert_eq!(fission_yield(u235, xe135), Some(0.000_785_125));
1914        assert_eq!(fission_yield_by_name("U235", "Xe135"), Some(0.000_785_125));
1915        assert_eq!(fission_yield_by_name("U235", "Fe56"), None);
1916        assert_eq!(default_fission_yields_by_name("Fe56"), None);
1917        // Parents outside the library (non-fissionable or unevaluated).
1918        assert_eq!(
1919            fission_yields_by_name("Fe56", Default::default(), Default::default()),
1920            None
1921        );
1922        assert_eq!(default_fission_yields_by_name("Cm247"), None);
1923        assert_eq!(
1924            fission_yields_by_name("Xx999", Default::default(), Default::default()),
1925            None
1926        );
1927    }
1928
1929    #[test]
1930    fn fission_yield_origin_kind_tokens() {
1931        use FissionYieldKind as K;
1932        use FissionYieldOrigin as O;
1933        assert_eq!(O::parse("n"), Some(O::NeutronInduced));
1934        assert_eq!(O::parse("SF"), Some(O::Spontaneous));
1935        assert_eq!(O::parse("spontaneous"), Some(O::Spontaneous));
1936        assert_eq!(O::parse("x"), None);
1937        assert_eq!(O::NeutronInduced.as_str(), "n");
1938        assert_eq!(O::Spontaneous.as_str(), "sf");
1939        assert_eq!(K::parse("independent"), Some(K::Independent));
1940        assert_eq!(K::parse("Cumulative"), Some(K::Cumulative));
1941        assert_eq!(K::parse("x"), None);
1942        assert_eq!(K::Independent.as_str(), "independent");
1943        assert_eq!(K::Cumulative.as_str(), "cumulative");
1944    }
1945
1946    #[test]
1947    fn decay_branch_mode_parsing() {
1948        use DecayBranchMode as M;
1949        assert_eq!(M::parse("beta-"), Some(M::BetaMinus));
1950        assert_eq!(M::parse("ec/beta+"), Some(M::EcBetaPlus));
1951        assert_eq!(M::parse("EC/BETA+"), Some(M::EcBetaPlus));
1952        assert_eq!(M::parse("alpha"), Some(M::Alpha));
1953        assert_eq!(M::parse("IT"), Some(M::It));
1954        assert_eq!(M::parse("it"), Some(M::It));
1955        assert_eq!(M::parse("sf"), Some(M::Sf));
1956        assert_eq!(M::parse("n"), Some(M::Neutron));
1957        assert_eq!(M::parse("p"), Some(M::Proton));
1958        assert_eq!(M::parse("nope"), None);
1959        assert_eq!(M::BetaMinus.as_str(), "beta-");
1960        assert_eq!(M::EcBetaPlus.as_str(), "ec/beta+");
1961        assert_eq!(M::It.as_str(), "IT");
1962    }
1963
1964    #[test]
1965    fn decay_data_facade_delegates_branches() {
1966        let provider = DecayData;
1967        let k40 = NuclideId::from_name("K40").unwrap().nucid();
1968        assert_eq!(provider.decay_branches(k40), decay_branches(k40));
1969        assert_eq!(
1970            provider.decay_branches_by_name("Es254"),
1971            decay_branches_by_name("Es254")
1972        );
1973        assert_eq!(
1974            provider.branching_fraction_by_name("K40", "Ca40"),
1975            Some(0.8914)
1976        );
1977        assert_eq!(provider.decay_branches_by_name("Fe56"), None);
1978    }
1979
1980    #[test]
1981    fn dose_factor_row_count_matches_table() {
1982        let rows = DOSE_FACTORS_TSV
1983            .lines()
1984            .filter(|l| !l.is_empty() && !l.starts_with('#'))
1985            .count();
1986        assert_eq!(dose_table().len(), rows);
1987        assert_eq!(rows, 1116);
1988    }
1989
1990    #[test]
1991    fn dose_factor_spot_values() {
1992        use DosePathway as P;
1993        use DoseSource as S;
1994        // Raw table factors from the PyNE CSVs (folded +D parents).
1995        assert_eq!(
1996            dose_factor_by_name("Co60", P::Ingest, S::Epa),
1997            Some(2.69e-05)
1998        );
1999        assert_eq!(
2000            dose_factor_by_name("Cs137", P::Inhale, S::Epa),
2001            Some(3.19e-05)
2002        );
2003        assert_eq!(dose_factor_by_name("H3", P::Air, S::Epa), Some(4.41e-012));
2004        assert_eq!(dose_factor_by_name("K40", P::Soil, S::Epa), Some(4.33e02));
2005    }
2006
2007    #[test]
2008    fn dose_factor_missing_air_is_minus_one_sentinel() {
2009        use DosePathway as P;
2010        use DoseSource as S;
2011        // Air is EPA-only: GENII/DOE air rows are -1 sentinels (PyNE).
2012        let h3 = NuclideId::from_name("H3").unwrap().nucid();
2013        assert_eq!(dose_factor(h3, P::Air, S::Genii), Some(-1.0));
2014        assert_eq!(dose_factor(h3, P::Air, S::Doe), Some(-1.0));
2015        // Unknown nuclides have no row at all.
2016        assert_eq!(dose_factor(999_999_999, P::Ingest, S::Epa), None);
2017        assert_eq!(dose_factor_by_name("Fe56", P::Ingest, S::Epa), None);
2018        assert_eq!(dose_factor_by_name("Xx999", P::Ingest, S::Epa), None);
2019    }
2020
2021    #[test]
2022    fn dose_aux_columns() {
2023        use DoseSource as S;
2024        // f1 lives on ingest rows; lung model on inhale rows.
2025        assert_eq!(dose_f1_by_name("Co60", S::Epa), Some(0.3));
2026        assert_eq!(dose_f1_by_name("H3", S::Epa), Some(1.0));
2027        assert_eq!(dose_lung_model_by_name("Co60", S::Epa), Some('Y'));
2028        assert_eq!(dose_lung_model_by_name("H3", S::Epa), Some('V'));
2029        assert_eq!(dose_lung_model_by_name("C14", S::Epa), Some('O'));
2030        // Air/soil rows carry neither aux column.
2031        let h3 = NuclideId::from_name("H3").unwrap().nucid();
2032        assert_eq!(dose_entry(h3, DosePathway::Air, S::Epa).unwrap().f1, None);
2033        assert_eq!(
2034            dose_entry(h3, DosePathway::Air, S::Epa).unwrap().lung_model,
2035            None
2036        );
2037    }
2038
2039    #[test]
2040    fn dose_pathway_source_parsing() {
2041        assert_eq!(DosePathway::parse("air"), Some(DosePathway::Air));
2042        assert_eq!(DosePathway::parse("ext_air"), Some(DosePathway::Air));
2043        assert_eq!(DosePathway::parse("ext_soil"), Some(DosePathway::Soil));
2044        assert_eq!(DosePathway::parse("INGEST"), Some(DosePathway::Ingest));
2045        assert_eq!(DosePathway::parse("inhale"), Some(DosePathway::Inhale));
2046        assert_eq!(DosePathway::parse("nope"), None);
2047        assert_eq!(DoseSource::parse("epa"), Some(DoseSource::Epa));
2048        assert_eq!(DoseSource::parse("DOE"), Some(DoseSource::Doe));
2049        assert_eq!(DoseSource::parse("genii"), Some(DoseSource::Genii));
2050        assert_eq!(DoseSource::from_int(0), Some(DoseSource::Epa));
2051        assert_eq!(DoseSource::from_int(1), Some(DoseSource::Doe));
2052        assert_eq!(DoseSource::from_int(2), Some(DoseSource::Genii));
2053        assert_eq!(DoseSource::from_int(3), None);
2054        assert_eq!(DoseSource::Epa.to_int(), 0);
2055    }
2056
2057    #[test]
2058    fn dose_data_facade_delegates() {
2059        use DosePathway as P;
2060        use DoseSource as S;
2061        let provider = DoseData;
2062        let co60 = NuclideId::from_name("Co60").unwrap().nucid();
2063        assert_eq!(
2064            provider.dose_factor(co60, P::Ingest, S::Epa),
2065            dose_factor(co60, P::Ingest, S::Epa)
2066        );
2067        assert_eq!(
2068            provider.dose_factor_by_name("K40", P::Soil, S::Epa),
2069            Some(4.33e02)
2070        );
2071        assert_eq!(provider.dose_f1_by_name("H3", S::Epa), Some(1.0));
2072        assert_eq!(provider.dose_lung_model_by_name("H3", S::Epa), Some('V'));
2073        assert_eq!(
2074            provider.dose_factor_by_name("Fe56", P::Ingest, S::Epa),
2075            None
2076        );
2077    }
2078}