Skip to main content

nucleide_material/
material.rs

1//! The [`Material`] composition model: construction, conversions, mixing,
2//! arithmetic, and (de)serialization.
3
4use 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
13/// True for values that cannot serve as a positive total mass.
14fn not_positive(v: f64) -> bool {
15    v.is_nan() || v <= 0.0
16}
17
18/// True for values that cannot serve as a mixing fraction.
19fn is_negative(v: f64) -> bool {
20    v.is_nan() || v < 0.0
21}
22
23/// Source of per-nuclide atomic masses in g/mol.
24///
25/// Atomic-mass-dependent operations ([`Material::from_atom_frac`] and
26/// [`Material::atom_fractions`]) are generic over this trait so the material
27/// crate never depends on the nuclear-data tables directly. Integrating the
28/// real tables later is a single `impl MassProvider for nucleide_nuclei::data::...`.
29pub trait MassProvider {
30    /// Atomic mass of the nuclide identified by raw `nucid`
31    /// (`(Z*1000 + A)*10_000 + state`), or `None` if unknown.
32    fn mass(&self, nucid: u32) -> Option<f64>;
33}
34
35/// A [`MassProvider`] that knows no masses.
36///
37/// Useful as an explicit placeholder; every lookup returns `None`, so
38/// mass-dependent conversions fail with [`crate::Error::MissingMass`].
39#[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/// [`MassProvider`] backed by the AME2020 tables in `nucleide_nuclei::data`.
49#[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/// A nuclear material: nuclide masses plus optional density and metadata.
59///
60/// The composition stores absolute masses per nuclide, in grams by
61/// convention; only relative amounts matter for fraction-based consumers,
62/// which normalize on demand. Density is deliberately separate from the
63/// composition: it is a property of the physical stream and is
64/// not scaled or combined by the arithmetic operators except where noted.
65///
66/// Combining two materials clears density and metadata (a mixture has no
67/// single density); scalar scaling preserves them.
68#[derive(Debug, Clone, Default, PartialEq)]
69pub struct Material {
70    /// Stored masses (grams) keyed by nuclide.
71    pub comp: BTreeMap<NuclideId, f64>,
72    density: Option<f64>,
73    metadata: Option<serde_json::Value>,
74}
75
76impl Material {
77    /// An empty material.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Build a material from atom counts/fractions, converting to masses via
83    /// `m_i = n_i * M_i` with atomic masses from `masses`.
84    ///
85    /// Entries with zero atom count are skipped. Fails with
86    /// [`crate::Error::MissingMass`] if any nonzero entry lacks a known
87    /// atomic mass.
88    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    /// Add `mass` grams of `id`, accumulating when already present.
110    pub fn add_nuclide(&mut self, id: NuclideId, mass: f64) {
111        *self.comp.entry(id).or_insert(0.0) += mass;
112    }
113
114    /// Remove a nuclide, returning its stored mass if present.
115    pub fn remove_nuclide(&mut self, id: NuclideId) -> Option<f64> {
116        self.comp.remove(&id)
117    }
118
119    /// Drop the entire composition (density and metadata are kept).
120    pub fn clear(&mut self) {
121        self.comp.clear();
122    }
123
124    /// Total stored mass in grams.
125    pub fn mass(&self) -> f64 {
126        self.comp.values().sum()
127    }
128
129    /// Mass density previously set on this material, if any.
130    pub fn density(&self) -> Option<f64> {
131        self.density
132    }
133
134    /// Set (or unset) the mass density.
135    pub fn set_density(&mut self, density: Option<f64>) {
136        self.density = density;
137    }
138
139    /// Free-form metadata attached to this material.
140    pub fn metadata(&self) -> Option<&serde_json::Value> {
141        self.metadata.as_ref()
142    }
143
144    /// Replace the free-form metadata.
145    pub fn set_metadata(&mut self, metadata: Option<serde_json::Value>) {
146        self.metadata = metadata;
147    }
148
149    /// Normalized weight fractions; they sum to one.
150    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    /// Normalized atom fractions; they sum to one.
159    ///
160    /// Each nuclide contributes moles proportional to `mass / M`; atomic
161    /// masses come from `masses`.
162    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    /// Mix streams weighted by relative mass amounts.
183    ///
184    /// Fractions need not sum to one; they are relative weights of each
185    /// stream's full mass.
186    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    /// Mix streams weighted by relative volumes, converting each stream's
203    /// contribution through its own density (`m = v * rho`). Every input
204    /// must have a positive density set.
205    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    /// Split this material into product and tails streams by per-nuclide
227    /// separation efficiency.
228    ///
229    /// Each listed nuclide sends the fraction `eff` of its stored mass to
230    /// the product stream and `1 - eff` to the tails stream; nuclides absent
231    /// from `effs` send nothing to product (`eff = 0`). Mass is conserved
232    /// per nuclide: `product + tails == self` up to floating-point rounding.
233    /// Efficiencies must be finite values in `[0, 1]` (else
234    /// [`crate::Error::InvalidEfficiency`]); a repeated nuclide keeps its
235    /// last-listed efficiency. Both outputs clear density and metadata (a
236    /// split stream has no single density), and nuclides with exactly zero
237    /// mass on a side are dropped from that side.
238    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    /// Blend streams at fixed ratios with explicit normalization.
263    ///
264    /// Ratios are relative target proportions: they are normalized by their
265    /// sum (`w_i = r_i / Σr`) and the output is the weighted average
266    /// `Σ w_i · mat_i` (density and metadata cleared, as for the arithmetic
267    /// operators). Unlike the cycamore mixer this never falls back to a
268    /// silent uniform split: an empty slice or an all-zero (or non-finite)
269    /// ratio sum fails with [`crate::Error::Degenerate`], and any negative
270    /// or non-finite ratio fails with [`crate::Error::NegativeFraction`].
271    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    /// Scale all stored masses by `factor`, keeping density and metadata.
299    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    /// Combine two mass streams: per-nuclide masses add. Density and
312    /// metadata are cleared on the mixture. Nuclides whose combined mass is
313    /// exactly zero are dropped.
314    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    /// Remove a mass stream: per-nuclide masses subtract. Density and
332    /// metadata are cleared. Nuclides whose combined mass is exactly zero
333    /// are dropped.
334    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    /// Scale every stored mass by `rhs` (density unchanged).
352    fn mul(self, rhs: f64) -> Material {
353        self.scaled(rhs)
354    }
355}
356
357impl Div<f64> for Material {
358    type Output = Material;
359
360    /// Divide every stored mass by `rhs` (density unchanged).
361    ///
362    /// # Panics
363    /// If `rhs` is zero.
364    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    /// Raise the total mass by `rhs` grams while preserving relative
374    /// composition. Density is unchanged.
375    ///
376    /// # Panics
377    /// If the current mass is non-positive or the new total would be.
378    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    /// Lower the total mass by `rhs` grams while preserving relative
393    /// composition.
394    ///
395    /// # Panics
396    /// If the current mass is non-positive or the remainder would be.
397    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
454// ---------------------------------------------------------------------------
455// Radioanalytics
456//
457// Activity (and derived specific activity) computed from stored masses via a
458// [`DecayProvider`] plus the existing [`MassProvider`]. Decay heat
459// ([`Material::decay_heat`]) additionally needs mean recoverable decay
460// energies ([`DecayEnergyProvider`], backed by the `decay_energy.tsv` table
461// in `nucleide_nuclei::data`).
462//
463// Dose per gram ([`Material::dose_per_g`]) follows PyNE's
464// `Material::dose_per_g` using the EPA/DOE/GENII ingestion/inhalation/air-soil
465// factors in `dose_factors.tsv` ([`DoseProvider`], backed by
466// [`DoseFactors`]/`nucleide_nuclei::data::DoseData`). Screening-level only —
467// not for safety decisions (upstream HNF-5636/PyNE disclaimer).
468// ---------------------------------------------------------------------------
469
470/// Avogadro constant, atoms per mole (exact, 2019 SI).
471pub const AVOGADRO: f64 = 6.022_140_76e23;
472
473/// One unified atomic mass unit in grams (2022 CODATA).
474pub const GRAMS_PER_U: f64 = 1.660_539_068_92e-24;
475
476/// One MeV in joules (exact, 2019 SI: 1 eV = 1.602176634e-19 J).
477pub const MEV_TO_JOULES: f64 = 1.602_176_634e-13;
478
479/// Curies per becquerel (PyNE `Ci_per_Bq`, `src/data.cpp`).
480pub const CI_PER_BQ: f64 = 2.702_702_7e-11;
481
482/// Picocuries per becquerel (PyNE `pCi_per_Bq`, `Material::dose_per_g`).
483pub const PCI_PER_BQ: f64 = 27.027_027;
484
485/// Dose pathway and source types (re-exported from `nucleide-nuclei` so
486/// analytics call sites need one import).
487pub use nucleide_nuclei::data::{DosePathway, DoseSource};
488
489/// Source of per-nuclide decay constants λ in inverse seconds.
490///
491/// Like [`MassProvider`], injected as a trait so analytics never hard-depend
492/// on decay data availability.
493pub trait DecayProvider {
494    /// Decay constant of the nuclide identified by raw `nucid`, or `None`
495    /// if unknown (stable nuclides included).
496    fn decay_constant(&self, nucid: u32) -> Option<f64>;
497}
498
499/// A [`DecayProvider`] that knows no decays.
500///
501/// Every lookup returns `None`, so activities fail explicitly with
502/// [`AnalyticsError::MissingDecay`].
503#[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/// [`DecayProvider`] backed by the ENDF/B-VIII.0 half-life table in
513/// `nucleide_nuclei::data`.
514#[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
523/// Provider bundle for radioanalytic quantities.
524///
525/// Both providers are needed at once — atom numbers come from masses,
526/// activities from decay constants — so they travel together:
527///
528/// ```
529/// use nucleide_material::{Analytics, Ame2020, ChainDecays};
530/// # let mut mat = nucleide_material::Material::new();
531/// # let co = nucleide_nuclei::NuclideId::from_name("Co60").unwrap();
532/// # mat.add_nuclide(co, 1e-6);
533/// let an = Analytics { masses: &Ame2020, decays: &ChainDecays };
534/// let a = mat.activity(&an).unwrap();
535/// ```
536pub struct Analytics<'a> {
537    /// Atomic masses (u) for gram → atom conversion.
538    pub masses: &'a dyn MassProvider,
539    /// Decay constants λ (1/s).
540    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/// Errors from radioanalytics beyond the shared [`enum@crate::Error`] set.
550#[derive(Debug, Error)]
551#[non_exhaustive]
552pub enum AnalyticsError {
553    /// No decay data was available for a requested nuclide.
554    ///
555    /// Reserved for provider-level absence. The built-in analytics paths
556    /// below never construct this anymore: a known atomic mass with no
557    /// decay constant is a stable nuclide (λ = 0) and contributes exactly
558    /// 0.0 instead of erroring, so only a genuinely unknown nuclide (no
559    /// mass data, [`crate::Error::MissingMass`]) can still fail. The
560    /// variant is retained for API compatibility.
561    #[error("no decay data available for nuclide `{0}`")]
562    MissingDecay(NuclideId),
563    /// No mean decay energy was available for a requested nuclide.
564    #[error("no decay energy available for nuclide `{0}`")]
565    MissingEnergy(NuclideId),
566    /// No dose factor was available for a requested nuclide/pathway/source.
567    #[error("no dose factor available for nuclide `{0}`")]
568    MissingDose(NuclideId),
569    /// An underlying composition failure (missing mass, degenerate total).
570    #[error(transparent)]
571    Core(#[from] crate::Error),
572}
573
574/// Source of per-nuclide mean recoverable decay energies in MeV per decay.
575///
576/// Like [`MassProvider`], injected as a trait so analytics never hard-depend
577/// on decay-energy data availability. Kept separate from [`DecayProvider`]
578/// (which supplies decay constants) so depletion callers can mix sources;
579/// `nucleide_nuclei::data::DecayData` implements this trait, giving Stream A
580/// a single provider for both without any material↔depletion coupling
581/// (material never depends on depletion; nuclei never depends on material).
582pub trait DecayEnergyProvider {
583    /// Mean recoverable energy per decay of the nuclide identified by raw
584    /// `nucid`, in MeV, or `None` if unknown (stable nuclides included).
585    fn decay_energy_mev(&self, nucid: u32) -> Option<f64>;
586}
587
588/// A [`DecayEnergyProvider`] that knows no decay energies.
589///
590/// Every lookup returns `None`, so heat calculations fail explicitly with
591/// [`AnalyticsError::MissingEnergy`].
592#[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/// [`DecayEnergyProvider`] backed by the ENDF/B-VII.1 prompt-decay-energy
602/// table in `nucleide_nuclei::data` (mean-field evaluation values, generated
603/// by `scripts/gen-nuclear-data.py` — see the table docs before quoting
604/// heat numbers).
605#[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
620/// Source of per-nuclide dose factors (raw table values).
621///
622/// Like [`MassProvider`], injected as a trait so analytics never hard-depend
623/// on dose-table availability. Kept separate from [`DecayProvider`]/
624/// [`DecayEnergyProvider`] so callers can mix sources; the blanket impl for
625/// `nucleide_nuclei::data::DoseData` gives a single provider with no
626/// material↔nuclei circularity (material depends on nuclei, never the reverse).
627pub trait DoseProvider {
628    /// Raw dose factor for the nuclide identified by raw `nucid`, or `None`
629    /// if the nuclide has no row for this `pathway`/`source`.
630    ///
631    /// Implementations backed by [`crate::DoseFactors`] return the stored
632    /// `-1` sentinel for GENII/DOE air (PyNE missing-air convention);
633    /// [`Material::dose_per_g`] treats negative factors as missing and fails
634    /// with [`AnalyticsError::MissingDose`].
635    fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64>;
636}
637
638/// A [`DoseProvider`] that knows no dose factors.
639///
640/// Every lookup returns `None`, so dose calculations fail explicitly with
641/// [`AnalyticsError::MissingDose`].
642#[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/// [`DoseProvider`] backed by the HNF-5636/PyNE dose-factor table in
652/// `nucleide_nuclei::data` (generated by `scripts/gen-nuclear-data.py` —
653/// see the table docs; screening-level only, not for safety decisions).
654#[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    /// Activity `A = λ·N` per nuclide, in becquerels.
671    ///
672    /// Atom counts follow from stored masses through `masses`
673    /// (`N = m / (M · u)` with `u = 1.66053906892e-24 g`) and decay
674    /// constants through `decays`. Stable-as-zero: a known atomic mass
675    /// with no decay constant is a stable nuclide (λ = 0, mirroring the
676    /// chain rule where `None` decay → 0.0) and contributes exactly 0.0.
677    /// Fails with [`AnalyticsError::Core`] wrapping
678    /// [`crate::Error::MissingMass`] when an atomic mass is unknown, so
679    /// genuinely unknown nuclides never collapse to silent zeros.
680    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    /// Specific activity of the whole material, in Bq/g: total activity
698    /// divided by total stored mass. Fails with
699    /// [`AnalyticsError::Core`](`crate::Error::Degenerate`) for empty or
700    /// non-positive materials; otherwise identical error behavior to
701    /// [`Material::activity`].
702    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    /// Decay heat per nuclide, in watts: `P_i = A_i · E_i`.
715    ///
716    /// Activities come from [`Material::activity`] (masses via `analytics`,
717    /// decay constants via `analytics.decays`); mean recoverable energies
718    /// per decay come from `energies` in MeV, converted with
719    /// [`MEV_TO_JOULES`]. Energies are screening-level placeholders (see
720    /// [`DecayEnergies`]), so heat numbers are order-of-magnitude checks,
721    /// not calorimetry.
722    ///
723    /// Fails with [`AnalyticsError::MissingEnergy`] for radioactive
724    /// nuclides without a decay-energy row; stable nuclides (zero
725    /// activity, hence `P = A·E = 0` regardless of `E`) skip the energy
726    /// lookup and contribute exactly 0.0. Otherwise identical error
727    /// behavior to [`Material::activity`].
728    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            // Exact zero by construction (λ = 0 or zero stored mass); no
737            // energy row exists for stable nuclides, and none is needed.
738            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    /// Total decay heat of the whole material, in watts: the sum of
751    /// [`Material::decay_heat`]. Same error behavior.
752    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    /// Dose per gram per nuclide, mirroring PyNE `Material::dose_per_g`.
765    ///
766    /// For weight fraction `w_i = m_i / m_tot`:
767    ///
768    /// ```text
769    /// dose_i = Ci_per_Bq · N_A · w_i · λ_i · DF_i / M_i   (air/soil)
770    /// dose_i = pCi_per_Bq · N_A · w_i · λ_i · DF_i / M_i  (ingest/inhale)
771    /// ```
772    ///
773    /// with [`CI_PER_BQ`] = 2.7027027e-11, [`PCI_PER_BQ`] = 27.027027,
774    /// `N_A` = [`AVOGADRO`] (PyNE uses 6.0221415e23; the difference is <0.1 ppm),
775    /// `λ` from `analytics.decays`, `M` (g/mol) from `analytics.masses`, and
776    /// `DF` from `doses`. Units follow the table: air `mrem/h per g per m^3`,
777    /// soil `mrem/h per g per m^2`, ingest/inhale `mrem per g`. The returned
778    /// map holds each nuclide's per-gram contribution; sum for the total.
779    ///
780    /// Screening-level only — not for safety decisions. Stable nuclides
781    /// (known mass, λ = 0 or absent) contribute exactly 0.0 and skip the
782    /// `doses` lookup entirely, so no [`AnalyticsError::MissingDose`] is
783    /// raised for them. Fails with [`AnalyticsError::MissingDose`] when a
784    /// radioactive nuclide's factor is absent or negative (`-1` GENII/DOE
785    /// air sentinel); otherwise identical error behavior to
786    /// [`Material::activity`] (plus `Degenerate` for empty materials).
787    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    /// Total dose per gram of the whole material: the sum of
824    /// [`Material::dose_per_g`]. Same units and error behavior.
825    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    /// Round-number atomic masses so mixture math stays hand-checkable.
853    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        // Stream a carries 2 g (U235 + Pu239) at weight 1; stream b carries
1067        // 1 g of U238 at weight 2.
1068        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        // 1 volume unit at rho=10 plus 1.5 units at rho=2:
1085        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    /// Feed for the separation tests: U235 10 g, U238 90 g, Pu239 1 g,
1111    /// Pu240 2 g, Am241 3 g, Am242 2.8 g (108.8 g total).
1112    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        // Element shorthands expanded per nuclide: U at 0.7, Pu at 0.4,
1126        // Am241 at 0.4; unlisted Am242 goes entirely to tails.
1127        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        // Hand-computed product masses (g).
1138        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        // Hand-computed tails masses (g).
1147        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        // Per-nuclide conservation: product + tails == feed.
1156        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        // eff 1 sends everything to product; eff 0 sends all to tails.
1170        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        // Ratios [1, 2] normalize to [1/3, 2/3]: the output is the
1211        // weighted average (1/3)*a + (2/3)*b, total 4/3 g.
1212        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        // Equal ratios [2, 2] give the plain mean: total 1.5 g.
1223        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        // Empty and all-zero recipes are degenerate (no silent 1/N split).
1236        assert!(matches!(Material::blend(&[]), Err(Error::Degenerate)));
1237        assert!(matches!(
1238            Material::blend(&[(&a, 0.0)]),
1239            Err(Error::Degenerate)
1240        ));
1241        // Negative, NaN, and infinite ratios are rejected outright.
1242        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        // λ from the half-life table, independently of ChainDecays.
1310        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        // N = m / (M · u) atoms for 1 g.
1318        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        // 5 g of Cs137: specific activity must equal total activity / 5.
1326        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        // Cs137 specific activity is ~3.2 TBq/g; sanity-band the units.
1337        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        // Stable Fe56 has no tabulated decay data.
1347        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        // Provider-level absence resolves through the same stable-as-zero
1353        // rule: a known mass with no decay constant contributes 0.0 rather
1354        // than raising MissingDecay.
1355        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        // P = A * E with E from the ENDF/B-VII.1 decay-energy table.
1403        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        // 1 g Co60 is ~40 TBq * ~4.2e-13 J ≈ ~17 W; sanity-band the units.
1408        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        // Stream A wiring: a single DecayData covers λ and MeV with no
1417        // material<->depletion coupling.
1418        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        // Cf237 has a half-life (activity computes) but no ENDF decay tape,
1438        // hence no decay-energy row.
1439        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        // The explicit no-data provider fails on the first nuclide too.
1450        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    /// Synthetic mass provider with small inline maps (never vendored data).
1459    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    /// Synthetic dose provider with small inline maps (never vendored data).
1468    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        // Hand-checkable masses/lambdas; DFs are synthetic (not PyNE values).
1502        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        // w_H=0.25, w_Co=0.75.
1526        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        // Air uses Ci_per_Bq instead of pCi_per_Bq.
1538        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        // Raw table factor is the Co-60 ingest EPA spot (2.69e-05 mrem/pCi).
1565        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        // Fe56 is stable with no dose row: stable-as-zero skips the dose
1583        // provider, so it contributes exactly 0.0 instead of erroring.
1584        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        // GENII air is a -1 sentinel, treated as missing.
1596        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        // Explicit no-data provider fails too.
1606        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        // Empty materials are degenerate.
1616        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        // H2O: every member has an AME2020 mass but no decay data, so all
1630        // three observables resolve to exactly 0.0 on every pathway/source.
1631        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        // Activity and heat are absolute per nuclide: the U235 entries are
1674        // identical with or without the stable diluent, which adds 0.0.
1675        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        // Dose is per gram, so the U235 entry scales by its weight fraction
1698        // (1/2 here) while H1 contributes exactly 0.0.
1699        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        // Og296 parses (Z = 118) but has no AME2020 row anywhere: the
1714        // guard rail against silent zeros for genuinely unknown nuclides.
1715        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        // H2O from atom fractions with real masses
1779        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}