Skip to main content

lindhard/electron/data/
binding.rs

1use super::endf6;
2use super::{checked_text, invalid, parse_error, read_text, ElectronDataError, Result};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6/// The committed EADL2017 binding-energy table (see
7/// [`SubshellBindingTable::eadl2017`]); its header carries the credit.
8const EADL2017_TOML: &str = include_str!("../eadl2017_binding.toml");
9
10/// Subshell labels and orbitals by ENDF subshell designator 1..=39, from the
11/// ENDF-6 Formats Manual (Trkov, Herman, Brown (eds.), CSEWG Document
12/// ENDF-102, report BNL-203218-2018-INRE (2018)), section 28.2 and Appendix
13/// B.1: designator `n` is the subshell whose photo- or electro-atomic
14/// cross section has `MT = 533 + n` (MT 534 = K ... MT 572 = Q3). The second
15/// field is `2j`, so the subshell holds at most `2j + 1` electrons.
16const SUBSHELLS: [(&str, &str, u8); 39] = [
17    ("K", "1s1/2", 1),
18    ("L1", "2s1/2", 1),
19    ("L2", "2p1/2", 1),
20    ("L3", "2p3/2", 3),
21    ("M1", "3s1/2", 1),
22    ("M2", "3p1/2", 1),
23    ("M3", "3p3/2", 3),
24    ("M4", "3d3/2", 3),
25    ("M5", "3d5/2", 5),
26    ("N1", "4s1/2", 1),
27    ("N2", "4p1/2", 1),
28    ("N3", "4p3/2", 3),
29    ("N4", "4d3/2", 3),
30    ("N5", "4d5/2", 5),
31    ("N6", "4f5/2", 5),
32    ("N7", "4f7/2", 7),
33    ("O1", "5s1/2", 1),
34    ("O2", "5p1/2", 1),
35    ("O3", "5p3/2", 3),
36    ("O4", "5d3/2", 3),
37    ("O5", "5d5/2", 5),
38    ("O6", "5f5/2", 5),
39    ("O7", "5f7/2", 7),
40    ("O8", "5g7/2", 7),
41    ("O9", "5g9/2", 9),
42    ("P1", "6s1/2", 1),
43    ("P2", "6p1/2", 1),
44    ("P3", "6p3/2", 3),
45    ("P4", "6d3/2", 3),
46    ("P5", "6d5/2", 5),
47    ("P6", "6f5/2", 5),
48    ("P7", "6f7/2", 7),
49    ("P8", "6g7/2", 7),
50    ("P9", "6g9/2", 9),
51    ("P10", "6h9/2", 9),
52    ("P11", "6h11/2", 11),
53    ("Q1", "7s1/2", 1),
54    ("Q2", "7p1/2", 1),
55    ("Q3", "7p3/2", 3),
56];
57
58/// One atomic subshell, identified by its ENDF subshell designator
59/// (1 = K (1s1/2), 2 = L1 (2s1/2), 3 = L2 (2p1/2), 4 = L3 (2p3/2), ...,
60/// 39 = Q3 (7p3/2); see [`Subshell::label`] and [`Subshell::orbital`]).
61///
62/// Serialized as the bare designator number.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
64#[serde(try_from = "u8", into = "u8")]
65pub struct Subshell(u8);
66
67impl Subshell {
68    /// The K shell (designator 1).
69    pub const K: Subshell = Subshell(1);
70
71    /// Largest designator defined by the ENDF-6 manual (Q3).
72    pub const MAX_DESIGNATOR: u8 = 39;
73
74    /// A subshell from its ENDF designator, 1..=39.
75    pub fn new(designator: u8) -> Result<Self> {
76        if (1..=Self::MAX_DESIGNATOR).contains(&designator) {
77            Ok(Self(designator))
78        } else {
79            Err(invalid(
80                "subshell designator",
81                format!("{designator} is not in 1..={}", Self::MAX_DESIGNATOR),
82            ))
83        }
84    }
85
86    /// Look a subshell up by its label (`"K"`, `"L1"`, ..., `"Q3"`).
87    pub fn from_label(label: &str) -> Option<Self> {
88        SUBSHELLS
89            .iter()
90            .position(|s| s.0 == label)
91            .map(|i| Self(i as u8 + 1))
92    }
93
94    /// The ENDF designator.
95    pub fn designator(self) -> u8 {
96        self.0
97    }
98
99    /// X-ray notation label, e.g. `"L3"`.
100    pub fn label(self) -> &'static str {
101        SUBSHELLS[usize::from(self.0) - 1].0
102    }
103
104    /// Orbital, e.g. `"2p3/2"`.
105    pub fn orbital(self) -> &'static str {
106        SUBSHELLS[usize::from(self.0) - 1].1
107    }
108
109    /// Maximum number of electrons, `2j + 1`.
110    pub fn capacity(self) -> u8 {
111        SUBSHELLS[usize::from(self.0) - 1].2 + 1
112    }
113}
114
115impl TryFrom<u8> for Subshell {
116    type Error = ElectronDataError;
117    fn try_from(d: u8) -> Result<Self> {
118        Self::new(d)
119    }
120}
121
122impl From<Subshell> for u8 {
123    fn from(s: Subshell) -> u8 {
124        s.0
125    }
126}
127
128/// The binding energy and ground-state occupancy of one occupied subshell.
129#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
130#[serde(try_from = "RawShellBinding", into = "RawShellBinding")]
131pub struct ShellBinding {
132    subshell: Subshell,
133    binding_energy_ev: f64,
134    occupancy: f64,
135}
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
138#[serde(deny_unknown_fields)]
139struct RawShellBinding {
140    subshell: Subshell,
141    binding_energy_ev: f64,
142    occupancy: f64,
143}
144
145impl TryFrom<RawShellBinding> for ShellBinding {
146    type Error = ElectronDataError;
147    fn try_from(r: RawShellBinding) -> Result<Self> {
148        Self::new(r.subshell, r.binding_energy_ev, r.occupancy)
149    }
150}
151
152impl From<ShellBinding> for RawShellBinding {
153    fn from(s: ShellBinding) -> Self {
154        Self {
155            subshell: s.subshell,
156            binding_energy_ev: s.binding_energy_ev,
157            occupancy: s.occupancy,
158        }
159    }
160}
161
162impl ShellBinding {
163    /// An occupied subshell: binding energy finite and positive (eV), and
164    /// occupancy finite, positive and at most the subshell capacity `2j + 1`.
165    /// Occupancies may be fractional: EADL spreads the electrons of an open
166    /// shell over its `j`-split subshells in proportion to their capacity.
167    pub fn new(subshell: Subshell, binding_energy_ev: f64, occupancy: f64) -> Result<Self> {
168        if !(binding_energy_ev.is_finite() && binding_energy_ev > 0.0) {
169            return Err(invalid(
170                "binding energy",
171                format!(
172                    "{} binding energy must be finite and positive, got {binding_energy_ev} eV",
173                    subshell.label()
174                ),
175            ));
176        }
177        let cap = f64::from(subshell.capacity());
178        if !(occupancy.is_finite() && occupancy > 0.0 && occupancy <= cap) {
179            return Err(invalid(
180                "occupancy",
181                format!(
182                    "{} occupancy must be in (0, {cap}], got {occupancy}",
183                    subshell.label()
184                ),
185            ));
186        }
187        Ok(Self {
188            subshell,
189            binding_energy_ev,
190            occupancy,
191        })
192    }
193
194    /// The subshell.
195    pub fn subshell(&self) -> Subshell {
196        self.subshell
197    }
198
199    /// Binding energy, eV.
200    pub fn binding_energy_ev(&self) -> f64 {
201        self.binding_energy_ev
202    }
203
204    /// Number of electrons in the subshell in the neutral ground state.
205    pub fn occupancy(&self) -> f64 {
206        self.occupancy
207    }
208}
209
210/// Absolute tolerance on `Σ occupancy = Z`. Source tables quote fractional
211/// occupancies to two or three digits (e.g. 0.67 + 1.33).
212pub const OCCUPANCY_SUM_TOLERANCE: f64 = 1.0e-3;
213
214/// The occupied subshells of one neutral atom.
215///
216/// **Absent shells are not listed.** A subshell that is unoccupied in the
217/// neutral ground state has no entry; [`AtomBindings::binding_energy_ev`]
218/// returns `None` for it. There is no zero or sentinel binding energy.
219///
220/// Invariants: `1 <= z <= 92` (the crate's element table); at least one
221/// subshell; subshells strictly increasing by designator (so no duplicates);
222/// occupancies summing to `z` within [`OCCUPANCY_SUM_TOLERANCE`] (a neutral
223/// atom).
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225#[serde(try_from = "RawAtomBindings", into = "RawAtomBindings")]
226pub struct AtomBindings {
227    z: u8,
228    shells: Vec<ShellBinding>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(deny_unknown_fields)]
233struct RawAtomBindings {
234    z: u8,
235    shells: Vec<ShellBinding>,
236}
237
238impl TryFrom<RawAtomBindings> for AtomBindings {
239    type Error = ElectronDataError;
240    fn try_from(r: RawAtomBindings) -> Result<Self> {
241        Self::new(r.z, r.shells)
242    }
243}
244
245impl From<AtomBindings> for RawAtomBindings {
246    fn from(a: AtomBindings) -> Self {
247        Self {
248            z: a.z,
249            shells: a.shells,
250        }
251    }
252}
253
254impl AtomBindings {
255    /// Build a validated atom. Shells must be given in increasing designator
256    /// order (the order of the ENDF format).
257    pub fn new(z: u8, shells: Vec<ShellBinding>) -> Result<Self> {
258        if crate::elements::element(z).is_none() {
259            return Err(invalid(
260                "atomic number",
261                format!("Z = {z} is not in 1..={}", crate::elements::NUM_ELEMENTS),
262            ));
263        }
264        if shells.is_empty() {
265            return Err(invalid("subshells", format!("Z = {z} has no subshells")));
266        }
267        for w in shells.windows(2) {
268            let (a, b) = (w[0].subshell, w[1].subshell);
269            if a == b {
270                return Err(invalid(
271                    "subshells",
272                    format!("Z = {z} lists subshell {} twice", a.label()),
273                ));
274            }
275            if b < a {
276                return Err(invalid(
277                    "subshells",
278                    format!(
279                        "Z = {z}: subshells must be in increasing designator order, got {} after {}",
280                        b.label(),
281                        a.label()
282                    ),
283                ));
284            }
285        }
286        let electrons: f64 = shells.iter().map(|s| s.occupancy).sum();
287        if (electrons - f64::from(z)).abs() > OCCUPANCY_SUM_TOLERANCE {
288            return Err(invalid(
289                "occupancy",
290                format!("Z = {z}: occupancies sum to {electrons}, not {z} (neutral atom)"),
291            ));
292        }
293        Ok(Self { z, shells })
294    }
295
296    /// Atomic number.
297    pub fn z(&self) -> u8 {
298        self.z
299    }
300
301    /// Occupied subshells, in increasing designator order.
302    pub fn shells(&self) -> &[ShellBinding] {
303        &self.shells
304    }
305
306    /// The entry for a subshell, or `None` if it is unoccupied.
307    pub fn shell(&self, subshell: Subshell) -> Option<&ShellBinding> {
308        self.shells
309            .binary_search_by_key(&subshell, |s| s.subshell)
310            .ok()
311            .map(|i| &self.shells[i])
312    }
313
314    /// Binding energy of a subshell, eV, or `None` if it is unoccupied.
315    pub fn binding_energy_ev(&self, subshell: Subshell) -> Option<f64> {
316        self.shell(subshell).map(|s| s.binding_energy_ev)
317    }
318}
319
320/// Subshell binding energies for a set of elements, with provenance.
321///
322/// Invariants: non-blank provenance; at least one atom; atoms strictly
323/// increasing in `Z` (no duplicates). Coverage of a required range of `Z` is
324/// checked separately by [`SubshellBindingTable::require_coverage`], because
325/// a table for a few elements is legitimate.
326///
327/// No table is committed to this repository: the redistribution terms of the
328/// candidate source (EADL, as distributed in EPICS2017) are an open question
329/// (`docs/data-provenance.md`). A user supplies a local copy and reads it with
330/// [`SubshellBindingTable::from_endf6_mf28_file`].
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332#[serde(try_from = "RawSubshellBindingTable", into = "RawSubshellBindingTable")]
333pub struct SubshellBindingTable {
334    provenance: String,
335    atoms: Vec<AtomBindings>,
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(deny_unknown_fields)]
340struct RawSubshellBindingTable {
341    provenance: Option<String>,
342    atoms: Vec<AtomBindings>,
343}
344
345impl TryFrom<RawSubshellBindingTable> for SubshellBindingTable {
346    type Error = ElectronDataError;
347    fn try_from(r: RawSubshellBindingTable) -> Result<Self> {
348        let provenance = checked_text("provenance", r.provenance.as_deref())?;
349        Self::new(provenance, r.atoms)
350    }
351}
352
353impl From<SubshellBindingTable> for RawSubshellBindingTable {
354    fn from(t: SubshellBindingTable) -> Self {
355        Self {
356            provenance: Some(t.provenance),
357            atoms: t.atoms,
358        }
359    }
360}
361
362impl SubshellBindingTable {
363    /// Build a validated table; atoms in increasing `Z`.
364    pub fn new(provenance: impl Into<String>, atoms: Vec<AtomBindings>) -> Result<Self> {
365        let provenance = checked_text("provenance", Some(&provenance.into()))?;
366        if atoms.is_empty() {
367            return Err(invalid("binding table", "has no atoms"));
368        }
369        if let Some(w) = atoms.windows(2).find(|w| w[1].z <= w[0].z) {
370            return Err(invalid(
371                "binding table",
372                format!(
373                    "atoms must be in strictly increasing Z (no duplicates), got Z = {} after Z = {}",
374                    w[1].z, w[0].z
375                ),
376            ));
377        }
378        Ok(Self { provenance, atoms })
379    }
380
381    /// Provenance string (to be copied into run metadata).
382    pub fn provenance(&self) -> &str {
383        &self.provenance
384    }
385
386    /// All atoms, in increasing `Z`.
387    pub fn atoms(&self) -> &[AtomBindings] {
388        &self.atoms
389    }
390
391    /// The atom with this `Z`, if tabulated.
392    pub fn atom(&self, z: u8) -> Option<&AtomBindings> {
393        self.atoms
394            .binary_search_by_key(&z, |a| a.z)
395            .ok()
396            .map(|i| &self.atoms[i])
397    }
398
399    /// The `Z` in `range` that the table lacks.
400    pub fn missing(&self, range: std::ops::RangeInclusive<u8>) -> Vec<u8> {
401        range.filter(|&z| self.atom(z).is_none()).collect()
402    }
403
404    /// An error naming the missing elements unless every `Z` in `range` is
405    /// tabulated.
406    pub fn require_coverage(&self, range: std::ops::RangeInclusive<u8>) -> Result<()> {
407        let (lo, hi) = (*range.start(), *range.end());
408        let missing = self.missing(range);
409        if missing.is_empty() {
410            Ok(())
411        } else {
412            Err(invalid(
413                "binding table coverage",
414                format!("Z = {lo}..={hi} required; missing {missing:?}"),
415            ))
416        }
417    }
418
419    /// Parse and validate the TOML form:
420    ///
421    /// ```toml
422    /// provenance = "..."
423    /// [[atoms]]
424    /// z = 1
425    /// shells = [{ subshell = 1, binding_energy_ev = 13.6, occupancy = 1.0 }]
426    /// ```
427    pub fn from_toml_str(text: &str) -> Result<Self> {
428        let raw: RawSubshellBindingTable = toml::from_str(text).map_err(parse_error)?;
429        Self::try_from(raw)
430    }
431
432    /// Serialize to the TOML form.
433    pub fn to_toml_string(&self) -> Result<String> {
434        toml::to_string(self).map_err(|e| ElectronDataError::Parse(e.to_string()))
435    }
436
437    /// The committed EADL2017 table, Z = 1..92 (EADL as distributed in
438    /// EPICS2017; D. E. Cullen, IAEA-NDS-224 Rev. 1 (April 2018), issued by the
439    /// IAEA Nuclear Data Section and the NNDC). The data and the credit
440    /// notice are in `eadl2017_binding.toml`, embedded in the library; the
441    /// provenance row is in `docs/data-provenance.md`.
442    ///
443    /// # Panics
444    ///
445    /// Never in a correct build: the embedded file is validated by the test
446    /// suite.
447    pub fn eadl2017() -> Self {
448        Self::from_toml_str(EADL2017_TOML).expect("the embedded EADL2017 table is valid")
449    }
450
451    /// Read subshell binding energies and occupancies from ENDF-6 File 28
452    /// (MF = 28, MT = 533, atomic relaxation data), the format in which EADL
453    /// is distributed (ENDF-6 Formats Manual, CSEWG Document ENDF-102,
454    /// BNL-203218-2018-INRE (2018), section 28.2).
455    ///
456    /// For each material the section is a HEAD record `[ZA, AWR, 0, 0, NSS,
457    /// 0]` followed by NSS LIST records `[SUBI, 0, 0, 0, NW, NTR / EBI, ELN,
458    /// 0, 0, 0, 0, (SUBJ, SUBK, ETR, FTR, 0, 0) x NTR]` with `NW = 6 (1 +
459    /// NTR)`. Only `SUBI` (designator), `EBI` (binding energy, eV) and `ELN`
460    /// (electrons in the neutral atom) are kept; the transition data are
461    /// skipped. Lines of other files and sections are ignored. Materials with
462    /// `Z` beyond the crate's element table (92) are skipped; every other
463    /// record must be well formed, or the whole read fails.
464    ///
465    /// `provenance` is required (non-blank): name the file, its version and
466    /// where it was retrieved, so the run metadata records it.
467    pub fn from_endf6_mf28_str(text: &str, provenance: &str) -> Result<Self> {
468        let provenance = checked_text("provenance", Some(provenance))?;
469        let atoms = endf6::read_mf28(text)?;
470        Self::new(provenance, atoms)
471    }
472
473    /// [`SubshellBindingTable::from_endf6_mf28_str`] on a file.
474    pub fn from_endf6_mf28_file(path: impl AsRef<Path>, provenance: &str) -> Result<Self> {
475        Self::from_endf6_mf28_str(&read_text(path.as_ref())?, provenance)
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn subshell_table_matches_endf_mt_numbering() {
485        // Designator n is MT 533 + n: K = 534, L1 = 535, ..., Q3 = 572.
486        assert_eq!(Subshell::K.label(), "K");
487        assert_eq!(Subshell::new(4).unwrap().label(), "L3");
488        assert_eq!(Subshell::new(4).unwrap().orbital(), "2p3/2");
489        assert_eq!(Subshell::new(39).unwrap().label(), "Q3");
490        assert_eq!(Subshell::new(16).unwrap().capacity(), 8); // 4f7/2
491        assert_eq!(Subshell::from_label("P11").unwrap().designator(), 36);
492        assert!(Subshell::new(0).is_err());
493        assert!(Subshell::new(40).is_err());
494        // Every capacity is 2j + 1 with j read from the orbital string.
495        for d in 1..=Subshell::MAX_DESIGNATOR {
496            let s = Subshell::new(d).unwrap();
497            let j2: u8 = s.orbital().split('/').next().unwrap()[2..].parse().unwrap();
498            assert_eq!(s.capacity(), j2 + 1, "{}", s.label());
499        }
500    }
501}