1use super::bragg::{bragg_cross_section_per_atom, CompoundCorrection};
38use super::{to_ev_1e15_cm2, ElectronicStopping, Ion, StoppingError};
39use crate::elements::{element, element_by_symbol};
40use crate::material::Material;
41use std::path::Path;
42
43pub const FORMAT_VERSION: &str = "lindhard-stopping-data/1";
45
46pub const COLUMNS: [&str; 8] = [
50 "ion_z",
51 "ion_mass_amu",
52 "target",
53 "energy_ev",
54 "s_e_ev_1e15_cm2",
55 "sigma_ev_1e15_cm2",
56 "method",
57 "citation",
58];
59
60pub const OPTIONAL_COLUMNS: [&str; 2] = ["citation_key", "origin"];
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Origin {
68 DigitizedCompilation,
72}
73
74impl Origin {
75 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::DigitizedCompilation => "digitized-compilation",
79 }
80 }
81
82 fn parse(text: &str) -> Option<Self> {
83 (text == "digitized-compilation").then_some(Self::DigitizedCompilation)
84 }
85}
86
87pub const PLACEHOLDER_DENSITY_KG_M3: f64 = 1000.0;
95
96#[derive(Debug, Clone, PartialEq)]
98pub enum Target {
99 Element(u8),
102 Compound {
106 formula: String,
108 material: Material,
110 },
111}
112
113impl Target {
114 pub fn parse(text: &str) -> Result<Self, String> {
119 let parts = parse_formula(text)?;
120 if parts.len() == 1 {
121 return Ok(Self::Element(parts[0].0));
122 }
123 let material = Material::from_atom_fractions(&parts, Some(PLACEHOLDER_DENSITY_KG_M3))
124 .map_err(|e| format!("target {text:?}: {e}"))?
125 .with_name(text);
126 Ok(Self::Compound {
127 formula: text.to_string(),
128 material,
129 })
130 }
131
132 pub fn label(&self) -> &str {
134 match self {
135 Self::Element(z) => element(*z).map_or("?", |e| e.symbol),
137 Self::Compound { formula, .. } => formula,
138 }
139 }
140}
141
142fn parse_formula(text: &str) -> Result<Vec<(u8, f64)>, String> {
143 if text.is_empty() {
144 return Err("empty target".into());
145 }
146 let b = text.as_bytes();
147 let mut i = 0;
148 let mut parts: Vec<(u8, f64)> = Vec::new();
149 while i < b.len() {
150 if !b[i].is_ascii_uppercase() {
151 return Err(format!(
152 "target {text:?}: expected an element symbol at {:?}",
153 &text[i..]
154 ));
155 }
156 let start = i;
157 i += 1;
158 while i < b.len() && b[i].is_ascii_lowercase() {
159 i += 1;
160 }
161 let symbol = &text[start..i];
162 let el = element_by_symbol(symbol)
163 .ok_or_else(|| format!("target {text:?}: unknown element {symbol:?}"))?;
164 let cstart = i;
165 while i < b.len() && (b[i].is_ascii_digit() || b[i] == b'.') {
166 i += 1;
167 }
168 let count = if cstart == i {
169 1.0
170 } else {
171 text[cstart..i]
172 .parse::<f64>()
173 .map_err(|_| format!("target {text:?}: bad count {:?}", &text[cstart..i]))?
174 };
175 if !(count.is_finite() && count > 0.0) {
176 return Err(format!(
177 "target {text:?}: count of {symbol} must be positive"
178 ));
179 }
180 if parts.iter().any(|&(z, _)| z == el.z) {
181 return Err(format!("target {text:?}: {symbol} appears more than once"));
182 }
183 parts.push((el.z, count));
184 }
185 Ok(parts)
186}
187
188#[derive(Debug, Clone, PartialEq)]
190pub struct StoppingPoint {
191 line: usize,
192 ion: Ion,
193 target: Target,
194 energy_ev: f64,
195 s_e_ev_1e15_cm2: f64,
196 sigma_ev_1e15_cm2: f64,
197 method: String,
198 citation: String,
199 citation_key: Option<String>,
200 origin: Option<Origin>,
201}
202
203impl StoppingPoint {
204 pub fn line(&self) -> usize {
206 self.line
207 }
208
209 pub fn ion(&self) -> &Ion {
212 &self.ion
213 }
214
215 pub fn target(&self) -> &Target {
217 &self.target
218 }
219
220 pub fn energy_ev(&self) -> f64 {
222 self.energy_ev
223 }
224
225 pub fn s_e_ev_1e15_cm2(&self) -> f64 {
228 self.s_e_ev_1e15_cm2
229 }
230
231 pub fn sigma_ev_1e15_cm2(&self) -> f64 {
234 self.sigma_ev_1e15_cm2
235 }
236
237 pub fn method(&self) -> &str {
239 &self.method
240 }
241
242 pub fn citation(&self) -> &str {
245 &self.citation
246 }
247
248 pub fn citation_key(&self) -> Option<&str> {
251 self.citation_key.as_deref()
252 }
253
254 pub fn origin(&self) -> Option<Origin> {
256 self.origin
257 }
258}
259
260#[derive(Debug, Clone, PartialEq)]
262pub struct StoppingDataset {
263 name: String,
264 description: Option<String>,
265 metadata: Vec<(String, String)>,
266 points: Vec<StoppingPoint>,
267}
268
269fn bad(line: usize, reason: impl Into<String>) -> StoppingError {
270 StoppingError::InvalidDataset {
271 line,
272 reason: reason.into(),
273 }
274}
275
276fn metadata_entry(comment: &str) -> Option<(&str, &str)> {
278 let (key, value) = comment.split_once(':')?;
279 let key = key.trim();
280 let is_key = !key.is_empty()
281 && key
282 .bytes()
283 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_');
284 is_key.then(|| (key, value.trim()))
285}
286
287fn split_csv(line: &str) -> Result<Vec<String>, String> {
290 let mut fields = Vec::new();
291 let mut chars = line.chars().peekable();
292 loop {
293 while chars.peek().is_some_and(|c| *c == ' ' || *c == '\t') {
294 chars.next();
295 }
296 let mut field = String::new();
297 if chars.peek() == Some(&'"') {
298 chars.next();
299 loop {
300 match chars.next() {
301 Some('"') if chars.peek() == Some(&'"') => {
302 chars.next();
303 field.push('"');
304 }
305 Some('"') => break,
306 Some(c) => field.push(c),
307 None => return Err("unterminated quoted field".into()),
308 }
309 }
310 while chars.peek().is_some_and(|c| *c == ' ' || *c == '\t') {
311 chars.next();
312 }
313 match chars.next() {
314 None => {
315 fields.push(field);
316 return Ok(fields);
317 }
318 Some(',') => fields.push(field),
319 Some(c) => return Err(format!("unexpected {c:?} after a quoted field")),
320 }
321 } else {
322 loop {
323 match chars.next() {
324 None => {
325 fields.push(field.trim().to_string());
326 return Ok(fields);
327 }
328 Some(',') => break,
329 Some('"') => return Err("quote inside an unquoted field".into()),
330 Some(c) => field.push(c),
331 }
332 }
333 fields.push(field.trim().to_string());
334 }
335 }
336}
337
338fn number(line: usize, column: &str, text: &str) -> Result<f64, StoppingError> {
339 text.parse::<f64>()
340 .map_err(|_| bad(line, format!("{column}: not a number: {text:?}")))
341}
342
343fn finite_positive(line: usize, column: &str, text: &str) -> Result<f64, StoppingError> {
344 let v = number(line, column, text)?;
345 if v.is_finite() && v > 0.0 {
346 Ok(v)
347 } else {
348 Err(bad(
349 line,
350 format!("{column} must be finite and positive, got {v}"),
351 ))
352 }
353}
354
355fn parse_point(
356 line: usize,
357 f: &[String],
358 with_origin: bool,
359) -> Result<StoppingPoint, StoppingError> {
360 let want = COLUMNS.len()
361 + if with_origin {
362 OPTIONAL_COLUMNS.len()
363 } else {
364 0
365 };
366 if f.len() != want {
367 return Err(bad(
368 line,
369 format!("expected {want} fields, found {}", f.len()),
370 ));
371 }
372 let (citation_key, origin) = if with_origin {
373 let key = f[8].trim();
374 if key.is_empty() {
375 return Err(bad(line, "empty citation_key; every point needs one"));
376 }
377 let origin = Origin::parse(&f[9]).ok_or_else(|| {
378 bad(
379 line,
380 format!(
381 "unknown origin {:?}; the only known value is {:?}",
382 f[9],
383 Origin::DigitizedCompilation.as_str()
384 ),
385 )
386 })?;
387 (Some(key.to_string()), Some(origin))
388 } else {
389 (None, None)
390 };
391 let ion_z: u8 = f[0]
392 .parse()
393 .map_err(|_| bad(line, format!("ion_z: not an atomic number: {:?}", f[0])))?;
394 if element(ion_z).is_none() {
395 return Err(bad(line, format!("unknown ion Z = {ion_z}")));
396 }
397 let ion = if f[1].is_empty() {
398 Ion::new(ion_z)
399 } else {
400 Ion::with_mass(ion_z, number(line, "ion_mass_amu", &f[1])?)
401 }
402 .map_err(|e| bad(line, e.to_string()))?;
403 let target = Target::parse(&f[2]).map_err(|e| bad(line, e))?;
404 let energy_ev = finite_positive(line, "energy_ev", &f[3])?;
405 let s_e = finite_positive(line, "s_e_ev_1e15_cm2", &f[4])?;
406 let sigma = number(line, "sigma_ev_1e15_cm2", &f[5])?;
407 if !(sigma.is_finite() && sigma >= 0.0) {
408 return Err(bad(
409 line,
410 format!("sigma_ev_1e15_cm2 must be finite and non-negative, got {sigma}"),
411 ));
412 }
413 if f[6].trim().is_empty() {
414 return Err(bad(line, "empty method; every point needs one"));
415 }
416 if f[7].trim().is_empty() {
417 return Err(bad(line, "empty citation; every point needs one"));
418 }
419 Ok(StoppingPoint {
420 line,
421 ion,
422 target,
423 energy_ev,
424 s_e_ev_1e15_cm2: s_e,
425 sigma_ev_1e15_cm2: sigma,
426 method: f[6].trim().to_string(),
427 citation: f[7].trim().to_string(),
428 citation_key,
429 origin,
430 })
431}
432
433impl StoppingDataset {
434 pub fn from_csv_str(text: &str) -> Result<Self, StoppingError> {
441 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
442 let mut metadata: Vec<(String, String)> = Vec::new();
443 let mut in_header = true;
444 let mut with_origin = false;
445 let mut points = Vec::new();
446 for (i, raw) in text.lines().enumerate() {
447 let line = i + 1;
448 let l = raw.strip_suffix('\r').unwrap_or(raw).trim();
449 if l.is_empty() {
450 continue;
451 }
452 if let Some(comment) = l.strip_prefix('#') {
453 if in_header {
454 if let Some((key, value)) = metadata_entry(comment) {
455 if metadata.iter().any(|(k, _)| k == key) {
456 return Err(bad(line, format!("header key {key:?} given twice")));
457 }
458 metadata.push((key.to_string(), value.to_string()));
459 }
460 }
461 continue;
462 }
463 let fields = split_csv(l).map_err(|e| bad(line, e))?;
464 if in_header {
465 with_origin = check_columns(line, &fields)?;
466 check_metadata(line, &metadata)?;
467 in_header = false;
468 } else {
469 points.push(parse_point(line, &fields, with_origin)?);
470 }
471 }
472 if in_header {
473 return Err(StoppingError::InvalidTable(
474 "stopping dataset has no column header row".into(),
475 ));
476 }
477 if points.is_empty() {
478 return Err(StoppingError::InvalidTable(
479 "stopping dataset has no points".into(),
480 ));
481 }
482 let get = |key: &str| {
483 metadata
484 .iter()
485 .find(|(k, _)| k == key)
486 .map(|(_, v)| v.clone())
487 };
488 Ok(Self {
489 name: get("name").unwrap_or_default(),
490 description: get("description").filter(|d| !d.is_empty()),
491 metadata,
492 points,
493 })
494 }
495
496 pub fn from_csv_file(path: impl AsRef<Path>) -> Result<Self, StoppingError> {
498 let text = std::fs::read_to_string(path.as_ref())
499 .map_err(|e| StoppingError::InvalidTable(e.to_string()))?;
500 Self::from_csv_str(&text)
501 }
502
503 pub fn name(&self) -> &str {
505 &self.name
506 }
507
508 pub fn description(&self) -> Option<&str> {
510 self.description.as_deref()
511 }
512
513 pub fn metadata(&self) -> &[(String, String)] {
515 &self.metadata
516 }
517
518 pub fn points(&self) -> &[StoppingPoint] {
520 &self.points
521 }
522}
523
524fn check_columns(line: usize, fields: &[String]) -> Result<bool, StoppingError> {
527 for (k, want) in COLUMNS.iter().enumerate() {
528 match fields.get(k) {
529 Some(got) if got == want => {}
530 Some(got) => {
531 return Err(bad(
532 line,
533 format!("column {} must be {want:?}, found {got:?}", k + 1),
534 ))
535 }
536 None => return Err(bad(line, format!("missing column {want:?}"))),
537 }
538 }
539 let extra = &fields[COLUMNS.len()..];
540 if extra.is_empty() {
541 return Ok(false);
542 }
543 if extra.len() == 1 {
544 return Err(bad(
545 line,
546 format!(
547 "unexpected extra column {:?}; the optional columns {OPTIONAL_COLUMNS:?} \
548 come as a pair",
549 extra[0]
550 ),
551 ));
552 }
553 for (k, want) in OPTIONAL_COLUMNS.iter().enumerate() {
554 if extra[k] != *want {
555 return Err(bad(
556 line,
557 format!(
558 "column {} must be {want:?}, found {:?}",
559 COLUMNS.len() + k + 1,
560 extra[k]
561 ),
562 ));
563 }
564 }
565 if extra.len() > OPTIONAL_COLUMNS.len() {
566 return Err(bad(
567 line,
568 format!(
569 "unexpected extra column {:?}",
570 extra[OPTIONAL_COLUMNS.len()]
571 ),
572 ));
573 }
574 Ok(true)
575}
576
577fn check_metadata(line: usize, metadata: &[(String, String)]) -> Result<(), StoppingError> {
578 let get = |key: &str| metadata.iter().find(|(k, _)| k == key).map(|(_, v)| v);
579 match get("format") {
580 Some(v) if v == FORMAT_VERSION => {}
581 Some(v) => {
582 return Err(bad(
583 line,
584 format!("unsupported format {v:?}; expected {FORMAT_VERSION:?}"),
585 ))
586 }
587 None => return Err(bad(line, "header has no `format` key before the columns")),
588 }
589 match get("name") {
590 Some(v) if !v.is_empty() => Ok(()),
591 _ => Err(bad(
592 line,
593 "header has no non-empty `name` key before the columns",
594 )),
595 }
596}
597
598#[derive(Debug, Clone, Copy, PartialEq)]
602pub struct PointResidual {
603 pub index: usize,
605 pub line: usize,
607 pub model_ev_1e15_cm2: f64,
609 pub measured_ev_1e15_cm2: f64,
611 pub relative: f64,
613 pub absolute_ev_1e15_cm2: f64,
615 pub pull: Option<f64>,
618}
619
620#[derive(Debug, Clone, PartialEq)]
623pub struct SkippedPoint {
624 pub index: usize,
626 pub line: usize,
628 pub reason: StoppingError,
631}
632
633#[derive(Debug, Clone, Copy, PartialEq)]
635pub struct ResidualSummary {
636 pub n: usize,
638 pub n_skipped: usize,
640 pub mean_relative: f64,
642 pub rms_relative: f64,
644 pub max_abs_relative: f64,
646}
647
648impl ResidualSummary {
649 fn of(relative: &[f64], n_skipped: usize) -> Self {
652 let n = relative.len();
653 if n == 0 {
654 return Self {
655 n,
656 n_skipped,
657 mean_relative: f64::NAN,
658 rms_relative: f64::NAN,
659 max_abs_relative: f64::NAN,
660 };
661 }
662 let (mut sum, mut sum_sq, mut max_abs) = (0.0, 0.0, 0.0_f64);
663 for &r in relative {
664 sum += r;
665 sum_sq += r * r;
666 max_abs = max_abs.max(r.abs());
667 }
668 let nf = n as f64;
669 Self {
670 n,
671 n_skipped,
672 mean_relative: sum / nf,
673 rms_relative: (sum_sq / nf).sqrt(),
674 max_abs_relative: max_abs,
675 }
676 }
677}
678
679#[derive(Debug, Clone, PartialEq)]
682pub struct SystemSummary {
683 pub ion_z: u8,
685 pub ion_mass_amu: f64,
687 pub target: String,
689 pub summary: ResidualSummary,
691}
692
693#[derive(Debug, Clone, PartialEq)]
695pub struct Comparison {
696 pub model: &'static str,
698 pub residuals: Vec<PointResidual>,
700 pub skipped: Vec<SkippedPoint>,
702 pub overall: ResidualSummary,
704 pub systems: Vec<SystemSummary>,
706}
707
708type SystemKey = (u8, u64, String);
710
711pub fn compare(
721 model: &dyn ElectronicStopping,
722 correction: &dyn CompoundCorrection,
723 dataset: &StoppingDataset,
724) -> Comparison {
725 let mut residuals = Vec::with_capacity(dataset.points.len());
726 let mut skipped = Vec::new();
727 let mut systems: Vec<(SystemKey, Vec<f64>, usize)> = Vec::new();
729 for (index, p) in dataset.points.iter().enumerate() {
730 let key = (
731 p.ion.z(),
732 p.ion.mass_amu().to_bits(),
733 p.target.label().to_string(),
734 );
735 let slot = match systems.iter().position(|s| s.0 == key) {
736 Some(k) => k,
737 None => {
738 systems.push((key, Vec::new(), 0));
739 systems.len() - 1
740 }
741 };
742 let s = match &p.target {
743 Target::Element(z) => model.stopping(&p.ion, *z, p.energy_ev),
744 Target::Compound { material, .. } => {
745 bragg_cross_section_per_atom(model, correction, &p.ion, material, p.energy_ev)
746 }
747 };
748 match s {
749 Ok(s) => {
750 let m = to_ev_1e15_cm2(s);
751 let meas = p.s_e_ev_1e15_cm2;
752 let relative = m / meas - 1.0;
753 let absolute = m - meas;
754 residuals.push(PointResidual {
755 index,
756 line: p.line,
757 model_ev_1e15_cm2: m,
758 measured_ev_1e15_cm2: meas,
759 relative,
760 absolute_ev_1e15_cm2: absolute,
761 pull: (p.sigma_ev_1e15_cm2 > 0.0).then(|| absolute / p.sigma_ev_1e15_cm2),
762 });
763 systems[slot].1.push(relative);
764 }
765 Err(reason) => {
766 skipped.push(SkippedPoint {
767 index,
768 line: p.line,
769 reason,
770 });
771 systems[slot].2 += 1;
772 }
773 }
774 }
775 let all: Vec<f64> = residuals.iter().map(|r| r.relative).collect();
776 Comparison {
777 model: model.name(),
778 overall: ResidualSummary::of(&all, skipped.len()),
779 systems: systems
780 .into_iter()
781 .map(
782 |((ion_z, mass_bits, target), rel, n_skipped)| SystemSummary {
783 ion_z,
784 ion_mass_amu: f64::from_bits(mass_bits),
785 target,
786 summary: ResidualSummary::of(&rel, n_skipped),
787 },
788 )
789 .collect(),
790 residuals,
791 skipped,
792 }
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798
799 #[test]
800 fn csv_quoting() {
801 let f = split_csv(r#"1, "a, b" ,"say ""hi""",,x"#).unwrap();
802 assert_eq!(f, ["1", "a, b", "say \"hi\"", "", "x"]);
803 assert!(split_csv(r#"1,"open"#).is_err());
804 assert!(split_csv(r#"1,"a"b"#).is_err());
805 assert!(split_csv(r#"1,a"b"#).is_err());
806 }
807
808 #[test]
809 fn formulas() {
810 assert_eq!(Target::parse("Si").unwrap(), Target::Element(14));
811 assert_eq!(Target::parse("O2").unwrap(), Target::Element(8));
812 let Target::Compound { material, formula } = Target::parse("SiO2").unwrap() else {
813 panic!("compound expected")
814 };
815 assert_eq!(formula, "SiO2");
816 assert_eq!(material.name(), Some("SiO2"));
817 let x = material.atom_fractions();
818 assert_eq!(x[0].0, 14);
819 assert!((x[0].1 - 1.0 / 3.0).abs() < 1e-15);
820 assert!(Target::parse("Si0.5Ge0.5").is_ok());
821 for bad in ["", "si", "Xx", "SiSi", "Si0", "Si(O)2", "Si-1", "Si1.2.3"] {
822 assert!(Target::parse(bad).is_err(), "{bad:?}");
823 }
824 }
825
826 #[test]
827 fn metadata_keys() {
828 assert_eq!(metadata_entry(" name: x: y"), Some(("name", "x: y")));
829 assert_eq!(metadata_entry(" Note: free"), None);
830 assert_eq!(metadata_entry(" a comment"), None);
831 }
832}