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}