Skip to main content

lindhard/input/
resolve.rs

1//! Validation and resolution: turns an [`Input`] into the engine's types.
2
3use crate::elements::element_by_symbol;
4use crate::geometry::Stack;
5use crate::ion::bca::{
6    BcaConfig, Beam, CrystalTarget, ElectronicLoss, MeanFreePath, Thermal, MAX_WEAK_COLLISIONS,
7};
8use crate::ion::crystal::Divergence;
9use crate::ion::stopping::table::StoppingTable;
10use crate::ion::stopping::Ion;
11use crate::material::{EnergyKind, Material};
12use sha2::{Digest, Sha256};
13use std::collections::BTreeMap;
14use std::f64::consts::PI;
15use std::path::Path;
16
17use super::resolved::*;
18use super::schema::*;
19use super::{invalid, Input, InputError};
20
21/// Validate a [`DivergenceSpec`] found at `path` (for error messages) and
22/// convert it to the engine's [`Divergence`]; shared by the ion and electron
23/// inputs.
24pub(crate) fn divergence_from_spec(
25    spec: &DivergenceSpec,
26    path: &str,
27) -> Result<Divergence, InputError> {
28    let (field, width_deg) = match *spec {
29        DivergenceSpec::Gaussian { sigma_deg } => (format!("{path}.sigma_deg"), sigma_deg),
30        DivergenceSpec::UniformCone { half_angle_deg } => {
31            (format!("{path}.half_angle_deg"), half_angle_deg)
32        }
33    };
34    if !(width_deg.is_finite() && (0.0..=MAX_DIVERGENCE_DEG).contains(&width_deg)) {
35        return Err(invalid(
36            field,
37            format!(
38                "must be finite and in [0, {MAX_DIVERGENCE_DEG}] degrees (small-angle \
39                 spreads only)"
40            ),
41        ));
42    }
43    let rad = width_deg.to_radians();
44    Ok(match spec {
45        DivergenceSpec::Gaussian { .. } => Divergence::Gaussian { sigma_rad: rad },
46        DivergenceSpec::UniformCone { .. } => Divergence::UniformCone {
47            half_angle_rad: rad,
48        },
49    })
50}
51
52impl Input {
53    /// Validate `[dynamic]`.
54    fn check_dynamic(&self, d: &DynamicSpec) -> Result<(), InputError> {
55        if !finite_pos(d.fluence_cm2) {
56            return Err(invalid(
57                "dynamic.fluence_cm2",
58                "must be finite and positive",
59            ));
60        }
61        if d.ions_per_step == 0 {
62            return Err(invalid("dynamic.ions_per_step", "must be at least 1"));
63        }
64        match d.max_change {
65            Some(c) if !finite_pos(c) => {
66                return Err(invalid("dynamic.max_change", "must be finite and positive"))
67            }
68            Some(_) => {
69                if d.min_ions_per_step == 0 || d.min_ions_per_step > d.ions_per_step {
70                    return Err(invalid(
71                        "dynamic.min_ions_per_step",
72                        "must be between 1 and ions_per_step",
73                    ));
74                }
75            }
76            None => {
77                if d.min_ions_per_step != default_min_ions() {
78                    return Err(invalid(
79                        "dynamic.min_ions_per_step",
80                        "only used with max_change (adaptive steps)",
81                    ));
82                }
83            }
84        }
85        if let Some(t) = d.slab_nm {
86            if !finite_pos(t) {
87                return Err(invalid("dynamic.slab_nm", "must be finite and positive"));
88            }
89        }
90        match (d.relaxation, d.number_density_cm3) {
91            (RelaxationChoice::FixedNumberDensity, None) => {
92                return Err(invalid(
93                    "dynamic.number_density_cm3",
94                    "required with relaxation = \"fixed-number-density\"",
95                ))
96            }
97            (RelaxationChoice::FixedNumberDensity, Some(n)) if !finite_pos(n) => {
98                return Err(invalid(
99                    "dynamic.number_density_cm3",
100                    "must be finite and positive",
101                ))
102            }
103            (RelaxationChoice::IdealMixing, Some(_)) => {
104                return Err(invalid(
105                    "dynamic.number_density_cm3",
106                    "only used with relaxation = \"fixed-number-density\"",
107                ))
108            }
109            _ => {}
110        }
111        if d.relaxation == RelaxationChoice::FixedNumberDensity && !d.atomic_volume_nm3.is_empty() {
112            return Err(invalid(
113                "dynamic.atomic_volume_nm3",
114                "only used with relaxation = \"ideal-mixing\"",
115            ));
116        }
117        for (sym, v) in &d.atomic_volume_nm3 {
118            let field = format!("dynamic.atomic_volume_nm3.{sym}");
119            if element_by_symbol(sym).is_none() {
120                return Err(invalid(&field, format!("unknown element symbol {sym:?}")));
121            }
122            if !finite_pos(*v) {
123                return Err(invalid(&field, "must be finite and positive"));
124            }
125        }
126        for (sym, o) in &d.energies {
127            let field = format!("dynamic.energies.{sym}");
128            if element_by_symbol(sym).is_none() {
129                return Err(invalid(&field, format!("unknown element symbol {sym:?}")));
130            }
131            for (key, v) in [
132                ("e_d_ev", o.e_d_ev),
133                ("e_b_ev", o.e_b_ev),
134                ("e_s_ev", o.e_s_ev),
135            ] {
136                if v.is_some_and(|v| !(v.is_finite() && v >= 0.0)) {
137                    return Err(invalid(
138                        format!("{field}.{key}"),
139                        "must be finite and non-negative",
140                    ));
141                }
142            }
143        }
144        for (sym, f) in &d.retention_limit {
145            let field = format!("dynamic.retention_limit.{sym}");
146            if element_by_symbol(sym).is_none() {
147                return Err(invalid(&field, format!("unknown element symbol {sym:?}")));
148            }
149            if !(f.is_finite() && *f > 0.0 && *f < 1.0) {
150                return Err(invalid(&field, "must be an atomic fraction in (0, 1)"));
151            }
152        }
153        Ok(())
154    }
155
156    /// Read and validate every `[stopping]` table file.
157    fn load_stopping_tables(
158        &self,
159        base_dir: &Path,
160    ) -> Result<Vec<LoadedStoppingTable>, InputError> {
161        let mut loaded: Vec<LoadedStoppingTable> = Vec::new();
162        for (i, path) in self.stopping.tables.iter().enumerate() {
163            let field = format!("stopping.tables[{i}]");
164            let joined = base_dir.join(path);
165            let bytes = std::fs::read(&joined)
166                .map_err(|e| invalid(&field, format!("cannot read {}: {e}", joined.display())))?;
167            let text = std::str::from_utf8(&bytes).map_err(|_| {
168                invalid(
169                    &field,
170                    format!("{} is not valid UTF-8 text", joined.display()),
171                )
172            })?;
173            let table = StoppingTable::from_toml_str(text)
174                .map_err(|e| invalid(&field, format!("{path}: {e}")))?;
175            if let Some(prev) = loaded
176                .iter()
177                .position(|l| l.table.covers_pair(table.ion_z(), table.target_z()))
178            {
179                return Err(invalid(
180                    &field,
181                    format!(
182                        "{path} declares the same ion/target pair (Z1={}, Z2={}) as \
183                         stopping.tables[{prev}]; give each pair one table",
184                        table.ion_z(),
185                        table.target_z()
186                    ),
187                ));
188            }
189            let sha256 = Sha256::digest(&bytes)
190                .iter()
191                .map(|b| format!("{b:02x}"))
192                .collect();
193            loaded.push(LoadedStoppingTable {
194                path: path.clone(),
195                resolved_path: std::fs::canonicalize(&joined).unwrap_or(joined),
196                sha256,
197                table,
198            });
199        }
200        Ok(loaded)
201    }
202
203    /// Checks of the loaded tables against the rest of the run.
204    fn check_stopping_tables(
205        &self,
206        tables: &[LoadedStoppingTable],
207        ion: &Ion,
208        layers: &[ResolvedLayer],
209        warnings: &mut Vec<String>,
210    ) -> Result<(), InputError> {
211        if tables.is_empty() {
212            return Ok(());
213        }
214        if self.physics.stopping == StoppingChoice::EquipartitionLsOr {
215            return Err(invalid(
216                "stopping.tables",
217                "not usable with physics.stopping = \"equipartition-ls-or\", which carries \
218                 its own Lindhard-Scharff/Oen-Robinson loss and would ignore the tables; \
219                 use \"lindhard-scharff\" (or another model) as the fallback",
220            ));
221        }
222        let e0 = self.beam.energy_ev;
223        let cutoff = self.physics.primary_cutoff_ev;
224        let recoil_cutoff = self.physics.recoil_cutoff_ev;
225        let follow = self.physics.follow_recoils;
226        let tol = crate::ion::stopping::table::MASS_RELATIVE_TOLERANCE;
227        let target_elements: std::collections::BTreeSet<u8> = layers
228            .iter()
229            .flat_map(|ly| ly.material.components().iter().map(|c| c.z()))
230            .collect();
231        let symbol = |z: u8| crate::elements::element(z).map_or("?", |e| e.symbol);
232        for (i, l) in tables.iter().enumerate() {
233            let field = format!("stopping.tables[{i}]");
234            let t = &l.table;
235            let in_target = target_elements.contains(&t.target_z());
236            // A table serves the beam ion of its element and, when recoils
237            // are followed, every recoil of its element (`TableOverride`
238            // keys on Z1, Z2 only).
239            let serves_beam = t.ion_z() == ion.z();
240            let serves_recoils = follow && target_elements.contains(&t.ion_z());
241            if !in_target || !(serves_beam || serves_recoils) {
242                let recoils = if follow {
243                    ""
244                } else {
245                    " and physics.follow_recoils = false"
246                };
247                warnings.push(format!(
248                    "{field}: the table ({}->{}, Z1={}, Z2={}) is for a pair that does not \
249                     occur in this run (beam {}, target elements {}{recoils}); it is unused",
250                    symbol(t.ion_z()),
251                    symbol(t.target_z()),
252                    t.ion_z(),
253                    t.target_z(),
254                    self.beam.ion,
255                    target_elements
256                        .iter()
257                        .map(|&z| symbol(z))
258                        .collect::<Vec<_>>()
259                        .join(", ")
260                ));
261                continue;
262            }
263            let (lo, hi) = t.energy_range_ev();
264            if serves_recoils {
265                // Recoils are built with the standard atomic weight
266                // (`Ion::new`), so a table for another mass can never serve
267                // them, and the run would stop at the first such recoil.
268                let recoil = Ion::new(t.ion_z()).map_err(|e| invalid(&field, e.to_string()))?;
269                if (recoil.mass_amu() - t.ion_mass_amu()).abs() > tol * t.ion_mass_amu() {
270                    return Err(invalid(
271                        &field,
272                        format!(
273                            "{}: table is for an ion of mass {} u, but with \
274                             physics.follow_recoils = true it also serves the {} recoils, \
275                             which have the standard atomic weight {} u; a table serves one \
276                             mass per pair (use the standard weight in the table and the beam, \
277                             drop the table, or set physics.follow_recoils = false)",
278                            l.path,
279                            t.ion_mass_amu(),
280                            symbol(t.ion_z()),
281                            recoil.mass_amu()
282                        ),
283                    ));
284                }
285                // Every recoil is followed down to recoil_cutoff_ev.
286                if recoil_cutoff < lo {
287                    return Err(invalid(
288                        &field,
289                        format!(
290                            "{}: the table starts at {lo} eV, above physics.recoil_cutoff_ev = \
291                             {recoil_cutoff} eV; with physics.follow_recoils = true it serves \
292                             the {} recoils, which slow down to the recoil cutoff, so the run \
293                             would stop outside the table range (extend the table down to the \
294                             cutoff or raise physics.recoil_cutoff_ev)",
295                            l.path,
296                            symbol(t.ion_z()),
297                        ),
298                    ));
299                }
300                // The most a beam collision can hand a recoil of this element.
301                let (m1, m2) = (ion.mass_amu(), recoil.mass_amu());
302                let e_max = 4.0 * m1 * m2 / ((m1 + m2) * (m1 + m2)) * e0;
303                if hi < e_max {
304                    warnings.push(format!(
305                        "{field}: the table ends at {hi} eV, below the largest energy a \
306                         {} recoil can receive from the beam ({e_max:.4e} eV); the run fails \
307                         if a recoil starts above the table range",
308                        symbol(t.ion_z())
309                    ));
310                }
311            }
312            if !serves_beam {
313                continue;
314            }
315            // The beam ion's pair: mass and the energy range it travels.
316            if (ion.mass_amu() - t.ion_mass_amu()).abs() > tol * t.ion_mass_amu() {
317                return Err(invalid(
318                    &field,
319                    format!(
320                        "{}: table is for an ion of mass {} u but the beam ion has mass {} u \
321                         (set `ion_mass_amu` in the table, or beam.mass_amu)",
322                        l.path,
323                        t.ion_mass_amu(),
324                        ion.mass_amu()
325                    ),
326                ));
327            }
328            if e0 < lo || e0 > hi {
329                return Err(invalid(
330                    &field,
331                    format!(
332                        "{}: beam energy {e0} eV is outside the table range [{lo}, {hi}] eV",
333                        l.path
334                    ),
335                ));
336            }
337            if cutoff < lo {
338                warnings.push(format!(
339                    "{field}: the table starts at {lo} eV, above physics.primary_cutoff_ev = \
340                     {cutoff} eV; the run fails if a projectile slows below the table range"
341                ));
342            }
343        }
344        Ok(())
345    }
346
347    fn material(&self, field: &str, r: &MaterialRef) -> Result<ResolvedLayer, InputError> {
348        resolve_material(&self.materials, field, r)
349    }
350
351    /// Validate everything and build the engine's inputs. Errors name the
352    /// offending key.
353    pub fn resolve(&self) -> Result<Resolved, InputError> {
354        self.resolve_in(Path::new("."))
355    }
356
357    /// Like [`Input::resolve`], with relative `[stopping]` table paths taken
358    /// relative to `base_dir` (the directory of the input file).
359    pub fn resolve_in(&self, base_dir: &Path) -> Result<Resolved, InputError> {
360        self.resolve_with_sets(base_dir, TUNING_SETS)
361    }
362
363    /// [`Input::resolve_in`] against an explicit registry of tuning sets
364    /// (tests use synthetic sets; production uses [`TUNING_SETS`]).
365    pub fn resolve_with_sets(
366        &self,
367        base_dir: &Path,
368        sets: &[TuningSet],
369    ) -> Result<Resolved, InputError> {
370        let mut warnings = Vec::new();
371
372        // Beam.
373        let b = &self.beam;
374        let el = element_by_symbol(&b.ion).ok_or_else(|| {
375            invalid(
376                "beam.ion",
377                format!(
378                    "unknown element symbol {:?} (symbols are case-sensitive)",
379                    b.ion
380                ),
381            )
382        })?;
383        let ion = match b.mass_amu {
384            None => Ion::new(el.z),
385            Some(m) => Ion::with_mass(el.z, m),
386        }
387        .map_err(|e| invalid("beam.mass_amu", e.to_string()))?;
388        if !finite_pos(b.energy_ev) {
389            return Err(invalid("beam.energy_ev", "must be finite and positive"));
390        }
391        if !(b.tilt_deg.is_finite() && (0.0..90.0).contains(&b.tilt_deg)) {
392            return Err(invalid("beam.tilt_deg", "must be in [0, 90) degrees"));
393        }
394        if !b.azimuth_deg.is_finite() {
395            return Err(invalid("beam.azimuth_deg", "must be finite"));
396        }
397        if self.run.ions == 0 {
398            return Err(invalid("run.ions", "must be at least 1"));
399        }
400        if self.run.threads == Some(0) {
401            return Err(invalid(
402                "run.threads",
403                "must be at least 1 (omit for all cores)",
404            ));
405        }
406        let divergence = self.resolve_divergence()?;
407        let beam = Beam {
408            ion,
409            energy_ev: b.energy_ev,
410            polar_rad: b.tilt_deg.to_radians(),
411            azimuth_rad: b.azimuth_deg.to_radians(),
412            count: self.run.ions,
413        };
414
415        // Materials (all of them, so a broken unused entry is still reported).
416        for (name, spec) in &self.materials {
417            Material::try_from(spec.clone())
418                .map_err(|e| invalid(format!("materials.{name}"), e.to_string()))?;
419        }
420
421        // Target.
422        let t = &self.target;
423        if t.layers.is_empty() && t.substrate.is_none() {
424            return Err(invalid(
425                "target",
426                "needs at least one [[target.layers]] entry or a substrate",
427            ));
428        }
429        let mut layers = Vec::with_capacity(t.layers.len() + 1);
430        let mut fields = Vec::with_capacity(t.layers.len() + 1);
431        let mut thick = Vec::with_capacity(t.layers.len());
432        for (i, l) in t.layers.iter().enumerate() {
433            let field = format!("target.layers[{i}]");
434            if !finite_pos(l.thickness_nm) {
435                return Err(invalid(
436                    format!("{field}.thickness_nm"),
437                    format!("{} nm must be finite and positive", l.thickness_nm),
438                ));
439            }
440            layers.push(self.material(&format!("{field}.material"), &l.material)?);
441            fields.push(format!("{field}.material"));
442            thick.push(l.thickness_nm * 1e-9);
443        }
444        if let Some(s) = &t.substrate {
445            layers.push(self.material("target.substrate", s)?);
446            fields.push("target.substrate".to_string());
447        }
448
449        if let Some(d) = &self.dynamic {
450            self.check_dynamic(d)?;
451        }
452
453        // Energy overrides.
454        let p = &self.physics;
455        for (sym, o) in &p.energies {
456            let field = format!("physics.energies.{sym}");
457            let el = element_by_symbol(sym)
458                .ok_or_else(|| invalid(&field, format!("unknown element symbol {sym:?}")))?;
459            let mut used = false;
460            for l in &mut layers {
461                if l.material.components().iter().all(|c| c.z() != el.z) {
462                    continue;
463                }
464                used = true;
465                let m = &mut l.material;
466                let set = |r: Result<(), _>, key: &str| {
467                    r.map_err(|e: crate::material::MaterialError| {
468                        invalid(format!("{field}.{key}"), e.to_string())
469                    })
470                };
471                if let Some(v) = o.e_d_ev {
472                    set(m.set_displacement_energy_ev(el.z, v), "e_d_ev")?;
473                }
474                if let Some(v) = o.e_b_ev {
475                    set(m.set_lattice_binding_energy_ev(el.z, v), "e_b_ev")?;
476                }
477                if let Some(v) = o.e_s_ev {
478                    set(m.set_surface_binding_energy_ev(el.z, v), "e_s_ev")?;
479                }
480            }
481            if !used {
482                return Err(invalid(&field, format!("{sym} is not in any target layer")));
483            }
484        }
485        for (l, field) in layers.iter().zip(&fields) {
486            if let Some(&(z, kind)) = l.material.unset_energies().first() {
487                let sym = crate::elements::element(z).expect("validated").symbol;
488                let key = match kind {
489                    EnergyKind::Displacement => "e_d_ev",
490                    EnergyKind::LatticeBinding => "e_b_ev",
491                    EnergyKind::SurfaceBinding => "e_s_ev",
492                };
493                return Err(invalid(
494                    field.as_str(),
495                    format!(
496                        "{kind} of {sym} has no default; set `{key}` for {sym} in the \
497                         material, or in [physics.energies.{sym}]"
498                    ),
499                ));
500            }
501        }
502
503        let tuning = self.apply_tuning(sets, &mut layers, &mut warnings)?;
504
505        let mut finite = Vec::with_capacity(thick.len());
506        let mut it = layers.iter();
507        for &th in &thick {
508            finite.push((it.next().expect("one per layer").material.clone(), th));
509        }
510        let substrate = it.next().map(|l| l.material.clone());
511        let stack = Stack::new(finite, substrate).map_err(|e| invalid("target", e.to_string()))?;
512
513        // Physics.
514        let mean_free_path = match (p.free_path, p.min_cm_angle_deg) {
515            (FreePathChoice::Constant, None) => MeanFreePath::Constant,
516            (FreePathChoice::Constant, Some(_)) => {
517                return Err(invalid(
518                    "physics.min_cm_angle_deg",
519                    "only used with free_path = \"energy-dependent\"",
520                ))
521            }
522            (FreePathChoice::EnergyDependent, None) => {
523                return Err(invalid(
524                    "physics.min_cm_angle_deg",
525                    "required with free_path = \"energy-dependent\"",
526                ))
527            }
528            (FreePathChoice::EnergyDependent, Some(a)) => {
529                if !(a > 0.0 && a < 180.0) {
530                    return Err(invalid(
531                        "physics.min_cm_angle_deg",
532                        format!("{a} must be in (0, 180) degrees"),
533                    ));
534                }
535                MeanFreePath::EnergyDependent {
536                    min_cm_angle_rad: a * PI / 180.0,
537                }
538            }
539        };
540        for (key, v) in [
541            ("primary_cutoff_ev", p.primary_cutoff_ev),
542            ("recoil_cutoff_ev", p.recoil_cutoff_ev),
543        ] {
544            if !finite_pos(v) {
545                return Err(invalid(
546                    format!("physics.{key}"),
547                    format!("{v} must be finite and positive"),
548                ));
549            }
550        }
551        if !(p.primary_surface_binding_ev.is_finite() && p.primary_surface_binding_ev >= 0.0) {
552            return Err(invalid(
553                "physics.primary_surface_binding_ev",
554                "must be finite and non-negative",
555            ));
556        }
557        if p.primary_cutoff_ev >= b.energy_ev {
558            return Err(invalid(
559                "physics.primary_cutoff_ev",
560                format!(
561                    "{} eV is not below the beam energy {} eV",
562                    p.primary_cutoff_ev, b.energy_ev
563                ),
564            ));
565        }
566        if p.follow_recoils {
567            let min_es = layers
568                .iter()
569                .flat_map(|l| {
570                    l.material
571                        .components()
572                        .iter()
573                        .map(|c| l.material.surface_binding_energy_ev(c.z()).expect("set"))
574                })
575                .fold(f64::INFINITY, f64::min);
576            if p.recoil_cutoff_ev > min_es {
577                warnings.push(format!(
578                    "physics.recoil_cutoff_ev = {} eV is above the smallest surface binding \
579                     energy ({min_es} eV); sputtering will be underestimated",
580                    p.recoil_cutoff_ev
581                ));
582            }
583        }
584        if p.weak_collisions > MAX_WEAK_COLLISIONS {
585            return Err(invalid(
586                "physics.weak_collisions",
587                format!(
588                    "{} must be at most {MAX_WEAK_COLLISIONS}",
589                    p.weak_collisions
590                ),
591            ));
592        }
593        if p.weak_collisions > 0 && mean_free_path != MeanFreePath::Constant {
594            return Err(invalid(
595                "physics.weak_collisions",
596                "only used with free_path = \"constant\"",
597            ));
598        }
599        let mut config = BcaConfig::new(p.primary_cutoff_ev, p.recoil_cutoff_ev);
600        config.mean_free_path = mean_free_path;
601        config.weak_collisions = p.weak_collisions;
602        config.electronic = match p.stopping {
603            StoppingChoice::EquipartitionLsOr => ElectronicLoss::EquipartitionLsOr,
604            // `None` is nonlocal loss with a zero model: no energy is taken
605            // along the flight, and no local loss is added at collisions.
606            StoppingChoice::LindhardScharff | StoppingChoice::BetheBloch | StoppingChoice::None => {
607                ElectronicLoss::NonLocal
608            }
609        };
610        config.follow_recoils = p.follow_recoils;
611        config.primary_surface_binding_ev = p.primary_surface_binding_ev;
612        config.seed = self.run.seed;
613
614        // Tally.
615        if !finite_pos(self.tally.depth_bin_nm) {
616            return Err(invalid("tally.depth_bin_nm", "must be finite and positive"));
617        }
618        if self.tally.depth_bins == 0 {
619            return Err(invalid("tally.depth_bins", "must be at least 1"));
620        }
621        if !finite_pos(self.tally.lateral_bin_nm) {
622            return Err(invalid(
623                "tally.lateral_bin_nm",
624                "must be finite and positive",
625            ));
626        }
627        for (key, n) in [
628            ("lateral_bins", self.tally.lateral_bins),
629            ("escape_energy_bins", self.tally.escape_energy_bins),
630            ("escape_polar_bins", self.tally.escape_polar_bins),
631        ] {
632            if n == 0 {
633                return Err(invalid(format!("tally.{key}"), "must be at least 1"));
634            }
635        }
636        if let Some(v) = &self.tally.voxel {
637            if self.dynamic.is_some() {
638                return Err(invalid(
639                    "tally.voxel",
640                    "not supported with a [dynamic] target; use a static run",
641                ));
642            }
643            for a in 0..3 {
644                if !v.origin_nm[a].is_finite() {
645                    return Err(invalid("tally.voxel.origin_nm", "must be finite"));
646                }
647                if !finite_pos(v.spacing_nm[a]) {
648                    return Err(invalid(
649                        "tally.voxel.spacing_nm",
650                        "must be finite and positive",
651                    ));
652                }
653                if v.dims[a] == 0 {
654                    return Err(invalid("tally.voxel.dims", "must be at least 1"));
655                }
656            }
657        }
658        if let Some(e) = self.tally.escape_energy_max_ev {
659            if !finite_pos(e) {
660                return Err(invalid(
661                    "tally.escape_energy_max_ev",
662                    "must be finite and positive",
663                ));
664            }
665        }
666
667        let crystals = self.resolve_crystals(&layers, &mut warnings)?;
668
669        let screening = p.potential.screening();
670        let screening_length = p
671            .screening_length
672            .map_or(screening.default_length(), LengthChoice::length);
673        let stopping_tables = self.load_stopping_tables(base_dir)?;
674        let fallback = stopping_model(p.stopping);
675        let validity = fallback.validity(&ion);
676        // The advisory range of the fallback model only matters for the pairs
677        // it actually serves; a beam pair with a table has its range checked
678        // as an error above.
679        let beam_pair_uncovered = layers.iter().any(|l| {
680            l.material.components().iter().any(|c| {
681                !stopping_tables
682                    .iter()
683                    .any(|t| t.table.covers_pair(ion.z(), c.z()))
684            })
685        });
686        if beam_pair_uncovered && !validity.contains(b.energy_ev) {
687            warnings.push(format!(
688                "beam energy {} eV is outside the advisory validity range of the {} \
689                 stopping model for {} ([{}, {}] eV)",
690                b.energy_ev,
691                fallback.name(),
692                b.ion,
693                validity.min_energy_ev,
694                validity.max_energy_ev
695            ));
696        }
697        self.check_stopping_tables(&stopping_tables, &ion, &layers, &mut warnings)?;
698
699        Ok(Resolved {
700            input: self.echo(),
701            beam,
702            divergence,
703            stack,
704            layers,
705            config,
706            screening,
707            screening_length,
708            table_spec: TABLE_SPEC,
709            stopping_tables,
710            crystals,
711            warnings,
712            tuning,
713        })
714    }
715
716    /// Validate `[beam.divergence]` and convert it to the engine's
717    /// [`Divergence`]. Static ion runs only: a `[dynamic]` target is
718    /// rejected, since the spread is not threaded through the fluence loop.
719    fn resolve_divergence(&self) -> Result<Divergence, InputError> {
720        let Some(spec) = &self.beam.divergence else {
721            return Ok(Divergence::None);
722        };
723        if self.dynamic.is_some() {
724            return Err(invalid(
725                "beam.divergence",
726                "not supported with a [dynamic] target; use a static run",
727            ));
728        }
729        divergence_from_spec(spec, "beam.divergence")
730    }
731
732    /// Validate `[[crystal]]` and build the engine's crystal targets. The
733    /// beam's tilt and azimuth are the orientation's tilt and twist. Every
734    /// check the engine would make at [`crate::ion::bca::Bca::with_crystal`]
735    /// that does not need an engine is made here, so `check` reports it.
736    fn resolve_crystals(
737        &self,
738        layers: &[ResolvedLayer],
739        warnings: &mut Vec<String>,
740    ) -> Result<Vec<ResolvedCrystal>, InputError> {
741        if self.crystal.is_empty() {
742            return Ok(Vec::new());
743        }
744        if self.dynamic.is_some() {
745            return Err(invalid(
746                "crystal",
747                "not supported with a [dynamic] target (the composition changes during the run \
748                 while the lattice is fixed); use a static run",
749            ));
750        }
751        let p = &self.physics;
752        if p.free_path != FreePathChoice::Constant {
753            return Err(invalid(
754                "physics.free_path",
755                "crystal regions need free_path = \"constant\" (the lattice replaces the free path)",
756            ));
757        }
758        if p.weak_collisions > 0 {
759            return Err(invalid(
760                "physics.weak_collisions",
761                "crystal regions need weak_collisions = 0 (the lattice replaces them)",
762            ));
763        }
764        if p.tuning != NO_TUNING {
765            return Err(invalid(
766                "physics.tuning",
767                "not supported together with [[crystal]] (the pilot sets were fitted for amorphous runs)",
768            ));
769        }
770        if p.stopping == StoppingChoice::EquipartitionLsOr {
771            warnings.push(
772                "crystal: physics.stopping = \"equipartition-ls-or\" takes the local \
773                 Oen-Robinson loss, whose constants are not verified against the paper \
774                 (summary: physics.crystal[].electronic_constants_unverified); channeled \
775                 ranges depend on them"
776                    .to_string(),
777            );
778        }
779        let beam = &self.beam;
780        let mut owner: Vec<Option<usize>> = vec![None; layers.len()];
781        let mut out = Vec::with_capacity(self.crystal.len());
782        for (i, c) in self.crystal.iter().enumerate() {
783            let f = |k: &str| format!("crystal[{i}].{k}");
784            if c.layers.is_empty() {
785                return Err(invalid(f("layers"), "needs at least one layer index"));
786            }
787            for &l in &c.layers {
788                if l >= layers.len() {
789                    return Err(invalid(
790                        f("layers"),
791                        format!(
792                            "layer {l} does not exist: the target has {} layer(s), indices 0..{}",
793                            layers.len(),
794                            layers.len()
795                        ),
796                    ));
797                }
798                if let Some(prev) = owner[l] {
799                    return Err(invalid(
800                        f("layers"),
801                        format!("layer {l} is already assigned by crystal[{prev}]"),
802                    ));
803                }
804                owner[l] = Some(i);
805            }
806            if !c.wafer_rotation_deg.is_finite() {
807                return Err(invalid(f("wafer_rotation_deg"), "must be finite"));
808            }
809            let lattice = c.preset.lattice();
810            // Index triples, then the zone law, with the engine's own messages.
811            let orientation = crate::ion::crystal::Orientation::new(
812                &lattice,
813                c.normal,
814                c.reference,
815                beam.tilt_deg.to_radians(),
816                beam.azimuth_deg.to_radians(),
817                c.wafer_rotation_deg.to_radians(),
818            )
819            .map_err(|e| {
820                use crate::ion::crystal::CrystalError as E;
821                let key = match &e {
822                    E::ReferenceNotInPlane { .. } => "reference",
823                    E::ZeroIndex(v) if *v == c.reference => "reference",
824                    _ => "normal",
825                };
826                invalid(f(key), e.to_string())
827            })?;
828            let mut target = CrystalTarget::new(lattice, orientation);
829            for (key, v, slot) in [
830                ("p_max_nm", c.p_max_nm, &mut target.p_max_m),
831                ("q_max_nm", c.q_max_nm, &mut target.q_max_m),
832                (
833                    "search_length_nm",
834                    c.search_length_nm,
835                    &mut target.search_length_m,
836                ),
837            ] {
838                if let Some(v) = v {
839                    if !finite_pos(v) {
840                        return Err(invalid(f(key), "must be finite and positive"));
841                    }
842                    *slot = Some(v * 1e-9);
843                }
844            }
845            if let Some(t) = &c.thermal {
846                if !(t.temperature_k.is_finite() && t.temperature_k >= 0.0) {
847                    return Err(invalid(
848                        f("thermal.temperature_k"),
849                        "must be finite and non-negative",
850                    ));
851                }
852                let theta = t
853                    .debye_temperature_k
854                    .unwrap_or_else(|| c.preset.default_debye_temperature_k());
855                if !finite_pos(theta) {
856                    return Err(invalid(
857                        f("thermal.debye_temperature_k"),
858                        "must be finite and positive",
859                    ));
860                }
861                target = target.with_thermal(Thermal::new(t.temperature_k, theta));
862            }
863            for &l in &c.layers {
864                target.check_material(&layers[l].material).map_err(|m| {
865                    invalid(
866                        f("preset"),
867                        format!(
868                            "{} does not fit layer {l} ({}): {m}",
869                            c.preset.name(),
870                            layers[l].source
871                        ),
872                    )
873                })?;
874            }
875            out.push(ResolvedCrystal {
876                regions: c.layers.clone(),
877                target,
878            });
879        }
880        Ok(out)
881    }
882
883    /// Apply `[physics] tuning` to the resolved layers (see [`TuningSet`]).
884    fn apply_tuning(
885        &self,
886        sets: &[TuningSet],
887        layers: &mut [ResolvedLayer],
888        warnings: &mut Vec<String>,
889    ) -> Result<Option<TuningReport>, InputError> {
890        let name = self.physics.tuning.as_str();
891        if name == NO_TUNING {
892            return Ok(None);
893        }
894        let f = "physics.tuning";
895        let set = sets.iter().find(|s| s.name == name).ok_or_else(|| {
896            let known: Vec<&str> = sets.iter().map(|s| s.name).collect();
897            invalid(
898                f,
899                format!("unknown tuning set {name:?}; use \"none\" or one of {known:?}"),
900            )
901        })?;
902        if self.dynamic.is_some() {
903            return Err(invalid(
904                f,
905                "tuning is not supported with a [dynamic] target (pilot: static single-element layers)",
906            ));
907        }
908        let (e_lo, e_hi) = set.energy_range_ev;
909        if !(e_lo.is_finite() && e_hi.is_finite() && 0.0 < e_lo && e_lo <= e_hi) {
910            return Err(invalid(
911                f,
912                format!(
913                    "set {name:?}: energy range {e_lo}..{e_hi} eV is not a finite positive range"
914                ),
915            ));
916        }
917        if !set.ions.contains(&self.beam.ion.as_str()) {
918            return Err(invalid(
919                f,
920                format!(
921                    "set {name:?} was fitted for beam ion(s) {:?}, not {:?}; use \"none\"",
922                    set.ions, self.beam.ion
923                ),
924            ));
925        }
926        let e = self.beam.energy_ev;
927        if !(e_lo..=e_hi).contains(&e) {
928            warnings.push(format!(
929                "tuning set {name:?} was fitted at {e_lo}..{e_hi} eV; {e} eV is an extrapolation"
930            ));
931        }
932        if self.beam.tilt_deg != 0.0 {
933            warnings.push(format!(
934                "tuning set {name:?} was fitted at normal incidence; tilt {} deg is an extrapolation",
935                self.beam.tilt_deg
936            ));
937        }
938        let mut factors = BTreeMap::new();
939        for &(sym, k) in set.e_s_factors {
940            let el = element_by_symbol(sym)
941                .ok_or_else(|| invalid(f, format!("set {name:?} names unknown element {sym:?}")))?;
942            if !(k.is_finite() && k > 0.0) {
943                return Err(invalid(
944                    f,
945                    format!("set {name:?}: factor for {sym} is {k}; must be finite and positive"),
946                ));
947            }
948            if factors.insert(el.z, k).is_some() {
949                return Err(invalid(f, format!("set {name:?} lists {sym} twice")));
950            }
951        }
952        for (i, l) in layers.iter().enumerate() {
953            if l.material.components().len() != 1 {
954                return Err(invalid(
955                    f,
956                    format!(
957                        "tuning supports single-element layers only; layer {i} ({}) is a compound",
958                        l.source
959                    ),
960                ));
961            }
962        }
963        let mut components = Vec::with_capacity(layers.len());
964        for (i, l) in layers.iter_mut().enumerate() {
965            let z = l.material.components()[0].z();
966            let sym = crate::elements::element(z).expect("validated").symbol;
967            let original = l.material.surface_binding_energy_ev(z).expect("set");
968            let factor = *factors.get(&z).ok_or_else(|| {
969                invalid(
970                    f,
971                    format!(
972                        "set {name:?} has no factor for {sym} (layer {i}); it supports {:?}",
973                        set.e_s_factors.iter().map(|p| p.0).collect::<Vec<_>>()
974                    ),
975                )
976            })?;
977            let effective = original * factor;
978            l.material
979                .set_surface_binding_energy_ev(z, effective)
980                .map_err(|e| invalid(f, format!("layer {i}: effective E_s of {sym}: {e}")))?;
981            components.push(TunedEnergy {
982                layer: i,
983                element: sym.to_string(),
984                e_s_original_ev: original,
985                factor,
986                e_s_effective_ev: effective,
987            });
988        }
989        if components.iter().all(|c| c.factor == 1.0) {
990            warnings.push(format!(
991                "tuning set {name:?} has factor 1 for every element in the target; nothing changed"
992            ));
993        }
994        Ok(Some(TuningReport {
995            set: set.name.to_string(),
996            version: set.version,
997            provenance: set.provenance.to_string(),
998            quantity: "surface-binding-energy",
999            components,
1000        }))
1001    }
1002}
1003
1004fn finite_pos(v: f64) -> bool {
1005    v.is_finite() && v > 0.0
1006}