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}