Skip to main content

lindhard/input/electron/
resolve.rs

1//! Validation and resolution: turns an [`ElectronInput`] into a
2//! [`ResolvedElectron`].
3
4use std::collections::BTreeMap;
5use std::path::Path;
6
7use sha2::{Digest, Sha256};
8
9use crate::electron::boundary::{BandModel, BandStructure};
10use crate::electron::data::{OpticalElf, Subshell, SubshellBindingTable};
11use crate::electron::elastic::corrections::{CorrelationPolarization, PolarizationCutoff};
12use crate::electron::elastic::table::DEFAULT_MAX_ENERGY_EV;
13use crate::electron::inelastic::{FitWeighting, MerminFitOptions, PennAlgorithm, MAX_OSCILLATORS};
14use crate::electron::phonon::{FrohlichPhonon, InsulatorChannels, PolaronTrapping, Sio2LoMode};
15use crate::electron::secondary::SecondaryModel;
16use crate::electron::transport::{
17    BeamProfile, BeamSpread, BoundaryModel, CutoffReference, EscapeRule, Primary, TransportConfig,
18};
19use crate::elements::element_by_symbol;
20use crate::geometry::Stack;
21use crate::input::{divergence_from_spec, invalid, resolve_material, InputError, ResolvedLayer};
22use crate::ion::crystal::Divergence;
23use crate::material::Material;
24use crate::tally::{
25    BeamProfile as ExposureBeamProfile, Binning, CartesianGrid, CylindricalGrid, DepthDoseConfig,
26    DoseConfig, ElectronTallyConfig, ExposureConfig, ExposureVolume, InterfaceRefine,
27    LogRadialBinning, PsfConfig, PsfModel, PsfNormalization, VolumeShape, VoxelTallyGrid,
28};
29
30use super::resolved::*;
31use super::schema::*;
32
33const NM: f64 = 1e-9;
34
35/// XORed into the run seed for the exposure tally's landing-offset stream.
36const EXPOSURE_SEED_SALT: u64 = 0x6578_706f_7375_7265;
37
38fn finite_pos(v: f64) -> bool {
39    v.is_finite() && v > 0.0
40}
41
42/// Validate the landing point, profile and divergence of `[electron.beam]`.
43fn resolve_beam_spread(b: &ElectronBeamSpec) -> Result<BeamSpread, InputError> {
44    if !b.landing_nm.iter().all(|v| v.is_finite()) {
45        return Err(invalid("electron.beam.landing_nm", "must be finite"));
46    }
47    let size = |field: &str, v: f64| {
48        if finite_pos(v) {
49            Ok(v * NM)
50        } else {
51            Err(invalid(
52                format!("electron.beam.profile.{field}"),
53                "must be finite and positive",
54            ))
55        }
56    };
57    let profile = match b.profile {
58        None | Some(BeamProfileSpec::Point) => BeamProfile::Point,
59        Some(BeamProfileSpec::Gaussian { sigma_nm }) => BeamProfile::Gaussian {
60            sigma_m: size("sigma_nm", sigma_nm)?,
61        },
62        Some(BeamProfileSpec::Disc { radius_nm }) => BeamProfile::Disc {
63            radius_m: size("radius_nm", radius_nm)?,
64        },
65        Some(BeamProfileSpec::Rectangle {
66            width_y_nm,
67            width_z_nm,
68        }) => BeamProfile::Rectangle {
69            width_y_m: size("width_y_nm", width_y_nm)?,
70            width_z_m: size("width_z_nm", width_z_nm)?,
71        },
72    };
73    let divergence = match &b.divergence {
74        None => Divergence::None,
75        Some(d) => divergence_from_spec(d, "electron.beam.divergence")?,
76    };
77    Ok(BeamSpread {
78        landing_m: [b.landing_nm[0] * NM, b.landing_nm[1] * NM],
79        profile,
80        divergence,
81    })
82}
83
84/// `band` with the optional valence binding of a metal applied.
85fn with_valence_binding(
86    band: BandStructure,
87    binding_ev: Option<f64>,
88) -> Result<BandStructure, crate::electron::boundary::BandError> {
89    match binding_ev {
90        None => Ok(band),
91        Some(b) => band.with_valence_binding_ev(b),
92    }
93}
94
95fn read_data_file(
96    base_dir: &Path,
97    field: &str,
98    path: &str,
99) -> Result<(String, DataFile), InputError> {
100    let joined = base_dir.join(path);
101    let bytes = std::fs::read(&joined)
102        .map_err(|e| invalid(field, format!("cannot read {}: {e}", joined.display())))?;
103    let text = String::from_utf8(bytes.clone()).map_err(|_| {
104        invalid(
105            field,
106            format!("{} is not valid UTF-8 text", joined.display()),
107        )
108    })?;
109    let sha256 = Sha256::digest(&bytes)
110        .iter()
111        .map(|b| format!("{b:02x}"))
112        .collect();
113    Ok((
114        text,
115        DataFile {
116            path: path.to_string(),
117            resolved_path: std::fs::canonicalize(&joined).unwrap_or(joined),
118            sha256,
119        },
120    ))
121}
122
123fn binning_nm(field: &str, b: &BinsNm) -> Result<Binning, InputError> {
124    Binning::new(b.lo_nm * NM, b.hi_nm * NM, b.bins)
125        .map_err(|e| invalid(field, format!("{e} (edges in nm)")))
126}
127
128/// The smallest stopping threshold over the layers (the transport's rule:
129/// the cutoff, or the inner potential plus the cutoff from the vacuum level).
130fn lowest_threshold(
131    materials: &[ResolvedElectronMaterial],
132    layer_material: &[usize],
133    reference: CutoffReference,
134    cutoff_ev: f64,
135) -> f64 {
136    layer_material
137        .iter()
138        .map(|&i| match reference {
139            CutoffReference::BandBottom => cutoff_ev,
140            CutoffReference::VacuumLevel => {
141                materials[i]
142                    .band
143                    .as_ref()
144                    .map_or(0.0, BandStructure::inner_potential_ev)
145                    + cutoff_ev
146            }
147        })
148        .fold(f64::INFINITY, f64::min)
149}
150
151/// The tally pieces `resolve_in` hands to the resolved input.
152struct ResolvedTally {
153    tally: ElectronTallyConfig,
154    voxel_grid: Option<VoxelTallyGrid>,
155    psf_fits: Vec<PsfModel>,
156    psf_normalization: PsfNormalization,
157}
158
159/// Refuse the options in `fields` when they are set but the choice they
160/// belong to (`applies_with`, e.g. `secondaries = "kieft-bosch"`) is not
161/// selected.
162fn reject_unless_selected(
163    fields: [(&str, Option<bool>); 2],
164    applies_with: &str,
165) -> Result<(), InputError> {
166    for (k, v) in fields {
167        if v.is_some() {
168            return Err(invalid(
169                format!("electron.transport.{k}"),
170                format!("only applies with {applies_with}"),
171            ));
172        }
173    }
174    Ok(())
175}
176
177/// Validate the energy and direction of `[electron.beam]`.
178fn resolve_primary(b: &ElectronBeamSpec) -> Result<Primary, InputError> {
179    if !finite_pos(b.energy_ev) {
180        return Err(invalid(
181            "electron.beam.energy_ev",
182            "must be finite and positive",
183        ));
184    }
185    if !(b.tilt_deg.is_finite() && (0.0..90.0).contains(&b.tilt_deg)) {
186        return Err(invalid(
187            "electron.beam.tilt_deg",
188            "must be in [0, 90) degrees",
189        ));
190    }
191    if !b.azimuth_deg.is_finite() {
192        return Err(invalid("electron.beam.azimuth_deg", "must be finite"));
193    }
194    let (s, c) = b.tilt_deg.to_radians().sin_cos();
195    let (sa, ca) = b.azimuth_deg.to_radians().sin_cos();
196    Ok(Primary {
197        energy_ev: b.energy_ev,
198        direction: [c, s * ca, s * sa],
199    })
200}
201
202/// Validate `[electron.transport]`, fill in the echoed defaults and build
203/// the transport configuration. Also returns whether the choices need band
204/// structures.
205fn resolve_transport(
206    t: &TransportSpec,
207    echo: &mut ElectronInput,
208) -> Result<(TransportConfig, bool), InputError> {
209    if !finite_pos(t.cutoff_ev) {
210        return Err(invalid(
211            "electron.transport.cutoff_ev",
212            "must be finite and positive",
213        ));
214    }
215    if t.max_events == 0 {
216        return Err(invalid(
217            "electron.transport.max_events",
218            "must be at least 1",
219        ));
220    }
221    let secondaries = match t.secondaries {
222        SecondaryChoice::Off => {
223            reject_unless_selected(
224                [
225                    ("instantaneous_momentum", t.instantaneous_momentum),
226                    ("momentum_conservation", t.momentum_conservation),
227                ],
228                "secondaries = \"kieft-bosch\"",
229            )?;
230            SecondaryModel::Off
231        }
232        SecondaryChoice::KieftBosch => {
233            let im = t.instantaneous_momentum.unwrap_or(true);
234            let mc = t.momentum_conservation.unwrap_or(true);
235            echo.electron.transport.instantaneous_momentum = Some(im);
236            echo.electron.transport.momentum_conservation = Some(mc);
237            SecondaryModel::KieftBosch {
238                instantaneous_momentum: im,
239                momentum_conservation: mc,
240            }
241        }
242    };
243    let boundary = match t.boundary {
244        BoundaryChoice::Transparent => {
245            reject_unless_selected(
246                [
247                    ("quantum_transmission", t.quantum_transmission),
248                    ("refraction", t.refraction),
249                ],
250                "boundary = \"step-barrier\"",
251            )?;
252            BoundaryModel::Transparent
253        }
254        BoundaryChoice::StepBarrier => {
255            let qt = t.quantum_transmission.unwrap_or(true);
256            let rf = t.refraction.unwrap_or(true);
257            echo.electron.transport.quantum_transmission = Some(qt);
258            echo.electron.transport.refraction = Some(rf);
259            BoundaryModel::StepBarrier {
260                quantum_transmission: qt,
261                refraction: rf,
262            }
263        }
264    };
265    let cutoff_reference = match t.cutoff_reference {
266        CutoffReferenceChoice::BandBottom => CutoffReference::BandBottom,
267        CutoffReferenceChoice::VacuumLevel => CutoffReference::VacuumLevel,
268    };
269    let config = TransportConfig {
270        cutoff_ev: t.cutoff_ev,
271        escape_rule: match t.escape_rule {
272            EscapeRuleChoice::BothFaces => EscapeRule::BothFaces,
273            EscapeRuleChoice::FrontOnly => EscapeRule::FrontOnly,
274        },
275        max_events: t.max_events,
276        secondaries,
277        boundary,
278        cutoff_reference,
279    };
280    let needs_bands = secondaries != SecondaryModel::Off
281        || boundary != BoundaryModel::Transparent
282        || cutoff_reference == CutoffReference::VacuumLevel;
283    Ok((config, needs_bands))
284}
285
286/// Validate `[electron.elastic]`, including the correlation-polarization
287/// inputs against the elements of the target `layers`.
288fn resolve_elastic(
289    el: &ElasticSpec,
290    layers: &[ResolvedLayer],
291) -> Result<ElasticChoice, InputError> {
292    let mut cp = BTreeMap::new();
293    if let Some(c) = &el.correlation_polarization {
294        let f = "electron.elastic.correlation_polarization";
295        let cutoff = match c.b_pol_squared {
296            None => PolarizationCutoff::Seltzer,
297            Some(b) if b.is_finite() && b >= 0.0 => PolarizationCutoff::BPolSquared(b),
298            Some(b) => {
299                return Err(invalid(
300                    format!("{f}.b_pol_squared"),
301                    format!("{b} must be finite and non-negative"),
302                ))
303            }
304        };
305        if !finite_pos(c.outer_radius_bohr) {
306            return Err(invalid(
307                format!("{f}.outer_radius_bohr"),
308                "must be finite and positive",
309            ));
310        }
311        let mut zs: Vec<u8> = Vec::new();
312        for l in layers {
313            for comp in l.material.components() {
314                if comp.atom_fraction() > 0.0 && !zs.contains(&comp.z()) {
315                    zs.push(comp.z());
316                }
317            }
318        }
319        for (sym, p) in &c.polarizability {
320            let pf = format!("{f}.polarizability.{sym}");
321            let elm = element_by_symbol(sym)
322                .ok_or_else(|| invalid(&pf, format!("unknown element symbol {sym:?}")))?;
323            if !zs.contains(&elm.z) {
324                return Err(invalid(&pf, format!("{sym} is not in any target layer")));
325            }
326            if !finite_pos(p.bohr3) {
327                return Err(invalid(
328                    format!("{pf}.bohr3"),
329                    "must be finite and positive",
330                ));
331            }
332            if p.source.trim().is_empty() {
333                return Err(invalid(
334                    format!("{pf}.source"),
335                    "a polarizability needs a source (a citation of the value)",
336                ));
337            }
338            cp.insert(
339                elm.z,
340                CorrelationPolarization {
341                    polarizability: p.bohr3,
342                    polarizability_source: p.source.trim().to_string(),
343                    cutoff,
344                    outer_radius: c.outer_radius_bohr,
345                },
346            );
347        }
348        for z in zs {
349            if !cp.contains_key(&z) {
350                let sym = crate::elements::element(z).expect("valid").symbol;
351                return Err(invalid(
352                    format!("{f}.polarizability"),
353                    format!("no polarizability for {sym}; give every target element one"),
354                ));
355            }
356        }
357    }
358    Ok(ElasticChoice {
359        model: el.model,
360        potential: el.potential,
361        exchange: el.exchange,
362        correlation_polarization: cp,
363    })
364}
365
366/// Validate `[electron.inelastic]` against the resolved `materials`; fills
367/// in the echoed Mermin fit floors.
368fn resolve_inelastic(
369    e: &ElectronSpec,
370    materials: &[ResolvedElectronMaterial],
371    echo: &mut ElectronInput,
372) -> Result<PennAlgorithm, InputError> {
373    let inelastic: PennAlgorithm =
374        e.inelastic
375            .model
376            .parse()
377            .map_err(|err: crate::electron::data::ElectronDataError| {
378                invalid("electron.inelastic.model", err.to_string())
379            })?;
380    if !(e.inelastic.fermi_energy_ev.is_finite() && e.inelastic.fermi_energy_ev >= 0.0) {
381        return Err(invalid(
382            "electron.inelastic.fermi_energy_ev",
383            "must be finite and non-negative",
384        ));
385    }
386    // The fit options only shape the `mermin-melf` table; anywhere else
387    // they would be silently ignored.
388    for (name, spec) in &e.materials {
389        if spec.mermin_fit.is_some() && inelastic != PennAlgorithm::Mermin {
390            return Err(invalid(
391                format!("electron.materials.{name}.mermin_fit"),
392                format!(
393                    "only applies with electron.inelastic.model = \"{}\" (the model is \"{}\")",
394                    PennAlgorithm::Mermin.label(),
395                    inelastic.label()
396                ),
397            ));
398        }
399    }
400    // Shell channels are tabulated with the single-pole model only
401    // (`build_shell_channel_tables`).
402    for (name, spec) in &e.materials {
403        if spec.inner_shells.is_some() && inelastic != PennAlgorithm::SinglePole {
404            return Err(invalid(
405                format!("electron.materials.{name}.inner_shells"),
406                format!(
407                    "only applies with electron.inelastic.model = \"{}\" (the model is \"{}\")",
408                    PennAlgorithm::SinglePole.label(),
409                    inelastic.label()
410                ),
411            ));
412        }
413    }
414    for m in materials {
415        if let FitWeighting::Relative { floor } = m.mermin_fit.weighting {
416            if let Some(f) = echo
417                .electron
418                .materials
419                .get_mut(&m.name)
420                .and_then(|s| s.mermin_fit.as_mut())
421            {
422                f.relative_floor = Some(floor);
423            }
424        }
425    }
426    // The inelastic table of a material with a band takes its Fermi
427    // energy from the band (#241); a second one would count it twice.
428    if e.inelastic.fermi_energy_ev != 0.0 {
429        if let Some(m) = materials.iter().find(|m| m.band.is_some()) {
430            return Err(invalid(
431                "electron.inelastic.fermi_energy_ev",
432                format!(
433                    "must be 0 when a material has a band (material {} does): the \
434                     inelastic table of a material with a band takes its Fermi energy \
435                     from the band",
436                    m.name
437                ),
438            ));
439        }
440    }
441    Ok(inelastic)
442}
443
444/// Validate `[electron.tables]` and build the shared energy grid (eV);
445/// fills in the echoed upper edge and pushes coverage warnings.
446fn resolve_table_grid(
447    e: &ElectronSpec,
448    config: &TransportConfig,
449    materials: &[ResolvedElectronMaterial],
450    layer_material: &[usize],
451    echo: &mut ElectronInput,
452    warnings: &mut Vec<String>,
453) -> Result<Vec<f64>, InputError> {
454    let g = &e.tables;
455    let max_inner = materials
456        .iter()
457        .filter_map(|m| m.band.as_ref().map(BandStructure::inner_potential_ev))
458        .fold(0.0, f64::max);
459    let top = match config.boundary {
460        BoundaryModel::StepBarrier { .. } => e.beam.energy_ev + max_inner,
461        BoundaryModel::Transparent => e.beam.energy_ev,
462    };
463    let max_energy = g.max_energy_ev.unwrap_or(top);
464    echo.electron.tables.max_energy_ev = Some(max_energy);
465    if !(finite_pos(g.min_energy_ev) && max_energy.is_finite() && max_energy > g.min_energy_ev) {
466        return Err(invalid(
467            "electron.tables",
468            format!(
469                "need 0 < min_energy_ev < max_energy_ev, got {} and {max_energy} eV",
470                g.min_energy_ev
471            ),
472        ));
473    }
474    let table_energy_ev = crate::electron::elastic::table::log_energy_grid(
475        g.min_energy_ev,
476        max_energy,
477        g.points_per_decade,
478    )
479    .map_err(|err| invalid("electron.tables", err.to_string()))?;
480    if max_energy < top {
481        warnings.push(format!(
482            "electron.tables.max_energy_ev: {max_energy} eV is below the largest energy \
483             an electron can have ({top} eV); rates above it are held at the last row \
484             (results.table_coverage counts how often)"
485        ));
486    }
487    // The lowest energy an electron is followed at: the cutoff, or
488    // `U + cutoff` from the vacuum level (the transport's thresholds).
489    let lowest = lowest_threshold(
490        materials,
491        layer_material,
492        config.cutoff_reference,
493        e.transport.cutoff_ev,
494    );
495    if g.min_energy_ev > lowest {
496        warnings.push(format!(
497            "electron.tables.min_energy_ev: {} eV is above the lowest stopping threshold \
498             ({lowest} eV); rates below it are held at the first row \
499             (results.table_coverage counts how often)",
500            g.min_energy_ev
501        ));
502    }
503    if max_energy > DEFAULT_MAX_ENERGY_EV {
504        warnings.push(format!(
505            "electron.tables.max_energy_ev: {max_energy} eV is above the {DEFAULT_MAX_ENERGY_EV} eV \
506             the elastic tables are tested to; the inelastic models are nonrelativistic"
507        ));
508    }
509    Ok(table_energy_ev)
510}
511
512impl ElectronInput {
513    /// Whether a TOML document is an electron input: it has a top-level
514    /// `electron` table.
515    pub fn is_electron_toml(text: &str) -> bool {
516        text.parse::<toml::Table>()
517            .map(|t| t.contains_key("electron"))
518            .unwrap_or(false)
519    }
520
521    /// Parse a TOML document (schema errors name the key and line).
522    pub fn from_toml_str(text: &str) -> Result<Self, InputError> {
523        if text
524            .parse::<toml::Table>()
525            .is_ok_and(|t| t.contains_key("crystal"))
526        {
527            return Err(invalid(
528                "crystal",
529                "not supported with an [electron] run (crystal transport is for ion runs)",
530            ));
531        }
532        toml::from_str(text).map_err(|e| InputError::Parse(e.to_string()))
533    }
534
535    /// The input as a TOML document in the same schema.
536    pub fn to_toml_string(&self) -> Result<String, InputError> {
537        toml::to_string(self).map_err(|e| InputError::Parse(e.to_string()))
538    }
539
540    /// Validate everything with relative data paths taken from the current
541    /// directory.
542    pub fn resolve(&self) -> Result<ResolvedElectron, InputError> {
543        self.resolve_in(Path::new("."))
544    }
545
546    /// Validate everything, read the data files (paths relative to
547    /// `base_dir`, the input file's directory) and build the engine's
548    /// inputs. Errors name the offending key.
549    pub fn resolve_in(&self, base_dir: &Path) -> Result<ResolvedElectron, InputError> {
550        let mut warnings = Vec::new();
551        let e = &self.electron;
552        let mut echo = self.clone();
553        echo.run.threads = None;
554
555        self.resolve_run()?;
556
557        let primary = resolve_primary(&e.beam)?;
558        let beam = resolve_beam_spread(&e.beam)?;
559
560        let (config, needs_bands) = resolve_transport(&e.transport, &mut echo)?;
561
562        // Materials (all of them, so a broken unused entry is still reported).
563        for (name, spec) in &self.materials {
564            Material::try_from(spec.clone())
565                .map_err(|err| invalid(format!("materials.{name}"), err.to_string()))?;
566        }
567
568        let (stack, layers, fields) = self.resolve_target()?;
569
570        let (materials, layer_material) =
571            self.resolve_layer_materials(base_dir, &layers, &fields, needs_bands, &mut warnings)?;
572
573        let elastic = resolve_elastic(&e.elastic, &layers)?;
574
575        let inelastic = resolve_inelastic(e, &materials, &mut echo)?;
576
577        let table_energy_ev = resolve_table_grid(
578            e,
579            &config,
580            &materials,
581            &layer_material,
582            &mut echo,
583            &mut warnings,
584        )?;
585
586        let ResolvedTally {
587            tally,
588            voxel_grid,
589            psf_fits,
590            psf_normalization,
591        } = self.resolve_tally(&mut echo)?;
592
593        Ok(ResolvedElectron {
594            input: echo,
595            stack,
596            layers,
597            layer_material,
598            materials,
599            config,
600            primary,
601            beam,
602            elastic,
603            inelastic,
604            inelastic_fermi_ev: e.inelastic.fermi_energy_ev,
605            table_energy_ev,
606            tally,
607            voxel_grid,
608            psf_fits,
609            psf_normalization,
610            warnings,
611        })
612    }
613
614    /// Validate `[run]`.
615    fn resolve_run(&self) -> Result<(), InputError> {
616        if self.run.histories == 0 {
617            return Err(invalid("run.histories", "must be at least 1"));
618        }
619        if self.run.threads == Some(0) {
620            return Err(invalid(
621                "run.threads",
622                "must be at least 1 (omit for all cores)",
623            ));
624        }
625        Ok(())
626    }
627
628    /// Validate `[target]` and build the stack. Returns the stack, the
629    /// layers in stack order (the substrate last) and the input key of each
630    /// layer's material, for error messages.
631    fn resolve_target(&self) -> Result<(Stack, Vec<ResolvedLayer>, Vec<String>), InputError> {
632        let tg = &self.target;
633        if tg.layers.is_empty() && tg.substrate.is_none() {
634            return Err(invalid(
635                "target",
636                "needs at least one [[target.layers]] entry or a substrate",
637            ));
638        }
639        let mut layers = Vec::new();
640        let mut fields = Vec::new();
641        let mut thick = Vec::new();
642        for (i, l) in tg.layers.iter().enumerate() {
643            let field = format!("target.layers[{i}]");
644            if !finite_pos(l.thickness_nm) {
645                return Err(invalid(
646                    format!("{field}.thickness_nm"),
647                    format!("{} nm must be finite and positive", l.thickness_nm),
648                ));
649            }
650            let f = format!("{field}.material");
651            layers.push(resolve_material(&self.materials, &f, &l.material)?);
652            fields.push(f);
653            thick.push(l.thickness_nm * NM);
654        }
655        if let Some(sub) = &tg.substrate {
656            layers.push(resolve_material(&self.materials, "target.substrate", sub)?);
657            fields.push("target.substrate".to_string());
658        }
659        let substrate = tg.substrate.as_ref().map(|_| {
660            layers
661                .last()
662                .expect("substrate pushed last")
663                .material
664                .clone()
665        });
666        let finite: Vec<(Material, f64)> = layers
667            .iter()
668            .zip(&thick)
669            .map(|(l, &t)| (l.material.clone(), t))
670            .collect();
671        let stack =
672            Stack::new(finite, substrate).map_err(|err| invalid("target", err.to_string()))?;
673        Ok((stack, layers, fields))
674    }
675
676    /// Electron data, one entry per material name the target uses. Returns
677    /// the materials and, for each layer, the index of its material.
678    fn resolve_layer_materials(
679        &self,
680        base_dir: &Path,
681        layers: &[ResolvedLayer],
682        fields: &[String],
683        needs_bands: bool,
684        warnings: &mut Vec<String>,
685    ) -> Result<(Vec<ResolvedElectronMaterial>, Vec<usize>), InputError> {
686        let e = &self.electron;
687        let mut materials: Vec<ResolvedElectronMaterial> = Vec::new();
688        let mut layer_material = Vec::with_capacity(layers.len());
689        for (l, field) in layers.iter().zip(fields) {
690            if l.source == "inline" {
691                return Err(invalid(
692                    field,
693                    "an electron run needs a named material (a [materials] key or an \
694                     element symbol), so that [electron.materials.<name>] can give its \
695                     electron data",
696                ));
697            }
698            if let Some(i) = materials.iter().position(|m| m.name == l.source) {
699                layer_material.push(i);
700                continue;
701            }
702            let name = &l.source;
703            let mfield = format!("electron.materials.{name}");
704            let spec = e.materials.get(name).ok_or_else(|| {
705                invalid(
706                    field,
707                    format!(
708                        "material {name:?} has no electron data; add [{mfield}] with at \
709                         least optical_elf"
710                    ),
711                )
712            })?;
713            materials.push(self.resolve_electron_material(
714                base_dir,
715                &mfield,
716                name,
717                &l.material,
718                spec,
719                needs_bands,
720            )?);
721            layer_material.push(materials.len() - 1);
722        }
723        for name in e.materials.keys() {
724            if !materials.iter().any(|m| &m.name == name) {
725                warnings.push(format!(
726                    "electron.materials.{name}: not used by any target layer"
727                ));
728            }
729        }
730        Ok((materials, layer_material))
731    }
732
733    /// Validate `[electron.tally]` and build the tally configuration; fills
734    /// in the echoed defaults.
735    fn resolve_tally(&self, echo: &mut ElectronInput) -> Result<ResolvedTally, InputError> {
736        let e = &self.electron;
737        let b = &e.beam;
738        let ts = &e.tally;
739        let f = "electron.tally";
740        if !(ts.se_bse_split_ev.is_finite() && ts.se_bse_split_ev >= 0.0) {
741            return Err(invalid(
742                format!("{f}.se_bse_split_ev"),
743                "must be finite and non-negative",
744            ));
745        }
746        let emax = ts.escape_energy_max_ev.unwrap_or(b.energy_ev);
747        echo.electron.tally.escape_energy_max_ev = Some(emax);
748        let escape_energy = Binning::new(0.0, emax, ts.escape_energy_bins).map_err(|err| {
749            invalid(
750                format!("{f}.escape_energy_max_ev / escape_energy_bins"),
751                err.to_string(),
752            )
753        })?;
754        if !(finite_pos(ts.escape_polar_max_deg) && ts.escape_polar_max_deg <= 180.0) {
755            return Err(invalid(
756                format!("{f}.escape_polar_max_deg"),
757                "must be in (0, 180] degrees",
758            ));
759        }
760        let escape_polar = Binning::new(
761            0.0,
762            ts.escape_polar_max_deg
763                .to_radians()
764                .min(std::f64::consts::PI),
765            ts.escape_polar_bins,
766        )
767        .map_err(|err| invalid(format!("{f}.escape_polar_bins"), err.to_string()))?;
768        let mut tally = ElectronTallyConfig::new(escape_energy, escape_polar);
769        tally.se_bse_split_ev = ts.se_bse_split_ev;
770        if let Some(c) = &ts.cartesian {
771            tally.cartesian = Some(CartesianGrid {
772                x: binning_nm(&format!("{f}.cartesian.x"), &c.x)?,
773                y: binning_nm(&format!("{f}.cartesian.y"), &c.y)?,
774                z: binning_nm(&format!("{f}.cartesian.z"), &c.z)?,
775            });
776        }
777        if let Some(c) = &ts.cylindrical {
778            if c.r.lo_nm < 0.0 {
779                return Err(invalid(format!("{f}.cylindrical.r.lo_nm"), "must be >= 0"));
780            }
781            tally.cylindrical = Some(CylindricalGrid {
782                r: binning_nm(&format!("{f}.cylindrical.r"), &c.r)?,
783                depth: binning_nm(&format!("{f}.cylindrical.depth"), &c.depth)?,
784            });
785        }
786        if let Some(d) = &ts.dose {
787            let depth = match d.depth_bins_per_layer {
788                None => {
789                    if d.refine_width_nm.is_some()
790                        || d.refine_bins.is_some()
791                        || d.substrate_depth_nm.is_some()
792                    {
793                        return Err(invalid(
794                            format!("{f}.dose.depth_bins_per_layer"),
795                            "required when a depth-profile key is given",
796                        ));
797                    }
798                    None
799                }
800                Some(bins_per_layer) => {
801                    if bins_per_layer == 0 {
802                        return Err(invalid(
803                            format!("{f}.dose.depth_bins_per_layer"),
804                            "must be at least 1",
805                        ));
806                    }
807                    let refine = match d.refine_width_nm {
808                        None => None,
809                        Some(w) => {
810                            if !(w.is_finite() && w >= 0.0) {
811                                return Err(invalid(
812                                    format!("{f}.dose.refine_width_nm"),
813                                    "must be finite and non-negative",
814                                ));
815                            }
816                            let bins = d.refine_bins.unwrap_or(10);
817                            if bins == 0 {
818                                return Err(invalid(
819                                    format!("{f}.dose.refine_bins"),
820                                    "must be at least 1",
821                                ));
822                            }
823                            Some(InterfaceRefine {
824                                width_m: w * NM,
825                                bins,
826                            })
827                        }
828                    };
829                    Some(DepthDoseConfig {
830                        bins_per_layer,
831                        refine,
832                        substrate_depth_m: d.substrate_depth_nm.unwrap_or(0.0) * NM,
833                    })
834                }
835            };
836            tally.dose = Some(DoseConfig {
837                depth,
838                transmission: d.transmission,
839            });
840        }
841        let mut voxel_grid = None;
842        if let Some(v) = &ts.voxel {
843            for a in 0..3 {
844                if !v.origin_nm[a].is_finite() {
845                    return Err(invalid(format!("{f}.voxel.origin_nm"), "must be finite"));
846                }
847                if !finite_pos(v.spacing_nm[a]) {
848                    return Err(invalid(
849                        format!("{f}.voxel.spacing_nm"),
850                        "must be finite and positive",
851                    ));
852                }
853                if v.dims[a] == 0 {
854                    return Err(invalid(format!("{f}.voxel.dims"), "must be at least 1"));
855                }
856            }
857            voxel_grid = Some(
858                VoxelTallyGrid::new(
859                    v.origin_nm.map(|x| x * NM),
860                    v.spacing_nm.map(|x| x * NM),
861                    v.dims,
862                )
863                .map_err(|e| invalid(format!("{f}.voxel"), e.to_string()))?,
864            );
865        }
866        let mut psf_fits: Vec<PsfModel> = Vec::new();
867        let mut psf_normalization = PsfNormalization::default();
868        if let Some(p) = &ts.psf {
869            let nm = |key: &str, v: f64| -> Result<f64, InputError> {
870                if v.is_finite() {
871                    Ok(v * NM)
872                } else {
873                    Err(invalid(format!("{f}.psf.{key}"), "must be finite"))
874                }
875            };
876            let depth_lo = nm("depth_lo_nm", p.depth_lo_nm)?;
877            let depth_hi = nm("depth_hi_nm", p.depth_hi_nm)?;
878            if depth_hi <= depth_lo {
879                return Err(invalid(
880                    format!("{f}.psf.depth_hi_nm"),
881                    "must be greater than depth_lo_nm",
882                ));
883            }
884            let r_min = nm("r_min_nm", p.r_min_nm)?;
885            let r_max = nm("r_max_nm", p.r_max_nm)?;
886            if r_min <= 0.0 {
887                return Err(invalid(format!("{f}.psf.r_min_nm"), "must be positive"));
888            }
889            if r_max <= r_min {
890                return Err(invalid(
891                    format!("{f}.psf.r_max_nm"),
892                    "must be greater than r_min_nm",
893                ));
894            }
895            if p.bins == 0 {
896                return Err(invalid(format!("{f}.psf.bins"), "must be at least 1"));
897            }
898            let radial = LogRadialBinning::new(r_min, r_max, p.bins)
899                .map_err(|e| invalid(format!("{f}.psf.bins"), e.to_string()))?;
900            tally.psf = Some(
901                PsfConfig::new(radial, depth_lo, depth_hi)
902                    .map_err(|e| invalid(format!("{f}.psf"), e.to_string()))?,
903            );
904            for m in p.fits.iter().copied().map(PsfModel::from) {
905                if !psf_fits.contains(&m) {
906                    psf_fits.push(m);
907                }
908            }
909            psf_normalization = p.normalization.into();
910        }
911
912        if let Some(x) = &ts.exposure {
913            tally.exposure = Some(self.resolve_exposure(x, echo)?);
914        }
915        Ok(ResolvedTally {
916            tally,
917            voxel_grid,
918            psf_fits,
919            psf_normalization,
920        })
921    }
922
923    /// Validate `[electron.tally.exposure]` and build its configuration.
924    fn resolve_exposure(
925        &self,
926        x: &ExposureSpec,
927        echo: &mut ElectronInput,
928    ) -> Result<ExposureConfig, InputError> {
929        let k = "electron.tally.exposure".to_string();
930        let nm = |key: &str, v: f64| -> Result<f64, InputError> {
931            if v.is_finite() {
932                Ok(v * NM)
933            } else {
934                Err(invalid(format!("{k}.{key}"), "must be finite"))
935            }
936        };
937        let beam = match x.beam {
938            ExposureBeamSpec::Point => ExposureBeamProfile::Point,
939            ExposureBeamSpec::Gaussian { sigma_nm } => ExposureBeamProfile::Gaussian {
940                sigma_m: nm("beam.sigma_nm", sigma_nm)?,
941            },
942            ExposureBeamSpec::UniformDisc { radius_nm } => ExposureBeamProfile::UniformDisc {
943                radius_m: nm("beam.radius_nm", radius_nm)?,
944            },
945        };
946        let mut volumes = Vec::new();
947        for (i, v) in x.volumes.iter().enumerate() {
948            let key = format!("volumes[{i}]");
949            if v.name.is_empty() || x.volumes[..i].iter().any(|o| o.name == v.name) {
950                return Err(invalid(
951                    format!("{k}.{key}.name"),
952                    "must be non-empty and unique",
953                ));
954            }
955            let need = |name: &str, v: Option<f64>| -> Result<f64, InputError> {
956                let v = v.ok_or_else(|| {
957                    invalid(format!("{k}.{key}.{name}"), "required for this shape")
958                })?;
959                nm(&format!("{key}.{name}"), v)
960            };
961            let shape = match v.shape {
962                ExposureShapeSpec::Cylinder => VolumeShape::Cylinder {
963                    radius_m: need("radius_nm", v.radius_nm)?,
964                },
965                ExposureShapeSpec::Box => VolumeShape::Box {
966                    half_y_m: need("half_y_nm", v.half_y_nm)?,
967                    half_z_m: need("half_z_nm", v.half_z_nm)?,
968                },
969            };
970            volumes.push(ExposureVolume {
971                name: v.name.clone(),
972                shape,
973                depth_lo_m: nm(&format!("{key}.depth_lo_nm"), v.depth_lo_nm)?,
974                depth_hi_m: nm(&format!("{key}.depth_hi_nm"), v.depth_hi_nm)?,
975                offset_y_m: nm(&format!("{key}.offset_y_nm"), v.offset_y_nm)?,
976                offset_z_m: nm(&format!("{key}.offset_z_nm"), v.offset_z_nm)?,
977            });
978        }
979        let x_max_ev = x.x_max_ev.unwrap_or(self.electron.beam.energy_ev);
980        echo.electron.tally.exposure.as_mut().unwrap().x_max_ev = Some(x_max_ev);
981        let cfg = ExposureConfig {
982            volumes,
983            beam,
984            // A stream independent of the transport's (same run seed).
985            seed: self.run.seed ^ EXPOSURE_SEED_SALT,
986            bins: x.bins,
987            x_max_ev,
988            lambda: x.lambda,
989            quantiles: x.quantiles.clone(),
990            below_ev: x.below_ev.clone(),
991            above_ev: x.above_ev.clone(),
992            tail_tolerance: x.tail_tolerance,
993        };
994        cfg.validate()
995            .map_err(|e| invalid(k.clone(), e.to_string()))?;
996        Ok(cfg)
997    }
998
999    fn resolve_electron_material(
1000        &self,
1001        base_dir: &Path,
1002        field: &str,
1003        name: &str,
1004        material: &Material,
1005        spec: &ElectronMaterialSpec,
1006        needs_bands: bool,
1007    ) -> Result<ResolvedElectronMaterial, InputError> {
1008        let ef = format!("{field}.optical_elf");
1009        let (text, file) = read_data_file(base_dir, &ef, &spec.optical_elf)?;
1010        // The loader refuses a missing or blank provenance.
1011        let optical_elf = OpticalElf::from_toml_str(&text)
1012            .map_err(|err| invalid(&ef, format!("{}: {err}", spec.optical_elf)))?;
1013
1014        let band = match &spec.band {
1015            None if needs_bands => {
1016                return Err(invalid(
1017                    format!("{field}.band"),
1018                    "required with secondaries, the step barrier or the vacuum-level \
1019                     cutoff (they need the material's Fermi energy and inner potential)",
1020                ))
1021            }
1022            None => None,
1023            Some(bs) => {
1024                let r = match bs {
1025                    BandSpec::Metal {
1026                        fermi_ev,
1027                        work_function_ev,
1028                        valence_binding_ev,
1029                        provenance,
1030                    } => BandStructure::new(
1031                        BandModel::Metal {
1032                            fermi_ev: *fermi_ev,
1033                            work_function_ev: *work_function_ev,
1034                        },
1035                        provenance.trim(),
1036                    )
1037                    .and_then(|b| with_valence_binding(b, *valence_binding_ev)),
1038                    BandSpec::Insulator {
1039                        valence_band_width_ev,
1040                        band_gap_ev,
1041                        affinity_ev,
1042                        provenance,
1043                    } => BandStructure::new(
1044                        BandModel::Insulator {
1045                            valence_band_width_ev: *valence_band_width_ev,
1046                            band_gap_ev: *band_gap_ev,
1047                            affinity_ev: *affinity_ev,
1048                        },
1049                        provenance.trim(),
1050                    ),
1051                    BandSpec::FreeElectronMetal {
1052                        valence_electrons_per_atom,
1053                        work_function_ev,
1054                        valence_binding_ev,
1055                        provenance,
1056                    } => BandStructure::free_electron_metal(
1057                        material,
1058                        *valence_electrons_per_atom,
1059                        *work_function_ev,
1060                        provenance.trim(),
1061                    )
1062                    .and_then(|b| with_valence_binding(b, *valence_binding_ev)),
1063                };
1064                Some(r.map_err(|err| invalid(format!("{field}.band"), err.to_string()))?)
1065            }
1066        };
1067
1068        let mut channels = InsulatorChannels::none();
1069        if let Some(p) = &spec.phonon {
1070            let pf = format!("{field}.phonon");
1071            let ph = match p.preset.as_deref() {
1072                Some(preset) => {
1073                    if p.hbar_omega_ev.is_some()
1074                        || p.eps_static.is_some()
1075                        || p.eps_high_frequency.is_some()
1076                        || p.provenance.is_some()
1077                    {
1078                        return Err(invalid(
1079                            &pf,
1080                            "a preset fixes the parameters and their source; give only \
1081                             preset and temperature_k",
1082                        ));
1083                    }
1084                    let mode = match preset {
1085                        "sio2-63mev" => Sio2LoMode::Mev63,
1086                        "sio2-153mev" => Sio2LoMode::Mev153,
1087                        other => {
1088                            return Err(invalid(
1089                                format!("{pf}.preset"),
1090                                format!(
1091                                    "unknown preset {other:?}; expected \"sio2-63mev\" or \
1092                                     \"sio2-153mev\""
1093                                ),
1094                            ))
1095                        }
1096                    };
1097                    FrohlichPhonon::sio2(mode, p.temperature_k)
1098                }
1099                None => {
1100                    let need = |k: &str, v: Option<f64>| {
1101                        v.ok_or_else(|| invalid(format!("{pf}.{k}"), "required without a preset"))
1102                    };
1103                    FrohlichPhonon::new(
1104                        need("hbar_omega_ev", p.hbar_omega_ev)?,
1105                        need("eps_static", p.eps_static)?,
1106                        need("eps_high_frequency", p.eps_high_frequency)?,
1107                        p.temperature_k,
1108                        p.provenance.as_deref().unwrap_or("").trim(),
1109                    )
1110                }
1111            };
1112            channels.phonon = Some(ph.map_err(|err| invalid(&pf, err.to_string()))?);
1113        }
1114        if let Some(p) = &spec.polaron {
1115            channels.polaron = Some(
1116                PolaronTrapping::new(p.c_per_nm / NM, p.gamma_per_ev, p.provenance.trim())
1117                    .map_err(|err| invalid(format!("{field}.polaron"), err.to_string()))?,
1118            );
1119        }
1120
1121        let mermin_fit = match &spec.mermin_fit {
1122            None => MerminFitOptions::default(),
1123            Some(f) => mermin_fit_options(&format!("{field}.mermin_fit"), f)?,
1124        };
1125
1126        let inner_shells = match &spec.inner_shells {
1127            None => None,
1128            Some(i) => Some(resolve_inner_shells(
1129                base_dir,
1130                &format!("{field}.inner_shells"),
1131                material,
1132                i,
1133            )?),
1134        };
1135
1136        let e_pair = match &spec.e_pair {
1137            None => None,
1138            Some(p) => {
1139                if !(p.ev.is_finite() && p.ev > 0.0) {
1140                    return Err(invalid(
1141                        format!("{field}.e_pair.ev"),
1142                        format!("must be finite and positive, got {}", p.ev),
1143                    ));
1144                }
1145                if p.source.trim().is_empty() {
1146                    return Err(invalid(
1147                        format!("{field}.e_pair.source"),
1148                        "a citation is required: E_pair has no default",
1149                    ));
1150                }
1151                Some(ResolvedEPair {
1152                    ev: p.ev,
1153                    source: p.source.trim().to_string(),
1154                })
1155            }
1156        };
1157
1158        Ok(ResolvedElectronMaterial {
1159            name: name.to_string(),
1160            material: material.clone(),
1161            optical_elf,
1162            optical_elf_file: file,
1163            band,
1164            channels,
1165            mermin_fit,
1166            inner_shells,
1167            e_pair,
1168        })
1169    }
1170}
1171
1172/// The subshells and binding table of an `inner_shells` table; errors name
1173/// `field` and the key. The table must hold every requested subshell of every
1174/// element of `material`.
1175fn resolve_inner_shells(
1176    base_dir: &Path,
1177    field: &str,
1178    material: &Material,
1179    spec: &InnerShellsSpec,
1180) -> Result<ResolvedInnerShells, InputError> {
1181    let sf = format!("{field}.subshells");
1182    if spec.subshells.is_empty() {
1183        return Err(invalid(&sf, "must list at least one subshell"));
1184    }
1185    let mut subshells: Vec<Subshell> = Vec::new();
1186    for label in &spec.subshells {
1187        let s = match Subshell::from_label(label) {
1188            Some(s) if SUPPORTED_SUBSHELLS.contains(&s.label()) => s,
1189            _ => {
1190                return Err(invalid(
1191                    &sf,
1192                    format!(
1193                        "unknown or unsupported subshell {label:?}; expected one of \"K\", \
1194                         \"L1\", \"L2\", \"L3\" (no hydrogenic formula is implemented for the M \
1195                         shell and above)"
1196                    ),
1197                ))
1198            }
1199        };
1200        if subshells.contains(&s) {
1201            return Err(invalid(&sf, format!("{label:?} is listed twice")));
1202        }
1203        subshells.push(s);
1204    }
1205    let (binding, binding_file) = match &spec.binding {
1206        None => (SubshellBindingTable::eadl2017(), None),
1207        Some(path) => {
1208            let bf = format!("{field}.binding");
1209            let (text, file) = read_data_file(base_dir, &bf, path)?;
1210            // The loader refuses a missing or blank provenance.
1211            let table = SubshellBindingTable::from_toml_str(&text)
1212                .map_err(|err| invalid(&bf, format!("{path}: {err}")))?;
1213            (table, Some(file))
1214        }
1215    };
1216    for c in material.components() {
1217        if c.atom_fraction() <= 0.0 {
1218            continue;
1219        }
1220        let z = c.z();
1221        let sym = crate::elements::element(z).map_or("?", |e| e.symbol);
1222        if binding.atom(z).is_none() {
1223            return Err(invalid(
1224                format!("{field}.binding"),
1225                format!("the table has no entry for {sym} (Z = {z}), an element of the material"),
1226            ));
1227        }
1228        for s in &subshells {
1229            if binding.atom(z).and_then(|a| a.shell(*s)).is_none() {
1230                return Err(invalid(
1231                    &sf,
1232                    format!(
1233                        "{} is not in the binding table for {sym} (Z = {z}), an element of \
1234                         the material",
1235                        s.label()
1236                    ),
1237                ));
1238            }
1239        }
1240    }
1241    Ok(ResolvedInnerShells {
1242        subshells,
1243        binding,
1244        binding_file,
1245    })
1246}
1247
1248/// The subshells with a hydrogenic formula (`electron::inelastic::shell_elf`).
1249const SUPPORTED_SUBSHELLS: [&str; 4] = ["K", "L1", "L2", "L3"];
1250
1251/// The fit options of a `mermin_fit` table; errors name `field` and the key.
1252fn mermin_fit_options(field: &str, f: &MerminFitSpec) -> Result<MerminFitOptions, InputError> {
1253    if !(1..=MAX_OSCILLATORS).contains(&f.oscillators) {
1254        return Err(invalid(
1255            format!("{field}.oscillators"),
1256            format!("must be in 1..={MAX_OSCILLATORS}, got {}", f.oscillators),
1257        ));
1258    }
1259    let weighting = match f.weighting {
1260        MerminWeightingChoice::Uniform => {
1261            if f.relative_floor.is_some() {
1262                return Err(invalid(
1263                    format!("{field}.relative_floor"),
1264                    "only applies with weighting = \"relative\"",
1265                ));
1266            }
1267            FitWeighting::Uniform
1268        }
1269        MerminWeightingChoice::Relative => {
1270            let floor = f.relative_floor.unwrap_or_else(default_relative_floor);
1271            if !(floor.is_finite() && floor > 0.0 && floor <= 1.0) {
1272                return Err(invalid(
1273                    format!("{field}.relative_floor"),
1274                    format!("must be in (0, 1], got {floor}"),
1275                ));
1276            }
1277            FitWeighting::Relative { floor }
1278        }
1279    };
1280    Ok(MerminFitOptions {
1281        n_oscillators: f.oscillators,
1282        weighting,
1283        ..MerminFitOptions::default()
1284    })
1285}