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;