Skip to main content

lindhard/ion/stopping/
dataset.rs

1//! Stopping datasets: measured `S_e` points with a citation on every point, and
2//! the comparison of an [`ElectronicStopping`] model against them.
3//!
4//! The format is specified in `docs/stopping-data-format.md`. In short: a
5//! `#`-prefixed `key: value` header, one CSV header row with the units in the
6//! column names, then one point per line:
7//!
8//! ```text
9//! # format: lindhard-stopping-data/1
10//! # name: example
11//! # description: free text
12//! ion_z,ion_mass_amu,target,energy_ev,s_e_ev_1e15_cm2,sigma_ev_1e15_cm2,method,citation
13//! 5,,Si,1.0e4,12.5,0.3,transmission,"Author, Journal vol, page (year), Table N"
14//! 5,11.009,SiO2,2.0e4,10.1,0.2,backscattering,"Author, Journal vol, page (year)"
15//! ```
16//!
17//! * Stopping and its uncertainty are cross sections per target atom in
18//!   eV·10⁻¹⁵ cm² (the unit of [`to_ev_1e15_cm2`]). For a compound target the
19//!   value is per **average atom** (per molecule divided by the number of atoms
20//!   in the formula), the quantity [`bragg_cross_section_per_atom`] returns.
21//! * Every row needs a non-empty `method` and `citation`; a row without one is
22//!   rejected with its line number (see `docs/data-provenance.md`: data without
23//!   an origin is not admitted).
24//! * The parser is a small hand-written CSV reader: comma-separated fields,
25//!   double quotes around a field that contains a comma, `""` for a literal
26//!   quote inside one. Quoted fields do not span lines.
27//!
28//! No measurement is shipped with this module. Which published data may enter
29//! the tree is decided in issue #34; the one real dataset so far is in
30//! `validation/data/stopping/`. Tests here use synthetic points generated from
31//! this repository's own models.
32//!
33//! Every result is a pure function of the model and the dataset, computed in
34//! dataset order with sequential summation, so it is bitwise reproducible
35//! whatever the thread count of the caller.
36
37use 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
43/// Value of the `format` header key this loader reads.
44pub const FORMAT_VERSION: &str = "lindhard-stopping-data/1";
45
46/// The column header row, in order. The units are part of the names
47/// (`_ev`, `_amu`, `_ev_1e15_cm2`) so a file in another unit cannot be read
48/// silently.
49pub 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
60/// The two optional trailing columns. They are accepted together or not at
61/// all.
62pub const OPTIONAL_COLUMNS: [&str; 2] = ["citation_key", "origin"];
63
64/// Where a point came from, when the file says so (the optional `origin`
65/// column).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Origin {
68    /// Read off the plots of a published compilation (see
69    /// `docs/stopping-data-format.md` and `docs/data-provenance.md`), not
70    /// copied from a table.
71    DigitizedCompilation,
72}
73
74impl Origin {
75    /// The value written in the `origin` column.
76    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
87/// Mass density given to the [`Material`] built for a compound target,
88/// kg/m³. It is a placeholder: under Bragg's rule (W. H. Bragg and R. Kleeman,
89/// Phil. Mag. 10, 318 (1905)) the stopping cross section per atom does not
90/// depend on the density, and a dataset does not record one. A
91/// [`CompoundCorrection`] used with [`compare`] must therefore not depend on
92/// [`Material::mass_density`]; it can identify the compound by
93/// [`Material::name`], which is set to the formula as written in the file.
94pub const PLACEHOLDER_DENSITY_KG_M3: f64 = 1000.0;
95
96/// The target of a point: one element, or a compound given by its formula.
97#[derive(Debug, Clone, PartialEq)]
98pub enum Target {
99    /// An element, by atomic number. A formula with a single element (`Si`,
100    /// `O2`) is an element target.
101    Element(u8),
102    /// A compound: the formula as written and a material built from it
103    /// (atom fractions from the formula counts, named with the formula,
104    /// density [`PLACEHOLDER_DENSITY_KG_M3`]).
105    Compound {
106        /// The formula as written in the file, e.g. `SiO2`.
107        formula: String,
108        /// The material handed to [`bragg_cross_section_per_atom`].
109        material: Material,
110    },
111}
112
113impl Target {
114    /// Parse an element symbol or a formula such as `SiO2`, `Al2O3` or
115    /// `Si0.5Ge0.5`: element symbols (case-sensitive), each followed by an
116    /// optional positive count. Parentheses and repeated elements are not
117    /// accepted.
118    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    /// The target as written for a compound, or the element symbol.
133    pub fn label(&self) -> &str {
134        match self {
135            // `parse` only builds elements that exist.
136            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/// One measured stopping point.
189#[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    /// 1-based line number in the source text.
205    pub fn line(&self) -> usize {
206        self.line
207    }
208
209    /// The projectile (with the mass given in the row, or the standard atomic
210    /// weight when the column is empty).
211    pub fn ion(&self) -> &Ion {
212        &self.ion
213    }
214
215    /// The target.
216    pub fn target(&self) -> &Target {
217        &self.target
218    }
219
220    /// Ion kinetic energy, eV.
221    pub fn energy_ev(&self) -> f64 {
222        self.energy_ev
223    }
224
225    /// Measured stopping cross section per (average) target atom,
226    /// eV·10⁻¹⁵ cm².
227    pub fn s_e_ev_1e15_cm2(&self) -> f64 {
228        self.s_e_ev_1e15_cm2
229    }
230
231    /// Stated one-standard-deviation uncertainty of
232    /// [`StoppingPoint::s_e_ev_1e15_cm2`], same unit; 0 if none was stated.
233    pub fn sigma_ev_1e15_cm2(&self) -> f64 {
234        self.sigma_ev_1e15_cm2
235    }
236
237    /// Measurement method (free text).
238    pub fn method(&self) -> &str {
239        &self.method
240    }
241
242    /// Citation of the point's source (free text: a paper, or a database name
243    /// and record identifier).
244    pub fn citation(&self) -> &str {
245        &self.citation
246    }
247
248    /// Short key of the source in the point's bibliography, if the file has
249    /// the optional `citation_key` column.
250    pub fn citation_key(&self) -> Option<&str> {
251        self.citation_key.as_deref()
252    }
253
254    /// The point's origin, if the file has the optional `origin` column.
255    pub fn origin(&self) -> Option<Origin> {
256        self.origin
257    }
258}
259
260/// A validated stopping dataset.
261#[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
276/// A header line `# key: value` with a lowercase identifier as the key.
277fn 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
287/// Split one CSV line into trimmed fields (see the module docs for the
288/// quoting rules).
289fn 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    /// Parse and validate a dataset. A byte-order mark, CRLF line endings,
435    /// blank lines and `#` comment lines in the data block are accepted.
436    ///
437    /// Errors name the offending line ([`StoppingError::InvalidDataset`]);
438    /// a file with no column header or no points is
439    /// [`StoppingError::InvalidTable`].
440    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    /// Load from a file.
497    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    /// The `name` header value.
504    pub fn name(&self) -> &str {
505        &self.name
506    }
507
508    /// The `description` header value, if given and non-empty.
509    pub fn description(&self) -> Option<&str> {
510        self.description.as_deref()
511    }
512
513    /// Every header `key: value` pair, in file order.
514    pub fn metadata(&self) -> &[(String, String)] {
515        &self.metadata
516    }
517
518    /// The points, in file order.
519    pub fn points(&self) -> &[StoppingPoint] {
520        &self.points
521    }
522}
523
524/// Check the column header row; `true` when it carries the optional
525/// `citation_key`, `origin` pair.
526fn 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/// Model-versus-measurement residual at one point.
599///
600/// Sign convention: positive means the model is **above** the measurement.
601#[derive(Debug, Clone, Copy, PartialEq)]
602pub struct PointResidual {
603    /// Index of the point in [`StoppingDataset::points`].
604    pub index: usize,
605    /// Source line of the point.
606    pub line: usize,
607    /// Model stopping per (average) atom, eV·10⁻¹⁵ cm².
608    pub model_ev_1e15_cm2: f64,
609    /// Measured stopping, eV·10⁻¹⁵ cm².
610    pub measured_ev_1e15_cm2: f64,
611    /// `model / measured − 1`.
612    pub relative: f64,
613    /// `model − measured`, eV·10⁻¹⁵ cm².
614    pub absolute_ev_1e15_cm2: f64,
615    /// `(model − measured) / sigma`, or `None` when no uncertainty was stated
616    /// (sigma 0).
617    pub pull: Option<f64>,
618}
619
620/// A point the model could not evaluate, with the model's error. Such points
621/// are not in any statistic.
622#[derive(Debug, Clone, PartialEq)]
623pub struct SkippedPoint {
624    /// Index of the point in [`StoppingDataset::points`].
625    pub index: usize,
626    /// Source line of the point.
627    pub line: usize,
628    /// Why the model returned no value (for example
629    /// [`StoppingError::NotApplicable`]).
630    pub reason: StoppingError,
631}
632
633/// Summary statistics of the relative residuals of a set of points.
634#[derive(Debug, Clone, Copy, PartialEq)]
635pub struct ResidualSummary {
636    /// Number of points evaluated.
637    pub n: usize,
638    /// Number of points skipped (model error).
639    pub n_skipped: usize,
640    /// Mean of [`PointResidual::relative`]; NaN when `n == 0`.
641    pub mean_relative: f64,
642    /// Root mean square of [`PointResidual::relative`]; NaN when `n == 0`.
643    pub rms_relative: f64,
644    /// Largest `|relative|`; NaN when `n == 0`.
645    pub max_abs_relative: f64,
646}
647
648impl ResidualSummary {
649    /// Statistics over `relative` in the order given (sequential sums, so the
650    /// result does not depend on how the caller was parallelised).
651    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/// Statistics for one ion–target system: same ion Z, same ion mass, same
680/// target as written.
681#[derive(Debug, Clone, PartialEq)]
682pub struct SystemSummary {
683    /// Ion atomic number.
684    pub ion_z: u8,
685    /// Ion mass, u.
686    pub ion_mass_amu: f64,
687    /// Target label ([`Target::label`]).
688    pub target: String,
689    /// Statistics over this system's points.
690    pub summary: ResidualSummary,
691}
692
693/// Result of [`compare`].
694#[derive(Debug, Clone, PartialEq)]
695pub struct Comparison {
696    /// The model's [`ElectronicStopping::name`].
697    pub model: &'static str,
698    /// One entry per evaluated point, in dataset order.
699    pub residuals: Vec<PointResidual>,
700    /// Points the model could not evaluate, in dataset order.
701    pub skipped: Vec<SkippedPoint>,
702    /// Statistics over all evaluated points.
703    pub overall: ResidualSummary,
704    /// Statistics per ion–target system, in order of first appearance.
705    pub systems: Vec<SystemSummary>,
706}
707
708/// Ion Z, ion mass bits, target label.
709type SystemKey = (u8, u64, String);
710
711/// Compare `model` against every point of `dataset`.
712///
713/// An element target is evaluated with [`ElectronicStopping::stopping`]; a
714/// compound with [`bragg_cross_section_per_atom`] and `correction` (which is
715/// not applied to element targets). Residuals follow the sign convention of
716/// [`PointResidual`]: positive when the model is above the measurement. A
717/// point where the model returns an error (such as
718/// [`StoppingError::NotApplicable`]) is listed in [`Comparison::skipped`]
719/// with that error, never dropped silently.
720pub 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    // Per system: key, relative residuals, skipped count.
728    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}