Skip to main content

lindhard/electron/
elastic.rs

1//! Elastic scattering of electrons by a static central potential: the radial
2//! Dirac equation, partial-wave phase shifts and the Mott cross sections.
3//!
4//! This is step 1 of the Mott elastic model (issue #17): phase shifts, the
5//! differential cross section `DCS(theta)`, the total elastic cross section
6//! `sigma_el`, the first transport cross section `sigma_tr1` and the Sherman
7//! function, all at **one** kinetic energy and for a potential given as a
8//! function. Optional Furness-McCarthy exchange and correlation-polarization
9//! corrections (issue #91), off by default, are in [`corrections`]
10//! ([`solve_corrected`]). Energy-grid tables for a material are in [`table`]
11//! (issue #90); condensed phases are a follow-up issue.
12//!
13//! # Units
14//!
15//! Hartree atomic units inside this module: lengths in bohr, energies in
16//! hartree, the electron mass is 1 and the speed of light is `c = 1/alpha`
17//! (`alpha` from [`crate::constants::FINE_STRUCTURE`]). Cross sections come out
18//! in bohr^2 (`DCS` in bohr^2/sr); [`BOHR2_TO_M2`] converts. The public energy
19//! argument is the electron **kinetic** energy in eV.
20//!
21//! # Method
22//!
23//! With `P(r) = r g(r)` and `Q(r) = r f(r)` the large and small radial
24//! components, the radial Dirac equations for a central potential energy
25//! `V(r)` and Dirac quantum number `kappa` are (kinetic energy `E`, `c = 1/alpha`)
26//!
27//! ```text
28//! dP/dr = -kappa/r P + (E - V + 2c^2)/c Q
29//! dQ/dr = -(E - V)/c P + kappa/r Q
30//! ```
31//!
32//! (the form used in the ELSEPA paper, Salvat, Jablonski & Powell, Comput.
33//! Phys. Commun. 165, 157 (2005); here re-derived from the Dirac equation and
34//! checked against the closed-form free solution below, not copied from any
35//! code). `kappa = -(l+1)` is the spin-up channel `j = l + 1/2` and `kappa = l`
36//! the spin-down channel `j = l - 1/2` (`l >= 1`).
37//!
38//! * **Free solutions.** For `V = 0` the regular and irregular solutions are
39//!   `r (u_l(kr), sigma s u_lbar(kr))`, with `u` a spherical Bessel `j` or
40//!   `y`, `k = sqrt(E (E + 2c^2))/c`, `s = c k/(E + 2c^2)`, `sigma = -1` and
41//!   `lbar = l + 1` for `kappa < 0`, `sigma = +1` and `lbar = l - 1` for
42//!   `kappa > 0`. (Verified by substitution in the equations above and by the
43//!   tests.)
44//! * **Integration.** The regular solution is integrated outward from a small
45//!   radius `r_s`. Inside the centrifugal barrier the irregular solution
46//!   decays outward, so an imprecise starting ratio `Q/P` is harmless as long
47//!   as `r_s` is well below the classical turning point: `r_s` is placed by a
48//!   WKB criterion on the bisected turning point, so that the irregular
49//!   admixture is suppressed by `exp(-60)` there (see `wkb_start`), but
50//!   never below a floor of `1e-10/(1+Z)` bohr, where the leading-order
51//!   point-Coulomb or regular power-series start is accurate. (Until #131 the
52//!   static potentials used `r_s = r_t exp(-60/|kappa|)`, which assumes
53//!   power-law growth; measured against the WKB start for Au, Salvat DHFS
54//!   screening, its phase-shift error was below `2e-12` rad at 10 and 20 keV,
55//!   `6.3e-10` rad at 30 keV, `1.6e-7` rad at 50 keV and `2.5e-4` rad at
56//!   100 keV (`|kappa|` 779, `sigma_tr1` off by `2.4e-5` relative).) The
57//!   integrator
58//!   is the Gragg-Bulirsch-Stoer modified-midpoint method with polynomial
59//!   extrapolation in `h^2` (Stoer & Bulirsch, *Introduction to Numerical
60//!   Analysis*), substep sequence 2, 4, 6, 8 (order 8), with a step set from
61//!   the local eigenvalue of the system,
62//!   `h = step_scale / (sqrt|q| + (1 + (2 kappa^2)^(1/3))/r)`
63//!   (the cube-root term resolves the Airy region at a turning point), capped
64//!   at a quarter of `step_scale` times the potential's length scale,
65//!   `q = (E-V)(E-V+2c^2)/c^2 - kappa^2/r^2`. The amplitude is rescaled when
66//!   it grows past `1e100`; only the ratio `Q/P` matters.
67//! * **Matching.** At the matching radius `R`, where `|r V(r)|` has fallen
68//!   below [`SolverOptions::matching_threshold`] (a potential that has
69//!   a finite range ends there exactly), `tan(delta) = (Q J_p - P J_q) /
70//!   (Q Y_p - P Y_q)` with `J`, `Y` the regular and irregular free solutions.
71//!   The potentials here are screened, so no Coulomb tail is matched. The
72//!   neglected tail changes `delta` by roughly the threshold divided by `k`.
73//! * **Partial-wave sum.** `f(theta)` and `g(theta)` are the Mott direct and
74//!   spin-flip amplitudes, `f = (1/2ik) sum_l [(l+1)(e^{2 i d_-} - 1) +
75//!   l (e^{2 i d_+} - 1)] P_l(cos theta)`, `g = (1/2ik) sum_l
76//!   [e^{2 i d_-} - e^{2 i d_+}] P_l^1(cos theta)` with `P_l^1 = sin theta
77//!   dP_l/d cos theta`, and `DCS = |f|^2 + |g|^2`. These are the standard
78//!   textbook (Mott 1929; Motz, Olsen & Koch 1964) expressions written from
79//!   memory of the form, **not** checked digit for digit against a paper in
80//!   this work; the tests that pin them are the optical theorem, the exact
81//!   `1 - beta^2 sin^2(theta/2)` first-Born limit (which fixes the relative
82//!   size of `g`) and the square-well closed form. The Sherman function is
83//!   `S = i (f g* - f* g) / (|f|^2 + |g|^2)`; its **sign convention is not
84//!   verified** against a published definition.
85//! * **Cutoff in `l`.** Phase shifts are computed for `l = 0, 1, 2, ...` and
86//!   the series is cut after `consecutive` (5) successive `l` for which both
87//!   `|delta_-|` and `|delta_+|` are below `phase_tolerance` (1e-8 rad);
88//!   [`PartialWaves::l_max`] is the highest `l` computed. When `x = kR` is
89//!   below `l` by enough that the free Bessel functions leave the range of
90//!   `f64`, the wave never reaches the potential and its shift is set to
91//!   zero. At high energy `l_max` grows into the thousands; this module
92//!   **integrates every partial wave directly** (cost linear in `l_max` times
93//!   `k R`). A WKB / asymptotic phase for large `l`, as in the literature on
94//!   which the issue draws (Salvat et al. 2005), is **not implemented**: that
95//!   paper was not opened for this work (closed access) and the method is
96//!   not reproduced from memory. See follow-up work.
97//! * **Cross sections.** `sigma_el = (4 pi/k^2) sum_l [(l+1) sin^2 d_- +
98//!   l sin^2 d_+]` (equal to `(4 pi/k) Im f(0)`, the optical theorem). The
99//!   `DCS` times `(1 - cos theta)` is a polynomial of degree at most
100//!   `2 l_max + 1` in `cos theta`, so `sigma_tr1` is integrated *exactly*
101//!   with an `(l_max + 2)`-point Gauss-Legendre rule.
102//!
103//! # Potentials
104//!
105//! [`Yukawa`] and [`SquareWell`] are analytically tractable. [`SalvatDhfs`] is
106//! the analytic Dirac-Hartree-Fock-Slater screening function of Salvat,
107//! Martinez, Mayol & Parellada, Phys. Rev. A 36, 467 (1987),
108//! doi:10.1103/PhysRevA.36.467, with its Table I coefficients for Z = 1..92
109//! (transcribed from the open repository copy of the paper, #130), see the
110//! type and `docs/data-provenance.md`.
111//!
112//! # Determinism
113//!
114//! Each phase shift is an independent, deterministic computation. Blocks of
115//! partial waves are evaluated in parallel with rayon and collected in order,
116//! so the result is bit-identical on any thread count (tested).
117
118use crate::constants::{BOHR_RADIUS, ELEMENTARY_CHARGE, FINE_STRUCTURE, HARTREE_ENERGY};
119use rayon::prelude::*;
120
121pub mod corrections;
122pub use corrections::{
123    solve_corrected, CorrectedPotential, CorrectionMetadata, Corrections, CorrelationPolarization,
124    ElectronDensity, PolarizationCutoff,
125};
126
127/// Square metres per bohr squared (`a0^2`, [`BOHR_RADIUS`] squared).
128pub const BOHR2_TO_M2: f64 = BOHR_RADIUS * BOHR_RADIUS;
129
130/// Hartree energy in eV (CODATA 2022 `E_h` over `e`).
131pub const HARTREE_EV: f64 = HARTREE_ENERGY / ELEMENTARY_CHARGE;
132
133/// Errors from the elastic solver.
134#[derive(Debug, Clone, PartialEq, thiserror::Error)]
135pub enum ElasticError {
136    /// An input violates an invariant.
137    #[error("invalid {what}: {reason}")]
138    Invalid {
139        /// Which quantity.
140        what: &'static str,
141        /// What is wrong with it.
142        reason: String,
143    },
144    /// No screening coefficients for this atomic number: Table I of Salvat
145    /// et al. (1987) covers Z = 1..92 only.
146    #[error(
147        "Salvat et al. (1987) DHFS screening coefficients for Z={0} are not available: \
148         Table I covers Z = 1..92"
149    )]
150    ScreeningCoefficientsUnavailable(u32),
151    /// The integration produced a non-finite value.
152    #[error("numerical failure integrating the radial equations for kappa = {0}")]
153    NumericalFailure(i32),
154    /// The adaptive partial-wave sum did not converge by `max_l`.
155    #[error("partial-wave series not converged by l = {0}")]
156    NotConverged(usize),
157}
158
159fn invalid(what: &'static str, reason: impl Into<String>) -> ElasticError {
160    ElasticError::Invalid {
161        what,
162        reason: reason.into(),
163    }
164}
165
166/// A static, spherically symmetric potential energy of the electron.
167pub trait ScreenedPotential: Sync {
168    /// Potential energy `V(r)` of the electron, hartree, at radius `r` bohr.
169    /// Attractive potentials are negative.
170    fn energy(&self, r: f64) -> f64;
171
172    /// Coefficient `Z` of the point-Coulomb singularity `V -> -Z/r` at the
173    /// origin (0 for a regular potential). Selects the series used to start
174    /// the integration.
175    fn nuclear_charge(&self) -> f64 {
176        0.0
177    }
178
179    /// Radii at which `V` is discontinuous; the integrator restarts there.
180    fn breakpoints(&self) -> Vec<f64> {
181        Vec::new()
182    }
183
184    /// A length over which `V` changes appreciably, bohr. It caps the step.
185    fn length_scale(&self) -> f64;
186
187    /// The kinetic energy (eV) this potential was built for, when it depends
188    /// on one (such as [`CorrectedPotential`], whose exchange and polarization
189    /// cutoff use the energy). [`ElasticSolver::new`] rejects any other solver
190    /// energy. The default, `None`, accepts every energy.
191    fn bound_energy_ev(&self) -> Option<f64> {
192        None
193    }
194
195    /// Which corrections the potential includes; copied into
196    /// [`ElasticResult::corrections`] by [`solve`]. The default reports both
197    /// off.
198    fn correction_metadata(&self) -> CorrectionMetadata {
199        CorrectionMetadata::default()
200    }
201
202    /// Radius beyond which the potential is neglected: the smallest radius on
203    /// a geometric grid (ratio 1.05, from `length_scale`) with
204    /// `|r V(r)| < threshold` that also lies beyond every breakpoint.
205    fn matching_radius(&self, threshold: f64) -> f64 {
206        let mut r = self.length_scale();
207        let floor = self.breakpoints().into_iter().fold(0.0_f64, f64::max);
208        while (r < floor || (r * self.energy(r)).abs() >= threshold) && r < 1.0e7 {
209            r *= 1.05;
210        }
211        r
212    }
213}
214
215/// Exponentially screened Coulomb potential `V(r) = -Z exp(-r/a)/r`
216/// (hartree, bohr). Its first-Born cross sections are analytic.
217#[derive(Debug, Clone, Copy, PartialEq)]
218pub struct Yukawa {
219    z: f64,
220    screening_length: f64,
221}
222
223impl Yukawa {
224    /// `z` is the (positive for attraction) charge, `screening_length` the
225    /// screening length `a` in bohr.
226    pub fn new(z: f64, screening_length: f64) -> Result<Self, ElasticError> {
227        if !z.is_finite() || z < 0.0 {
228            return Err(invalid("Yukawa charge", format!("{z} (need finite, >= 0)")));
229        }
230        if !screening_length.is_finite() || screening_length <= 0.0 {
231            return Err(invalid(
232                "Yukawa screening length",
233                format!("{screening_length} (need finite, > 0)"),
234            ));
235        }
236        Ok(Self {
237            z,
238            screening_length,
239        })
240    }
241
242    /// Charge `Z`.
243    pub fn z(&self) -> f64 {
244        self.z
245    }
246
247    /// Screening length, bohr.
248    pub fn screening_length(&self) -> f64 {
249        self.screening_length
250    }
251}
252
253impl ScreenedPotential for Yukawa {
254    fn energy(&self, r: f64) -> f64 {
255        -self.z * (-r / self.screening_length).exp() / r
256    }
257    fn nuclear_charge(&self) -> f64 {
258        self.z
259    }
260    fn length_scale(&self) -> f64 {
261        self.screening_length
262    }
263}
264
265/// Spherical square well `V = -depth` for `r < radius`, 0 beyond (hartree,
266/// bohr). A negative `depth` is a barrier.
267#[derive(Debug, Clone, Copy, PartialEq)]
268pub struct SquareWell {
269    depth: f64,
270    radius: f64,
271}
272
273impl SquareWell {
274    /// Build a well; `radius` must be positive and `depth` finite.
275    pub fn new(depth: f64, radius: f64) -> Result<Self, ElasticError> {
276        if !depth.is_finite() {
277            return Err(invalid("square-well depth", format!("{depth}")));
278        }
279        if !radius.is_finite() || radius <= 0.0 {
280            return Err(invalid(
281                "square-well radius",
282                format!("{radius} (need finite, > 0)"),
283            ));
284        }
285        Ok(Self { depth, radius })
286    }
287
288    /// Depth `V0` (the potential energy inside is `-V0`), hartree.
289    pub fn depth(&self) -> f64 {
290        self.depth
291    }
292
293    /// Well radius, bohr.
294    pub fn radius(&self) -> f64 {
295        self.radius
296    }
297}
298
299impl ScreenedPotential for SquareWell {
300    fn energy(&self, r: f64) -> f64 {
301        if r < self.radius {
302            -self.depth
303        } else {
304            0.0
305        }
306    }
307    fn breakpoints(&self) -> Vec<f64> {
308        vec![self.radius]
309    }
310    fn length_scale(&self) -> f64 {
311        self.radius
312    }
313    fn matching_radius(&self, _threshold: f64) -> f64 {
314        self.radius
315    }
316}
317
318/// The three-term analytic screening function of Salvat, Martinez, Mayol &
319/// Parellada, Phys. Rev. A 36, 467 (1987), doi:10.1103/PhysRevA.36.467:
320/// `V(r) = -(Z/r) sum_i A_i exp(-alpha_i r)` with `sum_i A_i = 1`
321/// (neutral atom), `r` in bohr.
322///
323/// The form is the paper's Eq. (11) (screening function) inserted in its
324/// Eq. (1); its Eq. (12) gives the electron density used by
325/// [`ElectronDensity`]. Both were checked (issue #91) in the open copy of the
326/// paper in the University of Barcelona repository (diposit.ub.edu; the
327/// publisher's copy is closed). [`SalvatDhfs::for_element`] returns the
328/// paper's Table I coefficients for Z = 1..92 (pp. 470-471, transcribed in
329/// #130, see `salvat_table.rs` and `docs/data-provenance.md`);
330/// [`SalvatDhfs::from_coefficients`] takes caller-supplied ones.
331///
332/// Rows marked with an asterisk in Table I (H..P, Ca, Sc, Se, Br, Kr, Xe)
333/// have `A_3 = 0` and no `alpha_3` (p. 471); they are held as **two-term**
334/// potentials, so no `alpha_3` value enters [`ScreenedPotential::energy`],
335/// [`ElectronDensity::density`] or [`ScreenedPotential::length_scale`].
336///
337/// Some amplitudes are negative (H, He: `A_1`; S, Cl, Ar: `A_2`). For H
338/// (`A_1 = -184.39`, `A_2 = 185.39`, nearly equal `alpha`s) the two terms
339/// cancel to about 1 part in 370 near the nucleus, so `phi(r)` there keeps
340/// roughly 13 significant digits in `f64` rather than 16.
341#[derive(Debug, Clone, Copy, PartialEq)]
342pub struct SalvatDhfs {
343    z: f64,
344    a: [f64; 3],
345    alpha: [f64; 3],
346    /// Number of terms in use (2 or 3); entries past it are unused.
347    terms: usize,
348}
349
350impl SalvatDhfs {
351    /// Build from caller-supplied coefficients (`a` dimensionless summing to
352    /// 1 within 1e-6, `alpha` in 1/bohr and positive).
353    pub fn from_coefficients(z: u32, a: [f64; 3], alpha: [f64; 3]) -> Result<Self, ElasticError> {
354        if !(1..=92).contains(&z) {
355            return Err(invalid("atomic number", format!("{z} (need 1..=92)")));
356        }
357        if a.iter().chain(alpha.iter()).any(|v| !v.is_finite()) {
358            return Err(invalid("screening coefficients", "not finite"));
359        }
360        if (a.iter().sum::<f64>() - 1.0).abs() > 1e-6 {
361            return Err(invalid(
362                "screening coefficients",
363                "the amplitudes A_i must sum to 1 (neutral atom)",
364            ));
365        }
366        if alpha.iter().any(|&v| v <= 0.0) {
367            return Err(invalid("screening coefficients", "alpha_i must be > 0"));
368        }
369        Ok(Self {
370            z: f64::from(z),
371            a,
372            alpha,
373            terms: 3,
374        })
375    }
376
377    /// Build a two-term potential (`A_3 = 0`, the form of the asterisked rows
378    /// of Salvat et al. (1987) Table I): `a` sums to 1 within 1e-6, `alpha`
379    /// in 1/bohr and positive.
380    pub fn from_two_terms(z: u32, a: [f64; 2], alpha: [f64; 2]) -> Result<Self, ElasticError> {
381        // Validate through the three-term path with a zero third term; the
382        // third alpha only satisfies the check and is never used.
383        let mut p = Self::from_coefficients(z, [a[0], a[1], 0.0], [alpha[0], alpha[1], 1.0])?;
384        p.alpha[2] = 0.0;
385        p.terms = 2;
386        Ok(p)
387    }
388
389    /// The Salvat et al. (1987) Table I potential of element `z`
390    /// (pp. 470-471, `A_3 = 1 - A_1 - A_2`; two-term for the asterisked
391    /// rows). [`ElasticError::ScreeningCoefficientsUnavailable`] outside
392    /// Z = 1..92.
393    pub fn for_element(z: u32) -> Result<Self, ElasticError> {
394        let row = z
395            .checked_sub(1)
396            .and_then(|i| salvat_table::TABLE_I.get(i as usize))
397            .ok_or(ElasticError::ScreeningCoefficientsUnavailable(z))?;
398        debug_assert_eq!(row.z, z);
399        match row.alpha3 {
400            Some(alpha3) => Self::from_coefficients(
401                z,
402                [row.a1, row.a2, 1.0 - row.a1 - row.a2],
403                [row.alpha1, row.alpha2, alpha3],
404            ),
405            None => Self::from_two_terms(z, [row.a1, row.a2], [row.alpha1, row.alpha2]),
406        }
407    }
408
409    /// Amplitudes `A_i` of the terms in use (2 or 3).
410    pub fn amplitudes(&self) -> &[f64] {
411        &self.a[..self.terms]
412    }
413
414    /// Screening constants `alpha_i` (1/bohr) of the terms in use (2 or 3).
415    pub fn alphas(&self) -> &[f64] {
416        &self.alpha[..self.terms]
417    }
418}
419
420impl ScreenedPotential for SalvatDhfs {
421    fn energy(&self, r: f64) -> f64 {
422        let phi: f64 = self
423            .amplitudes()
424            .iter()
425            .zip(self.alphas())
426            .map(|(a, al)| a * (-al * r).exp())
427            .sum();
428        -self.z * phi / r
429    }
430    fn nuclear_charge(&self) -> f64 {
431        self.z
432    }
433    fn length_scale(&self) -> f64 {
434        1.0 / self.alphas().iter().copied().fold(0.0, f64::max)
435    }
436}
437
438/// Numerical settings of the solver.
439#[derive(Debug, Clone, Copy, PartialEq)]
440pub struct SolverOptions {
441    /// Matching radius criterion: `|r V(r)| < threshold` (hartree bohr).
442    pub matching_threshold: f64,
443    /// A partial wave is "small" when `|delta| < phase_tolerance` (rad).
444    pub phase_tolerance: f64,
445    /// Number of consecutive small partial waves that ends the series.
446    pub consecutive: usize,
447    /// Radians of local phase per integrator step (smaller is more accurate).
448    pub step_scale: f64,
449    /// Safety cap on `l`.
450    pub max_l: usize,
451    /// Speed of light, atomic units. Defaults to `1/alpha`; the
452    /// non-relativistic limit is `c -> infinity`.
453    pub light_speed: f64,
454}
455
456impl Default for SolverOptions {
457    fn default() -> Self {
458        Self {
459            matching_threshold: 1.0e-10,
460            phase_tolerance: 1.0e-8,
461            consecutive: 5,
462            step_scale: 0.25,
463            max_l: 20_000,
464            light_speed: 1.0 / FINE_STRUCTURE,
465        }
466    }
467}
468
469// ---------------------------------------------------------------------------
470// Spherical Bessel functions
471
472/// Spherical Bessel `j_n(x)` and `y_n(x)` for `n = 0..=n_max`. `None` when a
473/// value leaves a safe range of `f64` (wave deep in the forbidden region).
474fn spherical_bessel(n_max: usize, x: f64) -> Option<(Vec<f64>, Vec<f64>)> {
475    if x.is_nan() || x <= 0.0 || !x.is_finite() {
476        return None;
477    }
478    let (s, c) = x.sin_cos();
479    let mut y = vec![0.0; n_max + 2];
480    y[0] = -c / x;
481    y[1] = -c / (x * x) - s / x;
482    for n in 1..=n_max {
483        y[n + 1] = (2 * n + 1) as f64 / x * y[n] - y[n - 1];
484        if !y[n + 1].is_finite() || y[n + 1].abs() > 1.0e280 {
485            return None;
486        }
487    }
488    y.truncate(n_max + 1);
489
490    // j_0, j_1 anchors (series for small x, where the closed forms cancel).
491    let (j0, j1) = if x < 0.1 {
492        let x2 = x * x;
493        (
494            1.0 - x2 / 6.0 * (1.0 - x2 / 20.0 * (1.0 - x2 / 42.0 * (1.0 - x2 / 72.0))),
495            x / 3.0 * (1.0 - x2 / 10.0 * (1.0 - x2 / 28.0 * (1.0 - x2 / 54.0))),
496        )
497    } else {
498        (s / x, s / (x * x) - c / x)
499    };
500
501    // Miller downward recurrence from far above both n_max and x.
502    let start = n_max + 30 + (2.0 * x) as usize + (40.0 * (n_max as f64)).sqrt() as usize;
503    let mut j = vec![0.0; start + 2];
504    j[start] = 1.0e-30;
505    for n in (1..=start).rev() {
506        j[n - 1] = (2 * n + 1) as f64 / x * j[n] - j[n + 1];
507        if j[n - 1].abs() > 1.0e250 {
508            for v in &mut j[(n - 1)..] {
509                *v *= 1.0e-250;
510            }
511        }
512    }
513    let norm = if j0.abs() >= j1.abs() {
514        j0 / j[0]
515    } else {
516        j1 / j[1]
517    };
518    let jn: Vec<f64> = j[..=n_max].iter().map(|v| v * norm).collect();
519    if jn.iter().any(|v| !v.is_finite()) {
520        return None;
521    }
522    Some((jn, y))
523}
524
525// ---------------------------------------------------------------------------
526// Gauss-Legendre nodes
527
528/// Gauss-Legendre nodes and weights on `[-1, 1]` (Newton iteration on the
529/// three-term recurrence), nodes ascending.
530fn gauss_legendre(n: usize) -> (Vec<f64>, Vec<f64>) {
531    let mut x = vec![0.0; n];
532    let mut w = vec![0.0; n];
533    let m = n.div_ceil(2);
534    for i in 0..m {
535        let mut z = (std::f64::consts::PI * (i as f64 + 0.75) / (n as f64 + 0.5)).cos();
536        let mut dp = 1.0;
537        for _ in 0..100 {
538            let (mut p1, mut p2) = (1.0, 0.0);
539            for l in 0..n {
540                let p3 = p2;
541                p2 = p1;
542                p1 = ((2 * l + 1) as f64 * z * p2 - l as f64 * p3) / (l + 1) as f64;
543            }
544            dp = n as f64 * (z * p1 - p2) / (z * z - 1.0);
545            let dz = p1 / dp;
546            z -= dz;
547            if dz.abs() < 1.0e-15 {
548                break;
549            }
550        }
551        x[i] = -z;
552        x[n - 1 - i] = z;
553        let wi = 2.0 / ((1.0 - z * z) * dp * dp);
554        w[i] = wi;
555        w[n - 1 - i] = wi;
556    }
557    (x, w)
558}
559
560// ---------------------------------------------------------------------------
561// Radial integration
562
563type Pair = [f64; 2];
564
565/// One Gragg-Bulirsch-Stoer step of size `h` for `y' = f(r, y)`.
566fn bs_step(f: &impl Fn(f64, Pair) -> Pair, r: f64, y: Pair, h: f64) -> Pair {
567    const SEQ: [usize; 4] = [2, 4, 6, 8];
568    let f0 = f(r, y);
569    let mut tab = [[[0.0_f64; 2]; 4]; 4];
570    for (i, &n) in SEQ.iter().enumerate() {
571        let hs = h / n as f64;
572        let mut zp = y;
573        let mut zc = [y[0] + hs * f0[0], y[1] + hs * f0[1]];
574        for m in 1..n {
575            let fz = f(r + m as f64 * hs, zc);
576            let zn = [zp[0] + 2.0 * hs * fz[0], zp[1] + 2.0 * hs * fz[1]];
577            zp = zc;
578            zc = zn;
579        }
580        let fz = f(r + h, zc);
581        tab[i][0] = [
582            0.5 * (zc[0] + zp[0] + hs * fz[0]),
583            0.5 * (zc[1] + zp[1] + hs * fz[1]),
584        ];
585        for j in 1..=i {
586            let ratio = (SEQ[i] as f64 / SEQ[i - j] as f64).powi(2);
587            let (cur, prev) = (tab[i][j - 1], tab[i - 1][j - 1]);
588            tab[i][j] = [
589                cur[0] + (cur[0] - prev[0]) / (ratio - 1.0),
590                cur[1] + (cur[1] - prev[1]) / (ratio - 1.0),
591            ];
592        }
593    }
594    tab[3][3]
595}
596
597/// WKB amplitude exponent required between the start radius and the turning
598/// point: the irregular solution admixed by an imprecise start is suppressed
599/// by `exp(-2 W)`, here `exp(-60)`.
600const WKB_START_EXPONENT: f64 = 30.0;
601
602/// Start radius of the outward integration, for every potential (#91, #131).
603///
604/// In a classically forbidden region (`q < 0`) the WKB approximation gives the
605/// two independent solutions as `|q|^(-1/4) exp(+-\int sqrt(-q) dr)` (Bender &
606/// Orszag, *Advanced Mathematical Methods for Scientists and Engineers*,
607/// McGraw-Hill 1978, ch. 10), so the solution decaying outward (the irregular
608/// one, admixed by an imprecise starting ratio `Q/P`) loses `exp(-2 W)`
609/// relative to the regular one between the start and the turning point,
610/// `W = \int_{r_s}^{r_t} sqrt(-q) dr`.
611///
612/// The step-1 rule `r_t exp(-60/|kappa|)` assumed the power-law growth of a
613/// free wave deep inside the barrier; for `|kappa|` of several hundred it lands
614/// within a few per cent of the turning point, and the 5 % grid on which `r_t`
615/// is found can even put it past the turning point, so the start error is not
616/// suppressed (measured: phase errors up to 0.25 rad for `l ~ 1250` with a
617/// polarization tail, #91; `2.5e-4` rad at `|kappa| = 779` for static Au at
618/// 100 keV, #131). Here the turning point is refined by bisection and the start
619/// is moved inward until `W >= WKB_START_EXPONENT` (midpoint rule on a
620/// geometric grid of ratio 0.99), or to `r_floor`.
621fn wkb_start(q_of: &impl Fn(f64) -> f64, r_t: f64, r_floor: f64, r_match: f64) -> f64 {
622    // Refine the first sign change of q below the grid value r_t.
623    let mut hi = r_t;
624    let mut lo = (r_t / 1.05).max(r_floor);
625    if q_of(lo) < 0.0 && q_of(hi) > 0.0 {
626        for _ in 0..60 {
627            let mid = 0.5 * (lo + hi);
628            if q_of(mid) > 0.0 {
629                hi = mid;
630            } else {
631                lo = mid;
632            }
633        }
634    } else {
635        lo = r_t;
636    }
637    let mut r = lo;
638    let mut w = 0.0;
639    while w < WKB_START_EXPONENT && r > r_floor {
640        let next = (r * 0.99).max(r_floor);
641        let q = q_of(0.5 * (r + next));
642        if q < 0.0 {
643            w += (-q).sqrt() * (r - next);
644        }
645        r = next;
646    }
647    r.min(0.5 * r_match)
648}
649
650/// Where [`ElasticSolver::phase_shift`] starts the outward integration. Only
651/// [`StartRule::Wkb`] is used outside the tests; the others are kept so the
652/// tests can measure it against them (#131).
653#[derive(Debug, Clone, Copy, PartialEq, Eq)]
654enum StartRule {
655    /// `wkb_start` (every potential since #131).
656    Wkb,
657    /// The step-1 rule `r_t exp(-60/|kappa|)`, used by potentials without a
658    /// polarization tail before #131.
659    #[cfg_attr(not(test), allow(dead_code))]
660    Legacy,
661    /// The floor radius `1e-10/(1+Z)` bohr: the most expensive start and the
662    /// least sensitive to the starting ratio, a reference for the others.
663    #[cfg_attr(not(test), allow(dead_code))]
664    Floor,
665}
666
667/// Mott partial-wave data at one energy: phase shifts and the amplitudes built
668/// from them.
669#[derive(Debug, Clone, PartialEq)]
670pub struct PartialWaves {
671    k: f64,
672    /// `delta_-[l]` is the phase shift of `kappa = -(l+1)`.
673    delta_minus: Vec<f64>,
674    /// `delta_+[l]` is the phase shift of `kappa = l` (`delta_+[0] = 0`: no such channel).
675    delta_plus: Vec<f64>,
676}
677
678impl PartialWaves {
679    /// Build from phase shifts for `l = 0..=l_max`. `delta_plus[0]` is ignored
680    /// (set to 0). The two vectors must have equal, nonzero length.
681    pub fn from_phase_shifts(
682        k: f64,
683        delta_minus: Vec<f64>,
684        mut delta_plus: Vec<f64>,
685    ) -> Result<Self, ElasticError> {
686        if !k.is_finite() || k <= 0.0 {
687            return Err(invalid("wave number", format!("{k}")));
688        }
689        if delta_minus.is_empty() || delta_minus.len() != delta_plus.len() {
690            return Err(invalid("phase-shift arrays", "need equal, nonzero lengths"));
691        }
692        if delta_minus
693            .iter()
694            .chain(&delta_plus)
695            .any(|v| !v.is_finite())
696        {
697            return Err(invalid("phase shifts", "not finite"));
698        }
699        delta_plus[0] = 0.0;
700        Ok(Self {
701            k,
702            delta_minus,
703            delta_plus,
704        })
705    }
706
707    /// Wave number `k`, 1/bohr.
708    pub fn wave_number(&self) -> f64 {
709        self.k
710    }
711
712    /// Highest `l` for which phase shifts are held.
713    pub fn l_max(&self) -> usize {
714        self.delta_minus.len() - 1
715    }
716
717    /// Phase shift `delta_kappa` (rad); `kappa` is `-(l+1)` or `l >= 1`.
718    pub fn phase_shift(&self, kappa: i32) -> Option<f64> {
719        if kappa < 0 {
720            self.delta_minus.get((-kappa - 1) as usize).copied()
721        } else if kappa >= 1 {
722            self.delta_plus.get(kappa as usize).copied()
723        } else {
724            None
725        }
726    }
727
728    /// Direct and spin-flip amplitudes `(f, g)` at `cos(theta) = x`, each as
729    /// `[re, im]`, bohr.
730    pub fn amplitudes(&self, x: f64) -> (Pair, Pair) {
731        let sin_t = (1.0 - x * x).max(0.0).sqrt();
732        let (mut p_prev, mut p) = (0.0_f64, 1.0_f64);
733        let (mut q_prev, mut q) = (0.0_f64, 0.0_f64);
734        let (mut f, mut g) = ([0.0; 2], [0.0; 2]);
735        let pref = 1.0 / (2.0 * self.k);
736        for l in 0..=self.l_max() {
737            let lf = l as f64;
738            let (dm, dp) = (self.delta_minus[l], self.delta_plus[l]);
739            // (e^{2 i d} - 1) = 2 i sin d e^{i d}; divide by 2 i k:  sin d e^{i d}/k.
740            let (sm, cm) = dm.sin_cos();
741            let (sp, cp) = dp.sin_cos();
742            // fcoef = [(l+1) sin dm e^{i dm} + l sin dp e^{i dp}]/k
743            let fr = ((lf + 1.0) * sm * cm + lf * sp * cp) / self.k;
744            let fi = ((lf + 1.0) * sm * sm + lf * sp * sp) / self.k;
745            f[0] += fr * p;
746            f[1] += fi * p;
747            // gcoef = (e^{2 i dm} - e^{2 i dp})/(2 i k)
748            let gr = pref * ((2.0 * dm).sin() - (2.0 * dp).sin());
749            let gi = -pref * ((2.0 * dm).cos() - (2.0 * dp).cos());
750            g[0] += gr * q;
751            g[1] += gi * q;
752            // advance Legendre recurrences to l + 1
753            let p_next = ((2.0 * lf + 1.0) * x * p - lf * p_prev) / (lf + 1.0);
754            p_prev = p;
755            p = p_next;
756            let q_next = if l == 0 {
757                sin_t
758            } else {
759                ((2.0 * lf + 1.0) * x * q - (lf + 1.0) * q_prev) / lf
760            };
761            q_prev = q;
762            q = q_next;
763        }
764        (f, g)
765    }
766
767    /// Differential cross section `|f|^2 + |g|^2` at polar angle `theta`
768    /// (rad), bohr^2/sr.
769    pub fn dcs(&self, theta: f64) -> f64 {
770        let (f, g) = self.amplitudes(theta.cos());
771        f[0] * f[0] + f[1] * f[1] + g[0] * g[0] + g[1] * g[1]
772    }
773
774    /// Sherman function `S = i (f g* - f* g)/(|f|^2 + |g|^2)` at `theta`.
775    /// The sign convention is not verified against a published definition.
776    pub fn sherman(&self, theta: f64) -> f64 {
777        let (f, g) = self.amplitudes(theta.cos());
778        let den = f[0] * f[0] + f[1] * f[1] + g[0] * g[0] + g[1] * g[1];
779        if den == 0.0 {
780            return 0.0;
781        }
782        // i (f g* - f* g) = -2 Im(f g*)
783        -2.0 * (f[1] * g[0] - f[0] * g[1]) / den
784    }
785
786    /// Total elastic cross section from the phase shifts, bohr^2.
787    pub fn sigma_el(&self) -> f64 {
788        let mut s = 0.0;
789        for l in 0..=self.l_max() {
790            let lf = l as f64;
791            s += (lf + 1.0) * self.delta_minus[l].sin().powi(2)
792                + lf * self.delta_plus[l].sin().powi(2);
793        }
794        4.0 * std::f64::consts::PI * s / (self.k * self.k)
795    }
796
797    /// `(4 pi/k) Im f(0)`: the optical-theorem value of `sigma_el`, bohr^2.
798    pub fn sigma_optical(&self) -> f64 {
799        let (f, _) = self.amplitudes(1.0);
800        4.0 * std::f64::consts::PI / self.k * f[1]
801    }
802
803    /// `2 pi \int DCS(x) w(x) dx` by an exact Gauss-Legendre rule: `w` must be
804    /// a polynomial of degree <= 1.
805    fn integrate_dcs(&self, w: impl Fn(f64) -> f64) -> f64 {
806        let (xs, ws) = gauss_legendre(self.l_max() + 2);
807        let mut s = 0.0;
808        for (x, wt) in xs.iter().zip(&ws) {
809            let (f, g) = self.amplitudes(*x);
810            let d = f[0] * f[0] + f[1] * f[1] + g[0] * g[0] + g[1] * g[1];
811            s += wt * d * w(*x);
812        }
813        2.0 * std::f64::consts::PI * s
814    }
815
816    /// `sigma_el` by integrating the DCS (a consistency check of
817    /// [`Self::sigma_el`]), bohr^2.
818    pub fn sigma_el_quadrature(&self) -> f64 {
819        self.integrate_dcs(|_| 1.0)
820    }
821
822    /// First transport cross section `\int (1 - cos theta) dsigma`, bohr^2.
823    pub fn sigma_tr1(&self) -> f64 {
824        self.integrate_dcs(|x| 1.0 - x)
825    }
826}
827
828/// Result of [`solve`]: everything the issue asks for at one energy.
829#[derive(Debug, Clone, PartialEq)]
830pub struct ElasticResult {
831    /// Kinetic energy, eV.
832    pub energy_ev: f64,
833    /// Highest partial wave computed.
834    pub l_max: usize,
835    /// The phase shifts behind the numbers below.
836    pub partial_waves: PartialWaves,
837    /// `DCS` at each requested angle, bohr^2/sr.
838    pub dcs: Vec<f64>,
839    /// Sherman function at each requested angle.
840    pub sherman: Vec<f64>,
841    /// Total elastic cross section, bohr^2.
842    pub sigma_el: f64,
843    /// First transport cross section, bohr^2.
844    pub sigma_tr1: f64,
845    /// Which exchange / correlation-polarization corrections the potential
846    /// included ([`solve`] reports both off).
847    pub corrections: CorrectionMetadata,
848}
849
850/// Radial Dirac solver at one kinetic energy.
851pub struct ElasticSolver<'a> {
852    pot: &'a dyn ScreenedPotential,
853    e: f64,
854    k: f64,
855    c: f64,
856    opts: SolverOptions,
857    r_match: f64,
858    breaks: Vec<f64>,
859    /// Start rule. Always [`StartRule::Wkb`] outside the tests, which switch
860    /// it to measure the other rules against it (#131).
861    start_rule: StartRule,
862}
863
864// Manual: the borrowed potential is a trait object without `Debug`.
865impl std::fmt::Debug for ElasticSolver<'_> {
866    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
867        f.debug_struct("ElasticSolver")
868            .field("e", &self.e)
869            .field("k", &self.k)
870            .field("opts", &self.opts)
871            .field("r_match", &self.r_match)
872            .field("start_rule", &self.start_rule)
873            .finish_non_exhaustive()
874    }
875}
876
877impl<'a> ElasticSolver<'a> {
878    /// Prepare a solver for kinetic energy `energy_ev` (eV, > 0).
879    pub fn new(
880        pot: &'a dyn ScreenedPotential,
881        energy_ev: f64,
882        opts: SolverOptions,
883    ) -> Result<Self, ElasticError> {
884        if !energy_ev.is_finite() || energy_ev <= 0.0 {
885            return Err(invalid(
886                "energy",
887                format!("{energy_ev} eV (need finite, > 0)"),
888            ));
889        }
890        if let Some(bound) = pot.bound_energy_ev() {
891            if bound != energy_ev {
892                return Err(invalid(
893                    "energy",
894                    format!(
895                        "{energy_ev} eV does not match the potential's bound energy {bound} eV"
896                    ),
897                ));
898            }
899        }
900        if opts.step_scale.is_nan() || opts.step_scale <= 0.0 || opts.step_scale > 4.0 {
901            return Err(invalid(
902                "step_scale",
903                format!("{} (need 0 < s <= 4)", opts.step_scale),
904            ));
905        }
906        if !opts.light_speed.is_finite()
907            || opts.light_speed <= 0.0
908            || opts.matching_threshold.is_nan()
909            || opts.matching_threshold <= 0.0
910            || opts.phase_tolerance.is_nan()
911            || opts.phase_tolerance <= 0.0
912            || opts.consecutive == 0
913        {
914            return Err(invalid("solver options", "non-positive setting"));
915        }
916        let e = energy_ev / HARTREE_EV;
917        let c = opts.light_speed;
918        let k = (e * (e + 2.0 * c * c)).sqrt() / c;
919        let r_match = pot.matching_radius(opts.matching_threshold);
920        let mut breaks: Vec<f64> = pot
921            .breakpoints()
922            .into_iter()
923            .filter(|b| *b > 0.0 && *b < r_match)
924            .collect();
925        breaks.sort_by(f64::total_cmp);
926        Ok(Self {
927            pot,
928            e,
929            k,
930            c,
931            opts,
932            r_match,
933            breaks,
934            start_rule: StartRule::Wkb,
935        })
936    }
937
938    /// Wave number, 1/bohr.
939    pub fn wave_number(&self) -> f64 {
940        self.k
941    }
942
943    /// Matching radius, bohr.
944    pub fn matching_radius(&self) -> f64 {
945        self.r_match
946    }
947
948    /// Phase shift (rad, reduced to `(-pi/2, pi/2]`) of Dirac channel `kappa`
949    /// (`kappa <= -1` or `kappa >= 1`).
950    pub fn phase_shift(&self, kappa: i32) -> Result<f64, ElasticError> {
951        if kappa == 0 {
952            return Err(invalid("kappa", "0 is not a Dirac quantum number"));
953        }
954        let (e, c) = (self.e, self.c);
955        let kap = f64::from(kappa);
956        let l = if kappa < 0 { -kappa - 1 } else { kappa } as usize;
957        let lbar = if kappa < 0 { l + 1 } else { l - 1 };
958        let sigma = if kappa < 0 { -1.0 } else { 1.0 };
959        let s = c * self.k / (e + 2.0 * c * c);
960
961        // Free Bessel functions at the matching radius.
962        let Some((jb, yb)) = spherical_bessel(l.max(lbar), self.k * self.r_match) else {
963            return Ok(0.0);
964        };
965        let (jp, jq) = (jb[l], sigma * s * jb[lbar]);
966        let (yp, yq) = (yb[l], sigma * s * yb[lbar]);
967
968        let z = self.pot.nuclear_charge();
969        let pot = self.pot;
970        let q_of = |r: f64| {
971            let d = e - pot.energy(r);
972            d * (d + 2.0 * c * c) / (c * c) - kap * kap / (r * r)
973        };
974
975        // Turning point: first radius where the local wave is oscillatory.
976        let r_floor = 1.0e-10 / (1.0 + z);
977        let mut r_t = self.r_match;
978        let mut r = r_floor;
979        while r < self.r_match {
980            if q_of(r) > 0.0 {
981                r_t = r;
982                break;
983            }
984            r *= 1.05;
985        }
986        let r_s = match self.start_rule {
987            StartRule::Wkb => wkb_start(&q_of, r_t, r_floor, self.r_match),
988            StartRule::Legacy => (r_t * (-60.0 / kap.abs()).exp())
989                .max(r_floor)
990                .min(0.5 * self.r_match),
991            StartRule::Floor => r_floor,
992        };
993
994        // Starting values.
995        let y0: Pair = if z > 0.0 {
996            let alpha = c.recip();
997            let gamma = (kap * kap - (alpha * z).powi(2)).sqrt();
998            if kappa < 0 {
999                [1.0, -(z / c) / (gamma - kap)]
1000            } else {
1001                [z / (c * (gamma + kap)), 1.0]
1002            }
1003        } else {
1004            let v = pot.energy(r_s);
1005            if kappa < 0 {
1006                [1.0, -(e - v) / (c * (2.0 * l as f64 + 3.0)) * r_s]
1007            } else {
1008                [(e - v + 2.0 * c * c) / (c * (2.0 * kap + 1.0)) * r_s, 1.0]
1009            }
1010        };
1011
1012        let [p, q] = self.integrate(kap, r_s, y0)?;
1013        let num = q * jp - p * jq;
1014        let den = q * yp - p * yq;
1015        if !(num.is_finite() && den.is_finite()) {
1016            return Err(ElasticError::NumericalFailure(kappa));
1017        }
1018        Ok((num / den).atan())
1019    }
1020
1021    fn integrate(&self, kap: f64, r_s: f64, y0: Pair) -> Result<Pair, ElasticError> {
1022        let (e, c) = (self.e, self.c);
1023        let pot = self.pot;
1024        let scale = pot.length_scale();
1025        let mut y = y0;
1026        let mut r;
1027        let mut edges: Vec<f64> = self.breaks.iter().copied().filter(|b| *b > r_s).collect();
1028        edges.push(self.r_match);
1029        let mut a = r_s;
1030        for &b in &edges {
1031            let lo = a + 1.0e-12 * b;
1032            let hi = b - 1.0e-12 * b;
1033            let f = |rr: f64, yy: Pair| -> Pair {
1034                let v = pot.energy(rr.clamp(lo, hi.max(lo)));
1035                let (d1, d2) = ((e - v + 2.0 * c * c) / c, (e - v) / c);
1036                [
1037                    -kap / rr * yy[0] + d1 * yy[1],
1038                    -d2 * yy[0] + kap / rr * yy[1],
1039                ]
1040            };
1041            r = a;
1042            while r < b {
1043                let v = pot.energy(r.clamp(lo, hi.max(lo)));
1044                let d = e - v;
1045                let q = d * (d + 2.0 * c * c) / (c * c) - kap * kap / (r * r);
1046                // the cube-root term resolves the Airy-like region at a turning point
1047                let rate = q.abs().sqrt() + (1.0 + (2.0 * kap * kap).cbrt()) / r;
1048                let mut h = (self.opts.step_scale / rate).min(0.25 * self.opts.step_scale * scale);
1049                if r + 1.25 * h >= b {
1050                    h = b - r;
1051                }
1052                y = bs_step(&f, r, y, h);
1053                r = if h == b - r { b } else { r + h };
1054                let m = y[0].abs().max(y[1].abs());
1055                if !m.is_finite() {
1056                    return Err(ElasticError::NumericalFailure(kap as i32));
1057                }
1058                if m > 1.0e100 {
1059                    y = [y[0] * 1.0e-100, y[1] * 1.0e-100];
1060                }
1061            }
1062            a = b;
1063        }
1064        Ok(y)
1065    }
1066
1067    /// Phase shifts for `l = 0..=l_max` (both spin channels), in parallel and
1068    /// deterministically.
1069    pub fn phase_shifts_up_to(&self, l_max: usize) -> Result<PartialWaves, ElasticError> {
1070        let ls: Vec<usize> = (0..=l_max).collect();
1071        let pairs: Result<Vec<(f64, f64)>, ElasticError> = ls
1072            .par_iter()
1073            .map(|&l| {
1074                let dm = self.phase_shift(-(l as i32) - 1)?;
1075                let dp = if l == 0 {
1076                    0.0
1077                } else {
1078                    self.phase_shift(l as i32)?
1079                };
1080                Ok((dm, dp))
1081            })
1082            .collect();
1083        let pairs = pairs?;
1084        PartialWaves::from_phase_shifts(
1085            self.k,
1086            pairs.iter().map(|p| p.0).collect(),
1087            pairs.iter().map(|p| p.1).collect(),
1088        )
1089    }
1090
1091    /// Phase shifts with the adaptive cutoff of the module documentation.
1092    pub fn partial_waves(&self) -> Result<PartialWaves, ElasticError> {
1093        const BLOCK: usize = 32;
1094        let tol = self.opts.phase_tolerance;
1095        let (mut dm, mut dp) = (Vec::new(), Vec::new());
1096        let mut run = 0usize;
1097        let mut start = 0usize;
1098        while start <= self.opts.max_l {
1099            let end = (start + BLOCK).min(self.opts.max_l + 1);
1100            let ls: Vec<usize> = (start..end).collect();
1101            let block: Result<Vec<(f64, f64)>, ElasticError> = ls
1102                .par_iter()
1103                .map(|&l| {
1104                    let m = self.phase_shift(-(l as i32) - 1)?;
1105                    let p = if l == 0 {
1106                        0.0
1107                    } else {
1108                        self.phase_shift(l as i32)?
1109                    };
1110                    Ok((m, p))
1111                })
1112                .collect();
1113            for (m, p) in block? {
1114                dm.push(m);
1115                dp.push(p);
1116                if m.abs() < tol && p.abs() < tol {
1117                    run += 1;
1118                } else {
1119                    run = 0;
1120                }
1121                if run >= self.opts.consecutive {
1122                    return PartialWaves::from_phase_shifts(self.k, dm, dp);
1123                }
1124            }
1125            start = end;
1126        }
1127        Err(ElasticError::NotConverged(self.opts.max_l))
1128    }
1129}
1130
1131/// Solve at one energy and evaluate the observables on a caller-supplied
1132/// angle grid (`thetas`, rad).
1133pub fn solve(
1134    pot: &dyn ScreenedPotential,
1135    energy_ev: f64,
1136    thetas: &[f64],
1137    opts: SolverOptions,
1138) -> Result<ElasticResult, ElasticError> {
1139    if thetas
1140        .iter()
1141        .any(|t| !t.is_finite() || *t < 0.0 || *t > std::f64::consts::PI)
1142    {
1143        return Err(invalid("theta grid", "angles must lie in [0, pi]"));
1144    }
1145    let solver = ElasticSolver::new(pot, energy_ev, opts)?;
1146    let pw = solver.partial_waves()?;
1147    Ok(ElasticResult {
1148        energy_ev,
1149        l_max: pw.l_max(),
1150        dcs: thetas.iter().map(|t| pw.dcs(*t)).collect(),
1151        sherman: thetas.iter().map(|t| pw.sherman(*t)).collect(),
1152        sigma_el: pw.sigma_el(),
1153        sigma_tr1: pw.sigma_tr1(),
1154        partial_waves: pw,
1155        corrections: pot.correction_metadata(),
1156    })
1157}
1158
1159mod salvat_table;
1160pub mod table;
1161
1162#[cfg(test)]
1163mod tests;