Skip to main content

lindhard/electron/elastic/
table.rs

1//! Energy-grid elastic tables: the radial-Dirac solver of the parent module
2//! run over a log energy grid for every element of a [`Material`], stored as
3//! an [`electron::data::CrossSectionTable`](crate::electron::data::CrossSectionTable)
4//! with [`SamplingAxis::ElasticPolarAngle`] (issue #90, step 2 of the Mott
5//! elastic model).
6//!
7//! # Independent-atom approximation
8//!
9//! A compound or mixture is treated as a set of **independent atoms**: the
10//! macroscopic differential cross section is the number-density-weighted sum
11//! of the atomic ones, `n dσ/dΩ = Σ_z n_z dσ_z/dΩ`. Chemical bonding, the
12//! solid-state potential and coherent (diffraction) effects between atoms are
13//! ignored. This is an approximation; condensed-phase (muffin-tin) potentials
14//! are follow-up work. Consequently
15//!
16//! * the inverse mean free path is `λ⁻¹(E) = Σ_z n_z σ_el,z(E)`, with `n_z`
17//!   from [`Material::number_density_of`] and `σ_el,z` the solver's total
18//!   elastic cross section ([`PartialWaves::sigma_el`]);
19//! * the forward cumulative distribution of the polar angle is
20//!   `F(θ) = Σ_z n_z S_z(θ) / Σ_z n_z S_z(π)`, with
21//!   `S_z(θ) = 2π ∫_0^θ dσ_z/dΩ sin θ' dθ'`.
22//!
23//! The stored quantiles are the inverse of that `F`: they do not add
24//! linearly between elements, the forward CDF does.
25//!
26//! # Angular distribution: exact Legendre form
27//!
28//! The Mott DCS `|f|² + |g|²` of a phase-shift set cut at `l_max` is a
29//! polynomial of degree `2 l_max` in `x = cos θ` (the parent module documents
30//! the amplitudes). Its Legendre coefficients
31//! `c_L = (2L+1)/2 ∫ DCS(x) P_L(x) dx`, `L = 0..=2 l_max`, are therefore
32//! obtained *exactly* (to rounding) with a `2 l_max + 2`-point Gauss-Legendre
33//! rule, and the cumulative cross section follows in closed form from the
34//! Legendre identity `(2L+1) P_L = P'_{L+1} - P'_{L-1}`. That identity is
35//! DLMF 18.9.7 (`(n+λ) C_n^(λ) = λ (C_n^(λ+1) - C_{n-2}^(λ+1))`) at
36//! `λ = 1/2`, with `C_n^(1/2) = P_n` (DLMF 18.7.9) and
37//! `d/dx C_{n+1}^(1/2) = C_n^(3/2)` (DLMF 18.9.19); NIST Digital Library of
38//! Mathematical Functions, <https://dlmf.nist.gov/18.9>, read 2026-10-07. The
39//! unit tests also check it numerically against direct quadrature:
40//!
41//! ```text
42//! S(θ) = 2π ∫_x^1 DCS dx' = 2π [ c_0 (1 - x) + Σ_{L>=1} c_L (P_{L-1}(x) - P_{L+1}(x)) / (2L+1) ]
43//! ```
44//!
45//! with the complementary form (integral from `-1` to `x`) used for
46//! cumulative probabilities above 1/2, so the backward tail keeps its
47//! relative precision. `S(π) = 4π c_0`. No angular grid of the DCS is
48//! interpolated anywhere: the only discretisation in a table is the
49//! probability grid of the stored inverse CDF.
50//!
51//! # Inverse CDF
52//!
53//! At each energy the quantile `θ_j` with `F(θ_j) = u_j` is found by a
54//! safeguarded Newton iteration (bisection fallback) on the exact `F`, after
55//! bracketing on a fixed coarse angle grid. `u = 0` maps to `θ = 0` and
56//! `u = 1` to `θ = π`. The probability grid is shared by all rows and must
57//! resolve both tails: the Mott DCS is strongly forward peaked at high energy,
58//! so most of the first transport cross section `σ_tr1 = σ_el ⟨1 - cos θ⟩`
59//! sits in the large-angle tail at `1 - u` far below 1e-3. The starting grid
60//! ([`default_probability_grid`]) is uniform in the logistic variable
61//! `t = ln(u/(1-u))`, which is geometric in `u` near 0 and in `1 - u` near 1.
62//!
63//! A fixed grid of that kind is not enough. At intermediate energies the DCS
64//! has deep diffraction minima, where `θ(u)` is steep, and linear
65//! interpolation there biases `⟨1 - cos θ⟩`. Measured with the Thomas-Fermi
66//! Yukawa stand-in on the default energy grid (Z = 1, 6, 14, 29, 79, 92), the
67//! worst relative error of the recovered `σ_tr1` was 2e-2 for a logistic
68//! step of 0.4, 3e-3 for a step of 0.1 and still 1.3e-3 for a step of 0.05
69//! (1109 points). The grid is therefore **refined adaptively**: shared by all
70//! rows of a table and split where any row needs it (see `refine` in the
71//! source). With the defaults (step 0.4, tolerance
72//! [`DEFAULT_REFINE_TOLERANCE`]) the same measurement gives a worst error of
73//! 9.4e-5 or less, with 939 to 1604 probability points. The tests hold the
74//! recovered `σ_tr1` to 1e-3 at every grid energy.
75//!
76//! # Potentials
77//!
78//! The atomic potential comes from a caller-supplied [`PotentialSource`].
79//! [`SalvatDhfsTable`] gives the Salvat et al. (1987) DHFS potentials (Table I,
80//! Z = 1..92, see the parent module and `docs/data-provenance.md`); most tests
81//! and the benchmarks use [`ThomasFermiYukawa`], a **stand-in** Yukawa
82//! potential with the Thomas-Fermi length. The table's `model` and
83//! `provenance` strings name the potential, so a stand-in table never reads
84//! as DHFS.
85//!
86//! The optional exchange and correlation-polarization corrections
87//! ([`super::corrections`]) enter through
88//! [`AtomicElastic::compute_corrected`], which rebuilds the corrected
89//! potential at each grid energy; the caller's description then names the
90//! corrections too.
91//!
92//! # Determinism
93//!
94//! Energies are solved in parallel with rayon and collected in grid order;
95//! every per-energy computation is serial and in a fixed order (the solver's
96//! own partial-wave parallelism is already order-preserving). Errors are
97//! reported for the lowest failing energy, whatever the thread count. Tables
98//! are bit-identical on any number of threads (tested).
99
100use super::corrections::{CorrectedPotential, Corrections, ElectronDensity};
101use super::{
102    gauss_legendre, ElasticError, ElasticSolver, PartialWaves, SalvatDhfs, ScreenedPotential,
103    SolverOptions, Yukawa, BOHR2_TO_M2,
104};
105use crate::electron::data::{
106    CrossSectionTable, CrossSectionTableParts, ElectronDataError, SamplingAxis,
107};
108use crate::elements::element;
109use crate::material::{Material, MaterialError};
110use rayon::prelude::*;
111use std::f64::consts::PI;
112
113/// Lowest energy of the default grid, eV.
114pub const DEFAULT_MIN_ENERGY_EV: f64 = 10.0;
115/// Highest energy of the default grid, eV.
116pub const DEFAULT_MAX_ENERGY_EV: f64 = 5.0e4;
117/// Minimum number of energy points per decade of the default grid.
118pub const DEFAULT_POINTS_PER_DECADE: f64 = 20.0;
119/// Default probability tails: the first and last interior probability points
120/// are `DEFAULT_PROBABILITY_TAIL` and `1 - DEFAULT_PROBABILITY_TAIL`.
121pub const DEFAULT_PROBABILITY_TAIL: f64 = 1.0e-12;
122/// Default step of the probability grid in the logistic variable
123/// `t = ln(u/(1-u))`. Chosen by measurement, see the module docs.
124pub const DEFAULT_LOGISTIC_STEP: f64 = 0.4;
125
126/// Default target of the probability-grid refinement, relative to
127/// `⟨1 - cos θ⟩` (see [`ElasticTableOptions::refine_tolerance`]).
128pub const DEFAULT_REFINE_TOLERANCE: f64 = 5.0e-4;
129
130/// Errors from the table builder.
131#[derive(Debug, thiserror::Error)]
132pub enum ElasticTableError {
133    /// An input violates an invariant.
134    #[error("invalid {what}: {reason}")]
135    Invalid {
136        /// Which quantity.
137        what: &'static str,
138        /// What is wrong with it.
139        reason: String,
140    },
141    /// The potential source has no potential for this element.
142    #[error("no screened potential for Z={z}: {source}")]
143    Potential {
144        /// Atomic number.
145        z: u8,
146        /// The source's error.
147        source: ElasticError,
148    },
149    /// The partial-wave solver failed.
150    #[error("elastic solve failed for Z={z} at {energy_ev} eV: {source}")]
151    Solve {
152        /// Atomic number.
153        z: u8,
154        /// Kinetic energy, eV.
155        energy_ev: f64,
156        /// The solver's error.
157        source: ElasticError,
158    },
159    /// A material lookup failed.
160    #[error(transparent)]
161    Material(#[from] MaterialError),
162    /// The assembled table failed validation.
163    #[error(transparent)]
164    Data(#[from] ElectronDataError),
165}
166
167fn invalid(what: &'static str, reason: impl Into<String>) -> ElasticTableError {
168    ElasticTableError::Invalid {
169        what,
170        reason: reason.into(),
171    }
172}
173
174// ---------------------------------------------------------------------------
175// Potential sources
176
177/// Supplies the atomic screened potential of each element.
178pub trait PotentialSource: Sync {
179    /// The potential of element `z`.
180    fn potential(&self, z: u8) -> Result<Box<dyn ScreenedPotential>, ElasticError>;
181
182    /// One line naming the potential model and its origin. It is written into
183    /// the table's `model` and `provenance` strings.
184    fn description(&self) -> String;
185}
186
187/// **Stand-in** potential: a Yukawa (single exponential) screened Coulomb
188/// potential `V = -(Z/r) exp(-r/a)` with the Thomas-Fermi length
189/// `a = 0.8853 a0 Z^(-1/3)`
190/// ([`thomas_fermi_constant`](crate::ion::potential::thomas_fermi_constant),
191/// Firsov 1958 / Lindhard, Scharff & Schiott 1963, see
192/// `docs/data-provenance.md`). It is not an atomic DHFS potential; it was
193/// the only potential while the Salvat et al. (1987) coefficients were
194/// missing, and remains a cheap one for tests and benchmarks.
195#[derive(Debug, Clone, Copy, Default, PartialEq)]
196pub struct ThomasFermiYukawa;
197
198impl ThomasFermiYukawa {
199    /// The stand-in potential of element `z`. A [`Yukawa`] is also an
200    /// [`ElectronDensity`] (its Poisson density), which is what the optional
201    /// exchange and correlation-polarization corrections need
202    /// ([`AtomicElastic::compute_corrected`]).
203    pub fn yukawa(z: u8) -> Result<Yukawa, ElasticError> {
204        let zf = f64::from(z);
205        if z == 0 {
206            return Err(ElasticError::Invalid {
207                what: "atomic number",
208                reason: "0".into(),
209            });
210        }
211        let a = crate::ion::potential::thomas_fermi_constant() / zf.cbrt();
212        Yukawa::new(zf, a)
213    }
214}
215
216impl PotentialSource for ThomasFermiYukawa {
217    fn potential(&self, z: u8) -> Result<Box<dyn ScreenedPotential>, ElasticError> {
218        Ok(Box::new(Self::yukawa(z)?))
219    }
220
221    fn description(&self) -> String {
222        "STAND-IN Yukawa potential -(Z/r) exp(-r/a) with Thomas-Fermi length \
223         a = 0.8853 a0 Z^(-1/3); not a DHFS atomic potential"
224            .into()
225    }
226}
227
228/// The Salvat et al. (1987) DHFS potentials via [`SalvatDhfs::for_element`]
229/// (Table I, Z = 1..92; `docs/data-provenance.md`).
230#[derive(Debug, Clone, Copy, Default, PartialEq)]
231pub struct SalvatDhfsTable;
232
233impl PotentialSource for SalvatDhfsTable {
234    fn potential(&self, z: u8) -> Result<Box<dyn ScreenedPotential>, ElasticError> {
235        Ok(Box::new(SalvatDhfs::for_element(u32::from(z))?))
236    }
237
238    fn description(&self) -> String {
239        "Salvat, Martinez, Mayol & Parellada, Phys. Rev. A 36, 467 (1987) DHFS analytic \
240         screening"
241            .into()
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Grids
247
248/// Most intervals [`log_energy_grid`] will build. A guard against an input
249/// whose `points_per_decade` would otherwise request an unbounded allocation
250/// (found by the malformed-input corpus in `tests/input_robustness.rs`); the
251/// default grid uses a few hundred.
252pub const MAX_GRID_INTERVALS: usize = 1_000_000;
253
254/// A log-spaced energy grid from `min_ev` to `max_ev` (both included exactly)
255/// with at least `points_per_decade` points per decade.
256pub fn log_energy_grid(
257    min_ev: f64,
258    max_ev: f64,
259    points_per_decade: f64,
260) -> Result<Vec<f64>, ElasticTableError> {
261    if !(min_ev.is_finite() && max_ev.is_finite() && min_ev > 0.0 && max_ev > min_ev) {
262        return Err(invalid(
263            "energy range",
264            format!("{min_ev} .. {max_ev} eV (need 0 < min < max, finite)"),
265        ));
266    }
267    if !(points_per_decade.is_finite() && points_per_decade > 0.0) {
268        return Err(invalid(
269            "points per decade",
270            format!("{points_per_decade} (need finite, > 0)"),
271        ));
272    }
273    let decades = (max_ev / min_ev).log10();
274    let wanted = (decades * points_per_decade).ceil();
275    if wanted > MAX_GRID_INTERVALS as f64 {
276        return Err(invalid(
277            "points per decade",
278            format!(
279                "{points_per_decade} over {decades:.1} decades needs {wanted} grid intervals \
280                 (at most {MAX_GRID_INTERVALS})"
281            ),
282        ));
283    }
284    let intervals = (wanted as usize).max(1);
285    let step = decades / intervals as f64;
286    let mut grid: Vec<f64> = (0..=intervals)
287        .map(|i| min_ev * 10f64.powf(step * i as f64))
288        .collect();
289    grid[0] = min_ev;
290    grid[intervals] = max_ev;
291    Ok(grid)
292}
293
294/// The default energy grid: [`DEFAULT_MIN_ENERGY_EV`] to
295/// [`DEFAULT_MAX_ENERGY_EV`] with at least [`DEFAULT_POINTS_PER_DECADE`]
296/// points per decade.
297pub fn default_energy_grid() -> Vec<f64> {
298    log_energy_grid(
299        DEFAULT_MIN_ENERGY_EV,
300        DEFAULT_MAX_ENERGY_EV,
301        DEFAULT_POINTS_PER_DECADE,
302    )
303    .expect("valid default grid")
304}
305
306/// A probability grid uniform in the logistic variable `t = ln(u/(1-u))`
307/// between `u = tail` and `u = 1 - tail` with step at most `step`, plus the
308/// end points 0 and 1.
309pub fn logistic_probability_grid(tail: f64, step: f64) -> Result<Vec<f64>, ElasticTableError> {
310    if !(tail.is_finite() && tail > 1.0e-15 && tail < 0.5) {
311        return Err(invalid(
312            "probability tail",
313            format!("{tail} (need 1e-15 < tail < 0.5)"),
314        ));
315    }
316    if !(step.is_finite() && step >= 1.0e-3) {
317        return Err(invalid(
318            "logistic step",
319            format!("{step} (need finite, >= 1e-3)"),
320        ));
321    }
322    let t_max = ((1.0 - tail) / tail).ln();
323    let n = ((2.0 * t_max / step).ceil() as usize).max(1);
324    let h = 2.0 * t_max / n as f64;
325    let mut grid = Vec::with_capacity(n + 3);
326    grid.push(0.0);
327    for i in 0..=n {
328        let t = -t_max + h * i as f64;
329        // Each branch keeps the small side (u or 1-u) at full relative precision.
330        let u = if t <= 0.0 {
331            let e = t.exp();
332            e / (1.0 + e)
333        } else {
334            1.0 - 1.0 / (1.0 + t.exp())
335        };
336        if u > *grid.last().expect("non-empty") && u < 1.0 {
337            grid.push(u);
338        }
339    }
340    grid.push(1.0);
341    Ok(grid)
342}
343
344/// The default probability grid: [`logistic_probability_grid`] with
345/// [`DEFAULT_PROBABILITY_TAIL`] and [`DEFAULT_LOGISTIC_STEP`].
346pub fn default_probability_grid() -> Vec<f64> {
347    logistic_probability_grid(DEFAULT_PROBABILITY_TAIL, DEFAULT_LOGISTIC_STEP)
348        .expect("valid default grid")
349}
350
351/// Grids and solver settings of a table build.
352#[derive(Debug, Clone, PartialEq)]
353pub struct ElasticTableOptions {
354    /// Incident kinetic energies, eV (strictly increasing, >= 2 points).
355    pub energy_ev: Vec<f64>,
356    /// Cumulative-probability grid of the stored inverse CDFs: the starting
357    /// grid when `refine_tolerance` is set, the final grid otherwise.
358    pub probability: Vec<f64>,
359    /// Target of the adaptive refinement of the probability grid, as a
360    /// fraction of `⟨1 - cos θ⟩` at each energy (`None`: no refinement).
361    /// The grid is shared by all rows of one table.
362    pub refine_tolerance: Option<f64>,
363    /// Radial-Dirac solver settings, used unchanged at every energy.
364    pub solver: SolverOptions,
365}
366
367impl Default for ElasticTableOptions {
368    fn default() -> Self {
369        Self {
370            energy_ev: default_energy_grid(),
371            probability: default_probability_grid(),
372            refine_tolerance: Some(DEFAULT_REFINE_TOLERANCE),
373            solver: SolverOptions::default(),
374        }
375    }
376}
377
378// ---------------------------------------------------------------------------
379// One element at one energy
380
381/// Partial-wave results of one element at one energy, with the DCS in exact
382/// Legendre form (see the module docs). Cross sections in bohr².
383#[derive(Debug, Clone, PartialEq)]
384pub struct AtomicElasticRow {
385    energy_ev: f64,
386    l_max: usize,
387    sigma_el: f64,
388    sigma_tr1: f64,
389    legendre: Vec<f64>,
390}
391
392/// Values of the Legendre series at one angle: `(DCS, S_lower, S_upper)`
393/// without the `2π` factors (`S_lower = ∫_x^1 DCS`, `S_upper = ∫_{-1}^x DCS`).
394fn legendre_sums(c: &[f64], theta: f64) -> (f64, f64, f64) {
395    let x = theta.cos();
396    // 1 - x and 1 + x at full relative precision near either end
397    let (sh, ch) = (0.5 * theta).sin_cos();
398    let (omx, opx) = (2.0 * sh * sh, 2.0 * ch * ch);
399    let mut dcs = c[0];
400    let mut lower = c[0] * omx;
401    let mut upper = c[0] * opx;
402    let (mut p_prev, mut p) = (1.0_f64, x);
403    for (l, &cl) in c.iter().enumerate().skip(1) {
404        let lf = l as f64;
405        let p_next = ((2.0 * lf + 1.0) * x * p - lf * p_prev) / (lf + 1.0);
406        dcs += cl * p;
407        let term = cl * (p_prev - p_next) / (2.0 * lf + 1.0);
408        lower += term;
409        upper -= term;
410        p_prev = p;
411        p = p_next;
412    }
413    (dcs, lower, upper)
414}
415
416impl AtomicElasticRow {
417    /// Build from converged partial waves at `energy_ev`.
418    pub fn from_partial_waves(energy_ev: f64, pw: &PartialWaves) -> Self {
419        let l_max = pw.l_max();
420        let deg = 2 * l_max;
421        let (xs, ws) = gauss_legendre(deg + 2);
422        let mut c = vec![0.0; deg + 1];
423        for (&x, &w) in xs.iter().zip(&ws) {
424            let (f, g) = pw.amplitudes(x);
425            let d = w * (f[0] * f[0] + f[1] * f[1] + g[0] * g[0] + g[1] * g[1]);
426            let (mut p_prev, mut p) = (0.0_f64, 1.0_f64);
427            for (l, cl) in c.iter_mut().enumerate() {
428                *cl += d * p;
429                let lf = l as f64;
430                let p_next = ((2.0 * lf + 1.0) * x * p - lf * p_prev) / (lf + 1.0);
431                p_prev = p;
432                p = p_next;
433            }
434        }
435        for (l, cl) in c.iter_mut().enumerate() {
436            *cl *= (2 * l + 1) as f64 / 2.0;
437        }
438        Self {
439            energy_ev,
440            l_max,
441            sigma_el: pw.sigma_el(),
442            sigma_tr1: pw.sigma_tr1(),
443            legendre: c,
444        }
445    }
446
447    /// Solve at one energy.
448    pub fn solve(
449        pot: &dyn ScreenedPotential,
450        energy_ev: f64,
451        opts: SolverOptions,
452    ) -> Result<Self, ElasticError> {
453        let pw = ElasticSolver::new(pot, energy_ev, opts)?.partial_waves()?;
454        Ok(Self::from_partial_waves(energy_ev, &pw))
455    }
456
457    /// Kinetic energy, eV.
458    pub fn energy_ev(&self) -> f64 {
459        self.energy_ev
460    }
461
462    /// Highest partial wave of the solve.
463    pub fn l_max(&self) -> usize {
464        self.l_max
465    }
466
467    /// The solver's total elastic cross section ([`PartialWaves::sigma_el`]),
468    /// bohr².
469    pub fn sigma_el(&self) -> f64 {
470        self.sigma_el
471    }
472
473    /// The solver's first transport cross section
474    /// ([`PartialWaves::sigma_tr1`]), bohr².
475    pub fn sigma_tr1(&self) -> f64 {
476        self.sigma_tr1
477    }
478
479    /// Legendre coefficients `c_L` of the DCS, bohr²/sr.
480    pub fn legendre_coefficients(&self) -> &[f64] {
481        &self.legendre
482    }
483
484    /// DCS at polar angle `theta` (rad) from the Legendre form, bohr²/sr.
485    pub fn dcs(&self, theta: f64) -> f64 {
486        legendre_sums(&self.legendre, theta).0
487    }
488
489    /// Cumulative cross section `S(θ) = 2π ∫_0^θ DCS sin θ' dθ'`, bohr².
490    pub fn cumulative(&self, theta: f64) -> f64 {
491        2.0 * PI * legendre_sums(&self.legendre, theta).1
492    }
493
494    /// `S(π) = 4π c_0`, the total of the Legendre form, bohr². Equal to
495    /// [`Self::sigma_el`] up to the solver's quadrature-vs-phase-shift
496    /// rounding.
497    pub fn cumulative_total(&self) -> f64 {
498        4.0 * PI * self.legendre[0]
499    }
500}
501
502/// Partial-wave results of one element over an energy grid.
503#[derive(Debug, Clone, PartialEq)]
504pub struct AtomicElastic {
505    z: u8,
506    potential: String,
507    solver: SolverOptions,
508    rows: Vec<AtomicElasticRow>,
509}
510
511impl AtomicElastic {
512    /// Solve element `z` with potential `pot` (described by `description`) at
513    /// every energy of `energy_ev`. Energies run in parallel; the result and
514    /// any error (the one at the lowest failing energy) do not depend on the
515    /// thread count.
516    pub fn compute(
517        z: u8,
518        pot: &dyn ScreenedPotential,
519        description: &str,
520        energy_ev: &[f64],
521        solver: SolverOptions,
522    ) -> Result<Self, ElasticTableError> {
523        check_grid(energy_ev)?;
524        let results: Vec<Result<AtomicElasticRow, ElasticError>> = energy_ev
525            .par_iter()
526            .map(|&e| AtomicElasticRow::solve(pot, e, solver))
527            .collect();
528        let mut rows = Vec::with_capacity(results.len());
529        for (r, &e) in results.into_iter().zip(energy_ev) {
530            rows.push(r.map_err(|source| ElasticTableError::Solve {
531                z,
532                energy_ev: e,
533                source,
534            })?);
535        }
536        Ok(Self {
537            z,
538            potential: description.to_string(),
539            solver,
540            rows,
541        })
542    }
543
544    /// As [`AtomicElastic::compute`], with the optional exchange and
545    /// correlation-polarization corrections of
546    /// [`super::corrections`] added to the static potential `pot` at every
547    /// energy (the corrected potential depends on the energy, so it is
548    /// rebuilt per grid point with [`CorrectedPotential::new`]). `density` is
549    /// the atomic electron density the corrections need. With both
550    /// corrections off this is [`AtomicElastic::compute`] (bit-identical
551    /// rows). `description` names the potential **and** the corrections; it
552    /// becomes the table's `model` and `provenance` (via [`combine`]), and
553    /// every element of one table must share it.
554    pub fn compute_corrected(
555        z: u8,
556        pot: &dyn ScreenedPotential,
557        density: &dyn ElectronDensity,
558        description: &str,
559        energy_ev: &[f64],
560        corrections: &Corrections,
561        solver: SolverOptions,
562    ) -> Result<Self, ElasticTableError> {
563        if corrections.is_none() {
564            return Self::compute(z, pot, description, energy_ev, solver);
565        }
566        check_grid(energy_ev)?;
567        let results: Vec<Result<AtomicElasticRow, ElasticError>> = energy_ev
568            .par_iter()
569            .map(|&e| {
570                let corrected = CorrectedPotential::new(pot, density, e, corrections)?;
571                let pw = corrected.solver(solver)?.partial_waves()?;
572                Ok(AtomicElasticRow::from_partial_waves(e, &pw))
573            })
574            .collect();
575        let mut rows = Vec::with_capacity(results.len());
576        for (r, &e) in results.into_iter().zip(energy_ev) {
577            rows.push(r.map_err(|source| ElasticTableError::Solve {
578                z,
579                energy_ev: e,
580                source,
581            })?);
582        }
583        Ok(Self {
584            z,
585            potential: description.to_string(),
586            solver,
587            rows,
588        })
589    }
590
591    /// Atomic number.
592    pub fn z(&self) -> u8 {
593        self.z
594    }
595
596    /// Description of the potential used.
597    pub fn potential(&self) -> &str {
598        &self.potential
599    }
600
601    /// Solver settings used.
602    pub fn solver(&self) -> SolverOptions {
603        self.solver
604    }
605
606    /// One row per grid energy.
607    pub fn rows(&self) -> &[AtomicElasticRow] {
608        &self.rows
609    }
610}
611
612fn check_grid(energy_ev: &[f64]) -> Result<(), ElasticTableError> {
613    if energy_ev.len() < 2 {
614        return Err(invalid("energy grid", "needs at least 2 points"));
615    }
616    if energy_ev.iter().any(|e| !(e.is_finite() && *e > 0.0))
617        || energy_ev.windows(2).any(|w| w[1] <= w[0])
618    {
619        return Err(invalid(
620            "energy grid",
621            "values must be finite, positive and strictly increasing",
622        ));
623    }
624    Ok(())
625}
626
627// ---------------------------------------------------------------------------
628// Mixture inverse CDF
629
630/// Angles of the coarse bracketing table: 0, then geometric from 1e-9 rad to π.
631fn bracket_angles() -> Vec<f64> {
632    const N: usize = 400;
633    let (lo, hi) = (1.0e-9_f64, PI);
634    let r = (hi / lo).ln() / (N - 1) as f64;
635    let mut v = Vec::with_capacity(N + 1);
636    v.push(0.0);
637    for i in 0..N {
638        v.push(lo * (r * i as f64).exp());
639    }
640    v[N] = PI;
641    v
642}
643
644/// The weighted sum of atomic Legendre series at one energy.
645struct MixtureRow<'a> {
646    parts: Vec<(f64, &'a [f64])>,
647    total: f64,
648}
649
650impl MixtureRow<'_> {
651    /// `(dW/dθ, W_lower, W_upper)` with `W = Σ n_z S_z` (without the common
652    /// `2π`, which cancels in the CDF).
653    fn eval(&self, theta: f64) -> (f64, f64, f64) {
654        let (mut d, mut lo, mut up) = (0.0, 0.0, 0.0);
655        for (n, c) in &self.parts {
656            let (a, b, cc) = legendre_sums(c, theta);
657            d += n * a;
658            lo += n * b;
659            up += n * cc;
660        }
661        (d * theta.sin(), lo, up)
662    }
663
664    /// Exact `⟨1 - cos θ⟩ = 1 - Σ n c_1 / (3 Σ n c_0)` of the mixture
665    /// (`∫ x DCS dx = 2 c_1 / 3`).
666    fn mean_one_minus_cos(&self) -> f64 {
667        let c1: f64 = self
668            .parts
669            .iter()
670            .map(|(n, c)| n * c.get(1).copied().unwrap_or(0.0))
671            .sum();
672        1.0 - (2.0 / 3.0) * c1 / self.total
673    }
674
675    /// Residual increasing in θ whose root is the quantile of `u`, and its
676    /// derivative.
677    fn residual(&self, u: f64, theta: f64) -> (f64, f64) {
678        let (d, lo, up) = self.eval(theta);
679        if u <= 0.5 {
680            (lo - u * self.total, d)
681        } else {
682            ((1.0 - u) * self.total - up, d)
683        }
684    }
685
686    fn quantiles(&self, probability: &[f64], angles: &[f64]) -> Vec<f64> {
687        let m = probability.len();
688        let coarse: Vec<(f64, f64)> = angles
689            .iter()
690            .map(|&t| {
691                let (_, lo, up) = self.eval(t);
692                (lo, up)
693            })
694            .collect();
695        let mut out = Vec::with_capacity(m);
696        let mut prev = 0.0_f64;
697        for (j, &u) in probability.iter().enumerate() {
698            let theta = if j == 0 {
699                0.0
700            } else if j == m - 1 {
701                PI
702            } else {
703                // first coarse node whose residual is positive
704                let k = if u <= 0.5 {
705                    let target = u * self.total;
706                    coarse.partition_point(|c| c.0 <= target)
707                } else {
708                    let target = (1.0 - u) * self.total;
709                    coarse.partition_point(|c| c.1 >= target)
710                };
711                let hi = angles[k.clamp(1, angles.len() - 1)];
712                let lo = angles[k.saturating_sub(1)].max(prev).min(hi);
713                self.solve(u, lo, hi).max(prev)
714            };
715            prev = theta.clamp(0.0, PI);
716            out.push(prev);
717        }
718        out
719    }
720
721    /// Safeguarded Newton on `[lo, hi]`.
722    fn solve(&self, u: f64, mut lo: f64, mut hi: f64) -> f64 {
723        let (flo, _) = self.residual(u, lo);
724        let (fhi, _) = self.residual(u, hi);
725        if flo > 0.0 || fhi < 0.0 {
726            // rounding made the coarse bracket fail; fall back to the widest
727            if flo > 0.0 {
728                lo = 0.0;
729            }
730            if fhi < 0.0 {
731                hi = PI;
732            }
733        }
734        let mut x = 0.5 * (lo + hi);
735        for _ in 0..200 {
736            let (f, df) = self.residual(u, x);
737            if f == 0.0 {
738                return x;
739            }
740            if f < 0.0 {
741                lo = x;
742            } else {
743                hi = x;
744            }
745            if hi - lo <= 4.0 * f64::EPSILON * hi {
746                break;
747            }
748            let newton = x - f / df;
749            x = if df > 0.0 && newton > lo && newton < hi {
750                newton
751            } else {
752                0.5 * (lo + hi)
753            };
754            if x == lo || x == hi {
755                break;
756            }
757        }
758        x
759    }
760}
761
762/// `⟨1 - cos θ⟩` of a stored inverse-CDF row, integrated **exactly** over the
763/// piecewise-linear `θ(u)` that
764/// [`CrossSectionTable::inverse_cdf`](crate::electron::data::CrossSectionTable::inverse_cdf)
765/// interpolates. `σ_tr1 = σ_el ⟨1 - cos θ⟩`.
766pub fn mean_one_minus_cos(probability: &[f64], quantiles: &[f64]) -> f64 {
767    let mut s = 0.0;
768    for (p, q) in probability.windows(2).zip(quantiles.windows(2)) {
769        let du = p[1] - p[0];
770        let m = 0.5 * (q[0] + q[1]);
771        let h = 0.5 * (q[1] - q[0]);
772        // (1/Δθ) ∫ (1 - cos θ) dθ = 1 - cos(m) sinc(h)
773        //                        = 2 sin²(m/2) + cos(m) (1 - sinc h)
774        let one_minus_sinc = if h.abs() < 1.0e-3 {
775            let h2 = h * h;
776            h2 / 6.0 * (1.0 - h2 / 20.0)
777        } else {
778            1.0 - h.sin() / h
779        };
780        s += du * (2.0 * (0.5 * m).sin().powi(2) + m.cos() * one_minus_sinc);
781    }
782    s
783}
784
785/// A one-line identity of the material: its name, if set, and its atom
786/// fractions and density.
787pub(crate) fn material_identity(material: &Material) -> String {
788    let comp: Vec<String> = material
789        .components()
790        .iter()
791        .map(|c| {
792            let sym = element(c.z()).map_or("?", |e| e.symbol);
793            format!("{sym}:{}", c.atom_fraction())
794        })
795        .collect();
796    let base = format!(
797        "{} at {} kg/m^3 (atom fractions)",
798        comp.join(" "),
799        material.mass_density()
800    );
801    match material.name() {
802        Some(n) => format!("{n}: {base}"),
803        None => base,
804    }
805}
806
807/// Combine atomic results into the elastic table of `material` by
808/// independent-atom additivity (module docs). `atoms` must hold every
809/// component of `material` with a non-zero atom fraction, all on the same
810/// energy grid, potential and solver settings. `probability` is the
811/// probability grid, used as given when `refine_tolerance` is `None` and as
812/// the starting grid of the adaptive refinement otherwise (see
813/// [`ElasticTableOptions::refine_tolerance`]).
814pub fn combine(
815    material: &Material,
816    atoms: &[AtomicElastic],
817    probability: &[f64],
818    refine_tolerance: Option<f64>,
819) -> Result<CrossSectionTable, ElasticTableError> {
820    let used: Vec<_> = material
821        .components()
822        .iter()
823        .filter(|c| c.atom_fraction() > 0.0)
824        .collect();
825    let mut parts = Vec::with_capacity(used.len());
826    for c in &used {
827        let atom = atoms
828            .iter()
829            .find(|a| a.z == c.z())
830            .ok_or_else(|| invalid("atomic results", format!("missing Z={}", c.z())))?;
831        parts.push((material.number_density_of(c.z())?, atom));
832    }
833    let first = parts
834        .first()
835        .ok_or_else(|| invalid("material", "no component with a non-zero fraction"))?
836        .1;
837    let energy: Vec<f64> = first.rows.iter().map(|r| r.energy_ev).collect();
838    for (_, a) in &parts {
839        let same_grid = a.rows.len() == energy.len()
840            && a.rows.iter().zip(&energy).all(|(r, e)| r.energy_ev == *e);
841        if !same_grid || a.potential != first.potential || a.solver != first.solver {
842            return Err(invalid(
843                "atomic results",
844                "all elements must share the energy grid, potential and solver settings",
845            ));
846        }
847    }
848    check_probability(probability)?;
849    if let Some(tol) = refine_tolerance {
850        if !(tol.is_finite() && tol > 0.0) {
851            return Err(invalid(
852                "refine tolerance",
853                format!("{tol} (need finite, > 0)"),
854            ));
855        }
856    }
857    let inverse_mfp_per_m: Vec<f64> = (0..energy.len())
858        .map(|i| {
859            parts
860                .iter()
861                .map(|(n, a)| n * a.rows[i].sigma_el * BOHR2_TO_M2)
862                .sum()
863        })
864        .collect();
865    let mixes: Vec<MixtureRow> = (0..energy.len())
866        .map(|i| MixtureRow {
867            parts: parts
868                .iter()
869                .map(|(n, a)| (*n, a.rows[i].legendre.as_slice()))
870                .collect(),
871            total: parts
872                .iter()
873                .map(|(n, a)| n * 2.0 * a.rows[i].legendre[0])
874                .sum(),
875        })
876        .collect();
877    let angles = bracket_angles();
878    let quantiles: Vec<Vec<f64>> = mixes
879        .par_iter()
880        .map(|m| m.quantiles(probability, &angles))
881        .collect();
882    let (probability, quantiles) = match refine_tolerance {
883        Some(tol) => refine(&mixes, probability.to_vec(), quantiles, tol),
884        None => (probability.to_vec(), quantiles),
885    };
886    let s = &first.solver;
887    let model = format!(
888        "lindhard {} electron::elastic::table: Mott DCS from radial-Dirac partial waves, \
889         independent-atom additivity; potential: {}",
890        env!("CARGO_PKG_VERSION"),
891        first.potential
892    );
893    let provenance = format!(
894        "computed by lindhard {} from published formulas (see electron::elastic docs); \
895         potential: {}; solver: matching_threshold={} phase_tolerance={} consecutive={} \
896         step_scale={} max_l={} light_speed={}; {} energies {}..{} eV; {} probability points",
897        env!("CARGO_PKG_VERSION"),
898        first.potential,
899        s.matching_threshold,
900        s.phase_tolerance,
901        s.consecutive,
902        s.step_scale,
903        s.max_l,
904        s.light_speed,
905        energy.len(),
906        energy[0],
907        energy[energy.len() - 1],
908        probability.len()
909    );
910    Ok(CrossSectionTable::new(CrossSectionTableParts {
911        model,
912        material: material_identity(material),
913        provenance,
914        axis: SamplingAxis::ElasticPolarAngle,
915        energy_ev: energy,
916        inverse_mfp_per_m,
917        probability,
918        quantiles,
919    })?)
920}
921
922fn check_probability(p: &[f64]) -> Result<(), ElasticTableError> {
923    let ok = p.len() >= 2
924        && p[0] == 0.0
925        && p[p.len() - 1] == 1.0
926        && p.iter().all(|x| x.is_finite())
927        && p.windows(2).all(|w| w[1] > w[0]);
928    if ok {
929        Ok(())
930    } else {
931        Err(invalid(
932            "probability grid",
933            "need >= 2 finite, strictly increasing points from exactly 0 to exactly 1",
934        ))
935    }
936}
937
938/// `ln(u/(1-u))`, with the small side of `u` and `1-u` at full precision.
939pub(crate) fn logit(u: f64) -> f64 {
940    if u <= 0.5 {
941        (u / (1.0 - u)).ln()
942    } else {
943        -((1.0 - u) / u).ln()
944    }
945}
946
947/// Inverse of [`logit`].
948pub(crate) fn logistic(t: f64) -> f64 {
949    if t <= 0.0 {
950        let e = t.exp();
951        e / (1.0 + e)
952    } else {
953        1.0 - 1.0 / (1.0 + t.exp())
954    }
955}
956
957/// Probability segments below this width are judged by their absolute
958/// contribution rather than by the pointwise error (see [`refine`]).
959const REFINE_SMALL_SEGMENT: f64 = 1.0e-4;
960/// Upper bound on the refined probability grid.
961pub const MAX_PROBABILITY_POINTS: usize = 20_000;
962
963/// Adaptive refinement of the shared probability grid.
964///
965/// Each interior segment `[u_s, u_{s+1}]` (the two end segments, which touch
966/// `u = 0` and `u = 1`, are left alone) is tested at its midpoint in the
967/// logistic variable: the exact quantile `θ_mid` is solved for at every
968/// energy and compared with the linear interpolation the table will use.
969/// With `δ = |cos θ_lin - cos θ_mid|` (the error of `1 - cos θ`) and `μ1` the
970/// exact `⟨1 - cos θ⟩` of the row, the segment is split when
971/// `δ Δu > tol μ1 max(Δu, 1e-4)` at any energy. Large segments are thus held
972/// to a pointwise error `tol μ1`; tiny tail segments to an absolute
973/// contribution `1e-4 tol μ1` each. The split inserts the already solved
974/// `θ_mid` and only the two halves are re-tested. Refinement stops when no
975/// segment is split, or at [`MAX_PROBABILITY_POINTS`]. The midpoint estimate
976/// is a heuristic: the achieved recovery of `σ_tr1` is what the tests check.
977fn refine(
978    mixes: &[MixtureRow],
979    mut grid: Vec<f64>,
980    mut q: Vec<Vec<f64>>,
981    tol: f64,
982) -> (Vec<f64>, Vec<Vec<f64>>) {
983    let mu1: Vec<f64> = mixes.iter().map(MixtureRow::mean_one_minus_cos).collect();
984    // segments to test, by left index
985    let mut check: Vec<usize> = (1..grid.len().saturating_sub(2)).collect();
986    while !check.is_empty() && grid.len() < MAX_PROBABILITY_POINTS {
987        let mids: Vec<f64> = check
988            .iter()
989            .map(|&s| logistic(0.5 * (logit(grid[s]) + logit(grid[s + 1]))))
990            .collect();
991        let per_row: Vec<(Vec<f64>, Vec<bool>)> = mixes
992            .par_iter()
993            .zip(q.par_iter())
994            .zip(mu1.par_iter())
995            .map(|((m, row), &mu)| {
996                let mut th = Vec::with_capacity(check.len());
997                let mut flag = Vec::with_capacity(check.len());
998                for (&s, &um) in check.iter().zip(&mids) {
999                    let (ua, ub) = (grid[s], grid[s + 1]);
1000                    let (qa, qb) = (row[s], row[s + 1]);
1001                    let t = m.solve(um, qa, qb).clamp(qa, qb);
1002                    let lin = qa + (um - ua) / (ub - ua) * (qb - qa);
1003                    let du = ub - ua;
1004                    let delta = (lin.cos() - t.cos()).abs();
1005                    th.push(t);
1006                    flag.push(delta * du > tol * mu * du.max(REFINE_SMALL_SEGMENT));
1007                }
1008                (th, flag)
1009            })
1010            .collect();
1011        let split: Vec<bool> = (0..check.len())
1012            .map(|k| per_row.iter().any(|r| r.1[k]))
1013            .collect();
1014        if !split.iter().any(|b| *b) {
1015            break;
1016        }
1017        // which check entry (if any) splits old segment s
1018        let mut at: Vec<Option<usize>> = vec![None; grid.len()];
1019        for (k, &s) in check.iter().enumerate() {
1020            if split[k] && mids[k] > grid[s] && mids[k] < grid[s + 1] {
1021                at[s] = Some(k);
1022            }
1023        }
1024        let mut new_grid = Vec::with_capacity(grid.len() + check.len());
1025        let mut new_q: Vec<Vec<f64>> = q
1026            .iter()
1027            .map(|_| Vec::with_capacity(grid.len() + check.len()))
1028            .collect();
1029        let mut new_check = Vec::new();
1030        for s in 0..grid.len() {
1031            new_grid.push(grid[s]);
1032            for (nr, r) in new_q.iter_mut().zip(&q) {
1033                nr.push(r[s]);
1034            }
1035            if let Some(k) = at[s] {
1036                new_check.push(new_grid.len() - 1);
1037                new_grid.push(mids[k]);
1038                for (nr, pr) in new_q.iter_mut().zip(&per_row) {
1039                    nr.push(pr.0[k]);
1040                }
1041                new_check.push(new_grid.len() - 1);
1042            }
1043        }
1044        grid = new_grid;
1045        q = new_q;
1046        check = new_check;
1047    }
1048    (grid, q)
1049}
1050
1051/// Build the elastic table of `material` with potentials from `source`.
1052/// Components with a zero atom fraction are skipped (their potential is not
1053/// requested).
1054pub fn build_elastic_table(
1055    material: &Material,
1056    source: &dyn PotentialSource,
1057    opts: &ElasticTableOptions,
1058) -> Result<CrossSectionTable, ElasticTableError> {
1059    check_grid(&opts.energy_ev)?;
1060    let description = source.description();
1061    let mut atoms = Vec::new();
1062    for c in material.components() {
1063        if c.atom_fraction() <= 0.0 {
1064            continue;
1065        }
1066        let z = c.z();
1067        let pot = source
1068            .potential(z)
1069            .map_err(|source| ElasticTableError::Potential { z, source })?;
1070        atoms.push(AtomicElastic::compute(
1071            z,
1072            pot.as_ref(),
1073            &description,
1074            &opts.energy_ev,
1075            opts.solver,
1076        )?);
1077    }
1078    combine(material, &atoms, &opts.probability, opts.refine_tolerance)
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084
1085    #[test]
1086    fn legendre_form_reproduces_the_partial_wave_dcs() {
1087        let pot = Yukawa::new(29.0, 0.8853 / 29f64.cbrt()).unwrap();
1088        for e in [50.0, 2000.0] {
1089            let pw = ElasticSolver::new(&pot, e, SolverOptions::default())
1090                .unwrap()
1091                .partial_waves()
1092                .unwrap();
1093            let row = AtomicElasticRow::from_partial_waves(e, &pw);
1094            for t in [0.0, 0.01, 0.3, 1.0, 2.0, 3.0, PI] {
1095                let (a, b) = (row.dcs(t), pw.dcs(t));
1096                assert!((a - b).abs() <= 1e-10 * pw.dcs(0.0), "{e} {t}: {a} {b}");
1097            }
1098            let s = row.cumulative_total();
1099            assert!(
1100                (s - pw.sigma_el()).abs() < 1e-10 * s,
1101                "{s} {}",
1102                pw.sigma_el()
1103            );
1104            assert!((row.cumulative(PI) - s).abs() < 1e-12 * s);
1105            assert_eq!(row.cumulative(0.0), 0.0);
1106        }
1107    }
1108
1109    #[test]
1110    fn closed_form_cumulative_matches_direct_quadrature() {
1111        let pot = Yukawa::new(14.0, 0.8853 / 14f64.cbrt()).unwrap();
1112        let pw = ElasticSolver::new(&pot, 300.0, SolverOptions::default())
1113            .unwrap()
1114            .partial_waves()
1115            .unwrap();
1116        let row = AtomicElasticRow::from_partial_waves(300.0, &pw);
1117        for theta in [0.05, 0.4, 1.3, 2.9] {
1118            // composite Simpson of 2 pi DCS sin over [0, theta]
1119            let n = 4000;
1120            let h = theta / n as f64;
1121            let f = |t: f64| 2.0 * PI * pw.dcs(t) * t.sin();
1122            let mut s = f(0.0) + f(theta);
1123            for k in 1..n {
1124                s += if k % 2 == 1 { 4.0 } else { 2.0 } * f(k as f64 * h);
1125            }
1126            let quad = s * h / 3.0;
1127            let exact = row.cumulative(theta);
1128            assert!(
1129                (exact - quad).abs() < 1e-9 * row.cumulative_total(),
1130                "{theta}: {exact} {quad}"
1131            );
1132        }
1133    }
1134
1135    #[test]
1136    fn one_minus_cos_is_exact_for_linear_rows() {
1137        // theta(u) = pi u: <1 - cos> = 1 - (sin pi)/pi = 1
1138        let p: Vec<f64> = (0..=4).map(|i| i as f64 / 4.0).collect();
1139        let q: Vec<f64> = p.iter().map(|u| PI * u).collect();
1140        assert!((mean_one_minus_cos(&p, &q) - 1.0).abs() < 1e-15);
1141        // constant theta = 0.5
1142        let q = vec![0.5; 5];
1143        assert!((mean_one_minus_cos(&p, &q) - (1.0 - 0.5f64.cos())).abs() < 1e-15);
1144    }
1145
1146    #[test]
1147    fn probability_grid_shape() {
1148        let g = default_probability_grid();
1149        assert_eq!(g[0], 0.0);
1150        assert_eq!(*g.last().unwrap(), 1.0);
1151        assert!(g.windows(2).all(|w| w[1] > w[0]));
1152        assert!((g[1] - DEFAULT_PROBABILITY_TAIL).abs() < 1e-20);
1153        assert!((1.0 - g[g.len() - 2] - DEFAULT_PROBABILITY_TAIL).abs() < 1e-15);
1154        assert!(logistic_probability_grid(0.0, 0.1).is_err());
1155        assert!(logistic_probability_grid(1e-6, 0.0).is_err());
1156    }
1157}