1use 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
21pub(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 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 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 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 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 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 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 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 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 pub fn resolve(&self) -> Result<Resolved, InputError> {
354 self.resolve_in(Path::new("."))
355 }
356
357 pub fn resolve_in(&self, base_dir: &Path) -> Result<Resolved, InputError> {
360 self.resolve_with_sets(base_dir, TUNING_SETS)
361 }
362
363 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 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 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 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 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 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 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 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 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 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 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 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 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}