Skip to main content

lindhard/electron/inelastic/
mermin.rs

1//! The Mermin dielectric function of a free-electron gas, and the Mermin-ELF
2//! inelastic model built from a fit of oscillators to an optical ELF.
3//!
4//! # Sources and what was and was not read
5//!
6//! Mermin's relaxation-time dielectric function is N. D. Mermin, "Lindhard
7//! dielectric function in the relaxation-time approximation", Phys. Rev. B 1,
8//! 2362 (1970), doi:10.1103/PhysRevB.1.2362. That paper is closed access and
9//! **was not opened**, so its own equation numbers are not known here. The
10//! formula is taken from the account in P. de Vera, S. Taioli, P. E.
11//! Trevisanutto, M. Dapor, I. Abril, S. Simonucci and R. Garcia-Molina,
12//! Int. J. Mol. Sci. 23, 6121 (2022), doi:10.3390/ijms23116121 (open access,
13//! PMC9181504, "dV2022", read 2026-10-07), section 2.1.1, whose eq. (4)
14//! cites Mermin (1970) as its reference 33:
15//!
16//! ```text
17//!                     (1 + iγ/ω) [ε_L(q, ω + iγ) - 1]
18//! ε_M(q, ω) = 1 + ---------------------------------------------   (dV2022 eq. (4))
19//!                  1 + (iγ/ω) [ε_L(q, ω + iγ) - 1] / [ε_L(q, 0) - 1]
20//! ```
21//!
22//! (energies `ħω`, `ħγ`; `ε_L` the Lindhard function,
23//! [`super::lindhard_gas`]). The Mermin-ELF (MELF) of the MELF-GOS method is
24//! (dV2022 eq. (3), without the threshold step `Θ(E - E_th)`)
25//!
26//! ```text
27//! Im[-1/ε(q, ω)] = Σ_j (A_j/(ħω_j)²) Im[-1/ε_M(q, ω; ω_j, γ_j)],
28//! ```
29//!
30//! where `ε_M(q, ω; ω_j, γ_j)` uses the Lindhard function of a gas of plasma
31//! frequency `ω_j`, and `A_j`, `ω_j`, `γ_j` are fitted to the optical ELF by
32//! dV2022 eq. (5) (see [`super::mermin_fit`]); at `q = 0` the Mermin-type
33//! ELF is the Drude-Lorentz one ([`super::drude`]).
34//!
35//! Checks made here instead of a comparison with Mermin's own paper (all in
36//! the unit tests):
37//!
38//! * `γ -> 0` gives the Lindhard function to 1e-8 (the analytic
39//!   continuation of the Lindhard function to complex frequency is checked
40//!   against the real-frequency form of [`super::lindhard_gas`]);
41//! * `q -> 0` gives the Drude function `1 - ω_p²/(ω² + iγω)`;
42//! * `ω -> 0` gives the static Lindhard function `ε_L(q, 0)` (the
43//!   local-equilibrium property that motivates the formula);
44//! * the f-sum rule `∫ ω Im[-1/ε_M] dω = (π/2) ω_p²` at finite `q`. We did
45//!   not find it stated in the sources read, so it is a numerical check of the
46//!   transcription, not an input.
47//!
48//! The independent algebraic form of Latyshev and Yushkanov, arXiv:1212.6260
49//! (eq. (5.5″), read as described in [`super::lindhard_gas`]), is the same
50//! function with `x + iy` for `ω + iγ`.
51//!
52//! # Complex-frequency Lindhard function
53//!
54//! `ε_L(q, ω) = 1 + (k_TF²/q²) [1/2 + (A(z - u) + A(z + u))/(8z)]` with
55//! `A(s) = (1 - s²) Log((s + 1)/(s - 1))` (principal logarithm of the ratio,
56//! analytic off `[-1, 1]` and vanishing at infinity) and `u = (ω + iγ)/(q
57//! k_F)`. For `γ -> 0+` this gives the real-frequency Lindhard function with
58//! the electron-hole continuum as its imaginary part. When `|z ± u| > 4` the
59//! bracket is evaluated from the same series as the real-frequency code
60//! (every term of which is `O(1/|s|^m)`), avoiding cancellation.
61//!
62//! # The model
63//!
64//! [`MerminPenn`] uses the MELF loss function in the DIIMFP of the same
65//! nonrelativistic kinematics as the Penn models ([`super::penn`],
66//! S2017 eqs. (2)-(3)):
67//!
68//! ```text
69//! p(T, ω) = (1/(π T')) ∫_{q-}^{q+} (dq/q) Im[-1/ε(q, ω)],
70//! ```
71//!
72//! and integrates it over `ω` (in `ln ω`, from `1e-8 T` to `T`) for the
73//! inverse IMFP and the stopping power. This is our own evaluation of a
74//! published construction; the omission of the threshold step is a choice
75//! documented in `docs/data-provenance.md` (a fitted Drude tail extends below
76//! any real band gap).
77//!
78//! # Comparison with the single-pole model (synthetic Drude fixture)
79//!
80//! For the synthetic Drude plasmon of the tests (`E_p = 20 eV`, `γ = 5 eV`,
81//! 240 tabulated energies; the fit returns it to 1e-13), ratios Mermin /
82//! single-pole of the stopping power `S` and of the inverse IMFP `1/λ`
83//! (default tolerances, release build; outputs of this code, not reference
84//! values):
85//!
86//! ```text
87//!    E / eV     S ratio   1/λ ratio
88//!      30.0      1.9222     2.0818
89//!     100.0      0.9832     1.0235
90//!    1000.0      1.0002     1.0173
91//!   10000.0      0.9991     1.0121
92//!   50000.0      0.9988     1.0100
93//! ```
94//!
95//! The stopping powers agree to 2e-3 from 1 keV (both obey the f-sum);
96//! below about 100 eV the models differ strongly, as the full Penn model does
97//! from the single pole (see [`super::full_penn`]). Nothing was tuned.
98
99use super::drude::DrudeLorentzOscillator;
100use super::lindhard_gas::LindhardGas;
101use super::mermin_fit::{fit_mermin_oscillators, MerminFit, MerminFitOptions};
102use super::penn::{hartree_ev, InelasticPoint};
103use super::quadrature::{integrate_segments, GaussLegendre};
104use crate::constants::BOHR_RADIUS;
105use crate::electron::data::{ElectronDataError, OpticalElf};
106use std::f64::consts::PI;
107use std::ops::{Add, Div, Mul, Sub};
108
109type Result<T> = std::result::Result<T, ElectronDataError>;
110
111/// Default relative tolerance of the integrals of the Mermin model.
112pub const DEFAULT_MERMIN_TOLERANCE: f64 = 1.0e-5;
113
114/// The lower limit of the loss integrals of the inverse IMFP and the
115/// stopping power, as a fraction of the kinetic energy `T`: they integrate
116/// `ω` over `[LOWEST_LOSS_FRACTION · T, T]` (module docs, "The model"). A
117/// numerical lower limit of the integration in `ln ω`, not a physical
118/// threshold (the fitted Drude-Lorentz ELF has none, module docs). The
119/// inelastic table opens the sampled loss window of a Mermin
120/// row at the same limit ([`super::table`], "Rows below the ELF table"), so
121/// the stored rate and the sampled losses cover one window (#342).
122pub(crate) const LOWEST_LOSS_FRACTION: f64 = 1.0e-8;
123
124const GL_ORDER: usize = 5;
125
126// ---- a minimal complex number (no dependency) ----
127
128#[derive(Debug, Clone, Copy, PartialEq)]
129struct C {
130    re: f64,
131    im: f64,
132}
133
134impl C {
135    const fn new(re: f64, im: f64) -> Self {
136        Self { re, im }
137    }
138    fn real(re: f64) -> Self {
139        Self { re, im: 0.0 }
140    }
141    fn norm_sq(self) -> f64 {
142        self.re * self.re + self.im * self.im
143    }
144    fn abs(self) -> f64 {
145        self.re.hypot(self.im)
146    }
147    fn ln(self) -> Self {
148        Self::new(self.abs().ln(), self.im.atan2(self.re))
149    }
150    fn scale(self, k: f64) -> Self {
151        Self::new(self.re * k, self.im * k)
152    }
153}
154
155impl Add for C {
156    type Output = C;
157    fn add(self, o: C) -> C {
158        C::new(self.re + o.re, self.im + o.im)
159    }
160}
161impl Sub for C {
162    type Output = C;
163    fn sub(self, o: C) -> C {
164        C::new(self.re - o.re, self.im - o.im)
165    }
166}
167impl Mul for C {
168    type Output = C;
169    fn mul(self, o: C) -> C {
170        C::new(
171            self.re * o.re - self.im * o.im,
172            self.re * o.im + self.im * o.re,
173        )
174    }
175}
176impl Div for C {
177    type Output = C;
178    fn div(self, o: C) -> C {
179        let d = o.norm_sq();
180        C::new(
181            (self.re * o.re + self.im * o.im) / d,
182            (self.im * o.re - self.re * o.im) / d,
183        )
184    }
185}
186
187/// `A(s) = (1 - s²) Log((s + 1)/(s - 1))`, zero at `s = ±1`.
188fn a_complex(s: C) -> C {
189    let one = C::real(1.0);
190    let d = one - s * s;
191    if d.re == 0.0 && d.im == 0.0 {
192        return C::real(0.0);
193    }
194    d * ((s + one) / (s - one)).ln()
195}
196
197/// The bracket `1/2 + (A(z - u) + A(z + u))/(8z)` for complex `u`.
198fn bracket(z: f64, u: C) -> C {
199    let hi = u + C::real(z);
200    let lo = u - C::real(z);
201    if hi.abs() > 4.0 && lo.abs() > 4.0 {
202        return bracket_series(hi, lo);
203    }
204    let half = C::real(0.5);
205    half + (a_complex(C::real(z) - u) + a_complex(C::real(z) + u)).scale(1.0 / (8.0 * z))
206}
207
208/// `-(1/4) Σ_k c_k S_m/(u² - z²)^m`, `m = 2k + 1`, `c_k = 4/((2k+1)(2k+3))`,
209/// `S_m = Σ_j hi^(m-1-j) lo^j` (`hi = u + z`, `lo = u - z`), from the
210/// expansion of `A` for `|s| > 1` (see `lindhard_gas::shape`).
211fn bracket_series(hi: C, lo: C) -> C {
212    let d = hi * lo;
213    let mut sum = C::real(0.0);
214    let mut s_m = C::real(1.0); // S_1
215    let mut lo_pow = lo; // lo^m
216    let mut d_pow = d; // d^m
217    for m in 1..=400usize {
218        if m % 2 == 1 {
219            let k = (m / 2) as f64;
220            let ck = 4.0 / ((2.0 * k + 1.0) * (2.0 * k + 3.0));
221            let term = (s_m / d_pow).scale(ck);
222            sum = sum + term;
223            if term.abs() <= 1e-17 * sum.abs() {
224                break;
225            }
226        }
227        // S_{m+1} = hi S_m + lo^m
228        s_m = hi * s_m + lo_pow;
229        lo_pow = lo_pow * lo;
230        d_pow = d_pow * d;
231    }
232    sum.scale(-0.25)
233}
234
235/// The Lindhard dielectric function at complex frequency, atomic units.
236fn lindhard_complex(gas: &LindhardGas, q: f64, w: C) -> C {
237    let kf = gas.fermi_wavenumber();
238    let z = q / (2.0 * kf);
239    let u = w.scale(1.0 / (q * kf));
240    C::real(1.0) + bracket(z, u).scale(gas.thomas_fermi_wavenumber_sq() / (q * q))
241}
242
243/// The Mermin dielectric function of a free-electron gas with a relaxation
244/// energy `ħγ` (see the module docs). Atomic units (Hartree, bohr).
245#[derive(Debug, Clone, Copy, PartialEq)]
246pub struct MerminGas {
247    gas: LindhardGas,
248    gamma: f64,
249}
250
251impl MerminGas {
252    /// The gas `gas` with relaxation energy `gamma >= 0` (Hartree).
253    /// `gamma = 0` is the Lindhard function.
254    pub fn new(gas: LindhardGas, gamma: f64) -> Result<Self> {
255        if !(gamma.is_finite() && gamma >= 0.0) {
256            return Err(ElectronDataError::Invalid {
257                what: "Mermin relaxation energy",
258                reason: format!("must be finite and non-negative, got {gamma} Hartree"),
259            });
260        }
261        Ok(Self { gas, gamma })
262    }
263
264    /// The underlying Lindhard gas.
265    pub fn gas(&self) -> &LindhardGas {
266        &self.gas
267    }
268
269    /// The relaxation energy `ħγ`, Hartree.
270    pub fn gamma(&self) -> f64 {
271        self.gamma
272    }
273
274    /// `ε_M(q, ω)` as `(Re, Im)` for `q > 0`, `ω > 0` (dV2022 eq. (4)).
275    pub fn epsilon(&self, q: f64, w: f64) -> (f64, f64) {
276        if self.gamma == 0.0 {
277            return self.gas.epsilon(q, w);
278        }
279        let l_w = lindhard_complex(&self.gas, q, C::new(w, self.gamma)) - C::real(1.0);
280        let z = q / (2.0 * self.gas.fermi_wavenumber());
281        let l_0 = bracket(z, C::real(0.0)).re * self.gas.thomas_fermi_wavenumber_sq() / (q * q);
282        let ig = C::new(0.0, self.gamma / w);
283        let num = (C::real(1.0) + ig) * l_w;
284        let den = C::real(1.0) + ig * l_w.scale(1.0 / l_0);
285        let eps = C::real(1.0) + num / den;
286        (eps.re, eps.im)
287    }
288
289    /// The loss function `Im[-1/ε_M(q, ω)] = Im ε/|ε|²`.
290    pub fn loss(&self, q: f64, w: f64) -> f64 {
291        let (re, im) = self.epsilon(q, w);
292        im / (re * re + im * im)
293    }
294}
295
296#[derive(Clone)]
297struct Term {
298    /// `A_j/(ħω_j)²`, dimensionless.
299    weight: f64,
300    gas: MerminGas,
301}
302
303/// The Mermin-ELF inelastic model: a fit of Mermin oscillators to one
304/// optical ELF, extended to finite momentum by the Mermin function, and the
305/// DIIMFP, IMFP and stopping power built from it. See the module docs.
306#[derive(Clone)]
307pub struct MerminPenn {
308    elf: OpticalElf,
309    fit: MerminFit,
310    terms: Vec<Term>,
311    fermi: f64,
312    rel_tol: f64,
313    gl: GaussLegendre,
314}
315
316impl std::fmt::Debug for MerminPenn {
317    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
318        f.debug_struct("MerminPenn")
319            .field("material", &self.elf.material())
320            .field("oscillators", &self.fit.oscillators)
321            .field("fermi_hartree", &self.fermi)
322            .field("rel_tol", &self.rel_tol)
323            .finish()
324    }
325}
326
327fn check_energy(what: &'static str, v: f64) -> Result<()> {
328    if v.is_finite() && v > 0.0 {
329        Ok(())
330    } else {
331        Err(ElectronDataError::Invalid {
332            what,
333            reason: format!("must be finite and positive, got {v} eV"),
334        })
335    }
336}
337
338impl MerminPenn {
339    /// The model of `fit`, a fit to `elf` (Fermi energy zero, default
340    /// tolerance). Oscillators of zero amplitude are carried in the fit
341    /// report but do not contribute.
342    pub fn new(elf: OpticalElf, fit: MerminFit) -> Result<Self> {
343        let h = hartree_ev();
344        let mut terms = Vec::new();
345        for o in &fit.oscillators {
346            check_oscillator(o)?;
347            if o.strength_ev2 == 0.0 {
348                continue;
349            }
350            let wp = o.energy_ev / h;
351            terms.push(Term {
352                weight: o.strength_ev2 / (o.energy_ev * o.energy_ev),
353                gas: MerminGas::new(LindhardGas::from_plasma_frequency(wp), o.width_ev / h)?,
354            });
355        }
356        if terms.is_empty() {
357            return Err(ElectronDataError::Invalid {
358                what: "Mermin fit",
359                reason: "every oscillator has zero amplitude".into(),
360            });
361        }
362        Ok(Self {
363            elf,
364            fit,
365            terms,
366            fermi: 0.0,
367            rel_tol: DEFAULT_MERMIN_TOLERANCE,
368            gl: GaussLegendre::new(GL_ORDER),
369        })
370    }
371
372    /// Fit `elf` with `options` ([`fit_mermin_oscillators`]) and build the
373    /// model from the result.
374    pub fn fit(elf: OpticalElf, options: &MerminFitOptions) -> Result<Self> {
375        let fit = fit_mermin_oscillators(&elf, options)?;
376        Self::new(elf, fit)
377    }
378
379    /// Set the Fermi energy (eV, finite and non-negative), as for the Penn
380    /// models: energies are kinetic energies above the Fermi level and the
381    /// kinematics use `T' = T + E_F`.
382    pub fn with_fermi_energy_ev(mut self, fermi_ev: f64) -> Result<Self> {
383        if !(fermi_ev.is_finite() && fermi_ev >= 0.0) {
384            return Err(ElectronDataError::Invalid {
385                what: "Fermi energy",
386                reason: format!("must be finite and non-negative, got {fermi_ev} eV"),
387            });
388        }
389        self.fermi = fermi_ev / hartree_ev();
390        Ok(self)
391    }
392
393    /// Set the relative integration tolerance, in `[1e-9, 1e-2]`.
394    pub fn with_relative_tolerance(mut self, rel_tol: f64) -> Result<Self> {
395        if !(1e-9..=1e-2).contains(&rel_tol) {
396            return Err(ElectronDataError::Invalid {
397                what: "integration tolerance",
398                reason: format!("must be in [1e-9, 1e-2], got {rel_tol}"),
399            });
400        }
401        self.rel_tol = rel_tol;
402        Ok(self)
403    }
404
405    /// The optical ELF the oscillators were fitted to.
406    pub fn optical_elf(&self) -> &OpticalElf {
407        &self.elf
408    }
409
410    /// The fit: parameters, residuals, f-sum and `P_eff`.
411    pub fn fit_report(&self) -> &MerminFit {
412        &self.fit
413    }
414
415    /// The Fermi energy, eV.
416    pub fn fermi_energy_ev(&self) -> f64 {
417        self.fermi * hartree_ev()
418    }
419
420    /// The relative integration tolerance.
421    pub fn relative_tolerance(&self) -> f64 {
422        self.rel_tol
423    }
424
425    fn loss_au(&self, q: f64, w: f64) -> f64 {
426        self.terms.iter().map(|t| t.weight * t.gas.loss(q, w)).sum()
427    }
428
429    /// `Im[-1/ε(q, ω)]` of the Mermin-ELF model at momentum transfer `q`
430    /// (m⁻¹) and energy loss `ω` (eV). At `q = 0` it is the fitted
431    /// Drude-Lorentz ELF.
432    pub fn loss_function(&self, q_per_m: f64, loss_ev: f64) -> f64 {
433        if !(q_per_m.is_finite() && q_per_m >= 0.0 && loss_ev.is_finite() && loss_ev > 0.0) {
434            return 0.0;
435        }
436        if q_per_m == 0.0 {
437            return self.fit.model_elf(loss_ev);
438        }
439        self.loss_au(q_per_m * BOHR_RADIUS, loss_ev / hartree_ev())
440    }
441
442    /// The DIIMFP `p(T, ω)` (S2017 eq. (2), nonrelativistic) in m⁻¹ eV⁻¹ at
443    /// kinetic energy `energy_ev` above the Fermi level and loss `loss_ev`.
444    /// Zero for `ω <= 0` or `ω > T`.
445    pub fn diimfp_per_m_ev(&self, energy_ev: f64, loss_ev: f64) -> Result<f64> {
446        check_energy("electron energy", energy_ev)?;
447        if !loss_ev.is_finite() {
448            return Err(ElectronDataError::Invalid {
449                what: "energy loss",
450                reason: format!("must be finite, got {loss_ev} eV"),
451            });
452        }
453        let h = hartree_ev();
454        Ok(self.diimfp_au(energy_ev / h, loss_ev / h) / (BOHR_RADIUS * h))
455    }
456
457    fn diimfp_au(&self, t: f64, w: f64) -> f64 {
458        if !(w > 0.0 && w <= t) {
459            return 0.0;
460        }
461        let tp = t + self.fermi;
462        let k = (2.0 * tp).sqrt();
463        let s = (2.0 * (tp - w)).max(0.0).sqrt();
464        let (q_minus, q_plus) = (2.0 * w / (k + s), k + s);
465        if q_plus.is_nan() || q_plus <= q_minus {
466            return 0.0;
467        }
468        let (lo, hi) = (q_minus.ln(), q_plus.ln());
469        let mut breaks: Vec<f64> = (0..=8).map(|i| lo + (hi - lo) * i as f64 / 8.0).collect();
470        // the edges of each gas's electron-hole continuum, q = ω/k_F ± ...
471        for t in &self.terms {
472            let kf = t.gas.gas().fermi_wavenumber();
473            for q in [2.0 * kf, w / kf] {
474                if q > q_minus && q < q_plus {
475                    breaks.push(q.ln());
476                }
477            }
478        }
479        breaks.sort_by(f64::total_cmp);
480        breaks.dedup();
481        let integral = integrate_segments(
482            &self.gl,
483            &mut |u: f64| [self.loss_au(u.exp(), w)],
484            &breaks,
485            self.rel_tol,
486        )[0];
487        integral / (PI * tp)
488    }
489
490    /// `(λ⁻¹, S)` at kinetic energy `t` (Hartree), atomic units.
491    fn imfp_and_stopping_au(&self, t: f64) -> (f64, f64) {
492        if t.is_nan() || t <= 0.0 {
493            return (0.0, 0.0);
494        }
495        let (lo, hi) = ((LOWEST_LOSS_FRACTION * t).ln(), t.ln());
496        let n = 24;
497        let mut breaks: Vec<f64> = (0..=n)
498            .map(|i| lo + (hi - lo) * i as f64 / n as f64)
499            .collect();
500        for term in &self.terms {
501            let e = term.gas.gas().plasma_frequency();
502            if e > lo.exp() && e < t {
503                breaks.push(e.ln());
504            }
505        }
506        breaks.sort_by(f64::total_cmp);
507        breaks.dedup();
508        let r = integrate_segments(
509            &self.gl,
510            &mut |u: f64| {
511                let w = u.exp();
512                let p = self.diimfp_au(t, w);
513                // d ω = ω d ln ω
514                [p * w, p * w * w]
515            },
516            &breaks,
517            self.rel_tol,
518        );
519        (r[0], r[1])
520    }
521
522    /// The inverse IMFP (m⁻¹) and the stopping power (eV/m) at kinetic
523    /// energy `energy_ev` above the Fermi level.
524    pub fn imfp_and_stopping(&self, energy_ev: f64) -> Result<InelasticPoint> {
525        check_energy("electron energy", energy_ev)?;
526        let h = hartree_ev();
527        let (inv, s) = self.imfp_and_stopping_au(energy_ev / h);
528        Ok(InelasticPoint {
529            energy_ev,
530            inverse_imfp_per_m: inv / BOHR_RADIUS,
531            stopping_ev_per_m: s * h / BOHR_RADIUS,
532        })
533    }
534
535    /// The inelastic mean free path `λ(E)`, m.
536    pub fn imfp_m(&self, energy_ev: f64) -> Result<f64> {
537        Ok(self.imfp_and_stopping(energy_ev)?.imfp_m())
538    }
539
540    /// The stopping power `S(E)`, eV/m.
541    pub fn stopping_power_ev_per_m(&self, energy_ev: f64) -> Result<f64> {
542        Ok(self.imfp_and_stopping(energy_ev)?.stopping_ev_per_m)
543    }
544
545    /// IMFP and stopping power on a grid of energies, in order. Sequential
546    /// and deterministic.
547    pub fn tabulate(&self, energies_ev: &[f64]) -> Result<Vec<InelasticPoint>> {
548        energies_ev
549            .iter()
550            .map(|&e| self.imfp_and_stopping(e))
551            .collect()
552    }
553}
554
555/// Amplitude finite and non-negative, energy and width finite and positive.
556pub(crate) fn check_oscillator(o: &DrudeLorentzOscillator) -> Result<()> {
557    let bad = |name: &str, v: f64| ElectronDataError::Invalid {
558        what: "Mermin oscillator",
559        reason: format!("{name} is invalid: {v}"),
560    };
561    if !(o.strength_ev2.is_finite() && o.strength_ev2 >= 0.0) {
562        return Err(bad("amplitude (must be finite, >= 0)", o.strength_ev2));
563    }
564    if !(o.energy_ev.is_finite() && o.energy_ev > 0.0) {
565        return Err(bad("energy (must be finite, > 0)", o.energy_ev));
566    }
567    if !(o.width_ev.is_finite() && o.width_ev > 0.0) {
568        return Err(bad("width (must be finite, > 0)", o.width_ev));
569    }
570    Ok(())
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    fn gas(kf: f64) -> LindhardGas {
578        LindhardGas::from_fermi_wavenumber(kf)
579    }
580
581    #[test]
582    fn complex_bracket_series_matches_direct_where_both_hold() {
583        for (z, u) in [
584            (0.3, C::new(5.0, 0.4)),
585            (1.5, C::new(7.0, 2.0)),
586            (0.1, C::new(4.5, 0.1)),
587            (2.0, C::new(0.5, 5.0)),
588            (3.0, C::new(0.0, 5.0)),
589        ] {
590            let (hi, lo) = (u + C::real(z), u - C::real(z));
591            assert!(hi.abs() > 4.0 && lo.abs() > 4.0, "{z} {u:?}");
592            let direct = C::real(0.5)
593                + (a_complex(C::real(z) - u) + a_complex(C::real(z) + u)).scale(1.0 / (8.0 * z));
594            let series = bracket_series(hi, lo);
595            let diff = (direct - series).abs();
596            assert!(diff <= 1e-9 * series.abs().max(1e-3), "{z} {u:?} {diff:e}");
597        }
598    }
599
600    #[test]
601    fn complex_lindhard_reduces_to_the_real_frequency_form() {
602        // tiny positive imaginary frequency -> the real-frequency Lindhard
603        // function, including the electron-hole imaginary part
604        for kf in [0.4, 0.9] {
605            let g = gas(kf);
606            for (q, w) in [(0.3, 0.2), (0.8, 0.5), (1.5, 1.4), (0.2, 1.0), (2.5, 0.3)] {
607                let c = lindhard_complex(&g, q, C::new(w, 1e-13));
608                let (re, im) = g.epsilon(q, w);
609                assert!(
610                    (c.re - re).abs() <= 1e-9 * re.abs().max(1.0),
611                    "{kf} {q} {w}: {c:?} vs {re} {im}"
612                );
613                assert!(
614                    (c.im - im).abs() <= 1e-9 * im.abs().max(1.0),
615                    "{kf} {q} {w}: {c:?} vs {re} {im}"
616                );
617            }
618        }
619    }
620
621    #[test]
622    fn mermin_reduces_to_lindhard_as_gamma_goes_to_zero_to_1e8() {
623        for kf in [0.4, 0.9] {
624            let g = gas(kf);
625            for (q, w) in [(0.3, 0.2), (0.8, 0.5), (1.5, 1.4), (0.2, 1.0), (2.5, 0.3)] {
626                let (lre, lim) = g.epsilon(q, w);
627                // exactly zero: the identity
628                let m0 = MerminGas::new(g, 0.0).unwrap().epsilon(q, w);
629                assert_eq!(m0, (lre, lim));
630                for gamma in [1e-11, 1e-13] {
631                    let (re, im) = MerminGas::new(g, gamma).unwrap().epsilon(q, w);
632                    assert!(
633                        (re - lre).abs() <= 1e-8 * lre.abs().max(1.0),
634                        "{kf} {q} {w}"
635                    );
636                    assert!(
637                        (im - lim).abs() <= 1e-8 * lim.abs().max(1.0),
638                        "{kf} {q} {w}"
639                    );
640                }
641            }
642        }
643    }
644
645    #[test]
646    fn mermin_is_drude_at_small_q() {
647        // ε_M(q -> 0, ω) = 1 - ω_p²/(ω² + iγω)
648        let g = LindhardGas::from_plasma_frequency(0.5);
649        let gamma = 0.12;
650        let m = MerminGas::new(g, gamma).unwrap();
651        let wp2 = 0.25;
652        for w in [0.1, 0.5, 1.3] {
653            let (re, im) = m.epsilon(1e-3, w);
654            let d = C::new(w * w, gamma * w);
655            let drude = C::real(1.0) - C::real(wp2) / d;
656            assert!((re - drude.re).abs() < 1e-3, "{w} {re} {}", drude.re);
657            assert!((im - drude.im).abs() < 1e-3, "{w} {im} {}", drude.im);
658        }
659    }
660
661    #[test]
662    fn mermin_static_limit_is_the_static_lindhard_function() {
663        let g = gas(0.7);
664        let m = MerminGas::new(g, 0.05).unwrap();
665        for q in [0.3, 0.9, 2.0] {
666            let (re, im) = m.epsilon(q, 1e-9);
667            let (lre, _) = g.epsilon(q, 0.0);
668            assert!((re - lre).abs() <= 1e-6 * lre, "{q} {re} {lre}");
669            assert!(im.abs() <= 1e-6 * lre, "{q} {im}");
670        }
671    }
672
673    #[test]
674    fn mermin_obeys_the_f_sum_rule_at_finite_q() {
675        // ∫ ω Im[-1/ε_M] dω = (π/2) ω_p²; the tail above the cutoff is
676        // ω_p² γ/ω_c (ε - 1 ≈ -ω_p²/(ω(ω + iγ)) there)
677        let g = LindhardGas::from_plasma_frequency(0.6);
678        let wp2 = g.plasma_frequency().powi(2);
679        let gl = GaussLegendre::new(8);
680        for (gamma, q) in [(0.08, 0.3), (0.15, 0.8), (0.05, 1.2)] {
681            let m = MerminGas::new(g, gamma).unwrap();
682            let w_c = 4000.0;
683            let breaks: Vec<f64> = (0..=60)
684                .map(|i| (1e-6f64).ln() + ((w_c / 1e-6f64).ln()) * i as f64 / 60.0)
685                .collect();
686            let r = integrate_segments(
687                &gl,
688                &mut |u: f64| {
689                    let w = u.exp();
690                    [w * w * m.loss(q, w)]
691                },
692                &breaks,
693                1e-9,
694            )[0];
695            let total = r + wp2 * gamma / w_c;
696            let want = 0.5 * PI * wp2;
697            assert!(
698                ((total - want) / want).abs() < 2e-4,
699                "gamma {gamma} q {q}: {total} vs {want}"
700            );
701        }
702    }
703
704    #[test]
705    fn invalid_relaxation_energy_is_rejected() {
706        let g = gas(0.5);
707        assert!(MerminGas::new(g, -1.0).is_err());
708        assert!(MerminGas::new(g, f64::NAN).is_err());
709    }
710}