Skip to main content

lindhard/ion/dynamic/
driver.rs

1//! The fluence stepping loop.
2//!
3//! [`DynamicRun`] drives a [`CompositionGrid`] through a sequence of fluence
4//! steps: run `n` primaries on the current target, turn the transport events
5//! into inventory deltas ([`InventoryTally`]), apply them, and repeat until
6//! all primaries of the beam are used.
7//!
8//! # Fluence
9//!
10//! The beam's `count` is the total number of primaries of the run and
11//! [`DynamicConfig::fluence_m2`] the total fluence they represent, so each
12//! primary stands for `fluence_m2 / count` atoms/m². A step of `n` primaries
13//! scales its integer atom counts by that, giving the inventory deltas in
14//! atoms/m². (The delta of a step is a mean over the `n` histories; the
15//! target is held fixed during the step, so the step must be small enough
16//! that the composition does not change much within it. That is what the
17//! adaptive policy bounds.)
18//!
19//! # Determinism
20//!
21//! The primaries of a run are numbered `0..count` globally and primary `i`
22//! always draws from [`stream`](crate::rng::stream)`(seed, i)`. A step that
23//! covers global primaries `start..start + n` runs exactly those histories
24//! ([`Bca::run_range`]), so a run is reproducible end to end, a step never
25//! replays indices of an earlier one, and the thread count never matters:
26//! per-chunk tallies are integer counts merged in chunk order and the deltas
27//! are applied in `(slab, Z)` order.
28//!
29//! # Adaptive steps
30//!
31//! With [`StepPolicy::Adaptive`] a step is accepted only if its largest
32//! relative composition change is at most `max_change`, where the change of a
33//! slab is `|delta_Z| / (total atoms/m² of the slab)` maximised over the
34//! elements `Z` (a slab emptied completely has change 1). A step that is too
35//! large is **discarded** (the grid is not touched), shrunk to
36//! `floor(n * max_change / change)` primaries (at least `min_ions_per_step`
37//! and fewer than before) and retried from the **same** first index, so
38//! rejected attempts consume no indices: the sequence of accepted
39//! `(first_index, n)` pairs is decided from merged step results only, hence
40//! deterministic. A step that fails [`CompositionGrid::apply`] (for example
41//! it would remove more of an element than a slab holds) is treated the same
42//! way, halving `n`. At `min_ions_per_step` a step is accepted whatever its
43//! change; if it still removes more of an element than a slab holds (the last
44//! atoms of an eroding slab), that removal is capped at what the slab holds
45//! and counted in [`StepRecord::clamped`] (the step then creates the atoms the
46//! cap refused to remove, so inventory is not conserved for that step by up to
47//! one step's worth of removal). A [`StepPolicy::Fixed`] run does not cap: an
48//! `apply` error is returned, with the grid unchanged, and the cure is a
49//! smaller step or an adaptive policy. After an accepted step with
50//! change below `max_change / 2` the next step size doubles, up to
51//! `max_ions_per_step`. These rules are this crate's own design, not taken
52//! from a published code.
53//!
54//! # Coordinates
55//!
56//! The front surface is `x = 0` of the grid at every step ([parent
57//! module](super), "Coordinates"). Per-step outputs report the thickness of
58//! the finite slabs and the interface depths measured from the surface of that
59//! moment. With [`DynamicConfig::erosion`] off the surface never moves in the
60//! sample frame either, and the depths are the depths of the original frame.
61//! With erosion on, the surface recedes ("Erosion" below) and a depth `x`
62//! at some step is `x + R` in the original frame, with `R` the cumulative
63//! recession ([`StepRecord::recession_total_m`]) at that step.
64//!
65//! # Erosion
66//!
67//! With [`DynamicConfig::erosion`] on, sputtered atoms leave the target from
68//! its front, not from the slab where they were displaced, as in the
69//! dynamic-composition codes TRIDYN (Moller and Eckstein, Nucl. Instrum.
70//! Methods B 2 (1984) 814, and the later TRIDYN papers: the target is updated
71//! by removing the sputtered atoms from the surface layers and letting the
72//! remaining material move up) and SDTrimSP (Mutzke et al., IPP report
73//! 2019-02, the sections on the dynamic target and sputter erosion). Only the
74//! published descriptions were used. The update, per accepted step:
75//!
76//! 1. The default tally subtracts every recoil at its origin slab, so an
77//!    escaped recoil is already a loss there. Erosion adds those losses back
78//!    (the tally counts them per origin slab, as integers, from
79//!    `Particle::origin_layer`) and instead removes the same number of atoms
80//!    of the same element, `Y_Z` per element, from the front of the target:
81//!    slab 0 first, then deeper slabs when the element is used up (counting
82//!    what the slab holds after the step's other deltas). Nothing is removed
83//!    twice and no atom is created or lost: for each `Z` the net change of
84//!    [`CompositionGrid::total_inventory`](super::CompositionGrid::total_inventory)
85//!    is the implanted atoms minus the sputtered ones, as with erosion off.
86//!    Atoms that came from the substrate were never in the inventory and
87//!    remove nothing. Counts are scaled to atoms/m² like all other deltas.
88//! 2. The step recession `dR` is the thickness the removed inventory occupied
89//!    under the grid's [`Relaxation`](super::Relaxation):
90//!    `sum_Z removed_Z v_Z` (ideal mixing) or `sum_Z removed_Z / n_mix` (fixed
91//!    number density). It is tracked explicitly and summed over accepted steps
92//!    into `R`; it cannot be recovered from the thickness, which also grows
93//!    with implantation.
94//! 3. Removal is part of the deltas the step is checked and applied with, so
95//!    `max_change` and the adaptive policy see it. Removal never exceeds what
96//!    the slabs hold, so it is never the cause of the `clamped` cap.
97//! 4. The grid is rebuilt from `x = 0` as usual, which is the re-anchoring.
98//!
99//! Erosion is a pure function of the step's merged integer tally and draws no
100//! random numbers, so it keeps the determinism of the run. With erosion off
101//! none of this code runs and results are unchanged.
102//!
103//! # Retention limit
104//!
105//! [`DynamicRun::with_retention`] sets, per implanted species, a saturation
106//! limit: the largest atomic fraction `f` of that species a slab may hold. The
107//! dynamic-composition codes TRIDYN (Moller and Eckstein, Nucl. Instrum.
108//! Methods B 2 (1984) 814) and SDTrimSP (Mutzke et al., IPP report 2019-02)
109//! describe a saturation concentration of the implanted species above which
110//! further atoms are not retained and are released again; only those published
111//! descriptions were used. The update below is this crate's own statement of
112//! that idea, and no numeric default is supplied: the limit is always given by
113//! the caller.
114//!
115//! Per accepted step, after the transport deltas (and erosion) are formed and
116//! before they are checked or applied, each slab is looked at with the
117//! composition it would have after the step. For a limited species `Z` with
118//! amount `a_Z` and `o` atoms/m² of all other elements, the slab may hold at
119//! most `a_max = f o / (1 - f)` of `Z` (that is `a_Z / (a_Z + o) <= f`). Any
120//! excess `a_Z - a_max` is **re-emitted**: it is added to the step's deltas as
121//! a removal from that slab and leaves the target, so it is not inventory any
122//! more and is reported in [`StepRecord::re_emitted_m2`]. Species are taken in
123//! ascending `Z` and each sees the amounts left by the earlier ones, so with
124//! several limited species in one slab the fractions are met in that order
125//! (removing one raises the fraction of the others slightly); with one limited
126//! species, the usual case, the limit is met exactly. A slab holding only the
127//! limited species has `o = 0` and loses all of it. Because the removal is part
128//! of the deltas, `max_change` and the adaptive policy see it. It is a pure
129//! function of the merged step tally and the grid, draws no random numbers and
130//! so keeps the thread-count invariance. The re-emitted atoms are not placed
131//! anywhere: this is a bookkeeping limit, not a model of diffusion or of where
132//! the released gas goes. With no limits set none of this code runs and
133//! results are unchanged.
134//!
135//! The atom balance of an element `Z` over a step is
136//! `Δ total_inventory(Z) = transport net - re_emitted`, where the transport net
137//! counts retained primaries and moved, sputtered and transmitted recoils per
138//! the conventions of the [parent module](super).
139
140use std::collections::BTreeMap;
141
142use crate::ion::bca::{Bca, BcaConfig, BcaError, Beam};
143use crate::ion::scattering::ScatteringTable;
144use crate::ion::stopping::ElectronicStopping;
145
146use super::adapter::{InventoryTally, Yields};
147use super::{CompositionGrid, DynamicError, InventoryDelta};
148
149/// How the primaries are divided into fluence steps.
150#[derive(Debug, Clone, Copy, PartialEq)]
151pub enum StepPolicy {
152    /// Every step has `ions_per_step` primaries (the last may have fewer).
153    Fixed {
154        /// Primaries per step, at least 1.
155        ions_per_step: u64,
156    },
157    /// Steps start at `max_ions_per_step`, shrink when the composition change
158    /// exceeds `max_change` and grow back (see the [module docs](super)).
159    Adaptive {
160        /// Largest and initial step, at least `min_ions_per_step`.
161        max_ions_per_step: u64,
162        /// Smallest step, at least 1.
163        min_ions_per_step: u64,
164        /// Largest accepted relative composition change per step, `> 0`.
165        max_change: f64,
166    },
167}
168
169/// Fluence-loop settings.
170#[derive(Debug, Clone, Copy, PartialEq)]
171pub struct DynamicConfig {
172    /// Total fluence of the whole run, ions/m², represented by the beam's
173    /// `count` primaries.
174    pub fluence_m2: f64,
175    /// Step sizes.
176    pub policy: StepPolicy,
177    /// Sputter erosion and surface recession (see the [`DynamicRun`] docs,
178    /// "Erosion"). Off reproduces the fixed-front behaviour exactly.
179    pub erosion: bool,
180}
181
182/// Errors from the fluence loop.
183#[derive(Debug, thiserror::Error)]
184pub enum DynamicRunError {
185    /// A setting is out of range.
186    #[error("invalid dynamic run setting: {0}")]
187    Invalid(String),
188    /// The grid rejected an update (or could not be turned into a stack) and
189    /// no smaller step is allowed.
190    #[error("composition update failed: {0}")]
191    Grid(#[from] DynamicError),
192    /// The transport engine could not be set up or failed.
193    #[error("transport failed: {0}")]
194    Transport(#[from] BcaError),
195}
196
197/// What one accepted step did.
198#[derive(Debug, Clone, PartialEq)]
199pub struct StepRecord {
200    /// Step number, from 1.
201    pub step: u64,
202    /// Global index of the first primary of the step.
203    pub first_index: u64,
204    /// Primaries in the step.
205    pub ions: u64,
206    /// Attempts made at this `first_index` (1 if never rejected).
207    pub attempts: u32,
208    /// Largest relative composition change of the step (see the
209    /// [module docs](super)).
210    pub max_change: f64,
211    /// `(slab, element)` removals that were capped at what the slab held (see
212    /// the [module docs](super)); 0 for a step that needed no capping.
213    pub clamped: u32,
214    /// Event counts of the step.
215    pub yields: Yields,
216    /// Slabs (indices before the step) that emptied and were removed.
217    pub removed_slabs: Vec<usize>,
218    /// Surface recession of this step, m (0 with erosion off).
219    pub recession_m: f64,
220    /// Cumulative recession up to and including this step, m (0 with erosion
221    /// off): add it to a depth of this step to get the depth in the original
222    /// frame.
223    pub recession_total_m: f64,
224    /// Atoms/m² re-emitted by the retention limit in this step, by atomic
225    /// number (see the module docs, "Retention limit"); empty with no
226    /// limit set or nothing in excess.
227    pub re_emitted_m2: BTreeMap<u8, f64>,
228}
229
230/// A fluence-stepped run. See the [module docs](super).
231pub struct DynamicRun<'a> {
232    grid: CompositionGrid,
233    beam: Beam,
234    config: BcaConfig,
235    stopping: &'a (dyn ElectronicStopping + Sync),
236    table: &'a ScatteringTable,
237    cfg: DynamicConfig,
238    next_index: u64,
239    steps: u64,
240    current_n: u64,
241    cumulative: Yields,
242    recession_m: f64,
243    retention: BTreeMap<u8, f64>,
244    re_emitted_m2: BTreeMap<u8, f64>,
245}
246
247// Manual: the borrowed stopping model is a trait object without `Debug`, and
248// the scattering table is large.
249impl std::fmt::Debug for DynamicRun<'_> {
250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251        f.debug_struct("DynamicRun")
252            .field("beam", &self.beam)
253            .field("config", &self.config)
254            .field("cfg", &self.cfg)
255            .field("next_index", &self.next_index)
256            .field("steps", &self.steps)
257            .field("current_n", &self.current_n)
258            .field("cumulative", &self.cumulative)
259            .field("recession_m", &self.recession_m)
260            .field("retention", &self.retention)
261            .finish_non_exhaustive()
262    }
263}
264
265impl<'a> DynamicRun<'a> {
266    /// Set up a run of `beam.count` primaries on `grid`.
267    pub fn new(
268        grid: CompositionGrid,
269        beam: Beam,
270        config: BcaConfig,
271        stopping: &'a (dyn ElectronicStopping + Sync),
272        table: &'a ScatteringTable,
273        cfg: DynamicConfig,
274    ) -> Result<Self, DynamicRunError> {
275        let bad = |m: &str| Err(DynamicRunError::Invalid(m.to_string()));
276        if beam.count == 0 {
277            return bad("the beam needs at least one primary");
278        }
279        if !(cfg.fluence_m2.is_finite() && cfg.fluence_m2 > 0.0) {
280            return bad("fluence must be finite and positive");
281        }
282        let current_n = match cfg.policy {
283            StepPolicy::Fixed { ions_per_step } => {
284                if ions_per_step == 0 {
285                    return bad("ions_per_step must be at least 1");
286                }
287                ions_per_step
288            }
289            StepPolicy::Adaptive {
290                max_ions_per_step,
291                min_ions_per_step,
292                max_change,
293            } => {
294                if min_ions_per_step == 0 || max_ions_per_step < min_ions_per_step {
295                    return bad("need 1 <= min_ions_per_step <= max_ions_per_step");
296                }
297                if !(max_change.is_finite() && max_change > 0.0) {
298                    return bad("max_change must be finite and positive");
299                }
300                max_ions_per_step
301            }
302        };
303        Ok(Self {
304            grid,
305            beam,
306            config,
307            stopping,
308            table,
309            cfg,
310            next_index: 0,
311            steps: 0,
312            current_n,
313            cumulative: Yields::default(),
314            recession_m: 0.0,
315            retention: BTreeMap::new(),
316            re_emitted_m2: BTreeMap::new(),
317        })
318    }
319
320    /// Set the retention limits (see the module docs, "Retention
321    /// limit"): for each `(Z, f)` the largest atomic fraction `f`, with
322    /// `0 < f < 1`, of element `Z` a slab may hold. Replaces any earlier
323    /// limits; an empty map switches the model off (the default).
324    pub fn with_retention(
325        mut self,
326        limits: impl IntoIterator<Item = (u8, f64)>,
327    ) -> Result<Self, DynamicRunError> {
328        let mut map = BTreeMap::new();
329        for (z, f) in limits {
330            if !(f.is_finite() && f > 0.0 && f < 1.0) {
331                return Err(DynamicRunError::Invalid(format!(
332                    "the retention limit of Z = {z} must be an atomic fraction in (0, 1)"
333                )));
334            }
335            if map.insert(z, f).is_some() {
336                return Err(DynamicRunError::Invalid(format!(
337                    "the retention limit of Z = {z} is given twice"
338                )));
339            }
340        }
341        self.retention = map;
342        Ok(self)
343    }
344
345    /// Atoms/m² re-emitted by the retention limit over the accepted steps, by
346    /// atomic number.
347    pub fn re_emitted_m2(&self) -> &BTreeMap<u8, f64> {
348        &self.re_emitted_m2
349    }
350
351    /// The current target.
352    pub fn grid(&self) -> &CompositionGrid {
353        &self.grid
354    }
355
356    /// Primaries used so far (accepted steps only).
357    pub fn ions_done(&self) -> u64 {
358        self.next_index
359    }
360
361    /// Total primaries of the run.
362    pub fn ions_total(&self) -> u64 {
363        self.beam.count
364    }
365
366    /// Fluence delivered so far, ions/m².
367    pub fn fluence_done_m2(&self) -> f64 {
368        self.next_index as f64 * self.fluence_per_ion_m2()
369    }
370
371    /// Fluence one primary stands for, ions/m².
372    pub fn fluence_per_ion_m2(&self) -> f64 {
373        self.cfg.fluence_m2 / self.beam.count as f64
374    }
375
376    /// Accepted steps so far.
377    pub fn steps_done(&self) -> u64 {
378        self.steps
379    }
380
381    /// Event counts summed over the accepted steps.
382    pub fn cumulative(&self) -> &Yields {
383        &self.cumulative
384    }
385
386    /// Cumulative surface recession of the accepted steps, m (0 with erosion
387    /// off).
388    pub fn recession_m(&self) -> f64 {
389        self.recession_m
390    }
391
392    /// Run the next step. `Ok(None)` when all primaries are used. On error the
393    /// grid and the run state are unchanged.
394    pub fn step(&mut self) -> Result<Option<StepRecord>, DynamicRunError> {
395        let remaining = self.beam.count - self.next_index;
396        if remaining == 0 {
397            return Ok(None);
398        }
399        let first = self.next_index;
400        let mut n = self.current_n.min(remaining);
401        let mut attempts = 0u32;
402        loop {
403            attempts += 1;
404            let n_slabs = self.grid.n_slabs();
405            let stack = self.grid.to_stack()?;
406            let beam = Beam {
407                count: n,
408                ..self.beam
409            };
410            let bca = Bca::new(beam, &stack, self.config, self.stopping, self.table)?;
411            let tally = bca.run_range(first, n, || InventoryTally::new(n_slabs))?;
412
413            let per_ion = self.fluence_per_ion_m2();
414            let (deltas, step_recession, leftover) = if self.cfg.erosion {
415                self.erosion_deltas(&tally, per_ion)?
416            } else {
417                let d = tally
418                    .counts()
419                    .map(|((slab, z), c)| InventoryDelta {
420                        slab,
421                        z,
422                        delta_atoms_m2: c as f64 * per_ion,
423                    })
424                    .collect();
425                (d, 0.0, 0)
426            };
427            let (deltas, re_emitted) = self.retention_deltas(deltas);
428            let change = self.max_change(&deltas);
429
430            let shrink = match self.cfg.policy {
431                StepPolicy::Adaptive {
432                    min_ions_per_step,
433                    max_change,
434                    ..
435                } if n > min_ions_per_step => {
436                    if change > max_change {
437                        let scaled = (n as f64 * max_change / change).floor() as u64;
438                        Some(scaled.clamp(min_ions_per_step, n - 1))
439                    } else {
440                        None
441                    }
442                }
443                _ => None,
444            };
445            if let Some(smaller) = shrink {
446                n = smaller;
447                continue;
448            }
449
450            let mut clamped = leftover;
451            let outcome = match self.grid.apply(&deltas) {
452                Ok(o) => o,
453                Err(e) => {
454                    let StepPolicy::Adaptive {
455                        min_ions_per_step, ..
456                    } = self.cfg.policy
457                    else {
458                        return Err(e.into());
459                    };
460                    if n > min_ions_per_step {
461                        n = (n / 2).max(min_ions_per_step);
462                        continue;
463                    }
464                    // Smallest step: a removal beyond what a slab holds is
465                    // capped at what it holds.
466                    if !matches!(e, DynamicError::NegativeInventory { .. }) {
467                        return Err(e.into());
468                    }
469                    let capped: Vec<InventoryDelta> = deltas
470                        .iter()
471                        .map(|d| {
472                            let have = self
473                                .grid
474                                .inventory(d.slab)
475                                .and_then(|v| v.iter().find(|&&(z, _)| z == d.z).map(|&(_, a)| a))
476                                .unwrap_or(0.0);
477                            if d.delta_atoms_m2 < -have {
478                                clamped += 1;
479                                InventoryDelta {
480                                    delta_atoms_m2: -have,
481                                    ..*d
482                                }
483                            } else {
484                                *d
485                            }
486                        })
487                        .collect();
488                    self.grid.apply(&capped)?
489                }
490            };
491            self.steps += 1;
492            self.next_index += n;
493            self.cumulative.add(tally.yields());
494            self.recession_m += step_recession;
495            for (&z, &a) in &re_emitted {
496                *self.re_emitted_m2.entry(z).or_insert(0.0) += a;
497            }
498            if let StepPolicy::Adaptive {
499                max_ions_per_step,
500                max_change,
501                ..
502            } = self.cfg.policy
503            {
504                self.current_n = if change < 0.5 * max_change {
505                    n.saturating_mul(2).min(max_ions_per_step)
506                } else {
507                    n
508                };
509            }
510            return Ok(Some(StepRecord {
511                step: self.steps,
512                first_index: first,
513                ions: n,
514                attempts,
515                max_change: change,
516                clamped,
517                yields: tally.yields().clone(),
518                removed_slabs: outcome.removed_slabs,
519                recession_m: step_recession,
520                recession_total_m: self.recession_m,
521                re_emitted_m2: re_emitted,
522            }));
523        }
524    }
525
526    /// The deltas of a step with erosion on, its recession in m, and the
527    /// number of elements whose sputtered count exceeded what the slabs hold
528    /// (that excess is not removed; the step reports it in `clamped`). See the
529    /// `DynamicRun` docs, "Erosion".
530    fn erosion_deltas(
531        &self,
532        tally: &InventoryTally,
533        per_ion: f64,
534    ) -> Result<(Vec<InventoryDelta>, f64, u32), DynamicRunError> {
535        // Integer net counts with the origin-slab loss of sputtered atoms
536        // cancelled; sputtered atoms per element to take from the front.
537        let mut net: BTreeMap<(usize, u8), i64> = tally.counts().collect();
538        let mut sputtered: BTreeMap<u8, u64> = BTreeMap::new();
539        for ((slab, z), n) in tally.sputtered_from() {
540            *net.entry((slab, z)).or_insert(0) += n as i64;
541            *sputtered.entry(z).or_insert(0) += n;
542        }
543        let mut delta: BTreeMap<(usize, u8), f64> = net
544            .into_iter()
545            .map(|(k, c)| (k, c as f64 * per_ion))
546            .collect();
547        let inventories: Vec<Vec<(u8, f64)>> = (0..self.grid.n_slabs())
548            .map(|i| self.grid.inventory(i).unwrap_or_default())
549            .collect();
550        let mut removed: BTreeMap<u8, f64> = BTreeMap::new();
551        let mut leftover = 0u32;
552        for (&z, &y) in &sputtered {
553            let mut rem = y as f64 * per_ion;
554            for (slab, inv) in inventories.iter().enumerate() {
555                if rem <= 0.0 {
556                    break;
557                }
558                let have = inv
559                    .iter()
560                    .find(|&&(zz, _)| zz == z)
561                    .map_or(0.0, |&(_, a)| a);
562                let d = delta.entry((slab, z)).or_insert(0.0);
563                let avail = (have + *d).max(0.0);
564                let take = avail.min(rem);
565                if take > 0.0 {
566                    *d -= take;
567                    rem -= take;
568                    *removed.entry(z).or_insert(0.0) += take;
569                }
570            }
571            if rem > 0.0 {
572                leftover += 1;
573            }
574        }
575        let recession = if removed.is_empty() {
576            0.0
577        } else {
578            self.grid.relaxation().thickness_m(&removed)?
579        };
580        let deltas = delta
581            .into_iter()
582            .filter(|&(_, d)| d != 0.0)
583            .map(|((slab, z), d)| InventoryDelta {
584                slab,
585                z,
586                delta_atoms_m2: d,
587            })
588            .collect();
589        Ok((deltas, recession, leftover))
590    }
591
592    /// Add the removals the retention limits require to the step's `deltas`;
593    /// returns them with the atoms/m² re-emitted per element. See the module
594    /// docs, "Retention limit". Returns the deltas unchanged with no limits.
595    fn retention_deltas(
596        &self,
597        deltas: Vec<InventoryDelta>,
598    ) -> (Vec<InventoryDelta>, BTreeMap<u8, f64>) {
599        let mut re_emitted = BTreeMap::new();
600        if self.retention.is_empty() {
601            return (deltas, re_emitted);
602        }
603        let mut delta: BTreeMap<(usize, u8), f64> = BTreeMap::new();
604        for d in &deltas {
605            *delta.entry((d.slab, d.z)).or_insert(0.0) += d.delta_atoms_m2;
606        }
607        for slab in 0..self.grid.n_slabs() {
608            let inv = self.grid.inventory(slab).unwrap_or_default();
609            let have = |z: u8| {
610                inv.iter()
611                    .find(|&&(zz, _)| zz == z)
612                    .map_or(0.0, |&(_, a)| a)
613            };
614            // Composition after the step, in Z order.
615            let mut after: BTreeMap<u8, f64> = inv.iter().map(|&(z, a)| (z, a)).collect();
616            for (&(s, z), &d) in delta.range((slab, 0)..=(slab, u8::MAX)) {
617                debug_assert_eq!(s, slab);
618                *after.entry(z).or_insert(0.0) += d;
619            }
620            for (&z, &f) in &self.retention {
621                let a = after.get(&z).copied().unwrap_or(0.0);
622                let others: f64 = after
623                    .iter()
624                    .filter(|(&zz, _)| zz != z)
625                    .map(|(_, &x)| x)
626                    .sum();
627                let a_max = f * others / (1.0 - f);
628                if a <= a_max {
629                    continue;
630                }
631                let excess = a - a_max;
632                let d = delta.entry((slab, z)).or_insert(0.0);
633                // A slab with nothing else loses the species exactly.
634                *d = if a_max == 0.0 { -have(z) } else { *d - excess };
635                after.insert(z, a_max);
636                *re_emitted.entry(z).or_insert(0.0) += excess;
637            }
638        }
639        let deltas = delta
640            .into_iter()
641            .filter(|&(_, d)| d != 0.0)
642            .map(|((slab, z), d)| InventoryDelta {
643                slab,
644                z,
645                delta_atoms_m2: d,
646            })
647            .collect();
648        (deltas, re_emitted)
649    }
650
651    /// Largest `|delta_Z| / (atoms/m^2 of the slab)` over the deltas.
652    fn max_change(&self, deltas: &[InventoryDelta]) -> f64 {
653        let totals: Vec<f64> = (0..self.grid.n_slabs())
654            .map(|i| {
655                self.grid
656                    .inventory(i)
657                    .map_or(0.0, |v| v.iter().map(|&(_, a)| a).sum())
658            })
659            .collect();
660        deltas
661            .iter()
662            .map(|d| d.delta_atoms_m2.abs() / totals[d.slab])
663            .fold(0.0, f64::max)
664    }
665
666    /// Run all remaining steps, calling `on_step` after each accepted step.
667    pub fn run_all(
668        &mut self,
669        mut on_step: impl FnMut(&StepRecord, &CompositionGrid),
670    ) -> Result<(), DynamicRunError> {
671        while let Some(rec) = self.step()? {
672            on_step(&rec, &self.grid);
673        }
674        Ok(())
675    }
676}