1use std::collections::BTreeMap;
5use std::ops::{Add, Div, Mul, Sub};
6
7use nucleide_nuclei::NuclideId;
8use serde::de::{Deserialize, Deserializer};
9use serde::ser::{Serialize, SerializeStruct, Serializer};
10
11use crate::Error;
12
13fn not_positive(v: f64) -> bool {
15 v.is_nan() || v <= 0.0
16}
17
18fn is_negative(v: f64) -> bool {
20 v.is_nan() || v < 0.0
21}
22
23pub trait MassProvider {
30 fn mass(&self, nucid: u32) -> Option<f64>;
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub struct NoMasses;
41
42impl MassProvider for NoMasses {
43 fn mass(&self, _nucid: u32) -> Option<f64> {
44 None
45 }
46}
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub struct Ame2020;
51
52impl MassProvider for Ame2020 {
53 fn mass(&self, nucid: u32) -> Option<f64> {
54 nucleide_nuclei::data::atomic_mass(nucid)
55 }
56}
57
58#[derive(Debug, Clone, Default, PartialEq)]
69pub struct Material {
70 pub comp: BTreeMap<NuclideId, f64>,
72 density: Option<f64>,
73 metadata: Option<serde_json::Value>,
74}
75
76impl Material {
77 pub fn new() -> Self {
79 Self::default()
80 }
81
82 pub fn from_atom_frac(
89 atoms: &[(NuclideId, f64)],
90 masses: &impl MassProvider,
91 density: Option<f64>,
92 ) -> crate::Result<Self> {
93 let mut mat = Self {
94 density,
95 ..Self::default()
96 };
97 for &(id, atoms) in atoms {
98 if atoms == 0.0 {
99 continue;
100 }
101 let am = masses
102 .mass(id.nucid())
103 .ok_or(crate::Error::MissingMass(id))?;
104 mat.comp.insert(id, am * atoms);
105 }
106 Ok(mat)
107 }
108
109 pub fn add_nuclide(&mut self, id: NuclideId, mass: f64) {
111 *self.comp.entry(id).or_insert(0.0) += mass;
112 }
113
114 pub fn remove_nuclide(&mut self, id: NuclideId) -> Option<f64> {
116 self.comp.remove(&id)
117 }
118
119 pub fn clear(&mut self) {
121 self.comp.clear();
122 }
123
124 pub fn mass(&self) -> f64 {
126 self.comp.values().sum()
127 }
128
129 pub fn density(&self) -> Option<f64> {
131 self.density
132 }
133
134 pub fn set_density(&mut self, density: Option<f64>) {
136 self.density = density;
137 }
138
139 pub fn metadata(&self) -> Option<&serde_json::Value> {
141 self.metadata.as_ref()
142 }
143
144 pub fn set_metadata(&mut self, metadata: Option<serde_json::Value>) {
146 self.metadata = metadata;
147 }
148
149 pub fn weight_fractions(&self) -> crate::Result<BTreeMap<NuclideId, f64>> {
151 let total = self.mass();
152 if not_positive(total) {
153 return Err(crate::Error::Degenerate);
154 }
155 Ok(self.comp.iter().map(|(&id, &m)| (id, m / total)).collect())
156 }
157
158 pub fn atom_fractions(
163 &self,
164 masses: &impl MassProvider,
165 ) -> crate::Result<BTreeMap<NuclideId, f64>> {
166 let mut moles = BTreeMap::new();
167 let mut total = 0.0;
168 for (&id, &m) in &self.comp {
169 let am = masses
170 .mass(id.nucid())
171 .ok_or(crate::Error::MissingMass(id))?;
172 let n = m / am;
173 moles.insert(id, n);
174 total += n;
175 }
176 if not_positive(total) {
177 return Err(crate::Error::Degenerate);
178 }
179 Ok(moles.into_iter().map(|(id, n)| (id, n / total)).collect())
180 }
181
182 pub fn mix_by_mass(parts: &[(&Material, f64)]) -> crate::Result<Self> {
187 let mut out = Self::new();
188 for &(mat, frac) in parts {
189 if is_negative(frac) {
190 return Err(crate::Error::NegativeFraction(frac));
191 }
192 for (&id, &m) in &mat.comp {
193 out.add_nuclide(id, frac * m);
194 }
195 }
196 if not_positive(out.mass()) {
197 return Err(crate::Error::Degenerate);
198 }
199 Ok(out)
200 }
201
202 pub fn mix_by_volume(parts: &[(&Material, f64)]) -> crate::Result<Self> {
206 let mut out = Self::new();
207 for &(mat, vol) in parts {
208 if is_negative(vol) {
209 return Err(crate::Error::NegativeFraction(vol));
210 }
211 match mat.density() {
212 Some(rho) if rho > 0.0 => {
213 for (&id, &m) in &mat.comp {
214 out.add_nuclide(id, vol * rho * m / mat.mass());
215 }
216 }
217 _ => return Err(crate::Error::MissingDensity),
218 }
219 }
220 if not_positive(out.mass()) {
221 return Err(crate::Error::Degenerate);
222 }
223 Ok(out)
224 }
225
226 pub fn separate(&self, effs: &[(NuclideId, f64)]) -> crate::Result<(Self, Self)> {
239 let mut table = BTreeMap::new();
240 for &(id, eff) in effs {
241 if !eff.is_finite() || eff < 0.0 || eff > 1.0 {
242 return Err(crate::Error::InvalidEfficiency(eff));
243 }
244 table.insert(id, eff);
245 }
246 let mut product = Self::new();
247 let mut tails = Self::new();
248 for (&id, &m) in &self.comp {
249 let eff = table.get(&id).copied().unwrap_or(0.0);
250 let p = m * eff;
251 let t = m - p;
252 if p != 0.0 {
253 product.comp.insert(id, p);
254 }
255 if t != 0.0 {
256 tails.comp.insert(id, t);
257 }
258 }
259 Ok((product, tails))
260 }
261
262 pub fn blend(parts: &[(&Material, f64)]) -> crate::Result<Self> {
272 if parts.is_empty() {
273 return Err(crate::Error::Degenerate);
274 }
275 let mut sum = 0.0;
276 for &(_, ratio) in parts {
277 if !ratio.is_finite() || ratio < 0.0 {
278 return Err(crate::Error::NegativeFraction(ratio));
279 }
280 sum += ratio;
281 }
282 if !(sum > 0.0 && sum.is_finite()) {
283 return Err(crate::Error::Degenerate);
284 }
285 let mut out = Self::new();
286 for &(mat, ratio) in parts {
287 let w = ratio / sum;
288 for (&id, &m) in &mat.comp {
289 out.add_nuclide(id, w * m);
290 }
291 }
292 if not_positive(out.mass()) {
293 return Err(crate::Error::Degenerate);
294 }
295 Ok(out)
296 }
297
298 fn scaled(&self, factor: f64) -> Self {
300 Self {
301 comp: self.comp.iter().map(|(&id, &m)| (id, m * factor)).collect(),
302 density: self.density,
303 metadata: self.metadata.clone(),
304 }
305 }
306}
307
308impl Add for Material {
309 type Output = Material;
310
311 fn add(self, rhs: Material) -> Material {
315 let mut comp = self.comp;
316 for (id, m) in rhs.comp {
317 *comp.entry(id).or_insert(0.0) += m;
318 }
319 comp.retain(|_, m| *m != 0.0);
320 Material {
321 comp,
322 density: None,
323 metadata: None,
324 }
325 }
326}
327
328impl Sub for Material {
329 type Output = Material;
330
331 fn sub(self, rhs: Material) -> Material {
335 let mut comp = self.comp;
336 for (id, m) in rhs.comp {
337 *comp.entry(id).or_insert(0.0) -= m;
338 }
339 comp.retain(|_, m| *m != 0.0);
340 Material {
341 comp,
342 density: None,
343 metadata: None,
344 }
345 }
346}
347
348impl Mul<f64> for Material {
349 type Output = Material;
350
351 fn mul(self, rhs: f64) -> Material {
353 self.scaled(rhs)
354 }
355}
356
357impl Div<f64> for Material {
358 type Output = Material;
359
360 fn div(self, rhs: f64) -> Material {
365 assert!(rhs != 0.0, "cannot divide a material mass by zero");
366 self.scaled(1.0 / rhs)
367 }
368}
369
370impl Add<f64> for Material {
371 type Output = Material;
372
373 fn add(self, rhs: f64) -> Material {
379 let total = self.mass();
380 let new_total = total + rhs;
381 assert!(
382 total > 0.0 && new_total > 0.0,
383 "cannot add {rhs} g to a material of {total} g"
384 );
385 self.scaled(new_total / total)
386 }
387}
388
389impl Sub<f64> for Material {
390 type Output = Material;
391
392 fn sub(self, rhs: f64) -> Material {
398 let total = self.mass();
399 let new_total = total - rhs;
400 assert!(
401 total > 0.0 && new_total > 0.0,
402 "cannot subtract {rhs} g from a material of {total} g"
403 );
404 self.scaled(new_total / total)
405 }
406}
407
408impl Serialize for Material {
409 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
410 where
411 S: Serializer,
412 {
413 let comp: BTreeMap<String, f64> =
414 self.comp.iter().map(|(id, m)| (id.to_name(), *m)).collect();
415 let mut state = serializer.serialize_struct("Material", 3)?;
416 state.serialize_field("comp", &comp)?;
417 state.serialize_field("density", &self.density)?;
418 state.serialize_field("metadata", &self.metadata)?;
419 state.end()
420 }
421}
422
423impl<'de> Deserialize<'de> for Material {
424 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
425 where
426 D: Deserializer<'de>,
427 {
428 #[derive(serde::Deserialize)]
429 struct RawMaterial {
430 comp: BTreeMap<String, f64>,
431 density: Option<f64>,
432 metadata: Option<serde_json::Value>,
433 }
434
435 let raw = RawMaterial::deserialize(deserializer)?;
436 let mut comp = BTreeMap::new();
437 for (name, mass) in raw.comp {
438 let id = NuclideId::from_name(&name).map_err(|source| {
439 serde::de::Error::custom(Error::BadNuclide {
440 name: name.clone(),
441 source,
442 })
443 })?;
444 comp.insert(id, mass);
445 }
446 Ok(Material {
447 comp,
448 density: raw.density,
449 metadata: raw.metadata,
450 })
451 }
452}
453
454pub const AVOGADRO: f64 = 6.022_140_76e23;
472
473pub const GRAMS_PER_U: f64 = 1.660_539_068_92e-24;
475
476pub const MEV_TO_JOULES: f64 = 1.602_176_634e-13;
478
479pub const CI_PER_BQ: f64 = 2.702_702_7e-11;
481
482pub const PCI_PER_BQ: f64 = 27.027_027;
484
485pub use nucleide_nuclei::data::{DosePathway, DoseSource};
488
489pub trait DecayProvider {
494 fn decay_constant(&self, nucid: u32) -> Option<f64>;
497}
498
499#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
504pub struct NoDecay;
505
506impl DecayProvider for NoDecay {
507 fn decay_constant(&self, _nucid: u32) -> Option<f64> {
508 None
509 }
510}
511
512#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
515pub struct ChainDecays;
516
517impl DecayProvider for ChainDecays {
518 fn decay_constant(&self, nucid: u32) -> Option<f64> {
519 nucleide_nuclei::data::decay_constant(nucid)
520 }
521}
522
523pub struct Analytics<'a> {
537 pub masses: &'a dyn MassProvider,
539 pub decays: &'a dyn DecayProvider,
541}
542
543impl std::fmt::Debug for Analytics<'_> {
544 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
545 f.debug_struct("Analytics").finish_non_exhaustive()
546 }
547}
548
549#[derive(Debug, Error)]
551#[non_exhaustive]
552pub enum AnalyticsError {
553 #[error("no decay data available for nuclide `{0}`")]
562 MissingDecay(NuclideId),
563 #[error("no decay energy available for nuclide `{0}`")]
565 MissingEnergy(NuclideId),
566 #[error("no dose factor available for nuclide `{0}`")]
568 MissingDose(NuclideId),
569 #[error(transparent)]
571 Core(#[from] crate::Error),
572}
573
574pub trait DecayEnergyProvider {
583 fn decay_energy_mev(&self, nucid: u32) -> Option<f64>;
586}
587
588#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
593pub struct NoDecayEnergies;
594
595impl DecayEnergyProvider for NoDecayEnergies {
596 fn decay_energy_mev(&self, _nucid: u32) -> Option<f64> {
597 None
598 }
599}
600
601#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
606pub struct DecayEnergies;
607
608impl DecayEnergyProvider for DecayEnergies {
609 fn decay_energy_mev(&self, nucid: u32) -> Option<f64> {
610 nucleide_nuclei::data::decay_energy_mev(nucid)
611 }
612}
613
614impl DecayEnergyProvider for nucleide_nuclei::data::DecayData {
615 fn decay_energy_mev(&self, nucid: u32) -> Option<f64> {
616 nucleide_nuclei::data::decay_energy_mev(nucid)
617 }
618}
619
620pub trait DoseProvider {
628 fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64>;
636}
637
638#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
643pub struct NoDoses;
644
645impl DoseProvider for NoDoses {
646 fn dose_factor(&self, _nucid: u32, _pathway: DosePathway, _source: DoseSource) -> Option<f64> {
647 None
648 }
649}
650
651#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
655pub struct DoseFactors;
656
657impl DoseProvider for DoseFactors {
658 fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
659 nucleide_nuclei::data::dose_factor(nucid, pathway, source)
660 }
661}
662
663impl DoseProvider for nucleide_nuclei::data::DoseData {
664 fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
665 nucleide_nuclei::data::dose_factor(nucid, pathway, source)
666 }
667}
668
669impl Material {
670 pub fn activity(
681 &self,
682 analytics: &Analytics<'_>,
683 ) -> std::result::Result<BTreeMap<NuclideId, f64>, AnalyticsError> {
684 let mut out = BTreeMap::new();
685 for (&id, &grams) in &self.comp {
686 let mass_u = analytics
687 .masses
688 .mass(id.nucid())
689 .ok_or(crate::Error::MissingMass(id))?;
690 let lambda = analytics.decays.decay_constant(id.nucid()).unwrap_or(0.0);
691 let atoms = grams / (mass_u * GRAMS_PER_U);
692 out.insert(id, lambda * atoms);
693 }
694 Ok(out)
695 }
696
697 pub fn specific_activity(&self, analytics: &Analytics<'_>) -> Result<f64, AnalyticsError> {
703 let total_mass = self.mass();
704 if not_positive(total_mass) {
705 return Err(crate::Error::Degenerate.into());
706 }
707 let mut total_activity = 0.0;
708 for value in self.activity(analytics)?.values() {
709 total_activity += value;
710 }
711 Ok(total_activity / total_mass)
712 }
713
714 pub fn decay_heat(
729 &self,
730 analytics: &Analytics<'_>,
731 energies: &impl DecayEnergyProvider,
732 ) -> Result<BTreeMap<NuclideId, f64>, AnalyticsError> {
733 let activities = self.activity(analytics)?;
734 let mut out = BTreeMap::new();
735 for (&id, &activity_bq) in &activities {
736 if activity_bq == 0.0 {
739 out.insert(id, 0.0);
740 continue;
741 }
742 let mev = energies
743 .decay_energy_mev(id.nucid())
744 .ok_or(AnalyticsError::MissingEnergy(id))?;
745 out.insert(id, activity_bq * mev * MEV_TO_JOULES);
746 }
747 Ok(out)
748 }
749
750 pub fn total_decay_heat(
753 &self,
754 analytics: &Analytics<'_>,
755 energies: &impl DecayEnergyProvider,
756 ) -> Result<f64, AnalyticsError> {
757 let mut total = 0.0;
758 for value in self.decay_heat(analytics, energies)?.values() {
759 total += value;
760 }
761 Ok(total)
762 }
763
764 pub fn dose_per_g(
788 &self,
789 analytics: &Analytics<'_>,
790 doses: &impl DoseProvider,
791 pathway: DosePathway,
792 source: DoseSource,
793 ) -> Result<BTreeMap<NuclideId, f64>, AnalyticsError> {
794 let total_mass = self.mass();
795 if not_positive(total_mass) {
796 return Err(crate::Error::Degenerate.into());
797 }
798 let per_bq = match pathway {
799 DosePathway::Air | DosePathway::Soil => CI_PER_BQ,
800 DosePathway::Ingest | DosePathway::Inhale => PCI_PER_BQ,
801 };
802 let mut out = BTreeMap::new();
803 for (&id, &grams) in &self.comp {
804 let mass_u = analytics
805 .masses
806 .mass(id.nucid())
807 .ok_or(crate::Error::MissingMass(id))?;
808 let lambda = analytics.decays.decay_constant(id.nucid()).unwrap_or(0.0);
809 if lambda <= 0.0 {
810 out.insert(id, 0.0);
811 continue;
812 }
813 let df = doses
814 .dose_factor(id.nucid(), pathway, source)
815 .filter(|v| v.is_finite() && *v >= 0.0)
816 .ok_or(AnalyticsError::MissingDose(id))?;
817 let w = grams / total_mass;
818 out.insert(id, per_bq * AVOGADRO * w * lambda * df / mass_u);
819 }
820 Ok(out)
821 }
822
823 pub fn total_dose_per_g(
826 &self,
827 analytics: &Analytics<'_>,
828 doses: &impl DoseProvider,
829 pathway: DosePathway,
830 source: DoseSource,
831 ) -> Result<f64, AnalyticsError> {
832 let mut total = 0.0;
833 for value in self.dose_per_g(analytics, doses, pathway, source)?.values() {
834 total += value;
835 }
836 Ok(total)
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 fn id(name: &str) -> NuclideId {
845 NuclideId::from_name(name).unwrap()
846 }
847
848 fn close(a: f64, b: f64) {
849 assert!((a - b).abs() < 1e-12, "{a} != {b}");
850 }
851
852 struct Table(BTreeMap<u32, f64>);
854
855 impl Table {
856 fn new(pairs: &[(&str, f64)]) -> Self {
857 Self(
858 pairs
859 .iter()
860 .map(|&(name, m)| (id(name).nucid(), m))
861 .collect(),
862 )
863 }
864 }
865
866 impl MassProvider for Table {
867 fn mass(&self, nucid: u32) -> Option<f64> {
868 self.0.get(&nucid).copied()
869 }
870 }
871
872 fn water_table() -> Table {
873 Table::new(&[("H1", 1.0), ("O16", 16.0)])
874 }
875
876 #[test]
877 fn empty_material_has_zero_mass() {
878 let mat = Material::new();
879 close(mat.mass(), 0.0);
880 assert!(mat.comp.is_empty());
881 assert_eq!(mat.density(), None);
882 }
883
884 #[test]
885 fn add_nuclide_accumulates_and_remove_returns_mass() {
886 let mut mat = Material::new();
887 let u5 = id("U235");
888 mat.add_nuclide(u5, 10.0);
889 mat.add_nuclide(u5, 5.0);
890 close(mat.mass(), 15.0);
891 close(mat.remove_nuclide(u5).unwrap(), 15.0);
892 assert_eq!(mat.remove_nuclide(u5), None);
893 }
894
895 #[test]
896 fn clear_drops_composition_only() {
897 let mut mat = Material::new();
898 mat.add_nuclide(id("U235"), 3.0);
899 mat.add_nuclide(id("U238"), 1.0);
900 mat.set_density(Some(19.1));
901 mat.clear();
902 assert!(mat.comp.is_empty());
903 assert_eq!(mat.density(), Some(19.1));
904 }
905
906 #[test]
907 fn from_atom_frac_water_hand_computed() {
908 let mat = Material::from_atom_frac(
909 &[(id("H1"), 2.0), (id("O16"), 1.0)],
910 &water_table(),
911 Some(1.0),
912 )
913 .unwrap();
914
915 close(mat.comp[&id("H1")], 2.0);
916 close(mat.comp[&id("O16")], 16.0);
917 close(mat.mass(), 18.0);
918
919 let wf = mat.weight_fractions().unwrap();
920 close(wf[&id("H1")], 1.0 / 9.0);
921 close(wf[&id("O16")], 8.0 / 9.0);
922
923 let af = mat.atom_fractions(&water_table()).unwrap();
924 close(af[&id("H1")], 2.0 / 3.0);
925 close(af[&id("O16")], 1.0 / 3.0);
926 }
927
928 #[test]
929 fn from_atom_frac_skips_zero_counts_and_sets_density() {
930 let mat =
931 Material::from_atom_frac(&[(id("H1"), 0.0), (id("O16"), 1.0)], &water_table(), None)
932 .unwrap();
933 assert!(!mat.comp.contains_key(&id("H1")));
934 assert!(mat.comp.contains_key(&id("O16")));
935 assert_eq!(mat.density(), None);
936 }
937
938 #[test]
939 fn from_atom_frac_without_masses_errors() {
940 let err = Material::from_atom_frac(&[(id("U235"), 1.0)], &NoMasses, None).unwrap_err();
941 assert!(matches!(err, Error::MissingMass(_)));
942 }
943
944 #[test]
945 fn weight_fractions_normalize_to_one() {
946 let mut mat = Material::new();
947 mat.add_nuclide(id("U235"), 19.0);
948 mat.add_nuclide(id("U238"), 1.0);
949 let wf = mat.weight_fractions().unwrap();
950 close(wf[&id("U235")], 0.95);
951 close(wf[&id("U238")], 0.05);
952 close(wf.values().sum(), 1.0);
953 }
954
955 #[test]
956 fn weight_fractions_of_empty_material_error() {
957 assert!(matches!(
958 Material::new().weight_fractions(),
959 Err(Error::Degenerate)
960 ));
961 }
962
963 #[test]
964 fn atom_fractions_missing_mass_errors() {
965 let mut mat = Material::new();
966 mat.add_nuclide(id("U235"), 1.0);
967 assert!(matches!(
968 mat.atom_fractions(&NoMasses),
969 Err(Error::MissingMass(_))
970 ));
971 }
972
973 #[test]
974 fn adding_materials_mixes_by_mass() {
975 let mut fuel = Material::new();
976 fuel.add_nuclide(id("U235"), 3.0);
977 fuel.set_density(Some(19.0));
978
979 let mut matrix = Material::new();
980 matrix.add_nuclide(id("U238"), 1.0);
981 matrix.set_density(Some(10.0));
982
983 let mixed = fuel + matrix;
984 close(mixed.mass(), 4.0);
985 let wf = mixed.weight_fractions().unwrap();
986 close(wf[&id("U235")], 0.75);
987 close(wf[&id("U238")], 0.25);
988 assert_eq!(mixed.density(), None, "mixtures have no single density");
989 }
990
991 #[test]
992 fn subtracting_materials_removes_stream() {
993 let mut a = Material::new();
994 a.add_nuclide(id("U235"), 3.0);
995 a.add_nuclide(id("U238"), 1.0);
996 let mut b = Material::new();
997 b.add_nuclide(id("U238"), 1.0);
998
999 let rest = a - b;
1000 assert_eq!(rest.comp.len(), 1);
1001 close(rest.comp[&id("U235")], 3.0);
1002 }
1003
1004 #[test]
1005 fn scalar_mul_div_scale_masses_and_keep_density() {
1006 let mut mat = Material::new();
1007 mat.add_nuclide(id("U235"), 3.0);
1008 mat.add_nuclide(id("U238"), 1.0);
1009 mat.set_density(Some(19.1));
1010
1011 let doubled = mat.clone() * 2.0;
1012 close(doubled.mass(), 8.0);
1013 close(doubled.comp[&id("U235")], 6.0);
1014 assert_eq!(doubled.density(), Some(19.1));
1015
1016 let quartered = doubled / 4.0;
1017 close(quartered.mass(), 2.0);
1018 close(quartered.comp[&id("U238")], 0.5);
1019 }
1020
1021 #[test]
1022 fn scalar_add_sub_shift_total_mass_proportionally() {
1023 let mut mat = Material::new();
1024 mat.add_nuclide(id("U235"), 2.0);
1025 mat.set_density(Some(19.1));
1026
1027 let grown = mat.clone() + 1.0;
1028 close(grown.mass(), 3.0);
1029 close(grown.comp[&id("U235")], 3.0);
1030
1031 let shrunk = grown - 1.0;
1032 close(shrunk.mass(), 2.0);
1033 close(shrunk.comp[&id("U235")], 2.0);
1034 assert_eq!(shrunk.density(), Some(19.1));
1035 }
1036
1037 #[test]
1038 #[should_panic(expected = "divide")]
1039 fn divide_by_zero_panics() {
1040 let _ = Material::new() / 0.0;
1041 }
1042
1043 #[test]
1044 #[should_panic(expected = "cannot add")]
1045 fn scalar_add_to_zero_mass_panics() {
1046 let _ = Material::new() + 5.0;
1047 }
1048
1049 #[test]
1050 #[should_panic(expected = "cannot subtract")]
1051 fn scalar_sub_below_zero_panics() {
1052 let mut mat = Material::new();
1053 mat.add_nuclide(id("U235"), 1.0);
1054 let _ = mat - 2.0;
1055 }
1056
1057 #[test]
1058 fn mix_by_mass_weights_full_streams() {
1059 let mut a = Material::new();
1060 a.add_nuclide(id("U235"), 1.0);
1061 a.add_nuclide(id("Pu239"), 1.0);
1062 let mut b = Material::new();
1063 b.add_nuclide(id("U238"), 1.0);
1064
1065 let mixed = Material::mix_by_mass(&[(&a, 1.0), (&b, 2.0)]).unwrap();
1066 close(mixed.mass(), 4.0);
1069 let wf = mixed.weight_fractions().unwrap();
1070 close(wf[&id("U235")], 0.25);
1071 close(wf[&id("Pu239")], 0.25);
1072 close(wf[&id("U238")], 0.5);
1073 }
1074
1075 #[test]
1076 fn mix_by_volume_converts_through_densities() {
1077 let mut heavy = Material::new();
1078 heavy.add_nuclide(id("U238"), 1.0);
1079 heavy.set_density(Some(10.0));
1080 let mut light = Material::new();
1081 light.add_nuclide(id("H1"), 1.0);
1082 light.set_density(Some(2.0));
1083
1084 let mixed = Material::mix_by_volume(&[(&heavy, 1.0), (&light, 1.5)]).unwrap();
1086 close(mixed.comp[&id("U238")], 10.0);
1087 close(mixed.comp[&id("H1")], 3.0);
1088 }
1089
1090 #[test]
1091 fn mix_by_volume_requires_density() {
1092 let mut mat = Material::new();
1093 mat.add_nuclide(id("U235"), 1.0);
1094 assert!(matches!(
1095 Material::mix_by_volume(&[(&mat, 1.0)]),
1096 Err(Error::MissingDensity)
1097 ));
1098 }
1099
1100 #[test]
1101 fn negative_mix_fraction_rejected() {
1102 let mut mat = Material::new();
1103 mat.add_nuclide(id("U235"), 1.0);
1104 assert!(matches!(
1105 Material::mix_by_mass(&[(&mat, -1.0)]),
1106 Err(Error::NegativeFraction(_))
1107 ));
1108 }
1109
1110 fn sep_feed() -> Material {
1113 let mut mat = Material::new();
1114 mat.add_nuclide(id("U235"), 10.0);
1115 mat.add_nuclide(id("U238"), 90.0);
1116 mat.add_nuclide(id("Pu239"), 1.0);
1117 mat.add_nuclide(id("Pu240"), 2.0);
1118 mat.add_nuclide(id("Am241"), 3.0);
1119 mat.add_nuclide(id("Am242"), 2.8);
1120 mat
1121 }
1122
1123 #[test]
1124 fn separate_splits_by_efficiency_and_conserves_mass() {
1125 let feed = sep_feed();
1128 let effs = [
1129 (id("U235"), 0.7),
1130 (id("U238"), 0.7),
1131 (id("Pu239"), 0.4),
1132 (id("Pu240"), 0.4),
1133 (id("Am241"), 0.4),
1134 ];
1135 let (product, tails) = feed.separate(&effs).unwrap();
1136
1137 close(product.comp[&id("U235")], 7.0);
1139 close(product.comp[&id("U238")], 63.0);
1140 close(product.comp[&id("Pu239")], 0.4);
1141 close(product.comp[&id("Pu240")], 0.8);
1142 close(product.comp[&id("Am241")], 1.2);
1143 assert!(!product.comp.contains_key(&id("Am242")));
1144 close(product.mass(), 72.4);
1145
1146 close(tails.comp[&id("U235")], 3.0);
1148 close(tails.comp[&id("U238")], 27.0);
1149 close(tails.comp[&id("Pu239")], 0.6);
1150 close(tails.comp[&id("Pu240")], 1.2);
1151 close(tails.comp[&id("Am241")], 1.8);
1152 close(tails.comp[&id("Am242")], 2.8);
1153 close(tails.mass(), 36.4);
1154
1155 for (&nuc, &m) in &feed.comp {
1157 let p = product.comp.get(&nuc).copied().unwrap_or(0.0);
1158 let t = tails.comp.get(&nuc).copied().unwrap_or(0.0);
1159 close(p + t, m);
1160 }
1161 close(product.mass() + tails.mass(), feed.mass());
1162 assert_eq!(product.density(), None);
1163 assert_eq!(tails.density(), None);
1164 }
1165
1166 #[test]
1167 fn separate_edge_efficiencies_route_wholly() {
1168 let feed = sep_feed();
1169 let (all_product, no_tails) = feed
1171 .separate(&[
1172 (id("U235"), 1.0),
1173 (id("U238"), 1.0),
1174 (id("Pu239"), 1.0),
1175 (id("Pu240"), 1.0),
1176 (id("Am241"), 1.0),
1177 (id("Am242"), 1.0),
1178 ])
1179 .unwrap();
1180 close(all_product.mass(), feed.mass());
1181 assert!(no_tails.comp.is_empty());
1182
1183 let (no_product, all_tails) = feed.separate(&[]).unwrap();
1184 assert!(no_product.comp.is_empty());
1185 close(all_tails.mass(), feed.mass());
1186 }
1187
1188 #[test]
1189 fn separate_rejects_out_of_range_efficiencies() {
1190 let feed = sep_feed();
1191 for bad in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
1192 assert!(
1193 matches!(
1194 feed.separate(&[(id("U235"), bad)]),
1195 Err(Error::InvalidEfficiency(_))
1196 ),
1197 "efficiency {bad} must be rejected"
1198 );
1199 }
1200 }
1201
1202 #[test]
1203 fn blend_normalizes_fixed_ratios() {
1204 let mut a = Material::new();
1205 a.add_nuclide(id("U235"), 1.0);
1206 a.add_nuclide(id("Pu239"), 1.0);
1207 let mut b = Material::new();
1208 b.add_nuclide(id("U238"), 1.0);
1209
1210 let out = Material::blend(&[(&a, 1.0), (&b, 2.0)]).unwrap();
1213 close(out.comp[&id("U235")], 1.0 / 3.0);
1214 close(out.comp[&id("Pu239")], 1.0 / 3.0);
1215 close(out.comp[&id("U238")], 2.0 / 3.0);
1216 close(out.mass(), 4.0 / 3.0);
1217 let wf = out.weight_fractions().unwrap();
1218 close(wf[&id("U235")], 0.25);
1219 close(wf[&id("Pu239")], 0.25);
1220 close(wf[&id("U238")], 0.5);
1221
1222 let half = Material::blend(&[(&a, 2.0), (&b, 2.0)]).unwrap();
1224 close(half.comp[&id("U235")], 0.5);
1225 close(half.comp[&id("Pu239")], 0.5);
1226 close(half.comp[&id("U238")], 0.5);
1227 close(half.mass(), 1.5);
1228 assert_eq!(half.density(), None);
1229 }
1230
1231 #[test]
1232 fn blend_rejects_degenerate_and_negative_recipes() {
1233 let mut a = Material::new();
1234 a.add_nuclide(id("U235"), 1.0);
1235 assert!(matches!(Material::blend(&[]), Err(Error::Degenerate)));
1237 assert!(matches!(
1238 Material::blend(&[(&a, 0.0)]),
1239 Err(Error::Degenerate)
1240 ));
1241 for bad in [-1.0, f64::NAN, f64::INFINITY] {
1243 assert!(
1244 matches!(
1245 Material::blend(&[(&a, bad)]),
1246 Err(Error::NegativeFraction(_))
1247 ),
1248 "ratio {bad} must be rejected"
1249 );
1250 }
1251 }
1252
1253 #[test]
1254 fn json_round_trip_preserves_everything() {
1255 let mut mat = Material::new();
1256 mat.add_nuclide(id("U235"), 19.0);
1257 mat.add_nuclide(id("Am242_m1"), 1.0);
1258 mat.set_density(Some(19.1));
1259 mat.set_metadata(Some(serde_json::json!({"enrichment": 0.03})));
1260
1261 let text = serde_json::to_string(&mat).unwrap();
1262 let parsed: Material = serde_json::from_str(&text).unwrap();
1263 assert_eq!(parsed, mat);
1264 }
1265
1266 #[test]
1267 fn json_uses_gnds_names_as_keys() {
1268 let mut mat = Material::new();
1269 mat.add_nuclide(id("U235"), 1.0);
1270 let text = serde_json::to_string(&mat).unwrap();
1271 assert!(
1272 text.contains("\"comp\":{\"U235\":1.0}"),
1273 "unexpected serialization: {text}"
1274 );
1275 }
1276
1277 #[test]
1278 fn json_rejects_unknown_nuclide_names() {
1279 let err = serde_json::from_str::<Material>(
1280 r#"{"comp":{"Notanuclide":1.0},"density":null,"metadata":null}"#,
1281 )
1282 .unwrap_err()
1283 .to_string();
1284 assert!(err.contains("invalid nuclide name `Notanuclide`"), "{err}");
1285 }
1286}
1287
1288#[cfg(test)]
1289mod radio_tests {
1290 use super::*;
1291 use std::f64::consts::LN_2;
1292
1293 fn nid(name: &str) -> NuclideId {
1294 NuclideId::from_name(name).unwrap()
1295 }
1296
1297 #[test]
1298 fn activity_of_one_gram_co60_matches_hand_calculation() {
1299 let mut mat = Material::new();
1300 mat.add_nuclide(nid("Co60"), 1.0);
1301
1302 let analytics = Analytics {
1303 masses: &Ame2020,
1304 decays: &ChainDecays,
1305 };
1306 let activity = mat.activity(&analytics).unwrap();
1307 let co60 = nid("Co60");
1308
1309 let lambda = LN_2 / nucleide_nuclei::data::half_life(co60.nucid()).unwrap();
1311 assert_eq!(activity.keys().next().copied(), Some(co60));
1312 assert_eq!(
1313 ChainDecays.decay_constant(co60.nucid()),
1314 Some(lambda),
1315 "ChainDecays must be ln(2)/t_half of the tabulated half-life"
1316 );
1317 let mass_u = nucleide_nuclei::data::atomic_mass(co60.nucid()).unwrap();
1319 let expected = lambda * (1.0 / (mass_u * GRAMS_PER_U));
1320 assert!((activity[&co60] - expected).abs() / expected < 1e-12);
1321 }
1322
1323 #[test]
1324 fn specific_activity_is_activity_per_gram_in_becquerels() {
1325 let mut mat = Material::new();
1327 mat.add_nuclide(nid("Cs137"), 5.0);
1328
1329 let analytics = Analytics {
1330 masses: &Ame2020,
1331 decays: &ChainDecays,
1332 };
1333 let total: f64 = mat.activity(&analytics).unwrap().values().sum();
1334 let spec = mat.specific_activity(&analytics).unwrap();
1335 assert!((spec - total / 5.0).abs() < 1e-6 * spec.abs());
1336 assert!(spec > 1e12 && spec < 1e14, "{spec} Bq/g");
1338 }
1339
1340 #[test]
1341 fn chain_decays_lambda_is_ln2_over_tabulated_half_life() {
1342 let nucid = nid("Co60").nucid();
1343 let lambda = ChainDecays.decay_constant(nucid).unwrap();
1344 let t_half = nucleide_nuclei::data::half_life(nucid).unwrap();
1345 assert!((lambda - LN_2 / t_half).abs() < 1e-18);
1346 assert_eq!(ChainDecays.decay_constant(nid("Fe56").nucid()), None);
1348 }
1349
1350 #[test]
1351 fn no_decay_provider_treats_known_masses_as_stable_zero() {
1352 let mut mat = Material::new();
1356 mat.add_nuclide(nid("Co60"), 1.0);
1357
1358 let analytics = Analytics {
1359 masses: &Ame2020,
1360 decays: &NoDecay,
1361 };
1362 let activity = mat.activity(&analytics).unwrap();
1363 assert_eq!(activity[&nid("Co60")], 0.0);
1364 assert_eq!(mat.specific_activity(&analytics).unwrap(), 0.0);
1365 }
1366
1367 #[test]
1368 fn activity_needs_masses_and_nonempty_materials() {
1369 let mut mat = Material::new();
1370 mat.add_nuclide(nid("Co60"), 1.0);
1371 let no_masses = Analytics {
1372 masses: &NoMasses,
1373 decays: &ChainDecays,
1374 };
1375 assert!(matches!(
1376 mat.activity(&no_masses),
1377 Err(AnalyticsError::Core(crate::Error::MissingMass(_)))
1378 ));
1379
1380 let empty = Analytics {
1381 masses: &Ame2020,
1382 decays: &ChainDecays,
1383 };
1384 assert!(matches!(
1385 Material::new().specific_activity(&empty),
1386 Err(AnalyticsError::Core(crate::Error::Degenerate))
1387 ));
1388 }
1389
1390 #[test]
1391 fn decay_heat_of_one_gram_co60_matches_hand_calculation() {
1392 let mut mat = Material::new();
1393 mat.add_nuclide(nid("Co60"), 1.0);
1394
1395 let analytics = Analytics {
1396 masses: &Ame2020,
1397 decays: &ChainDecays,
1398 };
1399 let heat = mat.decay_heat(&analytics, &DecayEnergies).unwrap();
1400 let co60 = nid("Co60");
1401
1402 let activity: f64 = mat.activity(&analytics).unwrap()[&co60];
1404 let e_mev = DecayEnergies.decay_energy_mev(co60.nucid()).unwrap();
1405 let expected = activity * e_mev * MEV_TO_JOULES;
1406 assert!((heat[&co60] - expected).abs() / expected < 1e-12);
1407 assert!(heat[&co60] > 5.0 && heat[&co60] < 50.0, "{}", heat[&co60]);
1409
1410 let total = mat.total_decay_heat(&analytics, &DecayEnergies).unwrap();
1411 assert!((total - expected).abs() / expected < 1e-12);
1412 }
1413
1414 #[test]
1415 fn nuclei_decay_data_serves_as_energy_provider() {
1416 let provider = nucleide_nuclei::data::DecayData;
1419 assert_eq!(
1420 DecayEnergies.decay_energy_mev(nid("Cs137").nucid()),
1421 provider.decay_energy_mev(nid("Cs137").nucid())
1422 );
1423
1424 let mut mat = Material::new();
1425 mat.add_nuclide(nid("Cs137"), 2.0);
1426 let analytics = Analytics {
1427 masses: &Ame2020,
1428 decays: &ChainDecays,
1429 };
1430 let via_facade = mat.total_decay_heat(&analytics, &provider).unwrap();
1431 let via_struct = mat.total_decay_heat(&analytics, &DecayEnergies).unwrap();
1432 assert!((via_facade - via_struct).abs() < 1e-18);
1433 }
1434
1435 #[test]
1436 fn decay_heat_missing_energy_errors() {
1437 let mut mat = Material::new();
1440 mat.add_nuclide(nid("Cf237"), 1.0);
1441 let analytics = Analytics {
1442 masses: &Ame2020,
1443 decays: &ChainDecays,
1444 };
1445 match mat.decay_heat(&analytics, &DecayEnergies).unwrap_err() {
1446 AnalyticsError::MissingEnergy(id) => assert_eq!(id, nid("Cf237")),
1447 other => panic!("{other:?}"),
1448 }
1449 let mut co = Material::new();
1451 co.add_nuclide(nid("Co60"), 1.0);
1452 match co.decay_heat(&analytics, &NoDecayEnergies).unwrap_err() {
1453 AnalyticsError::MissingEnergy(id) => assert_eq!(id, nid("Co60")),
1454 other => panic!("{other:?}"),
1455 }
1456 }
1457
1458 struct MassTable(BTreeMap<u32, f64>);
1460
1461 impl MassProvider for MassTable {
1462 fn mass(&self, nucid: u32) -> Option<f64> {
1463 self.0.get(&nucid).copied()
1464 }
1465 }
1466
1467 struct DoseTable {
1469 factors: BTreeMap<(u32, DosePathway, DoseSource), f64>,
1470 }
1471
1472 impl DoseTable {
1473 fn new(pairs: &[(&str, DosePathway, DoseSource, f64)]) -> Self {
1474 Self {
1475 factors: pairs
1476 .iter()
1477 .map(|&(name, p, s, v)| (nid(name).nucid(), p, s, v))
1478 .map(|(n, p, s, v)| ((n, p, s), v))
1479 .collect(),
1480 }
1481 }
1482 }
1483
1484 impl DoseProvider for DoseTable {
1485 fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
1486 self.factors.get(&(nucid, pathway, source)).copied()
1487 }
1488 }
1489
1490 struct ConstDecays(BTreeMap<u32, f64>);
1491
1492 impl DecayProvider for ConstDecays {
1493 fn decay_constant(&self, nucid: u32) -> Option<f64> {
1494 self.0.get(&nucid).copied()
1495 }
1496 }
1497
1498 #[test]
1499 fn dose_per_g_matches_pyne_equation_with_synthetic_data() {
1500 use DosePathway as P;
1501 let masses = MassTable(
1503 [(nid("H1").nucid(), 1.0), (nid("Co60").nucid(), 60.0)]
1504 .into_iter()
1505 .collect(),
1506 );
1507 let decays = ConstDecays(
1508 [(nid("H1").nucid(), 0.1), (nid("Co60").nucid(), 0.2)]
1509 .into_iter()
1510 .collect(),
1511 );
1512 let analytics = Analytics {
1513 masses: &masses,
1514 decays: &decays,
1515 };
1516 let doses = DoseTable::new(&[
1517 ("H1", P::Ingest, DoseSource::Epa, 2.0),
1518 ("Co60", P::Ingest, DoseSource::Epa, 3.0),
1519 ("H1", P::Air, DoseSource::Epa, 4.0),
1520 ("Co60", P::Air, DoseSource::Epa, 5.0),
1521 ]);
1522 let mut mat = Material::new();
1523 mat.add_nuclide(nid("H1"), 1.0);
1524 mat.add_nuclide(nid("Co60"), 3.0);
1525 let ingest = mat
1527 .dose_per_g(&analytics, &doses, P::Ingest, DoseSource::Epa)
1528 .unwrap();
1529 let e_h = PCI_PER_BQ * AVOGADRO * 0.25 * 0.1 * 2.0 / 1.0;
1530 let e_co = PCI_PER_BQ * AVOGADRO * 0.75 * 0.2 * 3.0 / 60.0;
1531 assert!((ingest[&nid("H1")] - e_h).abs() / e_h < 1e-12);
1532 assert!((ingest[&nid("Co60")] - e_co).abs() / e_co < 1e-12);
1533 let total = mat
1534 .total_dose_per_g(&analytics, &doses, P::Ingest, DoseSource::Epa)
1535 .unwrap();
1536 assert!((total - (e_h + e_co)).abs() / total < 1e-12);
1537 let air = mat
1539 .dose_per_g(&analytics, &doses, P::Air, DoseSource::Epa)
1540 .unwrap();
1541 let e_air = CI_PER_BQ * AVOGADRO * 0.25 * 0.1 * 4.0 / 1.0;
1542 assert!((air[&nid("H1")] - e_air).abs() / e_air < 1e-12);
1543 }
1544
1545 #[test]
1546 fn dose_per_g_of_one_gram_co60_matches_hand_calculation() {
1547 use DosePathway as P;
1548 use DoseSource as S;
1549 let mut mat = Material::new();
1550 mat.add_nuclide(nid("Co60"), 1.0);
1551 let analytics = Analytics {
1552 masses: &Ame2020,
1553 decays: &ChainDecays,
1554 };
1555 let per_nuc = mat
1556 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1557 .unwrap();
1558 let co60 = nid("Co60");
1559 let mass_u = nucleide_nuclei::data::atomic_mass(co60.nucid()).unwrap();
1560 let lambda = LN_2 / nucleide_nuclei::data::half_life(co60.nucid()).unwrap();
1561 let df = DoseFactors
1562 .dose_factor(co60.nucid(), P::Ingest, S::Epa)
1563 .unwrap();
1564 assert!((df - 2.69e-05).abs() / 2.69e-05 < 1e-9);
1566 let expected = PCI_PER_BQ * AVOGADRO * 1.0 * lambda * df / mass_u;
1567 assert!((per_nuc[&co60] - expected).abs() / expected < 1e-12);
1568 let total = mat
1569 .total_dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1570 .unwrap();
1571 assert!((total - expected).abs() / expected < 1e-12);
1572 }
1573
1574 #[test]
1575 fn dose_per_g_missing_dose_errors() {
1576 use DosePathway as P;
1577 use DoseSource as S;
1578 let analytics = Analytics {
1579 masses: &Ame2020,
1580 decays: &ChainDecays,
1581 };
1582 let mut fe = Material::new();
1585 fe.add_nuclide(nid("Fe56"), 1.0);
1586 let fe_dose = fe
1587 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1588 .unwrap();
1589 assert_eq!(fe_dose[&nid("Fe56")], 0.0);
1590 assert_eq!(
1591 fe.total_dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1592 .unwrap(),
1593 0.0
1594 );
1595 let mut h3 = Material::new();
1597 h3.add_nuclide(nid("H3"), 1.0);
1598 match h3
1599 .dose_per_g(&analytics, &DoseFactors, P::Air, S::Genii)
1600 .unwrap_err()
1601 {
1602 AnalyticsError::MissingDose(id) => assert_eq!(id, nid("H3")),
1603 other => panic!("{other:?}"),
1604 }
1605 let mut co = Material::new();
1607 co.add_nuclide(nid("Co60"), 1.0);
1608 match co
1609 .dose_per_g(&analytics, &NoDoses, P::Ingest, S::Epa)
1610 .unwrap_err()
1611 {
1612 AnalyticsError::MissingDose(id) => assert_eq!(id, nid("Co60")),
1613 other => panic!("{other:?}"),
1614 }
1615 match Material::new()
1617 .total_dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1618 .unwrap_err()
1619 {
1620 AnalyticsError::Core(crate::Error::Degenerate) => {}
1621 other => panic!("{other:?}"),
1622 }
1623 }
1624
1625 #[test]
1626 fn stable_water_contributes_exact_zeros() {
1627 use DosePathway as P;
1628 use DoseSource as S;
1629 let mut mat = Material::new();
1632 mat.add_nuclide(nid("H1"), 2.0);
1633 mat.add_nuclide(nid("O16"), 16.0);
1634 let analytics = Analytics {
1635 masses: &Ame2020,
1636 decays: &ChainDecays,
1637 };
1638
1639 let activity = mat.activity(&analytics).unwrap();
1640 assert_eq!(activity[&nid("H1")], 0.0);
1641 assert_eq!(activity[&nid("O16")], 0.0);
1642 assert_eq!(mat.specific_activity(&analytics).unwrap(), 0.0);
1643
1644 let heat = mat.decay_heat(&analytics, &DecayEnergies).unwrap();
1645 assert_eq!(heat[&nid("H1")], 0.0);
1646 assert_eq!(heat[&nid("O16")], 0.0);
1647 assert_eq!(
1648 mat.total_decay_heat(&analytics, &DecayEnergies).unwrap(),
1649 0.0
1650 );
1651
1652 for pathway in [P::Air, P::Soil, P::Ingest, P::Inhale] {
1653 for source in [S::Epa, S::Doe, S::Genii] {
1654 let dose = mat
1655 .dose_per_g(&analytics, &DoseFactors, pathway, source)
1656 .unwrap();
1657 assert_eq!(dose[&nid("H1")], 0.0, "{pathway:?}/{source:?}");
1658 assert_eq!(dose[&nid("O16")], 0.0, "{pathway:?}/{source:?}");
1659 assert_eq!(
1660 mat.total_dose_per_g(&analytics, &DoseFactors, pathway, source)
1661 .unwrap(),
1662 0.0,
1663 "{pathway:?}/{source:?}"
1664 );
1665 }
1666 }
1667 }
1668
1669 #[test]
1670 fn mixed_stable_plus_radioactive_matches_radioactive_only() {
1671 use DosePathway as P;
1672 use DoseSource as S;
1673 let mut mixed = Material::new();
1676 mixed.add_nuclide(nid("U235"), 1.0);
1677 mixed.add_nuclide(nid("H1"), 1.0);
1678 let mut pure = Material::new();
1679 pure.add_nuclide(nid("U235"), 1.0);
1680 let analytics = Analytics {
1681 masses: &Ame2020,
1682 decays: &ChainDecays,
1683 };
1684
1685 let mixed_act = mixed.activity(&analytics).unwrap();
1686 let pure_act = pure.activity(&analytics).unwrap();
1687 assert_eq!(mixed_act[&nid("U235")], pure_act[&nid("U235")]);
1688 assert!(pure_act[&nid("U235")] > 0.0);
1689 assert_eq!(mixed_act[&nid("H1")], 0.0);
1690
1691 let mixed_heat = mixed.decay_heat(&analytics, &DecayEnergies).unwrap();
1692 let pure_heat = pure.decay_heat(&analytics, &DecayEnergies).unwrap();
1693 assert_eq!(mixed_heat[&nid("U235")], pure_heat[&nid("U235")]);
1694 assert!(pure_heat[&nid("U235")] > 0.0);
1695 assert_eq!(mixed_heat[&nid("H1")], 0.0);
1696
1697 let mixed_dose = mixed
1700 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1701 .unwrap();
1702 let pure_dose = pure
1703 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1704 .unwrap();
1705 assert_eq!(mixed_dose[&nid("H1")], 0.0);
1706 assert_eq!(mixed_dose[&nid("U235")], pure_dose[&nid("U235")] * 0.5);
1707 }
1708
1709 #[test]
1710 fn unknown_nuclide_without_mass_still_errors() {
1711 use DosePathway as P;
1712 use DoseSource as S;
1713 let og = nid("Og296");
1716 assert_eq!(
1717 nucleide_nuclei::data::atomic_mass(og.nucid()),
1718 None,
1719 "Og296 must stay absent from the mass table"
1720 );
1721 let mut mat = Material::new();
1722 mat.add_nuclide(og, 1.0);
1723 let analytics = Analytics {
1724 masses: &Ame2020,
1725 decays: &ChainDecays,
1726 };
1727
1728 match mat.activity(&analytics).unwrap_err() {
1729 AnalyticsError::Core(crate::Error::MissingMass(id)) => assert_eq!(id, og),
1730 other => panic!("{other:?}"),
1731 }
1732 match mat.decay_heat(&analytics, &DecayEnergies).unwrap_err() {
1733 AnalyticsError::Core(crate::Error::MissingMass(id)) => assert_eq!(id, og),
1734 other => panic!("{other:?}"),
1735 }
1736 match mat
1737 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1738 .unwrap_err()
1739 {
1740 AnalyticsError::Core(crate::Error::MissingMass(id)) => assert_eq!(id, og),
1741 other => panic!("{other:?}"),
1742 }
1743 let msg = crate::Error::MissingMass(og).to_string();
1744 assert!(msg.contains("Og296"), "{msg}");
1745 }
1746
1747 #[test]
1748 fn nuclei_dose_data_serves_as_dose_provider() {
1749 use DosePathway as P;
1750 use DoseSource as S;
1751 let provider = nucleide_nuclei::data::DoseData;
1752 assert_eq!(
1753 DoseFactors.dose_factor(nid("Cs137").nucid(), P::Inhale, S::Epa),
1754 provider.dose_factor(nid("Cs137").nucid(), P::Inhale, S::Epa)
1755 );
1756 let mut mat = Material::new();
1757 mat.add_nuclide(nid("Cs137"), 2.0);
1758 let analytics = Analytics {
1759 masses: &Ame2020,
1760 decays: &ChainDecays,
1761 };
1762 let via_facade = mat
1763 .total_dose_per_g(&analytics, &provider, P::Inhale, S::Epa)
1764 .unwrap();
1765 let via_struct = mat
1766 .total_dose_per_g(&analytics, &DoseFactors, P::Inhale, S::Epa)
1767 .unwrap();
1768 assert!((via_facade - via_struct).abs() < 1e-18);
1769 }
1770}
1771
1772#[cfg(test)]
1773mod ame_tests {
1774 use super::*;
1775
1776 #[test]
1777 fn ame2020_provider_resolves_water() {
1778 let m = Material::from_atom_frac(
1780 &[
1781 (nucleide_nuclei::NuclideId::from_name("H1").unwrap(), 2.0),
1782 (nucleide_nuclei::NuclideId::from_name("O16").unwrap(), 1.0),
1783 ],
1784 &Ame2020,
1785 Some(1.0),
1786 )
1787 .unwrap();
1788 let af = m.atom_fractions(&Ame2020).unwrap();
1789 assert!(
1790 (af[&nucleide_nuclei::NuclideId::from_name("H1").unwrap()] - 2.0 / 3.0).abs() < 1e-12
1791 );
1792 }
1793}