lindhard/electron/inelastic/inner_shell.rs
1//! Inner-shell ionization channels from shell-resolved loss functions.
2//!
3//! # Source
4//!
5//! P. de Vera, S. Taioli, P. E. Trevisanutto, M. Dapor, I. Abril, S.
6//! Simonucci and R. Garcia-Molina, "Energy Deposition around Swift Carbon-Ion
7//! Tracks in Liquid Water", Int. J. Mol. Sci. 23, 6121 (2022),
8//! doi:10.3390/ijms23116121 (open access, PMC9181504; "dV2022", as in
9//! [`super::penn`]):
10//!
11//! * eq. (1) splits the ELF into an outer-shell and an inner-shell part, and
12//! eq. (2) writes the inner part as a sum over elements `j` (weighted by the
13//! atoms per molecule `ν_j`) and subshells `nℓ` of atomic generalised
14//! oscillator strengths, each multiplied by the step `Θ(E - B_nℓ^j)`: an
15//! inner shell contributes only to losses above its binding energy;
16//! * eqs. (32) and (35) write the secondary-electron spectrum and the
17//! ionization cross section as a sum of one outer-shell (valence) term and
18//! one term per inner shell `j`, each built from **that shell's own loss
19//! function** `Im[-1/ε(k, W + B_j)]_j`, where `W` is the kinetic energy of
20//! the emitted electron. So a loss `ω = W + B_j` in shell `j` hands the
21//! secondary `W = ω - B_j` ([`ShellResolvedChannels::secondary_energy_ev`]).
22//!
23//! # The model here
24//!
25//! [`ShellResolvedChannels`] takes what those equations take: a valence
26//! optical ELF and **one optical ELF per inner shell**. The shell ELFs are
27//! either supplied by the caller or built from atomic oscillator strengths
28//! as in dV2022 eq. (2) by [`super::shell_elf`] (the hydrogenic K-, L1-,
29//! L2- and L3-subshell formulas, with binding energies and occupancies from
30//! a [`SubshellBindingTable`]; other subshells are not built there). A single
31//! total ELF is never split: no partition rule of ours is applied. Each shell's ELF must
32//! start at or above its binding energy (the step of eq. (2)). Each channel's
33//! DIIMFP is the single-pole Penn DIIMFP ([`SinglePolePenn`]) of its own ELF;
34//! with the exchange correction applying, shell `j` uses its binding energy
35//! in the Born-Ochkur denominator `T' - W = T' - ω + B_j` and is limited to
36//! `ω <= (T' + B_j)/2`, and the valence channel has `B = 0` (dV2022 eqs. (32),
37//! (34), (35); see the `super::penn` module docs for the terms of eq. (32)
38//! not followed: the Coulomb-field correction of the direct term, and the
39//! energy-dependent valence binding energy `B(T)`).
40//!
41//! **Our choices, not from dV2022:** the momentum dependence of every
42//! channel's loss function is the Penn SPA of S2017 applied to that
43//! channel's optical ELF (dV2022 uses Mermin functions for the outer shells
44//! and hydrogenic GOS for the inner ones); and the channel models share the
45//! valence model's Fermi energy, tolerance and exchange setting. The SPA is
46//! linear in the optical ELF, so without exchange the channel inverse mean
47//! free paths add up to that of one model built from the sum of the ELFs
48//! (when the sum is representable on one grid; tested).
49//!
50//! The binding energies come from a [`SubshellBindingTable`]
51//! ([`InnerShell::from_table`]) or are given directly.
52//!
53//! # In the transport
54//!
55//! [`super::table::build_shell_channel_tables`] tabulates every channel
56//! (one [`ShellChannelTable`] per shell, beside the valence table), and
57//! [`crate::electron::transport::Transport::with_inner_shells`] samples them:
58//! a channel is chosen first, with probability proportional to its inverse
59//! IMFP, and the loss is then drawn from that channel's own table. That is a
60//! different construction from [`ShellResolvedChannels::sample_channel`]
61//! (channel conditional on a loss already drawn from the total DIIMFP), which
62//! the transport does not use. The secondary-energy convention on the
63//! transport's band-bottom axis is in [`crate::electron::secondary`],
64//! "Inner-shell events".
65
66use super::penn::SinglePolePenn;
67use crate::electron::data::{
68 ElectronDataError, OpticalElf, ShellChannelTable, Subshell, SubshellBindingTable,
69};
70
71type Result<T> = std::result::Result<T, ElectronDataError>;
72
73fn invalid(what: &'static str, reason: String) -> ElectronDataError {
74 ElectronDataError::Invalid { what, reason }
75}
76
77/// An inner-shell channel: one subshell of one element.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct InnerShell {
80 /// Atomic number.
81 pub z: u8,
82 /// The subshell.
83 pub subshell: Subshell,
84 /// Binding energy `B`, eV.
85 pub binding_energy_ev: f64,
86}
87
88impl InnerShell {
89 /// The shell `subshell` of element `z`, with its binding energy from
90 /// `table`.
91 pub fn from_table(table: &SubshellBindingTable, z: u8, subshell: Subshell) -> Result<Self> {
92 let binding_energy_ev = table
93 .atom(z)
94 .and_then(|a| a.binding_energy_ev(subshell))
95 .ok_or_else(|| {
96 invalid(
97 "inner shell",
98 format!("Z = {z} {} is not in the binding table", subshell.label()),
99 )
100 })?;
101 Ok(Self {
102 z,
103 subshell,
104 binding_energy_ev,
105 })
106 }
107}
108
109impl From<&ShellChannelTable> for InnerShell {
110 /// The shell a stored channel table belongs to.
111 fn from(t: &ShellChannelTable) -> Self {
112 Self {
113 z: t.z(),
114 subshell: t.subshell(),
115 binding_energy_ev: t.binding_energy_ev(),
116 }
117 }
118}
119
120/// A loss channel.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Channel {
123 /// Valence excitation and ionization.
124 Valence,
125 /// Ionization of inner shell `i` (an index into
126 /// [`ShellResolvedChannels::inner_shells`]).
127 InnerShell(usize),
128}
129
130/// Inverse mean free path by channel, m⁻¹.
131#[derive(Debug, Clone, PartialEq)]
132pub struct ChannelInverseImfp {
133 /// Valence channel.
134 pub valence_per_m: f64,
135 /// Inner-shell channels, in the order of
136 /// [`ShellResolvedChannels::inner_shells`].
137 pub shells_per_m: Vec<f64>,
138}
139
140impl ChannelInverseImfp {
141 /// The sum over channels.
142 pub fn total_per_m(&self) -> f64 {
143 self.valence_per_m + self.shells_per_m.iter().sum::<f64>()
144 }
145}
146
147/// DIIMFP by channel at one loss, m⁻¹ eV⁻¹.
148#[derive(Debug, Clone, PartialEq)]
149pub struct ChannelDiimfp {
150 /// Valence channel.
151 pub valence_per_m_ev: f64,
152 /// Inner-shell channels, in the order of
153 /// [`ShellResolvedChannels::inner_shells`].
154 pub shells_per_m_ev: Vec<f64>,
155}
156
157impl ChannelDiimfp {
158 /// The sum over channels.
159 pub fn total_per_m_ev(&self) -> f64 {
160 self.valence_per_m_ev + self.shells_per_m_ev.iter().sum::<f64>()
161 }
162}
163
164/// A valence channel and inner-shell channels, each with its own optical ELF
165/// (module docs).
166#[derive(Debug, Clone)]
167pub struct ShellResolvedChannels {
168 valence: SinglePolePenn,
169 shells: Vec<InnerShell>,
170 models: Vec<SinglePolePenn>,
171}
172
173impl ShellResolvedChannels {
174 /// Channels from a `valence` model and one optical ELF per inner shell,
175 /// kept in the given order. The shell models take the valence model's
176 /// Fermi energy, tolerance and exchange setting. Each binding energy must
177 /// be finite and positive, each shell's ELF must start at or above its
178 /// binding energy, and a `(Z, subshell)` may not repeat.
179 pub fn new(valence: SinglePolePenn, shells: Vec<(InnerShell, OpticalElf)>) -> Result<Self> {
180 let mut inner = Vec::with_capacity(shells.len());
181 let mut models = Vec::with_capacity(shells.len());
182 for (shell, elf) in shells {
183 let b = shell.binding_energy_ev;
184 let name = format!("Z = {} {}", shell.z, shell.subshell.label());
185 if !(b.is_finite() && b > 0.0) {
186 return Err(invalid(
187 "inner shell",
188 format!("{name}: binding energy must be finite and positive, got {b} eV"),
189 ));
190 }
191 let first = elf.energy_range_ev().0;
192 if first < b {
193 return Err(invalid(
194 "inner-shell ELF",
195 format!(
196 "{name}: the ELF starts at {first} eV, below the binding energy {b} eV \
197 (a shell contributes only above its edge)"
198 ),
199 ));
200 }
201 if inner
202 .iter()
203 .any(|s: &InnerShell| s.z == shell.z && s.subshell == shell.subshell)
204 {
205 return Err(invalid("inner shell", format!("{name} listed twice")));
206 }
207 models.push(valence.same_settings_for(elf));
208 inner.push(shell);
209 }
210 Ok(Self {
211 valence,
212 shells: inner,
213 models,
214 })
215 }
216
217 /// The valence model.
218 pub fn valence(&self) -> &SinglePolePenn {
219 &self.valence
220 }
221
222 /// The inner-shell channels, in the order given.
223 pub fn inner_shells(&self) -> &[InnerShell] {
224 &self.shells
225 }
226
227 /// The model of inner shell `i` (its own ELF, the valence settings).
228 pub fn shell_model(&self, i: usize) -> &SinglePolePenn {
229 &self.models[i]
230 }
231
232 /// The channel-resolved inverse IMFP at kinetic energy `energy_ev` above
233 /// the Fermi level: the valence model's `λ⁻¹`, and for shell `i`
234 /// [`SinglePolePenn::imfp_and_stopping_with_binding`] of its model with
235 /// its binding energy (module docs).
236 pub fn inverse_imfps(&self, energy_ev: f64) -> Result<ChannelInverseImfp> {
237 let valence_per_m = self
238 .valence
239 .imfp_and_stopping(energy_ev)?
240 .inverse_imfp_per_m;
241 let shells_per_m = self
242 .shells
243 .iter()
244 .zip(&self.models)
245 .map(|(s, m)| {
246 m.imfp_and_stopping_with_binding(energy_ev, s.binding_energy_ev)
247 .map(|p| p.inverse_imfp_per_m)
248 })
249 .collect::<Result<Vec<_>>>()?;
250 Ok(ChannelInverseImfp {
251 valence_per_m,
252 shells_per_m,
253 })
254 }
255
256 /// The channel-resolved DIIMFP at kinetic energy `energy_ev` and loss
257 /// `loss_ev` ([`SinglePolePenn::diimfp_with_binding_per_m_ev`] per
258 /// channel).
259 pub fn diimfps_per_m_ev(&self, energy_ev: f64, loss_ev: f64) -> Result<ChannelDiimfp> {
260 let valence_per_m_ev = self.valence.diimfp_per_m_ev(energy_ev, loss_ev)?;
261 let shells_per_m_ev = self
262 .shells
263 .iter()
264 .zip(&self.models)
265 .map(|(s, m)| m.diimfp_with_binding_per_m_ev(energy_ev, loss_ev, s.binding_energy_ev))
266 .collect::<Result<Vec<_>>>()?;
267 Ok(ChannelDiimfp {
268 valence_per_m_ev,
269 shells_per_m_ev,
270 })
271 }
272
273 /// The channel of a loss `loss_ev` at kinetic energy `energy_ev`, for a
274 /// uniform variate `u` in `[0, 1)`: each channel with probability
275 /// proportional to its DIIMFP there, the inner shells first in order,
276 /// then valence. `None` if every channel's DIIMFP is zero there.
277 /// Deterministic in its arguments.
278 pub fn sample_channel(&self, energy_ev: f64, loss_ev: f64, u: f64) -> Result<Option<Channel>> {
279 let d = self.diimfps_per_m_ev(energy_ev, loss_ev)?;
280 let total = d.total_per_m_ev();
281 if total <= 0.0 {
282 return Ok(None);
283 }
284 let mut acc = 0.0;
285 for (i, p) in d.shells_per_m_ev.iter().enumerate() {
286 acc += p / total;
287 if *p > 0.0 && u < acc {
288 return Ok(Some(Channel::InnerShell(i)));
289 }
290 }
291 Ok(Some(Channel::Valence))
292 }
293
294 /// The kinetic energy `W = ω - B` handed to the secondary electron by an
295 /// inner-shell loss `loss_ev`, eV (dV2022 eqs. (32), (34)); `None` for the
296 /// valence channel (its secondary energy depends on the band structure,
297 /// see `electron::secondary`) and below the edge.
298 pub fn secondary_energy_ev(&self, channel: Channel, loss_ev: f64) -> Option<f64> {
299 match channel {
300 Channel::Valence => None,
301 Channel::InnerShell(i) => {
302 let w = loss_ev - self.shells.get(i)?.binding_energy_ev;
303 (w >= 0.0).then_some(w)
304 }
305 }
306 }
307}