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}