Skip to main content

lindhard/ion/dynamic/
grid2d.rs

1//! A two-dimensional (depth–lateral) dynamic target: a laterally finite beam
2//! on a surface that recedes non-uniformly with fluence.
3//!
4//! [`DynamicRun2d`] is the 2-D counterpart of [`DynamicRun`](super::DynamicRun).
5//! The target is a row of **columns** of lateral width `h`; each column is a
6//! complete 1-D [`CompositionGrid`] (slabs, areal inventories, a
7//! [`Relaxation`](super::Relaxation) convention, an optional immutable
8//! substrate), and the **surface height profile** is one number per column,
9//! the cumulative recession `R_j` of its front (m, positive into the target;
10//! negative where redeposition has built the surface up). Nothing is
11//! three-dimensional: the target is uniform along the third axis `z`, and a
12//! column stands for the target between lateral positions `j h` and
13//! `(j+1) h`. Inventories are atoms/m² of column area, so a column behaves
14//! exactly as a 1-D run under the beam it receives, and with identical columns
15//! under a uniform beam the run reduces to the 1-D result (see "Uniform-beam
16//! limit"). Coordinates follow the engine: `x` is the depth, `y` the lateral
17//! coordinate (`[0, n h]`), `z` the invariant axis. (The `x–z` plane of the
18//! issue that introduced this module is the `x–y` plane here.)
19//!
20//! Only published descriptions of the physics were used: sputter erosion and
21//! the dynamic target follow the 1-D module ([`DynamicRun`](super::DynamicRun),
22//! TRIDYN, Moller and Eckstein, Nucl. Instrum. Methods B 2 (1984) 814, and
23//! SDTrimSP, Mutzke et al., IPP report 2019-02). The faceted height-field
24//! geometry, the redeposition rule and the local-angle rule below are this
25//! crate's own statements, not taken from a published code.
26//!
27//! # Surface and transport geometry
28//!
29//! The transport geometry of a step is a [`Geometry`] built from the current
30//! columns. The **surface** is the piecewise-linear curve through the
31//! vertices `(y_j, R_j)` at the column centres `y_j = (j + 1/2) h` (extended
32//! periodically, or flat beyond the first and last centre for
33//! [`Boundary::Vacuum`]). Material lies at depths `x >= s(y)`. Inside column
34//! `j` the layer boundaries are flat at `R_j + (cumulative slab thickness)`;
35//! the top slab extends up to the surface. A particle reaching the surface
36//! escapes or reflects against the **normal of the surface segment it
37//! crosses**, so the angle of incidence of a primary, the surface-barrier
38//! refraction and the angular distribution of sputtered atoms all follow
39//! from the local surface orientation without any tabulated angular yield.
40//! Column faces and layer boundaries are material events as in
41//! [`VoxelGrid`](crate::geometry::VoxelGrid) (a face between columns is an
42//! event even where the materials coincide; the unused part of the flight is
43//! kept, as documented in [`crate::ion::bca`]).
44//!
45//! **Local-angle rule.** The local surface normal on a segment between two
46//! neighbouring columns is that of the line through their two vertices, i.e.
47//! the slope is `(R_{j+1} - R_j) / h`; at a column centre the beam's
48//! incidence angle is reported ([`Step2d::incidence_rad`]) from the
49//! central-difference slope `m_j = (R_{j+1} - R_{j-1}) / (2 h)`, so that
50//! `cos(alpha) = (d_x - m_j d_y) / sqrt(1 + m_j^2)` for the beam direction
51//! `d` (one-sided at a [`Boundary::Vacuum`] end). The yield of a column then
52//! is a result of the transport against the faceted surface: it is the yield
53//! at the incidence angle the primaries have on the segments they strike, and
54//! the per-column yields are reported ([`Step2d::column_yield`]). This is
55//! exact for flat columns and a piecewise-linear approximation of a curved
56//! surface otherwise; sub-column curvature is not resolved.
57//!
58//! # Beam
59//!
60//! A lateral profile ([`BeamProfile`]) gives the relative fluence `p_j` at
61//! each column centre: uniform, or Gaussian of stated full width at half
62//! maximum, `p = exp(-d^2 / (2 sigma^2))` with `sigma = FWHM / (2 sqrt(2 ln 2))`
63//! and `d` the (nearest-image, for a periodic axis) distance from the beam
64//! centre. A column is **illuminated** if `p_j >= PROFILE_CUTOFF` (1e-4), so
65//! the far tails are dropped. [`Dynamic2dConfig::fluence_m2`] is the **peak**
66//! fluence (at `p = 1`), and the beam's `count` is the number of primaries
67//! **per illuminated column** over the run; a primary in column `j` stands for
68//! `fluence_m2 p_j / count` ions/m². All primaries of a column enter at the
69//! centre of the column, so the beam's lateral resolution is the column width
70//! (a wider-than-column spot is represented by many columns, not by sub-column
71//! sampling). Primary `k` of column `j` draws from
72//! [`stream`](crate::rng::stream)`(seed, j * count + k)`.
73//!
74//! # Composition changes and relaxation
75//!
76//! The inventory bookkeeping is that of the 1-D module and is delegated to
77//! each column's [`CompositionGrid`], so both relaxation conventions
78//! ([`Relaxation::IdealMixing`](super::Relaxation::IdealMixing) and
79//! [`Relaxation::FixedNumberDensity`](super::Relaxation::FixedNumberDensity))
80//! are supported unchanged: swelling pushes the interior and the back of a
81//! column down while its front stays at `R_j`. An atom displaced in one column
82//! that comes to rest in another changes both columns (lateral mixing is
83//! transport, not diffusion; no surface diffusion, charging or swelling-driven
84//! surface motion is modelled).
85//!
86//! # Erosion
87//!
88//! As in the 1-D module, a sputtered atom is removed from the **front of the
89//! column it was displaced in** (the origin loss is cancelled and `Y_Z` atoms
90//! of each element are taken slab by slab from the front, never more than the
91//! column holds), and the recession of the column is the thickness the removed
92//! inventory occupied under its relaxation convention. A column whose slabs
93//! have all been removed is the bare substrate: the substrate is immutable, so
94//! that column stops receding there (the hole has reached the substrate).
95//!
96//! # Redeposition
97//!
98//! One rule, deterministic and atom-conserving: a sputtered atom travels from
99//! its exit point (position and direction after the surface refraction, as
100//! reported by the transport) in a **straight line in vacuum** (no gas-phase
101//! collisions) and **sticks with probability 1 where it first meets the
102//! surface again**. It is then added to the top slab of the column it hit, as
103//! one atom of its own element, and the front of that column rises by the
104//! thickness it occupies (`R_j` decreases by the thickness the deposited
105//! inventory takes under the relaxation convention, so the interfaces below
106//! stay where they are). An atom whose line never meets the surface (it
107//! leaves above the highest point of the profile, leaves through a
108//! [`Boundary::Vacuum`] lateral end, hits a column that is bare substrate, or
109//! travels more than `16 n + 16` surface segments) is lost. The rule works from
110//! the exit position and direction distribution that the transport already
111//! delivers; there is no sticking-coefficient or angular re-emission model.
112//! Only atoms displaced from a finite slab are redeposited (an atom of the
113//! substrate was never part of any inventory). Per step the run reports the
114//! sputtered and redeposited atoms per metre of `z` and their ratio, the
115//! redeposited fraction ([`Step2d::redeposited_fraction`]); atoms are
116//! conserved: for each element the change of the total inventory of all
117//! columns is the implanted atoms minus the sputtered ones plus the
118//! redeposited ones.
119//!
120//! # Determinism
121//!
122//! Per column the transport is [`Bca::run_range`]; the integer tallies of each
123//! column are scaled by the column's weight and summed in column order, and
124//! the deltas are applied in `(column, slab, Z)` order, so a run is
125//! bit-identical at any thread count. A step that covers primaries
126//! `first..first + n` of each column never replays earlier indices; adaptive
127//! shrinking follows the 1-D rules ([`StepPolicy`]) with the largest relative
128//! change taken over all slabs of all columns. A step that makes a removal
129//! exceed what a slab holds is halved down to `min_ions_per_step`, where the
130//! removal is capped at what the slab holds and counted in [`Step2d::clamped`]
131//! (as in the 1-D run, so inventory is not conserved for that step by up to
132//! one step's worth of removal; the last atoms of a column that is being cut
133//! through); a fixed policy returns the error instead.
134//!
135//! # Uniform-beam limit
136//!
137//! With identical columns, a [`BeamProfile::Uniform`] beam and a periodic
138//! lateral axis, every column receives statistically the same history as a
139//! 1-D run, and the surface stays flat to within the counting noise: the
140//! recession and the composition–depth profile averaged over columns agree
141//! with [`DynamicRun`](super::DynamicRun) within the statistical error
142//! (committed test `tests/dynamic_2d.rs`). Differences at the noise level of
143//! a column grow into the surface roughness a real uniform beam would also
144//! develop.
145
146use std::collections::BTreeMap;
147
148use crate::geometry::{Boundary, Exit, ExitOutcome, Face, Flight, Geometry};
149use crate::ion::bca::{Bca, BcaConfig, BcaTally, Beam, Particle};
150use crate::ion::scattering::ScatteringTable;
151use crate::ion::stopping::ElectronicStopping;
152use crate::material::Material;
153use crate::tally::voxel::{VoxelTally, VoxelTallyGrid};
154
155use super::adapter::Yields;
156use super::driver::{DynamicRunError, StepPolicy};
157use super::profiles::{CutThrough, ProfileAccumulator, ProfileSnapshot};
158use super::{CompositionGrid, DynamicError, InventoryDelta};
159
160/// Columns whose relative fluence is below this are not illuminated.
161pub const PROFILE_CUTOFF: f64 = 1.0e-4;
162
163/// Lateral beam profile (relative fluence at a lateral position).
164#[derive(Debug, Clone, Copy, PartialEq)]
165pub enum BeamProfile {
166    /// The same fluence in every column.
167    Uniform,
168    /// Gaussian of full width at half maximum `fwhm_m`, centred at `center_m`
169    /// (lateral coordinate `y`).
170    Gaussian {
171        /// Centre, m.
172        center_m: f64,
173        /// Full width at half maximum, m, finite and positive.
174        fwhm_m: f64,
175    },
176}
177
178/// Settings of a 2-D run.
179#[derive(Debug, Clone, Copy, PartialEq)]
180pub struct Dynamic2dConfig {
181    /// Peak fluence (at relative fluence 1), ions/m², represented by `count`
182    /// primaries per illuminated column.
183    pub fluence_m2: f64,
184    /// Step sizes, in primaries per illuminated column.
185    pub policy: StepPolicy,
186    /// Lateral beam profile.
187    pub profile: BeamProfile,
188    /// Behaviour at the two lateral ends. [`Boundary::Periodic`] makes the
189    /// target an infinite periodic array; [`Boundary::Vacuum`] a finite strip.
190    pub lateral: Boundary,
191}
192
193/// What one accepted step of a 2-D run did.
194#[derive(Debug, Clone, PartialEq)]
195pub struct Step2d {
196    /// Step number, from 1.
197    pub step: u64,
198    /// Index of the first primary of the step within each column.
199    pub first_index: u64,
200    /// Primaries per illuminated column in the step.
201    pub ions: u64,
202    /// Attempts made at this `first_index`.
203    pub attempts: u32,
204    /// Largest relative composition change of the step over all slabs.
205    pub max_change: f64,
206    /// `(column, slab, element)` removals capped at what the slab held (only at
207    /// the smallest step of an adaptive policy; 0 otherwise).
208    pub clamped: u32,
209    /// Event counts of the step, summed over the illuminated columns.
210    pub yields: Yields,
211    /// Surface position after the step: cumulative recession per column, m
212    /// (positive into the target). This is `z_s(y)` of the issue, in depth
213    /// convention.
214    pub surface_m: Vec<f64>,
215    /// Beam incidence angle from the local surface normal at the start of the
216    /// step, per column, radians (local-angle rule, module docs).
217    pub incidence_rad: Vec<f64>,
218    /// Sputtered atoms per primary of the column the beam entered, per column
219    /// (0 for columns that are not illuminated).
220    pub column_yield: Vec<f64>,
221    /// Atoms per metre of `z` sputtered in the step (from finite slabs).
222    pub sputtered_atoms_per_m: f64,
223    /// Atoms per metre of `z` of those that were redeposited.
224    pub redeposited_atoms_per_m: f64,
225    /// Beam atoms per metre of `z` that came to rest in a finite slab.
226    pub implanted_atoms_per_m: f64,
227    /// Atoms that left through a lateral end (a [`Boundary::Vacuum`] axis):
228    /// primaries and recoils, event count.
229    pub lateral_escapes: u64,
230}
231
232impl Step2d {
233    /// Redeposited over sputtered atoms in this step (0 if nothing was
234    /// sputtered).
235    pub fn redeposited_fraction(&self) -> f64 {
236        if self.sputtered_atoms_per_m > 0.0 {
237            self.redeposited_atoms_per_m / self.sputtered_atoms_per_m
238        } else {
239            0.0
240        }
241    }
242}
243
244// ---------------------------------------------------------------------------
245// Geometry
246// ---------------------------------------------------------------------------
247
248/// The transport geometry of one step: faceted height-field surface over
249/// columns of flat layers. See the module docs.
250#[derive(Debug)]
251struct ColumnTarget {
252    h: f64,
253    n_cols: usize,
254    boundary: Boundary,
255    /// Surface depth at the column centres.
256    surface: Vec<f64>,
257    /// Region-indexed materials (region = column offset + layer).
258    materials: Vec<Material>,
259    /// First region of each column; one extra entry at the end.
260    offset: Vec<usize>,
261    /// Number of finite slabs per column (the substrate, if any, is the layer
262    /// after them).
263    n_slabs: Vec<usize>,
264    /// `tops[j][k]`: depth of the upper boundary of layer `k` of column `j`
265    /// (entry 0 is the nominal front and not used for lookups).
266    tops: Vec<Vec<f64>>,
267    /// Depth of the back face of each column without a substrate.
268    back: Vec<Option<f64>>,
269    min_surface: f64,
270}
271
272impl ColumnTarget {
273    fn new(
274        grids: &[CompositionGrid],
275        surface: &[f64],
276        h: f64,
277        boundary: Boundary,
278    ) -> Result<Self, DynamicError> {
279        let n_cols = grids.len();
280        let mut materials = Vec::new();
281        let mut offset = Vec::with_capacity(n_cols + 1);
282        let mut n_slabs = Vec::with_capacity(n_cols);
283        let mut tops = Vec::with_capacity(n_cols);
284        let mut back = Vec::with_capacity(n_cols);
285        for (j, g) in grids.iter().enumerate() {
286            offset.push(materials.len());
287            let stack = g.to_stack()?;
288            let mut x = surface[j];
289            let mut t = Vec::new();
290            let mut ns = 0;
291            let mut bk = None;
292            for (k, layer) in stack.layers().iter().enumerate() {
293                t.push(x);
294                materials.push(layer.material().clone());
295                if layer.thickness_m().is_finite() {
296                    x += layer.thickness_m();
297                    ns += 1;
298                } else {
299                    debug_assert_eq!(k + 1, stack.layers().len());
300                }
301            }
302            if ns == stack.layers().len() {
303                bk = Some(x);
304            }
305            n_slabs.push(ns);
306            tops.push(t);
307            back.push(bk);
308        }
309        offset.push(materials.len());
310        let min_surface = surface.iter().copied().fold(f64::INFINITY, f64::min);
311        Ok(Self {
312            h,
313            n_cols,
314            boundary,
315            surface: surface.to_vec(),
316            materials,
317            offset,
318            n_slabs,
319            tops,
320            back,
321            min_surface,
322        })
323    }
324
325    fn width(&self) -> f64 {
326        self.n_cols as f64 * self.h
327    }
328
329    /// Column and layer of a region index.
330    fn split(&self, region: usize) -> (usize, usize) {
331        let j = self.offset.partition_point(|&o| o <= region) - 1;
332        (j, region - self.offset[j])
333    }
334
335    fn col_of(&self, y: f64) -> Option<usize> {
336        let l = self.width();
337        if !(y >= 0.0 && y <= l) {
338            return None;
339        }
340        if y == l {
341            return match self.boundary {
342                Boundary::Vacuum => None,
343                Boundary::Periodic => Some(0),
344            };
345        }
346        Some(((y / self.h) as usize).min(self.n_cols - 1))
347    }
348
349    /// Layer of column `j` at depth `x` (`None` beyond the back face of a
350    /// column without substrate). The top layer extends up to the surface.
351    fn layer_of(&self, j: usize, x: f64) -> Option<usize> {
352        if let Some(b) = self.back[j] {
353            if x >= b {
354                return None;
355            }
356        }
357        let t = &self.tops[j];
358        Some(t[1..].partition_point(|&b| b <= x))
359    }
360
361    /// Surface depths at the two neighbouring centres of column `j`.
362    fn neighbours(&self, j: usize) -> (f64, f64) {
363        let n = self.n_cols;
364        let left = if j > 0 {
365            self.surface[j - 1]
366        } else if self.boundary == Boundary::Periodic {
367            self.surface[n - 1]
368        } else {
369            self.surface[0]
370        };
371        let right = if j + 1 < n {
372            self.surface[j + 1]
373        } else if self.boundary == Boundary::Periodic {
374            self.surface[0]
375        } else {
376            self.surface[n - 1]
377        };
378        (left, right)
379    }
380
381    /// Surface depth and slope at lateral position `y` in column `j`.
382    fn surface_at(&self, j: usize, y: f64) -> (f64, f64) {
383        let yc = (j as f64 + 0.5) * self.h;
384        let (l, r) = self.neighbours(j);
385        let rj = self.surface[j];
386        let m = if y < yc {
387            (rj - l) / self.h
388        } else {
389            (r - rj) / self.h
390        };
391        (rj + (y - yc) * m, m)
392    }
393
394    /// First crossing of the surface from inside (material) to outside within
395    /// path length `tmax`, with the outward unit normal there.
396    fn surface_crossing(
397        &self,
398        j: usize,
399        pos: [f64; 3],
400        dir: [f64; 3],
401        tmax: f64,
402    ) -> Option<(f64, [f64; 3])> {
403        let (x0, y0) = (pos[0], pos[1]);
404        let (dx, dy) = (dir[0], dir[1]);
405        let yc = (j as f64 + 0.5) * self.h;
406        let (left, right) = self.neighbours(j);
407        let rj = self.surface[j];
408        let t_c = if dy != 0.0 {
409            (yc - y0) / dy
410        } else {
411            f64::INFINITY
412        };
413        let mut lower = y0 < yc || (y0 == yc && dy < 0.0);
414        let mut t0 = 0.0;
415        loop {
416            let m = if lower {
417                (rj - left) / self.h
418            } else {
419                (right - rj) / self.h
420            };
421            let switches = (lower && dy > 0.0) || (!lower && dy < 0.0);
422            let t1 = if switches && t_c > t0 && t_c < tmax {
423                t_c
424            } else {
425                tmax
426            };
427            let g0 = x0 + t0 * dx - (rj + (y0 + t0 * dy - yc) * m);
428            let slope = dx - m * dy;
429            let g1 = g0 + slope * (t1 - t0);
430            if slope < 0.0 && g1 < 0.0 {
431                let t = if g0 <= 0.0 { t0 } else { t0 - g0 / slope };
432                let norm = (1.0 + m * m).sqrt();
433                return Some((t.min(tmax), [-1.0 / norm, m / norm, 0.0]));
434            }
435            if t1 >= tmax {
436                return None;
437            }
438            t0 = t1;
439            lower = !lower;
440        }
441    }
442
443    /// Column that a sputtered atom leaving `pos` along `dir` meets first, if
444    /// any (straight line in vacuum; see "Redeposition" in the module docs).
445    fn trace_to_surface(&self, pos: [f64; 3], dir: [f64; 3]) -> Option<usize> {
446        let (x0, dx, dy) = (pos[0], dir[0], dir[1]);
447        if !(x0.is_finite() && dx.is_finite() && dy.is_finite()) {
448            return None;
449        }
450        let mut j = self.col_of(pos[1].clamp(0.0, self.width()))?;
451        let y_start = pos[1];
452        let mut t0 = 0.0;
453        // `y` at the start of the current piece.
454        let mut y = y_start;
455        let mut first = true;
456        for _ in 0..(16 * self.n_cols + 16) {
457            let yc = (j as f64 + 0.5) * self.h;
458            let lower = y < yc || (y == yc && dy < 0.0);
459            let (l, r) = self.neighbours(j);
460            let rj = self.surface[j];
461            let m = if lower {
462                (rj - l) / self.h
463            } else {
464                (r - rj) / self.h
465            };
466            let (y_edge, at_centre) = if dy > 0.0 {
467                if lower {
468                    (yc, true)
469                } else {
470                    ((j as f64 + 1.0) * self.h, false)
471                }
472            } else if dy < 0.0 {
473                if lower {
474                    (j as f64 * self.h, false)
475                } else {
476                    (yc, true)
477                }
478            } else {
479                (y, true)
480            };
481            let t1 = if dy == 0.0 {
482                f64::INFINITY
483            } else {
484                t0 + ((y_edge - y) / dy).max(0.0)
485            };
486            let mut g0 = x0 + t0 * dx - (rj + (y - yc) * m);
487            if first {
488                g0 = g0.min(0.0);
489                first = false;
490            }
491            let slope = dx - m * dy;
492            if slope > 0.0 {
493                let t_hit = t0 + (-g0).max(0.0) / slope;
494                if t_hit <= t1 {
495                    return Some(j);
496                }
497            }
498            if !t1.is_finite() {
499                return None;
500            }
501            // The ray rises above every surface point and keeps rising.
502            if dx < 0.0 && x0 + t1 * dx < self.min_surface {
503                return None;
504            }
505            t0 = t1;
506            y = y_edge;
507            if !at_centre {
508                if dy > 0.0 {
509                    if j + 1 == self.n_cols {
510                        match self.boundary {
511                            Boundary::Vacuum => return None,
512                            Boundary::Periodic => {
513                                j = 0;
514                                y = 0.0;
515                            }
516                        }
517                    } else {
518                        j += 1;
519                    }
520                } else if j == 0 {
521                    match self.boundary {
522                        Boundary::Vacuum => return None,
523                        Boundary::Periodic => {
524                            j = self.n_cols - 1;
525                            y = self.width();
526                        }
527                    }
528                } else {
529                    j -= 1;
530                }
531            }
532        }
533        None
534    }
535}
536
537impl Geometry for ColumnTarget {
538    fn n_materials(&self) -> usize {
539        self.materials.len()
540    }
541
542    fn material(&self, index: usize) -> &Material {
543        &self.materials[index]
544    }
545
546    fn n_regions(&self) -> usize {
547        self.materials.len()
548    }
549
550    fn material_index(&self, region: usize) -> usize {
551        region
552    }
553
554    fn locate(&self, pos: [f64; 3]) -> Option<usize> {
555        if !pos.iter().all(|c| c.is_finite()) {
556            return None;
557        }
558        let j = self.col_of(pos[1])?;
559        let (s, _) = self.surface_at(j, pos[1].min(self.width()));
560        if pos[0] < s {
561            return None;
562        }
563        let k = self.layer_of(j, pos[0])?;
564        Some(self.offset[j] + k)
565    }
566
567    fn entry_point(&self) -> [f64; 3] {
568        [self.surface[0] + 1.0e-6 * self.h, 0.5 * self.h, 0.0]
569    }
570
571    fn exit(&self, region: usize, pos: [f64; 3], dir: [f64; 3], limit: f64) -> Option<Exit> {
572        match self.flight(region, pos, dir, limit) {
573            Flight::Event(e) => Some(e),
574            Flight::Clear { .. } => None,
575        }
576    }
577
578    fn flight(&self, region: usize, pos: [f64; 3], dir: [f64; 3], limit: f64) -> Flight {
579        if !dir.iter().all(|d| d.is_finite()) || dir == [0.0; 3] {
580            return Flight::Clear { region };
581        }
582        #[derive(Clone, Copy)]
583        enum Kind {
584            Lateral(bool),
585            Interface(bool),
586            Back,
587        }
588        let (j, k) = self.split(region);
589        let (dx, dy) = (dir[0], dir[1]);
590        let mut t_end = f64::INFINITY;
591        let mut kind = None;
592        let mut consider = |t: f64, kd: Kind| {
593            if t < t_end {
594                t_end = t;
595                kind = Some(kd);
596            }
597        };
598        if dy > 0.0 {
599            consider(
600                (((j as f64 + 1.0) * self.h - pos[1]) / dy).max(0.0),
601                Kind::Lateral(true),
602            );
603        } else if dy < 0.0 {
604            consider(
605                ((j as f64 * self.h - pos[1]) / dy).max(0.0),
606                Kind::Lateral(false),
607            );
608        }
609        if dx > 0.0 {
610            let n_layers = self.offset[j + 1] - self.offset[j];
611            if k + 1 < n_layers {
612                consider(
613                    ((self.tops[j][k + 1] - pos[0]) / dx).max(0.0),
614                    Kind::Interface(true),
615                );
616            } else if let Some(b) = self.back[j] {
617                consider(((b - pos[0]) / dx).max(0.0), Kind::Back);
618            }
619        } else if dx < 0.0 && k > 0 {
620            consider(
621                ((self.tops[j][k] - pos[0]) / dx).max(0.0),
622                Kind::Interface(false),
623            );
624        }
625        let tmax = t_end.min(limit);
626        let point = |t: f64| [pos[0] + t * dx, pos[1] + t * dy, pos[2] + t * dir[2]];
627        if let Some((t, normal)) = self.surface_crossing(j, pos, dir, tmax) {
628            return Flight::Event(Exit {
629                distance: t,
630                at: point(t),
631                outcome: ExitOutcome::Escape {
632                    face: Face::Front,
633                    normal,
634                },
635            });
636        }
637        let Some(kind) = kind.filter(|_| t_end <= limit) else {
638            return Flight::Clear { region };
639        };
640        let mut at = point(t_end);
641        let outcome = match kind {
642            Kind::Interface(down) => ExitOutcome::Enter {
643                region: if down { region + 1 } else { region - 1 },
644                pos: at,
645            },
646            Kind::Back => ExitOutcome::Escape {
647                face: Face::Back,
648                normal: [1.0, 0.0, 0.0],
649            },
650            Kind::Lateral(up) => {
651                at[1] = if up {
652                    (j as f64 + 1.0) * self.h
653                } else {
654                    j as f64 * self.h
655                };
656                let mut new_pos = at;
657                let next = if up {
658                    if j + 1 == self.n_cols {
659                        (self.boundary == Boundary::Periodic).then(|| {
660                            new_pos[1] = 0.0;
661                            0
662                        })
663                    } else {
664                        Some(j + 1)
665                    }
666                } else if j == 0 {
667                    (self.boundary == Boundary::Periodic).then(|| {
668                        new_pos[1] = self.width();
669                        self.n_cols - 1
670                    })
671                } else {
672                    Some(j - 1)
673                };
674                let normal = [0.0, if up { 1.0 } else { -1.0 }, 0.0];
675                match next {
676                    None => ExitOutcome::Escape {
677                        face: Face::Side,
678                        normal,
679                    },
680                    Some(nj) => match self.layer_of(nj, at[0]) {
681                        Some(nk) => ExitOutcome::Enter {
682                            region: self.offset[nj] + nk,
683                            pos: new_pos,
684                        },
685                        // Beyond the back face of the neighbouring column.
686                        None => ExitOutcome::Escape {
687                            face: Face::Back,
688                            normal,
689                        },
690                    },
691                }
692            }
693        };
694        Flight::Event(Exit {
695            distance: t_end,
696            at,
697            outcome,
698        })
699    }
700
701    fn in_vacuum(&self, pos: [f64; 3]) -> bool {
702        match self.col_of(pos[1]) {
703            None => true,
704            Some(j) => pos[0] < self.surface_at(j, pos[1].min(self.width())).0,
705        }
706    }
707}
708
709// ---------------------------------------------------------------------------
710// Tally
711// ---------------------------------------------------------------------------
712
713/// Per-column tally of one block of primaries: net atom counts per
714/// `(column, slab, Z)`, sputtered atoms per origin, and where they land again.
715struct Tally2d<'t> {
716    target: &'t ColumnTarget,
717    counts: BTreeMap<(usize, usize, u8), i64>,
718    sputtered_from: BTreeMap<(usize, usize, u8), u64>,
719    redeposited: BTreeMap<(usize, u8), u64>,
720    lateral: u64,
721    yields: Yields,
722    /// Voxel tally of all events, and of those in the substrate layer (both
723    /// present only when profiles are on).
724    vox: Option<(VoxelTally, VoxelTally)>,
725}
726
727impl<'t> Tally2d<'t> {
728    fn new(target: &'t ColumnTarget, proto: Option<&VoxelTally>) -> Self {
729        Self {
730            vox: proto.map(|p| (p.clone(), p.clone())),
731            target,
732            counts: BTreeMap::new(),
733            sputtered_from: BTreeMap::new(),
734            redeposited: BTreeMap::new(),
735            lateral: 0,
736            yields: Yields::default(),
737        }
738    }
739
740    /// `(column, slab)` of a region if it is a finite slab.
741    fn slab(&self, region: usize) -> Option<(usize, usize)> {
742        let (j, k) = self.target.split(region);
743        (k < self.target.n_slabs[j]).then_some((j, k))
744    }
745}
746
747impl BcaTally for Tally2d<'_> {
748    fn begin_history(&mut self, index: u64) {
749        self.yields.histories += 1;
750        if let Some((a, b)) = &mut self.vox {
751            a.begin_history(index);
752            b.begin_history(index);
753        }
754    }
755
756    fn end_history(&mut self, index: u64, budget: &crate::ion::bca::EnergyBudget) {
757        if let Some((a, b)) = &mut self.vox {
758            a.end_history(index, budget);
759            b.end_history(index, budget);
760        }
761    }
762
763    fn recoil(&mut self, recoil: &Particle) {
764        self.yields.displaced += 1;
765        if self.vox.is_some() {
766            let in_substrate = self.slab(recoil.layer).is_none();
767            if let Some((a, b)) = &mut self.vox {
768                a.recoil(recoil);
769                if in_substrate {
770                    b.recoil(recoil);
771                }
772            }
773        }
774        if let Some((j, k)) = self.slab(recoil.layer) {
775            *self.counts.entry((j, k, recoil.z)).or_insert(0) -= 1;
776        }
777    }
778
779    fn stopped(&mut self, p: &Particle) {
780        let slab = self.slab(p.layer);
781        if let Some((a, b)) = &mut self.vox {
782            a.stopped(p);
783            if slab.is_none() {
784                b.stopped(p);
785            }
786        }
787        if let Some((j, k)) = slab {
788            *self.counts.entry((j, k, p.z)).or_insert(0) += 1;
789        }
790        if p.is_primary() {
791            if slab.is_some() {
792                self.yields.primaries_in_slabs += 1;
793            } else {
794                self.yields.primaries_in_substrate += 1;
795            }
796        }
797    }
798
799    fn escaped(&mut self, p: &Particle, face: Face) {
800        if let Some((a, b)) = &mut self.vox {
801            a.escaped(p, face);
802            b.escaped(p, face);
803        }
804        match (p.is_primary(), face) {
805            (true, Face::Front) => self.yields.backscattered += 1,
806            (true, Face::Back) => self.yields.transmitted += 1,
807            (false, Face::Front) => {
808                *self.yields.sputtered.entry(p.z).or_insert(0) += 1;
809                if let Some((j, k)) = self.slab(p.origin_layer) {
810                    *self.sputtered_from.entry((j, k, p.z)).or_insert(0) += 1;
811                    if let Some(c) = self.target.trace_to_surface(p.pos, p.dir) {
812                        if self.target.n_slabs[c] > 0 {
813                            *self.redeposited.entry((c, p.z)).or_insert(0) += 1;
814                        }
815                    }
816                }
817            }
818            (false, Face::Back) => self.yields.recoils_transmitted += 1,
819            (_, Face::Side) => self.lateral += 1,
820        }
821    }
822
823    fn merge(&mut self, other: Self) {
824        for (k, n) in other.counts {
825            *self.counts.entry(k).or_insert(0) += n;
826        }
827        for (k, n) in other.sputtered_from {
828            *self.sputtered_from.entry(k).or_insert(0) += n;
829        }
830        for (k, n) in other.redeposited {
831            *self.redeposited.entry(k).or_insert(0) += n;
832        }
833        self.lateral += other.lateral;
834        self.yields.add(&other.yields);
835        if let (Some((a, b)), Some((oa, ob))) = (&mut self.vox, other.vox) {
836            a.merge(oa);
837            b.merge(ob);
838        }
839    }
840}
841
842// ---------------------------------------------------------------------------
843// The run
844// ---------------------------------------------------------------------------
845
846/// A fluence-stepped 2-D run. See the module documentation.
847pub struct DynamicRun2d<'a> {
848    grids: Vec<CompositionGrid>,
849    surface: Vec<f64>,
850    h: f64,
851    beam: Beam,
852    config: BcaConfig,
853    stopping: &'a (dyn ElectronicStopping + Sync),
854    table: &'a ScatteringTable,
855    cfg: Dynamic2dConfig,
856    /// Relative fluence per column (0 where not illuminated).
857    weights: Vec<f64>,
858    next_index: u64,
859    steps: u64,
860    current_n: u64,
861    cumulative: Yields,
862    sputtered_atoms_per_m: f64,
863    redeposited_atoms_per_m: f64,
864    profiles: Option<Profiles>,
865}
866
867/// Profile bookkeeping of a run (see [`DynamicRun2d::with_profiles`]).
868struct Profiles {
869    proto: VoxelTally,
870    acc: ProfileAccumulator,
871    /// Primary indices (per column) at which a snapshot is taken, ascending.
872    at: Vec<u64>,
873    snapshots: Vec<ProfileSnapshot>,
874}
875
876impl std::fmt::Debug for DynamicRun2d<'_> {
877    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
878        f.debug_struct("DynamicRun2d")
879            .field("columns", &self.grids.len())
880            .field("column_width_m", &self.h)
881            .field("cfg", &self.cfg)
882            .field("next_index", &self.next_index)
883            .field("steps", &self.steps)
884            .finish_non_exhaustive()
885    }
886}
887
888impl<'a> DynamicRun2d<'a> {
889    /// Set up a run on `columns` (front to back identical bookkeeping to the
890    /// 1-D module; all columns must share the lateral width `column_width_m`).
891    /// `beam.count` is the number of primaries per illuminated column.
892    pub fn new(
893        columns: Vec<CompositionGrid>,
894        column_width_m: f64,
895        beam: Beam,
896        config: BcaConfig,
897        stopping: &'a (dyn ElectronicStopping + Sync),
898        table: &'a ScatteringTable,
899        cfg: Dynamic2dConfig,
900    ) -> Result<Self, DynamicRunError> {
901        let bad = |m: &str| Err(DynamicRunError::Invalid(m.to_string()));
902        if columns.is_empty() {
903            return bad("at least one column is needed");
904        }
905        if !(column_width_m.is_finite() && column_width_m > 0.0) {
906            return bad("the column width must be finite and positive");
907        }
908        if beam.count == 0 {
909            return bad("the beam needs at least one primary per column");
910        }
911        if (columns.len() as u64).checked_mul(beam.count).is_none() {
912            return bad("columns times primaries overflows the index space");
913        }
914        if !(cfg.fluence_m2.is_finite() && cfg.fluence_m2 > 0.0) {
915            return bad("fluence must be finite and positive");
916        }
917        let n = columns.len();
918        let weights: Vec<f64> = match cfg.profile {
919            BeamProfile::Uniform => vec![1.0; n],
920            BeamProfile::Gaussian { center_m, fwhm_m } => {
921                if !(center_m.is_finite() && fwhm_m.is_finite() && fwhm_m > 0.0) {
922                    return bad("the Gaussian profile needs a finite centre and positive FWHM");
923                }
924                let sigma = fwhm_m / (2.0 * (2.0 * std::f64::consts::LN_2).sqrt());
925                let l = n as f64 * column_width_m;
926                (0..n)
927                    .map(|j| {
928                        let mut d = (j as f64 + 0.5) * column_width_m - center_m;
929                        if cfg.lateral == Boundary::Periodic {
930                            d -= l * (d / l).round();
931                        }
932                        let p = (-d * d / (2.0 * sigma * sigma)).exp();
933                        if p >= PROFILE_CUTOFF {
934                            p
935                        } else {
936                            0.0
937                        }
938                    })
939                    .collect()
940            }
941        };
942        if weights.iter().all(|&w| w == 0.0) {
943            return bad("the beam illuminates no column");
944        }
945        let current_n = match cfg.policy {
946            StepPolicy::Fixed { ions_per_step } => {
947                if ions_per_step == 0 {
948                    return bad("ions_per_step must be at least 1");
949                }
950                ions_per_step
951            }
952            StepPolicy::Adaptive {
953                max_ions_per_step,
954                min_ions_per_step,
955                max_change,
956            } => {
957                if min_ions_per_step == 0 || max_ions_per_step < min_ions_per_step {
958                    return bad("need 1 <= min_ions_per_step <= max_ions_per_step");
959                }
960                if !(max_change.is_finite() && max_change > 0.0) {
961                    return bad("max_change must be finite and positive");
962                }
963                max_ions_per_step
964            }
965        };
966        Ok(Self {
967            surface: vec![0.0; n],
968            grids: columns,
969            h: column_width_m,
970            beam,
971            config,
972            stopping,
973            table,
974            cfg,
975            weights,
976            next_index: 0,
977            steps: 0,
978            current_n,
979            cumulative: Yields::default(),
980            sputtered_atoms_per_m: 0.0,
981            redeposited_atoms_per_m: 0.0,
982            profiles: None,
983        })
984    }
985
986    /// Tally the implanted beam species and the displacement damage on `grid`
987    /// (sample frame, see the `profiles` module docs) and take a snapshot
988    /// when the peak fluence reaches each of `snapshot_fluences_m2` (each in
989    /// `(0, fluence_m2]`, rounded to a whole number of primaries per column).
990    /// Steps are shortened to end on a snapshot, so the step sequence (and
991    /// the random numbers drawn, which are indexed by primary) depends on the
992    /// snapshot list; with the same list a run is bit-identical at any thread
993    /// count. Without this call no profile is tallied and results are
994    /// unchanged.
995    pub fn with_profiles(
996        mut self,
997        grid: VoxelTallyGrid,
998        snapshot_fluences_m2: &[f64],
999    ) -> Result<Self, DynamicRunError> {
1000        let mut at = Vec::new();
1001        for &f in snapshot_fluences_m2 {
1002            if !(f.is_finite() && f > 0.0 && f <= self.cfg.fluence_m2) {
1003                return Err(DynamicRunError::Invalid(
1004                    "a snapshot fluence must be in (0, the run fluence]".to_string(),
1005                ));
1006            }
1007            let k = (f / self.cfg.fluence_m2 * self.beam.count as f64).round() as u64;
1008            at.push(k.clamp(1, self.beam.count));
1009        }
1010        at.sort_unstable();
1011        at.dedup();
1012        self.profiles = Some(Profiles {
1013            proto: VoxelTally::new(grid),
1014            acc: ProfileAccumulator::new(grid),
1015            at,
1016            snapshots: Vec::new(),
1017        });
1018        Ok(self)
1019    }
1020
1021    /// Snapshots taken so far at the requested fluences, in order.
1022    pub fn snapshots(&self) -> &[ProfileSnapshot] {
1023        self.profiles.as_ref().map_or(&[], |p| &p.snapshots)
1024    }
1025
1026    /// The profiles at the current fluence (`None` unless
1027    /// [`with_profiles`](Self::with_profiles) was called).
1028    pub fn profile_snapshot(&self) -> Option<ProfileSnapshot> {
1029        self.profiles.as_ref().map(|p| {
1030            p.acc
1031                .snapshot(self.beam.ion.z(), self.fluence_done_m2(), self.next_index)
1032        })
1033    }
1034
1035    /// Whether an illuminated column has lost all its slabs, i.e. the hole
1036    /// has reached the substrate there.
1037    fn cut_column(&self) -> Option<usize> {
1038        (0..self.grids.len()).find(|&j| self.weights[j] > 0.0 && self.grids[j].n_slabs() == 0)
1039    }
1040
1041    /// Run until the hole first reaches the substrate (an illuminated column
1042    /// with no slab left) or the primaries are used up, calling `on_step`
1043    /// after each accepted step.
1044    ///
1045    /// The columns must have a substrate (the layer beneath the film). The
1046    /// fluence is resolved to one step: see [`CutThrough`]. With a fixed or
1047    /// small adaptive step the bracket is narrow; the damage left in the
1048    /// substrate at the stop is in the profiles of the result.
1049    pub fn run_until_cut_through(
1050        &mut self,
1051        mut on_step: impl FnMut(&Step2d, &[CompositionGrid]),
1052    ) -> Result<CutThrough, DynamicRunError> {
1053        if let Some(j) =
1054            (0..self.grids.len()).find(|&j| self.weights[j] > 0.0 && !self.grids[j].has_substrate())
1055        {
1056            return Err(DynamicRunError::Invalid(format!(
1057                "column {j} has no substrate: there is nothing to cut through to"
1058            )));
1059        }
1060        let mut before = self.fluence_done_m2();
1061        while self.cut_column().is_none() {
1062            before = self.fluence_done_m2();
1063            let Some(rec) = self.step()? else {
1064                break;
1065            };
1066            on_step(&rec, &self.grids);
1067        }
1068        let column = self.cut_column();
1069        Ok(CutThrough {
1070            reached: column.is_some(),
1071            column,
1072            fluence_before_m2: before,
1073            fluence_m2: self.fluence_done_m2(),
1074            ions_done: self.next_index,
1075            profiles: self.profile_snapshot(),
1076        })
1077    }
1078
1079    /// The current columns.
1080    pub fn columns(&self) -> &[CompositionGrid] {
1081        &self.grids
1082    }
1083
1084    /// Surface position per column: cumulative recession, m.
1085    pub fn surface_m(&self) -> &[f64] {
1086        &self.surface
1087    }
1088
1089    /// Relative fluence per column (0 where not illuminated).
1090    pub fn profile_weights(&self) -> &[f64] {
1091        &self.weights
1092    }
1093
1094    /// Primaries per column of the whole run.
1095    pub fn ions_total_per_column(&self) -> u64 {
1096        self.beam.count
1097    }
1098
1099    /// Primaries per column used so far.
1100    pub fn ions_done(&self) -> u64 {
1101        self.next_index
1102    }
1103
1104    /// Peak fluence delivered so far, ions/m².
1105    pub fn fluence_done_m2(&self) -> f64 {
1106        self.cfg.fluence_m2 * self.next_index as f64 / self.beam.count as f64
1107    }
1108
1109    /// Accepted steps so far.
1110    pub fn steps_done(&self) -> u64 {
1111        self.steps
1112    }
1113
1114    /// Event counts summed over the accepted steps and columns.
1115    pub fn cumulative(&self) -> &Yields {
1116        &self.cumulative
1117    }
1118
1119    /// Redeposited over sputtered atoms, over the whole run so far.
1120    pub fn redeposited_fraction(&self) -> f64 {
1121        if self.sputtered_atoms_per_m > 0.0 {
1122            self.redeposited_atoms_per_m / self.sputtered_atoms_per_m
1123        } else {
1124            0.0
1125        }
1126    }
1127
1128    /// Beam incidence angle from the local surface normal per column, radians
1129    /// (the local-angle rule of the module docs) for the current surface.
1130    pub fn incidence_rad(&self) -> Vec<f64> {
1131        let n = self.surface.len();
1132        let d = self.beam.direction();
1133        (0..n)
1134            .map(|j| {
1135                let periodic = self.cfg.lateral == Boundary::Periodic;
1136                let lo = if j > 0 {
1137                    Some(j - 1)
1138                } else if periodic {
1139                    Some(n - 1)
1140                } else {
1141                    None
1142                };
1143                let hi = if j + 1 < n {
1144                    Some(j + 1)
1145                } else if periodic {
1146                    Some(0)
1147                } else {
1148                    None
1149                };
1150                let (a, b, span) = match (lo, hi) {
1151                    (Some(l), Some(r)) => (self.surface[l], self.surface[r], 2.0),
1152                    (None, Some(r)) => (self.surface[j], self.surface[r], 1.0),
1153                    (Some(l), None) => (self.surface[l], self.surface[j], 1.0),
1154                    (None, None) => (0.0, 0.0, 1.0),
1155                };
1156                let m = (b - a) / (span * self.h);
1157                ((d[0] - m * d[1]) / (1.0 + m * m).sqrt())
1158                    .clamp(-1.0, 1.0)
1159                    .acos()
1160            })
1161            .collect()
1162    }
1163
1164    fn max_change(&self, deltas: &BTreeMap<(usize, usize, u8), f64>) -> f64 {
1165        let totals: Vec<Vec<f64>> = self
1166            .grids
1167            .iter()
1168            .map(|g| {
1169                (0..g.n_slabs())
1170                    .map(|k| {
1171                        g.inventory(k)
1172                            .map_or(0.0, |v| v.iter().map(|&(_, a)| a).sum())
1173                    })
1174                    .collect()
1175            })
1176            .collect();
1177        deltas
1178            .iter()
1179            .map(|(&(j, k, _), d)| d.abs() / totals[j][k])
1180            .fold(0.0, f64::max)
1181    }
1182
1183    /// The columns after applying `delta`; the run state is untouched.
1184    fn apply_all(
1185        &self,
1186        delta: &BTreeMap<(usize, usize, u8), f64>,
1187    ) -> Result<Vec<CompositionGrid>, DynamicError> {
1188        let mut grids = self.grids.clone();
1189        for (j, g) in grids.iter_mut().enumerate() {
1190            let ds: Vec<InventoryDelta> = delta
1191                .range((j, 0, 0)..=(j, usize::MAX, u8::MAX))
1192                .map(|(&(_, slab, z), &d)| InventoryDelta {
1193                    slab,
1194                    z,
1195                    delta_atoms_m2: d,
1196                })
1197                .collect();
1198            g.apply(&ds)?;
1199        }
1200        Ok(grids)
1201    }
1202
1203    /// Run the next step. `Ok(None)` when all primaries are used. On error the
1204    /// run state is unchanged.
1205    pub fn step(&mut self) -> Result<Option<Step2d>, DynamicRunError> {
1206        let remaining = self.beam.count - self.next_index;
1207        if remaining == 0 {
1208            return Ok(None);
1209        }
1210        let first = self.next_index;
1211        let mut n = self.current_n.min(remaining);
1212        if let Some(p) = &self.profiles {
1213            if let Some(&next) = p.at.iter().find(|&&k| k > first) {
1214                n = n.min(next - first);
1215            }
1216        }
1217        let mut attempts = 0u32;
1218        let incidence = self.incidence_rad();
1219        let n_cols = self.grids.len();
1220        loop {
1221            attempts += 1;
1222            let target = ColumnTarget::new(&self.grids, &self.surface, self.h, self.cfg.lateral)?;
1223
1224            let mut delta: BTreeMap<(usize, usize, u8), f64> = BTreeMap::new();
1225            let mut sputtered: BTreeMap<(usize, u8), f64> = BTreeMap::new();
1226            let mut deposited: BTreeMap<(usize, u8), f64> = BTreeMap::new();
1227            let mut yields = Yields::default();
1228            let mut column_yield = vec![0.0; n_cols];
1229            let mut lateral = 0u64;
1230            let mut implanted = 0.0;
1231            let mut step_profiles = self
1232                .profiles
1233                .as_ref()
1234                .map(|p| ProfileAccumulator::new(*p.proto.grid()));
1235            #[allow(clippy::needless_range_loop)]
1236            for c in 0..n_cols {
1237                if self.weights[c] == 0.0 {
1238                    continue;
1239                }
1240                let beam = Beam {
1241                    count: n,
1242                    ..self.beam
1243                };
1244                let entry = [
1245                    self.surface[c] + 1.0e-6 * self.h,
1246                    (c as f64 + 0.5) * self.h,
1247                    0.0,
1248                ];
1249                let bca = Bca::new(beam, &target, self.config, self.stopping, self.table)?
1250                    .with_entry_point(entry)?;
1251                let tally: Tally2d<'_> =
1252                    bca.run_range(c as u64 * self.beam.count + first, n, || {
1253                        Tally2d::new(&target, self.profiles.as_ref().map(|p| &p.proto))
1254                    })?;
1255                // Ions/m² one primary of this column stands for.
1256                let w = self.cfg.fluence_m2 * self.weights[c] / self.beam.count as f64;
1257                if let (Some(acc), Some((all, sub))) = (&mut step_profiles, &tally.vox) {
1258                    acc.add_counts(&all.report(), &sub.report(), w * self.h);
1259                }
1260                for (&k, &cnt) in &tally.counts {
1261                    if cnt != 0 {
1262                        *delta.entry(k).or_insert(0.0) += cnt as f64 * w;
1263                    }
1264                }
1265                for (&(j, k, z), &cnt) in &tally.sputtered_from {
1266                    // Cancel the origin loss; the atoms come off the front.
1267                    *delta.entry((j, k, z)).or_insert(0.0) += cnt as f64 * w;
1268                    *sputtered.entry((j, z)).or_insert(0.0) += cnt as f64 * w;
1269                }
1270                for (&k, &cnt) in &tally.redeposited {
1271                    *deposited.entry(k).or_insert(0.0) += cnt as f64 * w;
1272                }
1273                column_yield[c] = tally.yields.sputtered_total() as f64 / n as f64;
1274                lateral += tally.lateral;
1275                implanted += tally.yields.primaries_in_slabs as f64 * w * self.h;
1276                yields.add(&tally.yields);
1277            }
1278
1279            // Erosion: take the sputtered atoms from the front of the column
1280            // they came from, element by element.
1281            let mut removed: Vec<BTreeMap<u8, f64>> = vec![BTreeMap::new(); n_cols];
1282            for (&(j, z), &y) in &sputtered {
1283                let mut rem = y;
1284                for slab in 0..self.grids[j].n_slabs() {
1285                    if rem <= 0.0 {
1286                        break;
1287                    }
1288                    let inv = self.grids[j].inventory(slab).unwrap_or_default();
1289                    let have = inv
1290                        .iter()
1291                        .find(|&&(zz, _)| zz == z)
1292                        .map_or(0.0, |&(_, a)| a);
1293                    let d = delta.entry((j, slab, z)).or_insert(0.0);
1294                    let take = (have + *d).max(0.0).min(rem);
1295                    if take > 0.0 {
1296                        *d -= take;
1297                        rem -= take;
1298                        *removed[j].entry(z).or_insert(0.0) += take;
1299                    }
1300                }
1301            }
1302            // Redeposition: onto the top slab of the column hit.
1303            let mut dep_inv: Vec<BTreeMap<u8, f64>> = vec![BTreeMap::new(); n_cols];
1304            for (&(j, z), &a) in &deposited {
1305                *delta.entry((j, 0, z)).or_insert(0.0) += a;
1306                *dep_inv[j].entry(z).or_insert(0.0) += a;
1307            }
1308            let mut step_recession = vec![0.0; n_cols];
1309            for j in 0..n_cols {
1310                let relax = self.grids[j].relaxation();
1311                if !removed[j].is_empty() {
1312                    step_recession[j] += relax.thickness_m(&removed[j])?;
1313                }
1314                if !dep_inv[j].is_empty() {
1315                    step_recession[j] -= relax.thickness_m(&dep_inv[j])?;
1316                }
1317            }
1318            delta.retain(|_, d| *d != 0.0);
1319            let change = self.max_change(&delta);
1320
1321            if let StepPolicy::Adaptive {
1322                min_ions_per_step,
1323                max_change,
1324                ..
1325            } = self.cfg.policy
1326            {
1327                if n > min_ions_per_step && change > max_change {
1328                    let scaled = (n as f64 * max_change / change).floor() as u64;
1329                    n = scaled.clamp(min_ions_per_step, n - 1);
1330                    continue;
1331                }
1332            }
1333
1334            let mut clamped = 0u32;
1335            let new_grids = match self.apply_all(&delta) {
1336                Ok(g) => g,
1337                Err(e) => {
1338                    let StepPolicy::Adaptive {
1339                        min_ions_per_step, ..
1340                    } = self.cfg.policy
1341                    else {
1342                        return Err(e.into());
1343                    };
1344                    if n > min_ions_per_step {
1345                        n = (n / 2).max(min_ions_per_step);
1346                        continue;
1347                    }
1348                    // Smallest step: a removal beyond what a slab holds is
1349                    // capped at what it holds (as in the 1-D run).
1350                    if !matches!(e, DynamicError::NegativeInventory { .. }) {
1351                        return Err(e.into());
1352                    }
1353                    let capped: BTreeMap<(usize, usize, u8), f64> = delta
1354                        .iter()
1355                        .map(|(&(j, k, z), &d)| {
1356                            let have = self.grids[j]
1357                                .inventory(k)
1358                                .and_then(|v| v.iter().find(|&&(zz, _)| zz == z).map(|&(_, a)| a))
1359                                .unwrap_or(0.0);
1360                            if d < -have {
1361                                clamped += 1;
1362                                ((j, k, z), -have)
1363                            } else {
1364                                ((j, k, z), d)
1365                            }
1366                        })
1367                        .collect();
1368                    self.apply_all(&capped)?
1369                }
1370            };
1371            self.grids = new_grids;
1372            for (r, d) in self.surface.iter_mut().zip(&step_recession) {
1373                *r += d;
1374            }
1375            self.steps += 1;
1376            self.next_index += n;
1377            self.cumulative.add(&yields);
1378            if let (Some(p), Some(acc)) = (&mut self.profiles, &step_profiles) {
1379                p.acc.merge(acc);
1380            }
1381            let per_m = |m: &BTreeMap<(usize, u8), f64>| (0.0 + m.values().sum::<f64>()) * self.h;
1382            let sputtered_apm = per_m(&sputtered);
1383            let redeposited_apm = per_m(&deposited);
1384            self.sputtered_atoms_per_m += sputtered_apm;
1385            self.redeposited_atoms_per_m += redeposited_apm;
1386            if let StepPolicy::Adaptive {
1387                max_ions_per_step,
1388                max_change,
1389                ..
1390            } = self.cfg.policy
1391            {
1392                self.current_n = if change < 0.5 * max_change {
1393                    n.saturating_mul(2).min(max_ions_per_step)
1394                } else {
1395                    n
1396                };
1397            }
1398            if let Some(p) = &mut self.profiles {
1399                if p.at.binary_search(&self.next_index).is_ok() {
1400                    let f = self.cfg.fluence_m2 * self.next_index as f64 / self.beam.count as f64;
1401                    p.snapshots
1402                        .push(p.acc.snapshot(self.beam.ion.z(), f, self.next_index));
1403                }
1404            }
1405            return Ok(Some(Step2d {
1406                step: self.steps,
1407                first_index: first,
1408                ions: n,
1409                attempts,
1410                max_change: change,
1411                clamped,
1412                yields,
1413                surface_m: self.surface.clone(),
1414                incidence_rad: incidence,
1415                column_yield,
1416                sputtered_atoms_per_m: sputtered_apm,
1417                redeposited_atoms_per_m: redeposited_apm,
1418                implanted_atoms_per_m: implanted,
1419                lateral_escapes: lateral,
1420            }));
1421        }
1422    }
1423
1424    /// Run all remaining steps, calling `on_step` after each accepted step.
1425    pub fn run_all(
1426        &mut self,
1427        mut on_step: impl FnMut(&Step2d, &[CompositionGrid]),
1428    ) -> Result<(), DynamicRunError> {
1429        while let Some(rec) = self.step()? {
1430            on_step(&rec, &self.grids);
1431        }
1432        Ok(())
1433    }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438    use super::*;
1439    use crate::geometry::Stack;
1440    use crate::ion::dynamic::Relaxation;
1441
1442    const NM: f64 = 1e-9;
1443
1444    fn si() -> Material {
1445        let mut m = Material::from_atom_fractions(&[(14, 1.0)], None).unwrap();
1446        m.set_displacement_energy_ev(14, 15.0).unwrap();
1447        m.set_lattice_binding_energy_ev(14, 2.0).unwrap();
1448        m.set_surface_binding_energy_ev(14, 2.0).unwrap();
1449        m
1450    }
1451
1452    fn target(surface: &[f64], boundary: Boundary) -> ColumnTarget {
1453        let stack = Stack::new(vec![(si(), 10.0 * NM)], Some(si())).unwrap();
1454        let g =
1455            CompositionGrid::from_stack(&stack, Relaxation::ideal_mixing_elemental(&[14]).unwrap())
1456                .unwrap();
1457        let grids = vec![g; surface.len()];
1458        ColumnTarget::new(&grids, surface, 10.0 * NM, boundary).unwrap()
1459    }
1460
1461    #[test]
1462    fn flat_surface_escape_is_vertical_and_exact() {
1463        let t = target(&[0.0; 3], Boundary::Periodic);
1464        let r = t.locate([2.0 * NM, 5.0 * NM, 0.0]).unwrap();
1465        // Straight up, 2 nm to the surface.
1466        let f = t.flight(r, [2.0 * NM, 5.0 * NM, 0.0], [-1.0, 0.0, 0.0], 1.0);
1467        let Flight::Event(e) = f else { panic!("{f:?}") };
1468        assert!((e.distance - 2.0 * NM).abs() < 1e-18);
1469        match e.outcome {
1470            ExitOutcome::Escape { face, normal } => {
1471                assert_eq!(face, Face::Front);
1472                assert_eq!(normal, [-1.0, 0.0, 0.0]);
1473            }
1474            o => panic!("{o:?}"),
1475        }
1476        // Down through the layer boundary at 10 nm.
1477        let f = t.flight(r, [2.0 * NM, 5.0 * NM, 0.0], [1.0, 0.0, 0.0], 1.0);
1478        let Flight::Event(e) = f else { panic!("{f:?}") };
1479        assert!((e.distance - 8.0 * NM).abs() < 1e-18);
1480        assert!(matches!(e.outcome, ExitOutcome::Enter { region, .. } if region == r + 1));
1481    }
1482
1483    #[test]
1484    fn sloped_surface_normal_follows_the_neighbours() {
1485        // Surface deepens by 10 nm per column: slope 1 (45 degrees).
1486        let t = target(&[0.0, 10.0 * NM, 20.0 * NM], Boundary::Vacuum);
1487        // Between centres 0 and 1 (y in [5, 15] nm) the surface is x = y - 5.
1488        let pos = [12.0 * NM, 10.0 * NM, 0.0];
1489        let r = t.locate(pos).unwrap();
1490        let f = t.flight(r, pos, [-1.0, 0.0, 0.0], 1.0);
1491        let Flight::Event(e) = f else { panic!("{f:?}") };
1492        assert!((e.distance - 7.0 * NM).abs() < 1e-18, "{}", e.distance);
1493        let ExitOutcome::Escape { normal, .. } = e.outcome else {
1494            panic!()
1495        };
1496        let s = std::f64::consts::FRAC_1_SQRT_2;
1497        assert!((normal[0] + s).abs() < 1e-12 && (normal[1] - s).abs() < 1e-12);
1498        // Above the surface is outside.
1499        assert_eq!(t.locate([2.0 * NM, 10.0 * NM, 0.0]), None);
1500        assert!(t.in_vacuum([2.0 * NM, 10.0 * NM, 0.0]));
1501    }
1502
1503    #[test]
1504    fn periodic_wrap_and_vacuum_side() {
1505        let p = target(&[0.0; 2], Boundary::Periodic);
1506        let pos = [3.0 * NM, 19.0 * NM, 0.0];
1507        let r = p.locate(pos).unwrap();
1508        let Flight::Event(e) = p.flight(r, pos, [0.0, 1.0, 0.0], 1.0) else {
1509            panic!()
1510        };
1511        let ExitOutcome::Enter { region, pos } = e.outcome else {
1512            panic!()
1513        };
1514        assert_eq!(p.split(region).0, 0);
1515        assert_eq!(pos[1], 0.0);
1516        let v = target(&[0.0; 2], Boundary::Vacuum);
1517        let r = v.locate([3.0 * NM, 19.0 * NM, 0.0]).unwrap();
1518        let Flight::Event(e) = v.flight(r, [3.0 * NM, 19.0 * NM, 0.0], [0.0, 1.0, 0.0], 1.0) else {
1519            panic!()
1520        };
1521        assert!(matches!(
1522            e.outcome,
1523            ExitOutcome::Escape {
1524                face: Face::Side,
1525                ..
1526            }
1527        ));
1528    }
1529
1530    #[test]
1531    fn redeposition_trace_hits_the_far_wall_of_a_groove() {
1532        // A groove: depths 0, 40, 0 nm at the centres 5, 15, 25 nm (vacuum
1533        // ends). The right flank is x = 40 - 4 (y - 15) for y in [15, 25].
1534        // A ray from the floor, 4 nm left of the flank at 36 nm depth,
1535        // going up and right meets it at y = 16.2 nm: column 1.
1536        let t = target(&[0.0, 40.0 * NM, 0.0], Boundary::Vacuum);
1537        let hit = t.trace_to_surface([36.0 * NM, 15.0 * NM, 0.0], [-0.6, 0.8, 0.0]);
1538        assert_eq!(hit, Some(1));
1539        // Straight up: lost.
1540        assert_eq!(
1541            t.trace_to_surface([40.0 * NM, 15.0 * NM, 0.0], [-1.0, 0.0, 0.0]),
1542            None
1543        );
1544        // Going up and out over the flat rim (vacuum end): lost.
1545        assert_eq!(
1546            t.trace_to_surface([0.0, 25.0 * NM, 0.0], [-0.5, 0.866, 0.0]),
1547            None
1548        );
1549    }
1550}