Skip to main content

lindhard/input/
schema.rs

1//! Schema types: the serde structs and enums of the run description.
2//!
3//! Moved out of the parent module unchanged; every item is re-exported there.
4
5use crate::ion::potential::{Screening, ScreeningLength};
6use crate::ion::stopping::table::StoppingTable;
7use crate::material::MaterialSpec;
8use serde::de::{self, MapAccess, Visitor};
9use serde::{Deserialize, Deserializer, Serialize};
10use std::collections::BTreeMap;
11use std::path::PathBuf;
12
13// Only for intra-doc links.
14#[cfg(doc)]
15use super::Resolved;
16#[cfg(doc)]
17use crate::ion::bca::{ElectronicLoss, MeanFreePath};
18#[cfg(doc)]
19use crate::ion::stopping::none::NoStopping;
20
21/// `[beam]`: species, energy and direction.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct BeamSpec {
25    /// Element symbol of the projectile, e.g. `"B"`.
26    pub ion: String,
27    /// Projectile mass, u. Default: the standard atomic weight.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub mass_amu: Option<f64>,
30    /// Incident kinetic energy, eV.
31    pub energy_ev: f64,
32    /// Polar angle of incidence from the surface normal, degrees, in `[0, 90)`.
33    #[serde(default)]
34    pub tilt_deg: f64,
35    /// Azimuth of the incidence plane about the normal, degrees.
36    #[serde(default)]
37    pub azimuth_deg: f64,
38    /// Angular spread about the nominal direction (optional; static ion runs
39    /// only). Absent: every primary has the nominal direction.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub divergence: Option<DivergenceSpec>,
42}
43
44/// `[beam.divergence]`: the beam's angular spread about its nominal
45/// direction, angles in degrees. Selected by `model`:
46///
47/// ```toml
48/// [beam.divergence]
49/// model = "gaussian"      # sigma_deg: standard deviation per plane
50/// sigma_deg = 0.05
51/// # model = "uniform-cone" # half_angle_deg: cone half-angle
52/// # half_angle_deg = 0.1
53/// ```
54///
55/// A Gaussian `sigma_deg` is the standard deviation of the angular deviation
56/// in each of two orthogonal planes through the nominal direction (the polar
57/// deviation is then Rayleigh distributed); it is not a cone width. A
58/// uniform cone is uniform in solid angle out to `half_angle_deg`. See
59/// [`crate::ion::crystal::Divergence`] for the laws. The sampled distribution
60/// is conditioned on directions into the target ("inward-conditioned"), and
61/// widths are limited to [`MAX_DIVERGENCE_DEG`].
62#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63#[serde(tag = "model", rename_all = "kebab-case", deny_unknown_fields)]
64pub enum DivergenceSpec {
65    /// Gaussian divergence, standard deviation per plane.
66    Gaussian {
67        /// Standard deviation per plane, degrees, in `[0, 10]`.
68        sigma_deg: f64,
69    },
70    /// Uniform in solid angle within a cone.
71    UniformCone {
72        /// Cone half-angle, degrees, in `[0, 10]`.
73        half_angle_deg: f64,
74    },
75}
76
77/// Largest accepted divergence width, degrees (Gaussian `sigma_deg` or cone
78/// `half_angle_deg`). The Gaussian law is a small-angle reading (plane
79/// angles), and the inward conditioning is documented for small spreads; a
80/// bound of 10 degrees is a front-end limit, not a physical constant.
81pub const MAX_DIVERGENCE_DEG: f64 = 10.0;
82
83/// A material given by name or inline.
84#[derive(Debug, Clone, PartialEq, Serialize)]
85#[serde(untagged)]
86pub enum MaterialRef {
87    /// A key of `[materials]`, or else an element symbol (a pure element at
88    /// its tabulated density).
89    Name(String),
90    /// An inline material table.
91    Inline(MaterialSpec),
92}
93
94impl<'de> Deserialize<'de> for MaterialRef {
95    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
96        struct V;
97        impl<'de> Visitor<'de> for V {
98            type Value = MaterialRef;
99            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
100                f.write_str("a material name (string) or an inline material table")
101            }
102            fn visit_str<E: de::Error>(self, s: &str) -> Result<MaterialRef, E> {
103                Ok(MaterialRef::Name(s.to_owned()))
104            }
105            fn visit_map<A: MapAccess<'de>>(self, m: A) -> Result<MaterialRef, A::Error> {
106                MaterialSpec::deserialize(de::value::MapAccessDeserializer::new(m))
107                    .map(MaterialRef::Inline)
108            }
109        }
110        d.deserialize_any(V)
111    }
112}
113
114/// One finite layer, front to back.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct LayerSpec {
118    /// The layer material.
119    pub material: MaterialRef,
120    /// Thickness, nm.
121    pub thickness_nm: f64,
122}
123
124/// `[target]`: finite layers front to back, then an optional semi-infinite
125/// substrate. At least one of the two must be given.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct TargetSpec {
129    /// Finite layers, front first.
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub layers: Vec<LayerSpec>,
132    /// Semi-infinite substrate behind the layers. Without one the target has
133    /// a back face and particles can be transmitted.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub substrate: Option<MaterialRef>,
136}
137
138/// Screening function choice.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
140#[serde(rename_all = "kebab-case")]
141pub enum PotentialChoice {
142    /// [`Screening::ZblUniversal`].
143    #[default]
144    Zbl,
145    /// [`Screening::KrC`].
146    KrC,
147    /// [`Screening::Moliere`].
148    Moliere,
149    /// [`Screening::LenzJensen`].
150    LenzJensen,
151}
152
153impl PotentialChoice {
154    /// The engine's screening function.
155    pub fn screening(self) -> Screening {
156        match self {
157            Self::Zbl => Screening::ZblUniversal,
158            Self::KrC => Screening::KrC,
159            Self::Moliere => Screening::Moliere,
160            Self::LenzJensen => Screening::LenzJensen,
161        }
162    }
163}
164
165/// Screening length choice.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "kebab-case")]
168pub enum LengthChoice {
169    /// [`ScreeningLength::Universal`].
170    Universal,
171    /// [`ScreeningLength::Firsov`].
172    Firsov,
173    /// [`ScreeningLength::Lindhard`].
174    Lindhard,
175}
176
177impl LengthChoice {
178    pub(super) fn from_length(l: ScreeningLength) -> Self {
179        match l {
180            ScreeningLength::Universal => Self::Universal,
181            ScreeningLength::Firsov => Self::Firsov,
182            ScreeningLength::Lindhard => Self::Lindhard,
183        }
184    }
185
186    /// The engine's screening length.
187    pub fn length(self) -> ScreeningLength {
188        match self {
189            Self::Universal => ScreeningLength::Universal,
190            Self::Firsov => ScreeningLength::Firsov,
191            Self::Lindhard => ScreeningLength::Lindhard,
192        }
193    }
194}
195
196/// `[stopping]`: user-supplied electronic stopping tables.
197///
198/// ```toml
199/// [stopping]
200/// tables = ["tables/b_in_si.toml"]
201/// ```
202///
203/// Each entry is a file in the [`StoppingTable`] format (one ion and target
204/// element, `eV 1e-15 cm^2` per atom, mandatory `provenance`). Relative paths
205/// resolve against the directory of the input file. A table replaces the
206/// `[physics] stopping` model for the (ion, target element) pair it declares;
207/// every other pair uses the `[physics] stopping` model. Queries outside a
208/// table's energy range are errors, never extrapolated.
209#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
210#[serde(deny_unknown_fields)]
211pub struct StoppingSpec {
212    /// Paths of table files, one per (ion, target element) pair.
213    #[serde(default)]
214    pub tables: Vec<String>,
215}
216
217impl StoppingSpec {
218    /// No tables declared.
219    pub fn is_empty(&self) -> bool {
220        self.tables.is_empty()
221    }
222}
223
224/// A user stopping table after loading, with what a run record needs to
225/// identify the exact file.
226#[derive(Debug, Clone)]
227pub struct LoadedStoppingTable {
228    /// The path as written in the input (`stopping.tables[i]`).
229    pub path: String,
230    /// The path the file was read from (the input directory joined with
231    /// `path`, made absolute when possible).
232    pub resolved_path: PathBuf,
233    /// SHA-256 of the file's bytes, lowercase hex.
234    pub sha256: String,
235    /// The validated table.
236    pub table: StoppingTable,
237}
238
239/// Electronic stopping choice.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
241#[serde(rename_all = "kebab-case")]
242pub enum StoppingChoice {
243    /// Lindhard-Scharff, all loss nonlocal along the free flight.
244    #[default]
245    LindhardScharff,
246    /// Bethe-Bloch (bare charge, Bloch-rule mean excitation energy), all loss
247    /// nonlocal. Only meaningful well above the Lindhard-Scharff regime.
248    BetheBloch,
249    /// Equipartition of Lindhard-Scharff: half nonlocal, half local at each
250    /// collision by Oen-Robinson ([`ElectronicLoss::EquipartitionLsOr`]).
251    EquipartitionLsOr,
252    /// No electronic stopping ([`NoStopping`]): nuclear loss only. For
253    /// like-for-like transport comparisons against codes run with electronic
254    /// stopping off, and for nuclear-only studies; not a realistic setting.
255    /// `[stopping]` tables still serve the pairs they declare.
256    None,
257}
258
259/// Free-path convention.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
261#[serde(rename_all = "kebab-case")]
262pub enum FreePathChoice {
263    /// [`MeanFreePath::Constant`].
264    #[default]
265    Constant,
266    /// [`MeanFreePath::EnergyDependent`]; needs `min_cm_angle_deg`.
267    EnergyDependent,
268}
269
270/// Per-element energy overrides, eV.
271#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
272#[serde(deny_unknown_fields)]
273pub struct EnergyOverride {
274    /// Displacement energy `E_d`.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub e_d_ev: Option<f64>,
277    /// Lattice binding energy `E_b`.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub e_b_ev: Option<f64>,
280    /// Surface binding energy `E_s`.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub e_s_ev: Option<f64>,
283}
284
285fn default_true() -> bool {
286    true
287}
288
289/// `[physics]`: model choices, cutoffs and energy overrides.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291#[serde(deny_unknown_fields)]
292pub struct PhysicsSpec {
293    /// Screening function. Default `"zbl"`.
294    #[serde(default)]
295    pub potential: PotentialChoice,
296    /// Screening length. Default: the length conventionally paired with the
297    /// screening function ([`Screening::default_length`]); filled in on
298    /// resolution and echoed.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub screening_length: Option<LengthChoice>,
301    /// Electronic stopping. Default `"lindhard-scharff"`.
302    #[serde(default)]
303    pub stopping: StoppingChoice,
304    /// Free-path convention. Default `"constant"`.
305    #[serde(default)]
306    pub free_path: FreePathChoice,
307    /// Smallest centre-of-mass deflection treated as a collision, degrees;
308    /// required with `free_path = "energy-dependent"`, rejected otherwise.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub min_cm_angle_deg: Option<f64>,
311    /// Weak collisions per collision step, `0..=3`
312    /// ([`crate::ion::bca::BcaConfig::weak_collisions`]); constant free path
313    /// only. Default 0.
314    #[serde(default, skip_serializing_if = "is_zero_u8")]
315    pub weak_collisions: u8,
316    /// The primary stops below this energy, eV. Required.
317    pub primary_cutoff_ev: f64,
318    /// Recoils stop below this energy, eV. Required. Keep it below the
319    /// smallest `E_s` when sputtering matters.
320    pub recoil_cutoff_ev: f64,
321    /// Follow displaced atoms as full cascades. Default `true`.
322    #[serde(default = "default_true")]
323    pub follow_recoils: bool,
324    /// Planar surface barrier for the beam species, eV. Default 0.
325    #[serde(default)]
326    pub primary_surface_binding_ev: f64,
327    /// `E_d`/`E_b`/`E_s` overrides by element symbol, applied to that element
328    /// in every layer after the material's own values (so they win).
329    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
330    pub energies: BTreeMap<String, EnergyOverride>,
331    /// Opt-in phenomenological calibration: `"none"` (default) or the name of
332    /// a versioned factor set ([`TuningSet`]). Not a published model choice.
333    /// See [`TuningSet`] for what it scales and where it applies.
334    #[serde(default = "default_tuning", skip_serializing_if = "is_no_tuning")]
335    pub tuning: String,
336}
337
338/// The `tuning` value that leaves the physics untouched.
339pub const NO_TUNING: &str = "none";
340
341fn default_tuning() -> String {
342    NO_TUNING.to_string()
343}
344
345fn is_no_tuning(s: &String) -> bool {
346    s == NO_TUNING
347}
348
349/// A named, immutable, versioned set of phenomenological surface-binding
350/// energy multipliers (the pilot of the opt-in "experiment-tuned" mode).
351///
352/// This is calibration, not a published model choice: the factors stand for
353/// the uncertain planar-barrier convention (`E_s` = cohesive energy). Each
354/// record names its measured source data and fit recipe in `provenance`
355/// (and in `docs/data-provenance.md`). Rules, all enforced on resolution:
356///
357/// - Only `[physics] tuning = "<name>"` selects a set; omission and `"none"`
358///   change nothing, and the echoed input is then identical to an untuned one.
359/// - A factor multiplies the *resolved* `E_s` of its element (the explicit
360///   `[physics.energies]` / material value if present, else the elemental
361///   default), exactly once per layer component, after overrides. The global
362///   element table and the collision algorithm are never touched.
363/// - Pilot scope: ion runs on single-element layers only, with a beam species
364///   the set was fitted for ([`TuningSet::ions`]) and a target element the set
365///   lists. Compounds, a `[dynamic]` target, other beams, unlisted elements and
366///   electron input are rejected. A beam energy outside the fitted range or a
367///   tilted beam is allowed but warned about (an extrapolation).
368/// - Both the original and the effective energies are reported in
369///   `summary.json` (`physics.tuning`).
370#[derive(Debug, Clone, Copy, PartialEq)]
371pub struct TuningSet {
372    /// Name used in `[physics] tuning`. Names are never reused: a new version
373    /// ships under a new name, so a name always selects the same factors.
374    pub name: &'static str,
375    /// Version of this set; a changed factor is a new version, never an edit.
376    pub version: u32,
377    /// Beam species (element symbols) the factors were fitted for.
378    pub ions: &'static [&'static str],
379    /// Beam energies (eV, inclusive) the fit data covered, at normal incidence.
380    pub energy_range_ev: (f64, f64),
381    /// `E_s` multiplier by element symbol; a target element not listed is
382    /// rejected.
383    pub e_s_factors: &'static [(&'static str, f64)],
384    /// Source measurements and fit procedure, in one line.
385    pub provenance: &'static str,
386}
387
388/// `es-sputter-ar-v1`: one `E_s` multiplier per element for Ar sputtering of
389/// Si, Cu, Ag and Au, fitted by `validation/experiments/run.py --fit-tuning`
390/// to measured yields only (the sets in `validation/data/sputtering/`, each
391/// cited to its original publication in `docs/data-provenance.md`), with a
392/// held-out evaluation in `validation/experiments/tuning_results.json`.
393///
394/// Phenomenological calibration, not a published value: the factors absorb
395/// whatever the untuned matched problem misses (not shown to be `E_s` itself)
396/// and were fitted under those matched settings (ZBL, Lindhard-Scharff,
397/// constant free path, `E_d` = untuned `E_s`, `E_b` = 0, cutoffs 2 / 1 eV,
398/// no weak collisions); their transfer to other settings is untested.
399pub const ES_SPUTTER_AR_V1: TuningSet = TuningSet {
400    name: "es-sputter-ar-v1",
401    version: 1,
402    ions: &["Ar"],
403    energy_range_ev: (196.0, 10020.0),
404    e_s_factors: &[("Si", 0.65), ("Cu", 0.55), ("Ag", 0.30), ("Au", 0.45)],
405    provenance: "E_s multipliers fitted to measured Ar sputter yields (validation/data/sputtering, \
406                 training sets only; held-out sets scored separately) by validation/experiments/run.py \
407                 --fit-tuning; record: validation/experiments/tuning_results.json, docs/data-provenance.md \
408                 (Tuning factor sets); issue #80",
409};
410
411/// The sets that ship with the engine. Each has a reproducible fit record and
412/// a held-out evaluation (`docs/data-provenance.md`, "Tuning factor sets").
413pub const TUNING_SETS: &[TuningSet] = &[ES_SPUTTER_AR_V1];
414
415/// One component's tuning, as applied.
416#[derive(Debug, Clone, PartialEq, Serialize)]
417pub struct TunedEnergy {
418    /// Index into [`Resolved::layers`].
419    pub layer: usize,
420    /// Element symbol.
421    pub element: String,
422    /// Resolved `E_s` before the factor, eV.
423    pub e_s_original_ev: f64,
424    /// The multiplier (1 when the set has no entry for the element).
425    pub factor: f64,
426    /// `E_s` used by the run, eV.
427    pub e_s_effective_ev: f64,
428}
429
430/// What a tuning set did to a run; present in [`Resolved`] only when enabled.
431#[derive(Debug, Clone, PartialEq, Serialize)]
432pub struct TuningReport {
433    /// Set name.
434    pub set: String,
435    /// Set version.
436    pub version: u32,
437    /// The set's provenance line.
438    pub provenance: String,
439    /// Always `"surface-binding-energy"` in the pilot.
440    pub quantity: &'static str,
441    /// Per layer component.
442    pub components: Vec<TunedEnergy>,
443}
444
445/// Cubic lattice preset of a `[[crystal]]` assignment. Each maps to a cited
446/// preset of [`crate::ion::crystal::Lattice`] (sources and lattice constants:
447/// `docs/crystal-orientation.md`, `docs/data-provenance.md`).
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
449pub enum CrystalPreset {
450    /// Diamond-structure silicon ([`crate::ion::crystal::Lattice::silicon`]).
451    #[serde(rename = "Si")]
452    Si,
453    /// Diamond-structure germanium.
454    #[serde(rename = "Ge")]
455    Ge,
456    /// Zincblende gallium arsenide.
457    #[serde(rename = "GaAs")]
458    GaAs,
459    /// Zincblende cubic silicon carbide.
460    #[serde(rename = "3C-SiC")]
461    SiC3C,
462}
463
464impl CrystalPreset {
465    /// The preset's name as written in the input.
466    pub fn name(self) -> &'static str {
467        match self {
468            Self::Si => "Si",
469            Self::Ge => "Ge",
470            Self::GaAs => "GaAs",
471            Self::SiC3C => "3C-SiC",
472        }
473    }
474
475    /// The engine's lattice.
476    pub fn lattice(self) -> crate::ion::crystal::Lattice {
477        use crate::ion::crystal::Lattice;
478        match self {
479            Self::Si => Lattice::silicon(),
480            Self::Ge => Lattice::germanium(),
481            Self::GaAs => Lattice::gallium_arsenide(),
482            Self::SiC3C => Lattice::silicon_carbide_3c(),
483        }
484    }
485
486    /// The cited default Debye temperature, K
487    /// ([`crate::ion::crystal::debye`]).
488    pub fn default_debye_temperature_k(self) -> f64 {
489        use crate::ion::crystal::debye::*;
490        match self {
491            Self::Si => THETA_D_SI,
492            Self::Ge => THETA_D_GE,
493            Self::GaAs => THETA_D_GAAS,
494            Self::SiC3C => THETA_D_3C_SIC,
495        }
496    }
497}
498
499/// `thermal` of a `[[crystal]]`: Debye-model vibration of the lattice atoms
500/// ([`crate::ion::bca::Thermal`]). Absent: a static, perfect lattice.
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502#[serde(deny_unknown_fields)]
503pub struct CrystalThermalSpec {
504    /// Target temperature, K (finite, `>= 0`). Sets the vibration amplitude
505    /// only; the lattice constant is not expanded.
506    pub temperature_k: f64,
507    /// Debye temperature, K (finite, positive). Default: the preset's cited
508    /// value ([`CrystalPreset::default_debye_temperature_k`]).
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub debye_temperature_k: Option<f64>,
511}
512
513/// `[[crystal]]`: one cubic crystal filling some stack layers; every layer
514/// not named stays amorphous. Several entries may describe different
515/// crystals in different layers.
516///
517/// ```toml
518/// [[crystal]]
519/// layers = [0]          # indices into the stack: finite layers, then the substrate
520/// preset = "Si"
521/// normal = [0, 0, 1]    # (hkl), inward surface normal
522/// reference = [0, 1, 0] # [uvw] in the surface plane: the zero of the azimuth
523/// ```
524///
525/// The beam's `tilt_deg` and `azimuth_deg` are the engine's tilt and twist
526/// (`docs/crystal-orientation.md`); there is no second beam direction.
527#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
528#[serde(deny_unknown_fields)]
529pub struct CrystalSpec {
530    /// Stack layer indices (front to back; the substrate is the last index)
531    /// filled by this crystal.
532    pub layers: Vec<usize>,
533    /// Lattice preset.
534    pub preset: CrystalPreset,
535    /// Miller indices `(hkl)` of the surface plane; the inward normal.
536    pub normal: [i32; 3],
537    /// Direction `[uvw]` in the surface plane (`h u + k v + l w = 0`) that is
538    /// lab `+y` at zero wafer rotation. Required: the azimuth has no hidden
539    /// zero.
540    pub reference: [i32; 3],
541    /// Wafer rotation about the surface normal, degrees. Default 0.
542    #[serde(default)]
543    pub wafer_rotation_deg: f64,
544    /// Thermal vibration. Default: static lattice.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub thermal: Option<CrystalThermalSpec>,
547    /// Largest partner impact parameter, nm. Default: the nearest-neighbour
548    /// distance.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub p_max_nm: Option<f64>,
551    /// Largest path distance between simultaneous partners, nm. Default: 5 %
552    /// of the nearest-neighbour distance.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub q_max_nm: Option<f64>,
555    /// Length of one search segment, nm (speed only). Default: the lattice
556    /// constant.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub search_length_nm: Option<f64>,
559}
560
561/// `[run]`: history count, seed and threads.
562#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
563#[serde(deny_unknown_fields)]
564pub struct RunSpec {
565    /// Number of primary ions (histories).
566    pub ions: u64,
567    /// Run seed. Results depend only on the seed and the input, never on the
568    /// thread count.
569    pub seed: u64,
570    /// Worker threads. Default: all available cores. Does not change results,
571    /// so it is not part of the echoed input.
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    pub threads: Option<usize>,
574}
575
576/// Volume-relaxation convention of a dynamic run
577/// ([`crate::ion::dynamic::Relaxation`]).
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
579#[serde(rename_all = "kebab-case")]
580pub enum RelaxationChoice {
581    /// Ideal mixing of atomic volumes; every element at its elemental solid
582    /// volume unless `atomic_volume_nm3` gives one.
583    #[default]
584    IdealMixing,
585    /// One total atom number density for every slab; needs
586    /// `number_density_cm3`.
587    FixedNumberDensity,
588}
589
590pub(super) fn default_min_ions() -> u64 {
591    1
592}
593
594/// `[dynamic]`: a fluence-dependent target. The run's `ions` are delivered in
595/// steps; after each step the target composition is updated from the
596/// transport events (module docs of [`crate::ion::dynamic`]).
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598#[serde(deny_unknown_fields)]
599pub struct DynamicSpec {
600    /// Total fluence of the whole run, ions/cm². The `run.ions` histories
601    /// represent it, each standing for `fluence_cm2 / ions` ions/cm².
602    pub fluence_cm2: f64,
603    /// Ions per step; with `max_change` set, the largest (and first) step.
604    pub ions_per_step: u64,
605    /// Adaptive steps: the largest relative composition change per step (see
606    /// [`crate::ion::dynamic::DynamicRun`]). Absent: fixed steps.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub max_change: Option<f64>,
609    /// Adaptive steps: the smallest step. Default 1.
610    #[serde(default = "default_min_ions")]
611    pub min_ions_per_step: u64,
612    /// Split every finite layer into slabs at most this thick, nm. Default
613    /// (absent): each layer is one slab.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub slab_nm: Option<f64>,
616    /// Volume relaxation convention. Default `"ideal-mixing"`.
617    #[serde(default)]
618    pub relaxation: RelaxationChoice,
619    /// Total atom number density, atoms/cm³ (`"fixed-number-density"` only).
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub number_density_cm3: Option<f64>,
622    /// Atomic volume per element symbol, nm³/atom (`"ideal-mixing"` only);
623    /// overrides the elemental solid volume. Required for an element with no
624    /// tabulated solid density (a gas, for example).
625    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
626    pub atomic_volume_nm3: BTreeMap<String, f64>,
627    /// Energies for elements that only enter the target during the run (the
628    /// beam species, for example); an element already in a layer keeps the
629    /// energies of that layer. Falls back to `[physics.energies]`, then to
630    /// the element defaults.
631    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
632    pub energies: BTreeMap<String, EnergyOverride>,
633    /// Sputter erosion and surface recession: sputtered atoms are removed from
634    /// the front of the target and the surface follows (see
635    /// [`crate::ion::dynamic::DynamicRun`], "Erosion"). Default `false`, which
636    /// keeps the surface fixed at `x = 0`.
637    #[serde(default, skip_serializing_if = "is_false")]
638    pub erosion: bool,
639    /// Retention limit per element symbol: the largest atomic fraction of that
640    /// element a slab may hold, in (0, 1); the excess is re-emitted (see
641    /// [`crate::ion::dynamic::DynamicRun`], "Retention limit"). There is no
642    /// default value; absent or empty: no limit.
643    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
644    pub retention_limit: BTreeMap<String, f64>,
645}
646
647fn is_false(v: &bool) -> bool {
648    !*v
649}
650
651fn is_zero_u8(v: &u8) -> bool {
652    *v == 0
653}
654
655fn default_bin_nm() -> f64 {
656    1.0
657}
658fn default_bins() -> usize {
659    1000
660}
661fn default_lateral_bins() -> usize {
662    100
663}
664fn default_escape_energy_bins() -> usize {
665    100
666}
667fn default_escape_polar_bins() -> usize {
668    30
669}
670
671/// `[tally]`: what the run records.
672#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
673#[serde(deny_unknown_fields)]
674pub struct TallySpec {
675    /// Depth-bin width of the stopped-primary profile, nm. Default 1.
676    #[serde(default = "default_bin_nm")]
677    pub depth_bin_nm: f64,
678    /// Number of depth bins; the last also collects everything deeper.
679    /// Default 1000.
680    #[serde(default = "default_bins")]
681    pub depth_bins: usize,
682    /// Write the final state of every primary ion. Default `true`.
683    #[serde(default = "default_true")]
684    pub per_ion: bool,
685    /// Bin width of the lateral (`y`, `z`) and radial profiles of stopped
686    /// primaries, nm. Default 1.
687    #[serde(default = "default_bin_nm")]
688    pub lateral_bin_nm: f64,
689    /// Lateral bins per side of the beam axis: `y` and `z` are binned over
690    /// `[-lateral_bins * lateral_bin_nm, +lateral_bins * lateral_bin_nm)` and
691    /// the radial distance over `[0, lateral_bins * lateral_bin_nm)`.
692    /// Default 100.
693    #[serde(default = "default_lateral_bins")]
694    pub lateral_bins: usize,
695    /// Upper edge of the escape-energy spectra, eV. Default (absent): the
696    /// beam energy.
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub escape_energy_max_ev: Option<f64>,
699    /// Number of escape-energy bins over `[0, escape_energy_max_ev)`.
700    /// Default 100.
701    #[serde(default = "default_escape_energy_bins")]
702    pub escape_energy_bins: usize,
703    /// Number of escape polar-angle bins over `[0, 90)` degrees from the
704    /// outward surface normal. Default 30.
705    #[serde(default = "default_escape_polar_bins")]
706    pub escape_polar_bins: usize,
707    /// Also fit a dual-Pearson profile to the depth histogram. Default
708    /// `false`.
709    #[serde(default)]
710    pub dual_pearson: bool,
711    /// Also write the 3D voxel tally with per-element vacancy, interstitial
712    /// and replacement maps (`[tally.voxel]`). Default: not written. Static
713    /// ion runs only.
714    #[serde(default, skip_serializing_if = "Option::is_none")]
715    pub voxel: Option<VoxelTallySpec>,
716}
717
718/// `[tally.voxel]`: the regular grid of the voxel tally
719/// ([`crate::tally::VoxelTallyGrid`]). `x` is the depth; the beam is along
720/// `+x` from `y = z = 0`. Voxel `i` owns `[origin + i * spacing, origin + (i +
721/// 1) * spacing)` on each axis.
722#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
723#[serde(deny_unknown_fields)]
724pub struct VoxelTallySpec {
725    /// Corner of the first voxel `[x, y, z]`, nm.
726    pub origin_nm: [f64; 3],
727    /// Voxel edge lengths `[x, y, z]`, nm.
728    pub spacing_nm: [f64; 3],
729    /// Voxel counts `[nx, ny, nz]`.
730    pub dims: [usize; 3],
731}
732
733impl Default for TallySpec {
734    fn default() -> Self {
735        Self {
736            depth_bin_nm: default_bin_nm(),
737            depth_bins: default_bins(),
738            per_ion: true,
739            lateral_bin_nm: default_bin_nm(),
740            lateral_bins: default_lateral_bins(),
741            escape_energy_max_ev: None,
742            escape_energy_bins: default_escape_energy_bins(),
743            escape_polar_bins: default_escape_polar_bins(),
744            dual_pearson: false,
745            voxel: None,
746        }
747    }
748}