1use 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
68pub const CORRECTED_MODEL_NAME: &str = "corrected";
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct FitSettings {
74 pub degree: usize,
76 pub held_out: usize,
78 pub replicates: usize,
80 pub seed: u64,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct ResidualStats {
90 pub n: usize,
92 pub mean_relative: f64,
94 pub rms_relative: f64,
96 pub mean_absolute_ev_1e15_cm2: f64,
98 pub rms_absolute_ev_1e15_cm2: f64,
100}
101
102impl ResidualStats {
103 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#[derive(Debug, Clone, Copy, PartialEq)]
137pub struct HeldOutPoint {
138 pub index: usize,
140 pub energy_ev: f64,
142 pub measured_ev_1e15_cm2: f64,
144 pub base_ev_1e15_cm2: f64,
146 pub corrected_ev_1e15_cm2: f64,
148}
149
150#[derive(Debug, Clone, PartialEq)]
152pub struct HeldOutReport {
153 pub points: Vec<HeldOutPoint>,
155 pub base: ResidualStats,
157 pub corrected: ResidualStats,
159}
160
161impl HeldOutReport {
162 pub fn reduces_rms_relative(&self) -> bool {
165 self.corrected.rms_relative < self.base.rms_relative
166 }
167}
168
169#[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 pub fn ion(&self) -> &Ion {
191 &self.ion
192 }
193
194 pub fn target_z(&self) -> u8 {
196 self.target_z
197 }
198
199 pub fn span_ev(&self) -> (f64, f64) {
202 (self.e_lo_ev, self.e_hi_ev)
203 }
204
205 pub fn coefficients(&self) -> &[f64] {
207 &self.coefficients
208 }
209
210 pub fn settings(&self) -> FitSettings {
212 self.settings
213 }
214
215 pub fn train_indices(&self) -> &[usize] {
217 &self.train
218 }
219
220 pub fn held_out_indices(&self) -> &[usize] {
222 &self.held_out
223 }
224
225 pub fn weighted(&self) -> bool {
228 self.weighted
229 }
230
231 pub fn failed_replicates(&self) -> usize {
233 self.failed_replicates
234 }
235
236 pub fn n_replicates(&self) -> usize {
238 self.replicates.len()
239 }
240
241 pub fn held_out_report(&self) -> &HeldOutReport {
243 &self.held_out_report
244 }
245
246 pub fn train_base(&self) -> ResidualStats {
248 self.train_base
249 }
250
251 pub fn train_corrected(&self) -> ResidualStats {
253 self.train_corrected
254 }
255
256 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 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 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#[derive(Debug, Clone)]
293pub struct Correction<M> {
294 base: M,
295 fit: CorrectionFit,
296}
297
298impl<M: ElectronicStopping> Correction<M> {
299 pub fn new(base: M, fit: CorrectionFit) -> Self {
301 Self { base, fit }
302 }
303
304 pub fn base(&self) -> &M {
306 &self.base
307 }
308
309 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
340fn 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
362fn 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#[derive(Clone, Copy)]
373struct Sample {
374 energy_ev: f64,
375 log_ratio: f64,
376 weight: f64,
377}
378
379fn 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
431fn draw_index(rng: &mut impl Rng, n: usize) -> usize {
434 ((u128::from(rng.next_u64()) * n as u128) >> 64) as usize
435}
436
437pub 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 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}