Skip to main content

lindhard/electron/inelastic/table/
build.rs

1//! The inelastic table builders (parent module docs): the options, the
2//! single-pole, any-model and shell-channel builders, and the rows they
3//! share.
4
5#[cfg(doc)]
6use super::super::full_penn::{DiimfpGrid, FullPenn};
7use super::super::inner_shell::ShellResolvedChannels;
8use super::super::model::PennInelastic;
9use super::super::penn::SinglePolePenn;
10use super::density::{loss_density, loss_quantile, refine_probability, LinearDensity, LossModel};
11use super::refine::{refinement_provenance, row_rate, with_refined_grid, RateRefinement};
12use super::{
13    check_no_exchange, invalid, EnergyAxis, InelasticTableError, DEFAULT_DENSITY_TOLERANCE,
14    DEFAULT_LOGISTIC_STEP, DEFAULT_PROBABILITY_TAIL, DEFAULT_REFINE_TOLERANCE,
15};
16use crate::electron::data::{
17    CrossSectionTable, CrossSectionTableParts, SamplingAxis, ShellChannelTable,
18};
19use crate::electron::elastic::table::{logistic_probability_grid, material_identity};
20use crate::material::Material;
21use rayon::prelude::*;
22
23/// Grids and tolerances of a table build.
24#[derive(Debug, Clone, PartialEq)]
25pub struct InelasticTableOptions {
26    /// Incident kinetic energies on the axis of [`Self::axis`], eV (strictly
27    /// increasing, >= 2 points): above the model's Fermi level by default,
28    /// above the band bottom with [`EnergyAxis::BandBottom`].
29    pub energy_ev: Vec<f64>,
30    /// The axis `energy_ev` is on ([`EnergyAxis::ModelFermiLevel`] by
31    /// default).
32    pub axis: EnergyAxis,
33    /// Starting cumulative-probability grid of the stored inverse CDFs (from
34    /// exactly 0 to exactly 1); the final grid when `refine_tolerance` is
35    /// `None`.
36    pub probability: Vec<f64>,
37    /// Tolerance of the loss-density model ([`DEFAULT_DENSITY_TOLERANCE`]).
38    pub density_tolerance: f64,
39    /// Target of the probability-grid refinement, relative to the mean loss
40    /// of each row (`None`: no refinement).
41    pub refine_tolerance: Option<f64>,
42    /// Extra rows that resolve the inelastic rate just above the Fermi level
43    /// (module docs, "Rate refinement"; #339). `None` (the default): the
44    /// table has exactly the rows of `energy_ev`. Only valid with
45    /// [`EnergyAxis::BandBottom`]; refused on the model's own axis.
46    pub rate_refinement: Option<RateRefinement>,
47}
48
49impl InelasticTableOptions {
50    /// The defaults on the given energy grid.
51    pub fn new(energy_ev: Vec<f64>) -> Self {
52        Self {
53            energy_ev,
54            axis: EnergyAxis::ModelFermiLevel,
55            probability: default_probability_grid(),
56            density_tolerance: DEFAULT_DENSITY_TOLERANCE,
57            refine_tolerance: Some(DEFAULT_REFINE_TOLERANCE),
58            rate_refinement: None,
59        }
60    }
61
62    /// The same options with the table stored on `axis`.
63    pub fn with_axis(mut self, axis: EnergyAxis) -> Self {
64        self.axis = axis;
65        self
66    }
67
68    /// The same options with the rate refinement `refinement` (module docs,
69    /// "Rate refinement"); needs [`EnergyAxis::BandBottom`].
70    pub fn with_rate_refinement(mut self, refinement: RateRefinement) -> Self {
71        self.rate_refinement = Some(refinement);
72        self
73    }
74}
75
76/// The default starting probability grid: [`logistic_probability_grid`] with
77/// [`DEFAULT_PROBABILITY_TAIL`] and [`DEFAULT_LOGISTIC_STEP`].
78pub fn default_probability_grid() -> Vec<f64> {
79    logistic_probability_grid(DEFAULT_PROBABILITY_TAIL, DEFAULT_LOGISTIC_STEP)
80        .expect("valid default grid")
81}
82
83fn check_options(o: &InelasticTableOptions) -> Result<(), InelasticTableError> {
84    let p = &o.probability;
85    let ok = p.len() >= 2
86        && p[0] == 0.0
87        && p[p.len() - 1] == 1.0
88        && p.iter().all(|x| x.is_finite())
89        && p.windows(2).all(|w| w[1] > w[0]);
90    if !ok {
91        return Err(invalid(
92            "probability grid",
93            "need >= 2 finite, strictly increasing points from exactly 0 to exactly 1",
94        ));
95    }
96    if !(o.density_tolerance.is_finite() && (1.0e-9..=1.0e-2).contains(&o.density_tolerance)) {
97        return Err(invalid(
98            "density tolerance",
99            format!("{} (need 1e-9 ..= 1e-2)", o.density_tolerance),
100        ));
101    }
102    if let Some(t) = o.refine_tolerance {
103        if !(t.is_finite() && t > 0.0) {
104            return Err(invalid(
105                "refine tolerance",
106                format!("{t} (need finite, > 0)"),
107            ));
108        }
109    }
110    if let Some(r) = &o.rate_refinement {
111        if o.axis != EnergyAxis::BandBottom {
112            return Err(invalid(
113                "rate refinement",
114                "only the band-bottom energy axis is refined (EnergyAxis::BandBottom)",
115            ));
116        }
117        let (lo, hi) = r.window_ev;
118        if !(lo.is_finite() && hi.is_finite() && lo > 0.0 && hi > lo) {
119            return Err(invalid(
120                "rate refinement window",
121                format!("{lo}..{hi} eV above E_F (need finite, 0 < low < high)"),
122            ));
123        }
124        if !(r.tolerance.is_finite() && r.tolerance > 0.0) {
125            return Err(invalid(
126                "rate refinement tolerance",
127                format!("{} (need finite, > 0)", r.tolerance),
128            ));
129        }
130    }
131    Ok(())
132}
133
134/// Build the inelastic energy-loss table of `penn` on the grid of `options`.
135///
136/// `material` only labels the table (its identity string); the density that
137/// sets the absolute rate is inside the optical ELF of `penn`
138/// ([`SinglePolePenn::imfp_and_stopping`] returns `λ⁻¹` in m⁻¹ with no
139/// further input). Energies are on the axis of `options.axis`: kinetic
140/// energies above the model's Fermi level by default, above the band bottom
141/// with [`EnergyAxis::BandBottom`] (module docs, "Energy axis").
142pub fn build_inelastic_table(
143    penn: &SinglePolePenn,
144    material: &Material,
145    options: &InelasticTableOptions,
146) -> Result<CrossSectionTable, InelasticTableError> {
147    check_no_exchange(penn)?;
148    check_options(options)?;
149    let (options, added) = with_refined_grid(penn, options)?;
150    let options = options.as_ref();
151    let (inverse_mfp_per_m, probability, quantiles) = loss_rows(penn, options)?;
152    let energy = &options.energy_ev;
153    let elf = penn.optical_elf();
154    let model = format!(
155        "lindhard {} electron::inelastic::table: single-pole Penn DIIMFP (nonrelativistic), \
156         W inverse CDF from an adaptive piecewise-linear density in ln W",
157        env!("CARGO_PKG_VERSION")
158    );
159    let provenance = format!(
160        "computed by lindhard {} from published formulas (see electron::inelastic docs); \
161         optical ELF: {} ({}); Fermi energy {} eV; integration tolerance {}; \
162         density tolerance {}; {} energies {}..{} eV; {} probability points",
163        env!("CARGO_PKG_VERSION"),
164        elf.material(),
165        elf.provenance(),
166        penn.fermi_energy_ev(),
167        penn.relative_tolerance(),
168        options.density_tolerance,
169        energy.len(),
170        energy[0],
171        energy[energy.len() - 1],
172        probability.len()
173    ) + &axis_provenance(penn, options)
174        + &refinement_provenance(options, added);
175    Ok(CrossSectionTable::new(CrossSectionTableParts {
176        model,
177        material: material_identity(material),
178        provenance,
179        axis: SamplingAxis::InelasticEnergyLoss,
180        energy_ev: energy.clone(),
181        inverse_mfp_per_m,
182        probability,
183        quantiles,
184    })?)
185}
186
187/// Build the inelastic energy-loss table of any of the three ELF-extension
188/// models ([`PennInelastic`]: single-pole Penn, full Penn or Mermin-ELF) on
189/// the grid of `options`, by the same procedure as [`build_inelastic_table`]
190/// (module docs), with that model's DIIMFP and inverse IMFP in place of the
191/// single pole's. For [`PennInelastic::SinglePole`] the rows are those of
192/// [`build_inelastic_table`]; only the `model` and `provenance` strings
193/// differ, and they carry [`PennInelastic::model_identity`], so a table
194/// names the algorithm it was built with.
195///
196/// The full Penn and Mermin models evaluate their DIIMFP by numerical
197/// integration, so a table of either costs far more than a single-pole table
198/// on the same grid. For the full model the rows read the DIIMFP from a
199/// [`DiimfpGrid`] built once for the grid energies
200/// ([`FullPenn::diimfp_grid`]: interpolation error held to the model
201/// tolerance), which is what makes such a table affordable (#256); the
202/// inverse mean free paths are still the model's own.
203pub fn build_inelastic_table_for_model(
204    model: &PennInelastic,
205    material: &Material,
206    options: &InelasticTableOptions,
207) -> Result<CrossSectionTable, InelasticTableError> {
208    if let PennInelastic::SinglePole(p) = model {
209        check_no_exchange(p)?;
210    }
211    check_options(options)?;
212    let (options, added) = with_refined_grid(model, options)?;
213    let options = options.as_ref();
214    let (inverse_mfp_per_m, probability, quantiles) = loss_rows(model, options)?;
215    let energy = &options.energy_ev;
216    let elf = model.optical_elf();
217    let identity = model.model_identity();
218    // the full model's rows read the DIIMFP from its loss grid (#256)
219    let via = match model {
220        PennInelastic::Full(_) => " (from the T-independent loss grid FullPenn::diimfp_grid)",
221        _ => "",
222    };
223    let model_text = format!(
224        "lindhard {} electron::inelastic::table: {identity} DIIMFP{via}, \
225         W inverse CDF from an adaptive piecewise-linear density in ln W",
226        env!("CARGO_PKG_VERSION")
227    );
228    let provenance = format!(
229        "computed by lindhard {} from published formulas (see electron::inelastic docs); \
230         model: {identity}; optical ELF: {} ({}); density tolerance {}; \
231         {} energies {}..{} eV; {} probability points",
232        env!("CARGO_PKG_VERSION"),
233        elf.material(),
234        elf.provenance(),
235        options.density_tolerance,
236        energy.len(),
237        energy[0],
238        energy[energy.len() - 1],
239        probability.len()
240    ) + &axis_provenance(model, options)
241        + &refinement_provenance(options, added);
242    Ok(CrossSectionTable::new(CrossSectionTableParts {
243        model: model_text,
244        material: material_identity(material),
245        provenance,
246        axis: SamplingAxis::InelasticEnergyLoss,
247        energy_ev: energy.clone(),
248        inverse_mfp_per_m,
249        probability,
250        quantiles,
251    })?)
252}
253
254/// The model's kinetic energy `T` of each row of `options`: the axis energy
255/// itself on [`EnergyAxis::ModelFermiLevel`], `E - E_F` (`E_F` the model's
256/// Fermi energy) on [`EnergyAxis::BandBottom`].
257fn model_energies_ev<M: LossModel>(penn: &M, options: &InelasticTableOptions) -> Vec<f64> {
258    match options.axis {
259        EnergyAxis::ModelFermiLevel => options.energy_ev.clone(),
260        EnergyAxis::BandBottom => {
261            let ef = penn.fermi_energy_ev();
262            options.energy_ev.iter().map(|&e| e - ef).collect()
263        }
264    }
265}
266
267/// The provenance text of the axis: empty on the model's own axis (so tables
268/// built before the axis option existed keep their bytes), the axis and the
269/// Fermi energy otherwise.
270fn axis_provenance<M: LossModel>(penn: &M, options: &InelasticTableOptions) -> String {
271    match options.axis {
272        EnergyAxis::ModelFermiLevel => String::new(),
273        EnergyAxis::BandBottom => format!(
274            "; energy axis: band bottom (row at E is the model's at T = E - {} eV)",
275            penn.fermi_energy_ev()
276        ),
277    }
278}
279
280/// The tables of a [`ShellResolvedChannels`]: the valence channel's and one
281/// per inner shell, in the order of
282/// [`ShellResolvedChannels::inner_shells`]
283/// ([`build_shell_channel_tables`]).
284#[derive(Debug, Clone, PartialEq)]
285pub struct ShellResolvedTables {
286    /// The valence channel, as [`build_inelastic_table`] of
287    /// [`ShellResolvedChannels::valence`].
288    pub valence: CrossSectionTable,
289    /// The inner-shell channels.
290    pub shells: Vec<ShellChannelTable>,
291}
292
293/// Build the energy-loss tables of every channel of `channels` on the grid of
294/// `options`: the valence table, and for inner shell `i` the table of
295/// [`ShellResolvedChannels::shell_model`] (that shell's own optical ELF with
296/// the valence model's settings), wrapped with the shell's identity, binding
297/// energy and `binding_provenance` (the origin of the binding energies, for
298/// example [`crate::electron::data::SubshellBindingTable::provenance`]).
299///
300/// Each table is built by [`build_inelastic_table`], one after the other in
301/// channel order, so the result is bit-identical at any thread count. Its
302/// rate is the channel's own `λ⁻¹_j(E)` and its rows the inverse CDF of that
303/// channel's DIIMFP: the **channel-first** construction, in which a
304/// transport picks a channel with probability `λ⁻¹_j / Σ λ⁻¹` and then draws
305/// the loss from that channel's table (not the conditional channel choice of
306/// [`ShellResolvedChannels::sample_channel`], which belongs with a loss drawn
307/// from the total DIIMFP; the two are not mixed).
308///
309/// **Supported model.** Single-pole Penn per channel only, without the
310/// exchange correction (rejected, as by [`build_inelastic_table`]); so the
311/// binding energy does not enter a shell's DIIMFP, which is zero below the
312/// first energy of the shell's ELF (at or above `B` by construction of
313/// [`ShellResolvedChannels::new`]). Each shell row is floored at `B`, which
314/// only removes the rounding of `exp(ln W)` at the edge. No full Penn or
315/// Mermin valence model is wired to shell channels, and the valence ELF must
316/// not already contain the shells (no partition rule is applied, see
317/// [`super::inner_shell`](super::super::inner_shell)).
318///
319/// **Energy axis.** As for [`build_inelastic_table`], every table (valence
320/// and shells) is on the axis of `options.axis`: kinetic energies above the
321/// model's Fermi energy (zero by default) with
322/// [`EnergyAxis::ModelFermiLevel`], above the band bottom with
323/// [`EnergyAxis::BandBottom`], each row then the model's at `T = E - E_F`
324/// with `E_F` the Fermi energy of the model (the shell models share the
325/// valence model's settings, so its Fermi energy) (module docs, "Energy
326/// axis"). See `crate::electron::transport`, "Energy reference of the
327/// inelastic table", for how the transport reads them.
328pub fn build_shell_channel_tables(
329    channels: &ShellResolvedChannels,
330    material: &Material,
331    options: &InelasticTableOptions,
332    binding_provenance: &str,
333) -> Result<ShellResolvedTables, InelasticTableError> {
334    let valence = build_inelastic_table(channels.valence(), material, options)?;
335    let mut shells = Vec::with_capacity(channels.inner_shells().len());
336    for (i, s) in channels.inner_shells().iter().enumerate() {
337        let b = s.binding_energy_ev;
338        let base = build_inelastic_table(channels.shell_model(i), material, options)?;
339        let mut parts = base.parts().clone();
340        let label = s.subshell.label();
341        parts.model = format!("{} [inner-shell channel Z = {} {label}]", parts.model, s.z);
342        parts.provenance = format!(
343            "{}; inner-shell channel Z = {} {label}, binding energy {b} eV ({}), \
344             losses floored at the binding energy",
345            parts.provenance,
346            s.z,
347            binding_provenance.trim()
348        );
349        for row in &mut parts.quantiles {
350            for w in row.iter_mut() {
351                *w = w.max(b);
352            }
353        }
354        let table = CrossSectionTable::new(parts)?;
355        shells.push(ShellChannelTable::new(
356            s.z,
357            s.subshell,
358            b,
359            binding_provenance,
360            table,
361        )?);
362    }
363    Ok(ShellResolvedTables { valence, shells })
364}
365
366/// Inverse mean free paths, the (refined) probability grid and the quantile
367/// rows of a table of `penn` on the grid of `options` (already checked).
368type LossRows = (Vec<f64>, Vec<f64>, Vec<Vec<f64>>);
369
370pub(super) fn loss_rows<M: LossModel>(
371    penn: &M,
372    options: &InelasticTableOptions,
373) -> Result<LossRows, InelasticTableError> {
374    // The model's energy `T` of each row (module docs, "Energy axis").
375    let energy = &model_energies_ev(penn, options);
376    // The DIIMFP grid covers only the rows that get losses: on the
377    // band-bottom axis a row at or below the Fermi level has none (below),
378    // and the grid would reject its energy.
379    let grid = match options.axis {
380        EnergyAxis::ModelFermiLevel => penn.diimfp_grid(energy)?,
381        EnergyAxis::BandBottom => {
382            let lossy: Vec<f64> = energy.iter().copied().filter(|&e| e > 0.0).collect();
383            penn.diimfp_grid(&lossy)?
384        }
385    };
386    // Rows in parallel, collected in grid order; the lowest failing energy
387    // is the reported error whatever the thread count.
388    type Row = Result<(f64, Option<LinearDensity>), InelasticTableError>;
389    let results: Vec<Row> = energy
390        .par_iter()
391        .map(|&e| {
392            let inv = row_rate(penn, options.axis, e)?;
393            if inv > 0.0 {
394                let d = loss_density(penn, grid.as_ref(), e, inv, options.density_tolerance)?;
395                Ok((inv, Some(d)))
396            } else {
397                Ok((0.0, None))
398            }
399        })
400        .collect();
401    let mut inverse_mfp_per_m = Vec::with_capacity(energy.len());
402    let mut rows: Vec<Option<LinearDensity>> = Vec::with_capacity(energy.len());
403    for r in results {
404        let (inv, d) = r?;
405        inverse_mfp_per_m.push(inv);
406        rows.push(d);
407    }
408    let probability = &options.probability;
409    let quantiles: Vec<Vec<f64>> = rows
410        .par_iter()
411        .zip(energy.par_iter())
412        .map(|(r, &e)| match r {
413            Some(d) => probability
414                .iter()
415                .map(|&u| loss_quantile(d, e, u))
416                .collect(),
417            None => Vec::new(),
418        })
419        .collect();
420    let (probability, quantiles) = match options.refine_tolerance {
421        Some(tol) => refine_probability(&rows, energy, probability.clone(), quantiles, tol),
422        None => (probability.clone(), quantiles),
423    };
424    Ok((inverse_mfp_per_m, probability, quantiles))
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn rate_refinement_needs_the_band_bottom_axis_and_a_valid_window() {
433        let mut o = InelasticTableOptions::new(vec![5.0, 20.0])
434            .with_rate_refinement(RateRefinement::default());
435        assert!(check_options(&o)
436            .unwrap_err()
437            .to_string()
438            .contains("band-bottom"));
439        o.axis = EnergyAxis::BandBottom;
440        assert!(check_options(&o).is_ok());
441        for (window, tol) in [((3.0, 2.0), 0.01), ((0.0, 2.0), 0.01), ((2.0, 20.0), 0.0)] {
442            let r = o.rate_refinement.as_mut().unwrap();
443            r.window_ev = window;
444            r.tolerance = tol;
445            assert!(check_options(&o).is_err(), "{window:?} {tol}");
446        }
447    }
448}