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)]
551pub enum AnalyticsError {
552    /// No decay data was available for a requested nuclide.
553    ///
554    /// Reserved for provider-level absence. The built-in analytics paths
555    /// below never construct this anymore: a known atomic mass with no
556    /// decay constant is a stable nuclide (λ = 0) and contributes exactly
557    /// 0.0 instead of erroring, so only a genuinely unknown nuclide (no
558    /// mass data, [`crate::Error::MissingMass`]) can still fail. The
559    /// variant is retained for API compatibility.
560    #[error("no decay data available for nuclide `{0}`")]
561    MissingDecay(NuclideId),
562    /// No mean decay energy was available for a requested nuclide.
563    #[error("no decay energy available for nuclide `{0}`")]
564    MissingEnergy(NuclideId),
565    /// No dose factor was available for a requested nuclide/pathway/source.
566    #[error("no dose factor available for nuclide `{0}`")]
567    MissingDose(NuclideId),
568    /// An underlying composition failure (missing mass, degenerate total).
569    #[error(transparent)]
570    Core(#[from] crate::Error),
571}
572
573/// Source of per-nuclide mean recoverable decay energies in MeV per decay.
574///
575/// Like [`MassProvider`], injected as a trait so analytics never hard-depend
576/// on decay-energy data availability. Kept separate from [`DecayProvider`]
577/// (which supplies decay constants) so depletion callers can mix sources;
578/// `nucleide_nuclei::data::DecayData` implements this trait, giving Stream A
579/// a single provider for both without any material↔depletion coupling
580/// (material never depends on depletion; nuclei never depends on material).
581pub trait DecayEnergyProvider {
582    /// Mean recoverable energy per decay of the nuclide identified by raw
583    /// `nucid`, in MeV, or `None` if unknown (stable nuclides included).
584    fn decay_energy_mev(&self, nucid: u32) -> Option<f64>;
585}
586
587/// A [`DecayEnergyProvider`] that knows no decay energies.
588///
589/// Every lookup returns `None`, so heat calculations fail explicitly with
590/// [`AnalyticsError::MissingEnergy`].
591#[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/// [`DecayEnergyProvider`] backed by the ENDF/B-VII.1 prompt-decay-energy
601/// table in `nucleide_nuclei::data` (mean-field evaluation values, generated
602/// by `scripts/gen-nuclear-data.py` — see the table docs before quoting
603/// heat numbers).
604#[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
619/// Source of per-nuclide dose factors (raw table values).
620///
621/// Like [`MassProvider`], injected as a trait so analytics never hard-depend
622/// on dose-table availability. Kept separate from [`DecayProvider`]/
623/// [`DecayEnergyProvider`] so callers can mix sources; the blanket impl for
624/// `nucleide_nuclei::data::DoseData` gives a single provider with no
625/// material↔nuclei circularity (material depends on nuclei, never the reverse).
626pub trait DoseProvider {
627    /// Raw dose factor for the nuclide identified by raw `nucid`, or `None`
628    /// if the nuclide has no row for this `pathway`/`source`.
629    ///
630    /// Implementations backed by [`crate::DoseFactors`] return the stored
631    /// `-1` sentinel for GENII/DOE air (PyNE missing-air convention);
632    /// [`Material::dose_per_g`] treats negative factors as missing and fails
633    /// with [`AnalyticsError::MissingDose`].
634    fn dose_factor(&self, nucid: u32, pathway: DosePathway, source: DoseSource) -> Option<f64>;
635}
636
637/// A [`DoseProvider`] that knows no dose factors.
638///
639/// Every lookup returns `None`, so dose calculations fail explicitly with
640/// [`AnalyticsError::MissingDose`].
641#[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/// [`DoseProvider`] backed by the HNF-5636/PyNE dose-factor table in
651/// `nucleide_nuclei::data` (generated by `scripts/gen-nuclear-data.py` —
652/// see the table docs; screening-level only, not for safety decisions).
653#[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    /// Activity `A = λ·N` per nuclide, in becquerels.
670    ///
671    /// Atom counts follow from stored masses through `masses`
672    /// (`N = m / (M · u)` with `u = 1.66053906892e-24 g`) and decay
673    /// constants through `decays`. Stable-as-zero: a known atomic mass
674    /// with no decay constant is a stable nuclide (λ = 0, mirroring the
675    /// chain rule where `None` decay → 0.0) and contributes exactly 0.0.
676    /// Fails with [`AnalyticsError::Core`] wrapping
677    /// [`crate::Error::MissingMass`] when an atomic mass is unknown, so
678    /// genuinely unknown nuclides never collapse to silent zeros.
679    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    /// Specific activity of the whole material, in Bq/g: total activity
697    /// divided by total stored mass. Fails with
698    /// [`AnalyticsError::Core`](`crate::Error::Degenerate`) for empty or
699    /// non-positive materials; otherwise identical error behavior to
700    /// [`Material::activity`].
701    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    /// Decay heat per nuclide, in watts: `P_i = A_i · E_i`.
714    ///
715    /// Activities come from [`Material::activity`] (masses via `analytics`,
716    /// decay constants via `analytics.decays`); mean recoverable energies
717    /// per decay come from `energies` in MeV, converted with
718    /// [`MEV_TO_JOULES`]. Energies are screening-level placeholders (see
719    /// [`DecayEnergies`]), so heat numbers are order-of-magnitude checks,
720    /// not calorimetry.
721    ///
722    /// Fails with [`AnalyticsError::MissingEnergy`] for radioactive
723    /// nuclides without a decay-energy row; stable nuclides (zero
724    /// activity, hence `P = A·E = 0` regardless of `E`) skip the energy
725    /// lookup and contribute exactly 0.0. Otherwise identical error
726    /// behavior to [`Material::activity`].
727    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            // Exact zero by construction (λ = 0 or zero stored mass); no
736            // energy row exists for stable nuclides, and none is needed.
737            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    /// Total decay heat of the whole material, in watts: the sum of
750    /// [`Material::decay_heat`]. Same error behavior.
751    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    /// Dose per gram per nuclide, mirroring PyNE `Material::dose_per_g`.
764    ///
765    /// For weight fraction `w_i = m_i / m_tot`:
766    ///
767    /// ```text
768    /// dose_i = Ci_per_Bq · N_A · w_i · λ_i · DF_i / M_i   (air/soil)
769    /// dose_i = pCi_per_Bq · N_A · w_i · λ_i · DF_i / M_i  (ingest/inhale)
770    /// ```
771    ///
772    /// with [`CI_PER_BQ`] = 2.7027027e-11, [`PCI_PER_BQ`] = 27.027027,
773    /// `N_A` = [`AVOGADRO`] (PyNE uses 6.0221415e23; the difference is <0.1 ppm),
774    /// `λ` from `analytics.decays`, `M` (g/mol) from `analytics.masses`, and
775    /// `DF` from `doses`. Units follow the table: air `mrem/h per g per m^3`,
776    /// soil `mrem/h per g per m^2`, ingest/inhale `mrem per g`. The returned
777    /// map holds each nuclide's per-gram contribution; sum for the total.
778    ///
779    /// Screening-level only — not for safety decisions. Stable nuclides
780    /// (known mass, λ = 0 or absent) contribute exactly 0.0 and skip the
781    /// `doses` lookup entirely, so no [`AnalyticsError::MissingDose`] is
782    /// raised for them. Fails with [`AnalyticsError::MissingDose`] when a
783    /// radioactive nuclide's factor is absent or negative (`-1` GENII/DOE
784    /// air sentinel); otherwise identical error behavior to
785    /// [`Material::activity`] (plus `Degenerate` for empty materials).
786    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    /// Total dose per gram of the whole material: the sum of
823    /// [`Material::dose_per_g`]. Same units and error behavior.
824    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    /// Round-number atomic masses so mixture math stays hand-checkable.
852    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        // Stream a carries 2 g (U235 + Pu239) at weight 1; stream b carries
1066        // 1 g of U238 at weight 2.
1067        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        // 1 volume unit at rho=10 plus 1.5 units at rho=2:
1084        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    /// Feed for the separation tests: U235 10 g, U238 90 g, Pu239 1 g,
1110    /// Pu240 2 g, Am241 3 g, Am242 2.8 g (108.8 g total).
1111    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        // Element shorthands expanded per nuclide: U at 0.7, Pu at 0.4,
1125        // Am241 at 0.4; unlisted Am242 goes entirely to tails.
1126        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        // Hand-computed product masses (g).
1137        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        // Hand-computed tails masses (g).
1146        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        // Per-nuclide conservation: product + tails == feed.
1155        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        // eff 1 sends everything to product; eff 0 sends all to tails.
1169        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        // Ratios [1, 2] normalize to [1/3, 2/3]: the output is the
1210        // weighted average (1/3)*a + (2/3)*b, total 4/3 g.
1211        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        // Equal ratios [2, 2] give the plain mean: total 1.5 g.
1222        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        // Empty and all-zero recipes are degenerate (no silent 1/N split).
1235        assert!(matches!(Material::blend(&[]), Err(Error::Degenerate)));
1236        assert!(matches!(
1237            Material::blend(&[(&a, 0.0)]),
1238            Err(Error::Degenerate)
1239        ));
1240        // Negative, NaN, and infinite ratios are rejected outright.
1241        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        // λ from the half-life table, independently of ChainDecays.
1309        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        // N = m / (M · u) atoms for 1 g.
1317        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        // 5 g of Cs137: specific activity must equal total activity / 5.
1325        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        // Cs137 specific activity is ~3.2 TBq/g; sanity-band the units.
1336        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        // Stable Fe56 has no tabulated decay data.
1346        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        // Provider-level absence resolves through the same stable-as-zero
1352        // rule: a known mass with no decay constant contributes 0.0 rather
1353        // than raising MissingDecay.
1354        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        // P = A * E with E from the ENDF/B-VII.1 decay-energy table.
1402        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        // 1 g Co60 is ~40 TBq * ~4.2e-13 J ≈ ~17 W; sanity-band the units.
1407        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        // Stream A wiring: a single DecayData covers λ and MeV with no
1416        // material<->depletion coupling.
1417        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        // Cf237 has a half-life (activity computes) but no ENDF decay tape,
1437        // hence no decay-energy row.
1438        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        // The explicit no-data provider fails on the first nuclide too.
1449        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    /// Synthetic mass provider with small inline maps (never vendored data).
1458    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    /// Synthetic dose provider with small inline maps (never vendored data).
1467    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        // Hand-checkable masses/lambdas; DFs are synthetic (not PyNE values).
1501        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        // w_H=0.25, w_Co=0.75.
1525        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        // Air uses Ci_per_Bq instead of pCi_per_Bq.
1537        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        // Raw table factor is the Co-60 ingest EPA spot (2.69e-05 mrem/pCi).
1564        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        // Fe56 is stable with no dose row: stable-as-zero skips the dose
1582        // provider, so it contributes exactly 0.0 instead of erroring.
1583        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        // GENII air is a -1 sentinel, treated as missing.
1595        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        // Explicit no-data provider fails too.
1605        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        // Empty materials are degenerate.
1615        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        // H2O: every member has an AME2020 mass but no decay data, so all
1629        // three observables resolve to exactly 0.0 on every pathway/source.
1630        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        // Activity and heat are absolute per nuclide: the U235 entries are
1673        // identical with or without the stable diluent, which adds 0.0.
1674        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        // Dose is per gram, so the U235 entry scales by its weight fraction
1697        // (1/2 here) while H1 contributes exactly 0.0.
1698        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        // Og296 parses (Z = 118) but has no AME2020 row anywhere: the
1713        // guard rail against silent zeros for genuinely unknown nuclides.
1714        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        // H2O from atom fractions with real masses
1778        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}