Skip to main content

lindhard/electron/
data.rs

1//! Data the electron engine consumes, as validated types.
2//!
3//! Three kinds of data live here, each with a mandatory provenance string:
4//!
5//! - [`OpticalElf`]: a tabulated optical energy-loss function
6//!   `ELF(ħω) = Im[-1/ε(ω)]` of one material at zero momentum transfer, the
7//!   input of dielectric-function inelastic models (Ritchie, Phys. Rev. 106,
8//!   874 (1957); Penn, Phys. Rev. B 35, 482 (1987)).
9//! - [`SubshellBindingTable`]: atomic subshell binding energies and ground-state
10//!   occupancies, for inner-shell ionization. It can be read from the
11//!   ENDF-6 File 28 (atomic relaxation) format in which EADL is distributed
12//!   ([`SubshellBindingTable::from_endf6_mf28_str`]).
13//! - [`CrossSectionTable`]: a precomputed cross-section cache on an
14//!   incident-energy grid: the inverse mean free path and the inverse CDF of
15//!   one sampled variable (elastic polar angle or inelastic energy loss).
16//!   [`ShellChannelTable`] wraps an energy-loss table of one inner-shell
17//!   ionisation channel with the shell's identity, binding energy and the
18//!   provenance of that energy.
19//!
20//! # Validation
21//!
22//! Every type has private fields and a checked constructor. The serde
23//! implementations go through the same checks (`#[serde(try_from = ...)]`), so
24//! a value read from disk by any serde format is valid by construction; a
25//! derived deserializer cannot bypass the invariants.
26//!
27//! A provenance (and every identity string: material, model) must be
28//! non-blank after trimming and must not contain control characters (it is a
29//! one-line citation that is copied into run metadata). Missing or blank
30//! provenance is [`ElectronDataError::MissingProvenance`], as for user stopping
31//! tables (`ion::stopping::table`): data without an origin is not admitted
32//! (`docs/data-provenance.md`).
33//!
34//! # Units
35//!
36//! SI internally (`crate::units`), with the unit spelled in each name at the
37//! API boundary: energies in eV (`_ev`), inverse mean free paths in m⁻¹
38//! (`_per_m`), angles in radians.
39//!
40//! The EADL2017 binding-energy table is committed with this module
41//! ([`SubshellBindingTable::eadl2017`]). No optical data is; see
42//! `docs/data-provenance.md` for the sources and for which redistribution
43//! terms are still open.
44
45mod binding;
46mod endf6;
47mod optical;
48mod table;
49
50pub use binding::{
51    AtomBindings, ShellBinding, Subshell, SubshellBindingTable, OCCUPANCY_SUM_TOLERANCE,
52};
53pub use optical::OpticalElf;
54pub use table::{
55    CrossSectionTable, CrossSectionTableParts, SamplingAxis, ShellChannelTable,
56    SHELL_CHANNEL_FORMAT_VERSION,
57};
58
59use std::path::Path;
60
61/// The cross-section cache format this build reads and writes.
62///
63/// Bump it whenever the meaning or layout of a [`CrossSectionTable`] file
64/// changes; a reader rejects every other version with
65/// [`ElectronDataError::UnsupportedVersion`] instead of guessing.
66pub const CACHE_FORMAT_VERSION: u32 = 1;
67
68/// Errors from building, reading or querying electron data.
69#[derive(Debug, Clone, PartialEq, thiserror::Error)]
70pub enum ElectronDataError {
71    /// No provenance, or one that is blank after trimming.
72    #[error("data has no provenance; a citation or description of its origin is required")]
73    MissingProvenance,
74    /// A required identity string (material, model) is missing or blank.
75    #[error("data has no {0}; it is required")]
76    MissingIdentity(&'static str),
77    /// A text field is present but unusable.
78    #[error("malformed {field}: {reason}")]
79    MalformedText {
80        /// Field name.
81        field: &'static str,
82        /// What is wrong with it.
83        reason: String,
84    },
85    /// A numeric field or the shape of the data violates an invariant.
86    #[error("invalid {what}: {reason}")]
87    Invalid {
88        /// Which quantity.
89        what: &'static str,
90        /// What is wrong with it.
91        reason: String,
92    },
93    /// A query outside the tabulated range. Nothing is extrapolated.
94    #[error("{quantity} {value} outside table range [{min}, {max}]")]
95    OutOfRange {
96        /// Which quantity was queried.
97        quantity: &'static str,
98        /// Queried value.
99        value: f64,
100        /// Table minimum.
101        min: f64,
102        /// Table maximum.
103        max: f64,
104    },
105    /// A grid index past the end of the table.
106    #[error("grid index {index} out of range for a table of {len} points")]
107    IndexOutOfRange {
108        /// Requested index.
109        index: usize,
110        /// Number of grid points.
111        len: usize,
112    },
113    /// A cache file declares a format version this build does not read.
114    #[error(
115        "unsupported cross-section cache format version {}; this build reads version {supported}",
116        found.map_or_else(|| "(none)".to_string(), |v| v.to_string())
117    )]
118    UnsupportedVersion {
119        /// Version found in the file (`None` if absent).
120        found: Option<u32>,
121        /// Version this build supports ([`CACHE_FORMAT_VERSION`]).
122        supported: u32,
123    },
124    /// Sampling was requested at an energy whose inverse mean free path is
125    /// zero: there is no interaction there and no distribution to sample.
126    #[error(
127        "no interaction at {energy_ev} eV: the inverse mean free path is zero, nothing to sample"
128    )]
129    ZeroRate {
130        /// Grid energy, eV.
131        energy_ev: f64,
132    },
133    /// A file could not be parsed.
134    #[error("parse error: {0}")]
135    Parse(String),
136    /// A file could not be read or written.
137    #[error("I/O error: {0}")]
138    Io(String),
139}
140
141type Result<T> = std::result::Result<T, ElectronDataError>;
142
143/// Check a required one-line text field: trimmed, non-blank, no control
144/// characters. A missing or blank `provenance` is
145/// [`ElectronDataError::MissingProvenance`]; any other missing field is
146/// [`ElectronDataError::MissingIdentity`].
147fn checked_text(field: &'static str, value: Option<&str>) -> Result<String> {
148    let text = value.unwrap_or_default().trim();
149    if text.is_empty() {
150        return Err(if field == "provenance" {
151            ElectronDataError::MissingProvenance
152        } else {
153            ElectronDataError::MissingIdentity(field)
154        });
155    }
156    if let Some(c) = text.chars().find(|c| c.is_control()) {
157        return Err(ElectronDataError::MalformedText {
158            field,
159            reason: format!("contains the control character {c:?}; use one line of text"),
160        });
161    }
162    Ok(text.to_string())
163}
164
165fn invalid(what: &'static str, reason: impl Into<String>) -> ElectronDataError {
166    ElectronDataError::Invalid {
167        what,
168        reason: reason.into(),
169    }
170}
171
172/// An energy grid: at least two points, finite, positive, strictly increasing.
173fn check_energy_grid(what: &'static str, grid: &[f64]) -> Result<()> {
174    if grid.len() < 2 {
175        return Err(invalid(what, "needs at least 2 points"));
176    }
177    if let Some(x) = grid.iter().find(|x| !(x.is_finite() && **x > 0.0)) {
178        return Err(invalid(
179            what,
180            format!("values must be finite and positive, got {x}"),
181        ));
182    }
183    if let Some(w) = grid.windows(2).find(|w| w[1] <= w[0]) {
184        return Err(invalid(
185            what,
186            format!(
187                "must be strictly increasing (no duplicates), got {} then {}",
188                w[0], w[1]
189            ),
190        ));
191    }
192    Ok(())
193}
194
195/// Finite and non-negative values (zero allowed).
196fn check_nonnegative(what: &'static str, values: &[f64]) -> Result<()> {
197    if let Some(x) = values.iter().find(|x| !(x.is_finite() && **x >= 0.0)) {
198        return Err(invalid(
199            what,
200            format!("values must be finite and non-negative, got {x}"),
201        ));
202    }
203    Ok(())
204}
205
206/// Piecewise-linear interpolation on a validated, strictly increasing grid,
207/// written as a convex combination so that knots are reproduced exactly and
208/// non-negative ordinates give non-negative results. `x` must be in range.
209fn lerp_on_grid(grid: &[f64], values: &[f64], x: f64) -> f64 {
210    let n = grid.len();
211    let i = grid.partition_point(|&g| g <= x).clamp(1, n - 1);
212    let (x0, x1) = (grid[i - 1], grid[i]);
213    let t = (x - x0) / (x1 - x0);
214    values[i - 1] * (1.0 - t) + values[i] * t
215}
216
217fn check_in_range(quantity: &'static str, value: f64, min: f64, max: f64) -> Result<()> {
218    if !value.is_finite() || value < min || value > max {
219        return Err(ElectronDataError::OutOfRange {
220            quantity,
221            value,
222            min,
223            max,
224        });
225    }
226    Ok(())
227}
228
229fn parse_error(e: impl std::fmt::Display) -> ElectronDataError {
230    ElectronDataError::Parse(e.to_string())
231}
232
233fn read_text(path: &Path) -> Result<String> {
234    std::fs::read_to_string(path)
235        .map_err(|e| ElectronDataError::Io(format!("{}: {e}", path.display())))
236}