lindhard/electron/inelastic/full_penn.rs
1//! The full Penn algorithm (FPA): the optical energy-loss function expanded
2//! over the Lindhard loss functions of free-electron gases, and the DIIMFP,
3//! inelastic mean free path and stopping power built from it.
4//!
5//! # Sources
6//!
7//! The algorithm is Penn's, D. R. Penn, Phys. Rev. B 35, 482 (1987),
8//! doi:10.1103/PhysRevB.35.482, which is closed access and **was not read**.
9//! As for the single-pole model ([`super::penn`]), the equations are those of
10//! the account by Penn and co-authors in H. Shinotsuka, B. Da, S. Tanuma, H.
11//! Yoshikawa, C. J. Powell, D. R. Penn, Surf. Interface Anal. 49, 238 (2017),
12//! doi:10.1002/sia.6123 (NIST author manuscript, PMC5524379, "S2017"), section
13//! "Full Penn algorithm (FPA)", eqs. (4)-(6):
14//!
15//! ```text
16//! Im[-1/ε(q, ω)] = ∫_0^∞ g(ω_p) Im[-1/ε_L(q, ω; ω_p)] dω_p, (S2017 eq. (4))
17//! g(ω_p) = (2/(π ω_p)) Im[-1/ε(ω_p)], (S2017 eq. (5))
18//! ```
19//!
20//! where `ε_L` is the Lindhard dielectric function of a free-electron gas of
21//! plasma frequency `ω_p` ([`super::lindhard_gas`]; `ω_p² = 4π n`), and the
22//! optical ELF `Im[-1/ε(ω)]` is the user's table. S2017 eq. (6) splits the
23//! result into a plasmon-pole part and a single-electron part,
24//!
25//! ```text
26//! Im[-1/ε(q, ω)] = Im[-1/ε(q, ω)]_pl + Im[-1/ε(q, ω)]_se.
27//! ```
28//!
29//! S2017 defers "the details of the calculation procedures for each term" to
30//! its reference 22 (Shinotsuka et al., Surf. Interface Anal. 47, 871
31//! (2015)), which was not read either. The procedure used here is our own, a
32//! direct evaluation of eq. (4): for every `ω_p` the Lindhard loss function
33//! is its electron-hole continuum `Im ε/|ε|²` plus the undamped plasmon
34//! delta function of weight `π/(∂Re ε/∂ω)` (exactly the pole of the SPA,
35//! S2017 eq. (7), at `q -> 0`). Integrating the delta function over `ω_p`
36//! gives a smooth plasmon term `g(ω₀) W/|dω_pl/dω_p|` at the `ω_p = ω₀`
37//! whose plasmon sits at the loss `ω`. Below the plasmon cutoff the plasmon
38//! term is the analogue of the SPA's S2017 eq. (13); above it, and at all
39//! `q` outside the plasmon, the continuum term is the "single-electron
40//! excitations" that the SPA neglects. The two are the `pl` and `se` of
41//! eq. (6) as far as we can tell from the account in S2017; nothing here was
42//! checked against Penn's own formulae.
43//!
44//! The kinematics, units and the definitions of the DIIMFP, IMFP and
45//! stopping power are those of the single-pole module (S2017 eqs. (2)-(3),
46//! nonrelativistic; Hartree units internally, eV and metres in the API).
47//!
48//! # Sum rules
49//!
50//! Each Lindhard gas satisfies `∫ ω Im[-1/ε_L] dω = (π/2) ω_p²` at every `q`
51//! (plasmon included; tested in [`super::lindhard_gas`]), so with eq. (5) the
52//! expanded ELF has `∫ ω Im[-1/ε(q, ω)] dω = ∫ ω_p Im[-1/ε(ω_p)] dω_p`, the
53//! optical f-sum, at every `q` ([`FullPenn::f_sum_ev2_at`] and the
54//! integration tests check this).
55//!
56//! # How the integrals are done
57//!
58//! **IMFP and stopping power** ([`FullPenn::imfp_and_stopping`]) are
59//! evaluated with the order of integration `ω_p`, `q`, `ω`:
60//!
61//! ```text
62//! λ⁻¹ = (1/(π T')) ∫ dω_p g(ω_p) ∫ (dq/q) ∫_0^{ω_c(q)} Im[-1/ε_L] dω,
63//! S = (1/(π T')) ∫ dω_p g(ω_p) ∫ (dq/q) ∫_0^{ω_c(q)} ω Im[-1/ε_L] dω,
64//! ω_c(q) = min(T, T' - (q - k)²/2), k = sqrt(2T'), 0 < q < 2k,
65//! ```
66//!
67//! (`q- <= q <= q+` is `ω <= T' - (q - k)²/2`, S2017 eq. (2)). The innermost
68//! integral is split at the kinks of the Lindhard continuum and includes the
69//! plasmon delta function if its energy is below `ω_c(q)`; the `q` integral
70//! is in `ln q`, split at the kinematic landmarks and at the momenta where
71//! the plasmon enters (`ω_pl = ω_c`) and leaves (cutoff) the allowed region;
72//! the outer integral is split at every knot of the
73//! table (the ELF is piecewise linear, and a feature narrower than the
74//! spacing of a subset of knots could be missed entirely by the adaptive
75//! quadrature, so no knot is dropped). All three use the adaptive Gauss-Legendre quadrature of
76//! `super::quadrature`, each with the tolerance of the model
77//! ([`DEFAULT_FULL_TOLERANCE`] = 1e-4): the result has a relative error of a
78//! few times that. The DIIMFP is the same integrals in the order `q`, `ω_p`
79//! at fixed `ω`.
80//!
81//! The outer (`ω_p`) integral of both runs on `quadrature::integrate_weighted`
82//! (#256): `g(ω_p)` is linear between knots, every knot interval still gets
83//! its own Gauss-Legendre rule (so the rule above, no knot dropped, holds),
84//! and the error of the smooth Lindhard factor is checked on groups of up to
85//! 16 neighbouring intervals instead of by bisecting every interval; an
86//! interval that fails is bisected as before. The plasmon term finds the
87//! plasma frequency whose plasmon sits at `ω` by bisecting on the sign of
88//! `Re ε_L` (one evaluation per step) instead of solving for the plasmon
89//! energy at every step. Neither changes the integrals beyond their
90//! tolerance; measured on the Al ELF of Hagemann et al. (148 knots), they
91//! cut the time of one DIIMFP evaluation by about 6 and of one IMFP point
92//! by about 4.5 (`docs/validation.md`, cost of a full-Penn build).
93//!
94//! **The edge resonance and the bound on the `ω_p` bisection (#298).** At
95//! fixed `(q, ω)` the continuum term starts at the plasma frequency `x_a`
96//! whose gas has its continuum edge `q k_F + q²/2` at `ω`. Where that gas's
97//! plasmon is about to enter the continuum there (`Re ε_L ≈ 0` at the edge;
98//! the plasmon cutoff), `Re ε_L` crosses zero just above `x_a`, where
99//! `Im ε_L` is still small, so `Im ε_L/|ε_L|²` has a narrow, tall resonance
100//! (on the Si ELF at `q = 1.48 a.u.`, `ω = 160 eV`: `Re ε_L = 1e-6` at `x_a`,
101//! the zero 1.5e-7 Hartree above it, a peak of height above 1e6 and width
102//! about 3e-8 Hartree). It is integrable and holds a finite share of the first
103//! knot interval, but the bisection that halves its absolute tolerance at
104//! every level then asks the cells on the peak for a relative accuracy
105//! below the floating-point noise of `Re ε_L` (a cancellation of terms of
106//! order 1 down to 1e-7) and fills the peak with cells down to the maximum
107//! depth: seconds for one evaluation. The bisection of each knot interval
108//! therefore also accepts a cell that agrees with its halves to the
109//! tolerance relative to its own integral, which keeps the error contract
110//! (see `quadrature::integrate_weighted`), and has a budget of
111//! [`OMEGA_P_INTERVAL_BUDGET`] rule applications; anything accepted without
112//! meeting its tolerance is counted and reported by
113//! [`FullPenn::loss_function_evaluation`] ([`LossFunctionEvaluation`]).
114//! The values elsewhere are unchanged within the tolerance (the inverse
115//! IMFP and stopping power by up to about 1e-7; measured differences in
116//! `docs/validation.md`, "Electron oracles"). The outer `ω_p` integral of
117//! [`FullPenn::imfp_and_stopping`] has the same bisection and budget and
118//! counts its unconverged cells ([`FullPenn::imfp_and_stopping_evaluation`],
119//! [`ImfpEvaluation`]; also [`FullPenn::imfp_unconverged`]); its inner
120//! integrals over `q` and `ω` are not bounded and report nothing.
121//!
122//! The momentum profiles of a [`DiimfpGrid`] had the same failure one level
123//! up. Above the end of a table whose last ELF value is not zero (Si:
124//! 199 eV), the plasmon term jumps where the plasmon of the last tabulated
125//! gas reaches `ω`, and its value carries a relative noise of a few 1e-8
126//! (the finite-difference slope); the bisection of a profile located the
127//! jump and then filled its neighbourhood with cells chasing the noise (Si,
128//! `ω` = 208.07 eV: over 10⁵ loss evaluations for one profile). The
129//! profiles' bisection (`quadrature::integrate_panels`) has the same
130//! relative acceptance and budget, and counts what did not converge
131//! ([`DiimfpGrid::unconverged_profile_panels`]), and the loss evaluations
132//! inside a profile that did not converge
133//! ([`DiimfpGrid::unconverged_profile_loss_evaluations`]). The direct
134//! DIIMFP counts the loss evaluations of its momentum integral that did not
135//! converge, with the same definition
136//! ([`FullPenn::diimfp_evaluation`], [`DiimfpEvaluation`]); its momentum
137//! integral itself is not bounded and reports nothing.
138//!
139//! **Tables.** An inelastic table needs the DIIMFP at some 10⁴ losses per
140//! row, which at about 15 ms per evaluation (and with the quadrature noise
141//! of a 1e-4 tolerance driving the table's 1e-5 density refinement to its
142//! node cap) is hours per row. The table builder therefore reads the DIIMFP
143//! from a [`DiimfpGrid`] ([`FullPenn::diimfp_grid`]): the `q` integrand is
144//! independent of the electron energy, so it is tabulated once per loss node
145//! and integrated over each row's momentum window, with an interpolation
146//! error held to the model tolerance (see [`DiimfpGrid`]). The inverse IMFP
147//! of every row is still [`FullPenn::imfp_and_stopping`].
148//!
149//! No Fermi-energy, exchange or relativistic correction is included, as for
150//! the single-pole model.
151//!
152//! # Comparison with the single-pole model (synthetic Drude fixture)
153//!
154//! For the Drude plasmon of the tests and examples (`E_p = 20 eV`, `γ = 5 eV`,
155//! synthetic, tabulated at 1000 energies from 10 meV to 100 keV; default
156//! tolerances; `cargo run --release -p lindhard --example
157//! penn_full_vs_single_pole`), ratios full/single-pole of the stopping power
158//! `S` and of the IMFP `λ`:
159//!
160//! ```text
161//! E / eV S ratio λ ratio
162//! 20.0 3.7971 0.2830
163//! 30.0 1.6561 0.5489
164//! 50.0 0.7988 1.1365
165//! 100.0 0.9605 1.0197
166//! 200.0 0.9860 1.0067
167//! 500.0 0.9953 1.0028
168//! 1000.0 0.9977 1.0018
169//! 10000.0 0.9998 1.0010
170//! 50000.0 1.0000 1.0008
171//! ```
172//!
173//! **Above 10 keV the two agree to 1e-3 in both quantities** (inside the 1 %
174//! requirement of the issue); both reach the Bethe limit, which fixes `S`
175//! through the f-sum and the mean excitation energy of the optical ELF, and
176//! their IMFP constants differ only by the single-electron continuum, a
177//! 1e-3 effect at these energies. At **100 eV to 1 keV** the differences are
178//! at most 4 % (`S`) and 2 % (`λ`). **Below about 100 eV they are large and
179//! of both signs**: at 20 to 30 eV the full model's stopping power and
180//! inverse IMFP are several times those of the single pole (the full model
181//! has single-electron excitations that the single pole does not, which is
182//! presumably the cause; it was not isolated), while at 50 eV the single pole is the larger. That the
183//! full-algorithm IMFP is smaller than the single-pole one at low energies
184//! is the direction reported for water in S2017 (section on Fig. 2); the
185//! sign change near 50 eV here depends on the shape of the synthetic ELF,
186//! and no statement about a real material follows. Nothing was tuned to
187//! make the two agree: these are the numbers of the implementation as it
188//! stands, and the regression bounds of `tests/penn_full.rs` follow them.
189
190use super::lindhard_gas::{kf_coefficient, LindhardGas};
191use super::penn::InelasticPoint;
192use super::quadrature::{
193 integrate_panels, integrate_segments, integrate_weighted, GaussLegendre, Panels, SEGMENT_BUDGET,
194};
195use crate::constants::{BOHR_RADIUS, ELEMENTARY_CHARGE, HARTREE_ENERGY};
196use crate::electron::data::{ElectronDataError, OpticalElf};
197use rayon::prelude::*;
198use std::cell::Cell;
199use std::f64::consts::PI;
200
201type Result<T> = std::result::Result<T, ElectronDataError>;
202
203/// Default relative tolerance of the integrals of the full Penn model.
204pub const DEFAULT_FULL_TOLERANCE: f64 = 1.0e-4;
205
206/// Budget of Gauss-Legendre rule applications (5 evaluations each) for the
207/// bisection of one knot interval of the `ω_p` integral of the loss
208/// function (#298); see [`LossFunctionEvaluation`].
209pub const OMEGA_P_INTERVAL_BUDGET: u64 = SEGMENT_BUDGET;
210
211const GL_ORDER: usize = 5;
212
213fn hartree_ev() -> f64 {
214 HARTREE_ENERGY / ELEMENTARY_CHARGE
215}
216
217fn check_energy(what: &'static str, v: f64) -> Result<()> {
218 if v.is_finite() && v > 0.0 {
219 Ok(())
220 } else {
221 Err(ElectronDataError::Invalid {
222 what,
223 reason: format!("must be finite and positive, got {v} eV"),
224 })
225 }
226}
227
228/// Geometric bisection for the point where `pred` switches, on `[lo, hi]`,
229/// with `pred(lo) != pred(hi)`. Returns the end where `pred` equals
230/// `pred(hi)`.
231fn bisect_switch(mut lo: f64, mut hi: f64, pred: &impl Fn(f64) -> bool) -> f64 {
232 let at_hi = pred(hi);
233 for _ in 0..200 {
234 let mid = if lo > 0.0 {
235 (lo * hi).sqrt()
236 } else {
237 0.5 * (lo + hi)
238 };
239 if !(mid > lo && mid < hi) {
240 break;
241 }
242 if pred(mid) == at_hi {
243 hi = mid;
244 } else {
245 lo = mid;
246 }
247 if hi - lo <= 1e-14 * hi {
248 break;
249 }
250 }
251 hi
252}
253
254/// One evaluation of the full Penn loss function `Im[-1/ε(q, ω)]`
255/// ([`FullPenn::loss_function_evaluation`]): the value, the work of its
256/// adaptive `ω_p` integral, and whether every cell of that integral met its
257/// tolerance.
258///
259/// The bisection of each knot interval of the `ω_p` integral has a budget of
260/// rule applications (#298; the rule and the budget are documented on the
261/// weighted quadrature in `quadrature.rs` and in the module docs). A cell
262/// that did not meet its tolerance (budget spent, maximum depth reached, or
263/// midpoint no longer representable) keeps its estimate, is counted in
264/// `unconverged_cells`, and contributes its error estimate to
265/// `unconverged_error`; [`Self::converged`] is then false.
266#[derive(Debug, Clone, Copy, PartialEq)]
267pub struct LossFunctionEvaluation {
268 /// `Im[-1/ε(q, ω)]` (dimensionless), as [`FullPenn::loss_function`].
269 pub value: f64,
270 /// Evaluations of the Lindhard continuum loss function in the `ω_p`
271 /// integral: the work, a deterministic count.
272 pub lindhard_evaluations: u64,
273 /// Cells of the `ω_p` integral accepted without meeting their tolerance.
274 pub unconverged_cells: u64,
275 /// The sum of the error estimates of those cells, in the units of
276 /// `value` (zero when the integral converged).
277 pub unconverged_error: f64,
278 /// The most rule applications (of 5 evaluations each) that the bisection
279 /// of any one knot interval used, at most [`OMEGA_P_INTERVAL_BUDGET`].
280 pub largest_interval_rules: u64,
281}
282
283impl LossFunctionEvaluation {
284 /// Whether every cell of the `ω_p` integral met its tolerance.
285 pub fn converged(&self) -> bool {
286 self.unconverged_cells == 0
287 }
288}
289
290/// One evaluation of the full Penn DIIMFP ([`FullPenn::diimfp_evaluation`]):
291/// the value and how many of the loss-function evaluations of its momentum
292/// integral did not converge (#368).
293///
294/// Each evaluation of `Im[-1/ε(q, ω)]` inside the `ln q` integral of the
295/// DIIMFP has the bounded `ω_p` bisection of [`LossFunctionEvaluation`]; one
296/// whose `unconverged_cells` is not zero is counted in
297/// `unconverged_loss_evaluations`, the definition of
298/// [`DiimfpGrid::unconverged_profile_loss_evaluations`]. The momentum
299/// integral itself (`quadrature::integrate_segments`) is not bounded and
300/// reports nothing.
301#[derive(Debug, Clone, Copy, PartialEq)]
302pub struct DiimfpEvaluation {
303 /// The DIIMFP, m⁻¹ eV⁻¹, as [`FullPenn::diimfp_per_m_ev`].
304 pub value: f64,
305 /// Loss-function evaluations of the momentum integral whose `ω_p`
306 /// integral left a cell without meeting its tolerance (zero when every
307 /// one converged).
308 pub unconverged_loss_evaluations: u64,
309 /// The largest `unconverged_error` of those evaluations, relative to the
310 /// value of the same evaluation of `Im[-1/ε(q, ω)]` (zero when every one
311 /// converged). An estimate that is not tight; read it as a bound, as
312 /// [`LossFunctionEvaluation::unconverged_error`].
313 pub largest_unconverged_error: f64,
314}
315
316/// One evaluation of the full Penn inverse IMFP and stopping power
317/// ([`FullPenn::imfp_and_stopping_evaluation`]): the point and the cells of
318/// its outer `ω_p` integral accepted without meeting their tolerance (#298,
319/// #368). The inner integrals over `q` and `ω` are not bounded and report
320/// nothing.
321#[derive(Debug, Clone, Copy, PartialEq)]
322pub struct ImfpEvaluation {
323 /// `λ⁻¹` and `S`, as [`FullPenn::imfp_and_stopping`].
324 pub point: InelasticPoint,
325 /// Cells of the outer `ω_p` integral accepted without meeting their
326 /// tolerance (budget of the bisection spent, maximum depth, or a
327 /// midpoint that is not representable); zero when it converged.
328 pub unconverged_cells: u64,
329 /// The error estimate of those cells relative to `λ⁻¹` (zero when the
330 /// integral converged).
331 pub unconverged_error: f64,
332}
333
334/// The full Penn model built from one optical ELF. See the module docs.
335#[derive(Debug, Clone)]
336pub struct FullPenn {
337 elf: OpticalElf,
338 /// Tabulated energies, Hartree.
339 w: Vec<f64>,
340 /// Tabulated ELF values.
341 e: Vec<f64>,
342 /// The break points of the outer integral (every knot of `w`).
343 outer: Vec<f64>,
344 fermi: f64,
345 rel_tol: f64,
346 gl: GaussLegendre,
347}
348
349impl FullPenn {
350 /// The model for `elf`, with Fermi energy zero and the default tolerance.
351 pub fn new(elf: OpticalElf) -> Self {
352 let h = hartree_ev();
353 let w: Vec<f64> = elf.energy_ev().iter().map(|x| x / h).collect();
354 let e = elf.elf_values().to_vec();
355 let outer: Vec<f64> = w.clone();
356 Self {
357 elf,
358 w,
359 e,
360 outer,
361 fermi: 0.0,
362 rel_tol: DEFAULT_FULL_TOLERANCE,
363 gl: GaussLegendre::new(GL_ORDER),
364 }
365 }
366
367 /// Set the Fermi energy (eV, finite and non-negative), as for the
368 /// single-pole model: energies are kinetic energies above the Fermi
369 /// level and the kinematics use `T' = T + E_F`.
370 pub fn with_fermi_energy_ev(mut self, fermi_ev: f64) -> Result<Self> {
371 if !(fermi_ev.is_finite() && fermi_ev >= 0.0) {
372 return Err(ElectronDataError::Invalid {
373 what: "Fermi energy",
374 reason: format!("must be finite and non-negative, got {fermi_ev} eV"),
375 });
376 }
377 self.fermi = fermi_ev / hartree_ev();
378 Ok(self)
379 }
380
381 /// Set the relative integration tolerance, in `[1e-9, 1e-2]`.
382 pub fn with_relative_tolerance(mut self, rel_tol: f64) -> Result<Self> {
383 if !(1e-9..=1e-2).contains(&rel_tol) {
384 return Err(ElectronDataError::Invalid {
385 what: "integration tolerance",
386 reason: format!("must be in [1e-9, 1e-2], got {rel_tol}"),
387 });
388 }
389 self.rel_tol = rel_tol;
390 Ok(self)
391 }
392
393 /// The optical ELF the model is built from.
394 pub fn optical_elf(&self) -> &OpticalElf {
395 &self.elf
396 }
397
398 /// The Fermi energy, eV.
399 pub fn fermi_energy_ev(&self) -> f64 {
400 self.fermi * hartree_ev()
401 }
402
403 /// The relative integration tolerance.
404 pub fn relative_tolerance(&self) -> f64 {
405 self.rel_tol
406 }
407
408 /// The optical ELF at `x` Hartree (linear interpolation, zero outside
409 /// the table). `hint` is the index of the interval that served the
410 /// previous call, tried first (it changes the speed, not the result).
411 fn elf_hint(&self, x: f64, hint: &Cell<usize>) -> f64 {
412 let n = self.w.len();
413 if !(x >= self.w[0] && x <= self.w[n - 1]) {
414 return 0.0;
415 }
416 let h = hint.get();
417 let i = if h >= 1 && h < n && self.w[h - 1] <= x && x < self.w[h] {
418 h
419 } else {
420 let i = self.w.partition_point(|&g| g <= x).clamp(1, n - 1);
421 hint.set(i);
422 i
423 };
424 let (x0, x1) = (self.w[i - 1], self.w[i]);
425 let t = (x - x0) / (x1 - x0);
426 (self.e[i - 1] * (1.0 - t) + self.e[i] * t).max(0.0)
427 }
428
429 /// The optical ELF at `x` Hartree (linear interpolation, zero outside
430 /// the table).
431 fn elf_au(&self, x: f64) -> f64 {
432 self.elf_hint(x, &Cell::new(1))
433 }
434
435 /// `g(ω_p)` of S2017 eq. (5).
436 fn g_au(&self, x: f64) -> f64 {
437 2.0 * self.elf_au(x) / (PI * x)
438 }
439
440 /// [`Self::g_au`] with an interval hint (see [`Self::elf_hint`]).
441 fn g_hint(&self, x: f64, hint: &Cell<usize>) -> f64 {
442 2.0 * self.elf_hint(x, hint) / (PI * x)
443 }
444
445 fn table_range(&self) -> (f64, f64) {
446 (self.w[0], self.w[self.w.len() - 1])
447 }
448
449 /// `∫ dω_p g(ω_p) [∫ ω^j Im[-1/ε_L(q, ω)] dω over (0, ∞)]` is the
450 /// optical f-sum at `j = 1`: the `ω` integral of `ω Im[-1/ε(q, ω)]` at
451 /// momentum `q` (m⁻¹), eV², evaluated Lindhard gas by Lindhard gas.
452 /// The integral over the Lindhard continuum is cut off at
453 /// `ω_max = 40 (q k_F + q²/2 + ω_p)` for each gas. The optical f-sum
454 /// `∫ ω Im[-1/ε(ω)] dω` of the same table (the value this must equal,
455 /// [`super::sum_rules::SumRuleReport::f_sum_ev2`]) is independent of `q`.
456 pub fn f_sum_ev2_at(&self, q_per_m: f64) -> Result<f64> {
457 if !(q_per_m.is_finite() && q_per_m > 0.0) {
458 return Err(ElectronDataError::Invalid {
459 what: "momentum transfer",
460 reason: format!("must be finite and positive, got {q_per_m} m^-1"),
461 });
462 }
463 let q = q_per_m * BOHR_RADIUS;
464 let breaks = &self.outer;
465 let r = integrate_segments(
466 &self.gl,
467 &mut |x: f64| {
468 let weight = self.g_au(x);
469 if weight == 0.0 {
470 return [0.0];
471 }
472 let gas = LindhardGas::from_plasma_frequency(x);
473 let wcut = 40.0 * (gas.continuum_upper_edge(q) + x);
474 [weight * gas.loss_moments(&self.gl, q, wcut, self.rel_tol)[1]]
475 },
476 breaks,
477 self.rel_tol,
478 )[0];
479 let h = hartree_ev();
480 Ok(r * h * h)
481 }
482
483 // ---- the expanded loss function at fixed (q, ω) ----
484
485 /// The smooth plasmon term of `Im[-1/ε(q, ω)]`:
486 /// `g(ω₀) W(q; ω₀)/(dω_pl/dω_p)` at the `ω₀` whose plasmon is at `ω`.
487 fn plasmon_term(&self, q: f64, w: f64) -> f64 {
488 let (wmin, wmax) = self.table_range();
489 let gas_at = |x: f64| LindhardGas::from_plasma_frequency(x);
490 let energy = |x: f64| gas_at(x).plasmon(q).map(|p| p.energy);
491 // The searches below only need predicates, which cost one evaluation
492 // of Re ε each; the plasmon energy is solved for only at the few
493 // points where its value is used.
494 // the plasmon exists for ω_p above a threshold x_c(q)
495 let exists = |x: f64| gas_at(x).plasmon_exists(q);
496 if !exists(wmax) {
497 return 0.0;
498 }
499 let x_c = if exists(wmin) {
500 wmin
501 } else {
502 bisect_switch(wmin, wmax, &exists)
503 };
504 let e_c = energy(x_c).unwrap_or(f64::INFINITY);
505 // ω_pl(ω_p) increases with ω_p: the plasmon reaches w at the root x0
506 let reached = |x: f64| gas_at(x).plasmon_reaches(q, w);
507 if w < e_c || !reached(wmax) {
508 return 0.0;
509 }
510 let x0 = if reached(x_c) {
511 x_c
512 } else {
513 bisect_switch(x_c, wmax, &reached)
514 };
515 let weight = self.g_au(x0);
516 if weight == 0.0 {
517 return 0.0;
518 }
519 let Some(p0) = gas_at(x0).plasmon(q) else {
520 return 0.0;
521 };
522 let h = 1e-6 * x0;
523 let (lo, hi) = (x0 - h, x0 + h);
524 let slope = match (energy(lo), energy(hi)) {
525 (Some(a), Some(b)) => (b - a) / (2.0 * h),
526 (_, Some(b)) => (b - p0.energy) / h,
527 (Some(a), None) => (p0.energy - a) / h,
528 _ => return 0.0,
529 };
530 if slope <= 0.0 {
531 return 0.0;
532 }
533 weight * p0.weight / slope
534 }
535
536 /// The expanded loss function `Im[-1/ε(q, ω)]` in atomic units, with
537 /// the work of its `ω_p` integral and whether that integral converged.
538 fn loss_eval_au(&self, q: f64, w: f64) -> LossFunctionEvaluation {
539 let (wmin, wmax) = self.table_range();
540 let a = w / q - 0.5 * q;
541 let x_a = (a.abs() / kf_coefficient()).powf(1.5).max(wmin);
542 let mut out = LossFunctionEvaluation {
543 value: 0.0,
544 lindhard_evaluations: 0,
545 unconverged_cells: 0,
546 unconverged_error: 0.0,
547 largest_interval_rules: 0,
548 };
549 if x_a < wmax {
550 let mut breaks = vec![x_a];
551 if a + q > 0.0 {
552 let x = ((a + q) / kf_coefficient()).powf(1.5);
553 if x > x_a && x < wmax {
554 breaks.push(x);
555 }
556 }
557 for &x in &self.outer {
558 if x > x_a && x < wmax {
559 breaks.push(x);
560 }
561 }
562 breaks.push(wmax);
563 breaks.sort_by(f64::total_cmp);
564 breaks.dedup();
565 let hint = Cell::new(1);
566 let count = Cell::new(0u64);
567 let r = integrate_weighted(
568 &self.gl,
569 &|x: f64| self.g_hint(x, &hint),
570 &mut |x: f64| {
571 count.set(count.get() + 1);
572 [LindhardGas::from_plasma_frequency(x).loss_continuum(q, w)]
573 },
574 &breaks,
575 self.rel_tol,
576 );
577 out.value = r.value[0];
578 out.lindhard_evaluations = count.get();
579 out.unconverged_cells = r.unconverged_cells;
580 out.unconverged_error = r.unconverged_error[0];
581 out.largest_interval_rules = r.largest_segment_rules;
582 }
583 out.value += self.plasmon_term(q, w);
584 out
585 }
586
587 /// `Im[-1/ε(q, ω)]` of the full Penn algorithm (S2017 eq. (4)), at
588 /// momentum transfer `q` (m⁻¹) and energy loss `ω` (eV). At `q = 0` it is
589 /// the optical ELF.
590 pub fn loss_function(&self, q_per_m: f64, loss_ev: f64) -> f64 {
591 self.loss_function_evaluation(q_per_m, loss_ev).value
592 }
593
594 /// [`Self::loss_function`] with the work of its `ω_p` integral (S2017
595 /// eq. (4)) and whether that integral met its tolerance; see
596 /// [`LossFunctionEvaluation`]. The `value` is the same number
597 /// [`Self::loss_function`] returns.
598 pub fn loss_function_evaluation(&self, q_per_m: f64, loss_ev: f64) -> LossFunctionEvaluation {
599 let mut out = LossFunctionEvaluation {
600 value: 0.0,
601 lindhard_evaluations: 0,
602 unconverged_cells: 0,
603 unconverged_error: 0.0,
604 largest_interval_rules: 0,
605 };
606 if !(q_per_m.is_finite() && q_per_m >= 0.0 && loss_ev.is_finite() && loss_ev > 0.0) {
607 return out;
608 }
609 let w = loss_ev / hartree_ev();
610 if q_per_m == 0.0 {
611 out.value = self.elf_au(w);
612 return out;
613 }
614 self.loss_eval_au(q_per_m * BOHR_RADIUS, w)
615 }
616
617 /// The DIIMFP `p(T, ω)` (S2017 eq. (2), nonrelativistic) at kinetic
618 /// energy `energy_ev` above the Fermi level and energy loss `loss_ev`, in
619 /// m⁻¹ eV⁻¹. Zero for `ω <= 0` or `ω > T`.
620 pub fn diimfp_per_m_ev(&self, energy_ev: f64, loss_ev: f64) -> Result<f64> {
621 Ok(self.diimfp_evaluation(energy_ev, loss_ev)?.value)
622 }
623
624 /// [`Self::diimfp_per_m_ev`] with the number of loss-function
625 /// evaluations of its momentum integral whose own `ω_p` integral did not
626 /// converge (#298, #368); see [`DiimfpEvaluation`]. The `value` is the
627 /// same number [`Self::diimfp_per_m_ev`] returns, and the errors are the
628 /// same.
629 pub fn diimfp_evaluation(&self, energy_ev: f64, loss_ev: f64) -> Result<DiimfpEvaluation> {
630 check_energy("electron energy", energy_ev)?;
631 if !loss_ev.is_finite() {
632 return Err(ElectronDataError::Invalid {
633 what: "energy loss",
634 reason: format!("must be finite, got {loss_ev} eV"),
635 });
636 }
637 let h = hartree_ev();
638 let mut out = self.diimfp_eval_au(energy_ev / h, loss_ev / h);
639 out.value /= BOHR_RADIUS * h;
640 Ok(out)
641 }
642
643 /// The DIIMFP in atomic units, with the count of its unconverged loss
644 /// evaluations (see [`DiimfpEvaluation`]).
645 fn diimfp_eval_au(&self, t: f64, w: f64) -> DiimfpEvaluation {
646 let mut out = DiimfpEvaluation {
647 value: 0.0,
648 unconverged_loss_evaluations: 0,
649 largest_unconverged_error: 0.0,
650 };
651 if !(w > 0.0 && w <= t) {
652 return out;
653 }
654 let tp = t + self.fermi;
655 let k = (2.0 * tp).sqrt();
656 let s = (2.0 * (tp - w)).max(0.0).sqrt();
657 let (q_minus, q_plus) = (2.0 * w / (k + s), k + s);
658 if q_plus.is_nan() || q_plus <= q_minus {
659 return out;
660 }
661 let (lo, hi) = (q_minus.ln(), q_plus.ln());
662 let breaks: Vec<f64> = (0..=8).map(|i| lo + (hi - lo) * i as f64 / 8.0).collect();
663 let inner = Cell::new(0u64);
664 let worst = Cell::new(0.0f64);
665 let integral = integrate_segments(
666 &self.gl,
667 &mut |u: f64| {
668 let e = self.loss_eval_au(u.exp(), w);
669 if e.unconverged_cells > 0 {
670 inner.set(inner.get() + 1);
671 if e.value != 0.0 {
672 worst.set(worst.get().max(e.unconverged_error / e.value.abs()));
673 }
674 }
675 [e.value]
676 },
677 &breaks,
678 self.rel_tol,
679 )[0];
680 out.value = integral / (PI * tp);
681 out.unconverged_loss_evaluations = inner.get();
682 out.largest_unconverged_error = worst.get();
683 out
684 }
685
686 // ---- IMFP and stopping power ----
687
688 /// `[∫ dq/q ∫_0^{ω_c} Im[-1/ε_L] dω, same with an extra ω]` for the gas
689 /// of plasma frequency `x`, at kinetic energy `t` (Hartree).
690 fn gas_moments(&self, x: f64, t: f64) -> [f64; 2] {
691 let tp = t + self.fermi;
692 let k = (2.0 * tp).sqrt();
693 let gas = LindhardGas::from_plasma_frequency(x);
694 let kf = gas.fermi_wavenumber();
695 let wcut = |q: f64| t.min(tp - 0.5 * (q - k) * (q - k));
696 let q_lo = 1e-3 * (x / k).min(k);
697 let q_hi = 2.0 * k;
698 if q_lo.is_nan() || q_lo >= q_hi {
699 return [0.0; 2];
700 }
701 let mut marks = vec![2.0 * kf, k - kf, k + kf, k];
702 // plasmon landmarks: cutoff, entry into and exit from the allowed region
703 let qc = gas.plasmon_cutoff();
704 marks.push(qc);
705 let inc = |q: f64| gas.plasmon_below(q, wcut(q));
706 let q_top = qc.min(k) * (1.0 - 1e-9);
707 if q_lo < q_top && inc(q_top) {
708 marks.push(if inc(q_lo) {
709 q_lo
710 } else {
711 bisect_switch(q_lo, q_top, &inc)
712 });
713 }
714 let q_out = qc * (1.0 - 1e-9);
715 if qc > k && q_out < q_hi && !inc(q_out) && inc(k) {
716 marks.push(bisect_switch(k, q_out, &inc));
717 }
718 let mut breaks = vec![q_lo.ln(), q_hi.ln()];
719 for m in marks {
720 if m > q_lo && m < q_hi {
721 breaks.push(m.ln());
722 }
723 }
724 breaks.sort_by(f64::total_cmp);
725 breaks.dedup();
726 integrate_segments(
727 &self.gl,
728 &mut |u: f64| {
729 let q = u.exp();
730 gas.loss_moments(&self.gl, q, wcut(q), self.rel_tol)
731 },
732 &breaks,
733 self.rel_tol,
734 )
735 }
736
737 /// `(λ⁻¹, S)` at kinetic energy `t` (Hartree) above the Fermi level, in
738 /// atomic units, with the number of cells of the outer `ω_p` integral
739 /// accepted without meeting their tolerance, and their error estimate
740 /// relative to `λ⁻¹`. The inner integrals over `q` and `ω` are not
741 /// bounded and report nothing.
742 fn imfp_and_stopping_eval_au(&self, t: f64) -> (f64, f64, u64, f64) {
743 if t.is_nan() || t <= 0.0 {
744 return (0.0, 0.0, 0, 0.0);
745 }
746 let tp = t + self.fermi;
747 let hint = Cell::new(1);
748 let r = integrate_weighted(
749 &self.gl,
750 &|x: f64| self.g_hint(x, &hint),
751 &mut |x: f64| self.gas_moments(x, t),
752 &self.outer,
753 self.rel_tol,
754 );
755 (
756 r.value[0] / (PI * tp),
757 r.value[1] / (PI * tp),
758 r.unconverged_cells,
759 if r.unconverged_error[0] > 0.0 && r.value[0] != 0.0 {
760 r.unconverged_error[0] / r.value[0].abs()
761 } else {
762 0.0
763 },
764 )
765 }
766
767 /// Cells of the outer `ω_p` integral of [`Self::imfp_and_stopping`] at
768 /// `energy_ev` that were accepted without meeting their tolerance
769 /// (budget of the bisection spent, maximum depth, or a midpoint that is
770 /// not representable; #298), and the error estimate of those cells
771 /// relative to `λ⁻¹`. `(0, 0.0)` when the integral converged.
772 pub fn imfp_unconverged(&self, energy_ev: f64) -> Result<(u64, f64)> {
773 let e = self.imfp_and_stopping_evaluation(energy_ev)?;
774 Ok((e.unconverged_cells, e.unconverged_error))
775 }
776
777 /// The inverse IMFP (m⁻¹) and the stopping power (eV/m) at kinetic
778 /// energy `energy_ev` above the Fermi level.
779 pub fn imfp_and_stopping(&self, energy_ev: f64) -> Result<InelasticPoint> {
780 Ok(self.imfp_and_stopping_evaluation(energy_ev)?.point)
781 }
782
783 /// [`Self::imfp_and_stopping`] with the cells of its outer `ω_p`
784 /// integral that were accepted without meeting their tolerance (#298,
785 /// #368), from the same single evaluation; see [`ImfpEvaluation`]. The
786 /// `point` is the same value [`Self::imfp_and_stopping`] returns, and
787 /// the errors are the same.
788 pub fn imfp_and_stopping_evaluation(&self, energy_ev: f64) -> Result<ImfpEvaluation> {
789 check_energy("electron energy", energy_ev)?;
790 let h = hartree_ev();
791 let (inv, s, cells, err) = self.imfp_and_stopping_eval_au(energy_ev / h);
792 Ok(ImfpEvaluation {
793 point: InelasticPoint {
794 energy_ev,
795 inverse_imfp_per_m: inv / BOHR_RADIUS,
796 stopping_ev_per_m: s * h / BOHR_RADIUS,
797 },
798 unconverged_cells: cells,
799 unconverged_error: err,
800 })
801 }
802
803 /// The inelastic mean free path `λ(E)`, m.
804 pub fn imfp_m(&self, energy_ev: f64) -> Result<f64> {
805 Ok(self.imfp_and_stopping(energy_ev)?.imfp_m())
806 }
807
808 /// The stopping power `S(E)`, eV/m.
809 pub fn stopping_power_ev_per_m(&self, energy_ev: f64) -> Result<f64> {
810 Ok(self.imfp_and_stopping(energy_ev)?.stopping_ev_per_m)
811 }
812
813 /// IMFP and stopping power on a grid of energies, in order. Sequential
814 /// and deterministic.
815 pub fn tabulate(&self, energies_ev: &[f64]) -> Result<Vec<InelasticPoint>> {
816 energies_ev
817 .iter()
818 .map(|&e| self.imfp_and_stopping(e))
819 .collect()
820 }
821
822 // ---- the DIIMFP grid of a table build ----
823
824 /// The `u = ln q` profile of `Im[-1/ε(q, ω)]` at the loss `w` (Hartree),
825 /// over every momentum window `[q-, q+]` of a kinetic energy up to
826 /// `tp_max - E_F` and a loss within a factor 2 of `w` (see
827 /// [`DiimfpGrid`]).
828 fn loss_profile(&self, w: f64, tp_max: f64) -> Panels {
829 let k = (2.0 * tp_max).sqrt();
830 let s = (2.0 * (tp_max - w)).max(0.0).sqrt();
831 let (lo, hi) = ((0.25 * 2.0 * w / (k + s)).ln(), (2.0 * k).ln());
832 let n = ((hi - lo) / PROFILE_SEGMENT).ceil().max(8.0) as usize;
833 let breaks: Vec<f64> = (0..=n)
834 .map(|i| lo + (hi - lo) * i as f64 / n as f64)
835 .collect();
836 let inner = Cell::new(0u64);
837 integrate_panels(
838 &self.gl,
839 &mut |u: f64| {
840 let e = self.loss_eval_au(u.exp(), w);
841 if e.unconverged_cells > 0 {
842 inner.set(inner.get() + 1);
843 }
844 e.value
845 },
846 &breaks,
847 self.rel_tol,
848 PROFILE_FLOOR,
849 )
850 .with_inner_unconverged(inner.get())
851 }
852
853 /// The DIIMFP of this model tabulated once for the rows of one
854 /// inelastic table, whose energies (eV, above the Fermi level) are
855 /// `energies_ev`. See [`DiimfpGrid`] for the method and its accuracy.
856 /// The profiles are built in parallel (rayon) and collected in a fixed
857 /// order, so the grid is bit-identical on any thread count.
858 pub fn diimfp_grid(&self, energies_ev: &[f64]) -> Result<DiimfpGrid> {
859 for &e in energies_ev {
860 check_energy("electron energy", e)?;
861 }
862 let h = hartree_ev();
863 let rows: Vec<f64> = energies_ev.iter().map(|e| e / h).collect();
864 let t_max = rows.iter().copied().fold(0.0, f64::max);
865 let elf_lo = self.w[0];
866 // The lowest loss node: the table's lowest loss, `FULL_LOWEST_LOSS_FRACTION`
867 // of the lowest row energy (#361), or the lowest ELF energy if that
868 // is lower.
869 let t_min = rows.iter().copied().fold(f64::INFINITY, f64::min);
870 let w_lo = elf_lo.min(FULL_LOWEST_LOSS_FRACTION * t_min);
871 let empty = DiimfpGrid {
872 s: Vec::new(),
873 profiles: Vec::new(),
874 gl: self.gl.clone(),
875 fermi: self.fermi,
876 t_max,
877 tolerance: self.rel_tol,
878 unresolved: Vec::new(),
879 scale_lo: elf_lo.ln(),
880 };
881 if !(t_max > 0.0 && w_lo < t_max) {
882 return Ok(empty);
883 }
884 // The profiles are integrated more tightly than the interpolation
885 // tolerance, so that their quadrature noise does not drive the
886 // refinement below.
887 let fine = Self {
888 rel_tol: (self.rel_tol * PROFILE_TOLERANCE_FACTOR).max(1e-9),
889 ..self.clone()
890 };
891 let tp_max = t_max + self.fermi;
892 // Starting nodes: both ends, every ELF knot in between, no cell wider
893 // than INITIAL_CELL in ln ω.
894 let (s_lo, s_hi) = (w_lo.ln(), t_max.ln());
895 let mut knots: Vec<f64> = vec![s_lo];
896 if elf_lo.ln() > s_lo && elf_lo.ln() < s_hi {
897 knots.push(elf_lo.ln());
898 }
899 knots.extend(
900 self.w
901 .iter()
902 .map(|x| x.ln())
903 .filter(|&x| x > s_lo && x < s_hi && x > elf_lo.ln()),
904 );
905 knots.push(s_hi);
906 let mut s: Vec<f64> = Vec::new();
907 for c in knots.windows(2) {
908 let n = ((c[1] - c[0]) / INITIAL_CELL).ceil().max(1.0) as usize;
909 for i in 0..n {
910 s.push(c[0] + (c[1] - c[0]) * i as f64 / n as f64);
911 }
912 }
913 s.push(s_hi);
914 let mut profiles: Vec<Panels> = s
915 .par_iter()
916 .map(|&x| fine.loss_profile(x.exp(), tp_max))
917 .collect();
918 let mut grid = DiimfpGrid {
919 s,
920 profiles: Vec::new(),
921 ..empty
922 };
923 // Cells still to be checked (all at first).
924 let mut todo: Vec<bool> = vec![true; grid.s.len() - 1];
925 let mut first = true;
926 loop {
927 grid.profiles = profiles;
928 // a cell at the narrowest width is not split again (after the
929 // first level, every cell to check is half of a failed one)
930 for (c, t) in todo.iter_mut().enumerate() {
931 if *t && grid.s[c + 1] - grid.s[c] < 2.0 * MIN_CELL {
932 *t = false;
933 if !first {
934 grid.note_unresolved(c);
935 }
936 }
937 }
938 first = false;
939 let cells: Vec<usize> = (0..todo.len()).filter(|&c| todo[c]).collect();
940 if cells.is_empty() || grid.s.len() + cells.len() > MAX_GRID_NODES {
941 for &c in &cells {
942 grid.note_unresolved(c);
943 }
944 grid.unresolved.sort_by(|a, b| a.0.total_cmp(&b.0));
945 return Ok(grid);
946 }
947 // Mean density of each row in ln ω on the current nodes: the
948 // scale of the absolute part of the tolerance.
949 let means: Vec<f64> = rows.iter().map(|&t| grid.mean_density(t)).collect();
950 let mids: Vec<f64> = cells
951 .iter()
952 .map(|&c| 0.5 * (grid.s[c] + grid.s[c + 1]))
953 .collect();
954 let mid_profiles: Vec<Panels> = mids
955 .par_iter()
956 .map(|&x| fine.loss_profile(x.exp(), tp_max))
957 .collect();
958 let mut split = vec![false; cells.len()];
959 for (j, &c) in cells.iter().enumerate() {
960 let w = mids[j].exp();
961 for (&t, &mean) in rows.iter().zip(&means) {
962 if w >= t {
963 continue;
964 }
965 let exact = w * grid.window(&mid_profiles[j], t, w);
966 let lin = w * blend(
967 grid.window(&grid.profiles[c], t, w),
968 grid.window(&grid.profiles[c + 1], t, w),
969 0.5,
970 );
971 if (exact - lin).abs() > self.rel_tol * exact.abs().max(mean) {
972 split[j] = true;
973 break;
974 }
975 }
976 }
977 // Insert every midpoint (it is computed; a node only makes the
978 // interpolation better) and check the halves of the split cells.
979 let old = std::mem::take(&mut grid.profiles);
980 let mut mid_iter = mid_profiles.into_iter();
981 let mut ns = Vec::with_capacity(grid.s.len() + cells.len());
982 let mut np = Vec::with_capacity(grid.s.len() + cells.len());
983 let mut ntodo = Vec::with_capacity(todo.len() + cells.len());
984 let mut k = 0;
985 for (c, p) in old.into_iter().enumerate() {
986 ns.push(grid.s[c]);
987 np.push(p);
988 if c < todo.len() {
989 if todo[c] {
990 ns.push(mids[k]);
991 np.push(mid_iter.next().expect("one profile per midpoint"));
992 ntodo.push(split[k]);
993 ntodo.push(split[k]);
994 k += 1;
995 } else {
996 ntodo.push(false);
997 }
998 }
999 }
1000 grid.s = ns;
1001 profiles = np;
1002 todo = ntodo;
1003 }
1004 }
1005}
1006
1007/// Interpolation between the window integrals `lo` and `hi` of two
1008/// neighbouring loss nodes at fraction `f` of the cell in `ln ω`: linear in
1009/// `ln p` (exact for a power law in `ω`, the shape of the tails) where both
1010/// are positive, linear in `p` otherwise.
1011fn blend(lo: f64, hi: f64, f: f64) -> f64 {
1012 if f <= 0.0 {
1013 lo
1014 } else if f >= 1.0 {
1015 hi
1016 } else if lo > 0.0 && hi > 0.0 {
1017 (lo.ln() * (1.0 - f) + hi.ln() * f).exp()
1018 } else {
1019 lo * (1.0 - f) + hi * f
1020 }
1021}
1022
1023/// Width (in `ln q`) of the starting segments of a [`DiimfpGrid`] profile.
1024const PROFILE_SEGMENT: f64 = 0.125;
1025/// The profiles of a [`DiimfpGrid`] are integrated at this fraction of the
1026/// model tolerance.
1027const PROFILE_TOLERANCE_FACTOR: f64 = 0.1;
1028/// Each segment of a [`DiimfpGrid`] profile is integrated to the profile
1029/// tolerance relative to its own integral, down to this fraction of the mean
1030/// segment (`quadrature::integrate_panels`).
1031const PROFILE_FLOOR: f64 = 1e-4;
1032/// Widest starting cell of a [`DiimfpGrid`] in `ln ω` (also the widest cell
1033/// at all, which the momentum range of the profiles relies on: `e^0.25 < 2`).
1034const INITIAL_CELL: f64 = 0.25;
1035/// Narrowest cell of a [`DiimfpGrid`] in `ln ω`: a cell is not halved below
1036/// it, which bounds the refinement where the profiles carry quadrature
1037/// noise.
1038const MIN_CELL: f64 = 1e-4;
1039/// The lowest loss of a full Penn table row, as a fraction of the row's
1040/// energy `T`: the row's loss window is `(FULL_LOWEST_LOSS_FRACTION · T, T]`
1041/// (#361; table module docs, "Rows below the ELF table"), and the
1042/// [`DiimfpGrid`] of a table reaches down to it for the lowest row.
1043///
1044/// The DIIMFP of this model is not zero below the lowest ELF energy: each
1045/// Lindhard gas has an electron-hole continuum down to `ω -> 0` at `q > 0`
1046/// (S2017 eq. (4)), and `Im[-1/ε(q, ω)] ∝ ω` there, so `p(T, ω) ∝ ω`, and
1047/// the rate and stopping power below a loss `W` fall as `W²` and `W³`. The
1048/// share of `λ⁻¹` below `1e-6 T` is about 2e-12 on the synthetic plasmon
1049/// ELF at 0.3 to 40 eV (of `S`, 2e-18), far below the model tolerance
1050/// (1e-4); `tests/penn_full.rs` bounds it from the direct DIIMFP at the
1051/// floor (below 1e-6 of `λ⁻¹` and 1e-8 of `S`). It is a numerical floor of the sampled distribution, not
1052/// a model cutoff: the stored rate is the model's own, which integrates
1053/// from zero.
1054pub(crate) const FULL_LOWEST_LOSS_FRACTION: f64 = 1.0e-6;
1055/// Upper bound on the number of loss nodes of a [`DiimfpGrid`].
1056const MAX_GRID_NODES: usize = 20_000;
1057
1058/// The full Penn DIIMFP of one table build, tabulated so that each row of
1059/// the table costs interpolations instead of nested integrals (issue #256).
1060///
1061/// The DIIMFP is `p(T, ω) = (1/(π T')) ∫_{q-}^{q+} Im[-1/ε(q, ω)] dq/q`
1062/// (S2017 eq. (2)), and only the window `[q-, q+]` depends on `T`. So for a
1063/// set of loss nodes `ω_i` the grid stores the integrand `Im[-1/ε(q, ω_i)]`
1064/// as a function of `u = ln q`, once, over every window it can be asked for
1065/// (from a quarter of `q-` at the highest table energy to `2k` there): the
1066/// accepted panels of an adaptive Gauss-Legendre integration
1067/// (`quadrature::integrate_panels`) from segments 0.125 wide in `u`, at a
1068/// tenth of the model tolerance and with every segment held to that
1069/// tolerance relative to its own integral (down to 1e-4 of the mean
1070/// segment), so that a narrow window is as accurate as a wide one. The
1071/// integral to any `u` comes from the polynomial through the node values of
1072/// its panel (`quadrature::Panels`), so the integral over the window of any
1073/// `(T, ω_i)` costs no new evaluation. (With 0.5-wide starting segments and
1074/// the mean-segment floor of [`FullPenn::diimfp_per_m_ev`], the bisection
1075/// was seen to stop early, by coincidence, at a few isolated `ω` near the Al
1076/// plasmon, with window errors up to 1.5e-3; the finer segments removed
1077/// these.)
1078///
1079/// Between two loss nodes the window integrals of the two neighbouring
1080/// profiles, each over the window of the requested `(T, ω)`, are
1081/// interpolated linearly in `ln p` against `ln ω` (exact for a power law,
1082/// the shape of the DIIMFP's tails; linear in `p` where one of them is
1083/// zero). The nodes are the two ends `ω = ω_min` (`FULL_LOWEST_LOSS_FRACTION`
1084/// of the lowest table energy, or the lowest ELF energy if lower; #361) and
1085/// `ω = T_max`, the lowest ELF energy, every ELF knot in between (where `Im[-1/ε]` has kinks at
1086/// small `q`, and where a feature narrower than the knot spacing sits) and a
1087/// subdivision to cells no wider than 0.25 in `ln ω`. A cell is then
1088/// halved, level by level, while at its midpoint `ω_m`, for any row energy
1089/// `T > ω_m` of the table,
1090/// `|ω_m (p_exact - p_interp)| > tol · max(ω_m p_exact, ḡ_T)`, with `tol`
1091/// the model tolerance and `ḡ_T` the mean of `ω p(T, ω)` over `ln ω` on the
1092/// current nodes (the row's average loss density in `ln ω`, the scale of
1093/// the table's own density tolerance). The interpolation error is therefore
1094/// held to the model tolerance (relative, or relative to the row's average
1095/// density where the DIIMFP is below it) at the midpoints checked, with the
1096/// row energies of the table as the test set. Every computed midpoint is
1097/// kept as a node. Cells are not halved below 1e-4 in `ln ω`, nor beyond
1098/// 20000 nodes; a cell that still fails there is counted in
1099/// [`Self::unresolved_cells`] and kept, and [`Self::diimfp_per_m_ev`]
1100/// does not answer inside it (the caller uses the direct model there), so
1101/// no unchecked interpolation enters a table.
1102#[derive(Debug, Clone)]
1103pub struct DiimfpGrid {
1104 /// Loss nodes, `ln ω` (ω in Hartree), ascending.
1105 s: Vec<f64>,
1106 profiles: Vec<Panels>,
1107 gl: GaussLegendre,
1108 fermi: f64,
1109 /// Highest table energy, Hartree.
1110 t_max: f64,
1111 tolerance: f64,
1112 /// Cells `(ln ω lo, ln ω hi)` that failed the check but were not halved
1113 /// (width or node cap), ascending; the grid does not answer in them.
1114 unresolved: Vec<(f64, f64)>,
1115 /// `ln` of the lowest ELF energy (Hartree): the lower end of the range
1116 /// of the mean density, whatever the lowest node (#361).
1117 scale_lo: f64,
1118}
1119
1120impl DiimfpGrid {
1121 /// Records cell `c` as unresolved.
1122 fn note_unresolved(&mut self, c: usize) {
1123 self.unresolved.push((self.s[c], self.s[c + 1]));
1124 }
1125
1126 /// Whether `x = ln ω` lies in (or on the edge of) an unresolved cell.
1127 fn in_unresolved(&self, x: f64) -> bool {
1128 let i = self.unresolved.partition_point(|&(lo, _)| lo <= x);
1129 // cells are ascending and disjoint apart from shared edges
1130 i > 0 && x <= self.unresolved[i - 1].1
1131 }
1132
1133 /// The window integral `∫_{q-}^{q+} Im[-1/ε] dq/q` of one profile at
1134 /// `(T, ω)`, Hartree.
1135 fn window(&self, p: &Panels, t: f64, w: f64) -> f64 {
1136 let tp = t + self.fermi;
1137 let k = (2.0 * tp).sqrt();
1138 let s = (2.0 * (tp - w)).max(0.0).sqrt();
1139 let (q_minus, q_plus) = (2.0 * w / (k + s), k + s);
1140 if q_plus.is_nan() || q_plus <= q_minus {
1141 return 0.0;
1142 }
1143 p.integral(&self.gl, q_minus.ln(), q_plus.ln())
1144 }
1145
1146 /// Mean over `ln ω` of `ω p(T, ω)` (without the factor `1/(π T')`) on
1147 /// the nodes below `T`, trapezoid rule; zero if there are none.
1148 fn mean_density(&self, t: f64) -> f64 {
1149 let st = t.ln();
1150 // The mean runs from the lowest ELF energy (#361): the nodes below
1151 // it, down to the floor of the rows' losses, are a power-law tail
1152 // that would only lower the mean, and so tighten the cell test, by
1153 // the number of decades they add. A row at or below the lowest ELF
1154 // energy has no other range.
1155 let lo = if st > self.scale_lo {
1156 self.scale_lo
1157 } else {
1158 self.s[0]
1159 };
1160 let first = self
1161 .s
1162 .partition_point(|&y| y < lo - 1e-9)
1163 .min(self.s.len() - 1);
1164 let g = |i: usize| {
1165 let w = self.s[i].exp();
1166 if w < t {
1167 w * self.window(&self.profiles[i], t, w)
1168 } else {
1169 0.0
1170 }
1171 };
1172 let mut acc = 0.0;
1173 let mut prev = g(first);
1174 for i in first + 1..self.s.len() {
1175 if self.s[i - 1] >= st {
1176 break;
1177 }
1178 let cur = g(i);
1179 let hi = self.s[i].min(st);
1180 acc += 0.5 * (prev + if self.s[i] <= st { cur } else { 0.0 }) * (hi - self.s[i - 1]);
1181 prev = cur;
1182 }
1183 let span = st - self.s[first];
1184 if span > 0.0 {
1185 acc / span
1186 } else {
1187 0.0
1188 }
1189 }
1190
1191 /// Number of loss nodes.
1192 pub fn len(&self) -> usize {
1193 self.s.len()
1194 }
1195
1196 /// Whether the grid has no nodes (no table energy).
1197 pub fn is_empty(&self) -> bool {
1198 self.s.is_empty()
1199 }
1200
1201 /// Total number of momentum panels over all profiles.
1202 pub fn panel_count(&self) -> usize {
1203 self.profiles.iter().map(Panels::len).sum()
1204 }
1205
1206 /// Number of cells of the momentum integrations of the profiles that
1207 /// were accepted without meeting their tolerance (budget of the
1208 /// bisection spent, maximum depth, or a midpoint that is not
1209 /// representable; #298). Zero when every profile converged.
1210 pub fn unconverged_profile_panels(&self) -> u64 {
1211 self.profiles.iter().map(Panels::unconverged_panels).sum()
1212 }
1213
1214 /// Number of evaluations of the loss function inside the profiles whose
1215 /// own `ω_p` integral did not converge (see
1216 /// [`FullPenn::loss_function_evaluation`]). Zero when every evaluation
1217 /// converged.
1218 pub fn unconverged_profile_loss_evaluations(&self) -> u64 {
1219 self.profiles.iter().map(Panels::inner_unconverged).sum()
1220 }
1221
1222 /// The largest error estimate of the cells counted in
1223 /// [`Self::unconverged_profile_panels`] within one profile, relative to
1224 /// that profile's integral over its whole momentum range (zero when
1225 /// every profile converged).
1226 pub fn largest_unconverged_profile_error(&self) -> f64 {
1227 self.profiles
1228 .iter()
1229 .map(Panels::unconverged_relative_error)
1230 .fold(0.0, f64::max)
1231 }
1232
1233 /// Number of cells that failed the interpolation check but were not
1234 /// halved, at the narrowest cell width or the node cap (zero when the
1235 /// refinement converged).
1236 pub fn unresolved_cells(&self) -> usize {
1237 self.unresolved.len()
1238 }
1239
1240 /// Whether loss `loss_ev` (eV) lies in an unresolved cell, where
1241 /// [`Self::diimfp_per_m_ev`] gives no answer.
1242 pub fn loss_is_unresolved_ev(&self, loss_ev: f64) -> bool {
1243 let w = loss_ev / hartree_ev();
1244 w > 0.0 && self.in_unresolved(w.ln())
1245 }
1246
1247 /// The loss range, eV, from the lowest to the highest edge of the
1248 /// cells counted in [`Self::unresolved_cells`]; `None` when there are
1249 /// none. The grid answers in none of these cells.
1250 pub fn unresolved_loss_range_ev(&self) -> Option<(f64, f64)> {
1251 let h = hartree_ev();
1252 let lo = self.unresolved.first()?.0;
1253 let hi = self.unresolved.iter().map(|c| c.1).fold(lo, f64::max);
1254 Some((lo.exp() * h, hi.exp() * h))
1255 }
1256
1257 /// The cells counted in [`Self::unresolved_cells`] as ranges of
1258 /// `ln(W / 1 eV)`, ascending: the loss axis of the table rows.
1259 pub(crate) fn unresolved_ln_loss_ev(&self) -> Vec<(f64, f64)> {
1260 let lh = hartree_ev().ln();
1261 self.unresolved
1262 .iter()
1263 .map(|&(lo, hi)| (lo + lh, hi + lh))
1264 .collect()
1265 }
1266
1267 /// The narrowest cell width of the grid in `ln ω` (1e-4): the finest
1268 /// scale at which the refinement tested the interpolation against the
1269 /// model tolerance. An unresolved cell is one that still failed at this
1270 /// scale, where the quadrature noise of the direct DIIMFP is not below
1271 /// the model tolerance.
1272 pub(crate) fn min_cell_width(&self) -> f64 {
1273 MIN_CELL
1274 }
1275
1276 /// The tolerance the grid was refined to (the model tolerance).
1277 pub fn tolerance(&self) -> f64 {
1278 self.tolerance
1279 }
1280
1281 /// The DIIMFP at kinetic energy `energy_ev` (above the Fermi level) and
1282 /// loss `loss_ev`, m⁻¹ eV⁻¹, from the grid; `None` where the grid does
1283 /// not cover it (a loss below the lowest node, `FULL_LOWEST_LOSS_FRACTION` of
1284 /// the lowest table energy, an energy above
1285 /// the highest table energy, or a loss in an unresolved cell, see
1286 /// [`Self::unresolved_cells`]), where the caller uses
1287 /// [`FullPenn::diimfp_per_m_ev`]. Zero for `ω <= 0` or `ω > T`, as there.
1288 ///
1289 /// Any `T` up to the highest table energy is answered, but the
1290 /// refinement of the loss nodes checks the interpolation only at the
1291 /// energies passed to [`FullPenn::diimfp_grid`]. The accuracy is
1292 /// controlled at those energies; between them it is not checked.
1293 pub fn diimfp_per_m_ev(&self, energy_ev: f64, loss_ev: f64) -> Option<f64> {
1294 let h = hartree_ev();
1295 let (t, w) = (energy_ev / h, loss_ev / h);
1296 if !(t.is_finite() && t > 0.0 && w.is_finite()) {
1297 return None;
1298 }
1299 if !(w > 0.0 && w <= t) {
1300 return Some(0.0);
1301 }
1302 if self.s.is_empty() || t > self.t_max * (1.0 + 1e-12) {
1303 return None;
1304 }
1305 let x = w.ln();
1306 let n = self.s.len();
1307 if !(x >= self.s[0] && x <= self.s[n - 1]) || self.in_unresolved(x) {
1308 return None;
1309 }
1310 let i = (self.s.partition_point(|&y| y <= x).max(1) - 1).min(n - 2);
1311 let (a, b) = (self.s[i], self.s[i + 1]);
1312 let f = ((x - a) / (b - a)).clamp(0.0, 1.0);
1313 let lo = self.window(&self.profiles[i], t, w);
1314 let hi = if f > 0.0 {
1315 self.window(&self.profiles[i + 1], t, w)
1316 } else {
1317 0.0
1318 };
1319 let tp = t + self.fermi;
1320 let p_au = blend(lo, hi, f) / (PI * tp);
1321 Some(p_au / (BOHR_RADIUS * h))
1322 }
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327 use super::*;
1328
1329 #[test]
1330 fn profile_across_the_plasmon_jump_above_the_elf_end_is_bounded() {
1331 // Si ELF (ends at 199 eV with a non-zero value), loss 208.068 eV,
1332 // grid tolerance 1e-5, momenta up to the 5 keV of the #168 grid: the
1333 // profile crosses the momentum where the plasmon of the 199 eV gas
1334 // reaches the loss and the plasmon term jumps (#298). Before the
1335 // relative acceptance this profile took over 10⁵ loss evaluations
1336 // (10164 panels when stopped there); measured after: 320 panels.
1337 // The cells holding the jump itself never meet their tolerance and
1338 // stop at the maximum depth, where they are counted; their error
1339 // is that of a cell 2^-40 of a segment wide.
1340 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1341 .join("../validation/data/optical/si_elf_yang2019.toml");
1342 let fp = FullPenn::new(OpticalElf::from_toml_file(path).unwrap())
1343 .with_relative_tolerance(1e-5)
1344 .unwrap();
1345 let h = hartree_ev();
1346 let p = fp.loss_profile(208.068_173_188_694_63 / h, 5000.0 / h);
1347 assert!(p.unconverged_panels() <= 8, "{}", p.unconverged_panels());
1348 assert!(
1349 p.unconverged_relative_error() < 1e-9,
1350 "{}",
1351 p.unconverged_relative_error()
1352 );
1353 assert!(p.len() < 1000, "{} panels", p.len());
1354 // the loss evaluations inside the profile all met their tolerance
1355 assert_eq!(p.inner_unconverged(), 0);
1356 eprintln!(
1357 "{} panels, {} unconverged, relative error {:.2e}",
1358 p.len(),
1359 p.unconverged_panels(),
1360 p.unconverged_relative_error()
1361 );
1362 }
1363}