Skip to main content

lindhard/ion/bca/
mod.rs

1//! Amorphous binary-collision-approximation (BCA) transport in a layered or
2//! voxelised target, with full recoil cascades.
3//!
4//! # Model
5//!
6//! The target is any [`Geometry`]: a [`Stack`](crate::geometry::Stack) of
7//! layers or a [`VoxelGrid`](crate::geometry::VoxelGrid), whose regions are
8//! homogeneous, structureless (amorphous). A particle alternates straight
9//! free flights and binary elastic collisions with target atoms, losing
10//! energy continuously to electrons along each flight. This is the
11//! amorphous-target BCA of J. P. Biersack and L. G. Haggmark, Nucl. Instrum.
12//! Methods 174 (1980) 257 (TRIM), within the general BCA framework of M. T.
13//! Robinson and I. M. Torrens, Phys. Rev. B 9 (1974) 5008 and the treatment
14//! in W. Eckstein, *Computer Simulation of Ion-Solid Interactions*
15//! (Springer, 1991). Everything here is implemented from those
16//! publications; no code from the Tier B/C programs named in
17//! `CONTRIBUTING.md` was consulted.
18//!
19//! One history:
20//!
21//! 1. The primary enters at the geometry's entry point (the origin of the
22//!    front face of a stack; the centre of the front face of a voxel grid
23//!    unless [`Bca::with_entry_point`] chooses another) with the beam's
24//!    direction.
25//! 2. **Free flight** of length `tau * lambda` ([`MeanFreePath`]), with the
26//!    electronic loss `N S_e(E) s` taken at the energy at the start of the
27//!    segment (Bragg additivity over the layer composition,
28//!    [`crate::ion::stopping::bragg`]).
29//! 3. **Collision** with a partner drawn by stoichiometry, at impact parameter
30//!    `p = p_max sqrt(R)` (uniform over the disc of radius `p_max`) and a
31//!    uniform azimuth. The centre-of-mass angle comes from the
32//!    [`ScatteringTable`]; lab angles and the transfer `T` from
33//!    [`kinematics`].
34//! 4. **Recoil**: if `T > E_d` (the partner's displacement energy) the atom is
35//!    displaced with energy `T - E_b` (the lattice binding `E_b` stays in the
36//!    lattice). Otherwise `T` stays in the lattice at the site. This is the
37//!    displacement criterion of Biersack and Haggmark (1980) and Eckstein
38//!    (1991). Displaced atoms are followed in turn as full cascades
39//!    ([`BcaConfig::follow_recoils`]), from an explicit stack (no recursion),
40//!    and draw from the same per-history random stream, so the stream is
41//!    keyed on the primary index only and results do not depend on the thread
42//!    count.
43//! 5. A particle stops when its energy falls below its **cutoff**
44//!    ([`BcaConfig::primary_cutoff_ev`], [`BcaConfig::recoil_cutoff_ev`]),
45//!    or leaves through the front or back face if it can overcome the planar
46//!    surface barrier ([`kinematics::refract_out`]); otherwise it is reflected
47//!    specularly back into the target.
48//!
49//! # Free-path convention and layer boundaries
50//!
51//! [`Particle::layer`] is the particle's *region* in the geometry: the layer
52//! index in a stack, the flat voxel index in a voxel grid. Material data
53//! (scattering, stopping, energies) is cached per material, shared by all
54//! voxels of that material. The surface barrier of an escaping atom uses the
55//! `E_s` of the material it leaves from and the actual outward normal of the
56//! face (see [`kinematics::refract_out_normal`]); a particle that cannot
57//! escape is reflected specularly about that normal. Escapes through the
58//! lateral faces of a voxel grid are reported as [`Face::Side`] and counted
59//! in [`EnergyBudget::lateral`]. [`crate::tally::ion::IonTally`] and the CLI
60//! tallies describe stacks only; use [`crate::tally::voxel::VoxelTally`],
61//! [`SummaryTally`] or a custom [`BcaTally`] for voxel runs.
62//!
63//! A flight is drawn as a dimensionless number of mean free paths `tau` and
64//! converted to a length with the local `lambda`. When a flight reaches a
65//! change of material (or a periodic wrap of a voxel grid) it is truncated
66//! exactly there, the electronic loss for the truncated length is applied,
67//! the material is switched, and the flight continues in the new region
68//! with the **unused** part `tau - s/lambda` (the flight is not redrawn). A
69//! voxel face between voxels of one material is not an event; the region a
70//! flight ends in is still tracked ([`Geometry::flight`]), so the collision,
71//! its tallies and its recoils carry the region of the collision site. The
72//! collision happens in the material where the flight ends, with a partner
73//! and `p_max` from that material. For exponential paths this is exact by
74//! memorylessness; for the constant path it means a layer split of one
75//! material changes nothing but floating-point rounding,
76//! which the `split_layer_is_equivalent` test checks.
77//!
78//! # Weak collisions (optional)
79//!
80//! With the constant free path every flight ends in one collision with
81//! `p <= p_max`, and the nuclear loss of collisions with `p > p_max` is
82//! dropped, while the electronic loss of the flight is charged in full. At
83//! cascade-tail energies (a few to tens of eV) the dropped part is large: for
84//! Cu on Cu with the ZBL potential the disc `p <= p_max` carries 45 % of the
85//! nuclear stopping cross section at 5 eV and 54 % at 10 eV, so the
86//! electronic share of a cascade comes out far too high (issue #64; the level-1
87//! check `damage.cascade_electronic_share` measures it).
88//!
89//! [`BcaConfig::weak_collisions`] `= K` adds the "weak" collisions of
90//! W. Moller and W. Eckstein, *TRIDYN - Binary collision simulation of atomic
91//! collisions and dynamic composition changes in solids*, report IPP 9/64,
92//! Max-Planck-Institut fur Plasmaphysik, Garching (1988) (the long write-up
93//! of Comput. Phys. Commun. 51 (1988) 355), read at
94//! <https://pure.mpg.de/rest/items/item_2131703/component/file_2131702/content>.
95//! Read only the report body, PDF pp. 1-47: never Appendix 1 (the TRIDYN
96//! program listing, PDF pp. 48-86), which is Tier C (see `CONTRIBUTING.md`).
97//! Quoted:
98//!
99//! * p. 14: "'weak' collisions might occur with more distant atoms which
100//!   might contribute to energy loss and angular deflection. The present
101//!   version allows up to three additional weak collisions with impact
102//!   parameters larger than `p_max`, each of them representing one additional
103//!   atomic volume", with eq. (9) replaced by eq. (26),
104//!   `p_k^weak = p_max sqrt(k + r_p)`, `k = 1, 2, 3` (`r_p` uniform in
105//!   `[0, 1)`): the `k`-th partner is uniform over the annulus between
106//!   `p_max sqrt(k)` and `p_max sqrt(k + 1)`, of area `pi p_max^2`. Fig. 3
107//!   (p. 11) draws the first-order partner at its own azimuth.
108//! * p. 26: "the weak collision loop is entered which finally defines new
109//!   directions after each of the simultaneous collisions. (Actually, the
110//!   last passage of the weak collision loop represents the hard collision.)
111//!   A primary recoil may be generated and stored for each hard collision."
112//! * p. 35: the program sums the elastic energy transfers of the weak and
113//!   hard collisions, and their local inelastic (Oen-Robinson) losses.
114//! * Figs. 7 and 8, pp. 29-30 (flow charts of the projectile and cascade
115//!   loops): inside the weak-collision loop, after the partner's species,
116//!   azimuth and impact parameter are drawn, a "Target beyond Surf.?" test
117//!   skips the passage when the partner would lie outside the target; and
118//!   "Elastic Energy Loss" is applied once, after the loop.
119//!
120//! So each collision step here is: the `K` weak collisions, `k = 1..K`, each
121//! with a partner drawn by stoichiometry, `p = p_max sqrt(k + R)` and a
122//! uniform azimuth, then the hard collision with `p = p_max sqrt(R)`. Each
123//! deflects the particle and takes its transfer `T`; under
124//! [`ElectronicLoss::EquipartitionLsOr`] each also takes its Oen-Robinson
125//! local loss, so the local half is sampled out to `p_max sqrt(K + 1)`. A weak
126//! collision never makes a recoil: its `T` stays in the lattice at the site
127//! ([`LatticeDeposit::Weak`], counted in [`EnergyBudget::lattice`]). Random
128//! draws per weak collision: partner, `R`, azimuth, in that order and before
129//! the hard collision's, so `K = 0` draws exactly what the engine drew before
130//! the option existed and reproduces it bit for bit.
131//!
132//! **Surface test.** As in Figs. 7 and 8, a weak collision whose partner would
133//! lie in front of the front surface (in vacuum) is skipped; its draws are
134//! still made. The partner sits at distance `p` from the path on the side
135//! opposite to the deflection (where a hard collision sends its recoil). In
136//! TRIDYN the test also covers the hard collision (the loop's last passage);
137//! here it does not, because that would change the engine without weak
138//! collisions, which this option leaves untouched. Whether the hard
139//! collision should be tested too is left as a separate model decision. The
140//! back face of a finite target is not tested: the flow charts test "the
141//! surface" only.
142//!
143//! **Deviation from TRIDYN: the energy of each collision.** The flow charts
144//! apply the elastic loss once, after the loop, and p. 35 sums the
145//! transfers, so in TRIDYN every collision of a step is evaluated at the
146//! energy the step starts with. Here each is evaluated at the energy left
147//! after the previous one (and in the direction after it), so no transfer
148//! can exceed the particle's energy. Evaluated at the starting energy, the
149//! summed transfers of a slow atom often exceed its energy, and the report
150//! shows no rule for that case: for Ar 1 keV on Cu with `E_d = E_s` and
151//! `K = 3`, 18 % of the binary collisions would need a clamp, about 200 eV per
152//! ion in all, and the sputter yield would depend on how the clamp is
153//! applied. Measured on that problem (20 000 ions, seed 1): 0.882 as here;
154//! 0.875 with only the weak collisions at the starting energy (each transfer
155//! clamped to the energy left, the hard collision at the energy left); 0.976
156//! with every collision at the starting energy and each transfer clamped in
157//! loop order. Since the clamp is not in the source, the sequential choice
158//! is kept.
159//!
160//! With `K` weak collisions the nuclear loss per path is the stopping cross
161//! section integrated to `p_max sqrt(K + 1)` instead of `p_max` (93 % of the
162//! full value at 5 eV for Cu on Cu with `K = 3`). It is available with
163//! [`MeanFreePath::Constant`] only (`p_max` is the constant-path radius);
164//! [`MeanFreePath::EnergyDependent`] rejects it, since that convention drops
165//! small-angle collisions by design.
166//!
167//! **Default 0**, because there is no single published convention: Biersack
168//! and Haggmark (1980), whose constant free path this engine follows, use one
169//! collision per flight; TRIDYN allows up to three (the report read does not
170//! give a default); SDTrimSP, a TRIDYN descendant, defaults to two for
171//! projectiles and two for recoils (`iwc`, `iwcr`: A. Mutzke et al.,
172//! *SDTrimSP Version 6.00*, report IPP 2019-02 (2019), Table 13, p. 73,
173//! "number of ring cylinders for weak simultaneous collisions"); and the
174//! RustBCA manual documents `weak_collision_order` with a default of 0 (its
175//! GitHub wiki, "Standalone Code: Input File"; manual only, Tier B).
176//!
177//! The option is not a free improvement, which is the other reason it is off
178//! by default. It brings the electronic share of a cascade in line with the
179//! Lindhard partition integral equation solved for the engine's own inputs
180//! (level-1 rows `damage.cascade_electronic_share.*`: for Cu on Cu at 1 keV
181//! about 0.79 without weak collisions and 0.35 with `K = 3`, against 0.38).
182//! But a weak transfer stays in the lattice, and at a few eV it is a large
183//! part of the energy (Cu on Cu at 5 eV, ZBL: on average 46, 25 and 13 % in
184//! the first three annuli), so slow atoms near the surface stop sooner: the
185//! Ar 1 keV on Cu sputter yield with `E_d = E_s` falls from 1.93 to 0.88 with
186//! `K = 3` (0.66 without the surface test; `docs/validation.md`, level 2).
187//! Use it for the energy partition (damage energy, electronic share); its
188//! effect on yields is large and has not been checked against measured
189//! yields.
190//!
191//! # Energy bookkeeping
192//!
193//! Every energy change is subtracted from the particle and added to exactly
194//! one field of an [`EnergyBudget`], so each history conserves energy up to
195//! rounding (`EnergyBudget::residual`). Electronic losses larger than the
196//! particle's energy are clamped to it.
197//!
198//! Vacancy, interstitial and replacement counts, and the NRT damage estimate,
199//! are kept by the tally [`crate::tally::IonTally`] from the events reported
200//! here ([`crate::ion::damage`] has the models).
201//!
202//! # Crystal regions
203//!
204//! Everything above is the amorphous partner model. [`Bca::with_crystal`]
205//! switches chosen regions to the crystal flight model (partners from an
206//! explicit lattice, in [`crystal`]); the other regions, and engines that do
207//! not call it, are untouched and bit-identical to the amorphous engine.
208//!
209//! # Not modelled here (see the issue tracker)
210//!
211//! Damage accumulation in crystal regions (the lattice there is perfect,
212//! static or thermally vibrating; the local Oen-Robinson loss of
213//! [`ElectronicLoss::EquipartitionLsOr`] is taken at every lattice partner,
214//! see [`crystal`]), target composition changes with fluence, and refraction
215//! of the incident beam at the entrance surface (negligible at keV energies).
216
217pub mod crystal;
218pub mod kinematics;
219pub mod tally;
220
221pub use crystal::{CrystalMetadata, CrystalTarget, Thermal, ThermalMetadata};
222pub use tally::{BcaTally, ElectronicChannel, EnergyBudget, Face, LatticeDeposit, SummaryTally};
223
224use std::collections::HashMap;
225use std::f64::consts::PI;
226
227use rand_core::Rng;
228
229use crate::geometry::{ExitOutcome, Flight, Geometry};
230use crate::ion::crystal::Divergence;
231use crate::ion::potential::{Potential, Screening};
232use crate::ion::scattering::{closest_approach, theta_quadrature, ScatteringTable};
233use crate::ion::stopping::bragg::{bragg_cross_section_per_atom, NoCorrection};
234use crate::ion::stopping::mix::EquipartitionMix;
235use crate::ion::stopping::{ElectronicStopping, Ion, StoppingError, ValidityRange};
236use crate::material::EnergyKind;
237use crate::rng::{run_particles_range, uniform, ParticleRng};
238use crate::units::J_PER_EV;
239
240/// Largest [`BcaConfig::weak_collisions`]: "up to three additional weak
241/// collisions" (Moller and Eckstein, IPP 9/64 (1988), p. 14).
242pub const MAX_WEAK_COLLISIONS: u8 = 3;
243
244/// The incident beam.
245#[derive(Debug, Clone, Copy, PartialEq)]
246pub struct Beam {
247    /// Projectile species (atomic number and mass).
248    pub ion: Ion,
249    /// Incident kinetic energy, eV.
250    pub energy_ev: f64,
251    /// Polar angle of incidence from the surface normal, radians, in `[0, pi/2)`.
252    pub polar_rad: f64,
253    /// Azimuth of incidence about the normal, radians.
254    pub azimuth_rad: f64,
255    /// Number of primary histories.
256    pub count: u64,
257}
258
259impl Beam {
260    /// Normal incidence.
261    pub fn normal(ion: Ion, energy_ev: f64, count: u64) -> Self {
262        Self {
263            ion,
264            energy_ev,
265            polar_rad: 0.0,
266            azimuth_rad: 0.0,
267            count,
268        }
269    }
270
271    /// Unit direction `[x, y, z]` of incidence.
272    pub fn direction(&self) -> [f64; 3] {
273        let (s, c) = self.polar_rad.sin_cos();
274        let (sa, ca) = self.azimuth_rad.sin_cos();
275        [c, s * ca, s * sa]
276    }
277}
278
279/// Word position (in the 32-bit words of the ChaCha stream) of the segment
280/// of a history's random stream that beam-divergence draws come from:
281/// `2^65`. The transport draws start at word 0 and never reach it, and it is
282/// disjoint from the thermal-displacement segment (`2^66`) and the
283/// crystal-shift segment (`2^67`) (each is far shorter than the gap to the
284/// next), so enabling divergence leaves every other draw of the history
285/// unchanged.
286pub const DIVERGENCE_STREAM_WORD: u128 = 1u128 << 65;
287
288/// Most deflected directions drawn for one primary before
289/// [`Bca::with_divergence`]'s inward conditioning gives up with
290/// [`BcaError::BeamDivergence`]. For the small-angle spreads the input
291/// front end accepts, one draw is inward with probability about one half or
292/// more, so exhausting 1000 attempts has probability below `2^-1000`; the
293/// bound only guarantees that a pathological setting cannot hang a run.
294pub const MAX_DIVERGENCE_ATTEMPTS: u32 = 1000;
295
296/// Run metadata of a beam divergence ([`Bca::divergence_metadata`]): the
297/// distribution, its width and the incidence policy, so a result states the
298/// spread it was run with.
299#[derive(Debug, Clone, PartialEq, serde::Serialize)]
300pub struct DivergenceMetadata {
301    /// `"gaussian"` or `"uniform-cone"`.
302    pub model: &'static str,
303    /// Which angle `width_rad` is: `"sigma_per_plane"` (Gaussian standard
304    /// deviation of each of two orthogonal plane angles) or
305    /// `"cone_half_angle"` (uniform in solid angle).
306    pub width_kind: &'static str,
307    /// The width, radians.
308    pub width_rad: f64,
309    /// The width, degrees (the unit the input uses).
310    pub width_deg: f64,
311    /// Incidence policy: `"inward-conditioned"`, i.e. the distribution
312    /// conditioned on directions into the target by rejection sampling.
313    pub incidence: &'static str,
314    /// Rejection-sampling attempt bound per primary.
315    pub max_attempts: u32,
316    /// Word position of the random-stream segment the draws come from.
317    pub stream_word: &'static str,
318}
319
320impl DivergenceMetadata {
321    /// Metadata of `divergence`; `None` for [`Divergence::None`].
322    pub fn new(divergence: &Divergence) -> Option<Self> {
323        let (model, width_kind, width_rad) = match *divergence {
324            Divergence::None => return None,
325            Divergence::Gaussian { sigma_rad } => ("gaussian", "sigma_per_plane", sigma_rad),
326            Divergence::UniformCone { half_angle_rad } => {
327                ("uniform-cone", "cone_half_angle", half_angle_rad)
328            }
329        };
330        Some(Self {
331            model,
332            width_kind,
333            width_rad,
334            width_deg: width_rad.to_degrees(),
335            incidence: "inward-conditioned",
336            max_attempts: MAX_DIVERGENCE_ATTEMPTS,
337            stream_word: "2^65",
338        })
339    }
340}
341
342/// Why a single history failed.
343#[derive(Debug, Clone, PartialEq, thiserror::Error)]
344pub enum HistoryError {
345    /// The stopping model failed.
346    #[error(transparent)]
347    Stopping(#[from] StoppingError),
348    /// No inward direction was drawn from the beam divergence within
349    /// [`MAX_DIVERGENCE_ATTEMPTS`] attempts.
350    #[error("no inward beam direction in {attempts} divergence draws")]
351    BeamDivergence {
352        /// Attempts made.
353        attempts: u32,
354    },
355}
356
357/// Draw `divergence` about `central` until the direction points into the
358/// target (`dir[0] > 0`), at most [`MAX_DIVERGENCE_ATTEMPTS`] times. The
359/// result is the divergence law conditioned on inward incidence.
360fn sample_inward<R: Rng + ?Sized>(
361    divergence: &Divergence,
362    central: [f64; 3],
363    rng: &mut R,
364) -> Result<[f64; 3], HistoryError> {
365    for _ in 0..MAX_DIVERGENCE_ATTEMPTS {
366        let d = divergence.sample_direction(central, rng);
367        if d[0] > 0.0 {
368            return Ok(d);
369        }
370    }
371    Err(HistoryError::BeamDivergence {
372        attempts: MAX_DIVERGENCE_ATTEMPTS,
373    })
374}
375
376/// How free-flight lengths and the impact-parameter limit are chosen.
377#[derive(Debug, Clone, Copy, PartialEq)]
378pub enum MeanFreePath {
379    /// Constant flight length `l = N^(-1/3)` (the mean interatomic distance)
380    /// and impact parameters uniform over a disc of radius
381    /// `p_max = (pi N^(2/3))^(-1/2)`, so that `N pi p_max^2 l = 1`: each flight
382    /// sweeps exactly one atom's worth of target. This is the amorphous-target
383    /// convention of Biersack and Haggmark, Nucl. Instrum. Methods 174 (1980)
384    /// 257. To avoid every primary making its first collision at the same depth
385    /// `l cos(theta_in)`, the primary's first flight is `R l` with `R` uniform
386    /// in `[0, 1)`; recoils start at an atom site and fly a full `l`.
387    /// Collisions with `p > p_max` are dropped unless
388    /// [`BcaConfig::weak_collisions`] adds them (module docs, "Weak
389    /// collisions").
390    Constant,
391    /// Energy-dependent free path with Poisson-distributed flight lengths.
392    /// Collisions deflecting by less than `min_cm_angle_rad` (centre-of-mass)
393    /// are neglected: for each element `i`, `p_i(E)` is the impact parameter at
394    /// which the centre-of-mass angle equals that minimum, capped at the
395    /// constant-convention `p_max` (so the free path is never shorter than
396    /// `N^(-1/3)`). The mean free path is `lambda = 1 / (N pi Sum_i x_i p_i^2)`,
397    /// the flight length is exponential with that mean, the partner is drawn
398    /// with probability `x_i p_i^2 / Sum_j x_j p_j^2`, and `p = p_i sqrt(R)`.
399    /// This is the standard cross-section cut-off treatment of a Poisson
400    /// collision process (Eckstein 1991). It reduces to `Constant` (with
401    /// exponential instead of fixed flight lengths) when every `p_i` hits the
402    /// cap. The nuclear energy loss of the neglected small-angle collisions is
403    /// dropped, so choose the minimum angle small; and since `p_i` never
404    /// exceeds the constant-convention `p_max`, collisions beyond it are
405    /// dropped too, as in `Constant` without weak collisions (which this
406    /// variant does not support).
407    EnergyDependent {
408        /// Smallest centre-of-mass deflection treated as a collision, radians,
409        /// in `(0, pi)`.
410        min_cm_angle_rad: f64,
411    },
412}
413
414/// Electronic energy-loss treatment.
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum ElectronicLoss {
417    /// All electronic loss is continuous (nonlocal) along the free flight,
418    /// from the stopping model passed to [`Bca::new`].
419    NonLocal,
420    /// Equipartition of Lindhard-Scharff stopping: half nonlocal along the
421    /// flight, half local at each collision by the Oen-Robinson
422    /// impact-parameter-dependent loss evaluated at the distance of closest
423    /// approach (O. S. Oen and M. T. Robinson, Nucl. Instrum. Methods 132
424    /// (1976) 647; [`EquipartitionMix`]). The stopping model passed to
425    /// [`Bca::new`] is not used in this mode. The local part is only sampled
426    /// at the collisions, out to `p_max` (to `p_max sqrt(K + 1)` with
427    /// [`BcaConfig::weak_collisions`] `= K`, each weak collision taking its
428    /// own local loss), so where that radius is not large compared with
429    /// `a / 0.3` (the decay length of the Oen-Robinson loss) the total
430    /// electronic stopping is somewhat below the Lindhard-Scharff value.
431    EquipartitionLsOr,
432}
433
434/// Run configuration.
435#[derive(Debug, Clone, Copy, PartialEq)]
436pub struct BcaConfig {
437    /// Free-path convention. Default [`MeanFreePath::Constant`].
438    pub mean_free_path: MeanFreePath,
439    /// Electronic-loss treatment. Default [`ElectronicLoss::NonLocal`].
440    pub electronic: ElectronicLoss,
441    /// Number of simultaneous weak collisions per collision step, `0..=3`
442    /// ([`MeanFreePath::Constant`] only; see the module docs, "Weak
443    /// collisions"). Default 0, the single-collision convention of Biersack
444    /// and Haggmark (1980).
445    pub weak_collisions: u8,
446    /// Follow displaced atoms as full cascades. If `false`, a displaced atom
447    /// stops where it was created and its energy counts as rest energy (an
448    /// ion-only run). Default `true`.
449    pub follow_recoils: bool,
450    /// The primary stops when its energy falls below this, eV.
451    pub primary_cutoff_ev: f64,
452    /// Recoils stop when their energy falls below this, eV. A recoil cutoff
453    /// above the surface binding energies suppresses sputtering, so keep it
454    /// below the smallest `E_s` when sputtering matters (Eckstein 1991).
455    pub recoil_cutoff_ev: f64,
456    /// Planar surface barrier for the primary's species at both faces, eV.
457    /// Default 0 (backscattered ions are not bound). Target atoms use the
458    /// `E_s` of the material at the face (see [`Bca::new`]).
459    pub primary_surface_binding_ev: f64,
460    /// Run seed for [`crate::rng::stream`].
461    pub seed: u64,
462    /// Histories per work chunk for [`crate::rng::run_particles`]; fixes the summation
463    /// order, so keep it independent of the thread count. Default 64.
464    pub chunk_size: u64,
465}
466
467impl BcaConfig {
468    /// Defaults with the two cutoffs, which have no defensible default and
469    /// must be chosen for the problem.
470    pub fn new(primary_cutoff_ev: f64, recoil_cutoff_ev: f64) -> Self {
471        Self {
472            mean_free_path: MeanFreePath::Constant,
473            electronic: ElectronicLoss::NonLocal,
474            weak_collisions: 0,
475            follow_recoils: true,
476            primary_cutoff_ev,
477            recoil_cutoff_ev,
478            primary_surface_binding_ev: 0.0,
479            seed: 0,
480            chunk_size: 64,
481        }
482    }
483}
484
485/// Errors from setting up or running the engine.
486#[derive(Debug, thiserror::Error)]
487pub enum BcaError {
488    /// A beam parameter is out of range.
489    #[error("invalid beam: {0}")]
490    InvalidBeam(String),
491    /// A configuration parameter is out of range.
492    #[error("invalid configuration: {0}")]
493    InvalidConfig(String),
494    /// A material has an energy with no value; set it before running.
495    #[error("layer {layer}: {kind} for element Z={z} is not set")]
496    EnergyNotSet {
497        /// Material index (the layer index for a stack).
498        layer: usize,
499        /// Atomic number.
500        z: u8,
501        /// Which energy.
502        kind: EnergyKind,
503    },
504    /// The stopping model failed during a history (for example a user table
505    /// queried outside its range). `index` is the lowest failing history.
506    #[error("history {index}: {source}")]
507    Stopping {
508        /// Primary index.
509        index: u64,
510        /// The model error.
511        source: StoppingError,
512    },
513    /// Beam divergence produced no inward direction within the attempt
514    /// bound ([`MAX_DIVERGENCE_ATTEMPTS`]). `index` is the lowest failing
515    /// history.
516    #[error("history {index}: no inward beam direction in {attempts} divergence draws")]
517    BeamDivergence {
518        /// Primary index.
519        index: u64,
520        /// Attempts made.
521        attempts: u32,
522    },
523}
524
525/// A moving or stopped particle.
526#[derive(Debug, Clone, PartialEq)]
527pub struct Particle {
528    /// Species index: 0 is the beam species, then the target elements in
529    /// order of first appearance in the stack.
530    pub species: usize,
531    /// Atomic number.
532    pub z: u8,
533    /// Mass, u.
534    pub mass_amu: f64,
535    /// Kinetic energy, eV.
536    pub energy_ev: f64,
537    /// Position `[x, y, z]`, m.
538    pub pos: [f64; 3],
539    /// Unit direction.
540    pub dir: [f64; 3],
541    /// Index of the region the particle is in: the layer of a stack, the flat
542    /// voxel index of a voxel grid (see [`Geometry`]).
543    pub layer: usize,
544    /// Region the particle started in: the entry region of a primary, the
545    /// region of the displacing collision for a recoil. Unlike `layer` it does
546    /// not change as the particle moves, so a tally that sees the particle
547    /// leave can tell where the atom came from.
548    pub origin_layer: usize,
549    /// 0 for the primary, parent's generation + 1 for recoils.
550    pub generation: u32,
551}
552
553impl Particle {
554    /// True for the beam particle of a history.
555    pub fn is_primary(&self) -> bool {
556        self.generation == 0
557    }
558}
559
560#[derive(Debug, Clone)]
561struct SpeciesData {
562    ion: Ion,
563    cutoff_ev: f64,
564    /// `E_s` used when the exit material does not contain the element: that
565    /// of the first material that does (the primary's own binding for the
566    /// beam).
567    barrier_default_ev: f64,
568}
569
570#[derive(Debug, Clone, Copy)]
571struct PairData {
572    /// Screening length, m.
573    a: f64,
574    /// Reduced energy per eV of lab energy.
575    eps_per_ev: f64,
576    gamma: f64,
577    /// M1 / M2.
578    mu: f64,
579}
580
581#[derive(Debug, Clone)]
582struct ElemData {
583    z: u8,
584    species: usize,
585    fraction: f64,
586    e_d_ev: f64,
587    e_b_ev: f64,
588}
589
590#[derive(Debug, Clone)]
591struct LayerData {
592    /// Atom density, m^-3.
593    n: f64,
594    /// Constant flight length N^(-1/3), m.
595    ell: f64,
596    /// Constant-convention p_max, m.
597    p_const: f64,
598    elems: Vec<ElemData>,
599    /// Per projectile species: `c` with `Σ_j x_j S_j(E) = c sqrt(E)` over the
600    /// layer's elements for the configured nonlocal model, if every element's
601    /// stopping has that form (see
602    /// [`ElectronicStopping::sqrt_energy_coefficient`]). The fractional
603    /// powers in the coefficient depend only on the ion and target, so they
604    /// are evaluated here once instead of at every flight.
605    loss_coef: Vec<Option<f64>>,
606}
607
608/// `ln beta_max` on a grid uniform in `ln eps`, for the energy-dependent
609/// free path.
610#[derive(Debug, Clone)]
611struct BetaMaxGrid {
612    ln_eps0: f64,
613    d: f64,
614    ln_beta: Vec<f64>,
615}
616
617impl BetaMaxGrid {
618    const PER_DECADE: f64 = 16.0;
619
620    fn build(table: &ScatteringTable, theta_min: f64) -> Self {
621        let spec = table.spec();
622        let d = std::f64::consts::LN_10 / Self::PER_DECADE;
623        let ln_eps0 = spec.eps_min.ln();
624        let n = ((spec.eps_max.ln() - ln_eps0) / d).ceil() as usize + 1;
625        let (lb0, lb1) = (spec.beta_min.ln(), spec.beta_max.ln());
626        let ln_beta = (0..n)
627            .map(|i| {
628                let eps = (ln_eps0 + i as f64 * d).exp().min(spec.eps_max);
629                let th = |lb: f64| table.theta(eps, lb.exp()).unwrap_or(0.0);
630                if th(lb1) >= theta_min {
631                    return lb1;
632                }
633                // theta decreases with beta; bisect in ln beta.
634                let (mut lo, mut hi) = (lb0, lb1);
635                for _ in 0..60 {
636                    let mid = 0.5 * (lo + hi);
637                    if th(mid) >= theta_min {
638                        lo = mid;
639                    } else {
640                        hi = mid;
641                    }
642                }
643                0.5 * (lo + hi)
644            })
645            .collect();
646        Self {
647            ln_eps0,
648            d,
649            ln_beta,
650        }
651    }
652
653    /// `beta_max(eps)`, linear in `ln eps`, clamped at the grid ends.
654    fn beta(&self, eps: f64) -> f64 {
655        let f = ((eps.ln() - self.ln_eps0) / self.d).max(0.0);
656        let last = self.ln_beta.len() - 1;
657        let i = (f.floor() as usize).min(last.saturating_sub(1));
658        let t = (f - i as f64).min(1.0);
659        let j = (i + 1).min(last);
660        ((1.0 - t) * self.ln_beta[i] + t * self.ln_beta[j]).exp()
661    }
662}
663
664/// What [`Bca::binary`] reports: the energy transfer `T`, the direction of
665/// the projectile before the collision, and the recoil's lab deflection as
666/// `(sin, cos)`.
667type BinaryOutcome = (f64, [f64; 3], (f64, f64));
668
669/// Per-flight scratch space for the per-element `p_max` and partner weights.
670#[derive(Default)]
671struct Scratch {
672    p_max: Vec<f64>,
673    cum: Vec<f64>,
674    /// Crystal flight model: the lattice sites found by the last search, the
675    /// sites hit by the previous collision, and the partners of the current
676    /// collision step.
677    cands: Vec<crystal::Candidate>,
678    last: Vec<crystal::SiteId>,
679    targets: Vec<crystal::Partner>,
680    /// Thermal crystals: the static site of each entry of `cands` (whose
681    /// positions are displaced), a sort buffer, and the displacements of the
682    /// sites met on the current flight line (`displaced_line`: crystal and
683    /// direction bits), with the buffer that replaces them after a search.
684    homes: Vec<[f64; 3]>,
685    encounter: Vec<(crystal::Candidate, [f64; 3])>,
686    displaced: HashMap<crystal::SiteId, [f64; 3]>,
687    displaced_next: HashMap<crystal::SiteId, [f64; 3]>,
688    displaced_line: Option<(usize, [u64; 3])>,
689}
690
691impl Scratch {
692    /// Forget the displaced sites: the next crystal search starts a new
693    /// encounter.
694    fn new_line(&mut self) {
695        self.displaced.clear();
696        self.displaced_line = None;
697    }
698}
699
700/// Reusable working memory for [`Bca::history_in`]: the stack of particles
701/// still to be followed and the per-flight scratch. A history that runs in
702/// buffers which have already seen a cascade as large allocates nothing, so a
703/// worker thread allocates only while its buffers grow to the largest cascade
704/// it meets, not per history and not per collision.
705#[derive(Default)]
706pub struct HistoryBuffers {
707    pending: Vec<Particle>,
708    scratch: Scratch,
709    /// Crystal regions: this history's random lattice translation per
710    /// crystal (empty when there are none).
711    shifts: Vec<[f64; 3]>,
712    /// Thermal crystals: this history's stream of site displacements
713    /// (`None` when no crystal vibrates).
714    thermal_rng: Option<ParticleRng>,
715}
716
717// Manual: the per-history scratch space is internal and has no `Debug`.
718impl std::fmt::Debug for HistoryBuffers {
719    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
720        f.debug_struct("HistoryBuffers")
721            .field("pending", &self.pending.len())
722            .field("shifts", &self.shifts.len())
723            .finish_non_exhaustive()
724    }
725}
726
727impl HistoryBuffers {
728    /// Empty buffers.
729    pub fn new() -> Self {
730        Self::default()
731    }
732}
733
734/// The nonlocal half of [`EquipartitionMix`] as an [`ElectronicStopping`]
735/// model, so the Bragg sum can be reused.
736struct MixNonLocal(EquipartitionMix);
737
738impl ElectronicStopping for MixNonLocal {
739    fn name(&self) -> &'static str {
740        "equipartition-ls-or-nonlocal"
741    }
742    fn stopping(&self, ion: &Ion, target_z: u8, energy_ev: f64) -> Result<f64, StoppingError> {
743        self.0.nonlocal_stopping(ion, target_z, energy_ev)
744    }
745    fn validity(&self, ion: &Ion) -> ValidityRange {
746        self.0.validity(ion)
747    }
748    fn sqrt_energy_coefficient(&self, ion: &Ion, target_z: u8) -> Option<f64> {
749        self.0.nonlocal_sqrt_energy_coefficient(ion, target_z)
750    }
751}
752
753/// The BCA engine for one beam, target and configuration.
754///
755/// Holds borrowed, read-only inputs and precomputed per-layer and per-pair
756/// data, so it is `Sync` and shared by all worker threads.
757pub struct Bca<'a> {
758    beam: Beam,
759    geometry: &'a dyn Geometry,
760    entry_pos: [f64; 3],
761    entry_region: usize,
762    config: BcaConfig,
763    stopping: &'a (dyn ElectronicStopping + Sync),
764    mix: MixNonLocal,
765    table: &'a ScatteringTable,
766    screening: Screening,
767    species: Vec<SpeciesData>,
768    /// Per-material data (indexed by material index, not region).
769    layers: Vec<LayerData>,
770    /// Surface barrier `E_s` by `material * n_species + species`.
771    barriers: Vec<f64>,
772    /// `pairs[projectile species * n_species + target species]`.
773    pairs: Vec<PairData>,
774    beta_max: Option<BetaMaxGrid>,
775    /// Crystal partner models ([`Bca::with_crystal`]); empty for an
776    /// amorphous target.
777    crystals: Vec<crystal::CrystalData>,
778    /// Index into `crystals` by region; empty when there are no crystals.
779    region_crystal: Vec<Option<usize>>,
780    /// Beam divergence about the nominal direction ([`Bca::with_divergence`]);
781    /// [`Divergence::None`] (the default) draws nothing.
782    divergence: Divergence,
783}
784
785fn finite_nonneg(v: f64) -> bool {
786    v.is_finite() && v >= 0.0
787}
788
789// Manual: the geometry and stopping model are trait objects without `Debug`,
790// and the precomputed tables are large.
791impl std::fmt::Debug for Bca<'_> {
792    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
793        f.debug_struct("Bca")
794            .field("beam", &self.beam)
795            .field("entry_pos", &self.entry_pos)
796            .field("entry_region", &self.entry_region)
797            .field("config", &self.config)
798            .field("screening", &self.screening)
799            .field("crystals", &self.crystals.len())
800            .finish_non_exhaustive()
801    }
802}
803
804impl<'a> Bca<'a> {
805    /// Validate the inputs and precompute per-layer and per-pair data.
806    ///
807    /// * Every layer material must have `E_d`, `E_b` and `E_s` set for every
808    ///   element (checked with [`crate::material::Material::unset_energies`]),
809    ///   so a run never fails half way for a missing parameter.
810    /// * The scattering table fixes the screening function and length
811    ///   convention; the same table serves every projectile/target pair,
812    ///   since the angle depends on the reduced variables only.
813    /// * Target atoms use, at each face, the `E_s` of their element in the
814    ///   material they leave from; an element absent from that material uses
815    ///   its `E_s` in the first material that contains it.
816    /// * `geometry` is a [`Stack`](crate::geometry::Stack) or a
817    ///   [`VoxelGrid`](crate::geometry::VoxelGrid) (or any other
818    ///   [`Geometry`]); its default entry point must lie in the target
819    ///   ([`Bca::with_entry_point`] changes it).
820    pub fn new(
821        beam: Beam,
822        geometry: &'a dyn Geometry,
823        config: BcaConfig,
824        stopping: &'a (dyn ElectronicStopping + Sync),
825        table: &'a ScatteringTable,
826    ) -> Result<Self, BcaError> {
827        if !(beam.energy_ev.is_finite() && beam.energy_ev > 0.0) {
828            return Err(BcaError::InvalidBeam(format!(
829                "energy {} eV must be finite and positive",
830                beam.energy_ev
831            )));
832        }
833        if !(beam.polar_rad >= 0.0 && beam.polar_rad < 0.5 * PI) {
834            return Err(BcaError::InvalidBeam(format!(
835                "polar angle {} rad must be in [0, pi/2)",
836                beam.polar_rad
837            )));
838        }
839        if !beam.azimuth_rad.is_finite() {
840            return Err(BcaError::InvalidBeam("azimuth must be finite".into()));
841        }
842        for (name, v) in [
843            ("primary_cutoff_ev", config.primary_cutoff_ev),
844            ("recoil_cutoff_ev", config.recoil_cutoff_ev),
845        ] {
846            if !(v.is_finite() && v > 0.0) {
847                return Err(BcaError::InvalidConfig(format!(
848                    "{name} = {v} must be finite and positive"
849                )));
850            }
851        }
852        if !finite_nonneg(config.primary_surface_binding_ev) {
853            return Err(BcaError::InvalidConfig(format!(
854                "primary_surface_binding_ev = {} must be finite and non-negative",
855                config.primary_surface_binding_ev
856            )));
857        }
858        if let MeanFreePath::EnergyDependent { min_cm_angle_rad } = config.mean_free_path {
859            if !(min_cm_angle_rad > 0.0 && min_cm_angle_rad < PI) {
860                return Err(BcaError::InvalidConfig(format!(
861                    "min_cm_angle_rad = {min_cm_angle_rad} must be in (0, pi)"
862                )));
863            }
864            if config.weak_collisions > 0 {
865                return Err(BcaError::InvalidConfig(
866                    "weak_collisions requires the constant free path".into(),
867                ));
868            }
869        }
870        if config.weak_collisions > MAX_WEAK_COLLISIONS {
871            return Err(BcaError::InvalidConfig(format!(
872                "weak_collisions = {} must be at most {MAX_WEAK_COLLISIONS}",
873                config.weak_collisions
874            )));
875        }
876        let n_materials = geometry.n_materials();
877        for layer in 0..n_materials {
878            if let Some(&(z, kind)) = geometry.material(layer).unset_energies().first() {
879                return Err(BcaError::EnergyNotSet { layer, z, kind });
880            }
881        }
882        let entry_pos = geometry.entry_point();
883        let entry_region = geometry.locate(entry_pos).ok_or_else(|| {
884            BcaError::InvalidBeam(format!("entry point {entry_pos:?} is outside the target"))
885        })?;
886
887        // Species: the beam, then target elements in order of appearance.
888        let mut species = vec![SpeciesData {
889            ion: beam.ion,
890            cutoff_ev: config.primary_cutoff_ev,
891            barrier_default_ev: config.primary_surface_binding_ev,
892        }];
893        let mut species_z: Vec<u8> = vec![0];
894        let mut layers = Vec::with_capacity(n_materials);
895        for mi in 0..n_materials {
896            let m = geometry.material(mi);
897            let n = m.atom_number_density();
898            let mut elems = Vec::with_capacity(m.components().len());
899            for c in m.components() {
900                let z = c.z();
901                let s = match species_z.iter().skip(1).position(|&q| q == z) {
902                    Some(i) => i + 1,
903                    None => {
904                        let e_s = m.surface_binding_energy_ev(z).expect("validated");
905                        species.push(SpeciesData {
906                            ion: Ion::new(z).expect("element in table"),
907                            cutoff_ev: config.recoil_cutoff_ev,
908                            barrier_default_ev: e_s,
909                        });
910                        species_z.push(z);
911                        species.len() - 1
912                    }
913                };
914                elems.push(ElemData {
915                    z,
916                    species: s,
917                    fraction: c.atom_fraction(),
918                    e_d_ev: m.displacement_energy_ev(z).expect("validated"),
919                    e_b_ev: m.lattice_binding_energy_ev(z).expect("validated"),
920                });
921            }
922            layers.push(LayerData {
923                n,
924                ell: n.powf(-1.0 / 3.0),
925                p_const: 1.0 / (PI * n.powf(2.0 / 3.0)).sqrt(),
926                elems,
927                loss_coef: Vec::new(),
928            });
929        }
930        let mix = MixNonLocal(EquipartitionMix::new());
931        {
932            let model: &dyn ElectronicStopping = match config.electronic {
933                ElectronicLoss::NonLocal => stopping,
934                ElectronicLoss::EquipartitionLsOr => &mix,
935            };
936            for lay in &mut layers {
937                lay.loss_coef = species
938                    .iter()
939                    .map(|sp| {
940                        // Same order as the Bragg sum.
941                        let mut sum = 0.0;
942                        for e in &lay.elems {
943                            sum += e.fraction * model.sqrt_energy_coefficient(&sp.ion, e.z)?;
944                        }
945                        Some(sum)
946                    })
947                    .collect();
948            }
949        }
950        // Surface barriers by exit material; the beam always uses its own.
951        let mut barriers = Vec::with_capacity(n_materials * species.len());
952        for mi in 0..n_materials {
953            let m = geometry.material(mi);
954            for (si, &z) in species_z.iter().enumerate() {
955                barriers.push(if si == 0 {
956                    species[0].barrier_default_ev
957                } else {
958                    m.surface_binding_energy_ev(z)
959                        .unwrap_or(species[si].barrier_default_ev)
960                });
961            }
962        }
963
964        let tp = table.potential();
965        let ns = species.len();
966        let mut pairs = Vec::with_capacity(ns * ns);
967        for p in &species {
968            for t in &species {
969                let (z1, z2) = (f64::from(p.ion.z()), f64::from(t.ion.z()));
970                let (m1, m2) = (p.ion.mass_amu(), t.ion.mass_amu());
971                let pot = Potential::new(tp.screening, z1, z2).with_length(tp.length);
972                pairs.push(PairData {
973                    a: pot.screening_length(),
974                    eps_per_ev: pot.reduced_energy(Potential::cm_energy(J_PER_EV, m1, m2)),
975                    gamma: kinematics::gamma(m1, m2),
976                    mu: m1 / m2,
977                });
978            }
979        }
980        let beta_max = match config.mean_free_path {
981            MeanFreePath::Constant => None,
982            MeanFreePath::EnergyDependent { min_cm_angle_rad } => {
983                Some(BetaMaxGrid::build(table, min_cm_angle_rad))
984            }
985        };
986        Ok(Self {
987            beam,
988            geometry,
989            entry_pos,
990            entry_region,
991            config,
992            stopping,
993            mix,
994            table,
995            screening: tp.screening,
996            species,
997            layers,
998            barriers,
999            pairs,
1000            beta_max,
1001            crystals: Vec::new(),
1002            region_crystal: Vec::new(),
1003            divergence: Divergence::None,
1004        })
1005    }
1006
1007    /// Give the beam an angular spread about its nominal direction (opt-in;
1008    /// the default is [`Divergence::None`], which changes nothing).
1009    ///
1010    /// Each primary's initial direction is the nominal
1011    /// [`Beam::direction`] deflected by [`Divergence::sample_direction`] and
1012    /// *conditioned on pointing into the target* (positive depth component):
1013    /// a deflected direction with `dir[0] <= 0` is rejected and redrawn, up
1014    /// to [`MAX_DIVERGENCE_ATTEMPTS`] attempts, after which the history
1015    /// fails with [`BcaError::BeamDivergence`]. The draws come from a copy of
1016    /// the history's stream positioned at word `2^65`
1017    /// ([`DIVERGENCE_STREAM_WORD`]), a segment disjoint from the transport
1018    /// draws, the thermal segment (`2^66`) and the crystal-shift segment
1019    /// (`2^67`), so the spread changes no other draw of the history. The
1020    /// spread is a property of the beam: it applies to the primary from the
1021    /// entry point, including while it crosses an amorphous layer; recoils
1022    /// keep their collision-generated directions. The nominal orientation
1023    /// convention (and the crystal orientation built from it) is unchanged.
1024    ///
1025    /// Errors with [`BcaError::InvalidBeam`] when the parameters fail
1026    /// [`Divergence::validate`].
1027    pub fn with_divergence(mut self, divergence: Divergence) -> Result<Self, BcaError> {
1028        divergence
1029            .validate()
1030            .map_err(|e| BcaError::InvalidBeam(format!("divergence: {e}")))?;
1031        self.divergence = divergence;
1032        Ok(self)
1033    }
1034
1035    /// Metadata of the beam divergence; `None` without one.
1036    pub fn divergence_metadata(&self) -> Option<DivergenceMetadata> {
1037        DivergenceMetadata::new(&self.divergence)
1038    }
1039
1040    /// The beam divergence ([`Divergence::None`] unless set by
1041    /// [`Bca::with_divergence`]).
1042    pub fn divergence(&self) -> Divergence {
1043        self.divergence
1044    }
1045
1046    /// The initial direction of primary `index` of this engine's run: what
1047    /// [`Bca::run`] gives that history (the stream is
1048    /// [`crate::rng::stream`]`(seed, index)`). The nominal direction without
1049    /// divergence; otherwise the inward-conditioned divergent sample.
1050    pub fn primary_direction(&self, index: u64) -> Result<[f64; 3], BcaError> {
1051        self.initial_direction(&crate::rng::stream(self.config.seed, index))
1052            .map_err(|e| match e {
1053                HistoryError::BeamDivergence { attempts } => {
1054                    BcaError::BeamDivergence { index, attempts }
1055                }
1056                HistoryError::Stopping(source) => BcaError::Stopping { index, source },
1057            })
1058    }
1059
1060    /// The direction of primary history `rng` starts with: the nominal beam
1061    /// direction, or the inward-conditioned divergent sample (see
1062    /// [`Bca::with_divergence`]).
1063    fn initial_direction(&self, rng: &ParticleRng) -> Result<[f64; 3], HistoryError> {
1064        let central = self.beam.direction();
1065        if matches!(self.divergence, Divergence::None) {
1066            return Ok(central);
1067        }
1068        let mut brng = rng.clone();
1069        brng.set_word_pos(DIVERGENCE_STREAM_WORD);
1070        sample_inward(&self.divergence, central, &mut brng)
1071    }
1072
1073    /// Start primaries at `pos` instead of the geometry's default entry point.
1074    /// `pos` must lie in the target (for a voxel grid this selects the
1075    /// incident voxel explicitly; on a shared face the voxel with the higher
1076    /// index owns the point).
1077    pub fn with_entry_point(mut self, pos: [f64; 3]) -> Result<Self, BcaError> {
1078        self.entry_region = self.geometry.locate(pos).ok_or_else(|| {
1079            BcaError::InvalidBeam(format!("entry point {pos:?} is outside the target"))
1080        })?;
1081        self.entry_pos = pos;
1082        Ok(self)
1083    }
1084
1085    /// The beam.
1086    pub fn beam(&self) -> &Beam {
1087        &self.beam
1088    }
1089
1090    /// The configuration.
1091    pub fn config(&self) -> &BcaConfig {
1092        &self.config
1093    }
1094
1095    /// Atomic numbers of the particle species, by species index (index 0 is
1096    /// the beam).
1097    pub fn species_z(&self) -> Vec<u8> {
1098        self.species.iter().map(|s| s.ion.z()).collect()
1099    }
1100
1101    /// Run all `beam.count` histories in parallel on the current rayon pool
1102    /// and return the merged tally. Bit-identical at any thread count when the
1103    /// tally's `merge` is order-independent of scheduling (it is called in
1104    /// chunk order). If a history fails, the error of the lowest failing
1105    /// index is returned.
1106    pub fn run<T, N>(&self, new_tally: N) -> Result<T, BcaError>
1107    where
1108        T: BcaTally,
1109        N: Fn() -> T + Sync,
1110    {
1111        self.run_range(0, self.beam.count, new_tally)
1112    }
1113
1114    /// [`Bca::run`] for the global primary indices
1115    /// `first_index..first_index + count` (instead of `0..beam.count`):
1116    /// primary `first_index + k` uses the stream
1117    /// `stream(seed, first_index + k)` and reports that index to the tally
1118    /// hooks. A run cut into consecutive ranges draws exactly the histories of
1119    /// one longer run; this is what lets the fluence steps of a dynamic run
1120    /// continue the global stream. `run` is `run_range(0, beam.count, ..)`.
1121    pub fn run_range<T, N>(&self, first_index: u64, count: u64, new_tally: N) -> Result<T, BcaError>
1122    where
1123        T: BcaTally,
1124        N: Fn() -> T + Sync,
1125    {
1126        struct Acc<T> {
1127            tally: T,
1128            err: Option<(u64, HistoryError)>,
1129            buffers: HistoryBuffers,
1130        }
1131        let acc = run_particles_range(
1132            self.config.seed,
1133            first_index,
1134            count,
1135            self.config.chunk_size,
1136            || Acc {
1137                tally: new_tally(),
1138                err: None,
1139                buffers: HistoryBuffers::new(),
1140            },
1141            |acc: &mut Acc<T>, rng, i| {
1142                if acc.err.is_some() {
1143                    return;
1144                }
1145                if let Err(e) = self.history_in(&mut acc.buffers, &mut acc.tally, rng, i) {
1146                    acc.err = Some((i, e));
1147                }
1148            },
1149            |a, b| {
1150                if a.err.is_none() {
1151                    a.err = b.err;
1152                }
1153                a.tally.merge(b.tally);
1154            },
1155        );
1156        match acc.err {
1157            Some((index, HistoryError::Stopping(source))) => {
1158                Err(BcaError::Stopping { index, source })
1159            }
1160            Some((index, HistoryError::BeamDivergence { attempts })) => {
1161                Err(BcaError::BeamDivergence { index, attempts })
1162            }
1163            None => Ok(acc.tally),
1164        }
1165    }
1166
1167    /// Simulate primary `index` and its cascade with the given stream (use
1168    /// [`crate::rng::stream`]`(seed, index)` for results consistent with
1169    /// [`Bca::run`]). Returns the history's energy budget.
1170    pub fn history<T: BcaTally>(
1171        &self,
1172        tally: &mut T,
1173        rng: &mut ParticleRng,
1174        index: u64,
1175    ) -> Result<EnergyBudget, HistoryError> {
1176        self.history_in(&mut HistoryBuffers::new(), tally, rng, index)
1177    }
1178
1179    /// [`Bca::history`] in caller-owned working memory. Identical results;
1180    /// reusing `buffers` across histories removes the per-history
1181    /// allocations ([`Bca::run`] does this per worker chunk).
1182    pub fn history_in<T: BcaTally>(
1183        &self,
1184        buffers: &mut HistoryBuffers,
1185        tally: &mut T,
1186        rng: &mut ParticleRng,
1187        index: u64,
1188    ) -> Result<EnergyBudget, HistoryError> {
1189        tally.begin_history(index);
1190        let mut budget = EnergyBudget {
1191            incident: self.beam.energy_ev,
1192            ..EnergyBudget::default()
1193        };
1194        let HistoryBuffers {
1195            pending,
1196            scratch,
1197            shifts,
1198            thermal_rng,
1199        } = buffers;
1200        // Each crystal's lattice is translated by a random vector for every
1201        // history, uniformly over the rectangular cell that tiles it
1202        // (`Lattice::orthogonal_cell`), so the beam samples all positions of
1203        // the unit cell. The shifts come from a copy of this history's stream
1204        // positioned at word 2^67, half the ChaCha period: a segment the
1205        // transport draws below can never reach. So the transport stream is the same with or
1206        // without crystals, and a particle that never enters a crystal
1207        // region sees exactly the draws of the amorphous engine
1208        // (`tests/crystal_amorphous_identity.rs`).
1209        shifts.clear();
1210        if !self.crystals.is_empty() {
1211            let mut srng = rng.clone();
1212            srng.set_word_pos(1u128 << 67);
1213            for cr in &self.crystals {
1214                let e = cr.cell_edges();
1215                shifts.push([
1216                    e[0] * uniform(&mut srng),
1217                    e[1] * uniform(&mut srng),
1218                    e[2] * uniform(&mut srng),
1219                ]);
1220            }
1221        }
1222        // Thermal displacements of lattice sites come from a third copy of
1223        // the stream, at word 2^66: like the shifts, a segment neither the
1224        // transport draws nor the shifts reach, so turning vibration on
1225        // changes no other draw of the history.
1226        *thermal_rng = if self.crystals.iter().any(|c| c.is_thermal()) {
1227            let mut trng = rng.clone();
1228            trng.set_word_pos(1u128 << 66);
1229            Some(trng)
1230        } else {
1231            None
1232        };
1233        pending.clear();
1234        let dir = self.initial_direction(rng)?;
1235        pending.push(Particle {
1236            species: 0,
1237            z: self.beam.ion.z(),
1238            mass_amu: self.beam.ion.mass_amu(),
1239            energy_ev: self.beam.energy_ev,
1240            pos: self.entry_pos,
1241            dir,
1242            layer: self.entry_region,
1243            origin_layer: self.entry_region,
1244            generation: 0,
1245        });
1246        while let Some(p) = pending.pop() {
1247            self.transport(
1248                p,
1249                rng,
1250                pending,
1251                &mut budget,
1252                tally,
1253                scratch,
1254                shifts,
1255                thermal_rng,
1256            )?;
1257        }
1258        tally.end_history(index, &budget);
1259        Ok(budget)
1260    }
1261
1262    /// Per-material data of the material filling `region`.
1263    fn lay(&self, region: usize) -> &LayerData {
1264        &self.layers[self.geometry.material_index(region)]
1265    }
1266
1267    /// Number of mean free paths for a new flight.
1268    fn draw_tau(&self, rng: &mut ParticleRng, first_primary_flight: bool) -> f64 {
1269        match self.config.mean_free_path {
1270            MeanFreePath::Constant if first_primary_flight => uniform(rng),
1271            MeanFreePath::Constant => 1.0,
1272            MeanFreePath::EnergyDependent { .. } => -(1.0 - uniform(rng)).ln(),
1273        }
1274    }
1275
1276    /// Mean free path at the particle's energy in its layer; fills
1277    /// `scratch` with the per-element `p_max` and cumulative partner weights.
1278    fn flight(&self, p: &Particle, lay: &LayerData, scratch: &mut Scratch) -> f64 {
1279        scratch.p_max.clear();
1280        scratch.cum.clear();
1281        let mut acc = 0.0;
1282        match &self.beta_max {
1283            None => {
1284                for e in &lay.elems {
1285                    acc += e.fraction;
1286                    scratch.p_max.push(lay.p_const);
1287                    scratch.cum.push(acc);
1288                }
1289                lay.ell
1290            }
1291            Some(grid) => {
1292                let ns = self.species.len();
1293                for e in &lay.elems {
1294                    let pair = &self.pairs[p.species * ns + e.species];
1295                    let pm = (grid.beta(p.energy_ev * pair.eps_per_ev) * pair.a).min(lay.p_const);
1296                    acc += e.fraction * pm * pm;
1297                    scratch.p_max.push(pm);
1298                    scratch.cum.push(acc);
1299                }
1300                1.0 / (lay.n * PI * acc)
1301            }
1302        }
1303    }
1304
1305    fn electronic_nonlocal<T: BcaTally>(
1306        &self,
1307        p: &mut Particle,
1308        from: [f64; 3],
1309        len: f64,
1310        budget: &mut EnergyBudget,
1311        tally: &mut T,
1312    ) -> Result<(), StoppingError> {
1313        if len <= 0.0 {
1314            return Ok(());
1315        }
1316        let mi = self.geometry.material_index(p.layer);
1317        let lay = &self.layers[mi];
1318        let s = match lay.loss_coef[p.species] {
1319            // `c sqrt(E)`; the `E > 0` test keeps a non-physical energy on the
1320            // general path, which reports it.
1321            Some(c) if p.energy_ev > 0.0 && p.energy_ev.is_finite() => c * p.energy_ev.sqrt(),
1322            _ => {
1323                let model: &dyn ElectronicStopping = match self.config.electronic {
1324                    ElectronicLoss::NonLocal => self.stopping,
1325                    ElectronicLoss::EquipartitionLsOr => &self.mix,
1326                };
1327                let material = self.geometry.material(mi);
1328                let ion = &self.species[p.species].ion;
1329                bragg_cross_section_per_atom(model, &NoCorrection, ion, material, p.energy_ev)?
1330            }
1331        };
1332        let de = (lay.n * s * len / J_PER_EV).min(p.energy_ev);
1333        if de > 0.0 {
1334            p.energy_ev -= de;
1335            budget.electronic_nonlocal += de;
1336            tally.electronic(p, from, ElectronicChannel::NonLocal, de);
1337        }
1338        Ok(())
1339    }
1340
1341    fn advance(p: &mut Particle, len: f64) {
1342        for k in 0..3 {
1343            p.pos[k] += len * p.dir[k];
1344        }
1345    }
1346
1347    /// Follow one particle until it stops or escapes; push its recoils.
1348    #[allow(clippy::too_many_arguments)]
1349    fn transport<T: BcaTally>(
1350        &self,
1351        mut p: Particle,
1352        rng: &mut ParticleRng,
1353        pending: &mut Vec<Particle>,
1354        budget: &mut EnergyBudget,
1355        tally: &mut T,
1356        scratch: &mut Scratch,
1357        shifts: &[[f64; 3]],
1358        thermal_rng: &mut Option<ParticleRng>,
1359    ) -> Result<(), StoppingError> {
1360        let cutoff = self.species[p.species].cutoff_ev;
1361        let mut tau = self.draw_tau(rng, p.is_primary());
1362        scratch.last.clear();
1363        scratch.new_line();
1364        loop {
1365            if p.energy_ev < cutoff {
1366                budget.rest += p.energy_ev;
1367                tally.stopped(&p);
1368                return Ok(());
1369            }
1370            if let Some(ci) = self.crystal_at(p.layer) {
1371                if self.crystal_step(
1372                    ci,
1373                    shifts[ci],
1374                    &mut p,
1375                    pending,
1376                    budget,
1377                    tally,
1378                    scratch,
1379                    thermal_rng,
1380                )? {
1381                    return Ok(());
1382                }
1383                if self.crystal_at(p.layer).is_none() {
1384                    // Left the crystal for an amorphous region: a fresh flight.
1385                    tau = self.draw_tau(rng, false);
1386                    scratch.last.clear();
1387                    scratch.new_line();
1388                }
1389                continue;
1390            }
1391            let lay = self.lay(p.layer);
1392            let lambda = self.flight(&p, lay, scratch);
1393            let s = tau * lambda;
1394
1395            let ex = match self.geometry.flight(p.layer, p.pos, p.dir, s) {
1396                Flight::Event(ex) => ex,
1397                Flight::Clear { region } => {
1398                    // The whole flight is in one material, but it may have
1399                    // crossed same-material faces (voxels of one block):
1400                    // take the region it ends in before colliding, so the
1401                    // next flight, the tallies and any recoil start from the
1402                    // right one.
1403                    let from = p.pos;
1404                    Self::advance(&mut p, s);
1405                    p.layer = region;
1406                    self.electronic_nonlocal(&mut p, from, s, budget, tally)?;
1407                    if p.energy_ev >= cutoff {
1408                        self.collide(&mut p, rng, scratch, pending, budget, tally)?;
1409                        tau = self.draw_tau(rng, false);
1410                    }
1411                    continue;
1412                }
1413            };
1414            // Truncate at the event; keep the unused part of the flight.
1415            let d_b = ex.distance;
1416            let from = p.pos;
1417            p.pos = ex.at;
1418            self.electronic_nonlocal(&mut p, from, d_b, budget, tally)?;
1419            tau = (tau - d_b / lambda).max(0.0);
1420            if p.energy_ev < cutoff {
1421                continue;
1422            }
1423            if self.apply_event(&mut p, ex.outcome, budget, tally) {
1424                return Ok(());
1425            }
1426        }
1427    }
1428
1429    /// Apply the outcome of a geometry event to `p`, which is already at the
1430    /// event point with the electronic loss of the segment taken: enter the
1431    /// next region, or reach a surface and escape (returns `true`, the
1432    /// particle is finished) or reflect specularly (returns `false`).
1433    fn apply_event<T: BcaTally>(
1434        &self,
1435        p: &mut Particle,
1436        outcome: ExitOutcome,
1437        budget: &mut EnergyBudget,
1438        tally: &mut T,
1439    ) -> bool {
1440        match outcome {
1441            ExitOutcome::Enter { region, pos } => {
1442                p.layer = region;
1443                p.pos = pos;
1444            }
1445            ExitOutcome::Escape { face, normal } => {
1446                let mi = self.geometry.material_index(p.layer);
1447                let e_s = self.barriers[mi * self.species.len() + p.species];
1448                match kinematics::refract_out_normal(p.energy_ev, p.dir, normal, e_s) {
1449                    Some((e_out, dir_out)) => {
1450                        budget.surface_barrier += p.energy_ev - e_out;
1451                        p.energy_ev = e_out;
1452                        p.dir = dir_out;
1453                        match (face, p.is_primary()) {
1454                            (Face::Front, true) => budget.backscattered += e_out,
1455                            (Face::Front, false) => budget.sputtered += e_out,
1456                            (Face::Back, _) => budget.transmitted += e_out,
1457                            (Face::Side, _) => budget.lateral += e_out,
1458                        }
1459                        tally.escaped(p, face);
1460                        return true;
1461                    }
1462                    None => {
1463                        // Specular reflection about the face normal.
1464                        let dn = p.dir[0] * normal[0] + p.dir[1] * normal[1] + p.dir[2] * normal[2];
1465                        for (d, n) in p.dir.iter_mut().zip(normal) {
1466                            *d -= 2.0 * dn * n;
1467                        }
1468                    }
1469                }
1470            }
1471        }
1472        false
1473    }
1474
1475    /// `tan(theta / 2)` for the centre-of-mass angle at `(eps, beta)`.
1476    fn half_angle_tan(&self, eps: f64, beta: f64) -> f64 {
1477        // Outside the tabulated energy range fall back to direct quadrature
1478        // (deterministic, just slower).
1479        self.table
1480            .half_angle_tan(eps, beta)
1481            .unwrap_or_else(|| (0.5 * theta_quadrature(self.screening, eps, beta)).tan())
1482    }
1483
1484    /// Partner element index, by stoichiometry (weighted by `p_max^2` for
1485    /// the energy-dependent free path).
1486    fn draw_partner(rng: &mut ParticleRng, scratch: &Scratch) -> usize {
1487        let total = *scratch.cum.last().expect("layer has elements");
1488        let u = uniform(rng) * total;
1489        scratch
1490            .cum
1491            .iter()
1492            .position(|&c| u < c)
1493            .unwrap_or(scratch.cum.len() - 1)
1494    }
1495
1496    /// One binary collision of `p`, at its current energy and direction,
1497    /// with element `j` of its layer at impact parameter `b` and azimuth
1498    /// azimuth `(sin, cos)` = `azimuth`. Deflects `p`, removes the transfer `T`
1499    /// and, under [`ElectronicLoss::EquipartitionLsOr`], the local electronic
1500    /// loss (clamped to the energy left), and returns `(T, incoming
1501    /// direction, recoil lab angle as (sin, cos))`.
1502    ///
1503    /// The angles are carried as sines and cosines: with `t = tan(theta/2)`
1504    /// from the table, `sin(theta/2) = t / sqrt(1 + t^2)` and `cos(theta/2) =
1505    /// 1 / sqrt(1 + t^2)` give the energy transfer, the projectile deflection
1506    /// ([`kinematics::lab_projectile_sc`]) and the recoil deflection
1507    /// `phi = (pi - theta) / 2`, `(sin, cos) = (cos(theta/2), sin(theta/2))`
1508    /// with one square root and no `atan`, `atan2`, `sin` or `cos`.
1509    #[allow(clippy::too_many_arguments)]
1510    fn binary<T: BcaTally>(
1511        &self,
1512        p: &mut Particle,
1513        j: usize,
1514        b: f64,
1515        azimuth: (f64, f64),
1516        budget: &mut EnergyBudget,
1517        tally: &mut T,
1518    ) -> Result<BinaryOutcome, StoppingError> {
1519        tally.partner(p, b, 1);
1520        let elem = &self.lay(p.layer).elems[j];
1521        let pair = self.pairs[p.species * self.species.len() + elem.species];
1522        let e0 = p.energy_ev;
1523        let eps = e0 * pair.eps_per_ev;
1524        let beta = b / pair.a;
1525        let tan_half = self.half_angle_tan(eps, beta);
1526        let c = 1.0 / (1.0 + tan_half * tan_half).sqrt();
1527        let s = tan_half * c;
1528        let t = (pair.gamma * e0 * s * s).min(e0);
1529        let psi = kinematics::lab_projectile_sc(s, c, pair.mu);
1530        let incoming = p.dir;
1531        p.dir = kinematics::rotate_sc(incoming, psi, azimuth);
1532        p.energy_ev -= t;
1533
1534        if self.config.electronic == ElectronicLoss::EquipartitionLsOr {
1535            let r_min = closest_approach(self.screening, eps, beta) * pair.a;
1536            let ion = &self.species[p.species].ion;
1537            let q = (self.mix.0.local_loss(ion, elem.z, e0, r_min)? / J_PER_EV).min(p.energy_ev);
1538            if q > 0.0 {
1539                p.energy_ev -= q;
1540                budget.electronic_local += q;
1541                tally.electronic(p, p.pos, ElectronicChannel::Local, q);
1542            }
1543        }
1544        Ok((t, incoming, (c, s)))
1545    }
1546
1547    /// Whether the partner of a weak collision of `p` at impact parameter
1548    /// `b` and azimuth `azimuth` lies in front of the target's front surface,
1549    /// in vacuum. The partner sits at distance `b` from the path on the side
1550    /// opposite to the deflection, where a hard collision sends its recoil:
1551    /// at `b rotate(dir, pi/2, azimuth + pi)` from the collision site.
1552    fn partner_beyond_surface(&self, p: &Particle, b: f64, azimuth: (f64, f64)) -> bool {
1553        // sin and cos of `azimuth + pi` are the negatives.
1554        let offset = kinematics::rotate_sc(p.dir, (1.0, 0.0), (-azimuth.0, -azimuth.1));
1555        self.geometry.in_vacuum([
1556            p.pos[0] + b * offset[0],
1557            p.pos[1] + b * offset[1],
1558            p.pos[2] + b * offset[2],
1559        ])
1560    }
1561
1562    /// One collision step: the `weak_collisions` simultaneous weak
1563    /// collisions, then the hard collision (see the module docs).
1564    fn collide<T: BcaTally>(
1565        &self,
1566        p: &mut Particle,
1567        rng: &mut ParticleRng,
1568        scratch: &Scratch,
1569        pending: &mut Vec<Particle>,
1570        budget: &mut EnergyBudget,
1571        tally: &mut T,
1572    ) -> Result<(), StoppingError> {
1573        // Weak collisions (Moller and Eckstein, IPP 9/64 (1988), p. 14,
1574        // eq. (26)): the k-th partner sits in the k-th annulus,
1575        // p = p_max sqrt(k + R), with its own species and azimuth. They come
1576        // before the hard collision, the direction is updated after each, and
1577        // only the hard collision makes a recoil (p. 26); a weak transfer
1578        // stays in the lattice at the site. A weak partner that would lie
1579        // beyond the front surface is skipped, after its draws are made (the
1580        // "Target beyond Surf.?" test inside the weak-collision loop of the
1581        // flow charts, Figs. 7 and 8, pp. 29-30). Each collision is evaluated
1582        // at the energy left after the previous one, a deliberate deviation
1583        // from TRIDYN (module docs).
1584        for k in 1..=self.config.weak_collisions {
1585            let j = Self::draw_partner(rng, scratch);
1586            let b = scratch.p_max[j] * (f64::from(k) + uniform(rng)).sqrt();
1587            let azimuth = kinematics::sin_cos_turns(uniform(rng));
1588            if self.partner_beyond_surface(p, b, azimuth) {
1589                continue;
1590            }
1591            let (t, _, _) = self.binary(p, j, b, azimuth, budget, tally)?;
1592            if t > 0.0 {
1593                budget.lattice += t;
1594                tally.lattice(p.pos, p.layer, LatticeDeposit::Weak, t);
1595            }
1596        }
1597
1598        // The hard collision: impact parameter uniform over the disc of
1599        // radius p_max, p = p_max sqrt(R). Its partner is not tested against
1600        // the surface, unlike in TRIDYN: that would change the engine
1601        // without weak collisions (module docs).
1602        let j = Self::draw_partner(rng, scratch);
1603        let b = scratch.p_max[j] * uniform(rng).sqrt();
1604        let azimuth = kinematics::sin_cos_turns(uniform(rng));
1605        let (t, incoming, phi) = self.binary(p, j, b, azimuth, budget, tally)?;
1606
1607        self.emit_recoil(
1608            p.generation,
1609            p.pos,
1610            p.layer,
1611            j,
1612            t,
1613            || kinematics::rotate_sc(incoming, phi, (-azimuth.0, -azimuth.1)),
1614            pending,
1615            budget,
1616            tally,
1617        );
1618        Ok(())
1619    }
1620
1621    /// Dispose of the energy `t` transferred to element `j` of the material
1622    /// of `layer` at `pos`: a displaced atom (if `t` exceeds both `E_d` and
1623    /// `E_b`; it starts with `t - E_b` along `recoil_dir()` and is followed
1624    /// or stopped as configured), otherwise heat in the lattice. The same
1625    /// criterion for the amorphous and the crystal flight model.
1626    #[allow(clippy::too_many_arguments)]
1627    fn emit_recoil<T: BcaTally>(
1628        &self,
1629        parent_generation: u32,
1630        pos: [f64; 3],
1631        layer: usize,
1632        j: usize,
1633        t: f64,
1634        recoil_dir: impl FnOnce() -> [f64; 3],
1635        pending: &mut Vec<Particle>,
1636        budget: &mut EnergyBudget,
1637        tally: &mut T,
1638    ) {
1639        let elem = &self.lay(layer).elems[j];
1640        if t > elem.e_d_ev && t > elem.e_b_ev {
1641            let e_r = t - elem.e_b_ev;
1642            if elem.e_b_ev > 0.0 {
1643                budget.lattice += elem.e_b_ev;
1644                tally.lattice(pos, layer, LatticeDeposit::Binding, elem.e_b_ev);
1645            }
1646            let sp = &self.species[elem.species];
1647            let r = Particle {
1648                species: elem.species,
1649                z: elem.z,
1650                mass_amu: sp.ion.mass_amu(),
1651                energy_ev: e_r,
1652                pos,
1653                dir: recoil_dir(),
1654                layer,
1655                origin_layer: layer,
1656                generation: parent_generation + 1,
1657            };
1658            tally.recoil(&r);
1659            if self.config.follow_recoils && e_r >= sp.cutoff_ev {
1660                pending.push(r);
1661            } else {
1662                budget.rest += e_r;
1663                tally.stopped(&r);
1664            }
1665        } else if t > 0.0 {
1666            budget.lattice += t;
1667            tally.lattice(pos, layer, LatticeDeposit::Subthreshold, t);
1668        }
1669    }
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674    #[test]
1675    fn inward_sampling_is_bounded_and_cannot_hang() {
1676        use crate::ion::crystal::Divergence;
1677        // A zero-width spread about an outward direction never yields an
1678        // inward sample: the bound turns the loop into an error.
1679        let mut rng = crate::rng::stream(1, 0);
1680        let r = sample_inward(
1681            &Divergence::Gaussian { sigma_rad: 0.0 },
1682            [-1.0, 0.0, 0.0],
1683            &mut rng,
1684        );
1685        assert_eq!(
1686            r,
1687            Err(HistoryError::BeamDivergence {
1688                attempts: MAX_DIVERGENCE_ATTEMPTS
1689            })
1690        );
1691    }
1692
1693    use super::*;
1694    use crate::geometry::Stack;
1695    use crate::ion::scattering::TableSpec;
1696    use crate::material::Material;
1697
1698    fn si() -> Material {
1699        let mut m = Material::from_atom_fractions(&[(14, 1.0)], None).unwrap();
1700        m.set_displacement_energy_ev(14, 15.0).unwrap();
1701        m
1702    }
1703
1704    fn small_table() -> ScatteringTable {
1705        let pot = Potential::new(Screening::ZblUniversal, 14.0, 14.0);
1706        ScatteringTable::build(
1707            &pot,
1708            &TableSpec {
1709                eps_min: 1e-6,
1710                eps_max: 1e3,
1711                beta_min: 1e-5,
1712                beta_max: 1e2,
1713                per_decade: 8,
1714            },
1715        )
1716    }
1717
1718    #[test]
1719    fn beam_direction_is_unit_and_inward() {
1720        let b = Beam {
1721            ion: Ion::new(5).unwrap(),
1722            energy_ev: 1e3,
1723            polar_rad: 0.7,
1724            azimuth_rad: 2.0,
1725            count: 1,
1726        };
1727        let d = b.direction();
1728        assert!((d[0] * d[0] + d[1] * d[1] + d[2] * d[2] - 1.0).abs() < 1e-15);
1729        assert!((d[0] - 0.7f64.cos()).abs() < 1e-15);
1730    }
1731
1732    #[test]
1733    fn constant_convention_sweeps_one_atom_per_flight() {
1734        let stack = Stack::semi_infinite(si());
1735        let table = small_table();
1736        let ls = crate::ion::stopping::lindhard_scharff::LindhardScharff::new();
1737        let bca = Bca::new(
1738            Beam::normal(Ion::new(5).unwrap(), 1e3, 1),
1739            &stack,
1740            BcaConfig::new(5.0, 2.0),
1741            &ls,
1742            &table,
1743        )
1744        .unwrap();
1745        let l = &bca.layers[0];
1746        assert!((l.n * PI * l.p_const * l.p_const * l.ell - 1.0).abs() < 1e-14);
1747    }
1748
1749    #[test]
1750    fn beta_max_grid_inverts_the_table() {
1751        let table = small_table();
1752        let th = 0.02;
1753        let g = BetaMaxGrid::build(&table, th);
1754        for &eps in &[1e-3, 0.1, 10.0, 300.0] {
1755            let b = g.beta(eps);
1756            let got = table.theta(eps, b).unwrap();
1757            assert!((got / th - 1.0).abs() < 0.05, "eps={eps}: {got}");
1758        }
1759    }
1760
1761    #[test]
1762    fn weak_partner_surface_test() {
1763        let stack = Stack::semi_infinite(si());
1764        let table = small_table();
1765        let ls = crate::ion::stopping::lindhard_scharff::LindhardScharff::new();
1766        let bca = Bca::new(
1767            Beam::normal(Ion::new(14).unwrap(), 1e3, 1),
1768            &stack,
1769            BcaConfig::new(5.0, 2.0),
1770            &ls,
1771            &table,
1772        )
1773        .unwrap();
1774        let b = 2e-10;
1775        let at = |x: f64, dir: [f64; 3]| Particle {
1776            species: 0,
1777            z: 14,
1778            mass_amu: 28.0,
1779            energy_ev: 10.0,
1780            pos: [x, 0.0, 0.0],
1781            dir,
1782            layer: 0,
1783            origin_layer: 0,
1784            generation: 0,
1785        };
1786        let n = 3600;
1787        let azimuths = (0..n).map(|i| 2.0 * PI * (i as f64 + 0.5) / n as f64);
1788        let beyond = |p: &Particle| {
1789            azimuths
1790                .clone()
1791                .filter(|&az| bca.partner_beyond_surface(p, b, az.sin_cos()))
1792                .count() as f64
1793                / n as f64
1794        };
1795        // Moving parallel to the surface: on the surface half of the
1796        // partners are in vacuum, at depth b/2 a third (offset_x < -1/2),
1797        // at depth b none. Moving along the normal, none.
1798        let par = [0.0, 1.0, 0.0];
1799        assert!((beyond(&at(0.0, par)) - 0.5).abs() < 1e-3);
1800        assert!((beyond(&at(0.5 * b, par)) - 1.0 / 3.0).abs() < 1e-3);
1801        assert_eq!(beyond(&at(1.0001 * b, par)), 0.0);
1802        assert_eq!(beyond(&at(0.0, [1.0, 0.0, 0.0])), 0.0);
1803        // The partner is on the side opposite to the deflection.
1804        let p = at(0.0, par);
1805        for az in azimuths {
1806            let deflected = kinematics::rotate(p.dir, 0.3, az);
1807            if deflected[0].abs() > 1e-6 {
1808                assert_eq!(
1809                    bca.partner_beyond_surface(&p, b, az.sin_cos()),
1810                    deflected[0] > 0.0
1811                );
1812            }
1813        }
1814    }
1815
1816    #[test]
1817    fn rejects_bad_setup() {
1818        let table = small_table();
1819        let ls = crate::ion::stopping::lindhard_scharff::LindhardScharff::new();
1820        let si = Stack::semi_infinite(si());
1821        let ion = Ion::new(5).unwrap();
1822        let ok = BcaConfig::new(5.0, 2.0);
1823        let mk = |beam: Beam, cfg: BcaConfig| Bca::new(beam, &si, cfg, &ls, &table).map(|_| ());
1824        assert!(matches!(
1825            mk(Beam::normal(ion, 0.0, 1), ok),
1826            Err(BcaError::InvalidBeam(_))
1827        ));
1828        let mut b = Beam::normal(ion, 1e3, 1);
1829        b.polar_rad = 0.5 * PI;
1830        assert!(matches!(mk(b, ok), Err(BcaError::InvalidBeam(_))));
1831        assert!(matches!(
1832            mk(Beam::normal(ion, 1e3, 1), BcaConfig::new(0.0, 2.0)),
1833            Err(BcaError::InvalidConfig(_))
1834        ));
1835        let mut c = ok;
1836        c.mean_free_path = MeanFreePath::EnergyDependent {
1837            min_cm_angle_rad: 0.0,
1838        };
1839        assert!(matches!(
1840            mk(Beam::normal(ion, 1e3, 1), c),
1841            Err(BcaError::InvalidConfig(_))
1842        ));
1843        assert!(mk(Beam::normal(ion, 1e3, 1), ok).is_ok());
1844        let mut c = ok;
1845        c.weak_collisions = MAX_WEAK_COLLISIONS;
1846        assert!(mk(Beam::normal(ion, 1e3, 1), c).is_ok());
1847        c.weak_collisions = MAX_WEAK_COLLISIONS + 1;
1848        assert!(matches!(
1849            mk(Beam::normal(ion, 1e3, 1), c),
1850            Err(BcaError::InvalidConfig(_))
1851        ));
1852        c.weak_collisions = 1;
1853        c.mean_free_path = MeanFreePath::EnergyDependent {
1854            min_cm_angle_rad: 0.01,
1855        };
1856        assert!(matches!(
1857            mk(Beam::normal(ion, 1e3, 1), c),
1858            Err(BcaError::InvalidConfig(_))
1859        ));
1860    }
1861}