1use 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
113pub const DEFAULT_MIN_ENERGY_EV: f64 = 10.0;
115pub const DEFAULT_MAX_ENERGY_EV: f64 = 5.0e4;
117pub const DEFAULT_POINTS_PER_DECADE: f64 = 20.0;
119pub const DEFAULT_PROBABILITY_TAIL: f64 = 1.0e-12;
122pub const DEFAULT_LOGISTIC_STEP: f64 = 0.4;
125
126pub const DEFAULT_REFINE_TOLERANCE: f64 = 5.0e-4;
129
130#[derive(Debug, thiserror::Error)]
132pub enum ElasticTableError {
133 #[error("invalid {what}: {reason}")]
135 Invalid {
136 what: &'static str,
138 reason: String,
140 },
141 #[error("no screened potential for Z={z}: {source}")]
143 Potential {
144 z: u8,
146 source: ElasticError,
148 },
149 #[error("elastic solve failed for Z={z} at {energy_ev} eV: {source}")]
151 Solve {
152 z: u8,
154 energy_ev: f64,
156 source: ElasticError,
158 },
159 #[error(transparent)]
161 Material(#[from] MaterialError),
162 #[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
174pub trait PotentialSource: Sync {
179 fn potential(&self, z: u8) -> Result<Box<dyn ScreenedPotential>, ElasticError>;
181
182 fn description(&self) -> String;
185}
186
187#[derive(Debug, Clone, Copy, Default, PartialEq)]
196pub struct ThomasFermiYukawa;
197
198impl ThomasFermiYukawa {
199 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#[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
245pub const MAX_GRID_INTERVALS: usize = 1_000_000;
253
254pub 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
294pub 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
306pub 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 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
344pub fn default_probability_grid() -> Vec<f64> {
347 logistic_probability_grid(DEFAULT_PROBABILITY_TAIL, DEFAULT_LOGISTIC_STEP)
348 .expect("valid default grid")
349}
350
351#[derive(Debug, Clone, PartialEq)]
353pub struct ElasticTableOptions {
354 pub energy_ev: Vec<f64>,
356 pub probability: Vec<f64>,
359 pub refine_tolerance: Option<f64>,
363 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#[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
392fn legendre_sums(c: &[f64], theta: f64) -> (f64, f64, f64) {
395 let x = theta.cos();
396 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 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 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 pub fn energy_ev(&self) -> f64 {
459 self.energy_ev
460 }
461
462 pub fn l_max(&self) -> usize {
464 self.l_max
465 }
466
467 pub fn sigma_el(&self) -> f64 {
470 self.sigma_el
471 }
472
473 pub fn sigma_tr1(&self) -> f64 {
476 self.sigma_tr1
477 }
478
479 pub fn legendre_coefficients(&self) -> &[f64] {
481 &self.legendre
482 }
483
484 pub fn dcs(&self, theta: f64) -> f64 {
486 legendre_sums(&self.legendre, theta).0
487 }
488
489 pub fn cumulative(&self, theta: f64) -> f64 {
491 2.0 * PI * legendre_sums(&self.legendre, theta).1
492 }
493
494 pub fn cumulative_total(&self) -> f64 {
498 4.0 * PI * self.legendre[0]
499 }
500}
501
502#[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 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 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 pub fn z(&self) -> u8 {
593 self.z
594 }
595
596 pub fn potential(&self) -> &str {
598 &self.potential
599 }
600
601 pub fn solver(&self) -> SolverOptions {
603 self.solver
604 }
605
606 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
627fn 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
644struct MixtureRow<'a> {
646 parts: Vec<(f64, &'a [f64])>,
647 total: f64,
648}
649
650impl MixtureRow<'_> {
651 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 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 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 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 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 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
762pub 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 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
785pub(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
807pub 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
938pub(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
947pub(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
957const REFINE_SMALL_SEGMENT: f64 = 1.0e-4;
960pub const MAX_PROBABILITY_POINTS: usize = 20_000;
962
963fn 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 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 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
1051pub 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 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 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 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}