1use 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
146pub const MEV_PER_U: f64 = 931.494_102_42;
149
150pub const NEUTRON_MASS_U: f64 = 1.008_664_915_95;
152
153pub const HELIUM4_MASS_U: f64 = 4.002_603_254_13;
155
156pub const fn neutron_mass_u() -> f64 {
160 NEUTRON_MASS_U
161}
162
163fn 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
176fn 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
194fn 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
224fn 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
242fn 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
260fn 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
288pub fn mass_table() -> &'static BTreeMap<u32, f64> {
290 masses()
291}
292
293pub fn abundance_table() -> &'static BTreeMap<u32, f64> {
295 abundances()
296}
297
298pub fn half_life_table() -> &'static BTreeMap<u32, f64> {
300 half_lives()
301}
302
303pub fn simple_xs_table() -> &'static BTreeMap<u32, (f64, f64)> {
306 simple_xs_map()
307}
308
309pub fn scattering_length_table() -> &'static BTreeMap<u32, (f64, f64)> {
312 scattering_length_map()
313}
314
315pub fn decay_energy_table() -> &'static BTreeMap<u32, f64> {
317 decay_energy_map()
318}
319
320pub fn simple_xs(nucid: u32) -> Option<(f64, f64)> {
327 simple_xs_map().get(&nucid).copied()
328}
329
330pub fn simple_xs_by_name(name: &str) -> Option<(f64, f64)> {
333 simple_xs(NuclideId::from_name(name).ok()?.nucid())
334}
335
336pub fn scattering_length(nucid: u32) -> Option<(f64, f64)> {
342 scattering_length_map().get(&nucid).copied()
343}
344
345pub fn scattering_length_by_name(name: &str) -> Option<(f64, f64)> {
348 scattering_length(NuclideId::from_name(name).ok()?.nucid())
349}
350
351pub fn decay_energy_mev(nucid: u32) -> Option<f64> {
357 decay_energy_map().get(&nucid).copied()
358}
359
360pub fn decay_energy_mev_by_name(name: &str) -> Option<f64> {
363 decay_energy_mev(NuclideId::from_name(name).ok()?.nucid())
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
378pub enum DecayBranchMode {
379 BetaMinus,
381 EcBetaPlus,
383 Alpha,
385 It,
387 Sf,
389 Neutron,
391 Proton,
393}
394
395impl DecayBranchMode {
396 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 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#[derive(Debug, Clone, Copy, PartialEq)]
426pub struct DecayBranch {
427 pub progeny: u32,
429 pub branching_fraction: f64,
431 pub mode: DecayBranchMode,
433}
434
435fn 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
475pub fn decay_branch_table() -> &'static BTreeMap<u32, Vec<DecayBranch>> {
480 decay_branch_map()
481}
482
483pub fn decay_branches(nucid: u32) -> Option<Vec<DecayBranch>> {
488 decay_branch_map().get(&nucid).cloned()
489}
490
491pub fn decay_branches_by_name(name: &str) -> Option<Vec<DecayBranch>> {
493 decay_branches(NuclideId::from_name(name).ok()?.nucid())
494}
495
496pub 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
505pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
522pub enum FissionYieldOrigin {
523 #[default]
525 NeutronInduced,
526 Spontaneous,
528}
529
530impl FissionYieldOrigin {
531 pub fn as_str(self) -> &'static str {
533 match self {
534 Self::NeutronInduced => "n",
535 Self::Spontaneous => "sf",
536 }
537 }
538
539 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
555pub enum FissionYieldKind {
556 #[default]
558 Independent,
559 Cumulative,
561}
562
563impl FissionYieldKind {
564 pub fn as_str(self) -> &'static str {
566 match self {
567 Self::Independent => "independent",
568 Self::Cumulative => "cumulative",
569 }
570 }
571
572 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#[derive(Debug, Clone, Copy, PartialEq)]
584pub struct FissionYieldProduct {
585 pub progeny: u32,
588 pub yield_fraction: f64,
590 pub uncertainty: f64,
597}
598
599#[derive(Debug, Clone, PartialEq)]
601pub struct FissionYieldSet {
602 pub energy_ev: f64,
604 pub products: Vec<FissionYieldProduct>,
606}
607
608type FissionYieldKey = (u32, FissionYieldOrigin, FissionYieldKind);
610
611fn 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
692pub fn fission_yield_table() -> &'static BTreeMap<FissionYieldKey, Vec<FissionYieldSet>> {
699 fission_yield_map()
700}
701
702pub 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
716pub 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
726pub 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
744pub fn default_fission_yields_by_name(parent: &str) -> Option<FissionYieldSet> {
747 default_fission_yields(NuclideId::from_name(parent).ok()?.nucid())
748}
749
750pub 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
764pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
782pub enum DosePathway {
783 Air,
785 Soil,
787 Ingest,
789 Inhale,
791}
792
793impl DosePathway {
794 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
821pub enum DoseSource {
822 Epa,
824 Doe,
826 Genii,
828}
829
830impl DoseSource {
831 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
876pub struct DoseEntry {
877 pub factor: f64,
879 pub f1: Option<f64>,
881 pub lung_model: Option<char>,
883}
884
885fn 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 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
931pub fn dose_table() -> &'static BTreeMap<(u32, DosePathway, DoseSource), DoseEntry> {
935 dose_factor_map()
936}
937
938pub fn dose_entry(nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<DoseEntry> {
943 dose_factor_map().get(&(nucid, pathway, source)).copied()
944}
945
946pub fn dose_factor(nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
952 dose_entry(nucid, pathway, source).map(|e| e.factor)
953}
954
955pub 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
960pub fn dose_f1(nucid: u32, source: DoseSource) -> Option<f64> {
962 dose_entry(nucid, DosePathway::Ingest, source)?.f1
963}
964
965pub fn dose_f1_by_name(name: &str, source: DoseSource) -> Option<f64> {
967 dose_f1(NuclideId::from_name(name).ok()?.nucid(), source)
968}
969
970pub fn dose_lung_model(nucid: u32, source: DoseSource) -> Option<char> {
972 dose_entry(nucid, DosePathway::Inhale, source)?.lung_model
973}
974
975pub 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
980pub 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
999pub fn atomic_mass_by_name(name: &str) -> Option<f64> {
1001 atomic_mass(NuclideId::from_name(name).ok()?.nucid())
1002}
1003
1004pub fn natural_abundance(nucid: u32) -> Option<f64> {
1010 abundances().get(&nucid).copied()
1011}
1012
1013pub fn natural_abundance_by_name(name: &str) -> Option<f64> {
1015 natural_abundance(NuclideId::from_name(name).ok()?.nucid())
1016}
1017
1018pub fn half_life(nucid: u32) -> Option<f64> {
1023 half_lives().get(&nucid).copied()
1024}
1025
1026pub fn half_life_by_name(name: &str) -> Option<f64> {
1028 half_life(NuclideId::from_name(name).ok()?.nucid())
1029}
1030
1031pub fn decay_constant(nucid: u32) -> Option<f64> {
1035 half_life(nucid).map(|t_half| std::f64::consts::LN_2 / t_half)
1036}
1037
1038pub fn decay_constant_by_name(name: &str) -> Option<f64> {
1040 decay_constant(NuclideId::from_name(name).ok()?.nucid())
1041}
1042
1043pub 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
1071pub fn q_value_neutron_capture_by_name(name: &str) -> Option<f64> {
1073 q_value_neutron_capture(NuclideId::from_name(name).ok()?.nucid())
1074}
1075
1076pub 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
1101pub fn q_value_alpha_by_name(name: &str) -> Option<f64> {
1103 q_value_alpha(NuclideId::from_name(name).ok()?.nucid())
1104}
1105
1106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1112pub struct AmeMasses;
1113
1114impl AmeMasses {
1115 pub fn atomic_mass(&self, nucid: u32) -> Option<f64> {
1117 atomic_mass(nucid)
1118 }
1119
1120 pub fn atomic_mass_by_name(&self, name: &str) -> Option<f64> {
1122 atomic_mass_by_name(name)
1123 }
1124
1125 pub fn natural_abundance(&self, nucid: u32) -> Option<f64> {
1127 natural_abundance(nucid)
1128 }
1129
1130 pub fn natural_abundance_by_name(&self, name: &str) -> Option<f64> {
1132 natural_abundance_by_name(name)
1133 }
1134}
1135
1136#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1143pub struct DecayData;
1144
1145impl DecayData {
1146 pub fn half_life(&self, nucid: u32) -> Option<f64> {
1148 half_life(nucid)
1149 }
1150
1151 pub fn half_life_by_name(&self, name: &str) -> Option<f64> {
1153 half_life_by_name(name)
1154 }
1155
1156 pub fn decay_constant(&self, nucid: u32) -> Option<f64> {
1158 decay_constant(nucid)
1159 }
1160
1161 pub fn decay_constant_by_name(&self, name: &str) -> Option<f64> {
1163 decay_constant_by_name(name)
1164 }
1165
1166 pub fn decay_energy_mev(&self, nucid: u32) -> Option<f64> {
1168 decay_energy_mev(nucid)
1169 }
1170
1171 pub fn decay_energy_mev_by_name(&self, name: &str) -> Option<f64> {
1173 decay_energy_mev_by_name(name)
1174 }
1175
1176 pub fn decay_branches(&self, nucid: u32) -> Option<Vec<DecayBranch>> {
1178 decay_branches(nucid)
1179 }
1180
1181 pub fn decay_branches_by_name(&self, name: &str) -> Option<Vec<DecayBranch>> {
1183 decay_branches_by_name(name)
1184 }
1185
1186 pub fn branching_fraction(&self, parent: u32, progeny: u32) -> Option<f64> {
1188 branching_fraction(parent, progeny)
1189 }
1190
1191 pub fn branching_fraction_by_name(&self, parent: &str, progeny: &str) -> Option<f64> {
1193 branching_fraction_by_name(parent, progeny)
1194 }
1195}
1196
1197#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1203pub struct DoseData;
1204
1205impl DoseData {
1206 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 pub fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
1218 dose_factor(nucid, pathway, source)
1219 }
1220
1221 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 pub fn dose_f1(&self, nucid: u32, source: DoseSource) -> Option<f64> {
1233 dose_f1(nucid, source)
1234 }
1235
1236 pub fn dose_f1_by_name(&self, name: &str, source: DoseSource) -> Option<f64> {
1238 dose_f1_by_name(name, source)
1239 }
1240
1241 pub fn dose_lung_model(&self, nucid: u32, source: DoseSource) -> Option<char> {
1243 dose_lung_model(nucid, source)
1244 }
1245
1246 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 assert_eq!(atomic_mass(10_000), None);
1278 assert_eq!(atomic_mass(999_999_999), None);
1279 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 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 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 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 let cu_m2 = NuclideId::from_name("Cu70_m2").unwrap().nucid();
1370 assert!(atomic_mass(cu_m2).unwrap() >= atomic_mass_by_name("Cu70").unwrap());
1371 assert_eq!(
1374 atomic_mass_by_name("Pm137_m1"),
1375 atomic_mass_by_name("Pm137")
1376 );
1377 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 let t_u238 = half_life(U238).unwrap();
1398 assert!((t_u238 - 1.409_99e17).abs() / t_u238 < 1e-9);
1399 assert_eq!(half_life(I135), Some(23_652.0));
1401 let t_cs135 = half_life_by_name("Cs135").unwrap();
1403 assert!((t_cs135 - 7.258_25e13).abs() / t_cs135 < 1e-12);
1404 let t_cs137 = half_life(CS137).unwrap();
1408 assert!((t_cs137 / 3.155_76e7 - 30.08).abs() < 0.01, "{t_cs137}");
1409 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 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 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 let q_h = q_value_neutron_capture(H1).unwrap();
1478 assert!((q_h - 2.224_566).abs() < 1e-3, "{q_h}");
1479 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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); 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 let total: f64 = thermal.products.iter().map(|p| p.yield_fraction).sum();
1838 assert!((total - 2.0).abs() < 1e-6, "{total}");
1839 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 let xe_m1 = fission_yield_by_name("U235", "Xe135_m1");
1855 assert_eq!(xe_m1, Some(0.001_781_22));
1856 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 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 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 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 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 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 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 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 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 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 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 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}