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)]
551pub enum AnalyticsError {
552 #[error("no decay data available for nuclide `{0}`")]
561 MissingDecay(NuclideId),
562 #[error("no decay energy available for nuclide `{0}`")]
564 MissingEnergy(NuclideId),
565 #[error("no dose factor available for nuclide `{0}`")]
567 MissingDose(NuclideId),
568 #[error(transparent)]
570 Core(#[from] crate::Error),
571}
572
573pub trait DecayEnergyProvider {
582 fn decay_energy_mev(&self, nucid: u32) -> Option<f64>;
585}
586
587#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
592pub struct NoDecayEnergies;
593
594impl DecayEnergyProvider for NoDecayEnergies {
595 fn decay_energy_mev(&self, _nucid: u32) -> Option<f64> {
596 None
597 }
598}
599
600#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
605pub struct DecayEnergies;
606
607impl DecayEnergyProvider for DecayEnergies {
608 fn decay_energy_mev(&self, nucid: u32) -> Option<f64> {
609 nucleide_nuclei::data::decay_energy_mev(nucid)
610 }
611}
612
613impl DecayEnergyProvider for nucleide_nuclei::data::DecayData {
614 fn decay_energy_mev(&self, nucid: u32) -> Option<f64> {
615 nucleide_nuclei::data::decay_energy_mev(nucid)
616 }
617}
618
619pub trait DoseProvider {
627 fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64>;
635}
636
637#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
642pub struct NoDoses;
643
644impl DoseProvider for NoDoses {
645 fn dose_factor(&self, _nucid: u32, _pathway: DosePathway, _source: DoseSource) -> Option<f64> {
646 None
647 }
648}
649
650#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
654pub struct DoseFactors;
655
656impl DoseProvider for DoseFactors {
657 fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
658 nucleide_nuclei::data::dose_factor(nucid, pathway, source)
659 }
660}
661
662impl DoseProvider for nucleide_nuclei::data::DoseData {
663 fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
664 nucleide_nuclei::data::dose_factor(nucid, pathway, source)
665 }
666}
667
668impl Material {
669 pub fn activity(
680 &self,
681 analytics: &Analytics<'_>,
682 ) -> std::result::Result<BTreeMap<NuclideId, f64>, AnalyticsError> {
683 let mut out = BTreeMap::new();
684 for (&id, &grams) in &self.comp {
685 let mass_u = analytics
686 .masses
687 .mass(id.nucid())
688 .ok_or(crate::Error::MissingMass(id))?;
689 let lambda = analytics.decays.decay_constant(id.nucid()).unwrap_or(0.0);
690 let atoms = grams / (mass_u * GRAMS_PER_U);
691 out.insert(id, lambda * atoms);
692 }
693 Ok(out)
694 }
695
696 pub fn specific_activity(&self, analytics: &Analytics<'_>) -> Result<f64, AnalyticsError> {
702 let total_mass = self.mass();
703 if not_positive(total_mass) {
704 return Err(crate::Error::Degenerate.into());
705 }
706 let mut total_activity = 0.0;
707 for value in self.activity(analytics)?.values() {
708 total_activity += value;
709 }
710 Ok(total_activity / total_mass)
711 }
712
713 pub fn decay_heat(
728 &self,
729 analytics: &Analytics<'_>,
730 energies: &impl DecayEnergyProvider,
731 ) -> Result<BTreeMap<NuclideId, f64>, AnalyticsError> {
732 let activities = self.activity(analytics)?;
733 let mut out = BTreeMap::new();
734 for (&id, &activity_bq) in &activities {
735 if activity_bq == 0.0 {
738 out.insert(id, 0.0);
739 continue;
740 }
741 let mev = energies
742 .decay_energy_mev(id.nucid())
743 .ok_or(AnalyticsError::MissingEnergy(id))?;
744 out.insert(id, activity_bq * mev * MEV_TO_JOULES);
745 }
746 Ok(out)
747 }
748
749 pub fn total_decay_heat(
752 &self,
753 analytics: &Analytics<'_>,
754 energies: &impl DecayEnergyProvider,
755 ) -> Result<f64, AnalyticsError> {
756 let mut total = 0.0;
757 for value in self.decay_heat(analytics, energies)?.values() {
758 total += value;
759 }
760 Ok(total)
761 }
762
763 pub fn dose_per_g(
787 &self,
788 analytics: &Analytics<'_>,
789 doses: &impl DoseProvider,
790 pathway: DosePathway,
791 source: DoseSource,
792 ) -> Result<BTreeMap<NuclideId, f64>, AnalyticsError> {
793 let total_mass = self.mass();
794 if not_positive(total_mass) {
795 return Err(crate::Error::Degenerate.into());
796 }
797 let per_bq = match pathway {
798 DosePathway::Air | DosePathway::Soil => CI_PER_BQ,
799 DosePathway::Ingest | DosePathway::Inhale => PCI_PER_BQ,
800 };
801 let mut out = BTreeMap::new();
802 for (&id, &grams) in &self.comp {
803 let mass_u = analytics
804 .masses
805 .mass(id.nucid())
806 .ok_or(crate::Error::MissingMass(id))?;
807 let lambda = analytics.decays.decay_constant(id.nucid()).unwrap_or(0.0);
808 if lambda <= 0.0 {
809 out.insert(id, 0.0);
810 continue;
811 }
812 let df = doses
813 .dose_factor(id.nucid(), pathway, source)
814 .filter(|v| v.is_finite() && *v >= 0.0)
815 .ok_or(AnalyticsError::MissingDose(id))?;
816 let w = grams / total_mass;
817 out.insert(id, per_bq * AVOGADRO * w * lambda * df / mass_u);
818 }
819 Ok(out)
820 }
821
822 pub fn total_dose_per_g(
825 &self,
826 analytics: &Analytics<'_>,
827 doses: &impl DoseProvider,
828 pathway: DosePathway,
829 source: DoseSource,
830 ) -> Result<f64, AnalyticsError> {
831 let mut total = 0.0;
832 for value in self.dose_per_g(analytics, doses, pathway, source)?.values() {
833 total += value;
834 }
835 Ok(total)
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 fn id(name: &str) -> NuclideId {
844 NuclideId::from_name(name).unwrap()
845 }
846
847 fn close(a: f64, b: f64) {
848 assert!((a - b).abs() < 1e-12, "{a} != {b}");
849 }
850
851 struct Table(BTreeMap<u32, f64>);
853
854 impl Table {
855 fn new(pairs: &[(&str, f64)]) -> Self {
856 Self(
857 pairs
858 .iter()
859 .map(|&(name, m)| (id(name).nucid(), m))
860 .collect(),
861 )
862 }
863 }
864
865 impl MassProvider for Table {
866 fn mass(&self, nucid: u32) -> Option<f64> {
867 self.0.get(&nucid).copied()
868 }
869 }
870
871 fn water_table() -> Table {
872 Table::new(&[("H1", 1.0), ("O16", 16.0)])
873 }
874
875 #[test]
876 fn empty_material_has_zero_mass() {
877 let mat = Material::new();
878 close(mat.mass(), 0.0);
879 assert!(mat.comp.is_empty());
880 assert_eq!(mat.density(), None);
881 }
882
883 #[test]
884 fn add_nuclide_accumulates_and_remove_returns_mass() {
885 let mut mat = Material::new();
886 let u5 = id("U235");
887 mat.add_nuclide(u5, 10.0);
888 mat.add_nuclide(u5, 5.0);
889 close(mat.mass(), 15.0);
890 close(mat.remove_nuclide(u5).unwrap(), 15.0);
891 assert_eq!(mat.remove_nuclide(u5), None);
892 }
893
894 #[test]
895 fn clear_drops_composition_only() {
896 let mut mat = Material::new();
897 mat.add_nuclide(id("U235"), 3.0);
898 mat.add_nuclide(id("U238"), 1.0);
899 mat.set_density(Some(19.1));
900 mat.clear();
901 assert!(mat.comp.is_empty());
902 assert_eq!(mat.density(), Some(19.1));
903 }
904
905 #[test]
906 fn from_atom_frac_water_hand_computed() {
907 let mat = Material::from_atom_frac(
908 &[(id("H1"), 2.0), (id("O16"), 1.0)],
909 &water_table(),
910 Some(1.0),
911 )
912 .unwrap();
913
914 close(mat.comp[&id("H1")], 2.0);
915 close(mat.comp[&id("O16")], 16.0);
916 close(mat.mass(), 18.0);
917
918 let wf = mat.weight_fractions().unwrap();
919 close(wf[&id("H1")], 1.0 / 9.0);
920 close(wf[&id("O16")], 8.0 / 9.0);
921
922 let af = mat.atom_fractions(&water_table()).unwrap();
923 close(af[&id("H1")], 2.0 / 3.0);
924 close(af[&id("O16")], 1.0 / 3.0);
925 }
926
927 #[test]
928 fn from_atom_frac_skips_zero_counts_and_sets_density() {
929 let mat =
930 Material::from_atom_frac(&[(id("H1"), 0.0), (id("O16"), 1.0)], &water_table(), None)
931 .unwrap();
932 assert!(!mat.comp.contains_key(&id("H1")));
933 assert!(mat.comp.contains_key(&id("O16")));
934 assert_eq!(mat.density(), None);
935 }
936
937 #[test]
938 fn from_atom_frac_without_masses_errors() {
939 let err = Material::from_atom_frac(&[(id("U235"), 1.0)], &NoMasses, None).unwrap_err();
940 assert!(matches!(err, Error::MissingMass(_)));
941 }
942
943 #[test]
944 fn weight_fractions_normalize_to_one() {
945 let mut mat = Material::new();
946 mat.add_nuclide(id("U235"), 19.0);
947 mat.add_nuclide(id("U238"), 1.0);
948 let wf = mat.weight_fractions().unwrap();
949 close(wf[&id("U235")], 0.95);
950 close(wf[&id("U238")], 0.05);
951 close(wf.values().sum(), 1.0);
952 }
953
954 #[test]
955 fn weight_fractions_of_empty_material_error() {
956 assert!(matches!(
957 Material::new().weight_fractions(),
958 Err(Error::Degenerate)
959 ));
960 }
961
962 #[test]
963 fn atom_fractions_missing_mass_errors() {
964 let mut mat = Material::new();
965 mat.add_nuclide(id("U235"), 1.0);
966 assert!(matches!(
967 mat.atom_fractions(&NoMasses),
968 Err(Error::MissingMass(_))
969 ));
970 }
971
972 #[test]
973 fn adding_materials_mixes_by_mass() {
974 let mut fuel = Material::new();
975 fuel.add_nuclide(id("U235"), 3.0);
976 fuel.set_density(Some(19.0));
977
978 let mut matrix = Material::new();
979 matrix.add_nuclide(id("U238"), 1.0);
980 matrix.set_density(Some(10.0));
981
982 let mixed = fuel + matrix;
983 close(mixed.mass(), 4.0);
984 let wf = mixed.weight_fractions().unwrap();
985 close(wf[&id("U235")], 0.75);
986 close(wf[&id("U238")], 0.25);
987 assert_eq!(mixed.density(), None, "mixtures have no single density");
988 }
989
990 #[test]
991 fn subtracting_materials_removes_stream() {
992 let mut a = Material::new();
993 a.add_nuclide(id("U235"), 3.0);
994 a.add_nuclide(id("U238"), 1.0);
995 let mut b = Material::new();
996 b.add_nuclide(id("U238"), 1.0);
997
998 let rest = a - b;
999 assert_eq!(rest.comp.len(), 1);
1000 close(rest.comp[&id("U235")], 3.0);
1001 }
1002
1003 #[test]
1004 fn scalar_mul_div_scale_masses_and_keep_density() {
1005 let mut mat = Material::new();
1006 mat.add_nuclide(id("U235"), 3.0);
1007 mat.add_nuclide(id("U238"), 1.0);
1008 mat.set_density(Some(19.1));
1009
1010 let doubled = mat.clone() * 2.0;
1011 close(doubled.mass(), 8.0);
1012 close(doubled.comp[&id("U235")], 6.0);
1013 assert_eq!(doubled.density(), Some(19.1));
1014
1015 let quartered = doubled / 4.0;
1016 close(quartered.mass(), 2.0);
1017 close(quartered.comp[&id("U238")], 0.5);
1018 }
1019
1020 #[test]
1021 fn scalar_add_sub_shift_total_mass_proportionally() {
1022 let mut mat = Material::new();
1023 mat.add_nuclide(id("U235"), 2.0);
1024 mat.set_density(Some(19.1));
1025
1026 let grown = mat.clone() + 1.0;
1027 close(grown.mass(), 3.0);
1028 close(grown.comp[&id("U235")], 3.0);
1029
1030 let shrunk = grown - 1.0;
1031 close(shrunk.mass(), 2.0);
1032 close(shrunk.comp[&id("U235")], 2.0);
1033 assert_eq!(shrunk.density(), Some(19.1));
1034 }
1035
1036 #[test]
1037 #[should_panic(expected = "divide")]
1038 fn divide_by_zero_panics() {
1039 let _ = Material::new() / 0.0;
1040 }
1041
1042 #[test]
1043 #[should_panic(expected = "cannot add")]
1044 fn scalar_add_to_zero_mass_panics() {
1045 let _ = Material::new() + 5.0;
1046 }
1047
1048 #[test]
1049 #[should_panic(expected = "cannot subtract")]
1050 fn scalar_sub_below_zero_panics() {
1051 let mut mat = Material::new();
1052 mat.add_nuclide(id("U235"), 1.0);
1053 let _ = mat - 2.0;
1054 }
1055
1056 #[test]
1057 fn mix_by_mass_weights_full_streams() {
1058 let mut a = Material::new();
1059 a.add_nuclide(id("U235"), 1.0);
1060 a.add_nuclide(id("Pu239"), 1.0);
1061 let mut b = Material::new();
1062 b.add_nuclide(id("U238"), 1.0);
1063
1064 let mixed = Material::mix_by_mass(&[(&a, 1.0), (&b, 2.0)]).unwrap();
1065 close(mixed.mass(), 4.0);
1068 let wf = mixed.weight_fractions().unwrap();
1069 close(wf[&id("U235")], 0.25);
1070 close(wf[&id("Pu239")], 0.25);
1071 close(wf[&id("U238")], 0.5);
1072 }
1073
1074 #[test]
1075 fn mix_by_volume_converts_through_densities() {
1076 let mut heavy = Material::new();
1077 heavy.add_nuclide(id("U238"), 1.0);
1078 heavy.set_density(Some(10.0));
1079 let mut light = Material::new();
1080 light.add_nuclide(id("H1"), 1.0);
1081 light.set_density(Some(2.0));
1082
1083 let mixed = Material::mix_by_volume(&[(&heavy, 1.0), (&light, 1.5)]).unwrap();
1085 close(mixed.comp[&id("U238")], 10.0);
1086 close(mixed.comp[&id("H1")], 3.0);
1087 }
1088
1089 #[test]
1090 fn mix_by_volume_requires_density() {
1091 let mut mat = Material::new();
1092 mat.add_nuclide(id("U235"), 1.0);
1093 assert!(matches!(
1094 Material::mix_by_volume(&[(&mat, 1.0)]),
1095 Err(Error::MissingDensity)
1096 ));
1097 }
1098
1099 #[test]
1100 fn negative_mix_fraction_rejected() {
1101 let mut mat = Material::new();
1102 mat.add_nuclide(id("U235"), 1.0);
1103 assert!(matches!(
1104 Material::mix_by_mass(&[(&mat, -1.0)]),
1105 Err(Error::NegativeFraction(_))
1106 ));
1107 }
1108
1109 fn sep_feed() -> Material {
1112 let mut mat = Material::new();
1113 mat.add_nuclide(id("U235"), 10.0);
1114 mat.add_nuclide(id("U238"), 90.0);
1115 mat.add_nuclide(id("Pu239"), 1.0);
1116 mat.add_nuclide(id("Pu240"), 2.0);
1117 mat.add_nuclide(id("Am241"), 3.0);
1118 mat.add_nuclide(id("Am242"), 2.8);
1119 mat
1120 }
1121
1122 #[test]
1123 fn separate_splits_by_efficiency_and_conserves_mass() {
1124 let feed = sep_feed();
1127 let effs = [
1128 (id("U235"), 0.7),
1129 (id("U238"), 0.7),
1130 (id("Pu239"), 0.4),
1131 (id("Pu240"), 0.4),
1132 (id("Am241"), 0.4),
1133 ];
1134 let (product, tails) = feed.separate(&effs).unwrap();
1135
1136 close(product.comp[&id("U235")], 7.0);
1138 close(product.comp[&id("U238")], 63.0);
1139 close(product.comp[&id("Pu239")], 0.4);
1140 close(product.comp[&id("Pu240")], 0.8);
1141 close(product.comp[&id("Am241")], 1.2);
1142 assert!(!product.comp.contains_key(&id("Am242")));
1143 close(product.mass(), 72.4);
1144
1145 close(tails.comp[&id("U235")], 3.0);
1147 close(tails.comp[&id("U238")], 27.0);
1148 close(tails.comp[&id("Pu239")], 0.6);
1149 close(tails.comp[&id("Pu240")], 1.2);
1150 close(tails.comp[&id("Am241")], 1.8);
1151 close(tails.comp[&id("Am242")], 2.8);
1152 close(tails.mass(), 36.4);
1153
1154 for (&nuc, &m) in &feed.comp {
1156 let p = product.comp.get(&nuc).copied().unwrap_or(0.0);
1157 let t = tails.comp.get(&nuc).copied().unwrap_or(0.0);
1158 close(p + t, m);
1159 }
1160 close(product.mass() + tails.mass(), feed.mass());
1161 assert_eq!(product.density(), None);
1162 assert_eq!(tails.density(), None);
1163 }
1164
1165 #[test]
1166 fn separate_edge_efficiencies_route_wholly() {
1167 let feed = sep_feed();
1168 let (all_product, no_tails) = feed
1170 .separate(&[
1171 (id("U235"), 1.0),
1172 (id("U238"), 1.0),
1173 (id("Pu239"), 1.0),
1174 (id("Pu240"), 1.0),
1175 (id("Am241"), 1.0),
1176 (id("Am242"), 1.0),
1177 ])
1178 .unwrap();
1179 close(all_product.mass(), feed.mass());
1180 assert!(no_tails.comp.is_empty());
1181
1182 let (no_product, all_tails) = feed.separate(&[]).unwrap();
1183 assert!(no_product.comp.is_empty());
1184 close(all_tails.mass(), feed.mass());
1185 }
1186
1187 #[test]
1188 fn separate_rejects_out_of_range_efficiencies() {
1189 let feed = sep_feed();
1190 for bad in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
1191 assert!(
1192 matches!(
1193 feed.separate(&[(id("U235"), bad)]),
1194 Err(Error::InvalidEfficiency(_))
1195 ),
1196 "efficiency {bad} must be rejected"
1197 );
1198 }
1199 }
1200
1201 #[test]
1202 fn blend_normalizes_fixed_ratios() {
1203 let mut a = Material::new();
1204 a.add_nuclide(id("U235"), 1.0);
1205 a.add_nuclide(id("Pu239"), 1.0);
1206 let mut b = Material::new();
1207 b.add_nuclide(id("U238"), 1.0);
1208
1209 let out = Material::blend(&[(&a, 1.0), (&b, 2.0)]).unwrap();
1212 close(out.comp[&id("U235")], 1.0 / 3.0);
1213 close(out.comp[&id("Pu239")], 1.0 / 3.0);
1214 close(out.comp[&id("U238")], 2.0 / 3.0);
1215 close(out.mass(), 4.0 / 3.0);
1216 let wf = out.weight_fractions().unwrap();
1217 close(wf[&id("U235")], 0.25);
1218 close(wf[&id("Pu239")], 0.25);
1219 close(wf[&id("U238")], 0.5);
1220
1221 let half = Material::blend(&[(&a, 2.0), (&b, 2.0)]).unwrap();
1223 close(half.comp[&id("U235")], 0.5);
1224 close(half.comp[&id("Pu239")], 0.5);
1225 close(half.comp[&id("U238")], 0.5);
1226 close(half.mass(), 1.5);
1227 assert_eq!(half.density(), None);
1228 }
1229
1230 #[test]
1231 fn blend_rejects_degenerate_and_negative_recipes() {
1232 let mut a = Material::new();
1233 a.add_nuclide(id("U235"), 1.0);
1234 assert!(matches!(Material::blend(&[]), Err(Error::Degenerate)));
1236 assert!(matches!(
1237 Material::blend(&[(&a, 0.0)]),
1238 Err(Error::Degenerate)
1239 ));
1240 for bad in [-1.0, f64::NAN, f64::INFINITY] {
1242 assert!(
1243 matches!(
1244 Material::blend(&[(&a, bad)]),
1245 Err(Error::NegativeFraction(_))
1246 ),
1247 "ratio {bad} must be rejected"
1248 );
1249 }
1250 }
1251
1252 #[test]
1253 fn json_round_trip_preserves_everything() {
1254 let mut mat = Material::new();
1255 mat.add_nuclide(id("U235"), 19.0);
1256 mat.add_nuclide(id("Am242_m1"), 1.0);
1257 mat.set_density(Some(19.1));
1258 mat.set_metadata(Some(serde_json::json!({"enrichment": 0.03})));
1259
1260 let text = serde_json::to_string(&mat).unwrap();
1261 let parsed: Material = serde_json::from_str(&text).unwrap();
1262 assert_eq!(parsed, mat);
1263 }
1264
1265 #[test]
1266 fn json_uses_gnds_names_as_keys() {
1267 let mut mat = Material::new();
1268 mat.add_nuclide(id("U235"), 1.0);
1269 let text = serde_json::to_string(&mat).unwrap();
1270 assert!(
1271 text.contains("\"comp\":{\"U235\":1.0}"),
1272 "unexpected serialization: {text}"
1273 );
1274 }
1275
1276 #[test]
1277 fn json_rejects_unknown_nuclide_names() {
1278 let err = serde_json::from_str::<Material>(
1279 r#"{"comp":{"Notanuclide":1.0},"density":null,"metadata":null}"#,
1280 )
1281 .unwrap_err()
1282 .to_string();
1283 assert!(err.contains("invalid nuclide name `Notanuclide`"), "{err}");
1284 }
1285}
1286
1287#[cfg(test)]
1288mod radio_tests {
1289 use super::*;
1290 use std::f64::consts::LN_2;
1291
1292 fn nid(name: &str) -> NuclideId {
1293 NuclideId::from_name(name).unwrap()
1294 }
1295
1296 #[test]
1297 fn activity_of_one_gram_co60_matches_hand_calculation() {
1298 let mut mat = Material::new();
1299 mat.add_nuclide(nid("Co60"), 1.0);
1300
1301 let analytics = Analytics {
1302 masses: &Ame2020,
1303 decays: &ChainDecays,
1304 };
1305 let activity = mat.activity(&analytics).unwrap();
1306 let co60 = nid("Co60");
1307
1308 let lambda = LN_2 / nucleide_nuclei::data::half_life(co60.nucid()).unwrap();
1310 assert_eq!(activity.keys().next().copied(), Some(co60));
1311 assert_eq!(
1312 ChainDecays.decay_constant(co60.nucid()),
1313 Some(lambda),
1314 "ChainDecays must be ln(2)/t_half of the tabulated half-life"
1315 );
1316 let mass_u = nucleide_nuclei::data::atomic_mass(co60.nucid()).unwrap();
1318 let expected = lambda * (1.0 / (mass_u * GRAMS_PER_U));
1319 assert!((activity[&co60] - expected).abs() / expected < 1e-12);
1320 }
1321
1322 #[test]
1323 fn specific_activity_is_activity_per_gram_in_becquerels() {
1324 let mut mat = Material::new();
1326 mat.add_nuclide(nid("Cs137"), 5.0);
1327
1328 let analytics = Analytics {
1329 masses: &Ame2020,
1330 decays: &ChainDecays,
1331 };
1332 let total: f64 = mat.activity(&analytics).unwrap().values().sum();
1333 let spec = mat.specific_activity(&analytics).unwrap();
1334 assert!((spec - total / 5.0).abs() < 1e-6 * spec.abs());
1335 assert!(spec > 1e12 && spec < 1e14, "{spec} Bq/g");
1337 }
1338
1339 #[test]
1340 fn chain_decays_lambda_is_ln2_over_tabulated_half_life() {
1341 let nucid = nid("Co60").nucid();
1342 let lambda = ChainDecays.decay_constant(nucid).unwrap();
1343 let t_half = nucleide_nuclei::data::half_life(nucid).unwrap();
1344 assert!((lambda - LN_2 / t_half).abs() < 1e-18);
1345 assert_eq!(ChainDecays.decay_constant(nid("Fe56").nucid()), None);
1347 }
1348
1349 #[test]
1350 fn no_decay_provider_treats_known_masses_as_stable_zero() {
1351 let mut mat = Material::new();
1355 mat.add_nuclide(nid("Co60"), 1.0);
1356
1357 let analytics = Analytics {
1358 masses: &Ame2020,
1359 decays: &NoDecay,
1360 };
1361 let activity = mat.activity(&analytics).unwrap();
1362 assert_eq!(activity[&nid("Co60")], 0.0);
1363 assert_eq!(mat.specific_activity(&analytics).unwrap(), 0.0);
1364 }
1365
1366 #[test]
1367 fn activity_needs_masses_and_nonempty_materials() {
1368 let mut mat = Material::new();
1369 mat.add_nuclide(nid("Co60"), 1.0);
1370 let no_masses = Analytics {
1371 masses: &NoMasses,
1372 decays: &ChainDecays,
1373 };
1374 assert!(matches!(
1375 mat.activity(&no_masses),
1376 Err(AnalyticsError::Core(crate::Error::MissingMass(_)))
1377 ));
1378
1379 let empty = Analytics {
1380 masses: &Ame2020,
1381 decays: &ChainDecays,
1382 };
1383 assert!(matches!(
1384 Material::new().specific_activity(&empty),
1385 Err(AnalyticsError::Core(crate::Error::Degenerate))
1386 ));
1387 }
1388
1389 #[test]
1390 fn decay_heat_of_one_gram_co60_matches_hand_calculation() {
1391 let mut mat = Material::new();
1392 mat.add_nuclide(nid("Co60"), 1.0);
1393
1394 let analytics = Analytics {
1395 masses: &Ame2020,
1396 decays: &ChainDecays,
1397 };
1398 let heat = mat.decay_heat(&analytics, &DecayEnergies).unwrap();
1399 let co60 = nid("Co60");
1400
1401 let activity: f64 = mat.activity(&analytics).unwrap()[&co60];
1403 let e_mev = DecayEnergies.decay_energy_mev(co60.nucid()).unwrap();
1404 let expected = activity * e_mev * MEV_TO_JOULES;
1405 assert!((heat[&co60] - expected).abs() / expected < 1e-12);
1406 assert!(heat[&co60] > 5.0 && heat[&co60] < 50.0, "{}", heat[&co60]);
1408
1409 let total = mat.total_decay_heat(&analytics, &DecayEnergies).unwrap();
1410 assert!((total - expected).abs() / expected < 1e-12);
1411 }
1412
1413 #[test]
1414 fn nuclei_decay_data_serves_as_energy_provider() {
1415 let provider = nucleide_nuclei::data::DecayData;
1418 assert_eq!(
1419 DecayEnergies.decay_energy_mev(nid("Cs137").nucid()),
1420 provider.decay_energy_mev(nid("Cs137").nucid())
1421 );
1422
1423 let mut mat = Material::new();
1424 mat.add_nuclide(nid("Cs137"), 2.0);
1425 let analytics = Analytics {
1426 masses: &Ame2020,
1427 decays: &ChainDecays,
1428 };
1429 let via_facade = mat.total_decay_heat(&analytics, &provider).unwrap();
1430 let via_struct = mat.total_decay_heat(&analytics, &DecayEnergies).unwrap();
1431 assert!((via_facade - via_struct).abs() < 1e-18);
1432 }
1433
1434 #[test]
1435 fn decay_heat_missing_energy_errors() {
1436 let mut mat = Material::new();
1439 mat.add_nuclide(nid("Cf237"), 1.0);
1440 let analytics = Analytics {
1441 masses: &Ame2020,
1442 decays: &ChainDecays,
1443 };
1444 match mat.decay_heat(&analytics, &DecayEnergies).unwrap_err() {
1445 AnalyticsError::MissingEnergy(id) => assert_eq!(id, nid("Cf237")),
1446 other => panic!("{other:?}"),
1447 }
1448 let mut co = Material::new();
1450 co.add_nuclide(nid("Co60"), 1.0);
1451 match co.decay_heat(&analytics, &NoDecayEnergies).unwrap_err() {
1452 AnalyticsError::MissingEnergy(id) => assert_eq!(id, nid("Co60")),
1453 other => panic!("{other:?}"),
1454 }
1455 }
1456
1457 struct MassTable(BTreeMap<u32, f64>);
1459
1460 impl MassProvider for MassTable {
1461 fn mass(&self, nucid: u32) -> Option<f64> {
1462 self.0.get(&nucid).copied()
1463 }
1464 }
1465
1466 struct DoseTable {
1468 factors: BTreeMap<(u32, DosePathway, DoseSource), f64>,
1469 }
1470
1471 impl DoseTable {
1472 fn new(pairs: &[(&str, DosePathway, DoseSource, f64)]) -> Self {
1473 Self {
1474 factors: pairs
1475 .iter()
1476 .map(|&(name, p, s, v)| (nid(name).nucid(), p, s, v))
1477 .map(|(n, p, s, v)| ((n, p, s), v))
1478 .collect(),
1479 }
1480 }
1481 }
1482
1483 impl DoseProvider for DoseTable {
1484 fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64> {
1485 self.factors.get(&(nucid, pathway, source)).copied()
1486 }
1487 }
1488
1489 struct ConstDecays(BTreeMap<u32, f64>);
1490
1491 impl DecayProvider for ConstDecays {
1492 fn decay_constant(&self, nucid: u32) -> Option<f64> {
1493 self.0.get(&nucid).copied()
1494 }
1495 }
1496
1497 #[test]
1498 fn dose_per_g_matches_pyne_equation_with_synthetic_data() {
1499 use DosePathway as P;
1500 let masses = MassTable(
1502 [(nid("H1").nucid(), 1.0), (nid("Co60").nucid(), 60.0)]
1503 .into_iter()
1504 .collect(),
1505 );
1506 let decays = ConstDecays(
1507 [(nid("H1").nucid(), 0.1), (nid("Co60").nucid(), 0.2)]
1508 .into_iter()
1509 .collect(),
1510 );
1511 let analytics = Analytics {
1512 masses: &masses,
1513 decays: &decays,
1514 };
1515 let doses = DoseTable::new(&[
1516 ("H1", P::Ingest, DoseSource::Epa, 2.0),
1517 ("Co60", P::Ingest, DoseSource::Epa, 3.0),
1518 ("H1", P::Air, DoseSource::Epa, 4.0),
1519 ("Co60", P::Air, DoseSource::Epa, 5.0),
1520 ]);
1521 let mut mat = Material::new();
1522 mat.add_nuclide(nid("H1"), 1.0);
1523 mat.add_nuclide(nid("Co60"), 3.0);
1524 let ingest = mat
1526 .dose_per_g(&analytics, &doses, P::Ingest, DoseSource::Epa)
1527 .unwrap();
1528 let e_h = PCI_PER_BQ * AVOGADRO * 0.25 * 0.1 * 2.0 / 1.0;
1529 let e_co = PCI_PER_BQ * AVOGADRO * 0.75 * 0.2 * 3.0 / 60.0;
1530 assert!((ingest[&nid("H1")] - e_h).abs() / e_h < 1e-12);
1531 assert!((ingest[&nid("Co60")] - e_co).abs() / e_co < 1e-12);
1532 let total = mat
1533 .total_dose_per_g(&analytics, &doses, P::Ingest, DoseSource::Epa)
1534 .unwrap();
1535 assert!((total - (e_h + e_co)).abs() / total < 1e-12);
1536 let air = mat
1538 .dose_per_g(&analytics, &doses, P::Air, DoseSource::Epa)
1539 .unwrap();
1540 let e_air = CI_PER_BQ * AVOGADRO * 0.25 * 0.1 * 4.0 / 1.0;
1541 assert!((air[&nid("H1")] - e_air).abs() / e_air < 1e-12);
1542 }
1543
1544 #[test]
1545 fn dose_per_g_of_one_gram_co60_matches_hand_calculation() {
1546 use DosePathway as P;
1547 use DoseSource as S;
1548 let mut mat = Material::new();
1549 mat.add_nuclide(nid("Co60"), 1.0);
1550 let analytics = Analytics {
1551 masses: &Ame2020,
1552 decays: &ChainDecays,
1553 };
1554 let per_nuc = mat
1555 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1556 .unwrap();
1557 let co60 = nid("Co60");
1558 let mass_u = nucleide_nuclei::data::atomic_mass(co60.nucid()).unwrap();
1559 let lambda = LN_2 / nucleide_nuclei::data::half_life(co60.nucid()).unwrap();
1560 let df = DoseFactors
1561 .dose_factor(co60.nucid(), P::Ingest, S::Epa)
1562 .unwrap();
1563 assert!((df - 2.69e-05).abs() / 2.69e-05 < 1e-9);
1565 let expected = PCI_PER_BQ * AVOGADRO * 1.0 * lambda * df / mass_u;
1566 assert!((per_nuc[&co60] - expected).abs() / expected < 1e-12);
1567 let total = mat
1568 .total_dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1569 .unwrap();
1570 assert!((total - expected).abs() / expected < 1e-12);
1571 }
1572
1573 #[test]
1574 fn dose_per_g_missing_dose_errors() {
1575 use DosePathway as P;
1576 use DoseSource as S;
1577 let analytics = Analytics {
1578 masses: &Ame2020,
1579 decays: &ChainDecays,
1580 };
1581 let mut fe = Material::new();
1584 fe.add_nuclide(nid("Fe56"), 1.0);
1585 let fe_dose = fe
1586 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1587 .unwrap();
1588 assert_eq!(fe_dose[&nid("Fe56")], 0.0);
1589 assert_eq!(
1590 fe.total_dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1591 .unwrap(),
1592 0.0
1593 );
1594 let mut h3 = Material::new();
1596 h3.add_nuclide(nid("H3"), 1.0);
1597 match h3
1598 .dose_per_g(&analytics, &DoseFactors, P::Air, S::Genii)
1599 .unwrap_err()
1600 {
1601 AnalyticsError::MissingDose(id) => assert_eq!(id, nid("H3")),
1602 other => panic!("{other:?}"),
1603 }
1604 let mut co = Material::new();
1606 co.add_nuclide(nid("Co60"), 1.0);
1607 match co
1608 .dose_per_g(&analytics, &NoDoses, P::Ingest, S::Epa)
1609 .unwrap_err()
1610 {
1611 AnalyticsError::MissingDose(id) => assert_eq!(id, nid("Co60")),
1612 other => panic!("{other:?}"),
1613 }
1614 match Material::new()
1616 .total_dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1617 .unwrap_err()
1618 {
1619 AnalyticsError::Core(crate::Error::Degenerate) => {}
1620 other => panic!("{other:?}"),
1621 }
1622 }
1623
1624 #[test]
1625 fn stable_water_contributes_exact_zeros() {
1626 use DosePathway as P;
1627 use DoseSource as S;
1628 let mut mat = Material::new();
1631 mat.add_nuclide(nid("H1"), 2.0);
1632 mat.add_nuclide(nid("O16"), 16.0);
1633 let analytics = Analytics {
1634 masses: &Ame2020,
1635 decays: &ChainDecays,
1636 };
1637
1638 let activity = mat.activity(&analytics).unwrap();
1639 assert_eq!(activity[&nid("H1")], 0.0);
1640 assert_eq!(activity[&nid("O16")], 0.0);
1641 assert_eq!(mat.specific_activity(&analytics).unwrap(), 0.0);
1642
1643 let heat = mat.decay_heat(&analytics, &DecayEnergies).unwrap();
1644 assert_eq!(heat[&nid("H1")], 0.0);
1645 assert_eq!(heat[&nid("O16")], 0.0);
1646 assert_eq!(
1647 mat.total_decay_heat(&analytics, &DecayEnergies).unwrap(),
1648 0.0
1649 );
1650
1651 for pathway in [P::Air, P::Soil, P::Ingest, P::Inhale] {
1652 for source in [S::Epa, S::Doe, S::Genii] {
1653 let dose = mat
1654 .dose_per_g(&analytics, &DoseFactors, pathway, source)
1655 .unwrap();
1656 assert_eq!(dose[&nid("H1")], 0.0, "{pathway:?}/{source:?}");
1657 assert_eq!(dose[&nid("O16")], 0.0, "{pathway:?}/{source:?}");
1658 assert_eq!(
1659 mat.total_dose_per_g(&analytics, &DoseFactors, pathway, source)
1660 .unwrap(),
1661 0.0,
1662 "{pathway:?}/{source:?}"
1663 );
1664 }
1665 }
1666 }
1667
1668 #[test]
1669 fn mixed_stable_plus_radioactive_matches_radioactive_only() {
1670 use DosePathway as P;
1671 use DoseSource as S;
1672 let mut mixed = Material::new();
1675 mixed.add_nuclide(nid("U235"), 1.0);
1676 mixed.add_nuclide(nid("H1"), 1.0);
1677 let mut pure = Material::new();
1678 pure.add_nuclide(nid("U235"), 1.0);
1679 let analytics = Analytics {
1680 masses: &Ame2020,
1681 decays: &ChainDecays,
1682 };
1683
1684 let mixed_act = mixed.activity(&analytics).unwrap();
1685 let pure_act = pure.activity(&analytics).unwrap();
1686 assert_eq!(mixed_act[&nid("U235")], pure_act[&nid("U235")]);
1687 assert!(pure_act[&nid("U235")] > 0.0);
1688 assert_eq!(mixed_act[&nid("H1")], 0.0);
1689
1690 let mixed_heat = mixed.decay_heat(&analytics, &DecayEnergies).unwrap();
1691 let pure_heat = pure.decay_heat(&analytics, &DecayEnergies).unwrap();
1692 assert_eq!(mixed_heat[&nid("U235")], pure_heat[&nid("U235")]);
1693 assert!(pure_heat[&nid("U235")] > 0.0);
1694 assert_eq!(mixed_heat[&nid("H1")], 0.0);
1695
1696 let mixed_dose = mixed
1699 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1700 .unwrap();
1701 let pure_dose = pure
1702 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1703 .unwrap();
1704 assert_eq!(mixed_dose[&nid("H1")], 0.0);
1705 assert_eq!(mixed_dose[&nid("U235")], pure_dose[&nid("U235")] * 0.5);
1706 }
1707
1708 #[test]
1709 fn unknown_nuclide_without_mass_still_errors() {
1710 use DosePathway as P;
1711 use DoseSource as S;
1712 let og = nid("Og296");
1715 assert_eq!(
1716 nucleide_nuclei::data::atomic_mass(og.nucid()),
1717 None,
1718 "Og296 must stay absent from the mass table"
1719 );
1720 let mut mat = Material::new();
1721 mat.add_nuclide(og, 1.0);
1722 let analytics = Analytics {
1723 masses: &Ame2020,
1724 decays: &ChainDecays,
1725 };
1726
1727 match mat.activity(&analytics).unwrap_err() {
1728 AnalyticsError::Core(crate::Error::MissingMass(id)) => assert_eq!(id, og),
1729 other => panic!("{other:?}"),
1730 }
1731 match mat.decay_heat(&analytics, &DecayEnergies).unwrap_err() {
1732 AnalyticsError::Core(crate::Error::MissingMass(id)) => assert_eq!(id, og),
1733 other => panic!("{other:?}"),
1734 }
1735 match mat
1736 .dose_per_g(&analytics, &DoseFactors, P::Ingest, S::Epa)
1737 .unwrap_err()
1738 {
1739 AnalyticsError::Core(crate::Error::MissingMass(id)) => assert_eq!(id, og),
1740 other => panic!("{other:?}"),
1741 }
1742 let msg = crate::Error::MissingMass(og).to_string();
1743 assert!(msg.contains("Og296"), "{msg}");
1744 }
1745
1746 #[test]
1747 fn nuclei_dose_data_serves_as_dose_provider() {
1748 use DosePathway as P;
1749 use DoseSource as S;
1750 let provider = nucleide_nuclei::data::DoseData;
1751 assert_eq!(
1752 DoseFactors.dose_factor(nid("Cs137").nucid(), P::Inhale, S::Epa),
1753 provider.dose_factor(nid("Cs137").nucid(), P::Inhale, S::Epa)
1754 );
1755 let mut mat = Material::new();
1756 mat.add_nuclide(nid("Cs137"), 2.0);
1757 let analytics = Analytics {
1758 masses: &Ame2020,
1759 decays: &ChainDecays,
1760 };
1761 let via_facade = mat
1762 .total_dose_per_g(&analytics, &provider, P::Inhale, S::Epa)
1763 .unwrap();
1764 let via_struct = mat
1765 .total_dose_per_g(&analytics, &DoseFactors, P::Inhale, S::Epa)
1766 .unwrap();
1767 assert!((via_facade - via_struct).abs() < 1e-18);
1768 }
1769}
1770
1771#[cfg(test)]
1772mod ame_tests {
1773 use super::*;
1774
1775 #[test]
1776 fn ame2020_provider_resolves_water() {
1777 let m = Material::from_atom_frac(
1779 &[
1780 (nucleide_nuclei::NuclideId::from_name("H1").unwrap(), 2.0),
1781 (nucleide_nuclei::NuclideId::from_name("O16").unwrap(), 1.0),
1782 ],
1783 &Ame2020,
1784 Some(1.0),
1785 )
1786 .unwrap();
1787 let af = m.atom_fractions(&Ame2020).unwrap();
1788 assert!(
1789 (af[&nucleide_nuclei::NuclideId::from_name("H1").unwrap()] - 2.0 / 3.0).abs() < 1e-12
1790 );
1791 }
1792}