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}