Skip to main content

nereids_physics/
lattice_doppler.rs

1//! Doppler broadening by nuclei bound in a harmonic crystal.
2//!
3//! Lamb's incoherent harmonic model, the one SAMMY's crystal-lattice model
4//! (DOPUSH, after NJOY's LEAPR) expands in phonons: a neutron of energy `E`
5//! absorbed by a nucleus of mass ratio `A` leaves an energy `ε` to the
6//! lattice, drawn from
7//!
8//! ```text
9//! P(ε) = (1/2π) ∫ e^{iεt} Φ(t) dt,   Φ(t) = exp(R·[G(t) − G(0)]),
10//! G(t) = ∫ ρ(ω)/ω · [coth(ω/2kT)·cos ωt − i·sin ωt] dω,   R = E/(A + 1),
11//! ```
12//!
13//! `ρ` the phonon density of states, so the cross section is
14//!
15//! ```text
16//! σ_L(E) = e^{−2W}·σ(E + R) + ∫ P_c(ε)·σ(E + R − ε) dε,   2W = R·G(0),
17//! ```
18//!
19//! the first term the zero-phonon line and `P_c = P − e^{−2W}·δ`.  `P` has
20//! mean `R` and variance `2R·kT_eff` with `kT_eff = ½∫ρ·ω·coth(ω/2kT) dω`,
21//! so a free gas at `T_eff` has the same two moments.
22//!
23//! `σ` is sampled once on a uniform grid of step `h`, the integral is the
24//! trapezoid rule on that grid, and `P_c` comes on the grid's nodes from a
25//! fast Fourier transform of `Φ − e^{−2W}`, sampled at `t = j·dt` up to
26//! `100/ω_max`, which resolves the phonon spectrum's structure, whose weight
27//! `e^{−2W}·2W` the one-phonon term carries.  A resonance of total width `Γ`
28//! makes the trapezoid rule's error `≈ e^{−πΓ/h}`, so `h ≤ πΓ/30` for every
29//! resonance the kernel reaches, `h ≤ π·ω_max/100`, and `h` is a tenth of
30//! every File-3 knot interval within reach.
31
32use std::f64::consts::PI;
33
34use nereids_core::constants::BOLTZMANN_EV_PER_K;
35use nereids_endf::resonance::ResonanceData;
36use num_complex::Complex64;
37use rayon::prelude::*;
38
39use crate::continuous_doppler::{Channel, TierOneBroadening};
40use crate::doppler::{DopplerError, DopplerParams, validate_doppler_grid};
41use crate::reich_moore::CrossSectionPlan;
42
43/// Standard deviations of the kernel, and phonon energies beyond them, its
44/// window spans on each side of its mean.
45const KERNEL_SIGMAS: f64 = 8.0;
46const KERNEL_PHONONS: f64 = 10.0;
47
48/// Most points of the cross section's grid, or of one transform.
49const MAX_POINTS: usize = 1 << 22;
50
51/// Most terms of the phonon sums and the transforms together.
52const MAX_WORK: f64 = 1e10;
53
54/// Most departure from 1 of a kernel's weights: the zero-phonon line's and
55/// `h` times its sampled values over its window.
56const MASS_TOLERANCE: f64 = 1e-7;
57
58/// Grid steps per File-3 knot interval within the kernel's reach.
59const STEPS_PER_KNOT: f64 = 10.0;
60
61/// Gauss–Legendre 8-point nodes and weights on `[−1, 1]`, non-negative half.
62const GAUSS_LEGENDRE_8: [(f64, f64); 4] = [
63    (0.183_434_642_495_649_8, 0.362_683_783_378_362),
64    (0.525_532_409_916_329, 0.313_706_645_877_887_3),
65    (0.796_666_477_413_626_7, 0.222_381_034_453_374_5),
66    (0.960_289_856_497_536_3, 0.101_228_536_290_376_26),
67];
68
69/// Errors from lattice broadening.
70#[derive(Debug)]
71pub enum LatticeError {
72    /// The phonon spectrum is not a valid density of states.
73    InvalidSpectrum(String),
74    /// The temperature is not finite and positive.
75    InvalidTemperature(f64),
76    /// The energy grid is invalid.
77    Grid(DopplerError),
78    /// The kernel reaches energies at or below zero.
79    BelowZero(f64),
80    /// File 3 jumps at this energy, within the kernel's reach.
81    Jump(f64),
82    /// The kernel at this energy is not resolved: its weights sum to `mass`.
83    Unresolved {
84        /// The target energy, eV.
85        energy_ev: f64,
86        /// The kernel's weights' sum.
87        mass: f64,
88    },
89    /// The cross section's grid or a transform would exceed 2²² points.
90    TooManyPoints(usize),
91    /// The phonon sums and transforms would take more than 10¹⁰ terms.
92    TooMuchWork(f64),
93}
94
95impl std::fmt::Display for LatticeError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            Self::InvalidSpectrum(message) => write!(f, "invalid phonon spectrum: {message}"),
99            Self::InvalidTemperature(t) => {
100                write!(f, "the temperature must be finite and positive, got {t} K")
101            }
102            Self::Grid(error) => write!(f, "{error}"),
103            Self::BelowZero(e) => {
104                write!(
105                    f,
106                    "the lattice kernel reaches {e} eV, at or below zero energy"
107                )
108            }
109            Self::TooManyPoints(n) => {
110                write!(
111                    f,
112                    "lattice broadening needs {n} points, more than {MAX_POINTS}"
113                )
114            }
115            Self::Jump(e) => write!(
116                f,
117                "File 3 jumps at {e} eV, within the lattice kernel's reach"
118            ),
119            Self::Unresolved { energy_ev, mass } => write!(
120                f,
121                "the lattice kernel at {energy_ev} eV is not resolved: its weights sum to {mass}"
122            ),
123            Self::TooMuchWork(work) => {
124                write!(f, "lattice broadening needs {work:e} terms, more than 1e10")
125            }
126        }
127    }
128}
129
130impl std::error::Error for LatticeError {}
131
132impl From<DopplerError> for LatticeError {
133    fn from(error: DopplerError) -> Self {
134        Self::Grid(error)
135    }
136}
137
138/// A crystal's phonon density of states `ρ(ω)`, `ω` in eV: the values
139/// `ρ_i` at `ω = i·step`, `ρ_0 = 0`, quadratic in `ω` up to the first
140/// point, linear between the others and zero beyond the last, scaled to
141/// unit area.
142#[derive(Debug, Clone, PartialEq)]
143pub struct PhononSpectrum {
144    step_ev: f64,
145    density: Vec<f64>,
146}
147
148impl PhononSpectrum {
149    /// The spectrum with values `density` every `step_ev` from 0.
150    ///
151    /// # Errors
152    /// [`LatticeError::InvalidSpectrum`] unless `step_ev` is finite and
153    /// positive, `density` holds at least two finite values, 0 or more, the
154    /// first 0 and another positive, and the scaled values are finite.
155    pub fn new(step_ev: f64, mut density: Vec<f64>) -> Result<Self, LatticeError> {
156        if !(step_ev.is_finite() && step_ev > 0.0)
157            || density.len() < 2
158            || density[0] != 0.0
159            || !density.iter().all(|r| r.is_finite() && *r >= 0.0)
160            || !density.iter().any(|r| *r > 0.0)
161        {
162            return Err(LatticeError::InvalidSpectrum(format!(
163                "needs a finite positive step and at least two finite values, 0 or more, the \
164                 first 0 and another positive; got step {step_ev} and {} values",
165                density.len()
166            )));
167        }
168        let peak = density.iter().fold(0.0_f64, |m, &r| m.max(r));
169        for r in &mut density {
170            *r /= peak;
171        }
172        let area = step_ev * density[1] / 3.0
173            + step_ev
174                * density[1..]
175                    .windows(2)
176                    .map(|w| 0.5 * (w[0] + w[1]))
177                    .sum::<f64>();
178        for r in &mut density {
179            *r /= area;
180        }
181        if !(area.is_finite() && area > 0.0 && density.iter().all(|r| r.is_finite())) {
182            return Err(LatticeError::InvalidSpectrum(format!(
183                "its area {area} eV after scaling to a peak of 1 leaves values that are not finite"
184            )));
185        }
186        Ok(Self { step_ev, density })
187    }
188
189    /// Debye's spectrum `3ω²/ω_D³` up to `ω_D = k·θ_D`, on `points` values.
190    ///
191    /// # Errors
192    /// [`LatticeError::InvalidSpectrum`] unless `theta_k` is finite and
193    /// positive and `points` is at least 2.
194    pub fn debye(theta_k: f64, points: usize) -> Result<Self, LatticeError> {
195        let cutoff = BOLTZMANN_EV_PER_K * theta_k;
196        if !(cutoff.is_finite() && cutoff > 0.0) || points < 2 {
197            return Err(LatticeError::InvalidSpectrum(format!(
198                "Debye needs a finite positive temperature and at least 2 points, got {theta_k} K \
199                 and {points}"
200            )));
201        }
202        let step = cutoff / (points - 1) as f64;
203        Self::new(
204            step,
205            (0..points)
206                .map(|i| (i as f64 * step / cutoff).powi(2))
207                .collect(),
208        )
209    }
210
211    /// The highest phonon energy, eV.
212    #[must_use]
213    pub fn max_ev(&self) -> f64 {
214        self.step_ev * (self.density.len() - 1) as f64
215    }
216
217    /// `T_eff`, the free gas's temperature with the kernel's variance at
218    /// `temperature_k`: `k·T_eff = ½∫ρ·ω·coth(ω/2kT) dω`.
219    #[must_use]
220    pub fn effective_temperature_k(&self, temperature_k: f64) -> f64 {
221        Phonons::new(&self.nodes(0.0), temperature_k, 0.0, 0).kt_eff / BOLTZMANN_EV_PER_K
222    }
223
224    /// How far in energy, eV, the kernel of a nucleus of mass ratio `awr` at
225    /// `energy_ev` and `temperature_k` reaches on either side of `E`: the
226    /// span [`broaden_in_lattice`] reads the cross section over.
227    #[must_use]
228    pub fn reach_ev(&self, energy_ev: f64, temperature_k: f64, awr: f64) -> f64 {
229        let kt_eff = BOLTZMANN_EV_PER_K * self.effective_temperature_k(temperature_k);
230        reach(energy_ev / (awr + 1.0), kt_eff, self.max_ev())
231    }
232
233    /// The number of quadrature nodes [`Self::nodes`] gives for `t_max`.
234    fn node_count(&self, t_max: f64) -> f64 {
235        (self.density.len() - 1) as f64 * (self.step_ev * t_max).ceil().max(1.0) * 16.0
236    }
237
238    /// Quadrature nodes `(ω, w·ρ(ω))` over the spectrum, each panel spanning
239    /// at most one radian of `ω·t` up to `t_max`.
240    fn nodes(&self, t_max: f64) -> Vec<(f64, f64)> {
241        let mut nodes = Vec::new();
242        let h = self.step_ev;
243        let pieces = ((h * t_max).ceil() as usize).max(1);
244        for i in 0..self.density.len() - 1 {
245            let (r0, r1) = (self.density[i], self.density[i + 1]);
246            let at = |omega: f64| {
247                if i == 0 {
248                    r1 * (omega / h).powi(2)
249                } else {
250                    r0 + (r1 - r0) * (omega / h - i as f64)
251                }
252            };
253            let width = h / pieces as f64;
254            for p in 0..pieces {
255                let centre = i as f64 * h + (p as f64 + 0.5) * width;
256                for &(x, w) in &GAUSS_LEGENDRE_8 {
257                    for omega in [centre - 0.5 * width * x, centre + 0.5 * width * x] {
258                        nodes.push((omega, 0.5 * width * w * at(omega)));
259                    }
260                }
261            }
262        }
263        nodes
264    }
265}
266
267/// The kernel's reach on either side of its mean, eV, at recoil `r`.
268fn reach(r: f64, kt_eff: f64, omega_max: f64) -> f64 {
269    KERNEL_SIGMAS * (2.0 * r * kt_eff).sqrt() + KERNEL_PHONONS * omega_max
270}
271
272/// The spectrum's sums at one temperature: `G(t_j)` and `∂G/∂T` at
273/// `t_j = j·dt`, `j = 0..=half`, and `kT_eff`.
274struct Phonons {
275    g: Vec<Complex64>,
276    g_slope: Vec<f64>,
277    kt_eff: f64,
278}
279
280impl Phonons {
281    fn new(nodes: &[(f64, f64)], temperature_k: f64, dt: f64, half: usize) -> Self {
282        let kt = BOLTZMANN_EV_PER_K * temperature_k;
283        let terms: Vec<(f64, f64, f64, f64)> = nodes
284            .iter()
285            .map(|&(omega, weight)| {
286                let x = omega / (2.0 * kt);
287                let coth = 1.0 / x.tanh();
288                let slope = x / (temperature_k * x.sinh().powi(2));
289                (omega, weight / omega, coth, slope)
290            })
291            .collect();
292        let kt_eff = 0.5
293            * terms
294                .iter()
295                .map(|&(omega, a, coth, _)| a * omega * omega * coth)
296                .sum::<f64>();
297        let (g, g_slope) = (0..=half)
298            .into_par_iter()
299            .map(|j| {
300                let t = j as f64 * dt;
301                let (mut g, mut slope) = (Complex64::new(0.0, 0.0), 0.0);
302                for &(omega, a, coth, dcoth) in &terms {
303                    let (sin, cos) = (omega * t).sin_cos();
304                    g += a * Complex64::new(coth * cos, -sin);
305                    slope += a * dcoth * cos;
306                }
307                (g, slope)
308            })
309            .unzip();
310        Self { g, g_slope, kt_eff }
311    }
312}
313
314/// `values` replaced by its discrete Fourier transform,
315/// `values[m] ← Σ_j values[j]·e^{−2πi·mj/N}`, `N` a power of 2.
316fn dft(values: &mut [Complex64]) {
317    let n = values.len();
318    let mut j = 0;
319    for i in 1..n {
320        let mut bit = n >> 1;
321        while j & bit != 0 {
322            j ^= bit;
323            bit >>= 1;
324        }
325        j |= bit;
326        if i < j {
327            values.swap(i, j);
328        }
329    }
330    let mut len = 2;
331    while len <= n {
332        let step = Complex64::from_polar(1.0, -2.0 * PI / len as f64);
333        for start in (0..n).step_by(len) {
334            let mut w = Complex64::new(1.0, 0.0);
335            for k in 0..len / 2 {
336                let (a, b) = (values[start + k], values[start + k + len / 2] * w);
337                values[start + k] = a + b;
338                values[start + k + len / 2] = a - b;
339                w *= step;
340            }
341        }
342        len <<= 1;
343    }
344}
345
346/// Value and temperature derivative of `channel` at every target energy,
347/// broadened by nuclei bound in a harmonic crystal with phonon spectrum
348/// `spectrum` at `temperature_k`.  `negative_values` counts the negative
349/// values; nothing is roundoff-limited.
350///
351/// # Errors
352/// [`LatticeError::InvalidTemperature`]; [`LatticeError::Grid`] for an empty
353/// or invalid grid or a mass ratio that is not finite and positive;
354/// [`LatticeError::BelowZero`]; [`LatticeError::Jump`] for a File-3 jump
355/// within a kernel's reach; [`LatticeError::TooManyPoints`] and
356/// [`LatticeError::TooMuchWork`]; [`LatticeError::Unresolved`] for a kernel
357/// whose weights do not sum to 1 within 1e-7.
358pub fn broaden_in_lattice(
359    energies: &[f64],
360    data: &ResonanceData,
361    temperature_k: f64,
362    spectrum: &PhononSpectrum,
363    channel: Channel,
364) -> Result<TierOneBroadening, LatticeError> {
365    if !(temperature_k.is_finite() && temperature_k > 0.0) {
366        return Err(LatticeError::InvalidTemperature(temperature_k));
367    }
368    DopplerParams::new(temperature_k, data.awr).map_err(DopplerError::from)?;
369    validate_doppler_grid(energies)?;
370    if energies.is_empty() {
371        return Err(DopplerError::EmptyGrid.into());
372    }
373    let recoil = |energy: f64| energy / (data.awr + 1.0);
374    let omega_max = spectrum.max_ev();
375    let kt_eff = BOLTZMANN_EV_PER_K * spectrum.effective_temperature_k(temperature_k);
376    let reach_at = |energy: f64| reach(recoil(energy), kt_eff, omega_max);
377    let lowest = energies
378        .iter()
379        .map(|&e| e - reach_at(e))
380        .fold(f64::INFINITY, f64::min);
381    let highest = energies
382        .iter()
383        .map(|&e| e + reach_at(e))
384        .fold(f64::NEG_INFINITY, f64::max);
385    if lowest <= 0.0 {
386        return Err(LatticeError::BelowZero(lowest));
387    }
388    let narrowest = data
389        .ranges
390        .iter()
391        .filter(|range| range.is_evaluable())
392        .flat_map(|range| range.levels())
393        .filter(|&(energy, _)| energy >= lowest - 1.0 && energy <= highest + 1.0)
394        .map(|(_, width)| width)
395        .filter(|&width| width > 0.0)
396        .fold(f64::INFINITY, f64::min);
397    let mut h = (PI * narrowest / 30.0).min(PI * omega_max / 100.0);
398    for table in data.smooth.tables() {
399        let start = table.points.partition_point(|&(e, _)| e < lowest);
400        let stop = table.points.partition_point(|&(e, _)| e <= highest);
401        let around = &table.points[start.saturating_sub(1)..(stop + 1).min(table.points.len())];
402        for pair in around.windows(2) {
403            let spacing = pair[1].0 - pair[0].0;
404            if spacing == 0.0 {
405                return Err(LatticeError::Jump(pair[0].0));
406            }
407            h = h.min(spacing / STEPS_PER_KNOT);
408        }
409    }
410    let widest = energies.iter().map(|&e| reach_at(e)).fold(0.0, f64::max);
411    let transform = ((2.0 * widest / h).ceil() as usize + 1).next_power_of_two();
412    let origin = lowest - 2.0 * h;
413    if origin <= 0.0 {
414        return Err(LatticeError::BelowZero(origin));
415    }
416    let grid_points = ((highest - origin) / h).ceil() as usize + 3;
417    for needed in [transform, grid_points] {
418        if needed > MAX_POINTS {
419            return Err(LatticeError::TooManyPoints(needed));
420        }
421    }
422    let half = transform / 2;
423    let dt = 2.0 * PI / (transform as f64 * h);
424    let t_max = (100.0 / omega_max).min(PI / h);
425    let samples = half.min((t_max / dt).ceil() as usize);
426    let work = spectrum.node_count(t_max) * (samples + 1) as f64
427        + energies.len() as f64 * transform as f64 * (transform as f64).log2();
428    if work > MAX_WORK {
429        return Err(LatticeError::TooMuchWork(work));
430    }
431    let phonons = Phonons::new(&spectrum.nodes(t_max), temperature_k, dt, samples);
432    let plan = CrossSectionPlan::new(data);
433    let sigma = |energy: f64| channel.pick(&plan.evaluate_one(energy));
434    let grid: Vec<f64> = (0..grid_points)
435        .into_par_iter()
436        .map(|m| sigma(origin + m as f64 * h))
437        .collect();
438    let g0 = phonons.g[0].re;
439    let g0_slope = phonons.g_slope[0];
440    let results: Vec<Result<(f64, f64), LatticeError>> = energies
441        .par_iter()
442        .map(|&energy| {
443            let r = recoil(energy);
444            let zero = (-r * g0).exp();
445            let zero_slope = -zero * r * g0_slope;
446            let shift = energy + r - origin;
447            let mut buffer = vec![Complex64::new(0.0, 0.0); transform];
448            for j in 0..samples.min(half) {
449                let phi = (r * (phonons.g[j] - g0)).exp();
450                let value = phi - zero;
451                let slope = phi * r * (phonons.g_slope[j] - g0_slope) - zero_slope;
452                let t = j as f64 * dt;
453                let rotate = Complex64::from_polar(dt / (2.0 * PI), shift * t);
454                buffer[j] = rotate * (value + Complex64::i() * slope);
455                if j > 0 {
456                    let back = rotate.conj() * (value.conj() + Complex64::i() * slope.conj());
457                    buffer[transform - j] = back;
458                }
459            }
460            dft(&mut buffer);
461            let own = reach_at(energy);
462            let first = ((energy - own - origin) / h).floor().max(0.0) as usize;
463            let last = (((energy + own - origin) / h).ceil() as usize).min(grid_points - 1);
464            let (mut mass, mut value, mut slope) = (zero, 0.0, 0.0);
465            for (m, &s) in grid.iter().enumerate().take(last + 1).skip(first) {
466                let kernel = buffer[m % transform];
467                mass += h * kernel.re;
468                value += kernel.re * s;
469                slope += kernel.im * s;
470            }
471            if (mass - 1.0).abs() > MASS_TOLERANCE {
472                return Err(LatticeError::Unresolved {
473                    energy_ev: energy,
474                    mass,
475                });
476            }
477            let at_recoil = sigma(energy + r);
478            Ok((
479                h * value + zero * at_recoil,
480                h * slope + zero_slope * at_recoil,
481            ))
482        })
483        .collect();
484    let (values, derivatives): (Vec<f64>, Vec<f64>) = results
485        .into_iter()
486        .collect::<Result<Vec<_>, _>>()?
487        .into_iter()
488        .unzip();
489    if let Some((k, &value)) = values
490        .iter()
491        .chain(&derivatives)
492        .enumerate()
493        .find(|(_, v)| !v.is_finite())
494    {
495        return Err(DopplerError::NonFiniteIntegral {
496            energy_ev: energies[k % energies.len()],
497            value,
498            derivative: k >= energies.len(),
499        }
500        .into());
501    }
502    let negative_values = values.iter().filter(|&&v| v < 0.0).count();
503    Ok(TierOneBroadening {
504        values,
505        derivatives,
506        negative_values,
507        roundoff_limited: 0,
508    })
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn the_transform_is_the_discrete_fourier_transform() {
517        let n = 32;
518        let values: Vec<Complex64> = (0..n)
519            .map(|j| Complex64::new((0.7 * j as f64).sin(), (1.3 * j as f64).cos() - 0.2))
520            .collect();
521        let mut fast = values.clone();
522        dft(&mut fast);
523        for (m, fast) in fast.iter().enumerate() {
524            let direct: Complex64 = values
525                .iter()
526                .enumerate()
527                .map(|(j, v)| v * Complex64::from_polar(1.0, -2.0 * PI * (m * j) as f64 / n as f64))
528                .sum();
529            assert!((fast - direct).norm() <= 1e-12, "{m}: {fast} {direct}");
530        }
531    }
532}