Skip to main content

lindhard/ion/stopping/
correction.rs

1//! Per-pair multiplicative correction of an electronic-stopping model, fitted
2//! to measured points, with a bootstrap uncertainty and held-out residuals.
3//!
4//! A [`Correction`] wraps a base [`ElectronicStopping`] model and multiplies
5//! its stopping cross section by a smooth factor `g(E)` for **one** ion–target
6//! pair. The factor is
7//!
8//! ```text
9//! g(E) = exp( sum_{k=0}^{d} c_k T_k(x) ),   x = (2 ln E - ln E_lo - ln E_hi) / (ln E_hi - ln E_lo)
10//! ```
11//!
12//! where `T_k` are Chebyshev polynomials of the first kind (J. C. Mason and
13//! D. C. Handscomb, *Chebyshev Polynomials*, CRC Press (2003), ch. 2) and
14//! `[E_lo, E_hi]` is the energy span of the training points. The exponential
15//! keeps `g` positive. The coefficients minimise
16//! `sum_i w_i (ln(S_i / B_i) - sum_k c_k T_k(x_i))^2`, where `S_i` is the
17//! measured stopping, `B_i` the base model at the same point and
18//! `w_i = 1 / (sigma_i / S_i)^2` when **every** training point states an
19//! uncertainty `sigma_i > 0`, otherwise `w_i = 1`. The normal equations are
20//! solved by Gaussian elimination with partial pivoting (G. H. Golub and
21//! C. F. Van Loan, *Matrix Computations*, 4th ed., Johns Hopkins (2013),
22//! sec. 3.4 and 4.2). The form of the correction (a low-order polynomial of
23//! `ln E` for the log of the ratio) is our own choice; no published fit is
24//! reproduced.
25//!
26//! # Where the correction applies
27//!
28//! Only inside `[E_lo, E_hi]` of the training points, and only for the ion
29//! (Z and mass) and target element it was fitted to. Anywhere else
30//! [`Correction::stopping`] returns the base model unchanged, because a
31//! polynomial extrapolated beyond its data has no physical basis.
32//!
33//! # Uncertainty
34//!
35//! The nonparametric bootstrap of B. Efron, Ann. Statist. 7, 1 (1979): the
36//! training points are resampled with replacement `replicates` times, the
37//! correction is refitted to each resample, and the pointwise band at energy
38//! `E` is the central interval of `g_b(E)` over the replicates (linear
39//! interpolation between order statistics). A resample whose fit is singular
40//! (for example fewer distinct energies than coefficients) is discarded and
41//! counted in [`CorrectionFit::failed_replicates`]. The band covers the
42//! scatter of the points about the fitted form; it does not include any
43//! systematic error of the measurement or any bias of the polynomial form.
44//! If the measured points carry no stated uncertainty (sigma 0, as in tables
45//! read off a smoothed curve), the band says nothing about measurement error.
46//!
47//! # Reproducibility
48//!
49//! The held-out split uses the stream `(seed, 0)` of [`crate::rng::stream`]
50//! and replicate `b` uses `(seed, b + 1)`. Replicates run on the current rayon
51//! pool and are collected in index order; every sum is sequential, so the
52//! result is bit-identical for a given seed whatever the thread count.
53//!
54//! # Held-out points
55//!
56//! A deterministic, seeded subset of the interior points (never the lowest and
57//! highest energy, so they stay inside the fitted span) is left out of the fit.
58//! [`CorrectionFit::held_out_report`] gives the residuals of the base model and
59//! of the corrected model on exactly those points, side by side, whether or not
60//! the correction helps.
61
62use super::dataset::{StoppingDataset, Target};
63use super::{to_ev_1e15_cm2, ElectronicStopping, Ion, StoppingError, ValidityRange};
64use crate::rng::stream;
65use rand_core::Rng;
66use rayon::prelude::*;
67
68/// Name returned by [`Correction::name`].
69pub const CORRECTED_MODEL_NAME: &str = "corrected";
70
71/// Settings of [`fit_correction`].
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct FitSettings {
74    /// Degree `d` of the Chebyshev polynomial; `d + 1` coefficients.
75    pub degree: usize,
76    /// Number of interior points left out of the fit and reported as held out.
77    pub held_out: usize,
78    /// Number of bootstrap replicates (0 gives no band).
79    pub replicates: usize,
80    /// Seed of the split and of the bootstrap.
81    pub seed: u64,
82}
83
84/// Statistics of `model - measured` over a set of points.
85///
86/// Signs follow [`super::dataset::PointResidual`]: positive when the model is
87/// above the measurement. With no points the statistics are NaN.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct ResidualStats {
90    /// Number of points.
91    pub n: usize,
92    /// Mean of `model / measured - 1`.
93    pub mean_relative: f64,
94    /// Root mean square of `model / measured - 1`.
95    pub rms_relative: f64,
96    /// Mean of `model - measured`, eV·10⁻¹⁵ cm².
97    pub mean_absolute_ev_1e15_cm2: f64,
98    /// Root mean square of `model - measured`, eV·10⁻¹⁵ cm².
99    pub rms_absolute_ev_1e15_cm2: f64,
100}
101
102impl ResidualStats {
103    /// Statistics of `(model, measured)` pairs, summed sequentially in order.
104    pub fn of(pairs: &[(f64, f64)]) -> Self {
105        let n = pairs.len();
106        if n == 0 {
107            return Self {
108                n,
109                mean_relative: f64::NAN,
110                rms_relative: f64::NAN,
111                mean_absolute_ev_1e15_cm2: f64::NAN,
112                rms_absolute_ev_1e15_cm2: f64::NAN,
113            };
114        }
115        let (mut sr, mut sr2, mut sa, mut sa2) = (0.0, 0.0, 0.0, 0.0);
116        for &(m, s) in pairs {
117            let r = m / s - 1.0;
118            let a = m - s;
119            sr += r;
120            sr2 += r * r;
121            sa += a;
122            sa2 += a * a;
123        }
124        let nf = n as f64;
125        Self {
126            n,
127            mean_relative: sr / nf,
128            rms_relative: (sr2 / nf).sqrt(),
129            mean_absolute_ev_1e15_cm2: sa / nf,
130            rms_absolute_ev_1e15_cm2: (sa2 / nf).sqrt(),
131        }
132    }
133}
134
135/// One held-out point with the base and corrected model values.
136#[derive(Debug, Clone, Copy, PartialEq)]
137pub struct HeldOutPoint {
138    /// Index of the point in the dataset.
139    pub index: usize,
140    /// Projectile energy, eV.
141    pub energy_ev: f64,
142    /// Measured stopping, eV·10⁻¹⁵ cm².
143    pub measured_ev_1e15_cm2: f64,
144    /// Base model, eV·10⁻¹⁵ cm².
145    pub base_ev_1e15_cm2: f64,
146    /// Corrected model, eV·10⁻¹⁵ cm².
147    pub corrected_ev_1e15_cm2: f64,
148}
149
150/// Residuals of the base and the corrected model on the held-out points.
151#[derive(Debug, Clone, PartialEq)]
152pub struct HeldOutReport {
153    /// The held-out points, in dataset order.
154    pub points: Vec<HeldOutPoint>,
155    /// Residual statistics of the uncorrected base model on those points.
156    pub base: ResidualStats,
157    /// Residual statistics of the corrected model on the same points.
158    pub corrected: ResidualStats,
159}
160
161impl HeldOutReport {
162    /// True if the correction lowered the held-out relative rms. A correction
163    /// that does not is still reported; it is never hidden.
164    pub fn reduces_rms_relative(&self) -> bool {
165        self.corrected.rms_relative < self.base.rms_relative
166    }
167}
168
169/// A fitted correction: coefficients, bootstrap replicates and the split.
170#[derive(Debug, Clone, PartialEq)]
171pub struct CorrectionFit {
172    ion: Ion,
173    target_z: u8,
174    e_lo_ev: f64,
175    e_hi_ev: f64,
176    coefficients: Vec<f64>,
177    replicates: Vec<Vec<f64>>,
178    failed_replicates: usize,
179    weighted: bool,
180    settings: FitSettings,
181    train: Vec<usize>,
182    held_out: Vec<usize>,
183    held_out_report: HeldOutReport,
184    train_base: ResidualStats,
185    train_corrected: ResidualStats,
186}
187
188impl CorrectionFit {
189    /// The projectile the correction was fitted for.
190    pub fn ion(&self) -> &Ion {
191        &self.ion
192    }
193
194    /// Atomic number of the target element.
195    pub fn target_z(&self) -> u8 {
196        self.target_z
197    }
198
199    /// Energy span `(lowest, highest)` of the training points, eV; the
200    /// correction applies inside it only (both ends included).
201    pub fn span_ev(&self) -> (f64, f64) {
202        (self.e_lo_ev, self.e_hi_ev)
203    }
204
205    /// Chebyshev coefficients `c_0..=c_d` of `ln g`.
206    pub fn coefficients(&self) -> &[f64] {
207        &self.coefficients
208    }
209
210    /// The settings the fit was made with (including the seed).
211    pub fn settings(&self) -> FitSettings {
212        self.settings
213    }
214
215    /// Dataset indices of the training points, ascending.
216    pub fn train_indices(&self) -> &[usize] {
217        &self.train
218    }
219
220    /// Dataset indices of the held-out points, ascending.
221    pub fn held_out_indices(&self) -> &[usize] {
222        &self.held_out
223    }
224
225    /// True if the fit used weights `1 / (sigma / S)^2`, which requires every
226    /// training point to state an uncertainty; otherwise it is unweighted.
227    pub fn weighted(&self) -> bool {
228        self.weighted
229    }
230
231    /// Bootstrap resamples discarded because their fit was singular.
232    pub fn failed_replicates(&self) -> usize {
233        self.failed_replicates
234    }
235
236    /// Number of bootstrap replicates kept.
237    pub fn n_replicates(&self) -> usize {
238        self.replicates.len()
239    }
240
241    /// Residuals of the base and corrected model on the held-out points.
242    pub fn held_out_report(&self) -> &HeldOutReport {
243        &self.held_out_report
244    }
245
246    /// Residual statistics of the base model on the training points.
247    pub fn train_base(&self) -> ResidualStats {
248        self.train_base
249    }
250
251    /// Residual statistics of the corrected model on the training points.
252    pub fn train_corrected(&self) -> ResidualStats {
253        self.train_corrected
254    }
255
256    /// True if `energy_ev` is inside the span the correction applies to.
257    pub fn in_span(&self, energy_ev: f64) -> bool {
258        energy_ev >= self.e_lo_ev && energy_ev <= self.e_hi_ev
259    }
260
261    /// The correction factor `g(E)` of the fit, or `None` outside the span.
262    pub fn factor(&self, energy_ev: f64) -> Option<f64> {
263        self.in_span(energy_ev)
264            .then(|| eval_factor(&self.coefficients, self.e_lo_ev, self.e_hi_ev, energy_ev))
265    }
266
267    /// Pointwise bootstrap band of `g(E)`: the central interval holding
268    /// `level` (for example 0.68) of the replicates, as `(low, high)`.
269    ///
270    /// `None` outside the span, with no replicates, or for `level` outside
271    /// `(0, 1)`.
272    pub fn band(&self, energy_ev: f64, level: f64) -> Option<(f64, f64)> {
273        if !self.in_span(energy_ev) || self.replicates.is_empty() || !(level > 0.0 && level < 1.0) {
274            return None;
275        }
276        let mut g: Vec<f64> = self
277            .replicates
278            .iter()
279            .map(|c| eval_factor(c, self.e_lo_ev, self.e_hi_ev, energy_ev))
280            .collect();
281        g.sort_by(f64::total_cmp);
282        let tail = 0.5 * (1.0 - level);
283        Some((quantile(&g, tail), quantile(&g, 1.0 - tail)))
284    }
285}
286
287/// An [`ElectronicStopping`] model multiplied by a fitted [`CorrectionFit`].
288///
289/// For the fitted ion and target element and an energy inside the fitted span
290/// the base value is multiplied by `g(E)`; in every other case the base model
291/// is returned unchanged.
292#[derive(Debug, Clone)]
293pub struct Correction<M> {
294    base: M,
295    fit: CorrectionFit,
296}
297
298impl<M: ElectronicStopping> Correction<M> {
299    /// Wrap `base` with `fit`.
300    pub fn new(base: M, fit: CorrectionFit) -> Self {
301        Self { base, fit }
302    }
303
304    /// The wrapped base model.
305    pub fn base(&self) -> &M {
306        &self.base
307    }
308
309    /// The fit.
310    pub fn fit(&self) -> &CorrectionFit {
311        &self.fit
312    }
313}
314
315impl<M: ElectronicStopping> ElectronicStopping for Correction<M> {
316    fn name(&self) -> &'static str {
317        CORRECTED_MODEL_NAME
318    }
319
320    fn stopping(&self, ion: &Ion, target_z: u8, energy_ev: f64) -> Result<f64, StoppingError> {
321        let s = self.base.stopping(ion, target_z, energy_ev)?;
322        let same_pair = ion.z() == self.fit.ion.z()
323            && ion.mass_amu().to_bits() == self.fit.ion.mass_amu().to_bits()
324            && target_z == self.fit.target_z;
325        match self.fit.factor(energy_ev) {
326            Some(g) if same_pair => Ok(s * g),
327            _ => Ok(s),
328        }
329    }
330
331    fn validity(&self, ion: &Ion) -> ValidityRange {
332        self.base.validity(ion)
333    }
334}
335
336fn invalid(msg: impl Into<String>) -> StoppingError {
337    StoppingError::InvalidFit(msg.into())
338}
339
340/// Chebyshev abscissa of `energy_ev` for the span `[lo, hi]`.
341fn abscissa(lo: f64, hi: f64, energy_ev: f64) -> f64 {
342    let (a, b) = (lo.ln(), hi.ln());
343    ((2.0 * energy_ev.ln() - a - b) / (b - a)).clamp(-1.0, 1.0)
344}
345
346fn eval_factor(coefficients: &[f64], lo: f64, hi: f64, energy_ev: f64) -> f64 {
347    let x = abscissa(lo, hi, energy_ev);
348    let (mut t_prev, mut t) = (1.0, x);
349    let mut sum = coefficients[0];
350    if coefficients.len() > 1 {
351        sum += coefficients[1] * x;
352    }
353    for &c in coefficients.iter().skip(2) {
354        let t_next = 2.0 * x * t - t_prev;
355        sum += c * t_next;
356        t_prev = t;
357        t = t_next;
358    }
359    sum.exp()
360}
361
362/// Quantile `q` in `[0, 1]` of a sorted slice, linear between order statistics.
363fn quantile(sorted: &[f64], q: f64) -> f64 {
364    let pos = q * (sorted.len() - 1) as f64;
365    let i = pos.floor() as usize;
366    let j = (i + 1).min(sorted.len() - 1);
367    let f = pos - i as f64;
368    sorted[i] + f * (sorted[j] - sorted[i])
369}
370
371/// One training point in the fit: abscissa data and weight.
372#[derive(Clone, Copy)]
373struct Sample {
374    energy_ev: f64,
375    log_ratio: f64,
376    weight: f64,
377}
378
379/// Weighted least squares of `log_ratio` on `T_0..=T_d(x)`; `None` if singular.
380fn solve(samples: &[Sample], degree: usize, lo: f64, hi: f64) -> Option<Vec<f64>> {
381    let m = degree + 1;
382    let mut a = vec![vec![0.0; m + 1]; m];
383    let mut basis = vec![0.0; m];
384    for s in samples {
385        let x = abscissa(lo, hi, s.energy_ev);
386        basis[0] = 1.0;
387        if m > 1 {
388            basis[1] = x;
389        }
390        for k in 2..m {
391            basis[k] = 2.0 * x * basis[k - 1] - basis[k - 2];
392        }
393        for r in 0..m {
394            for c in 0..m {
395                a[r][c] += s.weight * basis[r] * basis[c];
396            }
397            a[r][m] += s.weight * basis[r] * s.log_ratio;
398        }
399    }
400    let scale = (0..m).map(|i| a[i][i]).fold(0.0, f64::max);
401    for col in 0..m {
402        let mut piv = col;
403        for r in col + 1..m {
404            if a[r][col].abs() > a[piv][col].abs() {
405                piv = r;
406            }
407        }
408        if a[piv][col].abs().is_nan() || a[piv][col].abs() <= 1e-12 * scale {
409            return None;
410        }
411        a.swap(col, piv);
412        let pivot_row = a[col].clone();
413        for row in a.iter_mut().skip(col + 1) {
414            let f = row[col] / pivot_row[col];
415            for (x, p) in row[col..].iter_mut().zip(&pivot_row[col..]) {
416                *x -= f * p;
417            }
418        }
419    }
420    let mut c = vec![0.0; m];
421    for r in (0..m).rev() {
422        let mut v = a[r][m];
423        for k in r + 1..m {
424            v -= a[r][k] * c[k];
425        }
426        c[r] = v / a[r][r];
427    }
428    c.iter().all(|v| v.is_finite()).then_some(c)
429}
430
431/// Uniform index in `0..n` from one 64-bit draw (multiply-shift; the bias is
432/// below `n / 2^64`).
433fn draw_index(rng: &mut impl Rng, n: usize) -> usize {
434    ((u128::from(rng.next_u64()) * n as u128) >> 64) as usize
435}
436
437/// Fit a [`Correction`] of `base` to a single-system element-target dataset.
438///
439/// The dataset must hold points of one ion (same Z and mass) on one element
440/// target, at no fewer than `degree + 2 + held_out` points with at least two
441/// distinct energies, and `base` must return a positive value at every point.
442/// Anything else is an [`StoppingError::InvalidFit`] (or the base model's own
443/// error), so no point is dropped silently.
444pub fn fit_correction(
445    base: &dyn ElectronicStopping,
446    dataset: &StoppingDataset,
447    settings: FitSettings,
448) -> Result<CorrectionFit, StoppingError> {
449    let pts = dataset.points();
450    let n = pts.len();
451    let first = pts
452        .first()
453        .ok_or_else(|| invalid("dataset has no points"))?;
454    let Target::Element(target_z) = *first.target() else {
455        return Err(invalid("compound targets are not supported"));
456    };
457    let ion = *first.ion();
458    for p in pts {
459        if p.ion().z() != ion.z()
460            || p.ion().mass_amu().to_bits() != ion.mass_amu().to_bits()
461            || p.target().label() != first.target().label()
462        {
463            return Err(invalid(format!(
464                "line {}: the dataset must hold a single ion-target system",
465                p.line()
466            )));
467        }
468    }
469    let mut base_values = Vec::with_capacity(n);
470    for p in pts {
471        let b = to_ev_1e15_cm2(base.stopping(&ion, target_z, p.energy_ev())?);
472        if !(b.is_finite() && b > 0.0) {
473            return Err(invalid(format!(
474                "line {}: base model value {b} is not positive",
475                p.line()
476            )));
477        }
478        base_values.push(b);
479    }
480
481    // Indices by energy; the lowest and highest stay in training.
482    let mut order: Vec<usize> = (0..n).collect();
483    order.sort_by(|&i, &j| {
484        pts[i]
485            .energy_ev()
486            .total_cmp(&pts[j].energy_ev())
487            .then(i.cmp(&j))
488    });
489    if n < settings.degree + 2 + settings.held_out {
490        return Err(invalid(format!(
491            "{n} points are too few for degree {} with {} held out",
492            settings.degree, settings.held_out
493        )));
494    }
495    let interior = &order[1..n - 1];
496    if settings.held_out > interior.len() {
497        return Err(invalid("more held-out points than interior points"));
498    }
499    let mut shuffled = interior.to_vec();
500    let mut rng = stream(settings.seed, 0);
501    for i in (1..shuffled.len()).rev() {
502        shuffled.swap(i, draw_index(&mut rng, i + 1));
503    }
504    let mut held_out: Vec<usize> = shuffled[..settings.held_out].to_vec();
505    held_out.sort_unstable();
506    let train: Vec<usize> = (0..n)
507        .filter(|i| held_out.binary_search(i).is_err())
508        .collect();
509
510    let e_lo = pts[order[0]].energy_ev();
511    let e_hi = pts[order[n - 1]].energy_ev();
512    if e_hi <= e_lo {
513        return Err(invalid("the points span a single energy"));
514    }
515    let weighted = train.iter().all(|&i| pts[i].sigma_ev_1e15_cm2() > 0.0);
516    let samples: Vec<Sample> = train
517        .iter()
518        .map(|&i| {
519            let meas = pts[i].s_e_ev_1e15_cm2();
520            let sigma_log = pts[i].sigma_ev_1e15_cm2() / meas;
521            Sample {
522                energy_ev: pts[i].energy_ev(),
523                log_ratio: (meas / base_values[i]).ln(),
524                weight: if weighted {
525                    1.0 / (sigma_log * sigma_log)
526                } else {
527                    1.0
528                },
529            }
530        })
531        .collect();
532    let coefficients = solve(&samples, settings.degree, e_lo, e_hi)
533        .ok_or_else(|| invalid("the least-squares system is singular"))?;
534
535    let outcomes: Vec<Option<Vec<f64>>> = (0..settings.replicates)
536        .into_par_iter()
537        .map(|b| {
538            let mut rng = stream(settings.seed, b as u64 + 1);
539            let resample: Vec<Sample> = (0..samples.len())
540                .map(|_| samples[draw_index(&mut rng, samples.len())])
541                .collect();
542            solve(&resample, settings.degree, e_lo, e_hi)
543        })
544        .collect();
545    let failed_replicates = outcomes.iter().filter(|o| o.is_none()).count();
546    let replicates: Vec<Vec<f64>> = outcomes.into_iter().flatten().collect();
547
548    let corrected_at =
549        |i: usize| base_values[i] * eval_factor(&coefficients, e_lo, e_hi, pts[i].energy_ev());
550    let pairs = |idx: &[usize], corrected: bool| -> Vec<(f64, f64)> {
551        idx.iter()
552            .map(|&i| {
553                let m = if corrected {
554                    corrected_at(i)
555                } else {
556                    base_values[i]
557                };
558                (m, pts[i].s_e_ev_1e15_cm2())
559            })
560            .collect()
561    };
562    let held_out_report = HeldOutReport {
563        points: held_out
564            .iter()
565            .map(|&i| HeldOutPoint {
566                index: i,
567                energy_ev: pts[i].energy_ev(),
568                measured_ev_1e15_cm2: pts[i].s_e_ev_1e15_cm2(),
569                base_ev_1e15_cm2: base_values[i],
570                corrected_ev_1e15_cm2: corrected_at(i),
571            })
572            .collect(),
573        base: ResidualStats::of(&pairs(&held_out, false)),
574        corrected: ResidualStats::of(&pairs(&held_out, true)),
575    };
576
577    let train_base = ResidualStats::of(&pairs(&train, false));
578    let train_corrected = ResidualStats::of(&pairs(&train, true));
579    Ok(CorrectionFit {
580        ion,
581        target_z,
582        e_lo_ev: e_lo,
583        e_hi_ev: e_hi,
584        coefficients,
585        replicates,
586        failed_replicates,
587        weighted,
588        settings,
589        train_base,
590        train_corrected,
591        train,
592        held_out,
593        held_out_report,
594    })
595}