Skip to main content

lindhard/electron/inelastic/
lindhard_gas.rs

1//! The Lindhard dielectric function of a free-electron gas at zero
2//! temperature (random-phase approximation), in Hartree atomic units
3//! (`m_e = e = ħ = 1`), and the loss-function integrals built from it.
4//!
5//! This is the building block of the full Penn algorithm
6//! ([`super::full_penn`]): Penn expands the optical energy-loss function over
7//! the loss functions of free-electron gases of all plasma frequencies
8//! (Shinotsuka et al., Surf. Interface Anal. 49, 238 (2017),
9//! doi:10.1002/sia.6123, eq. (4), with `ε_L` "the dielectric function from
10//! the Lindhard model of a free electron gas", citing J. Lindhard, Mat. Fys.
11//! Medd. Dan. Vid. Selsk. 28, no. 8 (1954)).
12//!
13//! # Sources and what was and was not read
14//!
15//! Lindhard (1954) was **not opened** (no open copy was found). The function
16//! is implemented in its standard closed form, written here in the variables
17//! `z = q/(2 k_F)` and `u = ω/(q k_F)`:
18//!
19//! ```text
20//! Re ε(q, ω) = 1 + (k_TF²/q²) [ 1/2 + (A(z - u) + A(z + u)) / (8 z) ],
21//! A(s) = (1 - s²) ln|(s + 1)/(s - 1)|,      k_TF² = 4 k_F/π,
22//! Im ε(q, ω) = A_c(q, ω) / q³,
23//! A_c = (k_F² - a²)₊ - (k_F² - (a + q)²)₊,    a = ω/q - q/2,    ω > 0.
24//! ```
25//!
26//! These were compared with an opened source: A. V. Latyshev and A. A.
27//! Yushkanov, arXiv:1212.6260v1 (open-access preprint, PDF pages read
28//! 2026-10-07), section 5 "Degenerate plasma": the integral `B(k, z)` on
29//! p. 14 (`∝ ∫_{-1}^{1} (1 - P_x²) dP_x / ((P_x - z/k)² - (k/2)²)`, with
30//! `k = q/k_F`, `z = (ω + iν)/(k_F v_F)`), its closed form `b(k, z)` on
31//! pp. 14-15, and the dielectric function eq. (5.5″) on p. 15,
32//! `ε = 1 - (3 x_p²/4k²)(x + iy) b(k,z) b(k,0) / (x b(k,0) + i y b(k,z))`
33//! with `x_p = ω_p/(k_F v_F)`. Its abstract states that for collision
34//! frequency `ν -> 0` (`y -> 0`) the result is Lindhard's formula, which is
35//! then `ε = 1 - (3 x_p²/4k²) b(k, x)`. Compared numerically (atomic units,
36//! `v_F = k_F`, `ω_p² = 4 k_F³/(3π)`) at six `(k_F, q, ω)` points:
37//!
38//! * the real part above equals the closed form `b(k, z)` of p. 15 to
39//!   1e-10 (the `-2` and the two `ln` terms; their `ln` arguments are the
40//!   ones of `A(z ∓ u)` combined);
41//! * the imaginary part equals the residue (`+i0`) contribution of the
42//!   p. 14 integral at its two poles `P_x = z/k ± k/2` when they lie in
43//!   `[-1, 1]`, i.e. our `A_c/q³`, to 1e-15 (the printed closed form `b`
44//!   gives only the real part under principal-branch logarithms, so the
45//!   imaginary part is compared through the integral, not the closed form).
46//!
47//! This is a comparison with a secondary source that derives the formula, not
48//! with Lindhard's paper; the original equation numbers are not known here.
49//! The further checks are:
50//!
51//! * the imaginary part is derived here directly from the Fermi golden rule
52//!   (`Im ε = (4π/q²) π · 2 ∫ d³k/(2π)³ f_k (1 - f_{k+q}) δ(ω - ΔE)`, an
53//!   elementary Fermi-sphere area), and the unit tests check the real part
54//!   against it by a **Kramers-Kronig integral**;
55//! * the **static limit** `ε(q, 0) -> 1 + k_TF²/q²` for `q -> 0`
56//!   (Thomas-Fermi screening) and the exact static form
57//!   `1 + (k_TF²/q²)(1/2 + (1 - z²)/(4z) ln|(1+z)/(1-z)|)`;
58//! * the **long-wavelength limit** `ε(q -> 0, ω) -> 1 - ω_p²/ω²`;
59//! * the **f-sum rule** `∫ ω Im[-1/ε] dω = (π/2) ω_p²` at every `q`
60//!   (plasmon delta function included), the property Penn's algorithm relies
61//!   on (it is what makes the expanded ELF satisfy the optical f-sum rule).
62//!
63//! The plasma frequency and Fermi wavenumber are related by
64//! `ω_p² = 4π n`, `n = k_F³/(3π²)`, i.e. `k_F = (3π/4)^(1/3) ω_p^(2/3)`
65//! (S2017 below eq. (8)).
66//!
67//! # The loss function
68//!
69//! For `ω > 0`, `Im[-1/ε] = Im ε / |ε|²` inside the electron-hole continuum
70//! (`|a| < k_F`) and, above the continuum, a delta function at the plasmon
71//! energy `ω_pl(q)` (the root of `Re ε = 0` above the continuum edge
72//! `ω_+ = q k_F + q²/2`) of weight `π / (∂Re ε/∂ω)`. That weight is the one
73//! of S2017 eq. (7) at `q -> 0`: with `Re ε = 1 - ω_p²/ω²`, `∂Re ε/∂ω =
74//! 2/ω_p` at `ω = ω_p` and the weight is `π ω_p/2`.
75
76use super::quadrature::{integrate_segments, GaussLegendre};
77use std::f64::consts::PI;
78
79/// `(3π/4)^(1/3)`: `k_F = KF_COEFFICIENT · ω_p^(2/3)`.
80pub(crate) fn kf_coefficient() -> f64 {
81    (0.75 * PI).cbrt()
82}
83
84/// `k_F(ω_p)` in atomic units.
85pub fn fermi_wavenumber_au(plasma_frequency: f64) -> f64 {
86    kf_coefficient() * plasma_frequency.cbrt() * plasma_frequency.cbrt()
87}
88
89/// `(1 - s²) ln|(s + 1)/(s - 1)|`, zero at `s = ±1` (the limit).
90fn a_fn(s: f64) -> f64 {
91    let d = 1.0 - s * s;
92    if d == 0.0 {
93        0.0
94    } else {
95        d * ((s + 1.0) / (s - 1.0)).abs().ln()
96    }
97}
98
99/// The bracket `1/2 + (A(z - u) + A(z + u))/(8z)` of `Re ε`.
100///
101/// For `u - z > 4` (far above the continuum) the direct form loses all its
102/// digits to cancellation (the bracket is `O(1/u²)` but its terms are
103/// `O(u/z)`), so the expansion of `A(s) = -2s + Σ_k 4 s^-(2k+1)/((2k+1)(2k+3))`
104/// (from `ln|(s+1)/(s-1)| = 2 Σ_k s^-(2k+1)/(2k+1)`, `|s| > 1`) is used; with
105/// `A` odd, the bracket is `-(1/4) Σ_k c_k S_m/(u² - z²)^m`, `m = 2k+1`,
106/// `c_k = 4/((2k+1)(2k+3))` and `S_m = Σ_j (u+z)^(m-1-j) (u-z)^j`, every term
107/// positive. The unit tests compare both forms where they overlap.
108fn shape(z: f64, u: f64) -> f64 {
109    if u - z > 4.0 {
110        shape_series(z, u)
111    } else {
112        shape_direct(z, u)
113    }
114}
115
116fn shape_direct(z: f64, u: f64) -> f64 {
117    0.5 + (a_fn(z - u) + a_fn(z + u)) / (8.0 * z)
118}
119
120fn shape_series(z: f64, u: f64) -> f64 {
121    let (hi, lo) = (u + z, u - z);
122    let d = hi * lo;
123    let mut sum = 0.0;
124    let mut dpow = d; // (u² - z²)^m
125    for k in 0..200 {
126        let m = 2 * k + 1;
127        let mut sm = 0.0;
128        for j in 0..m {
129            sm += hi.powi(m - 1 - j) * lo.powi(j);
130        }
131        let ck = 4.0 / ((2 * k + 1) as f64 * (2 * k + 3) as f64);
132        let term = ck * sm / dpow;
133        sum += term;
134        if term <= 1e-17 * sum {
135            break;
136        }
137        dpow *= d * d;
138    }
139    -0.25 * sum
140}
141
142/// `∂(shape)/∂u = (A'(z + u) - A'(z - u))/(8z)`; for `u - z > 4` the
143/// series `(1/4) Σ_k c_k m S_{m+1}/(u² - z²)^(m+1)` (all terms positive, from
144/// `A'(s) = -2 - Σ_k c_k m s^-(m+1)`), otherwise the direct form.
145fn shape_du(z: f64, u: f64) -> f64 {
146    if u - z <= 4.0 {
147        return (a_prime(z + u) - a_prime(z - u)) / (8.0 * z);
148    }
149    let (hi, lo) = (u + z, u - z);
150    let d = hi * lo;
151    let mut sum = 0.0;
152    let mut dpow = d * d; // (u² - z²)^(m+1)
153    for k in 0..200 {
154        let m = 2 * k + 1;
155        let n = m + 1;
156        let mut sn = 0.0;
157        for j in 0..n {
158            sn += hi.powi(n - 1 - j) * lo.powi(j);
159        }
160        let ck = 4.0 / ((2 * k + 1) as f64 * (2 * k + 3) as f64);
161        let term = ck * m as f64 * sn / dpow;
162        sum += term;
163        if term <= 1e-17 * sum {
164            break;
165        }
166        dpow *= d * d;
167    }
168    0.25 * sum
169}
170
171/// `dA/ds = 2 - 2 s ln|(s+1)/(s-1)|`.
172fn a_prime(s: f64) -> f64 {
173    2.0 - 2.0 * s * ((s + 1.0) / (s - 1.0)).abs().ln()
174}
175
176/// The undamped plasmon of a Lindhard gas at one momentum.
177#[derive(Debug, Clone, Copy, PartialEq)]
178pub struct LindhardPlasmon {
179    /// Plasmon energy `ω_pl(q)`, Hartree.
180    pub energy: f64,
181    /// Weight of the delta function in `Im[-1/ε]`: `π/(∂Re ε/∂ω)`, Hartree.
182    pub weight: f64,
183}
184
185/// A free-electron gas of Fermi wavenumber `k_F` (bohr⁻¹), zero temperature.
186#[derive(Debug, Clone, Copy, PartialEq)]
187pub struct LindhardGas {
188    kf: f64,
189}
190
191impl LindhardGas {
192    /// The gas with Fermi wavenumber `kf > 0` (atomic units).
193    pub fn from_fermi_wavenumber(kf: f64) -> Self {
194        debug_assert!(kf > 0.0 && kf.is_finite());
195        Self { kf }
196    }
197
198    /// The gas with plasma frequency `wp > 0` (Hartree; `ω_p² = 4π n`).
199    pub fn from_plasma_frequency(wp: f64) -> Self {
200        Self::from_fermi_wavenumber(fermi_wavenumber_au(wp))
201    }
202
203    /// `k_F`, bohr⁻¹.
204    pub fn fermi_wavenumber(&self) -> f64 {
205        self.kf
206    }
207
208    /// `ω_p = sqrt(4 k_F³/(3π))`, Hartree.
209    pub fn plasma_frequency(&self) -> f64 {
210        (4.0 * self.kf * self.kf * self.kf / (3.0 * PI)).sqrt()
211    }
212
213    /// `k_TF² = 4 k_F/π`, bohr⁻².
214    pub fn thomas_fermi_wavenumber_sq(&self) -> f64 {
215        4.0 * self.kf / PI
216    }
217
218    /// The upper edge of the electron-hole continuum, `q k_F + q²/2`.
219    pub fn continuum_upper_edge(&self, q: f64) -> f64 {
220        q * self.kf + 0.5 * q * q
221    }
222
223    /// `Re ε(q, ω)` for `q > 0`, `ω >= 0`.
224    pub fn re_epsilon(&self, q: f64, w: f64) -> f64 {
225        let z = q / (2.0 * self.kf);
226        let u = w / (q * self.kf);
227        1.0 + self.thomas_fermi_wavenumber_sq() / (q * q) * shape(z, u)
228    }
229
230    /// `∂Re ε/∂ω` (valid off the singular points `|z ± u| = 1`).
231    fn d_re_epsilon_dw(&self, q: f64, w: f64) -> f64 {
232        let z = q / (2.0 * self.kf);
233        let u = w / (q * self.kf);
234        let df = shape_du(z, u);
235        self.thomas_fermi_wavenumber_sq() / (q * q) * df / (q * self.kf)
236    }
237
238    /// `Im ε(q, ω)` for `q > 0`, `ω > 0`: zero outside the continuum.
239    pub fn im_epsilon(&self, q: f64, w: f64) -> f64 {
240        let kf2 = self.kf * self.kf;
241        let a = w / q - 0.5 * q;
242        let b = w / q + 0.5 * q;
243        let area = (kf2 - a * a).max(0.0) - (kf2 - b * b).max(0.0);
244        area / (q * q * q)
245    }
246
247    /// `ε(q, ω)` as `(Re, Im)`.
248    pub fn epsilon(&self, q: f64, w: f64) -> (f64, f64) {
249        (self.re_epsilon(q, w), self.im_epsilon(q, w))
250    }
251
252    /// The continuum part of the loss function `Im[-1/ε(q, ω)]` (zero
253    /// outside the electron-hole continuum; the plasmon delta function is
254    /// separate, see [`Self::plasmon`]).
255    pub fn loss_continuum(&self, q: f64, w: f64) -> f64 {
256        let im = self.im_epsilon(q, w);
257        if im <= 0.0 {
258            return 0.0;
259        }
260        let re = self.re_epsilon(q, w);
261        im / (re * re + im * im)
262    }
263
264    /// Whether [`Self::plasmon`] finds a plasmon at `q`: the sign test at the
265    /// head of that method, without the root search (a single evaluation of
266    /// `Re ε`).
267    pub(crate) fn plasmon_exists(&self, q: f64) -> bool {
268        if !(q > 0.0 && q.is_finite()) {
269            return false;
270        }
271        let lo0 = self.continuum_upper_edge(q) * (1.0 + 1e-12);
272        self.re_epsilon(q, lo0) < 0.0
273    }
274
275    /// Whether the plasmon at `q` exists and has energy at least `w`, without
276    /// solving for the plasmon energy. Above the continuum edge `Re ε` rises
277    /// monotonically from a negative value to 1, so the plasmon energy is
278    /// `>= w` exactly when `Re ε(q, w) <= 0` (and always, for `w` at or below
279    /// the edge).
280    pub(crate) fn plasmon_reaches(&self, q: f64, w: f64) -> bool {
281        if !self.plasmon_exists(q) {
282            return false;
283        }
284        w <= self.continuum_upper_edge(q) * (1.0 + 1e-12) || self.re_epsilon(q, w) <= 0.0
285    }
286
287    /// Whether the plasmon at `q` exists and has energy below `w`; the
288    /// complement of [`Self::plasmon_reaches`] among the `q` with a plasmon.
289    pub(crate) fn plasmon_below(&self, q: f64, w: f64) -> bool {
290        self.plasmon_exists(q) && !self.plasmon_reaches(q, w)
291    }
292
293    /// The plasmon at momentum `q`, if it exists (above the continuum).
294    pub fn plasmon(&self, q: f64) -> Option<LindhardPlasmon> {
295        if !(q > 0.0 && q.is_finite()) {
296            return None;
297        }
298        let edge = self.continuum_upper_edge(q);
299        let lo0 = edge * (1.0 + 1e-12);
300        if self.re_epsilon(q, lo0) >= 0.0 {
301            return None;
302        }
303        // Re ε increases from negative at the edge to 1 at infinity.
304        let mut hi = 2.0 * (edge + self.plasma_frequency());
305        for _ in 0..80 {
306            if self.re_epsilon(q, hi) > 0.0 {
307                break;
308            }
309            hi *= 2.0;
310        }
311        let (mut lo, mut x) = (lo0, 0.5 * (lo0 + hi));
312        for _ in 0..200 {
313            let f = self.re_epsilon(q, x);
314            if f < 0.0 {
315                lo = x;
316            } else {
317                hi = x;
318            }
319            let d = self.d_re_epsilon_dw(q, x);
320            let newton = x - f / d;
321            let next = if newton > lo && newton < hi {
322                newton
323            } else {
324                0.5 * (lo + hi)
325            };
326            let done = (next - x).abs() <= 1e-15 * next.abs();
327            x = next;
328            if done || hi - lo <= 1e-15 * hi {
329                break;
330            }
331        }
332        let d = self.d_re_epsilon_dw(q, x);
333        (d > 0.0).then(|| LindhardPlasmon {
334            energy: x,
335            weight: PI / d,
336        })
337    }
338
339    /// The plasmon cutoff momentum `q_c`: the plasmon exists for `q < q_c`
340    /// (it enters the continuum there and is Landau damped).
341    pub fn plasmon_cutoff(&self) -> f64 {
342        let exists =
343            |q: f64| self.re_epsilon(q, self.continuum_upper_edge(q) * (1.0 + 1e-12)) < 0.0;
344        let mut hi = 1e-3 * self.kf;
345        while exists(hi) && hi < 1e6 * self.kf {
346            hi *= 2.0;
347        }
348        let mut lo = 0.5 * hi;
349        if !exists(lo) {
350            return lo; // pathological: no plasmon at all
351        }
352        for _ in 0..100 {
353            let mid = 0.5 * (lo + hi);
354            if exists(mid) {
355                lo = mid;
356            } else {
357                hi = mid;
358            }
359            if hi - lo <= 1e-15 * hi {
360                break;
361            }
362        }
363        0.5 * (lo + hi)
364    }
365
366    /// Break points in `ω` (ascending, inside `(0, wcut)`) where the
367    /// continuum loss function has a kink.
368    fn continuum_breaks(&self, q: f64, wcut: f64) -> Vec<f64> {
369        let h = 0.5 * q * q;
370        let qk = q * self.kf;
371        let mut v = vec![0.0];
372        for p in [h - qk, qk - h, h + qk] {
373            if p > 0.0 && p < wcut {
374                v.push(p);
375            }
376        }
377        v.push(wcut);
378        v.sort_by(f64::total_cmp);
379        v.dedup();
380        v
381    }
382
383    /// `[∫_0^{wcut} Im[-1/ε] dω, ∫_0^{wcut} ω Im[-1/ε] dω]` at momentum `q`,
384    /// the plasmon delta function included, with the adaptive integrator's
385    /// relative tolerance `rel_tol`.
386    pub(crate) fn loss_moments(
387        &self,
388        gl: &GaussLegendre,
389        q: f64,
390        wcut: f64,
391        rel_tol: f64,
392    ) -> [f64; 2] {
393        if wcut.is_nan() || wcut <= 0.0 {
394            return [0.0; 2];
395        }
396        let breaks = self.continuum_breaks(q, wcut);
397        let mut r = integrate_segments(
398            gl,
399            &mut |w: f64| {
400                let l = self.loss_continuum(q, w);
401                [l, w * l]
402            },
403            &breaks,
404            rel_tol,
405        );
406        // the root search for the plasmon only if it is inside the range
407        if self.plasmon_below(q, wcut) {
408            if let Some(p) = self.plasmon(q) {
409                if p.energy < wcut {
410                    r[0] += p.weight;
411                    r[1] += p.weight * p.energy;
412                }
413            }
414        }
415        r
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    fn gas() -> LindhardGas {
424        LindhardGas::from_plasma_frequency(0.7)
425    }
426
427    #[test]
428    fn kf_and_plasma_frequency_are_consistent() {
429        let wp = 0.7;
430        let g = LindhardGas::from_plasma_frequency(wp);
431        assert!((g.plasma_frequency() / wp - 1.0).abs() < 1e-14);
432        // ω_p² = 4π n with n = k_F³/(3π²)
433        let n = g.kf.powi(3) / (3.0 * PI * PI);
434        assert!((wp * wp / (4.0 * PI * n) - 1.0).abs() < 1e-14);
435        // k_TF² = 3 ω_p²/k_F² (= 4 k_F/π)
436        let tf = 3.0 * wp * wp / (g.kf * g.kf);
437        assert!((g.thomas_fermi_wavenumber_sq() / tf - 1.0).abs() < 1e-14);
438    }
439
440    /// Static limit: Thomas-Fermi screening, and the exact static form.
441    #[test]
442    fn static_limit_is_thomas_fermi_screening() {
443        let g = gas();
444        let q = 1e-3;
445        let tf = 1.0 + g.thomas_fermi_wavenumber_sq() / (q * q);
446        let re = g.re_epsilon(q, 0.0);
447        assert!((re / tf - 1.0).abs() < 1e-5, "{re} vs {tf}");
448        // exact static form, several q (including q > 2 k_F)
449        for q in [0.3, 1.0, 1.7, 3.0, 5.0] {
450            let z = q / (2.0 * g.kf);
451            let want = 1.0
452                + g.thomas_fermi_wavenumber_sq() / (q * q)
453                    * (0.5 + (1.0 - z * z) / (4.0 * z) * ((1.0 + z) / (1.0 - z)).abs().ln());
454            let got = g.re_epsilon(q, 0.0);
455            assert!((got / want - 1.0).abs() < 1e-12, "q={q}: {got} vs {want}");
456        }
457        assert!(g.im_epsilon(0.5, 1e-12) < 1e-9);
458    }
459
460    /// Long-wavelength limit: ε -> 1 - ω_p²/ω² (Drude).
461    #[test]
462    fn long_wavelength_limit_is_drude() {
463        let g = gas();
464        let wp = g.plasma_frequency();
465        for w in [1.5 * wp, 3.0 * wp, 0.8 * wp] {
466            let q = 1e-3;
467            let want = 1.0 - wp * wp / (w * w);
468            let got = g.re_epsilon(q, w);
469            assert!((got - want).abs() < 1e-5, "w={w}: {got} vs {want}");
470            assert_eq!(g.im_epsilon(q, w), 0.0);
471        }
472        // the plasmon sits at ω_p with weight π ω_p/2 as q -> 0
473        let p = g.plasmon(1e-3).unwrap();
474        assert!((p.energy / wp - 1.0).abs() < 1e-4, "{}", p.energy);
475        assert!(
476            (p.weight / (0.5 * PI * wp) - 1.0).abs() < 1e-3,
477            "{}",
478            p.weight
479        );
480    }
481
482    /// The first correction to the long-wavelength limit (Bohm-Pines):
483    /// ω_pl² = ω_p² + (3/5) k_F² q² + O(q⁴).
484    #[test]
485    fn plasmon_dispersion_starts_as_bohm_pines() {
486        let g = gas();
487        let (wp, kf) = (g.plasma_frequency(), g.kf);
488        let q = 0.02;
489        let p = g.plasmon(q).unwrap();
490        let want = (wp * wp + 0.6 * kf * kf * q * q).sqrt();
491        assert!(
492            (p.energy / want - 1.0).abs() < 2e-4,
493            "{} vs {want}",
494            p.energy
495        );
496    }
497
498    #[test]
499    fn large_u_series_matches_the_direct_form_where_both_are_accurate() {
500        for (z, u) in [(0.3, 4.5), (0.5, 6.0), (1.0, 5.5), (0.05, 5.0)] {
501            let (a, b) = (shape_series(z, u), shape_direct(z, u));
502            assert!(
503                (a - b).abs() < 1e-10 * a.abs().max(1e-3),
504                "{z} {u}: {a} vs {b}"
505            );
506        }
507    }
508
509    #[test]
510    fn shape_derivative_matches_a_finite_difference() {
511        for (z, u) in [(0.3, 4.5), (0.5, 6.0), (0.05, 2.0), (0.8, 1.2), (0.2, 9.0)] {
512            let h = 1e-5 * u;
513            let fd = (shape(z, u + h) - shape(z, u - h)) / (2.0 * h);
514            let an = shape_du(z, u);
515            assert!((fd / an - 1.0).abs() < 1e-6, "{z} {u}: {fd} vs {an}");
516        }
517    }
518
519    #[test]
520    fn continuum_has_the_known_low_frequency_form() {
521        // ω -> 0, q < 2 k_F: Im ε = 2ω/q³ (the Fermi-sphere area is q(2a+q) = 2ω).
522        let g = gas();
523        let (q, w) = (0.4, 1e-4);
524        assert!((g.im_epsilon(q, w) / (2.0 * w / q.powi(3)) - 1.0).abs() < 1e-12);
525        // no absorption above the upper edge
526        assert_eq!(g.im_epsilon(q, g.continuum_upper_edge(q) * 1.0001), 0.0);
527    }
528
529    /// Kramers-Kronig: Re ε(q, ω) - 1 = (2/π) P∫ ω' Im ε(q, ω')/(ω'² - ω²) dω'.
530    /// This checks the closed-form real part against the Fermi-golden-rule
531    /// imaginary part, independently.
532    #[test]
533    fn real_part_obeys_kramers_kronig_against_the_imaginary_part() {
534        let g = gas();
535        let gl = GaussLegendre::new(8);
536        for (q, w) in [(0.3, 0.2), (0.8, 1.9), (1.5, 0.4), (2.5, 3.0), (0.5, 0.05)] {
537            // Subtract the singular point: P∫ f(ω')/(ω'²-ω²) with
538            // f = ω' Im ε(ω') -> ∫ [f(ω') - f(ω)]/(ω'²-ω²) dω' + f(ω) P∫ 1/(ω'²-ω²),
539            // and P∫_0^Λ dω'/(ω'²-ω²) = (1/(2ω)) ln|(Λ-ω)/(Λ+ω)|.
540            let lam = g.continuum_upper_edge(q) * 1.0 + 1e-300;
541            let f = |x: f64| x * g.im_epsilon(q, x);
542            let fw = f(w);
543            let mut breaks = g.continuum_breaks(q, lam);
544            if w < lam && !breaks.contains(&w) {
545                breaks.push(w);
546                breaks.sort_by(f64::total_cmp);
547            }
548            let sub = integrate_segments(
549                &gl,
550                &mut |x: f64| {
551                    let d = x * x - w * w;
552                    if d.abs() < 1e-14 {
553                        [0.0]
554                    } else {
555                        [(f(x) - fw) / d]
556                    }
557                },
558                &breaks,
559                1e-10,
560            )[0];
561            let pv = if w < lam {
562                fw * (1.0 / (2.0 * w)) * ((lam - w) / (lam + w)).ln()
563            } else {
564                0.0
565            };
566            let kk = 1.0 + (2.0 / PI) * (sub + pv);
567            let re = g.re_epsilon(q, w);
568            assert!(
569                (kk - re).abs() < 2e-5 * (1.0 + re.abs()),
570                "q={q} w={w}: KK {kk} vs {re}"
571            );
572        }
573    }
574
575    /// f-sum rule of one Lindhard gas, plasmon included, at several q.
576    #[test]
577    fn single_gas_f_sum_rule_holds_at_every_q() {
578        let g = gas();
579        let gl = GaussLegendre::new(8);
580        let want = 0.5 * PI * g.plasma_frequency().powi(2);
581        // q well below, near and above the plasmon cutoff, and above 2 k_F
582        let qc = g.plasmon_cutoff();
583        assert!(qc > 0.0 && qc < 2.0 * g.kf, "qc = {qc}");
584        for q in [
585            0.01,
586            0.3 * qc,
587            0.9 * qc,
588            1.2 * qc,
589            2.0 * qc,
590            2.5 * g.kf,
591            5.0,
592        ] {
593            let wcut = 20.0 * (g.continuum_upper_edge(q) + g.plasma_frequency());
594            let [_, s1] = g.loss_moments(&gl, q, wcut, 1e-10);
595            assert!((s1 / want - 1.0).abs() < 1e-6, "q = {q}: {s1} vs {want}");
596        }
597    }
598
599    /// The sign tests used by the full Penn searches agree with the root
600    /// search of `plasmon` (away from the root itself).
601    #[test]
602    fn plasmon_predicates_agree_with_the_root_search() {
603        let g = gas();
604        let qc = g.plasmon_cutoff();
605        for q in [1e-3, 0.2 * qc, 0.7 * qc, 0.99 * qc, 1.01 * qc, 3.0 * qc] {
606            let p = g.plasmon(q);
607            assert_eq!(g.plasmon_exists(q), p.is_some(), "q = {q}");
608            for f in [0.5, 0.9, 0.999, 1.001, 1.1, 2.0] {
609                let w = f * p.map_or(g.continuum_upper_edge(q), |p| p.energy);
610                let reaches = p.is_some_and(|p| p.energy >= w);
611                assert_eq!(g.plasmon_reaches(q, w), reaches, "q = {q}, w = {w}");
612                assert_eq!(
613                    g.plasmon_below(q, w),
614                    p.is_some_and(|p| p.energy < w),
615                    "q = {q}, w = {w}"
616                );
617            }
618        }
619        assert!(!g.plasmon_exists(0.0) && !g.plasmon_exists(f64::NAN));
620    }
621
622    #[test]
623    fn plasmon_cutoff_is_where_the_plasmon_meets_the_continuum() {
624        let g = gas();
625        let qc = g.plasmon_cutoff();
626        assert!(g.plasmon(0.999 * qc).is_some());
627        assert!(g.plasmon(1.001 * qc).is_none());
628        let p = g.plasmon(0.999999 * qc).unwrap();
629        assert!((p.energy / g.continuum_upper_edge(qc) - 1.0).abs() < 1e-3);
630    }
631}