Skip to main content

lindhard/electron/inelastic/table/
refine.rs

1//! The rate refinement of the inelastic table's energy grid (parent module
2//! docs, "Rate refinement"): [`RateRefinement`], the rate of a row and the
3//! extra rows that hold its linear interpolation to a tolerance.
4
5use super::build::InelasticTableOptions;
6use super::density::LossModel;
7use super::{invalid, EnergyAxis, InelasticTableError};
8use rayon::prelude::*;
9
10/// The tolerance of the rate refinement (#339) and of the `rate` gate of
11/// `lindhard-cli/examples/inelastic_low_energy.rs` (#291), fixed there before
12/// any value was measured: the largest relative error of the interpolated
13/// `1/λ` over [`RATE_WINDOW_EV`] above the Fermi level must not exceed 1 %.
14/// It is the statistical error of the most precise committed `default` δ
15/// entries, rounded up: in `validation/experiments/se_yield_results.json`
16/// the Poisson floor of Al `default` is 0.0585 on δ = 6.85 at 600 eV
17/// (0.85 %) and 0.0571 on δ = 6.52 at 400 eV (0.88 %). Other configurations
18/// are more precise: Al `barrier-off` reaches 0.60 % (600 eV), and at 400 eV
19/// the Al `barrier-off`, `cutoff-band-bottom`, `phi-low` and `phi-high`
20/// entries are 0.63 to 0.90 %; the Au, Cu and Si `default` entries at 400 eV
21/// are 1.4, 1.7 and 2.8 %. A grid error below the tolerance cannot be told
22/// from the noise of those tables if δ responds at most in proportion to the
23/// rate in that window; that response is not measured here.
24pub const RATE_TOLERANCE: f64 = 0.01;
25
26/// The window of the rate refinement and of the `rate` gate (#291, #339):
27/// `E - E_F` from 2 to 20 eV. The upper end covers the steep rise of the
28/// rate above the Fermi level, which for the Al single pole peaks in error at
29/// 15 to 17 eV. The lower end lies below the transport cutoff of every
30/// `default` δ run of `validation/experiments/se_yield.py` (1 eV above the
31/// vacuum level, at least 5 eV above `E_F`); the `cutoff-band-bottom` runs
32/// read the rate down to 1 eV above `E_F`. Below 2 eV the rate starts from
33/// zero at `E_F`, and a relative tolerance there would bisect towards that
34/// node without end.
35pub const RATE_WINDOW_EV: (f64, f64) = (2.0, 20.0);
36
37/// The default bound on the rows the rate refinement may add (a safety
38/// bound against a rate that fails the test in many cells at once;
39/// not a tuned value): 1000 rows, so cells of 18 meV on average across the
40/// 18 eV of [`RATE_WINDOW_EV`], far below the 0.1 eV step of the `rate`
41/// gate. A step in the rate splits one cell per level and is stopped
42/// earlier, when its cell is exhausted to rounding (module docs, "Rate
43/// refinement").
44pub const DEFAULT_RATE_REFINEMENT_MAX_ROWS: usize = 1000;
45
46/// Extra rows that resolve the inelastic rate `λ⁻¹(E)` near the Fermi level
47/// on the band-bottom axis (module docs, "Rate refinement"; #339).
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct RateRefinement {
50    /// The window of `E - E_F` (eV) whose cells are refined: a cell is
51    /// refined when it overlaps it.
52    pub window_ev: (f64, f64),
53    /// The relative tolerance of the linear interpolation of the rate at a
54    /// cell's test points (its quartiles and midpoint).
55    pub tolerance: f64,
56    /// The most rows the refinement may add; more is an error.
57    pub max_rows: usize,
58}
59
60impl Default for RateRefinement {
61    /// [`RATE_WINDOW_EV`], [`RATE_TOLERANCE`] and
62    /// [`DEFAULT_RATE_REFINEMENT_MAX_ROWS`].
63    fn default() -> Self {
64        Self {
65            window_ev: RATE_WINDOW_EV,
66            tolerance: RATE_TOLERANCE,
67            max_rows: DEFAULT_RATE_REFINEMENT_MAX_ROWS,
68        }
69    }
70}
71
72/// The inverse mean free path of the row at model energy `t` (`T`, module
73/// docs, "Energy axis"): zero where the row is stored empty (`T <= 0` on the
74/// band-bottom axis, or an empty loss window), else the model's, floored at
75/// zero. The rate every row of a table stores.
76pub(super) fn row_rate<M: LossModel>(
77    penn: &M,
78    axis: EnergyAxis,
79    t: f64,
80) -> Result<f64, InelasticTableError> {
81    if axis == EnergyAxis::BandBottom && t <= 0.0 {
82        // A band-bottom energy at or below the Fermi level: no loss is
83        // allowed. (On the model's own axis the model rejects such an
84        // energy.)
85        return Ok(0.0);
86    }
87    if t <= penn.loss_min_ev(t) {
88        // The sampled loss window (loss_min, T] is empty (#340): no loss can
89        // be drawn, so the row is empty, whatever rate the model reports
90        // below its lowest loss (module docs, "Rows below the ELF table").
91        return Ok(0.0);
92    }
93    let inv = penn.inverse_imfp(t)?;
94    Ok(if inv > 0.0 { inv } else { 0.0 })
95}
96
97/// The points at which the rate refinement tests the cell `a..b` (module
98/// docs, "Rate refinement"): its first quartile, midpoint and third
99/// quartile, each formed as the midpoint of a half, so that a quartile is
100/// bitwise the midpoint of the half a bisection would make.
101///
102/// The interpolation error of a function with constant curvature peaks at
103/// the midpoint; the band-bottom rate's curvature changes across a cell,
104/// and the relative error divides by a rate that changes across it, so the
105/// peak moves off the midpoint (#339 measured misses of up to 1.43 % in
106/// cells whose midpoint missed by less than 1 %). The quartiles are the
107/// points of the next level of the bisection itself, a fixed dyadic set
108/// rather than a tuned count; they bracket the peak, which
109/// `rate_error_peak` then locates.
110fn rate_test_points(a: f64, b: f64) -> [f64; 3] {
111    let m = 0.5 * (a + b);
112    [0.5 * (a + m), m, 0.5 * (m + b)]
113}
114
115/// The estimated position of the largest relative interpolation error in
116/// the cell `a..b`, from its signed relative errors `e` at the quartiles and
117/// midpoint (`rate_test_points`; the error is zero at the cell's ends): the
118/// vertex of the parabola through the largest of the five samples and its
119/// two neighbours (parabolic interpolation of an extremum, the step of
120/// Brent's method; R. P. Brent, *Algorithms for Minimization without
121/// Derivatives*, Prentice-Hall 1973, ch. 5). The largest sample's
122/// neighbours do not exceed it in magnitude, so the vertex lies between
123/// them. `None` where every sample is zero or the three are collinear.
124fn rate_error_peak(a: f64, b: f64, e: [f64; 3]) -> Option<f64> {
125    let s = [0.0, e[0], e[1], e[2], 0.0];
126    // The largest sample in magnitude (the midpoint on a tie).
127    let k = [2, 1, 3]
128        .into_iter()
129        .fold(2, |k, j| if s[j].abs() > s[k].abs() { j } else { k });
130    let (l, c, r) = (s[k - 1], s[k], s[k + 1]);
131    let curvature = l - 2.0 * c + r;
132    if c == 0.0 || curvature == 0.0 || !curvature.is_finite() {
133        return None;
134    }
135    let h = 0.25 * (b - a);
136    let xk = a + h * k as f64;
137    let v = xk + 0.5 * h * (l - r) / curvature;
138    (v > a && v < b && v.is_finite()).then_some(v)
139}
140
141/// The miss of the interpolated rate at a test point of the rate refinement.
142#[derive(Debug, Clone, Copy)]
143struct RateMiss {
144    /// `(interpolated - exact) / exact` (infinite where the exact rate is
145    /// zero and the interpolated one is not).
146    rel: f64,
147    /// The energy of the test point, eV.
148    at: f64,
149    /// The interpolated rate, 1/m.
150    interpolated: f64,
151    /// The exact rate, 1/m.
152    exact: f64,
153}
154
155/// The energy grid of a table with the rows of the rate refinement of
156/// `options` merged in (module docs, "Rate refinement"), or `None` without
157/// one. `options` is checked.
158fn refined_energy_grid<M: LossModel>(
159    penn: &M,
160    options: &InelasticTableOptions,
161) -> Result<Option<Vec<f64>>, InelasticTableError> {
162    let Some(r) = options.rate_refinement else {
163        return Ok(None);
164    };
165    let ef = penn.fermi_energy_ev();
166    let (lo, hi) = (ef + r.window_ev.0, ef + r.window_ev.1);
167    let grid = &options.energy_ev;
168    let overlaps = |a: f64, b: f64| b > lo && a < hi;
169    // The rate's onset, where it is at or above the window start (#357):
170    // a cell below it, or straddling it, is not tested (module docs). An
171    // onset below the window start changes nothing.
172    let onset = penn.rate_onset_ev().map(|w| ef + w).filter(|&o| o >= lo);
173    let tested = |a: f64, b: f64| overlaps(a, b) && onset.is_none_or(|o| a >= o);
174    // The cells that overlap the window are contiguous: [first, last].
175    let Some(first) = grid.windows(2).position(|w| overlaps(w[0], w[1])) else {
176        return Ok(Some(grid.clone()));
177    };
178    let last = grid
179        .windows(2)
180        .rposition(|w| overlaps(w[0], w[1]))
181        .expect("one cell");
182    let rate = |e: f64| row_rate(penn, options.axis, e - ef);
183    // The nodes of the refined span and their exact rates, in parallel and
184    // collected in grid order; the lowest failing energy is the error.
185    let mut x: Vec<f64> = grid[first..=last + 1].to_vec();
186    let mut y: Vec<f64> = x.par_iter().map(|&e| rate(e)).collect::<Result<_, _>>()?;
187    // Cells still to test: those that overlap the window, at or above the
188    // onset.
189    let mut todo: Vec<bool> = x.windows(2).map(|w| tested(w[0], w[1])).collect();
190    let mut added = 0usize;
191    loop {
192        let cells: Vec<usize> = (0..todo.len()).filter(|&i| todo[i]).collect();
193        // A cell still to test is the child of a cell that failed. If it
194        // can no longer be bisected (its midpoint rounds to an end), the
195        // rate does not meet the tolerance there at any width, as at a
196        // step: an error naming the lowest such cell, never a grid that
197        // leaves it unresolved.
198        if let Some(&i) = cells.iter().find(|&&i| {
199            let m = 0.5 * (x[i] + x[i + 1]);
200            !(m > x[i] && m < x[i + 1])
201        }) {
202            return Err(invalid(
203                "rate refinement",
204                format!(
205                    "cannot hold the rate to {} between E - E_F = {} and {} eV: cell {}..{} eV \
206                     is exhausted to rounding and its parent still failed at a test point (end \
207                     rates {:e} and {:e} 1/m)",
208                    r.tolerance,
209                    r.window_ev.0,
210                    r.window_ev.1,
211                    x[i],
212                    x[i + 1],
213                    y[i],
214                    y[i + 1]
215                ),
216            ));
217        }
218        if cells.is_empty() {
219            break;
220        }
221        // The test points of each cell, in cell order: its quartiles and
222        // midpoint (`rate_test_points`), then the estimated peak of its
223        // error (`rate_error_peak`) where those pass.
224        let points: Vec<[f64; 3]> = cells
225            .iter()
226            .map(|&i| rate_test_points(x[i], x[i + 1]))
227            .collect();
228        let exact: Vec<f64> = points
229            .par_iter()
230            .flat_map_iter(|p| p.iter().copied())
231            .map(rate)
232            .collect::<Result<_, _>>()?;
233        // The miss of the transport's linear interpolation in cell `i` at
234        // `e`, where the exact rate is `m`: signed, relative to `m` (a zero
235        // exact rate with a nonzero interpolant is an infinite miss), with
236        // the interpolated rate.
237        let miss = |i: usize, e: f64, m: f64| {
238            let lin = y[i] + (e - x[i]) / (x[i + 1] - x[i]) * (y[i + 1] - y[i]);
239            let d = lin - m;
240            let rel = if d == 0.0 {
241                0.0
242            } else if m > 0.0 {
243                d / m
244            } else {
245                f64::INFINITY
246            };
247            RateMiss {
248                rel,
249                at: e,
250                interpolated: lin,
251                exact: m,
252            }
253        };
254        let mut worst: Vec<RateMiss> = Vec::with_capacity(cells.len());
255        let mut peaks: Vec<(usize, f64)> = Vec::new();
256        for (k, (&i, (p, ex))) in cells
257            .iter()
258            .zip(points.iter().zip(exact.as_chunks::<3>().0))
259            .enumerate()
260        {
261            let m: Vec<RateMiss> = p.iter().zip(ex).map(|(&e, &v)| miss(i, e, v)).collect();
262            let w = m
263                .iter()
264                .fold(m[1], |a, &b| if b.rel.abs() > a.rel.abs() { b } else { a });
265            if w.rel.abs() <= r.tolerance {
266                if let Some(e) = rate_error_peak(x[i], x[i + 1], [m[0].rel, m[1].rel, m[2].rel]) {
267                    peaks.push((k, e));
268                }
269            }
270            worst.push(w);
271        }
272        // The estimated peaks of the cells that passed at their quartiles and
273        // midpoint, in parallel and in cell order.
274        let peak_exact: Vec<f64> = peaks
275            .par_iter()
276            .map(|&(_, e)| rate(e))
277            .collect::<Result<_, _>>()?;
278        for (&(k, e), &v) in peaks.iter().zip(&peak_exact) {
279            let p = miss(cells[k], e, v);
280            if p.rel.abs() > worst[k].rel.abs() {
281                worst[k] = p;
282            }
283        }
284        // Split at the midpoint where the miss at any test point exceeds the
285        // tolerance.
286        let split: Vec<bool> = worst.iter().map(|w| w.rel.abs() > r.tolerance).collect();
287        let n_split = split.iter().filter(|&&s| s).count();
288        if n_split == 0 {
289            break;
290        }
291        if added + n_split > r.max_rows {
292            let k = split.iter().position(|&s| s).expect("one split");
293            let i = cells[k];
294            let RateMiss {
295                at,
296                interpolated: lin,
297                exact: m,
298                ..
299            } = worst[k];
300            return Err(invalid(
301                "rate refinement",
302                format!(
303                    "more than {} rows needed to hold the rate to {} between E - E_F = {} and \
304                     {} eV (cell {}..{} eV still fails at {} eV: interpolated {:e}, exact {:e} \
305                     1/m)",
306                    r.max_rows,
307                    r.tolerance,
308                    r.window_ev.0,
309                    r.window_ev.1,
310                    x[i],
311                    x[i + 1],
312                    at,
313                    lin,
314                    m
315                ),
316            ));
317        }
318        added += n_split;
319        let mut nx = Vec::with_capacity(x.len() + n_split);
320        let mut ny = Vec::with_capacity(x.len() + n_split);
321        let mut ntodo = Vec::with_capacity(todo.len() + n_split);
322        let mut k = 0;
323        for i in 0..todo.len() {
324            nx.push(x[i]);
325            ny.push(y[i]);
326            if k < cells.len() && cells[k] == i {
327                if split[k] {
328                    // The midpoint, a test point: its exact rate is known.
329                    let m = points[k][1];
330                    nx.push(m);
331                    ny.push(exact[3 * k + 1]);
332                    ntodo.push(tested(x[i], m));
333                    ntodo.push(tested(m, x[i + 1]));
334                } else {
335                    ntodo.push(false);
336                }
337                k += 1;
338            } else {
339                ntodo.push(false);
340            }
341        }
342        nx.push(x[x.len() - 1]);
343        ny.push(y[y.len() - 1]);
344        x = nx;
345        y = ny;
346        todo = ntodo;
347    }
348    let mut out = Vec::with_capacity(grid.len() + added);
349    out.extend_from_slice(&grid[..first]);
350    out.extend_from_slice(&x);
351    out.extend_from_slice(&grid[last + 2..]);
352    Ok(Some(out))
353}
354
355/// `options` with the rows of its rate refinement merged into the energy
356/// grid (and the refinement then spent), or `options` itself without one.
357pub(super) fn with_refined_grid<'a, M: LossModel>(
358    penn: &M,
359    options: &'a InelasticTableOptions,
360) -> Result<(std::borrow::Cow<'a, InelasticTableOptions>, usize), InelasticTableError> {
361    Ok(match refined_energy_grid(penn, options)? {
362        None => (std::borrow::Cow::Borrowed(options), 0),
363        Some(energy_ev) => {
364            let added = energy_ev.len() - options.energy_ev.len();
365            let mut o = options.clone();
366            o.energy_ev = energy_ev;
367            (std::borrow::Cow::Owned(o), added)
368        }
369    })
370}
371
372/// The provenance text of the rate refinement: empty without one (so tables
373/// built without it keep their bytes).
374pub(super) fn refinement_provenance(options: &InelasticTableOptions, added: usize) -> String {
375    match &options.rate_refinement {
376        None => String::new(),
377        Some(r) => format!(
378            "; rate refinement (#339): {added} rows added where the linear interpolation of \
379             the rate missed it by more than {} at a cell's quartiles, midpoint or estimated \
380             error peak, E - E_F = {}..{} eV",
381            r.tolerance, r.window_ev.0, r.window_ev.1
382        ),
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::electron::data::ElectronDataError;
390    use crate::electron::inelastic::mermin::LOWEST_LOSS_FRACTION;
391    use crate::electron::inelastic::model::PennInelastic;
392
393    /// A loss model with Fermi energy 10 eV whose rate is `rate(T)`, and a
394    /// flat DIIMFP: synthetic.
395    struct SteepRate(fn(f64) -> f64);
396
397    impl LossModel for SteepRate {
398        fn elf_min_ev(&self) -> f64 {
399            0.05
400        }
401        fn fermi_energy_ev(&self) -> f64 {
402            10.0
403        }
404        fn diimfp(&self, energy_ev: f64, loss_ev: f64) -> Result<f64, ElectronDataError> {
405            Ok(if loss_ev > 0.0 && loss_ev <= energy_ev {
406                1.0
407            } else {
408                0.0
409            })
410        }
411        fn inverse_imfp(&self, energy_ev: f64) -> Result<f64, ElectronDataError> {
412            Ok((self.0)(energy_ev))
413        }
414    }
415
416    fn band_options(grid: Vec<f64>) -> InelasticTableOptions {
417        InelasticTableOptions::new(grid)
418            .with_axis(EnergyAxis::BandBottom)
419            .with_rate_refinement(RateRefinement::default())
420    }
421
422    /// A rate that climbs steeply above `E_F`, like the band-bottom rates of
423    /// #291 (synthetic).
424    fn steep(t: f64) -> f64 {
425        1e6 * t.powi(3) / (1.0 + (t / 15.0).powi(4))
426    }
427
428    #[test]
429    fn rate_refinement_adds_rows_only_in_the_window_and_meets_the_test() {
430        let model = SteepRate(steep);
431        let grid = vec![5.0, 9.0, 14.0, 22.0, 35.0, 60.0, 100.0];
432        let o = band_options(grid.clone());
433        let refined = refined_energy_grid(&model, &o).unwrap().unwrap();
434        assert!(refined.len() > grid.len());
435        assert!(refined.windows(2).all(|w| w[1] > w[0]));
436        // The given grid is a subsequence of the refined one.
437        let mut it = refined.iter();
438        assert!(grid.iter().all(|g| it.any(|r| r == g)));
439        let (lo, hi) = (10.0 + RATE_WINDOW_EV.0, 10.0 + RATE_WINDOW_EV.1);
440        // Rows are added only inside cells of the grid that overlap the
441        // window (here 9..35 eV), never in a cell outside it.
442        for e in refined.iter().filter(|e| !grid.contains(e)) {
443            let k = grid.partition_point(|g| g < e);
444            let (a, b) = (grid[k - 1], grid[k]);
445            assert!(b > lo && a < hi, "{e} in the cell {a}..{b}");
446        }
447        // Every final cell overlapping the window passes the test at its
448        // quartiles and midpoint.
449        let rate = |e: f64| row_rate(&model, EnergyAxis::BandBottom, e - 10.0).unwrap();
450        for w in refined.windows(2) {
451            if w[1] > lo && w[0] < hi {
452                for e in rate_test_points(w[0], w[1]) {
453                    let m = rate(e);
454                    let lin = rate(w[0]) + (e - w[0]) / (w[1] - w[0]) * (rate(w[1]) - rate(w[0]));
455                    assert!((lin - m).abs() <= RATE_TOLERANCE * m, "{w:?} at {e}");
456                }
457            }
458        }
459        // Without the option the grid is the given one.
460        let mut off = o.clone();
461        off.rate_refinement = None;
462        assert_eq!(refined_energy_grid(&model, &off).unwrap(), None);
463    }
464
465    #[test]
466    fn rate_refinement_is_the_same_on_any_thread_count() {
467        let model = SteepRate(steep);
468        let o = band_options(vec![5.0, 9.0, 14.0, 22.0, 35.0, 60.0, 100.0]);
469        let on = |threads: usize| {
470            rayon::ThreadPoolBuilder::new()
471                .num_threads(threads)
472                .build()
473                .unwrap()
474                .install(|| refined_energy_grid(&model, &o).unwrap())
475        };
476        let one = on(1);
477        assert_eq!(on(3), one);
478        assert_eq!(on(8), one);
479    }
480
481    #[test]
482    fn rate_refinement_of_a_step_stops_at_the_row_bound() {
483        // A step in the rate inside the window cannot be resolved by
484        // bisection: an error naming the cell, not a runaway.
485        let model = SteepRate(|t| if t < 7.3 { 1e6 } else { 2e6 });
486        let mut o = band_options(vec![5.0, 9.0, 14.0, 22.0, 35.0, 60.0, 100.0]);
487        o.rate_refinement.as_mut().unwrap().max_rows = 30;
488        let err = refined_energy_grid(&model, &o).unwrap_err().to_string();
489        assert!(err.contains("more than 30 rows"), "{err}");
490        assert!(err.contains("cell 17."), "{err}");
491    }
492
493    #[test]
494    fn rate_refinement_of_a_step_is_an_error_at_the_default_bound() {
495        // A step splits one cell per level, so at the default bound the
496        // cell around it reaches rounding (about 51 levels) long before the
497        // row bound: still an error naming the cell, never a grid with the
498        // step unresolved.
499        let model = SteepRate(|t| if t < 7.3 { 1e6 } else { 2e6 });
500        let o = band_options(vec![5.0, 9.0, 14.0, 22.0, 35.0, 60.0, 100.0]);
501        assert_eq!(
502            o.rate_refinement.unwrap().max_rows,
503            DEFAULT_RATE_REFINEMENT_MAX_ROWS
504        );
505        let err = refined_energy_grid(&model, &o).unwrap_err().to_string();
506        assert!(err.contains("exhausted to rounding"), "{err}");
507        assert!(err.contains("cell 17."), "{err}");
508    }
509
510    #[test]
511    fn rate_test_points_are_the_quartiles_and_midpoint() {
512        let p = rate_test_points(2.0, 6.0);
513        assert_eq!(p, [3.0, 4.0, 5.0]);
514        // A quartile is bitwise the midpoint of the half a bisection makes.
515        let (a, b) = (0.1, 0.7);
516        let p = rate_test_points(a, b);
517        assert_eq!(p[0], rate_test_points(a, p[1])[1]);
518        assert_eq!(p[2], rate_test_points(p[1], b)[1]);
519    }
520
521    #[test]
522    fn rate_error_peak_is_the_vertex_of_the_parabola_at_the_largest_sample() {
523        // Samples of 1.69 - (x - 1.3)^2 at the quartiles and midpoint of
524        // 0..4 (zero at x = 0): the largest is at x = 1, and the parabola
525        // through it and its neighbours x = 0 and 2 has its vertex at 1.3.
526        let g = |x: f64| 1.69 - (x - 1.3) * (x - 1.3);
527        let v = rate_error_peak(0.0, 4.0, [g(1.0), g(2.0), g(3.0)]).unwrap();
528        assert!((v - 1.3).abs() < 1e-12, "{v}");
529        // The same with the sign reversed (an interpolant below the rate).
530        let v = rate_error_peak(0.0, 4.0, [-g(1.0), -g(2.0), -g(3.0)]).unwrap();
531        assert!((v - 1.3).abs() < 1e-12, "{v}");
532        // A symmetric profile peaks at the midpoint; no samples, no peak.
533        let v = rate_error_peak(0.0, 4.0, [0.75, 1.0, 0.75]).unwrap();
534        assert!((v - 2.0).abs() < 1e-12, "{v}");
535        assert_eq!(rate_error_peak(0.0, 4.0, [0.0; 3]), None);
536    }
537
538    #[test]
539    fn a_window_above_the_grid_adds_no_rows() {
540        let model = SteepRate(steep);
541        let grid = vec![1.0, 5.0, 11.0];
542        let o = band_options(grid.clone());
543        assert_eq!(refined_energy_grid(&model, &o).unwrap(), Some(grid));
544    }
545
546    /// A loss model with Fermi energy 10 eV, lowest tabulated ELF energy
547    /// `elf_min`, rate `rate(T)` and a flat DIIMFP, whose rate counts losses
548    /// down to `loss_min_fraction · T` where given (Mermin-style, a positive
549    /// rate below `elf_min`) and otherwise from `elf_min` (Penn-style), and
550    /// whose reported rate onset is `onset`: synthetic.
551    struct OnsetRate {
552        elf_min: f64,
553        loss_min_fraction: Option<f64>,
554        onset: Option<f64>,
555        rate: fn(f64) -> f64,
556    }
557
558    impl OnsetRate {
559        /// Penn-style: zero rate at and below `elf_min`, reported as the
560        /// onset.
561        fn penn(elf_min: f64, rate: fn(f64) -> f64) -> Self {
562            Self {
563                elf_min,
564                loss_min_fraction: None,
565                onset: Some(elf_min),
566                rate,
567            }
568        }
569    }
570
571    impl LossModel for OnsetRate {
572        fn elf_min_ev(&self) -> f64 {
573            self.elf_min
574        }
575        fn loss_min_ev(&self, energy_ev: f64) -> f64 {
576            self.loss_min_fraction
577                .map_or(self.elf_min, |f| f * energy_ev)
578        }
579        fn rate_onset_ev(&self) -> Option<f64> {
580            self.onset
581        }
582        fn fermi_energy_ev(&self) -> f64 {
583            10.0
584        }
585        fn diimfp(&self, energy_ev: f64, loss_ev: f64) -> Result<f64, ElectronDataError> {
586            Ok(if loss_ev > 0.0 && loss_ev <= energy_ev {
587                1.0
588            } else {
589                0.0
590            })
591        }
592        fn inverse_imfp(&self, energy_ev: f64) -> Result<f64, ElectronDataError> {
593            Ok((self.rate)(energy_ev))
594        }
595    }
596
597    /// A rate that is zero up to `T = 6` eV and climbs steeply above it,
598    /// linearly at the onset (synthetic).
599    fn onset_steep(t: f64) -> f64 {
600        let s = (t - 6.0).max(0.0);
601        1e6 * s * (1.0 + s * s) / (1.0 + (s / 15.0).powi(4))
602    }
603
604    /// Every final cell of `refined` that overlaps the window and starts at
605    /// or above `from` passes the test at its quartiles and midpoint.
606    fn assert_cells_pass<M: LossModel>(model: &M, refined: &[f64], from: f64) {
607        let ef = model.fermi_energy_ev();
608        let (lo, hi) = (ef + RATE_WINDOW_EV.0, ef + RATE_WINDOW_EV.1);
609        let rate = |e: f64| row_rate(model, EnergyAxis::BandBottom, e - ef).unwrap();
610        for w in refined.windows(2) {
611            if w[1] > lo && w[0] < hi && w[0] >= from {
612                for e in rate_test_points(w[0], w[1]) {
613                    let m = rate(e);
614                    let lin = rate(w[0]) + (e - w[0]) / (w[1] - w[0]) * (rate(w[1]) - rate(w[0]));
615                    assert!((lin - m).abs() <= RATE_TOLERANCE * m, "{w:?} at {e}");
616                }
617            }
618        }
619    }
620
621    /// The given grid is a subsequence of the refined one.
622    fn assert_subsequence(grid: &[f64], refined: &[f64]) {
623        assert!(refined.windows(2).all(|w| w[1] > w[0]));
624        let mut it = refined.iter();
625        assert!(grid.iter().all(|g| it.any(|r| r == g)));
626    }
627
628    #[test]
629    fn rate_refinement_starts_at_an_onset_above_the_window() {
630        // Issue #357: an ELF tabulated from 6 eV, so the Penn-style rate is
631        // zero up to E = E_F + 6 = 16 eV, inside the window 12..30 eV. The
632        // cell 14..17 straddles the onset and is left as given; the cells
633        // from 17 eV are refined to the tolerance.
634        let model = OnsetRate::penn(6.0, onset_steep);
635        let onset = 16.0;
636        for grid in [
637            vec![5.0, 9.0, 14.0, 17.0, 22.0, 35.0, 60.0, 100.0],
638            // a node on the onset: the cell from it is tested
639            vec![5.0, 9.0, 14.0, 16.0, 22.0, 35.0, 60.0, 100.0],
640        ] {
641            let o = band_options(grid.clone());
642            let refined = refined_energy_grid(&model, &o).unwrap().unwrap();
643            assert!(refined.len() > grid.len(), "{grid:?}");
644            assert_subsequence(&grid, &refined);
645            // Rows are added only in cells of the grid at or above the onset.
646            for e in refined.iter().filter(|e| !grid.contains(e)) {
647                let k = grid.partition_point(|g| g < e);
648                assert!(
649                    grid[k - 1] >= onset,
650                    "{e} in the cell {}..{}",
651                    grid[k - 1],
652                    grid[k]
653                );
654            }
655            assert_cells_pass(&model, &refined, onset);
656        }
657        // Without the onset the straddling cell is bisected towards the step
658        // until it is exhausted to rounding (the error before #357).
659        let unhooked = OnsetRate {
660            onset: None,
661            ..OnsetRate::penn(6.0, onset_steep)
662        };
663        let o = band_options(vec![5.0, 9.0, 14.0, 17.0, 22.0, 35.0, 60.0, 100.0]);
664        let err = refined_energy_grid(&unhooked, &o).unwrap_err().to_string();
665        assert!(err.contains("exhausted to rounding"), "{err}");
666        assert!(err.contains("cell 15.") || err.contains("cell 16"), "{err}");
667    }
668
669    #[test]
670    fn an_onset_below_the_window_leaves_the_refinement_unchanged() {
671        // elf_min = 0.5 eV (the Si ELF; every committed ELF starts at or
672        // below it), so the onset E_F + 0.5 lies below the window start
673        // E_F + 2: the grid is the one refined without an onset, including
674        // rows in the cell 10.3..14 that straddles both.
675        let grid = vec![5.0, 9.0, 10.3, 14.0, 22.0, 35.0, 60.0, 100.0];
676        let o = band_options(grid.clone());
677        let with = OnsetRate::penn(0.5, steep);
678        let without = OnsetRate {
679            onset: None,
680            ..OnsetRate::penn(0.5, steep)
681        };
682        let refined = refined_energy_grid(&with, &o).unwrap().unwrap();
683        assert_eq!(refined, refined_energy_grid(&without, &o).unwrap().unwrap());
684        assert!(refined.iter().any(|&e| e > 10.3 && e < 14.0), "{refined:?}");
685        // An onset exactly at the window start is the lowest that applies.
686        let at_start = OnsetRate::penn(RATE_WINDOW_EV.0, onset_steep);
687        let refined = refined_energy_grid(&at_start, &o).unwrap().unwrap();
688        assert!(
689            !refined.iter().any(|&e| e > 10.3 && e < 14.0),
690            "{refined:?}"
691        );
692        assert_subsequence(&grid, &refined);
693    }
694
695    #[test]
696    fn a_rate_without_an_onset_keeps_the_full_window() {
697        // Mermin-style: elf_min = 6 eV, but the rate counts losses down to
698        // 1e-8 T and is positive below elf_min, so it has no onset and the
699        // cells below E_F + 6 are refined as before.
700        let model = OnsetRate {
701            elf_min: 6.0,
702            loss_min_fraction: Some(LOWEST_LOSS_FRACTION),
703            onset: None,
704            rate: steep,
705        };
706        let grid = vec![5.0, 9.0, 14.0, 17.0, 22.0, 35.0, 60.0, 100.0];
707        let refined = refined_energy_grid(&model, &band_options(grid.clone()))
708            .unwrap()
709            .unwrap();
710        assert_subsequence(&grid, &refined);
711        assert!(refined.iter().any(|&e| e > 12.0 && e < 16.0), "{refined:?}");
712        assert_cells_pass(&model, &refined, f64::NEG_INFINITY);
713    }
714
715    #[test]
716    fn rate_refinement_from_an_onset_is_the_same_on_any_thread_count() {
717        let model = OnsetRate::penn(6.0, onset_steep);
718        let o = band_options(vec![5.0, 9.0, 14.0, 17.0, 22.0, 35.0, 60.0, 100.0]);
719        let on = |threads: usize| {
720            rayon::ThreadPoolBuilder::new()
721                .num_threads(threads)
722                .build()
723                .unwrap()
724                .install(|| refined_energy_grid(&model, &o).unwrap())
725        };
726        let one = on(1);
727        assert_eq!(on(3), one);
728        assert_eq!(on(8), one);
729    }
730
731    #[test]
732    fn the_single_pole_model_has_a_rate_onset_and_the_full_and_mermin_models_none() {
733        // A synthetic ELF (one Drude-Lorentz plasmon) tabulated from 6 eV.
734        let elf = crate::electron::inelastic::drude::DrudeLorentz::new(vec![
735            crate::electron::inelastic::drude::DrudeLorentzOscillator::plasmon(15.0, 4.0),
736        ])
737        .unwrap()
738        .to_optical_elf("synthetic", 6.0, 2000.0, 200)
739        .unwrap();
740        use crate::electron::inelastic::model::PennAlgorithm;
741        for (algorithm, onset) in [
742            (PennAlgorithm::SinglePole, Some(6.0)),
743            // #361: the full Penn DIIMFP is positive below elf_min, so is
744            // its rate
745            (PennAlgorithm::Full, None),
746            (PennAlgorithm::Mermin, None),
747        ] {
748            let model = PennInelastic::try_new(algorithm, elf.clone()).unwrap();
749            assert_eq!(model.rate_onset_ev(), onset, "{algorithm:?}");
750            // single pole: the rate is zero at the onset, as the hook says;
751            // full Penn and Mermin: positive below elf_min.
752            let below = row_rate(&model, EnergyAxis::ModelFermiLevel, 5.0).unwrap();
753            assert_eq!(below > 0.0, onset.is_none(), "{algorithm:?}: {below}");
754        }
755        // The single-pole Penn model on the band-bottom axis builds a refined
756        // grid with the default refinement, rows only from the onset.
757        let model = PennInelastic::new(PennAlgorithm::SinglePole, elf)
758            .with_fermi_energy_ev(10.0)
759            .unwrap();
760        let grid = vec![5.0, 9.0, 14.0, 17.0, 22.0, 35.0, 60.0, 100.0];
761        let refined = refined_energy_grid(&model, &band_options(grid.clone()))
762            .unwrap()
763            .unwrap();
764        assert!(refined.len() > grid.len(), "{refined:?}");
765        assert_subsequence(&grid, &refined);
766        for e in refined.iter().filter(|e| !grid.contains(e)) {
767            assert!(*e > 17.0, "{e}");
768        }
769        assert_cells_pass(&model, &refined, 16.0);
770    }
771}