Skip to main content

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}