Skip to main content

lindhard/ion/
dynamic.rs

1//! Dynamic composition: a finite-slab target whose per-slab composition can
2//! change, with volume relaxation.
3//!
4//! This is the stateful target model for fluence-dependent runs. The
5//! [`CompositionGrid`] itself is only the bookkeeping: callers supply explicit
6//! per-slab, per-element inventory deltas. Two submodule layers sit on top of
7//! it: [`InventoryTally`] turns the transport events of a block of primaries
8//! into those deltas (it is a [`BcaTally`](crate::ion::bca::BcaTally)), and
9//! [`DynamicRun`] is the fluence stepping loop (fixed or adaptive steps,
10//! deterministic at any thread count; see its module docs). [`DynamicRun2d`]
11//! is the two-dimensional variant (a laterally finite beam on a surface that
12//! recedes non-uniformly, with redeposition); see its module docs.
13//!
14//! # Inventory and units
15//!
16//! Each slab stores an **areal inventory** `A_i` in atoms/m² per atomic number
17//! (kept sorted by `Z`, so every derived quantity is deterministic). Thickness
18//! is in metres. A slab built from an existing layer gets
19//! `A_i = n_i * thickness`, with `n_i` the layer's atom number density
20//! ([`Material::number_density_of`]).
21//!
22//! # Coordinates
23//!
24//! As in [`crate::geometry`]: depth `x = 0` is the front surface and grows into
25//! the target. After every relaxation the cumulative boundaries are rebuilt
26//! from the front surface by [`Stack::new`], so layers are contiguous. The
27//! front surface is fixed at `x = 0`: swelling moves the interior interfaces
28//! and the back face, never the front surface. With sputter erosion on
29//! ([`DynamicConfig::erosion`]) the lost material is removed from the front
30//! and the grid is re-anchored, so the new surface is again `x = 0`; the
31//! cumulative recession `R` (m, reported per step) maps a depth back to the
32//! original frame as `x + R`. With erosion off the surface never moves. Only
33//! finite slabs are mutable. An optional semi-infinite substrate is an
34//! immutable backing material and is never part of the inventory.
35//!
36//! # Volume relaxation
37//!
38//! After an update, each slab's thickness is recomputed from its inventory so
39//! atomic densities stay consistent with a stated convention
40//! ([`Relaxation`]). The convention is applied to **every** slab on every
41//! update, including untouched ones, so a zero update is the identity if and
42//! only if the grid is already consistent with the convention.
43//!
44//! * [`Relaxation::IdealMixing`]: additive atomic volumes (Vegard-type
45//!   additivity applied to atoms). With reference atomic volumes `v_i`
46//!   (m³/atom), `thickness = sum_i A_i v_i`. Atom fractions are
47//!   `A_i / sum_j A_j` and the mass density is the total mass per area divided
48//!   by the thickness. Choose `v_i` as the atomic volume in the phase you wish
49//!   to represent; for an element in its own solid,
50//!   [`atomic_volume_from_density`] gives `M / (N_A rho)`. The caller must
51//!   supply the volume of every species that occurs: none is inferred, in
52//!   particular not for gases with no elemental solid density. A compound's
53//!   real density generally differs from the ideal-mixture prediction, and so
54//!   does the first relaxation of a grid built from a layer of such a compound;
55//!   the grid's initial thicknesses are kept as given until the first update.
56//!   Tradeoff: simple and parameter-light, but ignores chemistry, voids and
57//!   amorphization swelling.
58//! * [`Relaxation::FixedNumberDensity`]: the caller specifies one total atom
59//!   number density `n_mix` (atoms/m³) for every slab, and
60//!   `thickness = sum_i A_i / n_mix`. Needs no per-species data and reproduces
61//!   a known compound density, but the density cannot respond to composition:
62//!   a slab that goes from Si to mostly implanted species keeps the same
63//!   atoms per volume, and a single value serves all slabs, which suits
64//!   compositions that stay near one phase.
65//!
66//! Neither option is an equation of state with a literature-backed pressure or
67//! phase model; both are stated conventions.
68//!
69//! # Conservation and update semantics
70//!
71//! A slab inventory changes only by the deltas passed to
72//! [`CompositionGrid::apply`]. How a caller derives them:
73//!
74//! * A retained primary adds one atom of its species at its rest slab. A
75//!   primary that is transmitted or backscattered without retention removes no
76//!   target atom and adds nothing.
77//! * A recoil moves an existing atom: subtract at recoil creation, add where it
78//!   comes to rest. An internal relocation therefore cancels in the global
79//!   total of that species ([`CompositionGrid::total_inventory`]); a recoil
80//!   that escapes the target is a loss, with no addition after escape.
81//! * Beam and target atoms of the same element are the same species in the
82//!   inventory; the roles differ only in how the deltas are produced.
83//! * Aggregate front/back escape counts cannot say which slab an escaped atom
84//!   came from; loss deltas need the origin slab from the transport events,
85//!   not the exit position. The default tally gets it by subtracting at the
86//!   recoil event; [`InventoryTally`] also counts sputtered atoms per origin
87//!   slab (`Particle::origin_layer`).
88//! * With erosion on, the loss of a sputtered atom is not taken at its origin
89//!   slab but from the front of the target, element by element, the origin
90//!   loss being cancelled so nothing is removed twice (see
91//!   [`DynamicRun`], "Erosion").
92//!
93//! # Transactions
94//!
95//! [`CompositionGrid::apply`] either succeeds completely or returns an error
96//! and leaves the grid untouched. It rejects non-finite deltas, unknown slabs
97//! or elements, results below zero, missing or invalid reference volumes and
98//! overflow to non-finite thickness or density. A result a few ulps below zero
99//! (within `4 eps` of the larger of the inventory and the delta) from removing
100//! exactly what was there is rounded to zero. A species at exactly zero is
101//! dropped from its slab; a slab with no species left is removed, and its
102//! index is reported in [`UpdateOutcome::removed_slabs`] (indices of the grid
103//! before the update). If every slab disappears the update is an error unless
104//! a substrate backs the grid, in which case the grid is the bare substrate.
105//!
106//! # Energies
107//!
108//! Displacement, lattice-binding and surface-binding energies given on the
109//! initial materials are remembered per slab and per element, including
110//! explicitly overridden values, and re-applied when materials are rebuilt.
111//! An element new to a slab gets the defaults of [`crate::elements`].
112
113mod adapter;
114mod driver;
115mod grid2d;
116mod profiles;
117
118pub use adapter::{InventoryTally, Yields};
119pub use driver::{DynamicConfig, DynamicRun, DynamicRunError, StepPolicy, StepRecord};
120pub use grid2d::{BeamProfile, Dynamic2dConfig, DynamicRun2d, Step2d, PROFILE_CUTOFF};
121pub use profiles::{
122    compare_profile, CutThrough, PointDifference, ProfileCell, ProfileComparison, ProfileSnapshot,
123};
124
125use std::collections::BTreeMap;
126
127use crate::constants::AVOGADRO;
128use crate::elements::element;
129use crate::geometry::{GeometryError, Stack};
130use crate::material::{Material, MaterialError};
131
132/// Errors from building or updating a [`CompositionGrid`].
133#[derive(Debug, Clone, PartialEq, thiserror::Error)]
134pub enum DynamicError {
135    /// A delta was NaN or infinite.
136    #[error("non-finite inventory delta {delta} atoms/m^2 for slab {slab}, Z={z}")]
137    NonFiniteDelta {
138        /// Slab index.
139        slab: usize,
140        /// Atomic number.
141        z: u8,
142        /// Offending value.
143        delta: f64,
144    },
145    /// A delta referred to a slab that does not exist.
146    #[error("slab {slab} out of range ({n_slabs} slabs)")]
147    SlabOutOfRange {
148        /// Slab index.
149        slab: usize,
150        /// Number of slabs.
151        n_slabs: usize,
152    },
153    /// Unknown atomic number.
154    #[error("unknown atomic number {0}")]
155    UnknownAtomicNumber(u8),
156    /// An update would make an inventory negative.
157    #[error("slab {slab}, Z={z}: removing more than present ({result} atoms/m^2 would remain)")]
158    NegativeInventory {
159        /// Slab index.
160        slab: usize,
161        /// Atomic number.
162        z: u8,
163        /// Resulting (negative) value.
164        result: f64,
165    },
166    /// The relaxation convention has no volume for a species present.
167    #[error("no reference atomic volume for Z={0}")]
168    MissingVolume(u8),
169    /// A reference volume or number density was not finite and positive.
170    #[error("invalid relaxation parameter {0}: must be finite and positive")]
171    InvalidParameter(f64),
172    /// An element has no tabulated solid density to derive a volume from.
173    #[error("element Z={0} has no elemental solid density; supply its volume explicitly")]
174    NoElementalDensity(u8),
175    /// A relaxed thickness or density was not finite and positive.
176    #[error("slab {slab}: relaxed thickness or density is not finite and positive (overflow)")]
177    Overflow {
178        /// Slab index.
179        slab: usize,
180    },
181    /// Every slab was depleted and there is no substrate.
182    #[error("update would leave an empty target")]
183    EmptyTarget,
184    /// A slab of the initial stack had no usable composition.
185    #[error("initial composition invalid: {0}")]
186    InvalidInitial(String),
187    /// Stack construction failed.
188    #[error(transparent)]
189    Geometry(#[from] GeometryError),
190    /// Material construction failed.
191    #[error(transparent)]
192    Material(#[from] MaterialError),
193}
194
195/// Atomic volume `M / (N_A rho)` in m³/atom of element `z` in its elemental
196/// solid, from the default density of [`crate::elements`] (conventional
197/// atomic weight, CODATA `N_A`). Errors if `z` is unknown or has no tabulated
198/// density (for example a gas): no volume is invented for those.
199pub fn atomic_volume_from_density(z: u8) -> Result<f64, DynamicError> {
200    let el = element(z).ok_or(DynamicError::UnknownAtomicNumber(z))?;
201    let rho = el
202        .density_kg_m3()
203        .ok_or(DynamicError::NoElementalDensity(z))?;
204    Ok(el.atomic_weight * 1.0e-3 / (AVOGADRO * rho))
205}
206
207/// Volume-relaxation convention. See the [module docs](self).
208#[derive(Debug, Clone, PartialEq)]
209pub enum Relaxation {
210    /// Ideal mixing of atomic volumes, `thickness = sum A_i v_i`.
211    IdealMixing {
212        /// Reference atomic volume per atomic number, m³/atom.
213        atomic_volumes_m3: BTreeMap<u8, f64>,
214    },
215    /// Caller-specified total atom number density, `thickness = sum A_i / n`.
216    FixedNumberDensity {
217        /// Atoms per m³.
218        number_density_m3: f64,
219    },
220}
221
222impl Relaxation {
223    /// Ideal mixing with explicit volumes (m³/atom) per atomic number.
224    pub fn ideal_mixing(volumes: &[(u8, f64)]) -> Result<Self, DynamicError> {
225        let mut map = BTreeMap::new();
226        for &(z, v) in volumes {
227            if element(z).is_none() {
228                return Err(DynamicError::UnknownAtomicNumber(z));
229            }
230            if !(v.is_finite() && v > 0.0) {
231                return Err(DynamicError::InvalidParameter(v));
232            }
233            map.insert(z, v);
234        }
235        Ok(Self::IdealMixing {
236            atomic_volumes_m3: map,
237        })
238    }
239
240    /// Ideal mixing with each listed element at its elemental solid volume
241    /// ([`atomic_volume_from_density`]).
242    pub fn ideal_mixing_elemental(zs: &[u8]) -> Result<Self, DynamicError> {
243        let v: Result<Vec<_>, _> = zs
244            .iter()
245            .map(|&z| atomic_volume_from_density(z).map(|v| (z, v)))
246            .collect();
247        Self::ideal_mixing(&v?)
248    }
249
250    /// Fixed total number density, atoms/m³.
251    pub fn fixed_number_density(number_density_m3: f64) -> Result<Self, DynamicError> {
252        if !(number_density_m3.is_finite() && number_density_m3 > 0.0) {
253            return Err(DynamicError::InvalidParameter(number_density_m3));
254        }
255        Ok(Self::FixedNumberDensity { number_density_m3 })
256    }
257
258    /// Thickness in m of an inventory (all entries positive).
259    fn thickness_m(&self, inv: &BTreeMap<u8, f64>) -> Result<f64, DynamicError> {
260        match self {
261            Self::IdealMixing { atomic_volumes_m3 } => {
262                let mut t = 0.0;
263                for (&z, &a) in inv {
264                    let v = atomic_volumes_m3
265                        .get(&z)
266                        .ok_or(DynamicError::MissingVolume(z))?;
267                    t += a * v;
268                }
269                Ok(t)
270            }
271            Self::FixedNumberDensity { number_density_m3 } => {
272                Ok(inv.values().sum::<f64>() / number_density_m3)
273            }
274        }
275    }
276}
277
278/// An inventory change for one element in one slab, atoms/m² (positive adds).
279#[derive(Debug, Clone, Copy, PartialEq)]
280pub struct InventoryDelta {
281    /// Slab index (0 = front), in the grid before the update.
282    pub slab: usize,
283    /// Atomic number.
284    pub z: u8,
285    /// Change in areal inventory, atoms/m².
286    pub delta_atoms_m2: f64,
287}
288
289/// Result of a successful [`CompositionGrid::apply`].
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct UpdateOutcome {
292    /// Indices (before the update) of slabs removed because they emptied.
293    pub removed_slabs: Vec<usize>,
294}
295
296#[derive(Debug, Clone, Copy, Default, PartialEq)]
297struct Energies {
298    e_d: Option<f64>,
299    e_b: Option<f64>,
300    e_s: Option<f64>,
301}
302
303#[derive(Debug, Clone, PartialEq)]
304struct Slab {
305    inventory: BTreeMap<u8, f64>,
306    thickness_m: f64,
307    energies: BTreeMap<u8, Energies>,
308    name: Option<String>,
309}
310
311impl Slab {
312    fn from_layer(material: &Material, thickness_m: f64) -> Result<Self, DynamicError> {
313        let mut inventory = BTreeMap::new();
314        let mut energies = BTreeMap::new();
315        for c in material.components() {
316            let z = c.z();
317            let a = material.number_density_of(z)? * thickness_m;
318            if a > 0.0 {
319                inventory.insert(z, a);
320            }
321            energies.insert(
322                z,
323                Energies {
324                    e_d: material.displacement_energy_ev(z).ok(),
325                    e_b: material.lattice_binding_energy_ev(z).ok(),
326                    e_s: material.surface_binding_energy_ev(z).ok(),
327                },
328            );
329        }
330        if inventory.is_empty() {
331            return Err(DynamicError::InvalidInitial(
332                "a layer has no atoms".to_string(),
333            ));
334        }
335        Ok(Self {
336            inventory,
337            thickness_m,
338            energies,
339            name: material.name().map(str::to_owned),
340        })
341    }
342
343    /// Material for this slab's current inventory and thickness.
344    fn material(&self) -> Result<Material, DynamicError> {
345        let parts: Vec<(u8, f64)> = self.inventory.iter().map(|(&z, &a)| (z, a)).collect();
346        let atoms_per_area: f64 = self.inventory.values().sum();
347        let mass_per_area: f64 = self
348            .inventory
349            .iter()
350            .map(|(&z, &a)| a * element(z).expect("validated").atomic_weight * 1.0e-3 / AVOGADRO)
351            .sum();
352        let density = mass_per_area / self.thickness_m;
353        if !(atoms_per_area.is_finite() && density.is_finite() && density > 0.0) {
354            return Err(DynamicError::Overflow { slab: usize::MAX });
355        }
356        let mut m = Material::from_atom_fractions(&parts, Some(density))?;
357        if let Some(n) = &self.name {
358            m = m.with_name(n.clone());
359        }
360        for &(z, _) in &parts {
361            if let Some(e) = self.energies.get(&z) {
362                if let Some(v) = e.e_d {
363                    m.set_displacement_energy_ev(z, v)?;
364                }
365                if let Some(v) = e.e_b {
366                    m.set_lattice_binding_energy_ev(z, v)?;
367                }
368                if let Some(v) = e.e_s {
369                    m.set_surface_binding_energy_ev(z, v)?;
370                }
371            }
372        }
373        Ok(m)
374    }
375}
376
377/// A finite-slab target with per-slab species inventories, an optional
378/// immutable substrate, and a [`Relaxation`] convention.
379#[derive(Debug, Clone, PartialEq)]
380pub struct CompositionGrid {
381    slabs: Vec<Slab>,
382    substrate: Option<Material>,
383    relaxation: Relaxation,
384}
385
386impl CompositionGrid {
387    /// Build from a [`Stack`]: finite layers become slabs (inventory
388    /// `n_i * thickness`, thicknesses kept as given), and a semi-infinite last
389    /// layer becomes the immutable substrate.
390    pub fn from_stack(stack: &Stack, relaxation: Relaxation) -> Result<Self, DynamicError> {
391        let mut slabs = Vec::new();
392        let mut substrate = None;
393        for layer in stack.layers() {
394            if layer.thickness_m().is_finite() {
395                slabs.push(Slab::from_layer(layer.material(), layer.thickness_m())?);
396            } else {
397                substrate = Some(layer.material().clone());
398            }
399        }
400        Ok(Self {
401            slabs,
402            substrate,
403            relaxation,
404        })
405    }
406
407    /// Whether an immutable substrate backs the slabs.
408    pub fn has_substrate(&self) -> bool {
409        self.substrate.is_some()
410    }
411
412    /// Number of mutable slabs.
413    pub fn n_slabs(&self) -> usize {
414        self.slabs.len()
415    }
416
417    /// The relaxation convention in use.
418    pub fn relaxation(&self) -> &Relaxation {
419        &self.relaxation
420    }
421
422    /// Slab thicknesses in m, front first.
423    pub fn thicknesses_m(&self) -> Vec<f64> {
424        self.slabs.iter().map(|s| s.thickness_m).collect()
425    }
426
427    /// Inventory of one slab as `(Z, atoms/m^2)` sorted by `Z`.
428    pub fn inventory(&self, slab: usize) -> Option<Vec<(u8, f64)>> {
429        self.slabs
430            .get(slab)
431            .map(|s| s.inventory.iter().map(|(&z, &a)| (z, a)).collect())
432    }
433
434    /// Record energies for element `z` in every slab, for the case that `z` is
435    /// (or later becomes) present: only the values that are still unset are
436    /// filled, so explicit values and defaults already in place are kept.
437    /// This is how an element that only enters through implantation or
438    /// recoil mixing (the beam species, a substrate element) gets the
439    /// displacement, lattice-binding and surface-binding energies the engine
440    /// requires of every element in a layer.
441    pub fn seed_energies(
442        &mut self,
443        z: u8,
444        e_d_ev: Option<f64>,
445        e_b_ev: Option<f64>,
446        e_s_ev: Option<f64>,
447    ) {
448        let defaults = Material::from_atom_fractions(&[(z, 1.0)], None).ok();
449        for s in &mut self.slabs {
450            let e = s.energies.entry(z).or_default();
451            e.e_d = e.e_d.or(e_d_ev).or_else(|| {
452                defaults
453                    .as_ref()
454                    .and_then(|m| m.displacement_energy_ev(z).ok())
455            });
456            e.e_b = e.e_b.or(e_b_ev).or_else(|| {
457                defaults
458                    .as_ref()
459                    .and_then(|m| m.lattice_binding_energy_ev(z).ok())
460            });
461            e.e_s = e.e_s.or(e_s_ev).or_else(|| {
462                defaults
463                    .as_ref()
464                    .and_then(|m| m.surface_binding_energy_ev(z).ok())
465            });
466        }
467    }
468
469    /// Total areal inventory of element `z` over all finite slabs, atoms/m².
470    /// The substrate is not counted. Summed front to back.
471    pub fn total_inventory(&self, z: u8) -> f64 {
472        self.slabs
473            .iter()
474            .map(|s| s.inventory.get(&z).copied().unwrap_or(0.0))
475            .sum()
476    }
477
478    /// Re-relax without changing any inventory; same as `apply(&[])`.
479    pub fn relax(&mut self) -> Result<UpdateOutcome, DynamicError> {
480        self.apply(&[])
481    }
482
483    /// Apply inventory deltas, then relax every slab. Transactional: on error
484    /// the grid is unchanged. See the [module docs](self).
485    pub fn apply(&mut self, deltas: &[InventoryDelta]) -> Result<UpdateOutcome, DynamicError> {
486        let n = self.slabs.len();
487        let mut inv: Vec<BTreeMap<u8, f64>> =
488            self.slabs.iter().map(|s| s.inventory.clone()).collect();
489        // Largest magnitude seen per (slab, Z), for the rounding tolerance.
490        let mut scale: BTreeMap<(usize, u8), f64> = BTreeMap::new();
491        for d in deltas {
492            if d.slab >= n {
493                return Err(DynamicError::SlabOutOfRange {
494                    slab: d.slab,
495                    n_slabs: n,
496                });
497            }
498            if element(d.z).is_none() {
499                return Err(DynamicError::UnknownAtomicNumber(d.z));
500            }
501            if !d.delta_atoms_m2.is_finite() {
502                return Err(DynamicError::NonFiniteDelta {
503                    slab: d.slab,
504                    z: d.z,
505                    delta: d.delta_atoms_m2,
506                });
507            }
508            let e = inv[d.slab].entry(d.z).or_insert(0.0);
509            let s = scale.entry((d.slab, d.z)).or_insert(e.abs());
510            *s = s.max(e.abs()).max(d.delta_atoms_m2.abs());
511            *e += d.delta_atoms_m2;
512            if !e.is_finite() {
513                return Err(DynamicError::Overflow { slab: d.slab });
514            }
515        }
516        // Clean up: tolerance, negatives, zeros.
517        for (i, m) in inv.iter_mut().enumerate() {
518            for (&z, a) in m.iter_mut() {
519                if *a < 0.0 {
520                    let tol = 4.0 * f64::EPSILON * scale.get(&(i, z)).copied().unwrap_or(0.0);
521                    if -*a <= tol {
522                        *a = 0.0;
523                    } else {
524                        return Err(DynamicError::NegativeInventory {
525                            slab: i,
526                            z,
527                            result: *a,
528                        });
529                    }
530                }
531            }
532            m.retain(|_, a| *a > 0.0);
533        }
534        let removed_slabs: Vec<usize> = (0..n).filter(|&i| inv[i].is_empty()).collect();
535        if removed_slabs.len() == n && self.substrate.is_none() {
536            return Err(DynamicError::EmptyTarget);
537        }
538        // Relax and rebuild into a new slab list.
539        let mut new_slabs = Vec::with_capacity(n);
540        for (i, m) in inv.into_iter().enumerate() {
541            if m.is_empty() {
542                continue;
543            }
544            let t = self.relaxation.thickness_m(&m)?;
545            if !(t.is_finite() && t > 0.0) {
546                return Err(DynamicError::Overflow { slab: i });
547            }
548            let energies = self.slabs[i].energies.clone();
549            let slab = Slab {
550                inventory: m,
551                thickness_m: t,
552                energies,
553                name: self.slabs[i].name.clone(),
554            };
555            // Validate materials now so failure cannot leave a partial grid.
556            slab.material().map_err(|e| match e {
557                DynamicError::Overflow { .. } => DynamicError::Overflow { slab: i },
558                other => other,
559            })?;
560            new_slabs.push(slab);
561        }
562        self.slabs = new_slabs;
563        Ok(UpdateOutcome { removed_slabs })
564    }
565
566    /// Convert to the engine's layer model: contiguous finite layers from the
567    /// front surface, then the substrate if any.
568    pub fn to_stack(&self) -> Result<Stack, DynamicError> {
569        let layers = self
570            .slabs
571            .iter()
572            .map(|s| Ok((s.material()?, s.thickness_m)))
573            .collect::<Result<Vec<_>, DynamicError>>()?;
574        Ok(Stack::new(layers, self.substrate.clone())?)
575    }
576}