Skip to main content

lindhard/electron/inelastic/table/
density.rs

1//! The loss-density machinery of the inelastic table (parent module docs):
2//! the piecewise-linear density and its adaptive refinement, the
3//! [`LossModel`] interface of the ELF-extension models, the rows' loss
4//! densities and quantiles, and the shared probability-grid refinement.
5
6use super::super::full_penn::DiimfpGrid;
7#[cfg(doc)]
8use super::super::full_penn::FullPenn;
9use super::super::full_penn::FULL_LOWEST_LOSS_FRACTION;
10use super::super::mermin::LOWEST_LOSS_FRACTION;
11use super::super::model::PennInelastic;
12use super::super::penn::SinglePolePenn;
13use super::{InelasticTableError, INITIAL_LOSS_PANELS, MAX_NODES};
14use crate::electron::data::{CrossSectionTable, ElectronDataError};
15use crate::electron::elastic::table::{logistic, logit};
16use rayon::prelude::*;
17
18// ---------------------------------------------------------------------------
19// Piecewise-linear density in one variable
20
21/// A density `g(x)` that is linear between nodes, with its cumulative
22/// integral; `x` is `ln W` or `ln q`.
23#[derive(Debug, Clone, PartialEq)]
24pub(super) struct LinearDensity {
25    pub(super) x: Vec<f64>,
26    g: Vec<f64>,
27    /// Cumulative integral at each node.
28    cum: Vec<f64>,
29}
30
31impl LinearDensity {
32    fn from_nodes(x: Vec<f64>, g: Vec<f64>) -> Self {
33        let mut cum = Vec::with_capacity(x.len());
34        let mut acc = 0.0;
35        cum.push(0.0);
36        for i in 1..x.len() {
37            acc += 0.5 * (x[i] - x[i - 1]) * (g[i] + g[i - 1]);
38            cum.push(acc);
39        }
40        Self { x, g, cum }
41    }
42
43    pub(super) fn total(&self) -> f64 {
44        *self.cum.last().expect("non-empty")
45    }
46
47    /// `∫ e^x g dx` of the piecewise-linear model (closed form per panel).
48    fn first_moment(&self) -> f64 {
49        (1..self.x.len())
50            .map(|i| panel_exp_moment(self.x[i - 1], self.x[i], self.g[i - 1], self.g[i]))
51            .sum()
52    }
53
54    /// The inverse CDF: the `x` with cumulative probability `u`.
55    pub(super) fn quantile(&self, u: f64) -> f64 {
56        let n = self.x.len();
57        if u <= 0.0 {
58            return self.x[0];
59        }
60        if u >= 1.0 {
61            return self.x[n - 1];
62        }
63        let target = u * self.total();
64        // First node with cum >= target.
65        let j = self.cum.partition_point(|&c| c < target).clamp(1, n - 1);
66        let (x0, x1) = (self.x[j - 1], self.x[j]);
67        let (g0, g1) = (self.g[j - 1], self.g[j]);
68        let h = x1 - x0;
69        let rem = (target - self.cum[j - 1]).max(0.0);
70        // g0 t + (g1 - g0) t² / (2h) = rem, t in [0, h].
71        let disc = (g0 * g0 + 2.0 * (g1 - g0) * rem / h).max(0.0);
72        let den = g0 + disc.sqrt();
73        let t = if den > 0.0 { 2.0 * rem / den } else { 0.0 };
74        x0 + t.clamp(0.0, h)
75    }
76}
77
78/// `∫_{x0}^{x1} e^x g(x) dx` for `g` linear from `g0` to `g1`.
79fn panel_exp_moment(x0: f64, x1: f64, g0: f64, g1: f64) -> f64 {
80    // ∫ e^x (g0 + m (x - x0)) dx = e^{x0} [ g0 (E - 1) + m (h E - (E - 1)) ]
81    // with h = x1 - x0 and E = e^h.
82    let h = x1 - x0;
83    if h <= 0.0 {
84        return 0.0;
85    }
86    let m = (g1 - g0) / h;
87    let em1 = h.exp_m1();
88    x0.exp() * (g0 * em1 + m * (h * (em1 + 1.0) - em1))
89}
90
91/// Build the density model of `f` by adaptive panel splitting (module docs):
92/// `breaks` are fixed nodes, each segment is first cut into `sub` equal
93/// panels. `rough` lists ranges of `x` (ascending) where `f` is known not to
94/// be smooth below the width `rough_width` (the unresolved cells of a
95/// [`DiimfpGrid`], module docs): a panel within `rough_width` of one of them
96/// is not split once it is no wider than `rough_width`. With no ranges the
97/// refinement is the plain one. A panel whose midpoint is not a floating-point
98/// number strictly inside it is never split (module docs, "Floating-point
99/// resolution").
100///
101/// Returns the density and whether the refinement stopped at [`MAX_NODES`]
102/// with a panel still failing the split test.
103pub(super) fn adaptive_density(
104    f: &(impl Fn(f64) -> f64 + ?Sized),
105    breaks: &[f64],
106    sub: usize,
107    tol: f64,
108    first_moment: bool,
109    rough: &[(f64, f64)],
110    rough_width: f64,
111) -> (LinearDensity, bool) {
112    let at_resolution = |a: f64, b: f64| {
113        b - a <= rough_width
114            && rough
115                .iter()
116                .any(|&(lo, hi)| a <= hi + rough_width && b >= lo - rough_width)
117    };
118    let mut x: Vec<f64> = Vec::new();
119    for w in breaks.windows(2) {
120        for i in 0..sub {
121            x.push(w[0] + (w[1] - w[0]) * i as f64 / sub as f64);
122        }
123    }
124    x.push(*breaks.last().expect("two breaks"));
125    let mut g: Vec<f64> = x.iter().map(|&xi| f(xi)).collect();
126    // Cached midpoint values of the panels, once evaluated.
127    let mut mids: Vec<Option<f64>> = vec![None; x.len() - 1];
128    let span = x[x.len() - 1] - x[0];
129    loop {
130        for (i, m) in mids.iter_mut().enumerate() {
131            if m.is_none() {
132                *m = Some(f(0.5 * (x[i] + x[i + 1])));
133            }
134        }
135        let d = LinearDensity::from_nodes(x.clone(), g.clone());
136        let i0 = d.total();
137        let i1 = if first_moment { d.first_moment() } else { 0.0 };
138        let mut split = vec![false; mids.len()];
139        let mut any = false;
140        let mut capped = false;
141        for i in 0..mids.len() {
142            let h = x[i + 1] - x[i];
143            // The midpoint of a panel one ulp wide rounds onto an end: there
144            // is nothing left to split (module docs, "Floating-point
145            // resolution").
146            let xm = 0.5 * (x[i] + x[i + 1]);
147            if !(xm > x[i] && xm < x[i + 1]) {
148                continue;
149            }
150            let gm = mids[i].expect("filled");
151            let allowed = h / span * tol;
152            let e0 = (2.0 * h / 3.0 * (gm - 0.5 * (g[i] + g[i + 1]))).abs();
153            let mut bad = e0 > allowed * i0;
154            if first_moment {
155                let w = |xx: f64, gg: f64| xx.exp() * gg;
156                let lin = 0.5 * (w(x[i], g[i]) + w(x[i + 1], g[i + 1]));
157                let e1 = (2.0 * h / 3.0 * (w(xm, gm) - lin)).abs();
158                bad |= e1 > allowed * i1;
159            }
160            if bad && !at_resolution(x[i], x[i + 1]) {
161                if x.len() + 1 < MAX_NODES {
162                    split[i] = true;
163                    any = true;
164                } else {
165                    capped = true;
166                }
167            }
168        }
169        if !any {
170            return (d, capped);
171        }
172        let mut nx = Vec::with_capacity(x.len() + 16);
173        let mut ng = Vec::with_capacity(x.len() + 16);
174        let mut nm = Vec::with_capacity(mids.len() + 16);
175        for i in 0..mids.len() {
176            nx.push(x[i]);
177            ng.push(g[i]);
178            if split[i] {
179                nx.push(0.5 * (x[i] + x[i + 1]));
180                ng.push(mids[i].expect("filled"));
181                nm.push(None);
182                nm.push(None);
183            } else {
184                nm.push(mids[i]);
185            }
186        }
187        nx.push(x[x.len() - 1]);
188        ng.push(g[g.len() - 1]);
189        x = nx;
190        g = ng;
191        mids = nm;
192    }
193}
194
195/// What the table builder needs from an ELF-extension model: the lowest
196/// tabulated ELF energy, the lowest loss of its rate integral, the DIIMFP
197/// and the inverse IMFP. Implemented for [`SinglePolePenn`] and for every
198/// model of [`PennInelastic`]; the table is built the same way for each
199/// (module docs).
200pub(super) trait LossModel: Sync {
201    fn elf_min_ev(&self) -> f64;
202    /// The lowest loss (eV) the model's inverse IMFP at `energy_ev` counts,
203    /// which opens the sampled loss window of the row (module docs, "Rows
204    /// below the ELF table"). By default the lowest tabulated ELF energy,
205    /// below which the DIIMFP is zero.
206    fn loss_min_ev(&self, _energy_ev: f64) -> f64 {
207        self.elf_min_ev()
208    }
209    /// The kinetic energy `T` (eV, above the model's Fermi level) at and
210    /// below which the model's rate is identically zero and above which it
211    /// is positive, if it has one: the step of the rate refinement (module
212    /// docs, "Rate refinement"; #357). By default the lowest tabulated ELF
213    /// energy, the default [`LossModel::loss_min_ev`], below which the DIIMFP
214    /// is zero. A model whose rate counts losses below it (Mermin, full
215    /// Penn) has no onset.
216    fn rate_onset_ev(&self) -> Option<f64> {
217        Some(self.elf_min_ev())
218    }
219    /// A loss strictly inside the window `(loss_min, energy_ev)` at which
220    /// the row's initial panels are split into two ranges, so that the
221    /// part above it keeps its resolution however far below it the window
222    /// opens: the lowest ELF energy for the full Penn model (#361). None by
223    /// default.
224    fn window_break_ev(&self, _energy_ev: f64) -> Option<f64> {
225        None
226    }
227    fn fermi_energy_ev(&self) -> f64;
228    fn diimfp(&self, energy_ev: f64, loss_ev: f64) -> Result<f64, ElectronDataError>;
229    fn inverse_imfp(&self, energy_ev: f64) -> Result<f64, ElectronDataError>;
230    /// A tabulation of the DIIMFP for the rows at `energy_ev`, if the model
231    /// has one ([`FullPenn::diimfp_grid`]); the rows then read the DIIMFP
232    /// from it wherever it covers the loss.
233    fn diimfp_grid(&self, _energy_ev: &[f64]) -> Result<Option<DiimfpGrid>, ElectronDataError> {
234        Ok(None)
235    }
236}
237
238impl LossModel for SinglePolePenn {
239    fn elf_min_ev(&self) -> f64 {
240        self.optical_elf().energy_ev()[0]
241    }
242    fn fermi_energy_ev(&self) -> f64 {
243        SinglePolePenn::fermi_energy_ev(self)
244    }
245    fn diimfp(&self, energy_ev: f64, loss_ev: f64) -> Result<f64, ElectronDataError> {
246        self.diimfp_per_m_ev(energy_ev, loss_ev)
247    }
248    fn inverse_imfp(&self, energy_ev: f64) -> Result<f64, ElectronDataError> {
249        Ok(self.imfp_and_stopping(energy_ev)?.inverse_imfp_per_m)
250    }
251}
252
253impl LossModel for PennInelastic {
254    fn elf_min_ev(&self) -> f64 {
255        self.optical_elf().energy_ev()[0]
256    }
257    fn loss_min_ev(&self, energy_ev: f64) -> f64 {
258        match self {
259            // the lower limit of the Mermin rate integral (#342); the fitted
260            // ELF has no cutoff at the lowest tabulated energy
261            PennInelastic::Mermin(_) => LOWEST_LOSS_FRACTION * energy_ev,
262            // the full Penn DIIMFP is not zero below the lowest ELF energy
263            // (the electron-hole continuum of each Lindhard gas reaches
264            // omega -> 0): the floor of its model-integrated rate (#361)
265            PennInelastic::Full(_) => FULL_LOWEST_LOSS_FRACTION * energy_ev,
266            // the single-pole DIIMFP is zero below it
267            PennInelastic::SinglePole(_) => self.elf_min_ev(),
268        }
269    }
270    fn window_break_ev(&self, energy_ev: f64) -> Option<f64> {
271        let b = self.elf_min_ev();
272        match self {
273            PennInelastic::Full(_) if b > self.loss_min_ev(energy_ev) && b < energy_ev => Some(b),
274            _ => None,
275        }
276    }
277    fn rate_onset_ev(&self) -> Option<f64> {
278        match self {
279            // a positive rate at every T > 0, from losses in (1e-8 T, T]
280            // (#342): no onset
281            PennInelastic::Mermin(_) => None,
282            // a positive rate at every T > 0, from losses in (1e-6 T, T]
283            // (#361): no onset
284            PennInelastic::Full(_) => None,
285            // the rate counts losses above elf_min only (`loss_min_ev`), so
286            // it is zero for T <= elf_min
287            PennInelastic::SinglePole(_) => Some(self.elf_min_ev()),
288        }
289    }
290    fn fermi_energy_ev(&self) -> f64 {
291        PennInelastic::fermi_energy_ev(self)
292    }
293    fn diimfp(&self, energy_ev: f64, loss_ev: f64) -> Result<f64, ElectronDataError> {
294        self.diimfp_per_m_ev(energy_ev, loss_ev)
295    }
296    fn inverse_imfp(&self, energy_ev: f64) -> Result<f64, ElectronDataError> {
297        Ok(self.imfp_and_stopping(energy_ev)?.inverse_imfp_per_m)
298    }
299    fn diimfp_grid(&self, energy_ev: &[f64]) -> Result<Option<DiimfpGrid>, ElectronDataError> {
300        match self {
301            PennInelastic::Full(m) => Ok(Some(m.diimfp_grid(energy_ev)?)),
302            _ => Ok(None),
303        }
304    }
305}
306
307/// The DIIMFP of `penn` at `(energy_ev, loss_ev)`, from `grid` where it
308/// covers the point; zero where the model fails (only for non-finite input).
309fn row_diimfp<M: LossModel>(
310    penn: &M,
311    grid: Option<&DiimfpGrid>,
312    energy_ev: f64,
313    loss_ev: f64,
314) -> f64 {
315    grid.and_then(|g| g.diimfp_per_m_ev(energy_ev, loss_ev))
316        .unwrap_or_else(|| penn.diimfp(energy_ev, loss_ev).unwrap_or(0.0))
317}
318
319/// The density model of the row at `energy_ev`, on `ln W` from the model's
320/// lowest loss ([`LossModel::loss_min_ev`]) to `energy_ev` (module docs),
321/// and whether its refinement stopped at [`MAX_NODES`].
322fn row_density<M: LossModel>(
323    penn: &M,
324    grid: Option<&DiimfpGrid>,
325    energy_ev: f64,
326    tol: f64,
327) -> (LinearDensity, bool) {
328    let w_lo = penn.loss_min_ev(energy_ev);
329    let f = |s: f64| {
330        let w = s.exp().min(energy_ev);
331        row_diimfp(penn, grid, energy_ev, w) * w
332    };
333    // The unresolved cells of the grid, on the row's `ln W` axis (module
334    // docs, "Unresolved grid cells"); none without a grid.
335    let (rough, rough_width) = match grid {
336        Some(g) => (g.unresolved_ln_loss_ev(), g.min_cell_width()),
337        None => (Vec::new(), 0.0),
338    };
339    let mut breaks = vec![w_lo.ln(), energy_ev.ln()];
340    if let Some(b) = penn.window_break_ev(energy_ev) {
341        breaks.insert(1, b.ln());
342    }
343    adaptive_density(
344        &f,
345        &breaks,
346        INITIAL_LOSS_PANELS,
347        tol,
348        true,
349        &rough,
350        rough_width,
351    )
352}
353
354/// The number of nodes of the loss-density model of the row at `energy_ev`
355/// (eV, on the model's own axis) of a table of `model` built with density
356/// tolerance `density_tolerance`, reading the DIIMFP from `grid` where it
357/// covers the loss as a table build does (pass the grid of
358/// [`FullPenn::diimfp_grid`] for the table's energies, or `None`). The
359/// count is bounded by [`MAX_NODES`] (a few more: the last refinement pass
360/// splits every failing panel); a row that reaches it is refused by the
361/// table build ([`InelasticTableError::DensityNodeLimit`]). Each node not
362/// covered by the grid costs one direct DIIMFP evaluation.
363pub fn loss_density_node_count(
364    model: &PennInelastic,
365    grid: Option<&DiimfpGrid>,
366    energy_ev: f64,
367    density_tolerance: f64,
368) -> usize {
369    row_density(model, grid, energy_ev, density_tolerance)
370        .0
371        .x
372        .len()
373}
374
375/// The loss density of one energy.
376pub(super) fn loss_density<M: LossModel>(
377    penn: &M,
378    grid: Option<&DiimfpGrid>,
379    energy_ev: f64,
380    inverse_mfp: f64,
381    tol: f64,
382) -> Result<LinearDensity, InelasticTableError> {
383    let (d, capped) = row_density(penn, grid, energy_ev, tol);
384    if !(d.total() > 0.0 && d.total().is_finite()) {
385        return Err(InelasticTableError::EmptyDistribution {
386            energy_ev,
387            inverse_mfp_per_m: inverse_mfp,
388            integral: d.total(),
389        });
390    }
391    if capped {
392        return Err(InelasticTableError::DensityNodeLimit {
393            energy_ev,
394            max_nodes: MAX_NODES,
395        });
396    }
397    Ok(d)
398}
399
400/// The loss `W` at cumulative probability `u` of a row, clamped to
401/// `[0, energy_ev]`.
402pub(super) fn loss_quantile(d: &LinearDensity, energy_ev: f64, u: f64) -> f64 {
403    d.quantile(u).exp().min(energy_ev)
404}
405
406/// `∫_0^1 W(u) du` by the trapezoid rule of the table's linear
407/// interpolation.
408pub fn mean_loss_ev(probability: &[f64], quantiles: &[f64]) -> f64 {
409    probability
410        .windows(2)
411        .zip(quantiles.windows(2))
412        .map(|(p, q)| 0.5 * (p[1] - p[0]) * (q[0] + q[1]))
413        .sum()
414}
415
416/// The stopping power `λ⁻¹ ⟨W⟩` (eV/m) of row `i` of an energy-loss table,
417/// with `⟨W⟩` from [`mean_loss_ev`]; zero for a zero-rate row. `None` if `i`
418/// is out of range.
419pub fn stopping_power_ev_per_m(table: &CrossSectionTable, i: usize) -> Option<f64> {
420    let inv = *table.inverse_mfp_per_m().get(i)?;
421    Some(match table.quantiles(i) {
422        Some(q) => inv * mean_loss_ev(table.probability(), q),
423        None => 0.0,
424    })
425}
426
427/// Adaptive refinement of the shared probability grid (module docs).
428pub(super) fn refine_probability(
429    rows: &[Option<LinearDensity>],
430    energy_ev: &[f64],
431    mut grid: Vec<f64>,
432    mut quantiles: Vec<Vec<f64>>,
433    tol: f64,
434) -> (Vec<f64>, Vec<Vec<f64>>) {
435    let means: Vec<f64> = quantiles
436        .iter()
437        .map(|q| {
438            if q.is_empty() {
439                0.0
440            } else {
441                mean_loss_ev(&grid, q)
442            }
443        })
444        .collect();
445    // The two segments touching 0 and 1 are left alone.
446    let mut todo: Vec<bool> = (0..grid.len() - 1)
447        .map(|s| s > 0 && s + 2 < grid.len())
448        .collect();
449    loop {
450        let segs: Vec<usize> = (0..todo.len()).filter(|&s| todo[s]).collect();
451        if segs.is_empty() || grid.len() + segs.len() > MAX_NODES {
452            return (grid, quantiles);
453        }
454        let umid: Vec<f64> = segs
455            .iter()
456            .map(|&s| logistic(0.5 * (logit(grid[s]) + logit(grid[s + 1]))))
457            .collect();
458        // Exact quantiles at the midpoints, one row per task.
459        let exact: Vec<Vec<f64>> = rows
460            .par_iter()
461            .zip(energy_ev.par_iter())
462            .map(|(r, &e)| match r {
463                Some(d) => umid.iter().map(|&u| loss_quantile(d, e, u)).collect(),
464                None => Vec::new(),
465            })
466            .collect();
467        let mut split = vec![false; segs.len()];
468        for (k, &s) in segs.iter().enumerate() {
469            let du = grid[s + 1] - grid[s];
470            if !(umid[k] > grid[s] && umid[k] < grid[s + 1]) {
471                continue; // grid exhausted to rounding
472            }
473            // The table interpolates linearly in u, at the logistic midpoint
474            // as at any other point.
475            let t = (umid[k] - grid[s]) / du;
476            for (i, q) in quantiles.iter().enumerate() {
477                if q.is_empty() {
478                    continue;
479                }
480                let lin = q[s] + t * (q[s + 1] - q[s]);
481                if (lin - exact[i][k]).abs() * du > tol * means[i] * du.max(1.0e-4) {
482                    split[k] = true;
483                    break;
484                }
485            }
486        }
487        if !split.iter().any(|&b| b) {
488            return (grid, quantiles);
489        }
490        let mut ngrid = Vec::with_capacity(grid.len() + segs.len());
491        let mut nq: Vec<Vec<f64>> = quantiles
492            .iter()
493            .map(|q| {
494                Vec::with_capacity(if q.is_empty() {
495                    0
496                } else {
497                    q.len() + segs.len()
498                })
499            })
500            .collect();
501        let mut ntodo = Vec::with_capacity(todo.len() + segs.len());
502        let mut k = 0;
503        for s in 0..todo.len() {
504            ngrid.push(grid[s]);
505            for (i, q) in quantiles.iter().enumerate() {
506                if !q.is_empty() {
507                    nq[i].push(q[s]);
508                }
509            }
510            let mut halves = false;
511            if todo[s] {
512                if split[k] {
513                    ngrid.push(umid[k]);
514                    for (i, q) in nq.iter_mut().enumerate() {
515                        if !quantiles[i].is_empty() {
516                            q.push(exact[i][k]);
517                        }
518                    }
519                    halves = true;
520                }
521                k += 1;
522            }
523            ntodo.push(halves);
524            if halves {
525                ntodo.push(true);
526            }
527        }
528        ngrid.push(grid[grid.len() - 1]);
529        for (i, q) in quantiles.iter().enumerate() {
530            if !q.is_empty() {
531                nq[i].push(q[q.len() - 1]);
532            }
533        }
534        grid = ngrid;
535        quantiles = nq;
536        todo = ntodo;
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::super::build::{loss_rows, InelasticTableOptions};
543    use super::super::refine::row_rate;
544    use super::super::EnergyAxis;
545    use super::*;
546
547    /// A row-like integrand in `x = ln W` (a smooth loss peak) that, inside
548    /// the cell `[c, c + 1.5e-4]`, is replaced by a second model differing
549    /// by 1e-3 relative and carrying noise at 1e-4: the shape of a full-Penn
550    /// row at an unresolved [`DiimfpGrid`] cell, where the grid gives way to
551    /// the direct model (module docs, "Unresolved grid cells"). Synthetic,
552    /// not a material.
553    fn stepped_row(c: f64) -> impl Fn(f64) -> f64 {
554        move |x: f64| {
555            let smooth = (-(x - 3.0).powi(2)).exp();
556            if (c..=c + 1.5e-4).contains(&x) {
557                smooth * (1.0 + 1e-3 + 1e-4 * (1e7 * x).sin())
558            } else {
559                smooth
560            }
561        }
562    }
563
564    #[test]
565    fn a_step_at_an_unresolved_cell_does_not_exhaust_the_nodes() {
566        // A starting node (x = 3, the 25th of 64 panels on [0, 8]) falls in
567        // the cell, at the peak, so the step is sampled and far above the
568        // split threshold (about 3 tol of the mean).
569        let c = 3.0 - 0.5e-4;
570        let f = stepped_row(c);
571        let breaks = [0.0, 8.0];
572        let cells = [(c, c + 1.5e-4)];
573        let (d, capped) =
574            adaptive_density(&f, &breaks, INITIAL_LOSS_PANELS, 1e-5, true, &cells, 1e-4);
575        assert!(!capped);
576        let (smooth, _) = adaptive_density(
577            &|x: f64| (-(x - 3.0).powi(2)).exp(),
578            &breaks,
579            INITIAL_LOSS_PANELS,
580            1e-5,
581            true,
582            &[],
583            0.0,
584        );
585        let (n, n0) = (d.x.len(), smooth.x.len());
586        eprintln!("nodes: {n} with the stepped cell declared, {n0} for the smooth row");
587        // The cell costs a few bisection levels around its two edges, not
588        // the ~MAX_NODES of a bisection to floating-point resolution.
589        assert!(n < n0 + 100, "{n} nodes vs {n0} (MAX_NODES = {MAX_NODES})");
590        // The density still matches the smooth row's integral to far below
591        // the model tolerance: the step adds ~1.5e-7 of it.
592        let rel = (d.total() / smooth.total() - 1.0).abs();
593        assert!(rel < 1e-5, "{rel:e}");
594        // No panel was bisected towards floating-point resolution.
595        let narrowest = d.x.windows(2).map(|w| w[1] - w[0]).fold(f64::MAX, f64::min);
596        assert!(narrowest > 1e-5, "{narrowest:e}");
597    }
598
599    #[test]
600    fn without_unresolved_cells_the_refinement_is_unchanged() {
601        // A smooth row passes no rough ranges and is refined exactly as
602        // before; declaring a cell far from the row's structure changes no
603        // node either, since no panel near it ever gets that narrow.
604        let f = |x: f64| (-(x - 3.0).powi(2)).exp();
605        let breaks = [0.0, 8.0];
606        let plain = adaptive_density(&f, &breaks, INITIAL_LOSS_PANELS, 1e-5, true, &[], 0.0);
607        let far = adaptive_density(
608            &f,
609            &breaks,
610            INITIAL_LOSS_PANELS,
611            1e-5,
612            true,
613            &[(7.5, 7.5 + 1.5e-4)],
614            1e-4,
615        );
616        assert_eq!(plain, far);
617    }
618
619    /// The smooth row of [`stepped_row`] with jumps of a fixed relative size
620    /// `rel` at `at` (up, then down at the next, and so on): the shape of the
621    /// quadrature noise of the Mermin DIIMFP at high `T` (module docs,
622    /// "Floating-point resolution"). Synthetic, not a material.
623    fn jumped_row(at: &'static [f64], rel: f64) -> impl Fn(f64) -> f64 {
624        move |x: f64| {
625            let up = at.iter().filter(|&&a| x >= a).count() % 2;
626            (-(x - 3.0).powi(2)).exp() * (1.0 + rel * up as f64)
627        }
628    }
629
630    #[test]
631    fn a_jump_is_bisected_to_floating_point_resolution_and_no_further() {
632        // Two pairs of jumps of 2e-5 of the density near its peak: each fails
633        // the split test at every width (about 3 tol of the mean). Before
634        // #345 the panel around each was bisected until it was one ulp wide
635        // and then padded with zero-width panels up to MAX_NODES.
636        const AT: &[f64] = &[2.8765432, 2.8812345, 3.1234567, 3.1301234];
637        let breaks = [0.0, 8.0];
638        let (d, capped) = adaptive_density(
639            &jumped_row(AT, 2e-5),
640            &breaks,
641            INITIAL_LOSS_PANELS,
642            1e-5,
643            true,
644            &[],
645            0.0,
646        );
647        let (smooth, _) = adaptive_density(
648            &jumped_row(&[], 0.0),
649            &breaks,
650            INITIAL_LOSS_PANELS,
651            1e-5,
652            true,
653            &[],
654            0.0,
655        );
656        let (n, n0) = (d.x.len(), smooth.x.len());
657        eprintln!("nodes: {n} with four jumps, {n0} for the smooth row");
658        assert!(!capped);
659        // About 50 bisection levels per jump, not MAX_NODES.
660        assert!(
661            n < n0 + 4 * 60,
662            "{n} nodes vs {n0} (MAX_NODES = {MAX_NODES})"
663        );
664        // No zero-width panel; the narrowest are one ulp wide, at the jumps.
665        let narrowest = d.x.windows(2).map(|w| w[1] - w[0]).fold(f64::MAX, f64::min);
666        assert!(narrowest > 0.0, "{narrowest:e}");
667        assert!(narrowest <= 3.2 * f64::EPSILON, "{narrowest:e}");
668        // The jumps add about 1e-7 of the integral.
669        let rel = (d.total() / smooth.total() - 1.0).abs();
670        assert!(rel < 1e-5, "{rel:e}");
671    }
672
673    /// A loss model whose DIIMFP carries relative noise of 1e-2 at every
674    /// loss, which no panel width resolves: synthetic.
675    struct NoisyRow;
676
677    impl LossModel for NoisyRow {
678        fn elf_min_ev(&self) -> f64 {
679            0.5
680        }
681        fn fermi_energy_ev(&self) -> f64 {
682            0.0
683        }
684        fn diimfp(&self, energy_ev: f64, loss_ev: f64) -> Result<f64, ElectronDataError> {
685            if !(loss_ev > 0.0 && loss_ev <= energy_ev) {
686                return Ok(0.0);
687            }
688            // a deterministic hash of the loss, in [0, 1)
689            let h = loss_ev.to_bits().wrapping_mul(0x9e37_79b9_7f4a_7c15) >> 11;
690            Ok(1.0 + 1e-2 * (h as f64 / (1u64 << 53) as f64))
691        }
692        fn inverse_imfp(&self, energy_ev: f64) -> Result<f64, ElectronDataError> {
693            Ok(energy_ev - 0.5)
694        }
695    }
696
697    #[test]
698    fn a_row_that_reaches_the_node_bound_is_refused() {
699        let opts = InelasticTableOptions::new(vec![10.0]);
700        match loss_rows(&NoisyRow, &opts) {
701            Err(InelasticTableError::DensityNodeLimit {
702                energy_ev,
703                max_nodes,
704            }) => {
705                assert_eq!(energy_ev, 10.0);
706                assert_eq!(max_nodes, MAX_NODES);
707            }
708            Err(e) => panic!("unexpected error: {e}"),
709            Ok(_) => panic!("a row at the node bound was stored"),
710        }
711        let msg = loss_rows(&NoisyRow, &opts).unwrap_err().to_string();
712        assert!(msg.contains("within 20000 nodes"), "{msg}");
713    }
714
715    /// The committed Si ELF (Yang et al. 2019, `validation/data/optical`,
716    /// tabulated from 0.5 eV), or `None` in a packaged crate without it.
717    fn si_elf() -> Option<crate::electron::data::OpticalElf> {
718        let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
719            .join("../validation/data/optical/si_elf_yang2019.toml");
720        if !p.is_file() {
721            eprintln!("{} not found; Si check skipped", p.display());
722            return None;
723        }
724        Some(crate::electron::data::OpticalElf::from_toml_file(p).unwrap())
725    }
726
727    #[test]
728    fn mermin_rows_below_the_elf_table_open_at_the_rate_lower_limit() {
729        // Issues #340 and #342: the Mermin model of Si on the band-bottom
730        // axis (E_F = W_v + E_g = 13.46 eV, the band of
731        // validation/experiments/se_yield.py) with rows at T = E - E_F inside
732        // (0, elf_min]. The Mermin rate there is positive (its fitted ELF has
733        // no lower cutoff). Before #340 these rows were refused (a reversed
734        // ln W window); from #340 to #342 they were stored empty; now the
735        // window opens at the lower limit of the rate integral, 1e-8 T, and
736        // they carry the model's rate.
737        let Some(elf) = si_elf() else { return };
738        let ef = 13.46;
739        let model = PennInelastic::try_new(
740            crate::electron::inelastic::model::PennAlgorithm::Mermin,
741            elf,
742        )
743        .unwrap()
744        .with_fermi_energy_ev(ef)
745        .unwrap();
746        let elf_min = model.elf_min_ev();
747        assert_eq!(elf_min, 0.5);
748        let t_low = [0.2085, 0.2530, 0.4216];
749        let mut grid: Vec<f64> = t_low.iter().map(|t| ef + t).collect();
750        // the T = elf_min row, exactly on the band-bottom axis, and one above
751        grid.push(ef + elf_min);
752        grid.push(ef + 1.0);
753        let opts = InelasticTableOptions::new(grid.clone()).with_axis(EnergyAxis::BandBottom);
754        let (inv, _, q) = loss_rows(&model, &opts).expect("rows inside (0, elf_min] build");
755        for (k, &e) in grid.iter().enumerate() {
756            let t = e - ef;
757            let w_min = model.loss_min_ev(t);
758            assert_eq!(w_min, LOWEST_LOSS_FRACTION * t);
759            assert_eq!(inv[k], model.inverse_imfp(t).unwrap(), "T = {t}");
760            assert!(inv[k] > 0.0, "T = {t}");
761            let row = &q[k];
762            // the window is (1e-8 T, T]: the lowest quantile is its lower
763            // edge, far below elf_min, and the highest is T
764            assert!((row[0] / w_min - 1.0).abs() < 1e-12, "T = {t}: {}", row[0]);
765            assert!(row.iter().all(|&w| w > 0.0 && w <= t), "T = {t}");
766            assert!((row[row.len() - 1] - t).abs() <= 1e-12 * t, "T = {t}");
767            assert!(row.windows(2).all(|w| w[1] >= w[0]), "T = {t}");
768        }
769    }
770
771    #[test]
772    fn the_si_mermin_row_at_1000_ev_stops_at_floating_point_resolution() {
773        // Issue #345. The Mermin row at T = 1000 eV of the committed Si ELF
774        // (model Fermi energy 13.46 eV, density tolerance 1e-5) reached
775        // MAX_NODES (20 099 nodes, 14 307 of them zero-width panels) on 18
776        // jumps of its DIIMFP's quadrature noise; the rows at T <= 100 eV
777        // need 2 000 to 3 300 nodes.
778        let Some(elf) = si_elf() else { return };
779        let model = PennInelastic::try_new(
780            crate::electron::inelastic::model::PennAlgorithm::Mermin,
781            elf,
782        )
783        .unwrap()
784        .with_fermi_energy_ev(13.46)
785        .unwrap();
786        let (low, _) = row_density(&model, None, 100.0, 1e-5);
787        let (d, capped) = row_density(&model, None, 1000.0, 1e-5);
788        let (n, n_low) = (d.x.len(), low.x.len());
789        eprintln!("nodes: {n} at T = 1000 eV, {n_low} at T = 100 eV");
790        assert!(!capped);
791        assert!(
792            n < 3 * n_low && n < MAX_NODES / 2,
793            "{n} nodes vs {n_low} at 100 eV (MAX_NODES = {MAX_NODES})"
794        );
795        assert_eq!(
796            n,
797            loss_density_node_count(&model, None, 1000.0, 1e-5),
798            "the public count is the row's"
799        );
800        assert!(d.x.windows(2).all(|w| w[1] > w[0]), "a zero-width panel");
801        // The row's mean loss is the model's, S / λ⁻¹, to the tolerances.
802        let p = model.imfp_and_stopping(1000.0).unwrap();
803        let mean = d.first_moment() / d.total();
804        let want = p.stopping_ev_per_m / p.inverse_imfp_per_m;
805        let rel = (mean / want - 1.0).abs();
806        eprintln!("mean loss {mean} eV vs model {want} eV: {rel:e}");
807        assert!(rel < 1e-5, "{rel:e}");
808    }
809
810    /// A loss model whose rate counts losses down to `loss_min < elf_min`,
811    /// with a flat DIIMFP on `(loss_min, T]`: synthetic.
812    struct WideWindow;
813
814    impl LossModel for WideWindow {
815        fn elf_min_ev(&self) -> f64 {
816            0.5
817        }
818        fn loss_min_ev(&self, energy_ev: f64) -> f64 {
819            0.01 * energy_ev
820        }
821        fn rate_onset_ev(&self) -> Option<f64> {
822            // a positive rate below elf_min
823            None
824        }
825        fn fermi_energy_ev(&self) -> f64 {
826            0.0
827        }
828        fn diimfp(&self, energy_ev: f64, loss_ev: f64) -> Result<f64, ElectronDataError> {
829            Ok(if loss_ev > 0.0 && loss_ev <= energy_ev {
830                1.0
831            } else {
832                0.0
833            })
834        }
835        fn inverse_imfp(&self, energy_ev: f64) -> Result<f64, ElectronDataError> {
836            Ok(energy_ev * 0.99)
837        }
838    }
839
840    #[test]
841    fn a_row_opens_at_the_model_lowest_loss() {
842        // T = 0.3 lies in (loss_min, elf_min]: the row is built, not empty,
843        // and both rows open at loss_min = 0.01 T, not at elf_min.
844        let opts = InelasticTableOptions::new(vec![0.3, 2.0]);
845        let (inv, prob, q) = loss_rows(&WideWindow, &opts).unwrap();
846        assert_eq!(inv, vec![0.3 * 0.99, 2.0 * 0.99]);
847        for (row, t) in q.iter().zip([0.3, 2.0]) {
848            assert!((row[0] / (0.01 * t) - 1.0).abs() < 1e-12, "{}", row[0]);
849            assert!((row[row.len() - 1] - t).abs() <= 1e-12 * t);
850            // a flat DIIMFP: W(u) = loss_min + u (T - loss_min)
851            for (&u, &w) in prob.iter().zip(row) {
852                let want = 0.01 * t + u * 0.99 * t;
853                assert!((w - want).abs() <= 1e-4 * t, "u {u}: {w} vs {want}");
854            }
855        }
856    }
857
858    /// A loss model with a positive rate and no DIIMFP anywhere: synthetic.
859    struct RateWithoutDensity;
860
861    impl LossModel for RateWithoutDensity {
862        fn elf_min_ev(&self) -> f64 {
863            0.5
864        }
865        fn fermi_energy_ev(&self) -> f64 {
866            0.0
867        }
868        fn diimfp(&self, _: f64, _: f64) -> Result<f64, ElectronDataError> {
869            Ok(0.0)
870        }
871        fn inverse_imfp(&self, _: f64) -> Result<f64, ElectronDataError> {
872            Ok(1.0e6)
873        }
874    }
875
876    #[test]
877    fn a_positive_rate_without_density_above_the_elf_table_is_still_refused() {
878        // Below or at elf_min (the default lowest loss, as for the Penn
879        // models) the row is empty; above it, a positive rate with a density
880        // that integrates to zero is a defect, refused.
881        let opts = InelasticTableOptions::new(vec![0.25, 0.5]);
882        let (inv, _, q) = loss_rows(&RateWithoutDensity, &opts).unwrap();
883        assert_eq!(inv, vec![0.0, 0.0]);
884        assert!(q.iter().all(|r| r.is_empty()));
885        let opts = InelasticTableOptions::new(vec![0.25, 2.0]);
886        match loss_rows(&RateWithoutDensity, &opts) {
887            Err(InelasticTableError::EmptyDistribution { energy_ev, .. }) => {
888                assert_eq!(energy_ev, 2.0)
889            }
890            other => panic!("expected EmptyDistribution, got {other:?}"),
891        }
892    }
893
894    #[test]
895    fn full_penn_loss_window_opens_at_a_fraction_of_the_energy_and_has_no_onset() {
896        // #361: the window of a full Penn row is (1e-6 T, T] at every T > 0,
897        // below elf_min too; the single pole window opens at elf_min. The
898        // initial panels are split at elf_min (the part above it keeps its
899        // resolution) when elf_min lies inside the window.
900        let elf = crate::electron::inelastic::drude::DrudeLorentz::new(vec![
901            crate::electron::inelastic::drude::DrudeLorentzOscillator::plasmon(15.0, 4.0),
902        ])
903        .unwrap()
904        .to_optical_elf("synthetic", 6.0, 2000.0, 100)
905        .unwrap();
906        use crate::electron::inelastic::model::PennAlgorithm;
907        let full = PennInelastic::try_new(PennAlgorithm::Full, elf.clone()).unwrap();
908        let single = PennInelastic::try_new(PennAlgorithm::SinglePole, elf).unwrap();
909        for t in [0.5, 6.0, 40.0] {
910            assert_eq!(full.loss_min_ev(t), FULL_LOWEST_LOSS_FRACTION * t);
911            assert_eq!(single.loss_min_ev(t), 6.0);
912        }
913        assert_eq!(full.window_break_ev(5.0), None);
914        assert_eq!(full.window_break_ev(6.0), None);
915        assert_eq!(full.window_break_ev(40.0), Some(6.0));
916        assert_eq!(single.window_break_ev(40.0), None);
917        // a full Penn row at T <= elf_min is not empty, and a single-pole
918        // one is
919        assert!(row_rate(&full, EnergyAxis::ModelFermiLevel, 5.0).unwrap() > 0.0);
920        assert_eq!(
921            row_rate(&single, EnergyAxis::ModelFermiLevel, 5.0).unwrap(),
922            0.0
923        );
924        assert_eq!(full.rate_onset_ev(), None);
925        // With no onset the refinement tests every cell that overlaps its
926        // window, the cell straddling the old onset E_F + elf_min included
927        // (pinned for a rate of this kind by
928        // `a_rate_without_an_onset_keeps_the_full_window`; a refinement of
929        // the real model is not repeated here, its rate costs seconds per
930        // point in an unoptimised build).
931    }
932}