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}