Skip to main content

nereids_core/
elements.rs

1//! Element and isotope reference data.
2//!
3//! Provides element symbols, names, and natural isotopic abundances
4//! for all elements relevant to neutron resonance imaging.
5//!
6//! Delegates to the `endf-mat` crate for raw lookup data.
7
8use crate::error::NereidsError;
9use crate::types::Isotope;
10
11/// Element symbol lookup by atomic number Z.
12///
13/// Returns `Some("n")` for Z=0 (neutron). Returns `None` for Z > 118.
14pub fn element_symbol(z: u32) -> Option<&'static str> {
15    endf_mat::element_symbol(z)
16}
17
18/// Element name lookup by atomic number Z.
19pub fn element_name(z: u32) -> Option<&'static str> {
20    endf_mat::element_name(z)
21}
22
23/// Parse an isotope string like "U-238", "Pu-239", "Fe-56".
24///
25/// Returns `None` if the string cannot be parsed or validation fails.
26pub fn parse_isotope_str(s: &str) -> Option<Isotope> {
27    let parts: Vec<&str> = s.split('-').collect();
28    if parts.len() != 2 {
29        return None;
30    }
31    let symbol = parts[0].trim();
32    let a: u32 = parts[1].trim().parse().ok()?;
33    let z = symbol_to_z(symbol)?;
34    Isotope::new(z, a).ok()
35}
36
37/// Look up atomic number Z from element symbol (case-insensitive).
38pub fn symbol_to_z(symbol: &str) -> Option<u32> {
39    endf_mat::symbol_to_z(symbol)
40}
41
42/// Natural isotopic abundance for a given isotope, as a fraction (0.0 to 1.0).
43///
44/// Returns `None` if the isotope is not in the database (e.g., synthetic isotopes).
45/// Data from IUPAC 2016 recommended values (via NIST).
46pub fn natural_abundance(isotope: &Isotope) -> Option<f64> {
47    endf_mat::natural_abundance(isotope.z(), isotope.a())
48}
49
50/// Get all naturally occurring isotopes for element Z.
51///
52/// Silently skips any isotopes that fail validation (should never happen
53/// for data from the `endf_mat` crate, but defensive nonetheless).
54pub fn natural_isotopes(z: u32) -> Vec<(Isotope, f64)> {
55    endf_mat::natural_isotopes(z)
56        .into_iter()
57        .filter_map(|(a, frac)| Isotope::new(z, a).ok().map(|iso| (iso, frac)))
58        .collect()
59}
60
61/// Get all isotopes with ENDF evaluations for element Z.
62///
63/// Unlike [`natural_isotopes`], this includes synthetic and transuranic
64/// isotopes (Tc, Pm, Np, Pu, Am, etc.) that have no natural abundance
65/// but do have evaluated nuclear data files.
66pub fn known_isotopes(z: u32) -> Vec<Isotope> {
67    endf_mat::known_isotopes(z)
68        .into_iter()
69        .filter_map(|a| Isotope::new(z, a).ok())
70        .collect()
71}
72
73/// Whether the ENDF/B-VIII.0 sublibrary has an evaluation for (Z, A).
74pub fn has_endf_evaluation(z: u32, a: u32) -> bool {
75    endf_mat::has_endf_evaluation(z, a)
76}
77
78/// Compute ENDF ZA identifier: Z * 1000 + A.
79pub fn za_from_isotope(isotope: &Isotope) -> u32 {
80    endf_mat::za(isotope.z(), isotope.a())
81}
82
83/// Parse ENDF ZA identifier back to an [`Isotope`].
84///
85/// # Errors
86/// Returns `NereidsError::InvalidParameter` when the ZA value produces
87/// an invalid isotope (Z > A, or A == 0 — e.g. ZA = 26000 for natural
88/// iron).  Callers should propagate or handle this gracefully instead of
89/// panicking, since real ENDF files may contain such entries.
90pub fn isotope_from_za(za: u32) -> Result<Isotope, NereidsError> {
91    Isotope::new(endf_mat::z_from_za(za), endf_mat::a_from_za(za))
92}
93
94/// Format isotope as standard string, e.g. "U-238".
95pub fn isotope_to_string(isotope: &Isotope) -> String {
96    match element_symbol(isotope.z()) {
97        Some(sym) => format!("{}-{}", sym, isotope.a()),
98        None => format!("Z{}-{}", isotope.z(), isotope.a()),
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_element_symbol() {
108        assert_eq!(element_symbol(1), Some("H"));
109        assert_eq!(element_symbol(92), Some("U"));
110        assert_eq!(element_symbol(26), Some("Fe"));
111        assert_eq!(element_symbol(0), Some("n"));
112    }
113
114    #[test]
115    fn test_parse_isotope_str() {
116        let u238 = parse_isotope_str("U-238").unwrap();
117        assert_eq!(u238.z(), 92);
118        assert_eq!(u238.a(), 238);
119
120        let fe56 = parse_isotope_str("Fe-56").unwrap();
121        assert_eq!(fe56.z(), 26);
122        assert_eq!(fe56.a(), 56);
123
124        assert!(parse_isotope_str("invalid").is_none());
125    }
126
127    #[test]
128    fn test_za_roundtrip() {
129        let iso = Isotope::new(92, 238).unwrap();
130        let za = za_from_isotope(&iso);
131        assert_eq!(za, 92238);
132        let back = isotope_from_za(za).unwrap();
133        assert_eq!(back, iso);
134    }
135
136    #[test]
137    fn prop_za_roundtrip_over_accepted_domain() {
138        // Property: for every isotope `Isotope::new` accepts, the ZA
139        // encode→decode round-trip is lossless:
140        //     isotope_from_za(za_from_isotope(iso)) == iso
141        //
142        // This holds precisely because `Isotope::new` now rejects A >= 1000
143        // (which would carry into the Z field of `Z×1000 + A`). Exhaustively
144        // sweep the accepted domain (Z in 0..=118, A in 1..=999, Z <= A) so a
145        // future relaxation of the A bound breaks this test immediately.
146        for z in 0u32..=118 {
147            for a in 1u32..=999 {
148                let Ok(iso) = Isotope::new(z, a) else {
149                    // Skip pairs `new` rejects (e.g. Z > A); the property is
150                    // only claimed over the *accepted* domain.
151                    continue;
152                };
153                let za = za_from_isotope(&iso);
154                let back = isotope_from_za(za)
155                    .unwrap_or_else(|e| panic!("ZA {za} failed to decode for {iso}: {e}"));
156                assert_eq!(back, iso, "round-trip mismatch at Z={z}, A={a} (ZA={za})");
157            }
158        }
159    }
160
161    #[test]
162    fn test_isotope_from_za_natural_element_returns_error() {
163        // ZA=26000 → Z=26, A=0 (natural iron). A==0 fails validation.
164        let result = isotope_from_za(26000);
165        assert!(result.is_err());
166        assert!(result.unwrap_err().to_string().contains("must be positive"));
167    }
168
169    #[test]
170    fn test_isotope_from_za_invalid_z_greater_than_a() {
171        // Contrived ZA where Z > A (malformed data).
172        // ZA=999001 → Z=999, A=1 → Z > A.
173        let result = isotope_from_za(999001);
174        assert!(result.is_err());
175        assert!(result.unwrap_err().to_string().contains("cannot exceed"));
176    }
177
178    #[test]
179    fn test_natural_abundance() {
180        let u238 = Isotope::new(92, 238).unwrap();
181        let abund = natural_abundance(&u238).unwrap();
182        assert!((abund - 0.992742).abs() < 1e-6);
183    }
184
185    #[test]
186    fn test_natural_isotopes() {
187        let fe_isotopes = natural_isotopes(26);
188        assert_eq!(fe_isotopes.len(), 4);
189        let total: f64 = fe_isotopes.iter().map(|(_, a)| a).sum();
190        assert!((total - 1.0).abs() < 0.001);
191    }
192
193    #[test]
194    fn test_isotope_to_string() {
195        let iso = Isotope::new(92, 238).unwrap();
196        assert_eq!(isotope_to_string(&iso), "U-238");
197    }
198
199    #[test]
200    fn test_known_isotopes_plutonium() {
201        let pu = known_isotopes(94);
202        assert!(!pu.is_empty());
203        assert!(pu.iter().any(|iso| iso.a() == 239));
204    }
205
206    #[test]
207    fn test_known_isotopes_synthetic_element() {
208        // Tc has no natural isotopes but has ENDF evaluations
209        assert!(natural_isotopes(43).is_empty());
210        let tc = known_isotopes(43);
211        assert!(!tc.is_empty());
212    }
213
214    #[test]
215    fn test_known_isotopes_superset_of_natural() {
216        let natural: Vec<Isotope> = natural_isotopes(26)
217            .into_iter()
218            .map(|(iso, _)| iso)
219            .collect();
220        let known = known_isotopes(26);
221        for iso in &natural {
222            assert!(known.contains(iso));
223        }
224        // Fe-55 is in ENDF but not natural
225        assert!(known.iter().any(|iso| iso.a() == 55));
226    }
227
228    #[test]
229    fn test_has_endf_evaluation() {
230        assert!(has_endf_evaluation(94, 239));
231        assert!(!has_endf_evaluation(94, 999));
232    }
233}