lindhard/electron/inelastic/penn.rs
1//! The single-pole Penn algorithm: the momentum-dependent energy-loss
2//! function, the differential inverse inelastic mean free path (DIIMFP), the
3//! inelastic mean free path (IMFP) and the stopping power, built from an
4//! optical ELF.
5//!
6//! # Sources
7//!
8//! The algorithm is Penn's, D. R. Penn, Phys. Rev. B 35, 482 (1987),
9//! doi:10.1103/PhysRevB.35.482 (the "simple Penn algorithm" or single-pole
10//! approximation, SPA). That paper is closed access and **was not read** for
11//! this implementation. The equations below are taken from the account of the
12//! SPA by Penn and co-authors in H. Shinotsuka, B. Da, S. Tanuma, H.
13//! Yoshikawa, C. J. Powell, D. R. Penn, Surf. Interface Anal. 49, 238 (2017),
14//! doi:10.1002/sia.6123 (NIST author manuscript, open copy PMC5524379), and
15//! every equation number in this module refers to **that** paper ("S2017").
16//! The plasmon-pole idea for extending an optical ELF into momentum transfer
17//! goes back to Lindhard, Mat. Fys. Medd. Dan. Vid. Selsk. 28, no. 8 (1954)
18//! (the dielectric function each pole stands for) and Ritchie and Howie,
19//! Philos. Mag. 36, 463 (1977), doi:10.1080/14786437708244948 (neither read
20//! here; cited for attribution only).
21//!
22//! Hartree atomic units (`m_e = e = ħ = 1`) are used internally, as in S2017;
23//! the public API is in eV and metres.
24//!
25//! # The model
26//!
27//! The optical ELF `Im[-1/ε(ω)]` is written as a superposition of free-electron
28//! (Lindhard) loss functions of plasma frequency `ω_p` with weight
29//! `g(ω_p) = (2/(π ω_p)) Im[-1/ε(ω_p)]` (S2017 eqs. (4)-(5)), and in the SPA
30//! each Lindhard loss function is replaced by a single plasmon pole
31//! (S2017 eq. (7)),
32//!
33//! ```text
34//! Im[-1/ε_L(q, ω; ω_p)] ≈ (π/2) (ω_p² / ω_q(ω_p)) δ(ω - ω_q(ω_p)),
35//! ```
36//!
37//! with Penn's dispersion (S2017 eq. (8))
38//!
39//! ```text
40//! ω_q²(ω_p) = ω_p² + (1/3) (k_F(ω_p) q)² + q⁴/4, k_F(ω_p) = (3π/4)^(1/3) ω_p^(2/3).
41//! ```
42//!
43//! The `ω_p` integration then gives (S2017 eqs. (9)-(13))
44//!
45//! ```text
46//! Im[-1/ε(q, ω)] = Im[-1/ε(ω₀)] / (1 + π q² / (6 k_F(ω₀))),
47//! ```
48//!
49//! where `ω₀(q, ω)` is the plasma frequency whose pole sits at `ω`, the real
50//! positive root `x = ω₀^(2/3)` of the cubic (S2017 eq. (11))
51//! `x³ + a(q) x² + b(q, ω) = 0`, `a = (π²/48)^(1/3) q²`, `b = q⁴/4 - ω²`. There
52//! is a root only for `b < 0`, i.e. `q < sqrt(2ω)`; otherwise the loss
53//! function is zero. ([`SinglePolePenn::loss_function`].)
54//!
55//! **Kinematics are nonrelativistic** throughout: the relativistic factors of
56//! S2017 eq. (2) are set to 1 (`c -> ∞`). Relativistic kinematics is a later
57//! option. The DIIMFP is (S2017 eq. (2), nonrelativistic)
58//!
59//! ```text
60//! p(T, ω) = (1 / (π T')) ∫_{q-}^{q+} (dq/q) Im[-1/ε(q, ω)],
61//! q± = sqrt(2T') ± sqrt(2(T' - ω)),
62//! ```
63//!
64//! with `T` the kinetic energy measured from the Fermi level, `T' = T + E_F`,
65//! and losses `0 < ω <= T` (S2017 eq. (3), `ω_max = T' - E_F`). The Fermi
66//! energy `E_F` defaults to zero, so `T = T'` is the kinetic energy. The IMFP
67//! is `λ = [∫ p dω]⁻¹` (S2017 eq. (3)) and the stopping power
68//! `S = ∫ ω p dω`.
69//!
70//! # Low energies
71//!
72//! The SPA is an approximation to the full Penn algorithm (FPA) that holds at
73//! high energy and fails at low energy. S2017 computed IMFPs of liquid water
74//! from one ELF with the FPA, the SPA, a simplified SPA and the Mermin method.
75//! In its section "Calculated IMFPs from the four algorithms" (repeated in the
76//! Summary; open copy PMC5524379) it reports that the FPA and SPA IMFPs "agree
77//! well ... for energies over 50 eV. For energies less than 30 eV, the SPA
78//! IMFPs become larger than the FPA IMFPs with decreasing electron energy. The
79//! smaller FPA IMFPs in this energy range must be due to the contributions of
80//! single-electron excitations to the ELF, as shown in Eqn (6), that were
81//! neglected in the SPA." S2017 shows its values below 50 eV only for the
82//! trend, as less reliable, and computed water, not metals. In eq. (7) above
83//! each `ω_p` contributes only on its pole `ω = ω_q(ω_p) >= ω_p`, so a loss
84//! `ω` draws on ELF weight at `ω_p <= ω` only: the single-electron
85//! (electron-hole) continuum that the full Lindhard function of a plasmon
86//! has at losses below `ω_p` is dropped. An electron whose allowed losses lie
87//! below the main ELF peak then has almost nothing to lose.
88//!
89//! Measured in this code (#173; `lindhard-cli/examples/inelastic_low_energy.rs`,
90//! mode `imfp`), λ in nm for the Hagemann-Gudat-Kunz (1975) ELFs, model
91//! Fermi energy 0, for the single pole, the full Penn algorithm
92//! ([`super::full_penn`]) and Mermin ([`super::mermin`]):
93//!
94//! | E (eV) | Al SPA | Al full | Al Mermin | Au SPA | Au full | Au Mermin | Cu SPA | Cu full | Cu Mermin |
95//! |---|---|---|---|---|---|---|---|---|---|
96//! | 12 | 24.6 | 1.07 | 1.11 | 2.31 | 1.30 | 1.46 | 2.02 | 1.01 | 1.77 |
97//! | 19 | 9.83 | 0.720 | 0.739 | 1.66 | 1.00 | 1.04 | 1.10 | 0.735 | 1.17 |
98//! | 30 | 0.363 | 0.512 | 0.512 | 1.33 | 0.810 | 0.805 | 0.743 | 0.586 | 0.889 |
99//! | 100 | 0.402 | 0.401 | 0.433 | 0.528 | 0.533 | 0.469 | 0.507 | 0.503 | 0.628 |
100//! | 1000 | 1.96 | 1.97 | 2.13 | 1.29 | 1.30 | 1.30 | 1.63 | 1.65 | 1.93 |
101//!
102//! The SPA agrees with the full algorithm to 1 % at 100 eV and above, and
103//! below 30 eV becomes longer with decreasing energy, as S2017 reports for
104//! water: up to 23 times longer below 20 eV for Al (whose ELF is dominated by
105//! the 15 eV plasmon), 1.5 to 1.8 times for Au at 12 to 50 eV, and up to 2
106//! times for Cu at 12 to 19 eV. (At 30 eV the Al SPA value is the shorter
107//! one.) These are
108//! the energies of the secondary electrons that make up the yield δ:
109//! swapping only the table rows below 30 eV for Mermin rows takes the
110//! single-pole Al δ at 400 eV from 6.9 to 1.4, and Au keeps most of its
111//! single-pole excess over Mermin in the rows below 50 eV
112//! (`docs/validation.md`, "Secondary-electron yield δ(E)"). This is the
113//! published low-energy behaviour of the approximation; no error of this
114//! implementation was found (at 100 eV and above it agrees with the full
115//! algorithm of [`super::full_penn`] to about 1 %).
116//!
117//! # Optional exchange correction (Born-Ochkur)
118//!
119//! [`ExchangeCorrection`] makes the primary and the struck electron
120//! indistinguishable. The source is the open account in P. de Vera, S.
121//! Taioli, P. E. Trevisanutto, M. Dapor, I. Abril, S. Simonucci and R.
122//! Garcia-Molina, "Energy Deposition around Swift Carbon-Ion Tracks in Liquid
123//! Water", Int. J. Mol. Sci. 23, 6121 (2022), doi:10.3390/ijms23116121 (open
124//! access, PMC9181504), Section 2.4.3, eqs. (32)-(35) ("dV2022" below). It
125//! cites Ochkur and Rudge for the factor (their refs. 122-125) and its own
126//! earlier work (their ref. 42) for the setup; none of these was read. From
127//! dV2022, for a loss `E = W + B_α` that ionises a shell of binding energy
128//! `B_α` and emits an electron of kinetic energy `W`:
129//!
130//! * the exchange term of the DIIMFP is `(1/T) ∫ dk/k F Im[-1/ε(k, E)]`
131//! (eq. (32), last two terms), with the Born-Ochkur factor
132//! `F = -x + x²`, `x = (k²/2m) / (T - W)` (text below eq. (32); `ħ = m = 1`
133//! here). The denominator is `T - W = T - E + B_α`: it contains the
134//! **emitted** energy `W`, not the loss `E`;
135//! * its `k` limits are `sqrt(2mT) ± sqrt(2m(T - E))` (eq. (34)), the limits of
136//! the plain first Born DIIMFP at loss `E`;
137//! * the emitted energy is limited by indistinguishability to
138//! `W <= (T - B_α)/2` (text below eq. (35)), i.e. the loss to
139//! `E <= (T + B_α)/2`.
140//!
141//! Here that term is `(1/(π T')) ∫ dq/q F Im[-1/ε(q, ω)]` with `ω = E` and
142//! `x = (q²/2) / (T' - ω + B)` ([`SinglePolePenn::diimfp_with_binding_per_m_ev`]),
143//! and the loss is limited to `ω <= (T' + B)/2`. `B` is the binding energy of
144//! the channel the loss is attributed to (`super::inner_shell`). The plain
145//! model ([`SinglePolePenn::diimfp_per_m_ev`], [`SinglePolePenn::imfp_and_stopping`])
146//! and the valence channel use `B = 0`, so `x = (q²/2)/(T' - ω)` and
147//! `ω <= T'/2`, which is also the `ω_max` of the SSPA* variant of S2017. The SPA
148//! loss function vanishes for `q² >= 2ω` (S2017 eq. (11)), and
149//! `ω <= (T' + B)/2` gives `T' - ω + B >= ω`, so `0 <= x <= 1` wherever the
150//! integrand is nonzero and `-1/4 <= F <= 0` for every channel.
151//!
152//! **Where this departs from dV2022 eq. (32), deliberately:**
153//!
154//! * *Direct term.* dV2022 applies a Coulomb-field correction (their ref. 126,
155//! not read) to the direct term only: `T -> T + 2B_α` in its prefactor
156//! `1/(T + 2B_α)` and in its `k` limits (eq. (33)). We do **not** apply it:
157//! the direct term here is the plain first Born (S2017 eq. (2)) DIIMFP with
158//! prefactor `1/T'` and the eq. (34) limits for every channel, which is what
159//! dV2022 eq. (32) reduces to for `B_α = 0`. So for an inner shell only the
160//! exchange term, its denominator and the loss limit follow eq. (32); the
161//! direct term does not. Adding the correction would also break the property
162//! that the channels add up to the plain-model total.
163//! * *Valence binding energy.* dV2022 uses an energy-dependent mean binding
164//! energy `B(T)` of the outer shells, fitted for liquid water in their ref.
165//! 42 (not read). No such function is available for a general target, so
166//! the valence channel here has `B = 0`: its exchange denominator is
167//! `T' - ω` and its limit `ω <= T'/2`.
168//! * *Fermi energy.* dV2022 has none; here `T' = T + E_F` stands for its `T`
169//! (zero by default).
170//!
171//! The correction is optional and applied only for kinetic energies below the
172//! energy given to [`ExchangeCorrection::new`]; at and above it the model is the
173//! plain SPA (no `ω_max` cut either), so `λ(E)` has a step of the size of the
174//! correction at that energy. The total is then evaluated by direct numerical
175//! integration over `ω` of the DIIMFP (no closed form), with the outer
176//! tolerance `100 × rel_tol` since each DIIMFP value is itself an integral.
177//!
178//! # How the integrals are done
179//!
180//! **DIIMFP** ([`SinglePolePenn::diimfp_per_m_ev`]): the `q` integral of
181//! S2017 eq. (2) is done in `ln q` over `[q-, q+]`, restricted to the `q`
182//! where `ω₀(q, ω)` lies inside the tabulated range of the optical ELF (the
183//! ELF is zero outside it: nothing is extrapolated), and split at the `q`
184//! where `ω₀` crosses a table knot, so that each piece is smooth. `ω₀` is the
185//! root of the S2017 eq. (11) cubic, found by Newton's method from
186//! `x = ω^(2/3)` (the cubic is convex and increasing for `x > 0`, so the
187//! iteration converges monotonically).
188//!
189//! **IMFP and stopping power** ([`SinglePolePenn::imfp_and_stopping`]): the
190//! double integral over `(ω, q)` is done in the other order. Because the SPA
191//! puts the whole weight of each `ω_p` on one curve `ω = ω_q(ω_p)`, inserting
192//! S2017 eqs. (4), (5), (7) into `∫∫ dω (dq/q)` gives exactly
193//!
194//! ```text
195//! λ⁻¹ = (1/(π T')) ∫ dω_p Im[-1/ε(ω_p)] ω_p ∫_{qa}^{qb} dq / (q ω_q(ω_p)),
196//! S = (1/(π T')) ∫ dω_p Im[-1/ε(ω_p)] ω_p ln(qb / qa),
197//! ```
198//!
199//! where `[qa, qb]` is the set of `q` whose pole `ω = ω_q(ω_p)` is allowed:
200//! `q- <= q <= q+` is equivalent to `ω <= q k - q²/2` (`k = sqrt(2T')`), and
201//! `ω <= T` to `q <= q_T` with `ω_q(q_T) = T`. The function
202//! `φ(q) = q k - q²/2 - ω_q(q)` is concave (the numerator of `d²ω_q/dq²` is
203//! `c ω_p² + (3/2) q² ω_p² + (3/4) c q⁴ + q⁶/8 > 0`, `c = k_F²/3`), so the
204//! allowed set is one interval; its ends are found by Newton's method from
205//! `q = 0` and `q = k`, each converging monotonically. The inner `q`
206//! integral of `λ⁻¹` is elementary (`y = q²`,
207//! `∫ dy/(y sqrt(y²/4 + c y + ω_p²)) = -(1/ω_p) ln((2ω_p² + c y + 2 ω_p ω_q)/y)`).
208//! This reduction (the order of integration and the closed-form inner
209//! integral) is our own algebra on the published model; the tests check it
210//! against the direct `∫ p(T, ω) dω` to 1e-5.
211//!
212//! The outer integrals use adaptive Gauss-Legendre quadrature split at the
213//! table knots (`super::quadrature`): the estimated relative error of each
214//! integral is at most twice the tolerance (default
215//! [`DEFAULT_RELATIVE_TOLERANCE`] = 1e-7), plus the error of the Newton
216//! roots (relative 1e-15).
217//!
218//! # High-energy (Bethe) limits
219//!
220//! As `T -> ∞`, `qa -> ω_p/k` and `qb -> k`. The stopping power tends to
221//! `S -> (Ω_p²/(2T)) ln(2T/I)`, with `Ω_p² = (2/π) ∫ ω Im[-1/ε] dω` and
222//! `ln I = ∫ ω Im[-1/ε] ln ω dω / ∫ ω Im[-1/ε] dω`: the nonrelativistic Bethe
223//! formula with `W_max = T` (see [`super::bethe`]). The inverse IMFP tends to
224//!
225//! ```text
226//! λ⁻¹ -> (1/(2π T')) ∫ dω_p Im[-1/ε(ω_p)] ln(8 T' / (ω_p + k_F(ω_p)²/3)),
227//! ```
228//!
229//! which is the Bethe form `T/λ = A ln(B T)` of S2017 eq. (27) (with
230//! `α(T) = 1`): its slope `A = (1/(2π)) ∫ Im[-1/ε] dω = Ω_p² M_tot² / 2`
231//! (`Ω_p` and `M_tot²` as in S2017 eq. (29)) is fixed by the optical ELF
232//! alone (S2017 eqs. (28)-(29)), and its constant
233//! `ln B = ln 8 - ⟨ln(ω_p + k_F²/3)⟩` (average weighted by the ELF) by the
234//! SPA dispersion. That limit is our own expansion of the model
235//! ([`SinglePolePenn::bethe_inverse_imfp_per_m`]); the tests check that the
236//! model approaches it.
237
238use super::bethe;
239use super::quadrature::{integrate_segments, GaussLegendre};
240use super::sum_rules::SumRuleReport;
241use crate::constants::{BOHR_RADIUS, ELEMENTARY_CHARGE, HARTREE_ENERGY};
242use crate::electron::data::{ElectronDataError, OpticalElf};
243use std::f64::consts::PI;
244
245type Result<T> = std::result::Result<T, ElectronDataError>;
246
247/// Default relative tolerance of the IMFP, stopping and DIIMFP integrals.
248pub const DEFAULT_RELATIVE_TOLERANCE: f64 = 1.0e-7;
249
250/// The outer (loss) integral of the exchange-corrected total is this many
251/// times looser than the tolerance of the inner integrals.
252const EXCHANGE_OUTER_TOLERANCE_FACTOR: f64 = 100.0;
253
254/// Gauss-Legendre order of the adaptive integrals.
255const GL_ORDER: usize = 5;
256
257/// The Hartree energy in eV.
258pub(crate) fn hartree_ev() -> f64 {
259 HARTREE_ENERGY / ELEMENTARY_CHARGE
260}
261
262/// `(3π/4)^(1/3)`: `k_F(ω_p) = KF · ω_p^(2/3)` (S2017, below eq. (8)).
263fn kf_coefficient() -> f64 {
264 (0.75 * PI).cbrt()
265}
266
267/// `k_F(ω_p)` in atomic units.
268fn fermi_wavenumber(wp: f64) -> f64 {
269 kf_coefficient() * wp.cbrt() * wp.cbrt()
270}
271
272/// `c = k_F(ω_p)² / 3`, the coefficient of `q²` in S2017 eq. (8).
273fn dispersion_c(wp: f64) -> f64 {
274 let kf = fermi_wavenumber(wp);
275 kf * kf / 3.0
276}
277
278/// `ω_q(ω_p)` of S2017 eq. (8), given `c = k_F²/3`.
279fn pole(wp: f64, c: f64, q: f64) -> f64 {
280 let q2 = q * q;
281 (wp * wp + c * q2 + 0.25 * q2 * q2).sqrt()
282}
283
284/// `q²` at which the pole of `ω_p` reaches the energy `w > ω_p`:
285/// `q⁴/4 + c q² + ω_p² - w² = 0`, written without cancellation.
286fn q2_at_pole_energy(wp: f64, c: f64, w: f64) -> f64 {
287 let d = (w - wp) * (w + wp);
288 2.0 * d / (c + (c * c + d).sqrt())
289}
290
291/// `ω₀(q, ω)`: the plasma frequency whose pole is at `ω` for momentum `q`,
292/// the positive root `x = ω₀^(2/3)` of S2017 eq. (11). `None` if `q² >= 2ω`.
293fn pole_plasma_frequency(q: f64, w: f64) -> Option<f64> {
294 let q2 = q * q;
295 let a = (PI * PI / 48.0).cbrt() * q2;
296 let b = 0.25 * q2 * q2 - w * w;
297 if b >= 0.0 {
298 return None;
299 }
300 // f(x) = x³ + a x² + b is convex and increasing for x > 0, and
301 // f(ω^(2/3)) = a ω^(4/3) + q⁴/4 >= 0: Newton from there converges
302 // monotonically from the right.
303 let mut x = w.cbrt() * w.cbrt();
304 for _ in 0..200 {
305 let f = x * x * x + a * x * x + b;
306 let d = 3.0 * x * x + 2.0 * a * x;
307 if d <= 0.0 {
308 break;
309 }
310 let dx = f / d;
311 x -= dx;
312 if dx.abs() <= 1e-15 * x.abs() {
313 break;
314 }
315 }
316 (x > 0.0).then(|| x * x.sqrt())
317}
318
319/// The interval `[qa, qb]` of momenta for which the pole of `ω_p` is an
320/// allowed loss at kinetic energy `t` above the Fermi level, with
321/// `k = sqrt(2T')`. See the module docs.
322fn allowed_q(wp: f64, c: f64, t: f64, k: f64) -> Option<(f64, f64)> {
323 if wp.is_nan() || wp >= t {
324 return None;
325 }
326 let phi = |q: f64| {
327 let wq = pole(wp, c, q);
328 (
329 q * k - 0.5 * q * q - wq,
330 k - q - (c * q + 0.5 * q * q * q) / wq,
331 )
332 };
333 // Left end: Newton from q = 0, monotone from the left on a concave φ.
334 let mut qa = 0.0;
335 let mut converged = false;
336 for _ in 0..200 {
337 let (f, d) = phi(qa);
338 if f >= 0.0 {
339 converged = true;
340 break;
341 }
342 if d <= 0.0 {
343 return None; // past the maximum of φ with φ < 0: no allowed q
344 }
345 let dq = -f / d;
346 qa += dq;
347 if dq <= 1e-15 * qa {
348 converged = true;
349 break;
350 }
351 }
352 if !converged {
353 return None;
354 }
355 // Right end: Newton from q = k, monotone from the right. φ(k) < 0 and
356 // φ'(k) < 0 because ω_q(k) > k²/2 for ω_p > 0, so k lies right of the
357 // allowed interval.
358 let mut qb = k;
359 for _ in 0..200 {
360 let (f, d) = phi(qb);
361 if f >= 0.0 || d >= 0.0 {
362 break;
363 }
364 let dq = -f / d;
365 qb += dq;
366 if dq.abs() <= 1e-15 * qb {
367 break;
368 }
369 }
370 let qt = q2_at_pole_energy(wp, c, t).sqrt();
371 let qb = qb.min(qt);
372 (qb > qa).then_some((qa, qb))
373}
374
375/// `∫_{qa}^{qb} dq / (q ω_q)` times `ω_p` (see the module docs).
376fn imfp_kernel(wp: f64, c: f64, qa: f64, qb: f64) -> f64 {
377 let (ya, yb) = (qa * qa, qb * qb);
378 let g = |y: f64, q: f64| 2.0 * wp * wp + c * y + 2.0 * wp * pole(wp, c, q);
379 0.5 * ((g(ya, qa) / ya) / (g(yb, qb) / yb)).ln()
380}
381
382/// The Born-Ochkur exchange factor `F = x² - x`, `x = recoil / (T - W)`
383/// (dV2022, text below eq. (32); module docs), for a recoil energy
384/// `q²/2` and `T - W` in the same unit. `T - W` must be positive.
385fn ochkur_factor(recoil: f64, t_minus_emitted: f64) -> f64 {
386 let x = recoil / t_minus_emitted;
387 x * x - x
388}
389
390/// The Born-Ochkur exchange factor `F = -x + x²` of dV2022 (text below eq.
391/// (32); module docs), with `x = (ħ²q²/2m) / (T - W)` and `W = E - B` the
392/// kinetic energy of the emitted electron, for a recoil energy
393/// `ħ²q²/2m = recoil_ev`, a kinetic energy `T = energy_ev` (use `T' = T + E_F`
394/// with a Fermi energy), a loss `E = loss_ev` and a binding energy
395/// `B = binding_ev`, all in eV. This is the factor the DIIMFP integrand of
396/// [`SinglePolePenn::diimfp_with_binding_per_m_ev`] is multiplied by
397/// (`1 + F`). `NaN` unless `T - W > 0`.
398pub fn born_ochkur_factor(recoil_ev: f64, energy_ev: f64, loss_ev: f64, binding_ev: f64) -> f64 {
399 let t_minus_emitted = energy_ev - (loss_ev - binding_ev);
400 if t_minus_emitted > 0.0 {
401 ochkur_factor(recoil_ev, t_minus_emitted)
402 } else {
403 f64::NAN
404 }
405}
406
407/// Optional exchange correction (module docs, "Optional exchange
408/// correction"), applied for kinetic energies strictly below
409/// `applies_below_ev`.
410#[derive(Debug, Clone, Copy, PartialEq)]
411pub struct ExchangeCorrection {
412 applies_below_ev: f64,
413}
414
415impl ExchangeCorrection {
416 /// A correction applied for `T < applies_below_ev` (finite, positive).
417 pub fn new(applies_below_ev: f64) -> Result<Self> {
418 check_energy("exchange cutoff energy", applies_below_ev)?;
419 Ok(Self { applies_below_ev })
420 }
421
422 /// The energy below which the correction is applied, eV.
423 pub fn applies_below_ev(&self) -> f64 {
424 self.applies_below_ev
425 }
426}
427
428/// One point of an IMFP and stopping-power table.
429#[derive(Debug, Clone, Copy, PartialEq)]
430pub struct InelasticPoint {
431 /// Kinetic energy above the Fermi level, eV.
432 pub energy_ev: f64,
433 /// Inverse inelastic mean free path `λ⁻¹`, m⁻¹ (zero where no loss is
434 /// allowed).
435 pub inverse_imfp_per_m: f64,
436 /// Stopping power `S = -dE/dx`, eV/m.
437 pub stopping_ev_per_m: f64,
438}
439
440impl InelasticPoint {
441 /// The inelastic mean free path `λ`, m (infinite where no loss is
442 /// allowed).
443 pub fn imfp_m(&self) -> f64 {
444 1.0 / self.inverse_imfp_per_m
445 }
446}
447
448/// The single-pole Penn model built from one optical ELF. See the module docs
449/// for the equations, the units and the integration tolerance.
450#[derive(Debug, Clone)]
451pub struct SinglePolePenn {
452 elf: OpticalElf,
453 /// Tabulated energies, Hartree.
454 w: Vec<f64>,
455 /// Tabulated ELF values.
456 e: Vec<f64>,
457 /// Fermi energy, Hartree.
458 fermi: f64,
459 rel_tol: f64,
460 gl: GaussLegendre,
461 exchange: Option<ExchangeCorrection>,
462}
463
464fn check_energy(what: &'static str, v: f64) -> Result<()> {
465 if v.is_finite() && v > 0.0 {
466 Ok(())
467 } else {
468 Err(ElectronDataError::Invalid {
469 what,
470 reason: format!("must be finite and positive, got {v} eV"),
471 })
472 }
473}
474
475fn check_binding(v: f64) -> Result<()> {
476 if v.is_finite() && v >= 0.0 {
477 Ok(())
478 } else {
479 Err(ElectronDataError::Invalid {
480 what: "binding energy",
481 reason: format!("must be finite and non-negative, got {v} eV"),
482 })
483 }
484}
485
486impl SinglePolePenn {
487 /// The model for `elf`, with Fermi energy zero and the default tolerance.
488 pub fn new(elf: OpticalElf) -> Self {
489 let h = hartree_ev();
490 let w = elf.energy_ev().iter().map(|x| x / h).collect();
491 let e = elf.elf_values().to_vec();
492 Self {
493 elf,
494 w,
495 e,
496 fermi: 0.0,
497 rel_tol: DEFAULT_RELATIVE_TOLERANCE,
498 gl: GaussLegendre::new(GL_ORDER),
499 exchange: None,
500 }
501 }
502
503 /// A model for another ELF with this model's Fermi energy, tolerance and
504 /// exchange setting.
505 pub(crate) fn same_settings_for(&self, elf: OpticalElf) -> Self {
506 let mut m = Self::new(elf);
507 m.fermi = self.fermi;
508 m.rel_tol = self.rel_tol;
509 m.exchange = self.exchange;
510 m
511 }
512
513 /// Switch the exchange correction on (it is off by default).
514 pub fn with_exchange(mut self, exchange: ExchangeCorrection) -> Self {
515 self.exchange = Some(exchange);
516 self
517 }
518
519 /// The exchange correction, if enabled.
520 pub fn exchange(&self) -> Option<ExchangeCorrection> {
521 self.exchange
522 }
523
524 /// Whether the exchange correction applies at kinetic energy `energy_ev`.
525 pub fn exchange_applies_at(&self, energy_ev: f64) -> bool {
526 self.exchange
527 .is_some_and(|e| energy_ev < e.applies_below_ev)
528 }
529
530 /// Set the Fermi energy (eV, finite and non-negative). Energies passed to
531 /// the model are then kinetic energies above the Fermi level, and the
532 /// kinematics use `T' = T + E_F` (S2017 eq. (2)).
533 pub fn with_fermi_energy_ev(mut self, fermi_ev: f64) -> Result<Self> {
534 if !(fermi_ev.is_finite() && fermi_ev >= 0.0) {
535 return Err(ElectronDataError::Invalid {
536 what: "Fermi energy",
537 reason: format!("must be finite and non-negative, got {fermi_ev} eV"),
538 });
539 }
540 self.fermi = fermi_ev / hartree_ev();
541 Ok(self)
542 }
543
544 /// Set the relative integration tolerance, in `[1e-12, 1e-2]`.
545 pub fn with_relative_tolerance(mut self, rel_tol: f64) -> Result<Self> {
546 if !(1e-12..=1e-2).contains(&rel_tol) {
547 return Err(ElectronDataError::Invalid {
548 what: "integration tolerance",
549 reason: format!("must be in [1e-12, 1e-2], got {rel_tol}"),
550 });
551 }
552 self.rel_tol = rel_tol;
553 Ok(self)
554 }
555
556 /// The optical ELF the model is built from.
557 pub fn optical_elf(&self) -> &OpticalElf {
558 &self.elf
559 }
560
561 /// The Fermi energy, eV.
562 pub fn fermi_energy_ev(&self) -> f64 {
563 self.fermi * hartree_ev()
564 }
565
566 /// The relative integration tolerance.
567 pub fn relative_tolerance(&self) -> f64 {
568 self.rel_tol
569 }
570
571 /// The optical ELF at `x` Hartree: linear in segment `i`
572 /// (`w[i] <= x <= w[i+1]`), clamped to be non-negative.
573 fn elf_in_segment(&self, i: usize, x: f64) -> f64 {
574 let (x0, x1) = (self.w[i], self.w[i + 1]);
575 let t = (x - x0) / (x1 - x0);
576 (self.e[i] * (1.0 - t) + self.e[i + 1] * t).max(0.0)
577 }
578
579 /// The optical ELF at `x` Hartree; zero outside the table.
580 fn elf_au(&self, x: f64) -> f64 {
581 let n = self.w.len();
582 if !(x >= self.w[0] && x <= self.w[n - 1]) {
583 return 0.0;
584 }
585 let i = self.w.partition_point(|&g| g <= x).clamp(1, n - 1);
586 self.elf_in_segment(i - 1, x)
587 }
588
589 /// `Im[-1/ε(q, ω)]` in the SPA (S2017 eq. (13)), at momentum transfer
590 /// `q` (as a wavenumber, m⁻¹, so the momentum is `ħq`) and energy loss
591 /// `ω` (eV). Zero where S2017 eq. (11) has no root (`ħ²q²/2m >= ω`) and
592 /// where `ω₀` falls outside the tabulated range. At `q = 0` it is the
593 /// optical ELF.
594 pub fn loss_function(&self, q_per_m: f64, loss_ev: f64) -> f64 {
595 if !(q_per_m.is_finite() && q_per_m >= 0.0 && loss_ev.is_finite() && loss_ev > 0.0) {
596 return 0.0;
597 }
598 let q = q_per_m * BOHR_RADIUS;
599 let w = loss_ev / hartree_ev();
600 if q == 0.0 {
601 return self.elf_au(w);
602 }
603 match pole_plasma_frequency(q, w) {
604 Some(w0) => self.elf_au(w0) / (1.0 + PI * q * q / (6.0 * fermi_wavenumber(w0))),
605 None => 0.0,
606 }
607 }
608
609 /// The DIIMFP `p(T, ω)` (S2017 eq. (2), nonrelativistic) at kinetic
610 /// energy `energy_ev` above the Fermi level and energy loss `loss_ev`,
611 /// in m⁻¹ eV⁻¹. Zero for `ω <= 0` or `ω > T`, and, with the exchange
612 /// correction applying at this energy, for `ω > T'/2` (module docs; no
613 /// binding energy, `B = 0`).
614 pub fn diimfp_per_m_ev(&self, energy_ev: f64, loss_ev: f64) -> Result<f64> {
615 self.diimfp_with_binding_per_m_ev(energy_ev, loss_ev, 0.0)
616 }
617
618 /// The DIIMFP at kinetic energy `energy_ev` above the Fermi level and
619 /// energy loss `loss_ev`, m⁻¹ eV⁻¹, for a loss that ionises a shell of
620 /// binding energy `binding_ev` (finite, non-negative) and so emits an
621 /// electron of kinetic energy `W = ω - B`. `B` enters only through the
622 /// exchange correction, where it applies: the Born-Ochkur denominator is
623 /// `T' - W = T' - ω + B` and the loss is limited to `ω <= (T' + B)/2`
624 /// (dV2022 eqs. (32), (34), (35); module docs). Without exchange the
625 /// result does not depend on `B`; with `B = 0` it is
626 /// [`Self::diimfp_per_m_ev`].
627 pub fn diimfp_with_binding_per_m_ev(
628 &self,
629 energy_ev: f64,
630 loss_ev: f64,
631 binding_ev: f64,
632 ) -> Result<f64> {
633 check_energy("electron energy", energy_ev)?;
634 if !loss_ev.is_finite() {
635 return Err(ElectronDataError::Invalid {
636 what: "energy loss",
637 reason: format!("must be finite, got {loss_ev} eV"),
638 });
639 }
640 check_binding(binding_ev)?;
641 let h = hartree_ev();
642 let p_au = self.diimfp_au(energy_ev / h, loss_ev / h, binding_ev / h);
643 Ok(p_au / (BOHR_RADIUS * h))
644 }
645
646 /// `p(T, ω)` in atomic units (per bohr per hartree) for a channel of
647 /// binding energy `b`, with the exchange correction (and its
648 /// `ω <= (T' + B)/2` limit) where it applies.
649 fn diimfp_au(&self, t: f64, w: f64, b: f64) -> f64 {
650 let exchange = self.exchange_applies_at(t * hartree_ev());
651 if exchange && w > 0.5 * (t + self.fermi + b) {
652 return 0.0;
653 }
654 self.diimfp_core_au(t, w, exchange, b)
655 }
656
657 /// `p(T, ω)` in atomic units, with the exchange factor `1 + F` in the
658 /// integrand if `exchange`, `F` built with the emitted energy
659 /// `W = ω - b` (`b` the channel's binding energy, Hartree); no limit on
660 /// `ω` other than `ω <= T`.
661 pub(crate) fn diimfp_core_au(&self, t: f64, w: f64, exchange: bool, b: f64) -> f64 {
662 // T' - W, the Born-Ochkur denominator (module docs).
663 let t_minus_emitted = t + self.fermi - w + b;
664 if exchange && t_minus_emitted.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
665 return 0.0;
666 }
667 let Some(breaks) = self.q_slice_breaks_au(t, w) else {
668 return 0.0;
669 };
670 let tp = t + self.fermi;
671 let integral = integrate_segments(
672 &self.gl,
673 &mut |u: f64| {
674 let base = self.q_integrand_au(w, u);
675 if exchange {
676 let q = u.exp();
677 [base * (1.0 + ochkur_factor(0.5 * q * q, t_minus_emitted))]
678 } else {
679 [base]
680 }
681 },
682 &breaks,
683 self.rel_tol,
684 )[0];
685 integral / (PI * tp)
686 }
687
688 /// The `Im[-1/ε(q, ω)]` integrand of S2017 eq. (2) in `u = ln q` (`q` in
689 /// atomic units): `(dq/q) Im[-1/ε] = du Im[-1/ε]`. Zero where eq. (11) has
690 /// no root. This is the direct term only; [`Self::diimfp_core_au`]
691 /// multiplies it by the exchange factor `1 + F` where that applies.
692 pub(super) fn q_integrand_au(&self, w: f64, u: f64) -> f64 {
693 let q = u.exp();
694 match pole_plasma_frequency(q, w) {
695 Some(w0) => self.elf_au(w0) / (1.0 + PI * q * q / (6.0 * fermi_wavenumber(w0))),
696 None => 0.0,
697 }
698 }
699
700 /// Breakpoints in `ln q` (`q` in atomic units) of the momentum-transfer
701 /// integral of S2017 eq. (2) at kinetic energy `t` and loss `w`
702 /// (Hartree): the ends are the kinematic limits `q±` cut to the `q` where
703 /// `ω₀(q, ω)` lies in the tabulated range, with a break at every table
704 /// knot in between. `None` if the slice is empty (`w` outside
705 /// `(0, t]`, below the table, or no `q` allowed). The integrand is smooth
706 /// between breaks.
707 pub(super) fn q_slice_breaks_au(&self, t: f64, w: f64) -> Option<Vec<f64>> {
708 if !(w > 0.0 && w <= t) {
709 return None;
710 }
711 let n = self.w.len();
712 let (wmin, wmax) = (self.w[0], self.w[n - 1]);
713 if w <= wmin {
714 return None; // ω₀ < ω for q > 0, and ω₀ must be in the table
715 }
716 let tp = t + self.fermi;
717 let k = (2.0 * tp).sqrt();
718 let s = (2.0 * (tp - w)).max(0.0).sqrt();
719 let (q_minus, q_plus) = (2.0 * w / (k + s), k + s);
720 // q where ω₀ = table knot w_j (decreasing in w_j). Knots below ω
721 // only; ω₀ <= ω always.
722 let q_of = |wj: f64| q2_at_pole_energy(wj, dispersion_c(wj), w).sqrt();
723 let q_lo_table = if wmax < w { q_of(wmax) } else { 0.0 };
724 let q_hi_table = q_of(wmin);
725 let (lo, hi) = (q_minus.max(q_lo_table), q_plus.min(q_hi_table));
726 if hi.is_nan() || hi <= lo {
727 return None;
728 }
729 // Breakpoints in ln q at the knots strictly inside (lo, hi).
730 let mut breaks = vec![lo.ln()];
731 let first_knot_below_w = self.w.partition_point(|&x| x < w);
732 for j in (1..first_knot_below_w).rev() {
733 let qj = q_of(self.w[j]);
734 if qj > lo && qj < hi {
735 breaks.push(qj.ln());
736 }
737 }
738 breaks.push(hi.ln());
739 Some(breaks)
740 }
741
742 /// `(λ⁻¹, S)` at kinetic energy `t` (Hartree) above the Fermi level, in
743 /// atomic units (per bohr, hartree per bohr).
744 /// `b` is the channel's binding energy (Hartree), used only by the
745 /// exchange correction.
746 fn imfp_and_stopping_au(&self, t: f64, b: f64) -> (f64, f64) {
747 let n = self.w.len();
748 if t.is_nan() || t <= self.w[0] {
749 return (0.0, 0.0);
750 }
751 if self.exchange_applies_at(t * hartree_ev()) {
752 return self.imfp_and_stopping_exchange_au(t, b);
753 }
754 let tp = t + self.fermi;
755 let k = (2.0 * tp).sqrt();
756 let top = self.w[n - 1].min(t);
757 let mut breaks: Vec<f64> = self.w.iter().copied().filter(|&x| x < top).collect();
758 breaks.push(top);
759 let r = integrate_segments(
760 &self.gl,
761 &mut |x: f64| {
762 let c = dispersion_c(x);
763 match allowed_q(x, c, t, k) {
764 Some((qa, qb)) => {
765 let weight = self.elf_au(x);
766 [
767 weight * imfp_kernel(x, c, qa, qb),
768 weight * x * (qb / qa).ln(),
769 ]
770 }
771 None => [0.0, 0.0],
772 }
773 },
774 &breaks,
775 self.rel_tol,
776 );
777 (r[0] / (PI * tp), r[1] / (PI * tp))
778 }
779
780 /// `(λ⁻¹, S)` with the exchange correction: the direct integral of the
781 /// DIIMFP over `0 < ω <= min((T' + B)/2, T)` (module docs).
782 fn imfp_and_stopping_exchange_au(&self, t: f64, b: f64) -> (f64, f64) {
783 let top = (0.5 * (t + self.fermi + b)).min(t);
784 if top <= self.w[0] {
785 return (0.0, 0.0);
786 }
787 let mut breaks: Vec<f64> = self.w.iter().copied().filter(|&x| x < top).collect();
788 breaks.push(top);
789 let r = integrate_segments(
790 &self.gl,
791 &mut |x: f64| {
792 let p = self.diimfp_core_au(t, x, true, b);
793 [p, x * p]
794 },
795 &breaks,
796 (self.rel_tol * EXCHANGE_OUTER_TOLERANCE_FACTOR).min(1e-2),
797 );
798 (r[0], r[1])
799 }
800
801 /// The inverse IMFP (m⁻¹) and the stopping power (eV/m) at kinetic
802 /// energy `energy_ev` above the Fermi level. Both are zero where no loss
803 /// is allowed (below the first energy at which a pole of the tabulated
804 /// range fits inside the kinematic limits).
805 pub fn imfp_and_stopping(&self, energy_ev: f64) -> Result<InelasticPoint> {
806 self.imfp_and_stopping_with_binding(energy_ev, 0.0)
807 }
808
809 /// As [`Self::imfp_and_stopping`], for losses that ionise a shell of
810 /// binding energy `binding_ev` (finite, non-negative): with the exchange
811 /// correction applying, the Born-Ochkur denominator is `T' - ω + B` and
812 /// the losses run up to `min((T' + B)/2, T)`
813 /// ([`Self::diimfp_with_binding_per_m_ev`]). Without exchange it does not
814 /// depend on `B`. The loss range is not cut at `B` from below: an ELF that
815 /// should only act above the edge must start there (the model is zero
816 /// below the first tabulated energy).
817 pub fn imfp_and_stopping_with_binding(
818 &self,
819 energy_ev: f64,
820 binding_ev: f64,
821 ) -> Result<InelasticPoint> {
822 check_energy("electron energy", energy_ev)?;
823 check_binding(binding_ev)?;
824 let h = hartree_ev();
825 let (inv, s) = self.imfp_and_stopping_au(energy_ev / h, binding_ev / h);
826 Ok(InelasticPoint {
827 energy_ev,
828 inverse_imfp_per_m: inv / BOHR_RADIUS,
829 stopping_ev_per_m: s * h / BOHR_RADIUS,
830 })
831 }
832
833 /// The inelastic mean free path `λ(E)`, m; infinite where no loss is
834 /// allowed.
835 pub fn imfp_m(&self, energy_ev: f64) -> Result<f64> {
836 Ok(self.imfp_and_stopping(energy_ev)?.imfp_m())
837 }
838
839 /// The stopping power `S(E)`, eV/m.
840 pub fn stopping_power_ev_per_m(&self, energy_ev: f64) -> Result<f64> {
841 Ok(self.imfp_and_stopping(energy_ev)?.stopping_ev_per_m)
842 }
843
844 /// IMFP and stopping power on a grid of energies (eV above the Fermi
845 /// level), in order. Sequential and deterministic.
846 pub fn tabulate(&self, energies_ev: &[f64]) -> Result<Vec<InelasticPoint>> {
847 energies_ev
848 .iter()
849 .map(|&e| self.imfp_and_stopping(e))
850 .collect()
851 }
852
853 /// The model's own high-energy (Bethe) limit of the inverse IMFP, m⁻¹
854 /// (see the module docs): `(1/(2π T')) ∫ dω_p ELF(ω_p) ln(8T'/(ω_p +
855 /// k_F²/3))`, over the whole table.
856 pub fn bethe_inverse_imfp_per_m(&self, energy_ev: f64) -> Result<f64> {
857 check_energy("electron energy", energy_ev)?;
858 let h = hartree_ev();
859 let tp = energy_ev / h + self.fermi;
860 let r = integrate_segments(
861 &self.gl,
862 &mut |x: f64| [self.elf_au(x) * (8.0 * tp / (x + dispersion_c(x))).ln()],
863 &self.w,
864 self.rel_tol,
865 )[0];
866 Ok(r / (2.0 * PI * tp) / BOHR_RADIUS)
867 }
868
869 /// The nonrelativistic Bethe stopping power with `W_max = T`, the
870 /// electron density from the f-sum of the same ELF and the mean
871 /// excitation energy `I` of the same ELF ([`SumRuleReport`],
872 /// [`bethe::stopping_power_ev_per_m`]), eV/m. With a Fermi energy, the
873 /// kinematic energy is `T' = T + E_F` and `W_max = T`.
874 pub fn bethe_stopping_ev_per_m(&self, energy_ev: f64) -> Result<f64> {
875 check_energy("electron energy", energy_ev)?;
876 let r = SumRuleReport::new(&self.elf);
877 Ok(bethe::stopping_power_ev_per_m(
878 r.electron_density_per_m3,
879 r.mean_excitation_energy_ev,
880 energy_ev + self.fermi_energy_ev(),
881 energy_ev,
882 ))
883 }
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889
890 #[test]
891 fn closed_form_imfp_kernel_matches_quadrature() {
892 let gl = GaussLegendre::new(GL_ORDER);
893 for (wp, qa, qb) in [(0.5, 1e-3, 3.0), (2.0, 0.2, 40.0), (0.05, 1e-5, 0.4)] {
894 let c = dispersion_c(wp);
895 let want = wp
896 * integrate_segments(
897 &gl,
898 &mut |u: f64| [1.0 / pole(wp, c, u.exp())],
899 &[f64::ln(qa), f64::ln(qb)],
900 1e-13,
901 )[0];
902 let got = imfp_kernel(wp, c, qa, qb);
903 assert!((got / want - 1.0).abs() < 1e-11, "{wp}: {got} vs {want}");
904 }
905 }
906
907 #[test]
908 fn cubic_root_satisfies_the_dispersion() {
909 for (q, w) in [(0.1, 1.0), (1.0, 0.9), (1e-4, 3.0), (2.0, 2.0001)] {
910 let w0 = pole_plasma_frequency(q, w).unwrap();
911 let back = pole(w0, dispersion_c(w0), q);
912 assert!((back / w - 1.0).abs() < 1e-13, "{q} {w}: {back}");
913 }
914 assert!(pole_plasma_frequency(2.0, 1.9).is_none());
915 }
916
917 #[test]
918 fn allowed_interval_ends_satisfy_the_kinematics() {
919 let (t, wp): (f64, f64) = (50.0, 0.7);
920 let k = (2.0 * t).sqrt();
921 let c = dispersion_c(wp);
922 let (qa, qb) = allowed_q(wp, c, t, k).unwrap();
923 let phi = |q: f64| q * k - 0.5 * q * q - pole(wp, c, q);
924 assert!(phi(qa).abs() < 1e-12, "{}", phi(qa));
925 // qb is the kinematic root or q_T, whichever is smaller.
926 let qt = q2_at_pole_energy(wp, c, t).sqrt();
927 assert!(phi(qb).abs() < 1e-10 || (qb - qt).abs() < 1e-12 * qt);
928 assert!(allowed_q(wp, c, 0.5, 1.0).is_none());
929 }
930
931 /// Eq. (32) of de Vera et al.: with `T = 400`, `B = 150` and `ω = 200` eV
932 /// (`E_F = 0`), `W = ω - B = 50` eV and `T - W = 350` eV, not the `200` eV
933 /// of `T' - ω`.
934 #[test]
935 fn exchange_factor_uses_primary_energy_less_emitted_energy() {
936 let h = hartree_ev();
937 let (t, b, w) = (400.0 / h, 150.0 / h, 200.0 / h);
938 let t_minus_w = t - (w - b);
939 assert!((t_minus_w * h - 350.0).abs() < 1e-9);
940 for q in [0.5, 2.0, 5.0] {
941 let x = 0.5 * q * q / (350.0 / h);
942 let want = -x + x * x;
943 let got = ochkur_factor(0.5 * q * q, t_minus_w);
944 assert!((got - want).abs() <= 1e-14 * want.abs(), "{q}: {got}");
945 // The wrong denominator would give a different factor.
946 let wrong = ochkur_factor(0.5 * q * q, t - w);
947 assert!((wrong - want).abs() > 1e-3 * want.abs(), "{q}");
948 }
949 }
950
951 #[test]
952 fn diimfp_exchange_depends_on_binding_energy() {
953 use crate::electron::inelastic::{DrudeLorentz, DrudeLorentzOscillator};
954 let elf = DrudeLorentz::new(vec![DrudeLorentzOscillator::plasmon(20.0, 5.0)])
955 .unwrap()
956 .to_optical_elf("synthetic Drude plasmon", 0.05, 2e4, 240)
957 .unwrap();
958 let m = SinglePolePenn::new(elf);
959 let h = hartree_ev();
960 let (t, w, b) = (400.0 / h, 200.0 / h, 150.0 / h);
961 let direct = m.diimfp_core_au(t, w, false, 0.0);
962 let b0 = m.diimfp_core_au(t, w, true, 0.0);
963 let bj = m.diimfp_core_au(t, w, true, b);
964 assert!(direct > 0.0 && b0 > 0.0 && bj > 0.0);
965 assert!((bj / b0 - 1.0).abs() > 1e-3, "{bj} vs {b0}");
966 // Without exchange the binding energy does not enter.
967 assert_eq!(m.diimfp_core_au(t, w, false, b), direct);
968 }
969}