Skip to main content

lindhard/electron/
phonon.rs

1//! Insulator energy-loss channels for low-energy electrons: Fröhlich
2//! longitudinal-optical (LO) phonon scattering (emission and absorption) and
3//! polaron trapping.
4//!
5//! Both are extra channels of the event loop in [`crate::electron::transport`],
6//! each with its own inverse mean free path next to the elastic and inelastic
7//! tables. They are **opt-in per layer** ([`InsulatorChannels`]) and off by
8//! default: they describe an electron in a polar insulator, and a layer that
9//! is a metal (or any layer the caller does not opt in) has neither. The choice
10//! is recorded in the run metadata.
11//!
12//! # Sources
13//!
14//! The model is Fröhlich's electron–LO-phonon coupling (H. Fröhlich,
15//! "Electrons in lattice fields", Adv. Phys. 3, 325 (1954),
16//! doi:10.1080/00018735400101213) as applied to low-energy electron transport
17//! by J. Llacer and E. L. Garwin, J. Appl. Phys. 40, 2766 and 2776 (1969)
18//! (doi:10.1063/1.1658076 for part II), and the polaron-trapping rate of
19//! J. P. Ganachaud and A. Mokrani, Surf. Sci. 334, 329 (1995),
20//! doi:10.1016/0039-6028(95)00474-2. None of these three papers was open to
21//! us (no open-access copy; the publisher pages answer a bot challenge, see
22//! `docs/data-provenance.md`). The equations implemented here are taken from
23//! two open-access papers that restate them, and the two agree:
24//!
25//! - **Ding et al. (2021):** Z. J. Ding, C. Li, B. Da, J. Liu, "Charging effect induced by
26//!   electron beam irradiation: a review", Sci. Technol. Adv. Mater. 22, 932
27//!   (2021), doi:10.1080/14686996.2021.1976597 (CC BY 4.0), Section 2.3:
28//!   Eq. (14) (LO-phonon creation frequency), Eq. (15) (annihilation
29//!   frequency), the Bose occupation in the text after Eq. (14), and Eq. (16)
30//!   (angular distribution). The review credits these to H.-J. Fitting and
31//!   J.-U. Friemann, Phys. Status Solidi A 69, 349 (1982), building on
32//!   Llacer and Garwin.
33//! - **Taioli & Dapor (2025):** S. Taioli and M. Dapor, "Advancements in Secondary and
34//!   Backscattered Electron Energy Spectra and Yields Analysis: from Theory
35//!   to Applications", arXiv:2404.07521v5 (2025): Eq. (33) (phonon-emission
36//!   inverse mean free path), Eq. (34) (Bose occupation) and Eq. (37)
37//!   (polaron inverse mean free path `C exp(-γE)`), both credited there to
38//!   Ganachaud and Mokrani (1995).
39//!
40//! # Fröhlich LO-phonon scattering
41//!
42//! With `x = ħω/E`, `n` the Bose occupation of the mode at temperature `T`
43//! (Ding et al. (2021) text after Eq. (14); Taioli & Dapor Eq. (34)),
44//!
45//! ```text
46//! n(T) = 1 / (exp(ħω / k_B T) - 1)
47//! ```
48//!
49//! the inverse mean free paths for emitting (`+`, needs `E > ħω`) and
50//! absorbing (`-`) one phonon of energy `ħω` are
51//!
52//! ```text
53//! λ₊⁻¹(E) = (n + 1) / a₀ · (1/ε∞ - 1/ε₀) · x/2 · ln[(1 + √(1 - x)) / (1 - √(1 - x))]
54//! λ₋⁻¹(E) =  n      / a₀ · (1/ε∞ - 1/ε₀) · x/2 · ln[(√(1 + x) + 1) / (√(1 + x) - 1)]
55//! ```
56//!
57//! `λ₊⁻¹` is Taioli & Dapor Eq. (33) (where the coupling is written
58//! `(ε₀ - ε∞)/(ε₀ ε∞)`, the same quantity). Both are also Ding et al. (2021) Eqs. (14)
59//! and (15), which give the rates per unit time `f±`; dividing by the speed
60//! `v = √(2E/m*)` gives the forms above with `a₀ = 4πε₀ħ²/(m e²)` when the
61//! effective mass `m*` is the free-electron mass, which is the choice made
62//! here (as in Taioli & Dapor Eq. (33)). `ε₀` and `ε∞` are the static and
63//! high-frequency relative permittivities of the material, `a₀` the Bohr
64//! radius.
65//!
66//! **Detailed balance.** With `E' = E - ħω` the two logarithms coincide:
67//! both equal `ln[(√E + √E')² / ħω]`. Hence
68//! `E λ₊⁻¹(E) = ((n + 1)/n) E' λ₋⁻¹(E')`: the rate per unit time times the
69//! density of states (`∝ √E`) of a transition `E → E'` and of its reverse
70//! `E' → E` stand in the ratio `(n + 1)/n = exp(ħω/k_B T)`. This follows from
71//! the two formulas above by algebra; the tests check it, analytically and by
72//! Monte Carlo.
73//!
74//! **Angle.** The polar deflection `θ` in either process follows Ding et al. (2021)
75//! Eq. (16), with `E` and `E'` the energies before and after the event
76//! (`E' = E ∓ ħω`):
77//!
78//! ```text
79//! dw/dΩ = (EE')^(1/2) / [π (E + E' - 2(EE')^(1/2) cos θ) ln R],
80//! R = (E + E' + 2(EE')^(1/2)) / (E + E' - 2(EE')^(1/2))
81//! ```
82//!
83//! It is normalised over the sphere. Its cumulative distribution in
84//! `μ = cos θ` from `-1` is `F(μ) = ln[(a + b)/(a - bμ)] / ln R`
85//! (`a = E + E'`, `b = 2(EE')^(1/2)`), which inverts to
86//! `μ = (a - (a + b) R^(-u)) / b` for `u` uniform on `[0, 1)`. That inversion
87//! is our own algebra on Eq. (16). The azimuth is uniform.
88//!
89//! # Polaron trapping
90//!
91//! The trapping inverse mean free path is (Taioli & Dapor Eq. (37), from Ganachaud and
92//! Mokrani 1995)
93//!
94//! ```text
95//! λ_pol⁻¹(E) = C exp(-γ E)
96//! ```
97//!
98//! with `C` (here in m⁻¹) and `γ` (eV⁻¹) material constants. A trapped
99//! electron deposits its whole remaining energy at the trapping point and its
100//! history ends.
101//!
102//! # Material parameters
103//!
104//! Every parameter is a validated input with a provenance string, recorded in
105//! the run metadata. The only committed material values are the SiO₂ ones of
106//! [`FrohlichPhonon::sio2`] (Ding et al. (2021) Section 2.3). No `C`, `γ` is committed for
107//! any material: the source that tabulates them (Ganachaud and Mokrani 1995)
108//! could not be opened, so [`PolaronTrapping::new`] takes them from the
109//! caller. The gaps are listed in `docs/data-provenance.md`.
110
111use serde::Serialize;
112
113use crate::constants::{BOHR_RADIUS, BOLTZMANN, ELEMENTARY_CHARGE};
114
115/// Errors from building an insulator channel.
116#[derive(Debug, Clone, PartialEq, thiserror::Error)]
117pub enum PhononError {
118    /// A parameter is out of range.
119    #[error("invalid {what}: {why}")]
120    Invalid {
121        /// The parameter.
122        what: &'static str,
123        /// Why it was rejected.
124        why: String,
125    },
126}
127
128fn invalid<T>(what: &'static str, why: impl Into<String>) -> Result<T, PhononError> {
129    Err(PhononError::Invalid {
130        what,
131        why: why.into(),
132    })
133}
134
135fn check_provenance(p: &str) -> Result<(), PhononError> {
136    if p.trim().is_empty() {
137        return invalid("provenance", "must name the source of the parameters");
138    }
139    Ok(())
140}
141
142/// Boltzmann constant in eV/K (`k_B / e`, both exact in CODATA 2022, see
143/// [`crate::constants`]).
144const BOLTZMANN_EV_PER_K: f64 = BOLTZMANN / ELEMENTARY_CHARGE;
145
146/// The two LO-phonon modes of SiO₂ quoted in Ding et al. (2021) Section 2.3 (Ding, Li,
147/// Da, Liu, Sci. Technol. Adv. Mater. 22, 932 (2021),
148/// doi:10.1080/14686996.2021.1976597), who cite J.-C. Kuhr and H.-J. Fitting,
149/// J. Electron Spectrosc. Relat. Phenom. 105, 257 (1999) for them.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "kebab-case")]
152pub enum Sio2LoMode {
153    /// `ħω = 63 meV`.
154    Mev63,
155    /// `ħω = 153 meV`.
156    Mev153,
157}
158
159/// One Fröhlich LO-phonon mode of a polar material at a temperature: the
160/// parameters of the emission and absorption channels. See the module docs
161/// for the equations and their sources.
162#[derive(Debug, Clone, PartialEq, Serialize)]
163pub struct FrohlichPhonon {
164    hbar_omega_ev: f64,
165    eps_static: f64,
166    eps_high_frequency: f64,
167    temperature_k: f64,
168    provenance: String,
169}
170
171impl FrohlichPhonon {
172    /// Check and build. `hbar_omega_ev` is the LO-phonon energy `ħω` (eV,
173    /// finite and positive), `eps_static` and `eps_high_frequency` the static
174    /// and high-frequency relative permittivities `ε₀ > ε∞ > 0` (finite), and
175    /// `temperature_k` the lattice temperature (K, finite, `>= 0`; at `0` there
176    /// is no absorption). `provenance` names the source of the values and must
177    /// not be blank.
178    pub fn new(
179        hbar_omega_ev: f64,
180        eps_static: f64,
181        eps_high_frequency: f64,
182        temperature_k: f64,
183        provenance: impl Into<String>,
184    ) -> Result<Self, PhononError> {
185        if !(hbar_omega_ev.is_finite() && hbar_omega_ev > 0.0) {
186            return invalid("phonon energy", "must be finite and positive");
187        }
188        if !(eps_high_frequency.is_finite() && eps_high_frequency > 0.0) {
189            return invalid("high-frequency permittivity", "must be finite and positive");
190        }
191        if !(eps_static.is_finite() && eps_static > eps_high_frequency) {
192            return invalid(
193                "static permittivity",
194                format!(
195                    "{eps_static} must be finite and exceed the high-frequency value {eps_high_frequency}"
196                ),
197            );
198        }
199        if !(temperature_k.is_finite() && temperature_k >= 0.0) {
200            return invalid("temperature", "must be finite and non-negative");
201        }
202        let provenance = provenance.into();
203        check_provenance(&provenance)?;
204        Ok(Self {
205            hbar_omega_ev,
206            eps_static,
207            eps_high_frequency,
208            temperature_k,
209            provenance,
210        })
211    }
212
213    /// One LO mode of SiO₂ at `temperature_k`, with `ε₀ = 3.9` and
214    /// `ε∞ = 2.25` and the mode energies of [`Sio2LoMode`], all as quoted in
215    /// Ding, Li, Da, Liu, Sci. Technol. Adv. Mater. 22, 932 (2021),
216    /// doi:10.1080/14686996.2021.1976597, Section 2.3 (the text after
217    /// Eq. (14) for `ε₀`, `ε∞`; the text before it for the modes). The review
218    /// treats the two modes one at a time; this model has one mode per layer,
219    /// and how to combine both modes is not modelled here.
220    pub fn sio2(mode: Sio2LoMode, temperature_k: f64) -> Result<Self, PhononError> {
221        let hbar_omega_ev = match mode {
222            Sio2LoMode::Mev63 => 0.063,
223            Sio2LoMode::Mev153 => 0.153,
224        };
225        Self::new(
226            hbar_omega_ev,
227            3.9,
228            2.25,
229            temperature_k,
230            "SiO2: eps_static 3.9, eps_inf 2.25, LO modes 63 and 153 meV as quoted in \
231             Ding, Li, Da, Liu, Sci. Technol. Adv. Mater. 22, 932 (2021), \
232             doi:10.1080/14686996.2021.1976597, Sec. 2.3 (modes credited there to \
233             Kuhr & Fitting, J. Electron Spectrosc. Relat. Phenom. 105, 257 (1999))",
234        )
235    }
236
237    /// LO-phonon energy `ħω`, eV.
238    pub fn hbar_omega_ev(&self) -> f64 {
239        self.hbar_omega_ev
240    }
241
242    /// Static relative permittivity `ε₀`.
243    pub fn eps_static(&self) -> f64 {
244        self.eps_static
245    }
246
247    /// High-frequency relative permittivity `ε∞`.
248    pub fn eps_high_frequency(&self) -> f64 {
249        self.eps_high_frequency
250    }
251
252    /// Lattice temperature, K.
253    pub fn temperature_k(&self) -> f64 {
254        self.temperature_k
255    }
256
257    /// Source of the parameter values.
258    pub fn provenance(&self) -> &str {
259        &self.provenance
260    }
261
262    /// Bose occupation `n(T) = 1/(exp(ħω/k_B T) - 1)` of the mode (Ding et al. (2021)
263    /// text after Eq. (14); Taioli & Dapor Eq. (34)); `0` at `T = 0`.
264    pub fn occupation(&self) -> f64 {
265        if self.temperature_k == 0.0 {
266            return 0.0;
267        }
268        1.0 / (self.hbar_omega_ev / (BOLTZMANN_EV_PER_K * self.temperature_k)).exp_m1()
269    }
270
271    /// `(1/ε∞ - 1/ε₀) / (2 a₀)`, m⁻¹: the prefactor shared by both rates.
272    fn coupling_per_m(&self) -> f64 {
273        (1.0 / self.eps_high_frequency - 1.0 / self.eps_static) / (2.0 * BOHR_RADIUS)
274    }
275
276    /// Inverse mean free path for emitting one phonon at energy `e_ev`, m⁻¹
277    /// (Taioli & Dapor Eq. (33); Ding et al. (2021) Eq. (14) divided by the speed). Zero for
278    /// `e_ev <= ħω`. The logarithm is evaluated as
279    /// `ln[(1 + √(1 - x))² / x]`, the same quantity without the cancellation
280    /// in `1 - √(1 - x)` at small `x`.
281    pub fn emission_inverse_mfp_per_m(&self, e_ev: f64) -> f64 {
282        if !e_ev.is_finite() || e_ev <= self.hbar_omega_ev {
283            return 0.0;
284        }
285        let x = self.hbar_omega_ev / e_ev;
286        let r = (1.0 - x).sqrt();
287        (self.occupation() + 1.0) * self.coupling_per_m() * x * ((1.0 + r).powi(2) / x).ln()
288    }
289
290    /// Inverse mean free path for absorbing one phonon at energy `e_ev`, m⁻¹
291    /// (Ding et al. (2021) Eq. (15) divided by the speed). Zero at `T = 0` or for
292    /// `e_ev <= 0`. The logarithm is evaluated as `ln[(√(1 + x) + 1)² / x]`.
293    pub fn absorption_inverse_mfp_per_m(&self, e_ev: f64) -> f64 {
294        let n = self.occupation();
295        if n == 0.0 || !e_ev.is_finite() || e_ev <= 0.0 {
296            return 0.0;
297        }
298        let x = self.hbar_omega_ev / e_ev;
299        let q = (1.0 + x).sqrt();
300        n * self.coupling_per_m() * x * ((q + 1.0).powi(2) / x).ln()
301    }
302}
303
304/// `cos θ` of a phonon event that takes the electron from `e_ev` to
305/// `e_after_ev` (both positive and different), at cumulative probability `u`
306/// in `[0, 1)`: the inverse of the distribution of Ding et al. (2021)
307/// Eq. (16), see the module docs. `ln R` is evaluated as
308/// `2 ln[(√E + √E')² / |E - E'|]` to avoid the cancellation in
309/// `E + E' - 2√(EE')`.
310pub fn sample_cos_theta(e_ev: f64, e_after_ev: f64, u: f64) -> f64 {
311    let (s, s2) = (e_ev.sqrt(), e_after_ev.sqrt());
312    let a = e_ev + e_after_ev;
313    let b = 2.0 * s * s2;
314    let apb = (s + s2) * (s + s2);
315    let ln_r = 2.0 * (apb / (e_ev - e_after_ev).abs()).ln();
316    ((a - apb * (-u * ln_r).exp()) / b).clamp(-1.0, 1.0)
317}
318
319/// The cumulative distribution `F(μ)` of `μ = cos θ` under Ding et al. (2021) Eq. (16)
320/// for a phonon event `E -> E'` (the inverse of [`sample_cos_theta`]).
321pub fn cos_theta_cdf(e_ev: f64, e_after_ev: f64, mu: f64) -> f64 {
322    let (s, s2) = (e_ev.sqrt(), e_after_ev.sqrt());
323    let b = 2.0 * s * s2;
324    let apb = (s + s2) * (s + s2);
325    let ln_r = 2.0 * (apb / (e_ev - e_after_ev).abs()).ln();
326    let mu = mu.clamp(-1.0, 1.0);
327    // a - b·mu = (√E - √E')² + b(1 - mu), without the cancellation at mu = 1.
328    let denom = (s - s2) * (s - s2) + b * (1.0 - mu);
329    ((apb / denom).ln() / ln_r).clamp(0.0, 1.0)
330}
331
332/// Polaron trapping with inverse mean free path `C exp(-γE)` (Taioli & Dapor Eq. (37),
333/// Taioli & Dapor, arXiv:2404.07521v5, from Ganachaud & Mokrani, Surf. Sci.
334/// 334, 329 (1995)).
335#[derive(Debug, Clone, PartialEq, Serialize)]
336pub struct PolaronTrapping {
337    c_per_m: f64,
338    gamma_per_ev: f64,
339    provenance: String,
340}
341
342impl PolaronTrapping {
343    /// Check and build. `c_per_m` is `C` in m⁻¹ (finite, positive),
344    /// `gamma_per_ev` is `γ` in eV⁻¹ (finite, `>= 0`). No material values are
345    /// built in (see the module docs): the caller supplies them with a
346    /// non-blank `provenance`.
347    pub fn new(
348        c_per_m: f64,
349        gamma_per_ev: f64,
350        provenance: impl Into<String>,
351    ) -> Result<Self, PhononError> {
352        if !(c_per_m.is_finite() && c_per_m > 0.0) {
353            return invalid("polaron C", "must be finite and positive");
354        }
355        if !(gamma_per_ev.is_finite() && gamma_per_ev >= 0.0) {
356            return invalid("polaron gamma", "must be finite and non-negative");
357        }
358        let provenance = provenance.into();
359        check_provenance(&provenance)?;
360        Ok(Self {
361            c_per_m,
362            gamma_per_ev,
363            provenance,
364        })
365    }
366
367    /// `C`, m⁻¹.
368    pub fn c_per_m(&self) -> f64 {
369        self.c_per_m
370    }
371
372    /// `γ`, eV⁻¹.
373    pub fn gamma_per_ev(&self) -> f64 {
374        self.gamma_per_ev
375    }
376
377    /// Source of the parameter values.
378    pub fn provenance(&self) -> &str {
379        &self.provenance
380    }
381
382    /// Trapping inverse mean free path `C exp(-γE)` at `e_ev`, m⁻¹.
383    pub fn inverse_mfp_per_m(&self, e_ev: f64) -> f64 {
384        self.c_per_m * (-self.gamma_per_ev * e_ev).exp()
385    }
386}
387
388/// The insulator channels switched on in one layer. The default has none,
389/// which is what a metal (or any layer not opted in) gets.
390#[derive(Debug, Clone, Default, PartialEq, Serialize)]
391pub struct InsulatorChannels {
392    /// Fröhlich LO-phonon emission and absorption, if on.
393    pub phonon: Option<FrohlichPhonon>,
394    /// Polaron trapping, if on.
395    pub polaron: Option<PolaronTrapping>,
396}
397
398impl InsulatorChannels {
399    /// No insulator channel (the default, and the only choice for a metal).
400    pub fn none() -> Self {
401        Self::default()
402    }
403
404    /// Whether any channel is on.
405    pub fn any(&self) -> bool {
406        self.phonon.is_some() || self.polaron.is_some()
407    }
408
409    /// The three rates at `e_ev`, m⁻¹: (phonon emission, phonon absorption,
410    /// polaron trapping); zero for a channel that is off.
411    pub fn rates(&self, e_ev: f64) -> (f64, f64, f64) {
412        let (em, ab) = match &self.phonon {
413            Some(p) => (
414                p.emission_inverse_mfp_per_m(e_ev),
415                p.absorption_inverse_mfp_per_m(e_ev),
416            ),
417            None => (0.0, 0.0),
418        };
419        let tr = self
420            .polaron
421            .as_ref()
422            .map_or(0.0, |p| p.inverse_mfp_per_m(e_ev));
423        (em, ab, tr)
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn mode() -> FrohlichPhonon {
432        // Synthetic parameters, not material data.
433        FrohlichPhonon::new(0.1, 4.0, 2.0, 600.0, "synthetic").unwrap()
434    }
435
436    #[test]
437    fn stable_logs_equal_the_textbook_forms() {
438        let p = mode();
439        let pref = (1.0 / 2.0 - 1.0 / 4.0) / (2.0 * BOHR_RADIUS);
440        let n = p.occupation();
441        for e in [0.15, 1.0, 30.0] {
442            let x: f64 = 0.1 / e;
443            let em =
444                (n + 1.0) * pref * x * ((1.0 + (1.0 - x).sqrt()) / (1.0 - (1.0 - x).sqrt())).ln();
445            let ab = n * pref * x * (((1.0 + x).sqrt() + 1.0) / ((1.0 + x).sqrt() - 1.0)).ln();
446            assert!((p.emission_inverse_mfp_per_m(e) / em - 1.0).abs() < 1e-12);
447            assert!((p.absorption_inverse_mfp_per_m(e) / ab - 1.0).abs() < 1e-12);
448        }
449    }
450
451    #[test]
452    fn detailed_balance_holds_exactly_in_the_formulas() {
453        let p = mode();
454        let n = p.occupation();
455        for e in [0.11, 0.5, 2.0, 40.0] {
456            let ep = e - 0.1;
457            let ratio =
458                e * p.emission_inverse_mfp_per_m(e) / (ep * p.absorption_inverse_mfp_per_m(ep));
459            assert!(
460                (ratio / ((n + 1.0) / n) - 1.0).abs() < 1e-12,
461                "{e}: {ratio}"
462            );
463        }
464    }
465
466    #[test]
467    fn emission_needs_energy_above_the_phonon_and_absorption_needs_heat() {
468        let p = mode();
469        assert_eq!(p.emission_inverse_mfp_per_m(0.1), 0.0);
470        assert_eq!(p.emission_inverse_mfp_per_m(0.05), 0.0);
471        let cold = FrohlichPhonon::new(0.1, 4.0, 2.0, 0.0, "synthetic").unwrap();
472        assert_eq!(cold.occupation(), 0.0);
473        assert_eq!(cold.absorption_inverse_mfp_per_m(1.0), 0.0);
474        assert!(cold.emission_inverse_mfp_per_m(1.0) > 0.0);
475    }
476
477    #[test]
478    fn angular_inverse_cdf_round_trips_and_is_normalised() {
479        for (e, ep) in [(1.0, 0.9), (0.2, 0.1), (5.0, 5.1), (50.0, 49.95)] {
480            assert!(sample_cos_theta(e, ep, 0.0) <= -1.0 + 1e-9);
481            assert!((cos_theta_cdf(e, ep, 1.0) - 1.0).abs() < 1e-12);
482            assert!(cos_theta_cdf(e, ep, -1.0).abs() < 1e-12);
483            for u in [0.01, 0.3, 0.5, 0.9, 0.999] {
484                let mu = sample_cos_theta(e, ep, u);
485                assert!((cos_theta_cdf(e, ep, mu) - u).abs() < 1e-9, "{e} {ep} {u}");
486            }
487            // Midpoint-rule integral of Eq. (16) over the sphere (skipped for
488            // a forward spike narrower than the quadrature step).
489            if (e - ep).abs() / e < 0.01 {
490                continue;
491            }
492            let (a, b) = (e + ep, 2.0 * f64::sqrt(e * ep));
493            let ln_r = ((a + b) / (a - b)).ln();
494            let k = 200_000;
495            let mut sum = 0.0;
496            for i in 0..k {
497                let mu = -1.0 + (i as f64 + 0.5) * 2.0 / k as f64;
498                sum += 2.0 * std::f64::consts::PI * f64::sqrt(e * ep)
499                    / (std::f64::consts::PI * (a - b * mu) * ln_r)
500                    * (2.0 / k as f64);
501            }
502            assert!((sum - 1.0).abs() < 1e-3, "{e} {ep}: {sum}");
503        }
504    }
505
506    #[test]
507    fn validation_rejects_bad_parameters() {
508        assert!(FrohlichPhonon::new(0.0, 4.0, 2.0, 300.0, "s").is_err());
509        assert!(FrohlichPhonon::new(0.1, 2.0, 2.0, 300.0, "s").is_err());
510        assert!(FrohlichPhonon::new(0.1, 4.0, 0.0, 300.0, "s").is_err());
511        assert!(FrohlichPhonon::new(0.1, 4.0, 2.0, -1.0, "s").is_err());
512        assert!(FrohlichPhonon::new(0.1, 4.0, 2.0, 300.0, " ").is_err());
513        assert!(PolaronTrapping::new(0.0, 1.0, "s").is_err());
514        assert!(PolaronTrapping::new(1e9, -1.0, "s").is_err());
515        assert!(PolaronTrapping::new(1e9, 1.0, "").is_err());
516        assert!(PolaronTrapping::new(1e9, 0.0, "s").is_ok());
517    }
518
519    #[test]
520    fn sio2_preset_carries_the_quoted_values() {
521        let p = FrohlichPhonon::sio2(Sio2LoMode::Mev153, 300.0).unwrap();
522        assert_eq!(p.hbar_omega_ev(), 0.153);
523        assert_eq!(p.eps_static(), 3.9);
524        assert_eq!(p.eps_high_frequency(), 2.25);
525        assert!(p.provenance().contains("10.1080/14686996.2021.1976597"));
526        let q = FrohlichPhonon::sio2(Sio2LoMode::Mev63, 300.0).unwrap();
527        assert_eq!(q.hbar_omega_ev(), 0.063);
528    }
529}