Skip to main content

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}