Skip to main content

lindhard/ion/crystal/
debye.rs

1//! Debye model of thermal vibration: RMS displacement and its sampling.
2//!
3//! # Model
4//!
5//! A monatomic harmonic crystal with the Debye density of states, cut off at
6//! the Debye frequency `omega_D = k_B Theta_D / hbar`, has a mean-square
7//! displacement of an atom along one Cartesian axis
8//!
9//! ```text
10//! u1^2 = (3 hbar^2 / (m k_B Theta_D)) * [ (T/Theta_D) D1(Theta_D/T) + 1/4 ]
11//! D1(x) = (1/x) * integral_0^x  t / (exp(t) - 1)  dt
12//! ```
13//!
14//! where `m` is the atomic mass and `D1` is the Debye function of order 1
15//! (called `Phi` in the channeling literature). The `1/4` is the zero-point
16//! term; the `D1` term is the thermal part.
17//!
18//! **Source.** The expression is the Debye-model evaluation of the harmonic
19//! mean-square displacement, `2W(q) = q^2 <u_x^2>` with
20//! `<u_x^2> = (hbar / (6 m)) * int g(w) coth(hbar w / 2 k_B T) / w dw`
21//! (w is angular frequency; g(w) is the per-atom density of states normalised
22//! to three modes, `int_0^{w_D} g dw = 3`),
23//! as in N. W. Ashcroft and N. D. Mermin, *Solid State Physics* (Holt,
24//! Rinehart and Winston, 1976), Appendix L. We could not open the printed
25//! book; the equation was read in the form given in the "Mean squared
26//! displacement" section of the Wikipedia article "Debye function"
27//! (<https://en.wikipedia.org/wiki/Debye_function>, retrieved 2026-10-07),
28//! which cites that appendix and writes
29//! `2W(q) = (3/2) (hbar^2 q^2 / (m hbar omega_D)) [2 (k_B T/hbar omega_D)
30//! D1(hbar omega_D / k_B T) + 1/2]`; dividing by `q^2` gives the form above.
31//! The same quantity in the channeling literature is D. S. Gemmell, Rev. Mod.
32//! Phys. 46, 129 (1974), doi:10.1103/RevModPhys.46.129; we could not open that
33//! paper either, so its equation number is not cited here and its text was
34//! not consulted. As an independent check of the transcription, the unit
35//! tests integrate the `coth` form directly with the Debye density of states
36//! `g(w) = 9 w^2 / w_D^3` (so `int_0^{w_D} g dw = 3`) and compare it with the closed form.
37//!
38//! **Limits.** `T -> 0`: `u1^2 -> 3 hbar^2 / (4 m k_B Theta_D)` (zero-point).
39//! `T >> Theta_D`: `D1(x) -> 1 - x/4 + x^2/36 - ...`, so
40//! `u1^2 -> 3 hbar^2 T / (m k_B Theta_D^2) = 3 k_B T / (m omega_D^2)` (the
41//! classical equipartition value). The `-x/4` term of `D1` cancels the
42//! zero-point `1/4` exactly, so the relative correction is `+x^2/36` with
43//! `x = Theta_D/T`, i.e. about 0.03 % at `T = 10 Theta_D`.
44//!
45//! # One-dimensional versus three-dimensional amplitude
46//!
47//! [`ThermalVibration::rms_1d`] is `u1 = sqrt(u1^2)`, the RMS displacement
48//! along *one* Cartesian axis (the quantity named `u_1` in the channeling
49//! literature, which sets the Gaussian width of the transverse displacement
50//! distribution). [`ThermalVibration::rms_3d`] is the RMS length of the full
51//! displacement vector, `u = sqrt(3) u1` for an isotropic (cubic) crystal.
52//! Sampling draws each Cartesian component independently from a Gaussian of
53//! standard deviation `u1`.
54//!
55//! # Debye temperature
56//!
57//! A Debye temperature is not unique: it depends on the property it was
58//! fitted to (low-temperature specific heat, elastic constants, or the
59//! temperature dependence of X-ray or neutron Bragg intensities), and the
60//! values differ by tens of kelvin for the same crystal. The constants
61//! [`THETA_D_SI`] and friends are the room-temperature "Debye temperature"
62//! entries of the Ioffe Institute *New Semiconductor Materials* handbook;
63//! that source does not state the method for Si, Ge and GaAs, and for 3C-SiC
64//! it gives Goldberg et al. (2001) as its reference (also without a method).
65//! We therefore do **not** claim a specific-heat or X-ray determination for
66//! them. They are the only openable, citable values we found, and they are
67//! defaults only: the model takes `Theta_D` as an argument so a value
68//! matched to a given measurement can be used instead. A lattice-vibration
69//! amplitude fitted from X-ray Debye-Waller factors would be the more direct
70//! choice for channeling work; no openable source for such values was found
71//! (see `docs/data-provenance.md`, "Debye temperatures").
72//!
73//! For a compound (GaAs, SiC) one `Theta_D` is applied to every species with
74//! that species' own mass, which is the usual single-parameter approximation
75//! and not a property of the Debye model itself.
76//!
77//! # Sampling and determinism
78//!
79//! [`ThermalVibration::sample_displacement`] draws from the caller's
80//! per-particle stream ([`crate::rng::ParticleRng`]) and always consumes
81//! exactly six `u64` words (three Box-Muller pairs, of which only the cosine
82//! member is used), so a history's stream position does not depend on the
83//! sampled values and results do not depend on the thread count.
84//! Box-Muller: G. E. P. Box and M. E. Muller, Ann. Math. Statist. 29, 610
85//! (1958).
86
87use crate::constants::{ATOMIC_MASS_UNIT, BOLTZMANN, HBAR};
88use crate::rng::{uniform, ParticleRng};
89
90/// Debye temperature of Si in kelvin: 640 K, "Debye temperature" row of
91/// <https://www.ioffe.ru/SVA/NSM/Semicond/Si/basic.html> (retrieved
92/// 2026-10-07). Determination method not stated by the source; see the module
93/// docs.
94pub const THETA_D_SI: f64 = 640.0;
95
96/// Debye temperature of Ge in kelvin: 374 K, "Debye temperature" row of
97/// <https://www.ioffe.ru/SVA/NSM/Semicond/Ge/basic.html> (retrieved
98/// 2026-10-07). Method not stated by the source.
99pub const THETA_D_GE: f64 = 374.0;
100
101/// Debye temperature of GaAs in kelvin: 360 K, "Debye temperature" row of
102/// <https://www.ioffe.ru/SVA/NSM/Semicond/GaAs/basic.html> (retrieved
103/// 2026-10-07). Method not stated by the source.
104pub const THETA_D_GAAS: f64 = 360.0;
105
106/// Debye temperature of 3C-SiC in kelvin: 1200 K, "Debye temperature" row of
107/// <https://www.ioffe.ru/SVA/NSM/Semicond/SiC/basic.html> (retrieved
108/// 2026-10-07), which credits Goldberg et al. (2001). Method not stated.
109pub const THETA_D_3C_SIC: f64 = 1200.0;
110
111/// Errors from building a [`ThermalVibration`].
112#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
113pub enum DebyeError {
114    /// The Debye temperature must be finite and positive.
115    #[error("Debye temperature must be finite and > 0 K, got {0}")]
116    BadDebyeTemperature(f64),
117    /// The atomic mass must be finite and positive.
118    #[error("atomic mass must be finite and > 0 u, got {0}")]
119    BadMass(f64),
120    /// The temperature must be finite and non-negative.
121    #[error("temperature must be finite and >= 0 K, got {0}")]
122    BadTemperature(f64),
123}
124
125/// Debye function of order 1, `D1(x) = (1/x) int_0^x t/(e^t - 1) dt`, for
126/// `x > 0`. `D1(0) = 1`, `D1(x) -> pi^2/(6x)` as `x -> infinity`.
127///
128/// For `x <= 3` the integrand's Taylor series `t/(e^t-1) = sum b_n t^n`
129/// (radius of convergence `2 pi`) is integrated term by term; the `b_n` are
130/// generated from `(t/(e^t-1)) * ((e^t-1)/t) = 1`. For larger `x` the integral
131/// is `pi^2/6 - int_x^inf`, and the tail is the convergent series
132/// `sum_k e^{-kx} (x/k + 1/k^2)`.
133pub fn debye_d1(x: f64) -> f64 {
134    if x <= 0.0 {
135        return 1.0;
136    }
137    if x <= 3.0 {
138        const N: usize = 80;
139        // a_k = 1/(k+1)!, so that sum a_k t^k = (e^t - 1)/t.
140        let mut a = [0.0f64; N + 1];
141        a[0] = 1.0;
142        for k in 1..=N {
143            a[k] = a[k - 1] / (k as f64 + 1.0);
144        }
145        let mut b = [0.0f64; N + 1];
146        b[0] = 1.0;
147        for n in 1..=N {
148            let mut s = 0.0;
149            for k in 1..=n {
150                s += a[k] * b[n - k];
151            }
152            b[n] = -s;
153        }
154        // Horner in x for sum b_n x^n / (n+1).
155        let mut acc = 0.0;
156        for n in (0..=N).rev() {
157            acc = acc * x + b[n] / (n as f64 + 1.0);
158        }
159        acc
160    } else {
161        let mut tail = 0.0;
162        let mut k = 1.0f64;
163        loop {
164            let term = (-k * x).exp() * (x / k + 1.0 / (k * k));
165            tail += term;
166            if term < 1e-18 * tail || k > 2000.0 {
167                break;
168            }
169            k += 1.0;
170        }
171        (std::f64::consts::PI.powi(2) / 6.0 - tail) / x
172    }
173}
174
175/// Debye-model vibration of one atomic species at one temperature.
176#[derive(Debug, Clone, Copy, PartialEq)]
177pub struct ThermalVibration {
178    theta_d_k: f64,
179    mass_amu: f64,
180    temperature_k: f64,
181    u1_sq: f64,
182}
183
184impl ThermalVibration {
185    /// Build the model for Debye temperature `theta_d_k` (K), atomic mass
186    /// `mass_amu` (g/mol, numerically the mass in unified atomic mass units)
187    /// and target temperature `temperature_k` (K, may be 0).
188    pub fn new(theta_d_k: f64, mass_amu: f64, temperature_k: f64) -> Result<Self, DebyeError> {
189        if !(theta_d_k.is_finite() && theta_d_k > 0.0) {
190            return Err(DebyeError::BadDebyeTemperature(theta_d_k));
191        }
192        if !(mass_amu.is_finite() && mass_amu > 0.0) {
193            return Err(DebyeError::BadMass(mass_amu));
194        }
195        if !(temperature_k.is_finite() && temperature_k >= 0.0) {
196            return Err(DebyeError::BadTemperature(temperature_k));
197        }
198        Ok(Self {
199            theta_d_k,
200            mass_amu,
201            temperature_k,
202            u1_sq: mean_square_1d(theta_d_k, mass_amu, temperature_k),
203        })
204    }
205
206    /// Debye temperature in kelvin.
207    pub fn debye_temperature(&self) -> f64 {
208        self.theta_d_k
209    }
210
211    /// Target temperature in kelvin.
212    pub fn temperature(&self) -> f64 {
213        self.temperature_k
214    }
215
216    /// Atomic mass in u.
217    pub fn mass_amu(&self) -> f64 {
218        self.mass_amu
219    }
220
221    /// Mean-square displacement along one Cartesian axis, `u1^2`, in m^2.
222    pub fn mean_square_1d(&self) -> f64 {
223        self.u1_sq
224    }
225
226    /// One-dimensional RMS displacement `u1`, in metres.
227    pub fn rms_1d(&self) -> f64 {
228        self.u1_sq.sqrt()
229    }
230
231    /// Three-dimensional RMS displacement `u = sqrt(3) u1`, in metres.
232    pub fn rms_3d(&self) -> f64 {
233        (3.0 * self.u1_sq).sqrt()
234    }
235
236    /// Zero-point mean-square displacement `3 hbar^2 / (4 m k_B Theta_D)`
237    /// (the `T -> 0` limit of [`Self::mean_square_1d`]), in m^2.
238    pub fn zero_point_mean_square_1d(&self) -> f64 {
239        mean_square_1d(self.theta_d_k, self.mass_amu, 0.0)
240    }
241
242    /// Classical (equipartition) mean-square displacement
243    /// `3 hbar^2 T / (m k_B Theta_D^2)` (the `T >> Theta_D` limit), in m^2.
244    pub fn classical_mean_square_1d(&self) -> f64 {
245        let m = self.mass_amu * ATOMIC_MASS_UNIT;
246        3.0 * HBAR * HBAR * self.temperature_k / (m * BOLTZMANN * self.theta_d_k * self.theta_d_k)
247    }
248
249    /// Draw an independent displacement of one atom from its lattice site:
250    /// three Cartesian components, each Gaussian with standard deviation
251    /// [`Self::rms_1d`], in metres. Consumes exactly six `u64` words from
252    /// `rng`.
253    pub fn sample_displacement(&self, rng: &mut ParticleRng) -> [f64; 3] {
254        sample_gaussian_3d(self.rms_1d(), rng)
255    }
256
257    /// Thermal part of [`Self::mean_square_1d`] alone, without the zero-point
258    /// term: `u1^2 - 3 hbar^2 / (4 m k_B Theta_D)`, in m^2 (0 at `T = 0`).
259    /// Not a physical amplitude (zero-point motion is always present); it
260    /// exists so that a test can switch the zero-point term off.
261    pub fn thermal_only_mean_square_1d(&self) -> f64 {
262        let m = self.mass_amu * ATOMIC_MASS_UNIT;
263        let pref = 3.0 * HBAR * HBAR / (m * BOLTZMANN * self.theta_d_k);
264        if self.temperature_k > 0.0 {
265            let x = self.theta_d_k / self.temperature_k;
266            pref * (debye_d1(x) / x)
267        } else {
268            0.0
269        }
270    }
271}
272
273/// Three independent Gaussian components of standard deviation `sigma` (m),
274/// by Box-Muller (module docs). Consumes exactly six `u64` words from `rng`
275/// whatever `sigma` is; `sigma = 0` gives components equal to zero (possibly
276/// `-0.0`).
277pub fn sample_gaussian_3d(sigma: f64, rng: &mut ParticleRng) -> [f64; 3] {
278    let mut out = [0.0; 3];
279    for c in &mut out {
280        // u1 in (0, 1] so that ln is finite; u2 in [0, 1).
281        let u1 = 1.0 - uniform(rng);
282        let u2 = uniform(rng);
283        *c = sigma * (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
284    }
285    out
286}
287
288/// `u1^2` in m^2 for Debye temperature (K), mass (u), temperature (K).
289fn mean_square_1d(theta_d_k: f64, mass_amu: f64, temperature_k: f64) -> f64 {
290    let m = mass_amu * ATOMIC_MASS_UNIT;
291    let pref = 3.0 * HBAR * HBAR / (m * BOLTZMANN * theta_d_k);
292    let thermal = if temperature_k > 0.0 {
293        let x = theta_d_k / temperature_k;
294        debye_d1(x) / x
295    } else {
296        0.0
297    };
298    pref * (thermal + 0.25)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::rng::stream;
305    use rand_core::Rng;
306
307    /// Composite Simpson reference, independent of the series and tail
308    /// expansions used in `debye_d1`.
309    fn simpson<F: Fn(f64) -> f64>(f: F, a: f64, b: f64, n: usize) -> f64 {
310        let h = (b - a) / n as f64;
311        let mut s = f(a) + f(b);
312        for i in 1..n {
313            s += f(a + i as f64 * h) * if i % 2 == 1 { 4.0 } else { 2.0 };
314        }
315        s * h / 3.0
316    }
317
318    fn bose_integrand(t: f64) -> f64 {
319        if t < 1e-8 {
320            1.0 - 0.5 * t
321        } else {
322            t / t.exp_m1()
323        }
324    }
325
326    fn d1_quadrature(x: f64) -> f64 {
327        simpson(bose_integrand, 0.0, x, 20_000) / x
328    }
329
330    #[test]
331    fn debye_function_matches_quadrature() {
332        for &x in &[1e-6, 0.01, 0.3, 1.0, 2.9, 3.0, 3.1, 5.0, 10.0, 25.0, 40.0] {
333            let want = d1_quadrature(x);
334            let got = debye_d1(x);
335            assert!((got - want).abs() <= 1e-12 * want, "x={x}: {got} vs {want}");
336        }
337        assert_eq!(debye_d1(0.0), 1.0);
338    }
339
340    #[test]
341    fn debye_function_known_series_terms() {
342        // D1(x) = 1 - x/4 + x^2/36 - x^4/3600 + ...
343        let x = 0.1f64;
344        let s = 1.0 - x / 4.0 + x * x / 36.0 - x.powi(4) / 3600.0;
345        assert!((debye_d1(x) - s).abs() < 1e-9);
346    }
347
348    /// The formula as written in the module docs, evaluated independently.
349    fn formula(theta: f64, t: f64, mass_amu: f64) -> f64 {
350        let m = mass_amu * ATOMIC_MASS_UNIT;
351        let x = theta / t;
352        3.0 * HBAR * HBAR / (m * BOLTZMANN * theta) * (d1_quadrature(x) / x + 0.25)
353    }
354
355    #[test]
356    fn matches_cited_formula() {
357        for &(theta, t, mass) in &[
358            (640.0, 300.0, 28.085),
359            (374.0, 77.0, 72.63),
360            (360.0, 600.0, 69.723),
361            (1200.0, 1000.0, 12.011),
362            (500.0, 5.0, 40.0),
363        ] {
364            let v = ThermalVibration::new(theta, mass, t).unwrap();
365            let want = formula(theta, t, mass);
366            assert!(
367                (v.mean_square_1d() - want).abs() <= 1e-12 * want,
368                "{theta} {t}: {} vs {want}",
369                v.mean_square_1d()
370            );
371            assert!((v.rms_3d() - 3f64.sqrt() * want.sqrt()).abs() <= 1e-12 * want.sqrt() * 2.0);
372        }
373    }
374
375    /// Direct integration of the harmonic coth form with the Debye density of
376    /// states g(w) = 9 w^2 / w_D^3 (w angular frequency, normalised so int_0^{w_D} g dw = 3
377    /// modes per atom): u1^2 = (hbar/(6 m)) int g(w) coth(hbar w/2kT) / w dw.
378    #[test]
379    fn matches_coth_integral_with_debye_dos() {
380        let (theta, mass) = (640.0, 28.085);
381        let m = mass * ATOMIC_MASS_UNIT;
382        for &t in &[20.0, 100.0, 300.0, 900.0] {
383            // Variable y = hbar w / (k T) in (0, x]; w = y kT/hbar, w_D = x kT/hbar.
384            let x = theta / t;
385            let kt = BOLTZMANN * t;
386            // g normalised to 3 modes: int_0^{wD} 9 w^2/wD^3 dw = 3.
387            // u1^2 = (hbar/(6 m)) * int g(w) coth(y/2)/w dw
388            let integrand = |y: f64| {
389                if y < 1e-9 {
390                    // y coth(y/2) -> 2 as y -> 0.
391                    return 9.0 * 2.0 / (x * x * x) / (kt / HBAR);
392                }
393                // g(w) dw / w = 9 w dw / wD^3 = 9 y dy / (x^3 kT/hbar), using:
394                // w dw / wD^3 = y dy / (x^3 (kT/hbar)).
395                9.0 * y / (x * x * x) / (kt / HBAR) * (y / 2.0).tanh().recip()
396            };
397            let val = HBAR / (2.0 * m) / 3.0 * simpson(integrand, 0.0, x, 200_000);
398            let got = ThermalVibration::new(theta, mass, t)
399                .unwrap()
400                .mean_square_1d();
401            assert!((got - val).abs() <= 1e-9 * val, "T={t}: {got} vs {val}");
402        }
403    }
404
405    #[test]
406    fn zero_temperature_is_zero_point() {
407        for &(theta, mass) in &[(640.0, 28.085), (374.0, 72.63), (1200.0, 12.011)] {
408            let v = ThermalVibration::new(theta, mass, 0.0).unwrap();
409            let m = mass * ATOMIC_MASS_UNIT;
410            let zp = 3.0 * HBAR * HBAR / (4.0 * m * BOLTZMANN * theta);
411            assert!((v.mean_square_1d() - zp).abs() <= 1e-12 * zp);
412            // And the T -> 0+ limit of the finite-T evaluation.
413            let tiny = ThermalVibration::new(theta, mass, theta * 1e-8).unwrap();
414            assert!((tiny.mean_square_1d() - zp).abs() <= 1e-12 * zp);
415        }
416    }
417
418    #[test]
419    fn classical_limit_at_ten_theta() {
420        for &(theta, mass) in &[(640.0, 28.085), (374.0, 72.63), (360.0, 69.723)] {
421            let v = ThermalVibration::new(theta, mass, 10.0 * theta).unwrap();
422            let cl = v.classical_mean_square_1d();
423            let rel = (v.mean_square_1d() - cl).abs() / cl;
424            assert!(rel < 0.01, "{rel}");
425        }
426    }
427
428    #[test]
429    fn grows_with_temperature_and_rejects_bad_input() {
430        let a = ThermalVibration::new(640.0, 28.085, 100.0).unwrap();
431        let b = ThermalVibration::new(640.0, 28.085, 300.0).unwrap();
432        assert!(b.rms_1d() > a.rms_1d());
433        assert!(ThermalVibration::new(0.0, 28.0, 300.0).is_err());
434        assert!(ThermalVibration::new(f64::NAN, 28.0, 300.0).is_err());
435        assert!(ThermalVibration::new(640.0, -1.0, 300.0).is_err());
436        assert!(ThermalVibration::new(640.0, 28.0, -1.0).is_err());
437        assert!(ThermalVibration::new(640.0, 28.0, f64::INFINITY).is_err());
438    }
439
440    #[test]
441    fn sampled_rms_matches_model() {
442        let v = ThermalVibration::new(THETA_D_SI, 28.085, 300.0).unwrap();
443        let mut rng = stream(7, 0);
444        let n = 1_000_000;
445        let (mut s1, mut s3) = (0.0, 0.0);
446        for _ in 0..n {
447            let d = v.sample_displacement(&mut rng);
448            s1 += d[0] * d[0];
449            s3 += d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
450        }
451        let rms1 = (s1 / n as f64).sqrt();
452        let rms3 = (s3 / n as f64).sqrt();
453        assert!((rms1 / v.rms_1d() - 1.0).abs() < 0.01, "{rms1}");
454        assert!((rms3 / v.rms_3d() - 1.0).abs() < 0.01, "{rms3}");
455    }
456
457    #[test]
458    fn thermal_only_is_total_minus_zero_point() {
459        for &t in &[0.0, 50.0, 300.0, 600.0] {
460            let v = ThermalVibration::new(THETA_D_SI, 28.085, t).unwrap();
461            let want = v.mean_square_1d() - v.zero_point_mean_square_1d();
462            let got = v.thermal_only_mean_square_1d();
463            assert!((got - want).abs() <= 1e-12 * v.mean_square_1d(), "T={t}");
464        }
465        let cold = ThermalVibration::new(THETA_D_SI, 28.085, 0.0).unwrap();
466        assert_eq!(cold.thermal_only_mean_square_1d(), 0.0);
467    }
468
469    #[test]
470    fn sampling_consumes_fixed_stream_length() {
471        let v = ThermalVibration::new(THETA_D_SI, 28.085, 300.0).unwrap();
472        let mut a = stream(3, 9);
473        let mut b = stream(3, 9);
474        let _ = v.sample_displacement(&mut a);
475        for _ in 0..6 {
476            b.next_u64();
477        }
478        assert_eq!(a.next_u64(), b.next_u64());
479    }
480}