1use super::{check_mass, from_ev_1e15_cm2, ElectronicStopping, Ion, StoppingError, ValidityRange};
30use serde::Deserialize;
31use std::path::Path;
32
33pub const MASS_RELATIVE_TOLERANCE: f64 = 1.0e-4;
37
38#[derive(Deserialize)]
39struct RawTable {
40 provenance: Option<String>,
41 ion_z: u8,
42 ion_mass_amu: Option<f64>,
43 target_z: u8,
44 energy_ev: Vec<f64>,
45 stopping_ev_1e15_cm2: Vec<f64>,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct StoppingTable {
51 provenance: String,
52 ion_z: u8,
53 ion_mass_amu: f64,
54 target_z: u8,
55 ln_e: Vec<f64>,
57 ln_s: Vec<f64>,
59 energy_ev: Vec<f64>,
60}
61
62impl StoppingTable {
63 pub fn from_toml_str(text: &str) -> Result<Self, StoppingError> {
65 let raw: RawTable =
66 toml::from_str(text).map_err(|e| StoppingError::InvalidTable(e.to_string()))?;
67 let provenance = raw.provenance.unwrap_or_default().trim().to_string();
68 if provenance.is_empty() {
69 return Err(StoppingError::MissingProvenance);
70 }
71 let ion_element = super::target(raw.ion_z)?;
72 let ion_mass_amu = match raw.ion_mass_amu {
73 Some(m) => {
74 check_mass(m)?;
75 m
76 }
77 None => ion_element.atomic_weight,
78 };
79 super::target(raw.target_z)?;
80 let (e, s) = (raw.energy_ev, raw.stopping_ev_1e15_cm2);
81 if e.len() != s.len() || e.len() < 2 {
82 return Err(StoppingError::InvalidTable(
83 "energy and stopping need the same length, at least 2 points".into(),
84 ));
85 }
86 if e.iter().any(|x| !(x.is_finite() && *x > 0.0))
87 || s.iter().any(|x| !(x.is_finite() && *x > 0.0))
88 {
89 return Err(StoppingError::InvalidTable(
90 "energies and stopping values must be finite and positive".into(),
91 ));
92 }
93 if e.windows(2).any(|w| w[1] <= w[0]) {
94 return Err(StoppingError::InvalidTable(
95 "energies must be strictly increasing".into(),
96 ));
97 }
98 Ok(Self {
99 provenance,
100 ion_z: raw.ion_z,
101 ion_mass_amu,
102 target_z: raw.target_z,
103 ln_e: e.iter().map(|x| x.ln()).collect(),
104 ln_s: s.iter().map(|x| from_ev_1e15_cm2(*x).ln()).collect(),
105 energy_ev: e,
106 })
107 }
108
109 pub fn from_toml_file(path: impl AsRef<Path>) -> Result<Self, StoppingError> {
111 let text = std::fs::read_to_string(path.as_ref())
112 .map_err(|e| StoppingError::InvalidTable(e.to_string()))?;
113 Self::from_toml_str(&text)
114 }
115
116 pub fn provenance(&self) -> &str {
118 &self.provenance
119 }
120
121 pub fn ion_z(&self) -> u8 {
123 self.ion_z
124 }
125
126 pub fn ion_mass_amu(&self) -> f64 {
128 self.ion_mass_amu
129 }
130
131 pub fn target_z(&self) -> u8 {
133 self.target_z
134 }
135
136 pub fn energy_range_ev(&self) -> (f64, f64) {
138 (self.energy_ev[0], self.energy_ev[self.energy_ev.len() - 1])
139 }
140
141 pub fn covers_pair(&self, ion_z: u8, target_z: u8) -> bool {
144 self.ion_z == ion_z && self.target_z == target_z
145 }
146}
147
148pub struct TableOverride {
156 tables: Vec<StoppingTable>,
157 base: Box<dyn ElectronicStopping + Send + Sync>,
158}
159
160impl std::fmt::Debug for TableOverride {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 f.debug_struct("TableOverride")
164 .field("tables", &self.tables)
165 .finish_non_exhaustive()
166 }
167}
168
169impl TableOverride {
170 pub fn new(
172 tables: Vec<StoppingTable>,
173 base: Box<dyn ElectronicStopping + Send + Sync>,
174 ) -> Self {
175 Self { tables, base }
176 }
177}
178
179impl ElectronicStopping for TableOverride {
180 fn name(&self) -> &'static str {
181 "user-table-override"
182 }
183
184 fn stopping(&self, ion: &Ion, target_z: u8, energy_ev: f64) -> Result<f64, StoppingError> {
185 match self
186 .tables
187 .iter()
188 .find(|t| t.covers_pair(ion.z(), target_z))
189 {
190 Some(t) => t.stopping(ion, target_z, energy_ev),
191 None => self.base.stopping(ion, target_z, energy_ev),
192 }
193 }
194
195 fn validity(&self, ion: &Ion) -> ValidityRange {
198 self.base.validity(ion)
199 }
200}
201
202impl ElectronicStopping for StoppingTable {
203 fn name(&self) -> &'static str {
204 "user-table"
205 }
206
207 fn stopping(&self, ion: &Ion, target_z: u8, energy_ev: f64) -> Result<f64, StoppingError> {
208 super::check_energy(energy_ev)?;
209 if ion.z() != self.ion_z || target_z != self.target_z {
210 return Err(StoppingError::TableMismatch {
211 table_ion: self.ion_z,
212 table_target: self.target_z,
213 ion: ion.z(),
214 target: target_z,
215 });
216 }
217 if (ion.mass_amu() - self.ion_mass_amu).abs() > MASS_RELATIVE_TOLERANCE * self.ion_mass_amu
218 {
219 return Err(StoppingError::TableMassMismatch {
220 table_mass_amu: self.ion_mass_amu,
221 mass_amu: ion.mass_amu(),
222 });
223 }
224 let (lo, hi) = (self.energy_ev[0], self.energy_ev[self.energy_ev.len() - 1]);
225 if energy_ev < lo || energy_ev > hi {
226 return Err(StoppingError::OutOfTableRange {
227 energy_ev,
228 min_ev: lo,
229 max_ev: hi,
230 });
231 }
232 let x = energy_ev.ln();
233 let i = self
234 .ln_e
235 .partition_point(|&v| v <= x)
236 .clamp(1, self.ln_e.len() - 1);
237 let t = (x - self.ln_e[i - 1]) / (self.ln_e[i] - self.ln_e[i - 1]);
238 Ok((self.ln_s[i - 1] + t * (self.ln_s[i] - self.ln_s[i - 1])).exp())
239 }
240
241 fn validity(&self, _ion: &Ion) -> ValidityRange {
242 ValidityRange {
243 min_energy_ev: self.energy_ev[0],
244 max_energy_ev: self.energy_ev[self.energy_ev.len() - 1],
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 const GOOD: &str = r#"
254provenance = "synthetic test data, made up for unit tests"
255ion_z = 14
256target_z = 14
257energy_ev = [1.0e3, 1.0e4, 1.0e5]
258stopping_ev_1e15_cm2 = [10.0, 40.0, 90.0]
259"#;
260
261 #[test]
262 fn override_routes_declared_pairs_to_the_table_and_others_to_the_base() {
263 use crate::ion::stopping::lindhard_scharff::LindhardScharff;
264 let t = StoppingTable::from_toml_str(GOOD).unwrap();
265 let o = TableOverride::new(vec![t.clone()], Box::new(LindhardScharff::new()));
266 let si = Ion::new(14).unwrap();
267 assert_eq!(
268 o.stopping(&si, 14, 1.0e4).unwrap(),
269 t.stopping(&si, 14, 1.0e4).unwrap()
270 );
271 assert!(matches!(
274 o.stopping(&si, 14, 10.0),
275 Err(StoppingError::OutOfTableRange { .. })
276 ));
277 let ls = LindhardScharff::new();
279 assert_eq!(
280 o.stopping(&si, 8, 1.0e4).unwrap(),
281 ls.stopping(&si, 8, 1.0e4).unwrap()
282 );
283 }
284
285 #[test]
286 fn rejects_missing_or_blank_provenance() {
287 let no = GOOD.replace(
288 "provenance = \"synthetic test data, made up for unit tests\"\n",
289 "",
290 );
291 let msg = StoppingTable::from_toml_str(&no).unwrap_err().to_string();
292 assert!(msg.contains("provenance"), "{msg}");
293 let blank = GOOD.replace("synthetic test data, made up for unit tests", " ");
294 assert_eq!(
295 StoppingTable::from_toml_str(&blank).unwrap_err(),
296 StoppingError::MissingProvenance
297 );
298 }
299
300 #[test]
301 fn interpolates_exactly_at_nodes_and_log_log_between() {
302 let t = StoppingTable::from_toml_str(GOOD).unwrap();
303 let ion = Ion::new(14).unwrap();
304 let at = |e| super::super::to_ev_1e15_cm2(t.stopping(&ion, 14, e).unwrap());
305 assert!((at(1.0e4) / 40.0 - 1.0).abs() < 1e-12);
306 assert!((at(1.0e5) / 90.0 - 1.0).abs() < 1e-12);
307 let mid = at(10f64.powf(3.5));
308 assert!((mid / (10.0f64 * 40.0).sqrt() - 1.0).abs() < 1e-12);
309 let mut prev = 0.0;
310 for i in 0..200 {
311 let v = at(1.0e3 * 10f64.powf(f64::from(i) / 100.0));
312 assert!(v > prev);
313 prev = v;
314 }
315 }
316
317 #[test]
318 fn out_of_range_and_mismatch_are_errors() {
319 let t = StoppingTable::from_toml_str(GOOD).unwrap();
320 let ion = Ion::new(14).unwrap();
321 assert!(matches!(
322 t.stopping(&ion, 14, 10.0),
323 Err(StoppingError::OutOfTableRange { .. })
324 ));
325 assert!(matches!(
326 t.stopping(&ion, 8, 1.0e4),
327 Err(StoppingError::TableMismatch { .. })
328 ));
329 }
330
331 #[test]
332 fn declared_mass_must_match_projectile_isotope() {
333 let h1 = GOOD.replace("ion_z = 14", "ion_z = 1\nion_mass_amu = 1.00794");
334 let t = StoppingTable::from_toml_str(&h1).unwrap();
335 let ion = Ion::with_mass(1, 1.00794).unwrap();
337 assert!(t.stopping(&ion, 14, 1.0e4).is_ok());
338 let d = Ion::with_mass(1, 2.014).unwrap();
340 assert!(matches!(
341 t.stopping(&d, 14, 1.0e4),
342 Err(StoppingError::TableMassMismatch { .. })
343 ));
344 }
345
346 #[test]
347 fn omitted_mass_defaults_to_standard_weight() {
348 let t = StoppingTable::from_toml_str(GOOD).unwrap();
349 assert!(t.stopping(&Ion::new(14).unwrap(), 14, 1.0e4).is_ok());
350 let si28 = Ion::with_mass(14, 27.9769).unwrap();
351 assert!(matches!(
352 t.stopping(&si28, 14, 1.0e4),
353 Err(StoppingError::TableMassMismatch { .. })
354 ));
355 }
356
357 #[test]
358 fn rejects_invalid_declared_mass() {
359 for m in ["0.0", "-1.0", "nan", "inf"] {
360 let bad = GOOD.replace("ion_z = 14", &format!("ion_z = 14\nion_mass_amu = {m}"));
361 assert!(
362 matches!(
363 StoppingTable::from_toml_str(&bad),
364 Err(StoppingError::InvalidMass(_))
365 ),
366 "mass {m}"
367 );
368 }
369 }
370
371 #[test]
372 fn rejects_unsorted() {
373 let bad = GOOD.replace("[1.0e3, 1.0e4, 1.0e5]", "[1.0e4, 1.0e3, 1.0e5]");
374 assert!(StoppingTable::from_toml_str(&bad).is_err());
375 }
376}