Skip to main content

lindhard/tally/
psf.rs

1//! Radial point-spread function (PSF) of a pencil beam: extraction from the
2//! electron deposition tally and double- or triple-Gaussian fits.
3//!
4//! # The radial profile
5//!
6//! A [`RadialAccumulator`] bins the energy deposited by the electron
7//! transport in a depth slab `depth_lo <= x < depth_hi` by the distance
8//! `r = (y² + z²)^(1/2)` from the beam axis (the axis of
9//! [`CylindricalGrid`](super::CylindricalGrid)). The radial bins are
10//! log-spaced ([`LogRadialBinning`]) so that one profile spans nanometres to
11//! the backscatter range; bin 0 is the inner disc `[0, r_min)`. Deposits at
12//! `r >= r_max` in the slab are kept in [`RadialProfile::beyond_ev`], so the
13//! profile sums to the energy deposited in the slab. The accumulator is fed
14//! by [`FullElectronTally`](super::FullElectronTally) when
15//! [`ElectronTallyConfig::psf`](super::ElectronTallyConfig::psf) is set, from
16//! the same deposits (and with the same bookkeeping convention) as its
17//! cylindrical `r`-`x` grid; it is an `r`-`x` grid restricted to one depth
18//! slab, with log radial bins.
19//!
20//! **Per-bin errors from per-history accumulation.** Within a history the
21//! deposits of every bin are summed into a scratch array; at the end of the
22//! history the bin's history total `x_h` is added to `S = Σ_h x_h` and its
23//! square to `Q = Σ_h x_h²`. The standard error of the bin total `S` over `N`
24//! histories is then `(N/(N-1) (Q - S²/N))^(1/2)`, `N` times the unbiased
25//! sample variance of `x_h` taken to the square root (the usual batch
26//! estimator with batches of one history). Energy deposited by the
27//! secondaries of a history is part of that history. With `N < 2` the error
28//! is reported as 0, and a fit then has no usable bins.
29//!
30//! # The fitted forms
31//!
32//! **Double Gaussian.** The normalised double-Gaussian proximity function of
33//! T. H. P. Chang, "Proximity effect in electron-beam lithography",
34//! J. Vac. Sci. Technol. 12, 1271 (1975), doi:10.1116/1.568515, as printed in
35//! Q. Mao, J. Zhu, X. Cheng and Z. Wang, Discover Nano 20, 84 (2025),
36//! doi:10.1186/s11671-025-04264-0 (open access, CC BY-NC-ND 4.0; PMC12089565), eq. (1),
37//! section "Traditional Gaussian model":
38//!
39//! ```text
40//! f(r) = 1/(π (1 + η)) [ (1/α²) exp(-r²/α²) + (η/β²) exp(-r²/β²) ]
41//! ```
42//!
43//! with `α` the forward-scattering range, `β` the backscattering range and
44//! `η` the ratio of backscattered to forward-scattered energy (Mao et al.'s
45//! words). Chang's paper itself is closed access and was not seen (see
46//! `docs/data-provenance.md`); Mao et al. attribute the form to it.
47//!
48//! **Triple Gaussian.** The sum-of-Gaussians PSF of T. Rosa Figueiro,
49//! *Process modeling for proximity effect correction in electron beam
50//! lithography*, PhD thesis, Université Grenoble Alpes (2015), HAL
51//! tel-01206934, eq. (72), p. 111 (PDF page 112):
52//!
53//! ```text
54//! PSF(x) = 1/(1 + Σ η_i) [ 1/(π α²) exp(-x²/α²) + Σ_i η_i/(π β_i²) exp(-x²/β_i²) ]
55//! ```
56//!
57//! with two terms in the sum (the "3 Gaussian PSF model" of its Fig. 61).
58//! Here `β_1 = β`, `η_1 = η`, `β_2 = γ`, `η_2 = ν`:
59//!
60//! ```text
61//! f(r) = 1/(π (1 + η + ν)) [ (1/α²) e^(-r²/α²) + (η/β²) e^(-r²/β²) + (ν/γ²) e^(-r²/γ²) ]
62//! ```
63//!
64//! With one term in the sum, eq. (72) is Mao et al.'s eq. (1). Both are
65//! normalised, `∫₀^∞ f(r) 2πr dr = 1`; the fitted function is `E f(r)`, eV/m²,
66//! with `E` (eV) a free parameter, the energy the PSF represents.
67//!
68//! # How the fit is done
69//!
70//! The data are the bin energies themselves (not their logarithm), and the
71//! model of bin `[a, b)` is the exact annulus integral of `E f(r)`. Each
72//! Gaussian term integrates in closed form (our own one-line integral,
73//! checked numerically in the tests):
74//!
75//! ```text
76//! ∫_a^b (1/(π w²)) exp(-r²/w²) 2πr dr = exp(-a²/w²) - exp(-b²/w²)
77//! ```
78//!
79//! so there is no bin-centre approximation, however wide the log bins are.
80//! The fit minimises the weighted sum of squares
81//! `χ² = Σ_i ((E_i - μ_i)/σ_i)²` with `σ_i` the per-history standard error of
82//! bin `i`; bins with `σ_i = 0` (no deposit, or fewer than two histories)
83//! carry no information and are left out. Large dynamic ranges are handled by
84//! the weights: a bin's error scales with its content.
85//!
86//! **Normalisation.** By default ([`PsfNormalization::SlabTotal`]) `E` is
87//! fixed to the energy deposited in the slab ([`RadialProfile::total_ev`],
88//! deposits beyond `r_max` included) and only the shape is fitted, so the
89//! fitted function integrates to the slab energy whatever the quality of the
90//! fit: the forms are normalised, and their scale is the deposited energy.
91//! With [`PsfNormalization::Free`] `E` is fitted too. That recovers `E` when
92//! the form describes the profile, but a Gaussian sum fitted to a profile it
93//! does not describe follows the best-measured bins, and its integral can
94//! then miss the slab energy by tens of percent. One case is a point beam,
95//! which deposits a finite fraction of its energy on the axis itself, before
96//! the first deflection; no Gaussian of finite width reproduces that.
97//!
98//! **The algorithm is our own**, built from textbook pieces not opened for
99//! this implementation: a start from a grid search over the widths (for
100//! fixed widths the model is linear in the term amplitudes, solved by
101//! weighted linear least squares; starts with a non-positive amplitude are
102//! skipped), then Levenberg-Marquardt iterations (K. Levenberg, Q. Appl.
103//! Math. 2, 164 (1944); D. W. Marquardt, J. SIAM 11, 431 (1963)) on the
104//! logarithms of the parameters (which keeps them positive) with the
105//! analytic Jacobian. The covariance is `(Jᵀ W J)⁻¹` in the natural
106//! parameters at the minimum, with `W = diag(1/σ_i²)`; it is **not** scaled
107//! by the reduced `χ²`, so a reduced `χ²` well above 1 signals that the form
108//! does not describe the profile and that the errors are too small. The
109//! labels follow the start: the grid orders the widths `α < β (< γ)`.
110//!
111//! # Determinism
112//!
113//! The accumulator merges field by field in the order
114//! [`crate::rng::run_particles`] merges chunk tallies, so the profile is
115//! bit-identical at any thread count for a fixed chunk size, and the fit is a
116//! fixed sequence of floating-point operations on the merged profile.
117//!
118//! # Export
119//!
120//! [`PsfReport`] holds a profile and its fits, derives `Serialize` and
121//! `Deserialize` (callers serialise it, as for the other reports), and writes
122//! the profile with the fitted model per bin ([`PsfReport::profile_csv`]) and
123//! the parameters with their errors ([`PsfReport::parameters_csv`]) as CSV. Lengths are in metres, energies in
124//! eV, areal densities in eV/m².
125
126use serde::{Deserialize, Serialize};
127use std::f64::consts::PI;
128
129/// Errors of the PSF extraction and fit.
130#[derive(Debug, Clone, PartialEq, thiserror::Error)]
131pub enum PsfError {
132    /// The radial binning is not `0 < r_min < r_max` (finite) with `bins > 0`.
133    #[error("invalid radial binning: r_min = {r_min} m, r_max = {r_max} m, bins = {bins} (need finite 0 < r_min < r_max and bins > 0)")]
134    Binning {
135        /// Inner edge of the first log bin.
136        r_min: f64,
137        /// Outer edge of the last bin.
138        r_max: f64,
139        /// Number of log bins.
140        bins: usize,
141    },
142    /// The depth slab is not finite with `hi > lo`.
143    #[error("invalid depth slab [{lo}, {hi}) m: need finite lo < hi")]
144    Slab {
145        /// Lower depth.
146        lo: f64,
147        /// Upper depth.
148        hi: f64,
149    },
150    /// A profile's arrays are inconsistent.
151    #[error("invalid radial profile: {0}")]
152    Profile(String),
153    /// Fewer usable bins (with a positive error) than parameters plus one.
154    #[error("{used} usable bins for {parameters} parameters: need at least parameters + 1")]
155    TooFewBins {
156        /// Bins with a positive error.
157        used: usize,
158        /// Free parameters.
159        parameters: usize,
160    },
161    /// No start with positive amplitudes was found.
162    #[error("no start with positive term amplitudes was found")]
163    NoStart,
164    /// The curvature matrix at the minimum is singular.
165    #[error("the curvature matrix is singular; the parameters are not determined by the profile")]
166    Singular,
167    /// PSF parameters are not positive and finite, or do not match the model.
168    #[error("invalid PSF parameters: {0}")]
169    Parameters(String),
170}
171
172/// Log-spaced radial bins: bin 0 is the disc `[0, r_min)`, bins `1..=bins`
173/// split `[r_min, r_max)` into `bins` bins of equal width in `ln r`. Metres.
174#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
175pub struct LogRadialBinning {
176    /// Inner edge of the first log bin (outer edge of the central disc), m.
177    pub r_min_m: f64,
178    /// Outer edge of the last bin, m.
179    pub r_max_m: f64,
180    /// Number of log bins (the central disc is one more).
181    pub bins: usize,
182}
183
184impl LogRadialBinning {
185    /// Validated binning.
186    pub fn new(r_min_m: f64, r_max_m: f64, bins: usize) -> Result<Self, PsfError> {
187        let b = Self {
188            r_min_m,
189            r_max_m,
190            bins,
191        };
192        b.validate()?;
193        Ok(b)
194    }
195
196    fn validate(&self) -> Result<(), PsfError> {
197        if self.r_min_m.is_finite()
198            && self.r_max_m.is_finite()
199            && self.r_min_m > 0.0
200            && self.r_max_m > self.r_min_m
201            && self.bins > 0
202        {
203            Ok(())
204        } else {
205            Err(PsfError::Binning {
206                r_min: self.r_min_m,
207                r_max: self.r_max_m,
208                bins: self.bins,
209            })
210        }
211    }
212
213    /// Number of bins including the central disc, `bins + 1`.
214    pub fn len(&self) -> usize {
215        self.bins + 1
216    }
217
218    /// Always false: there is at least the central disc.
219    pub fn is_empty(&self) -> bool {
220        false
221    }
222
223    /// Inner edge of bin `i` (`i <= bins + 1`), m: `0`, then
224    /// `r_min (r_max/r_min)^((i-1)/bins)`, and exactly `r_max` for the last.
225    pub fn edge(&self, i: usize) -> f64 {
226        if i == 0 {
227            0.0
228        } else if i > self.bins {
229            self.r_max_m
230        } else {
231            let t = (i - 1) as f64 / self.bins as f64;
232            self.r_min_m * (self.r_max_m / self.r_min_m).powf(t)
233        }
234    }
235
236    /// All `bins + 2` edges, m.
237    pub fn edges(&self) -> Vec<f64> {
238        (0..=self.len()).map(|i| self.edge(i)).collect()
239    }
240
241    /// The bin of radius `r`, or `None` for `r >= r_max` (and NaN).
242    pub fn locate(&self, r: f64) -> Option<usize> {
243        if r < self.r_min_m {
244            return Some(0);
245        }
246        if r.is_nan() || r >= self.r_max_m {
247            return None;
248        }
249        let t = (r / self.r_min_m).ln() / (self.r_max_m / self.r_min_m).ln();
250        let i = (t * self.bins as f64) as usize;
251        Some(1 + i.min(self.bins - 1))
252    }
253}
254
255/// Where the radial profile is taken: log radial bins and a depth slab
256/// `depth_lo_m <= x < depth_hi_m`, m.
257#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
258pub struct PsfConfig {
259    /// Radial bins.
260    pub radial: LogRadialBinning,
261    /// Lower depth of the slab, m.
262    pub depth_lo_m: f64,
263    /// Upper depth of the slab (excluded), m.
264    pub depth_hi_m: f64,
265}
266
267impl PsfConfig {
268    /// Validated configuration.
269    pub fn new(
270        radial: LogRadialBinning,
271        depth_lo_m: f64,
272        depth_hi_m: f64,
273    ) -> Result<Self, PsfError> {
274        let c = Self {
275            radial,
276            depth_lo_m,
277            depth_hi_m,
278        };
279        c.validate()?;
280        Ok(c)
281    }
282
283    /// Checks the binning and the slab (a deserialised value skips `new`).
284    pub fn validate(&self) -> Result<(), PsfError> {
285        self.radial.validate()?;
286        if self.depth_lo_m.is_finite()
287            && self.depth_hi_m.is_finite()
288            && self.depth_hi_m > self.depth_lo_m
289        {
290            Ok(())
291        } else {
292            Err(PsfError::Slab {
293                lo: self.depth_lo_m,
294                hi: self.depth_hi_m,
295            })
296        }
297    }
298}
299
300/// Per-history accumulation of a radial profile (see
301/// [the module docs](self#the-radial-profile)). Call
302/// [`RadialAccumulator::deposit`] for every deposit of a history and
303/// [`RadialAccumulator::end_history`] once at its end.
304#[derive(Debug, Clone, PartialEq)]
305pub struct RadialAccumulator {
306    config: PsfConfig,
307    histories: u64,
308    /// `Σ_h x_h` per bin; the last entry is the slab beyond `r_max`, then the
309    /// slab total.
310    sum: Vec<f64>,
311    /// `Σ_h x_h²`, same layout.
312    sum_sq: Vec<f64>,
313    /// The current history's totals, same layout.
314    scratch: Vec<f64>,
315    /// Entries of `scratch` touched in the current history (bin entries
316    /// only; the total is always folded).
317    touched: Vec<usize>,
318}
319
320impl RadialAccumulator {
321    /// Empty accumulator.
322    pub fn new(config: PsfConfig) -> Result<Self, PsfError> {
323        config.validate()?;
324        let n = config.radial.len() + 2;
325        Ok(Self {
326            config,
327            histories: 0,
328            sum: vec![0.0; n],
329            sum_sq: vec![0.0; n],
330            scratch: vec![0.0; n],
331            touched: Vec::new(),
332        })
333    }
334
335    /// The configuration.
336    pub fn config(&self) -> &PsfConfig {
337        &self.config
338    }
339
340    /// Histories ended so far.
341    pub fn histories(&self) -> u64 {
342        self.histories
343    }
344
345    /// A deposit of `e` eV at `pos` (m) in the current history. Ignored
346    /// outside the depth slab or for `e <= 0`.
347    pub fn deposit(&mut self, pos: [f64; 3], e: f64) {
348        let in_slab = pos[0] >= self.config.depth_lo_m && pos[0] < self.config.depth_hi_m;
349        if !(e.is_finite() && e > 0.0 && in_slab) {
350            return;
351        }
352        let n = self.config.radial.len();
353        let i = self.config.radial.locate(pos[1].hypot(pos[2])).unwrap_or(n);
354        if self.scratch[i] == 0.0 {
355            self.touched.push(i);
356        }
357        self.scratch[i] += e;
358        self.scratch[n + 1] += e;
359    }
360
361    /// Ends the current history: folds its bin totals and their squares.
362    pub fn end_history(&mut self) {
363        self.histories += 1;
364        // Fold in increasing bin order, independent of the deposit order.
365        self.touched.sort_unstable();
366        for &i in &self.touched {
367            let x = self.scratch[i];
368            self.sum[i] += x;
369            self.sum_sq[i] += x * x;
370            self.scratch[i] = 0.0;
371        }
372        self.touched.clear();
373        let t = self.scratch.len() - 1;
374        let x = self.scratch[t];
375        if x != 0.0 {
376            self.sum[t] += x;
377            self.sum_sq[t] += x * x;
378            self.scratch[t] = 0.0;
379        }
380    }
381
382    /// Field-wise merge of the totals of another accumulator.
383    ///
384    /// # Panics
385    /// If the configurations differ.
386    pub fn merge(&mut self, o: &RadialAccumulator) {
387        assert!(
388            self.config == o.config,
389            "merging radial accumulators with different configurations"
390        );
391        self.histories += o.histories;
392        for (a, b) in self.sum.iter_mut().zip(&o.sum) {
393            *a += b;
394        }
395        for (a, b) in self.sum_sq.iter_mut().zip(&o.sum_sq) {
396            *a += b;
397        }
398    }
399
400    /// The profile with per-bin standard errors.
401    pub fn profile(&self) -> RadialProfile {
402        let n = self.config.radial.len();
403        let se = |i: usize| std_err_of_sum(self.sum[i], self.sum_sq[i], self.histories);
404        RadialProfile {
405            histories: self.histories,
406            depth_lo_m: self.config.depth_lo_m,
407            depth_hi_m: self.config.depth_hi_m,
408            edges_m: self.config.radial.edges()[..=n].to_vec(),
409            energy_ev: self.sum[..n].to_vec(),
410            std_err_ev: (0..n).map(se).collect(),
411            beyond_ev: self.sum[n],
412            beyond_std_err_ev: se(n),
413            total_ev: self.sum[n + 1],
414            total_std_err_ev: se(n + 1),
415        }
416    }
417}
418
419/// Standard error of `S = Σ_h x_h` over `n` histories from `S` and
420/// `Q = Σ_h x_h²`: `(n/(n-1) (Q - S²/n))^(1/2)`; 0 for `n < 2`.
421fn std_err_of_sum(s: f64, q: f64, n: u64) -> f64 {
422    if n < 2 {
423        return 0.0;
424    }
425    let nf = n as f64;
426    (nf / (nf - 1.0) * (q - s * s / nf)).max(0.0).sqrt()
427}
428
429/// A radial energy-deposition profile in a depth slab, summed over all
430/// histories, with per-bin standard errors. Bin `i` is the annulus
431/// `edges_m[i] <= r < edges_m[i + 1]`.
432#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
433pub struct RadialProfile {
434    /// Histories accumulated.
435    pub histories: u64,
436    /// Lower depth of the slab, m.
437    pub depth_lo_m: f64,
438    /// Upper depth of the slab (excluded), m.
439    pub depth_hi_m: f64,
440    /// Bin edges, m; one more than bins, starting at 0.
441    pub edges_m: Vec<f64>,
442    /// Energy per bin, eV.
443    pub energy_ev: Vec<f64>,
444    /// Standard error of each bin's energy, eV.
445    pub std_err_ev: Vec<f64>,
446    /// Energy deposited in the slab beyond the last edge, eV.
447    pub beyond_ev: f64,
448    /// Its standard error, eV.
449    pub beyond_std_err_ev: f64,
450    /// Energy deposited in the slab, eV: the bins plus `beyond_ev`.
451    pub total_ev: f64,
452    /// Its standard error, eV (from the per-history slab totals).
453    pub total_std_err_ev: f64,
454}
455
456impl RadialProfile {
457    /// Relative tolerance of the check that `total_ev` equals the bin
458    /// energies plus `beyond_ev` (see [`RadialProfile::validate`]).
459    ///
460    /// [`RadialAccumulator`] sums the same deposits into the bins and into the
461    /// slab total, but in a different order, so the two agree only to
462    /// floating-point rounding: a naive sum of `n` non-negative terms is off
463    /// by at most about `n ε` relative (`ε ≈ 1.1e-16`), well below `1e-6` for
464    /// any feasible number of histories.
465    pub const TOTAL_REL_TOLERANCE: f64 = 1e-6;
466
467    /// Checks that the profile is consistent: edges start at 0 and increase,
468    /// one energy and one error per bin, all finite, errors non-negative; the
469    /// depth slab finite with `depth_lo_m < depth_hi_m`; `beyond_ev`,
470    /// `total_ev` and their errors finite and non-negative; and `total_ev`
471    /// equal to the bin energies plus `beyond_ev` within
472    /// [`RadialProfile::TOTAL_REL_TOLERANCE`] relative.
473    pub fn validate(&self) -> Result<(), PsfError> {
474        let bad = |s: &str| Err(PsfError::Profile(s.to_string()));
475        let n = self.energy_ev.len();
476        if n == 0 || self.edges_m.len() != n + 1 || self.std_err_ev.len() != n {
477            return bad("need n >= 1 bins with n + 1 edges, n energies and n errors");
478        }
479        if self.edges_m[0] != 0.0 || !self.edges_m.windows(2).all(|w| w[1] > w[0]) {
480            return bad("edges must start at 0 and increase");
481        }
482        if !self.edges_m.iter().all(|e| e.is_finite())
483            || !self.energy_ev.iter().all(|e| e.is_finite())
484            || !self.std_err_ev.iter().all(|e| e.is_finite() && *e >= 0.0)
485        {
486            return bad("edges, energies and errors must be finite, errors non-negative");
487        }
488        if !(self.depth_lo_m.is_finite()
489            && self.depth_hi_m.is_finite()
490            && self.depth_lo_m < self.depth_hi_m)
491        {
492            return bad("depth bounds must be finite with depth_lo_m < depth_hi_m");
493        }
494        let scalars = [
495            self.beyond_ev,
496            self.beyond_std_err_ev,
497            self.total_ev,
498            self.total_std_err_ev,
499        ];
500        if !scalars.iter().all(|v| v.is_finite() && *v >= 0.0) {
501            return bad(
502                "beyond_ev, beyond_std_err_ev, total_ev and total_std_err_ev \
503                 must be finite and non-negative",
504            );
505        }
506        let parts = self.energy_ev.iter().sum::<f64>() + self.beyond_ev;
507        let scale =
508            self.energy_ev.iter().map(|e| e.abs()).sum::<f64>() + self.beyond_ev + self.total_ev;
509        if (self.total_ev - parts).abs() > Self::TOTAL_REL_TOLERANCE * scale {
510            return bad("total_ev must equal the bin energies plus beyond_ev");
511        }
512        Ok(())
513    }
514
515    /// Number of bins.
516    pub fn len(&self) -> usize {
517        self.energy_ev.len()
518    }
519
520    /// Whether there are no bins.
521    pub fn is_empty(&self) -> bool {
522        self.energy_ev.is_empty()
523    }
524
525    /// Area of the annulus of bin `i`, m².
526    pub fn area_m2(&self, i: usize) -> f64 {
527        let (a, b) = (self.edges_m[i], self.edges_m[i + 1]);
528        PI * (b * b - a * a)
529    }
530
531    /// Representative radius of bin `i`, m: the geometric mean of its edges,
532    /// and half the outer edge for the central disc.
533    pub fn center_m(&self, i: usize) -> f64 {
534        let (a, b) = (self.edges_m[i], self.edges_m[i + 1]);
535        if a > 0.0 {
536            (a * b).sqrt()
537        } else {
538            0.5 * b
539        }
540    }
541
542    /// Mean areal energy density of each bin, eV/m².
543    pub fn density_ev_per_m2(&self) -> Vec<f64> {
544        (0..self.len())
545            .map(|i| self.energy_ev[i] / self.area_m2(i))
546            .collect()
547    }
548
549    /// Standard error of each bin's areal density, eV/m².
550    pub fn density_std_err_ev_per_m2(&self) -> Vec<f64> {
551        (0..self.len())
552            .map(|i| self.std_err_ev[i] / self.area_m2(i))
553            .collect()
554    }
555}
556
557/// Which proximity function is fitted.
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
559pub enum PsfModel {
560    /// `(α, β, η)`: Mao et al. (2025) eq. (1), after Chang (1975).
561    DoubleGaussian,
562    /// `(α, β, η, γ, ν)`: Rosa Figueiro (2015) eq. (72) with two backscatter
563    /// terms.
564    TripleGaussian,
565}
566
567impl PsfModel {
568    /// Number of Gaussian terms.
569    pub fn terms(&self) -> usize {
570        match self {
571            PsfModel::DoubleGaussian => 2,
572            PsfModel::TripleGaussian => 3,
573        }
574    }
575
576    /// Number of free parameters, `E` included.
577    pub fn parameters(&self) -> usize {
578        2 * self.terms()
579    }
580
581    /// Parameter names in [`PsfFit::values`] order.
582    pub fn parameter_names(&self) -> Vec<&'static str> {
583        match self {
584            PsfModel::DoubleGaussian => vec!["total_ev", "alpha_m", "beta_m", "eta"],
585            PsfModel::TripleGaussian => {
586                vec!["total_ev", "alpha_m", "beta_m", "eta", "gamma_m", "nu"]
587            }
588        }
589    }
590
591    /// Source of the normalised form, as recorded in a [`PsfFit`].
592    pub fn source(&self) -> &'static str {
593        match self {
594            PsfModel::DoubleGaussian => {
595                "f(r) = [exp(-r^2/alpha^2)/alpha^2 + eta exp(-r^2/beta^2)/beta^2] / (pi (1 + eta)): \
596                 Mao, Zhu, Cheng and Wang, Discover Nano 20, 84 (2025), doi:10.1186/s11671-025-04264-0, \
597                 eq. (1), after Chang, J. Vac. Sci. Technol. 12, 1271 (1975), doi:10.1116/1.568515"
598            }
599            PsfModel::TripleGaussian => {
600                "f(r) = [exp(-r^2/alpha^2)/alpha^2 + eta exp(-r^2/beta^2)/beta^2 + nu exp(-r^2/gamma^2)/gamma^2] \
601                 / (pi (1 + eta + nu)): Rosa Figueiro, PhD thesis, Univ. Grenoble Alpes (2015), \
602                 HAL tel-01206934, eq. (72) with two backscatter terms (beta_1 = beta, eta_1 = eta, \
603                 beta_2 = gamma, eta_2 = nu)"
604            }
605        }
606    }
607}
608
609/// A double- or triple-Gaussian PSF scaled to the energy `total_ev`:
610/// `E f(r)`, eV/m² (see [the module docs](self#the-fitted-forms)). The
611/// triple form has `gamma_m` and `nu`; the double form has neither.
612#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
613pub struct GaussianPsf {
614    /// The energy `E` the PSF integrates to, eV.
615    pub total_ev: f64,
616    /// Forward-scattering width `α`, m.
617    pub alpha_m: f64,
618    /// Backscattering width `β`, m.
619    pub beta_m: f64,
620    /// Ratio `η` of the `β` term to the `α` term.
621    pub eta: f64,
622    /// Third width `γ`, m (triple Gaussian only).
623    pub gamma_m: Option<f64>,
624    /// Ratio `ν` of the `γ` term to the `α` term (triple Gaussian only).
625    pub nu: Option<f64>,
626}
627
628impl GaussianPsf {
629    /// Double Gaussian `(E, α, β, η)`.
630    pub fn double(total_ev: f64, alpha_m: f64, beta_m: f64, eta: f64) -> Result<Self, PsfError> {
631        let p = Self {
632            total_ev,
633            alpha_m,
634            beta_m,
635            eta,
636            gamma_m: None,
637            nu: None,
638        };
639        p.validate()?;
640        Ok(p)
641    }
642
643    /// Triple Gaussian `(E, α, β, η, γ, ν)`.
644    pub fn triple(
645        total_ev: f64,
646        alpha_m: f64,
647        beta_m: f64,
648        eta: f64,
649        gamma_m: f64,
650        nu: f64,
651    ) -> Result<Self, PsfError> {
652        let p = Self {
653            total_ev,
654            alpha_m,
655            beta_m,
656            eta,
657            gamma_m: Some(gamma_m),
658            nu: Some(nu),
659        };
660        p.validate()?;
661        Ok(p)
662    }
663
664    /// The model: triple if `gamma_m` is set.
665    pub fn model(&self) -> PsfModel {
666        if self.gamma_m.is_some() {
667            PsfModel::TripleGaussian
668        } else {
669            PsfModel::DoubleGaussian
670        }
671    }
672
673    /// Checks that every parameter is positive and finite and that `gamma_m`
674    /// and `nu` are both set or both unset.
675    pub fn validate(&self) -> Result<(), PsfError> {
676        if self.gamma_m.is_some() != self.nu.is_some() {
677            return Err(PsfError::Parameters(
678                "gamma_m and nu must both be set or both unset".into(),
679            ));
680        }
681        if self.values().iter().all(|v| v.is_finite() && *v > 0.0) {
682            Ok(())
683        } else {
684            Err(PsfError::Parameters(format!(
685                "every parameter must be positive and finite, got {:?}",
686                self.values()
687            )))
688        }
689    }
690
691    /// Parameters in [`PsfModel::parameter_names`] order.
692    pub fn values(&self) -> Vec<f64> {
693        let mut v = vec![self.total_ev, self.alpha_m, self.beta_m, self.eta];
694        if let (Some(g), Some(n)) = (self.gamma_m, self.nu) {
695            v.extend([g, n]);
696        }
697        v
698    }
699
700    fn from_values(model: PsfModel, v: &[f64]) -> Self {
701        Self {
702            total_ev: v[0],
703            alpha_m: v[1],
704            beta_m: v[2],
705            eta: v[3],
706            gamma_m: (model == PsfModel::TripleGaussian).then(|| v[4]),
707            nu: (model == PsfModel::TripleGaussian).then(|| v[5]),
708        }
709    }
710
711    /// `(widths, weights)` with the `α` term's weight 1.
712    fn terms(&self) -> (Vec<f64>, Vec<f64>) {
713        let mut w = vec![self.alpha_m, self.beta_m];
714        let mut c = vec![1.0, self.eta];
715        if let (Some(g), Some(n)) = (self.gamma_m, self.nu) {
716            w.push(g);
717            c.push(n);
718        }
719        (w, c)
720    }
721
722    /// The normalised PSF `f(r)`, 1/m².
723    pub fn normalized_per_m2(&self, r_m: f64) -> f64 {
724        let (w, c) = self.terms();
725        let sum_c: f64 = c.iter().sum();
726        let s: f64 = w
727            .iter()
728            .zip(&c)
729            .map(|(w, c)| c / (w * w) * (-(r_m * r_m) / (w * w)).exp())
730            .sum();
731        s / (PI * sum_c)
732    }
733
734    /// The areal energy density `E f(r)`, eV/m².
735    pub fn density_ev_per_m2(&self, r_m: f64) -> f64 {
736        self.total_ev * self.normalized_per_m2(r_m)
737    }
738
739    /// Energy in the annulus `[a, b)`, eV, by the closed-form integral of
740    /// [the module docs](self#how-the-fit-is-done).
741    pub fn annulus_energy_ev(&self, a_m: f64, b_m: f64) -> f64 {
742        let (w, c) = self.terms();
743        let sum_c: f64 = c.iter().sum();
744        let s: f64 = w
745            .iter()
746            .zip(&c)
747            .map(|(w, c)| c * gauss_annulus(a_m, b_m, *w))
748            .sum();
749        self.total_ev * s / sum_c
750    }
751}
752
753/// `exp(-a²/w²) - exp(-b²/w²)`, the fraction of a normalised 2D Gaussian
754/// term of width `w` in the annulus `[a, b)`, without cancellation for thin
755/// annuli.
756fn gauss_annulus(a: f64, b: f64, w: f64) -> f64 {
757    let w2 = w * w;
758    -(-(a * a) / w2).exp() * (-(b * b - a * a) / w2).exp_m1()
759}
760
761/// `d/dw` of [`gauss_annulus`]: `(2/w³)(a² e^(-a²/w²) - b² e^(-b²/w²))`.
762fn gauss_annulus_dw(a: f64, b: f64, w: f64) -> f64 {
763    let w2 = w * w;
764    2.0 / (w2 * w) * (a * a * (-(a * a) / w2).exp() - b * b * (-(b * b) / w2).exp())
765}
766
767/// How the energy scale `E` of the fitted PSF is set.
768#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
769pub enum PsfNormalization {
770    /// `E` is fixed to the energy deposited in the slab,
771    /// [`RadialProfile::total_ev`] (beyond `r_max` included), and only the
772    /// shape `(α, β, η[, γ, ν])` is fitted. The fitted function then
773    /// integrates to the slab energy. This is the default: the forms are
774    /// normalised PSFs, and their scale is the deposited energy.
775    #[default]
776    SlabTotal,
777    /// `E` is a free parameter. Only a form that describes the profile
778    /// (reduced `χ²` near 1) then integrates to the slab energy; on a
779    /// profile it does not describe, the weighted fit follows the
780    /// best-measured bins and `E` can be far off (by a third for the double
781    /// Gaussian on the transport profile of `lindhard/tests/psf.rs`).
782    Free,
783}
784
785/// Settings of [`fit_psf`].
786#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
787pub struct PsfFitOptions {
788    /// How `E` is set.
789    pub normalization: PsfNormalization,
790    /// Start of the Levenberg-Marquardt iterations; `None` runs the grid
791    /// search. Its model must match the fitted one; its `total_ev` is
792    /// replaced by the slab energy under [`PsfNormalization::SlabTotal`].
793    pub start: Option<GaussianPsf>,
794    /// Points of the log-spaced width grid of the start search, from the
795    /// first log edge to half the outer edge.
796    pub grid_points: usize,
797    /// Maximum Levenberg-Marquardt iterations.
798    pub max_iterations: usize,
799}
800
801impl Default for PsfFitOptions {
802    fn default() -> Self {
803        Self {
804            normalization: PsfNormalization::SlabTotal,
805            start: None,
806            grid_points: 40,
807            max_iterations: 500,
808        }
809    }
810}
811
812/// One bin of a fit: data, model and normalised residual.
813#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
814pub struct PsfResidual {
815    /// Inner edge, m.
816    pub r_lo_m: f64,
817    /// Outer edge, m.
818    pub r_hi_m: f64,
819    /// Bin energy, eV.
820    pub observed_ev: f64,
821    /// Fitted model energy in the bin, eV.
822    pub model_ev: f64,
823    /// Standard error of the bin energy, eV.
824    pub std_err_ev: f64,
825    /// `(observed - model) / std_err`; `None` for a bin left out of the fit
826    /// (zero error).
827    pub normalized: Option<f64>,
828}
829
830/// The result of [`fit_psf`].
831#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
832pub struct PsfFit {
833    /// The fitted form.
834    pub model: PsfModel,
835    /// Where the normalised form comes from.
836    pub source: String,
837    /// How the fit was done.
838    pub method: String,
839    /// How `E` was set.
840    pub normalization: PsfNormalization,
841    /// The fitted PSF.
842    pub psf: GaussianPsf,
843    /// Parameter names, in the order of `values`, `std_errors` and
844    /// `covariance`.
845    pub parameter_names: Vec<String>,
846    /// Fitted values (`E` first, fixed or fitted).
847    pub values: Vec<f64>,
848    /// Standard errors, the square roots of the covariance diagonal.
849    pub std_errors: Vec<f64>,
850    /// Covariance matrix, row-major, not scaled by the reduced `χ²`: `(Jᵀ W
851    /// J)⁻¹` over the fitted parameters. Under
852    /// [`PsfNormalization::SlabTotal`] the `E` row and column hold only the
853    /// variance of the slab energy ([`RadialProfile::total_std_err_ev`]
854    /// squared); its covariances with the shape are not estimated and are 0.
855    pub covariance: Vec<Vec<f64>>,
856    /// `χ²` at the minimum.
857    pub chi2: f64,
858    /// Degrees of freedom: bins used minus fitted parameters.
859    pub dof: usize,
860    /// `χ² / dof`.
861    pub reduced_chi2: f64,
862    /// Bins used (positive error).
863    pub bins_used: usize,
864    /// Levenberg-Marquardt iterations.
865    pub iterations: usize,
866    /// Whether the iterations stopped on a converged step rather than the
867    /// iteration cap.
868    pub converged: bool,
869    /// Per-bin data, model and residuals (every bin of the profile).
870    pub residuals: Vec<PsfResidual>,
871}
872
873impl PsfFit {
874    /// Standard error of the named parameter.
875    pub fn std_error(&self, name: &str) -> Option<f64> {
876        self.parameter_names
877            .iter()
878            .position(|n| n == name)
879            .map(|i| self.std_errors[i])
880    }
881}
882
883/// How the fit is done, as recorded in a [`PsfFit`].
884pub const PSF_FIT_METHOD: &str = "weighted least squares on the bin energies (not their logarithm), \
885     model = exact annulus integral of E f(r), weights 1/sigma_i^2 from per-history errors, bins with \
886     sigma_i = 0 left out; start from a log grid over the widths with linear least squares for the \
887     amplitudes; Levenberg-Marquardt on the logarithms of the parameters with the analytic Jacobian; \
888     covariance (J^T W J)^-1 over the fitted parameters in their natural units, not scaled by the \
889     reduced chi^2; E fixed to the slab energy (default) or fitted, see normalization";
890
891/// The data a fit uses: bins with a positive error.
892struct FitData {
893    a: Vec<f64>,
894    b: Vec<f64>,
895    y: Vec<f64>,
896    sigma: Vec<f64>,
897}
898
899impl FitData {
900    fn chi2(&self, model: PsfModel, p: &[f64]) -> f64 {
901        let psf = GaussianPsf::from_values(model, p);
902        (0..self.y.len())
903            .map(|i| {
904                let r = (self.y[i] - psf.annulus_energy_ev(self.a[i], self.b[i])) / self.sigma[i];
905                r * r
906            })
907            .sum()
908    }
909
910    /// Weighted residuals `(y - μ)/σ` and the Jacobian `(∂μ/∂p)/σ`, row-major
911    /// (one row per bin), in the natural parameters.
912    fn residuals_and_jacobian(&self, model: PsfModel, p: &[f64]) -> (Vec<f64>, Vec<f64>) {
913        let k = model.terms();
914        let np = model.parameters();
915        let e = p[0];
916        let mut w = vec![p[1]];
917        let mut c = vec![1.0];
918        for t in 1..k {
919            w.push(p[2 * t]);
920            c.push(p[2 * t + 1]);
921        }
922        let sum_c: f64 = c.iter().sum();
923        let n = self.y.len();
924        let mut r = vec![0.0; n];
925        let mut jac = vec![0.0; n * np];
926        for i in 0..n {
927            let g: Vec<f64> = w
928                .iter()
929                .map(|w| gauss_annulus(self.a[i], self.b[i], *w))
930                .collect();
931            let m: f64 = g.iter().zip(&c).map(|(g, c)| g * c).sum::<f64>() / sum_c;
932            let s = self.sigma[i];
933            r[i] = (self.y[i] - e * m) / s;
934            let row = &mut jac[i * np..(i + 1) * np];
935            row[0] = m / s;
936            for t in 0..k {
937                let dw = gauss_annulus_dw(self.a[i], self.b[i], w[t]);
938                let iw = if t == 0 { 1 } else { 2 * t };
939                row[iw] = e * c[t] * dw / sum_c / s;
940                if t > 0 {
941                    row[2 * t + 1] = e * (g[t] - m) / sum_c / s;
942                }
943            }
944        }
945        (r, jac)
946    }
947}
948
949/// Fits the double- or triple-Gaussian PSF to a radial profile. See
950/// [the module docs](self#how-the-fit-is-done).
951pub fn fit_psf(
952    profile: &RadialProfile,
953    model: PsfModel,
954    options: &PsfFitOptions,
955) -> Result<PsfFit, PsfError> {
956    profile.validate()?;
957    let np = model.parameters();
958    // Indices of the fitted parameters; `E` (index 0) only if free.
959    let free: Vec<usize> = match options.normalization {
960        PsfNormalization::Free => (0..np).collect(),
961        PsfNormalization::SlabTotal => (1..np).collect(),
962    };
963    let nf = free.len();
964    let used: Vec<usize> = (0..profile.len())
965        .filter(|&i| profile.std_err_ev[i] > 0.0)
966        .collect();
967    if used.len() < nf + 1 {
968        return Err(PsfError::TooFewBins {
969            used: used.len(),
970            parameters: nf,
971        });
972    }
973    if options.normalization == PsfNormalization::SlabTotal && profile.total_ev <= 0.0 {
974        return Err(PsfError::Profile(
975            "no energy in the slab to normalise the PSF to".into(),
976        ));
977    }
978    let data = FitData {
979        a: used.iter().map(|&i| profile.edges_m[i]).collect(),
980        b: used.iter().map(|&i| profile.edges_m[i + 1]).collect(),
981        y: used.iter().map(|&i| profile.energy_ev[i]).collect(),
982        sigma: used.iter().map(|&i| profile.std_err_ev[i]).collect(),
983    };
984    let mut start = match options.start {
985        Some(s) => {
986            s.validate()?;
987            if s.model() != model {
988                return Err(PsfError::Parameters(
989                    "the start's model differs from the fitted model".into(),
990                ));
991            }
992            s.values()
993        }
994        None => grid_start(profile, &data, model, options.grid_points.max(2))?,
995    };
996    if options.normalization == PsfNormalization::SlabTotal {
997        start[0] = profile.total_ev;
998    }
999
1000    // Levenberg-Marquardt on θ = ln p over the fitted parameters.
1001    let mut p = start;
1002    let mut chi2 = data.chi2(model, &p);
1003    let mut lambda = 1e-3;
1004    let mut iterations = 0;
1005    let mut converged = false;
1006    while iterations < options.max_iterations {
1007        iterations += 1;
1008        let (r, jac) = data.residuals_and_jacobian(model, &p);
1009        // Chain rule to θ: ∂μ/∂θ_j = p_j ∂μ/∂p_j.
1010        let mut a = vec![0.0; nf * nf];
1011        let mut g = vec![0.0; nf];
1012        for (i, ri) in r.iter().enumerate() {
1013            let row = &jac[i * np..(i + 1) * np];
1014            for (j, &fj) in free.iter().enumerate() {
1015                let jj = row[fj] * p[fj];
1016                g[j] += jj * ri;
1017                for (k, &fk) in free.iter().enumerate().take(j + 1) {
1018                    a[j * nf + k] += jj * row[fk] * p[fk];
1019                }
1020            }
1021        }
1022        for j in 0..nf {
1023            for k in 0..j {
1024                a[k * nf + j] = a[j * nf + k];
1025            }
1026        }
1027        let mut improved = false;
1028        while lambda <= 1e16 {
1029            let mut m = a.clone();
1030            for j in 0..nf {
1031                m[j * nf + j] += lambda * a[j * nf + j].max(f64::MIN_POSITIVE);
1032            }
1033            let Some(delta) = solve(m, g.clone(), nf) else {
1034                lambda *= 10.0;
1035                continue;
1036            };
1037            let mut pt = p.clone();
1038            for (d, &fj) in delta.iter().zip(&free) {
1039                pt[fj] = (p[fj].ln() + d).exp();
1040            }
1041            let c2 = data.chi2(model, &pt);
1042            if pt.iter().all(|v| v.is_finite() && *v > 0.0) && c2.is_finite() && c2 < chi2 {
1043                let step = delta.iter().fold(0.0f64, |m, d| m.max(d.abs()));
1044                let rel = (chi2 - c2) / c2.max(f64::MIN_POSITIVE);
1045                p = pt;
1046                chi2 = c2;
1047                lambda = (lambda * 0.1).max(1e-12);
1048                improved = true;
1049                if step < 1e-10 || rel < 1e-13 {
1050                    converged = true;
1051                }
1052                break;
1053            }
1054            lambda *= 10.0;
1055        }
1056        if !improved {
1057            // No step lowers χ²: a minimum to working precision.
1058            converged = true;
1059        }
1060        if converged {
1061            break;
1062        }
1063    }
1064
1065    // Covariance over the fitted parameters, in their natural units.
1066    let (_, jac) = data.residuals_and_jacobian(model, &p);
1067    let n = data.y.len();
1068    let mut a = vec![0.0; nf * nf];
1069    for i in 0..n {
1070        let row = &jac[i * np..(i + 1) * np];
1071        for (j, &fj) in free.iter().enumerate() {
1072            for (k, &fk) in free.iter().enumerate() {
1073                a[j * nf + k] += row[fj] * row[fk];
1074            }
1075        }
1076    }
1077    let inv = invert(a, nf).ok_or(PsfError::Singular)?;
1078    let mut covariance = vec![vec![0.0; np]; np];
1079    for (j, &fj) in free.iter().enumerate() {
1080        for (k, &fk) in free.iter().enumerate() {
1081            covariance[fj][fk] = inv[j * nf + k];
1082        }
1083    }
1084    if options.normalization == PsfNormalization::SlabTotal {
1085        covariance[0][0] = profile.total_std_err_ev * profile.total_std_err_ev;
1086    }
1087    // Every variance finite; those of the fitted parameters also positive
1088    // (the fixed `E` of `SlabTotal` carries the slab total's variance, which
1089    // is 0 for fewer than two histories).
1090    if !(0..np).all(|j| covariance[j][j].is_finite())
1091        || !free.iter().all(|&j| covariance[j][j] > 0.0)
1092    {
1093        return Err(PsfError::Singular);
1094    }
1095    let std_errors = (0..np).map(|j| covariance[j][j].sqrt()).collect();
1096    let psf = GaussianPsf::from_values(model, &p);
1097    let residuals = (0..profile.len())
1098        .map(|i| {
1099            let (lo, hi) = (profile.edges_m[i], profile.edges_m[i + 1]);
1100            let model_ev = psf.annulus_energy_ev(lo, hi);
1101            let s = profile.std_err_ev[i];
1102            PsfResidual {
1103                r_lo_m: lo,
1104                r_hi_m: hi,
1105                observed_ev: profile.energy_ev[i],
1106                model_ev,
1107                std_err_ev: s,
1108                normalized: (s > 0.0).then(|| (profile.energy_ev[i] - model_ev) / s),
1109            }
1110        })
1111        .collect();
1112    let dof = n - nf;
1113    Ok(PsfFit {
1114        model,
1115        source: model.source().to_string(),
1116        method: PSF_FIT_METHOD.to_string(),
1117        normalization: options.normalization,
1118        psf,
1119        parameter_names: model
1120            .parameter_names()
1121            .into_iter()
1122            .map(String::from)
1123            .collect(),
1124        values: p,
1125        std_errors,
1126        covariance,
1127        chi2,
1128        dof,
1129        reduced_chi2: chi2 / dof as f64,
1130        bins_used: n,
1131        iterations,
1132        converged,
1133        residuals,
1134    })
1135}
1136
1137/// The start: the best `χ²` over a log grid of ordered widths, with the
1138/// amplitudes of each grid point from weighted linear least squares.
1139fn grid_start(
1140    profile: &RadialProfile,
1141    data: &FitData,
1142    model: PsfModel,
1143    points: usize,
1144) -> Result<Vec<f64>, PsfError> {
1145    let k = model.terms();
1146    // First positive edge to half the outer edge.
1147    let lo = profile.edges_m[1];
1148    let hi = (0.5 * profile.edges_m[profile.len()]).max(lo * 1.0001);
1149    let widths: Vec<f64> = (0..points)
1150        .map(|i| lo * (hi / lo).powf(i as f64 / (points - 1) as f64))
1151        .collect();
1152    let n = data.y.len();
1153    // Weighted basis columns g_i(w)/σ_i and data y_i/σ_i.
1154    let cols: Vec<Vec<f64>> = widths
1155        .iter()
1156        .map(|&w| {
1157            (0..n)
1158                .map(|i| gauss_annulus(data.a[i], data.b[i], w) / data.sigma[i])
1159                .collect()
1160        })
1161        .collect();
1162    let yw: Vec<f64> = (0..n).map(|i| data.y[i] / data.sigma[i]).collect();
1163    let mut best: Option<(f64, Vec<usize>, Vec<f64>)> = None;
1164    let mut idx = vec![0usize; k];
1165    let mut consider = |idx: &[usize]| {
1166        let mut m = vec![0.0; k * k];
1167        let mut rhs = vec![0.0; k];
1168        for i in 0..n {
1169            for j in 0..k {
1170                let cj = cols[idx[j]][i];
1171                rhs[j] += cj * yw[i];
1172                for l in 0..k {
1173                    m[j * k + l] += cj * cols[idx[l]][i];
1174                }
1175            }
1176        }
1177        let Some(amp) = solve(m, rhs, k) else {
1178            return;
1179        };
1180        if !amp.iter().all(|a| a.is_finite() && *a > 0.0) {
1181            return;
1182        }
1183        let chi2: f64 = (0..n)
1184            .map(|i| {
1185                let mu: f64 = (0..k).map(|j| amp[j] * cols[idx[j]][i]).sum();
1186                (yw[i] - mu).powi(2)
1187            })
1188            .sum();
1189        if best.as_ref().is_none_or(|b| chi2 < b.0) {
1190            best = Some((chi2, idx.to_vec(), amp));
1191        }
1192    };
1193    // Every strictly increasing index tuple, in lexicographic order.
1194    fn next(idx: &mut [usize], points: usize) -> bool {
1195        let k = idx.len();
1196        let mut j = k;
1197        while j > 0 {
1198            j -= 1;
1199            if idx[j] < points - (k - j) {
1200                idx[j] += 1;
1201                for l in j + 1..k {
1202                    idx[l] = idx[l - 1] + 1;
1203                }
1204                return true;
1205            }
1206        }
1207        false
1208    }
1209    if points < k {
1210        return Err(PsfError::NoStart);
1211    }
1212    for (j, v) in idx.iter_mut().enumerate() {
1213        *v = j;
1214    }
1215    loop {
1216        consider(&idx);
1217        if !next(&mut idx, points) {
1218            break;
1219        }
1220    }
1221    let (_, idx, amp) = best.ok_or(PsfError::NoStart)?;
1222    // μ = Σ_t A_t g(w_t) with A_t = E c_t / Σc and c_0 = 1.
1223    let e: f64 = amp.iter().sum();
1224    let mut v = vec![e, widths[idx[0]]];
1225    for t in 1..k {
1226        v.push(widths[idx[t]]);
1227        v.push(amp[t] / amp[0]);
1228    }
1229    Ok(v)
1230}
1231
1232/// Solves the `n x n` system `m x = r` (row-major) by Gaussian elimination
1233/// with partial pivoting; `None` if singular.
1234fn solve(mut m: Vec<f64>, mut r: Vec<f64>, n: usize) -> Option<Vec<f64>> {
1235    for c in 0..n {
1236        let piv = (c..n).max_by(|&i, &j| m[i * n + c].abs().total_cmp(&m[j * n + c].abs()))?;
1237        if m[piv * n + c] == 0.0 || !m[piv * n + c].is_finite() {
1238            return None;
1239        }
1240        if piv != c {
1241            for k in 0..n {
1242                m.swap(c * n + k, piv * n + k);
1243            }
1244            r.swap(c, piv);
1245        }
1246        for i in c + 1..n {
1247            let f = m[i * n + c] / m[c * n + c];
1248            for k in c..n {
1249                m[i * n + k] -= f * m[c * n + k];
1250            }
1251            r[i] -= f * r[c];
1252        }
1253    }
1254    let mut x = vec![0.0; n];
1255    for c in (0..n).rev() {
1256        let s: f64 = (c + 1..n).map(|k| m[c * n + k] * x[k]).sum();
1257        x[c] = (r[c] - s) / m[c * n + c];
1258    }
1259    x.iter().all(|v| v.is_finite()).then_some(x)
1260}
1261
1262/// Inverse of an `n x n` matrix (row-major), column by column with
1263/// [`solve`]; `None` if singular.
1264fn invert(m: Vec<f64>, n: usize) -> Option<Vec<f64>> {
1265    let mut inv = vec![0.0; n * n];
1266    for c in 0..n {
1267        let mut e = vec![0.0; n];
1268        e[c] = 1.0;
1269        let x = solve(m.clone(), e, n)?;
1270        for r in 0..n {
1271            inv[r * n + c] = x[r];
1272        }
1273    }
1274    Some(inv)
1275}
1276
1277/// A radial profile and its fits, for export.
1278#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1279pub struct PsfReport {
1280    /// The profile.
1281    pub profile: RadialProfile,
1282    /// Its fits.
1283    pub fits: Vec<PsfFit>,
1284}
1285
1286impl PsfReport {
1287    /// The profile as CSV, one row per bin, with the model energy of each fit
1288    /// (`model_<model>_ev`) and the areal densities. Columns: `r_lo_m`,
1289    /// `r_hi_m`, `r_center_m`, `area_m2`, `energy_ev`, `std_err_ev`,
1290    /// `density_ev_per_m2`, `density_std_err_ev_per_m2`, then one per fit.
1291    pub fn profile_csv(&self) -> String {
1292        let p = &self.profile;
1293        let mut s = String::from(
1294            "r_lo_m,r_hi_m,r_center_m,area_m2,energy_ev,std_err_ev,density_ev_per_m2,density_std_err_ev_per_m2",
1295        );
1296        for f in &self.fits {
1297            s.push_str(&format!(",model_{}_ev", model_tag(f.model)));
1298        }
1299        s.push('\n');
1300        let d = p.density_ev_per_m2();
1301        let de = p.density_std_err_ev_per_m2();
1302        for i in 0..p.len() {
1303            s.push_str(&format!(
1304                "{},{},{},{},{},{},{},{}",
1305                p.edges_m[i],
1306                p.edges_m[i + 1],
1307                p.center_m(i),
1308                p.area_m2(i),
1309                p.energy_ev[i],
1310                p.std_err_ev[i],
1311                d[i],
1312                de[i]
1313            ));
1314            for f in &self.fits {
1315                s.push_str(&format!(",{}", f.residuals[i].model_ev));
1316            }
1317            s.push('\n');
1318        }
1319        s
1320    }
1321
1322    /// The fitted parameters as CSV: `model,parameter,value,std_error`, then
1323    /// one `chi2`, `dof`, `reduced_chi2`, `converged` (1 or 0) and
1324    /// `iterations` row per fit (with an empty error). The last two are the
1325    /// fit's own diagnostics: a `converged` of 0 means the iteration budget
1326    /// ran out and the parameters are the last iterate.
1327    pub fn parameters_csv(&self) -> String {
1328        let mut s = String::from("model,parameter,value,std_error\n");
1329        for f in &self.fits {
1330            let tag = model_tag(f.model);
1331            for ((n, v), e) in f.parameter_names.iter().zip(&f.values).zip(&f.std_errors) {
1332                s.push_str(&format!("{tag},{n},{v},{e}\n"));
1333            }
1334            s.push_str(&format!("{tag},chi2,{},\n", f.chi2));
1335            s.push_str(&format!("{tag},dof,{},\n", f.dof));
1336            s.push_str(&format!("{tag},reduced_chi2,{},\n", f.reduced_chi2));
1337            s.push_str(&format!("{tag},converged,{},\n", u8::from(f.converged)));
1338            s.push_str(&format!("{tag},iterations,{},\n", f.iterations));
1339        }
1340        s
1341    }
1342}
1343
1344fn model_tag(m: PsfModel) -> &'static str {
1345    match m {
1346        PsfModel::DoubleGaussian => "double_gaussian",
1347        PsfModel::TripleGaussian => "triple_gaussian",
1348    }
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353    use super::*;
1354
1355    #[test]
1356    fn log_binning_edges_and_locate_agree() {
1357        let b = LogRadialBinning::new(1e-9, 1e-5, 40).unwrap();
1358        let e = b.edges();
1359        assert_eq!(e.len(), 42);
1360        assert_eq!(e[0], 0.0);
1361        assert_eq!(e[1], 1e-9);
1362        assert_eq!(e[41], 1e-5);
1363        assert!(e.windows(2).all(|w| w[1] > w[0]));
1364        assert_eq!(b.locate(0.0), Some(0));
1365        assert_eq!(b.locate(0.5e-9), Some(0));
1366        assert_eq!(b.locate(1e-5), None);
1367        assert_eq!(b.locate(f64::NAN), None);
1368        for i in 1..=40 {
1369            let mid = (e[i] * e[i + 1]).sqrt();
1370            assert_eq!(b.locate(mid), Some(i), "bin {i}");
1371        }
1372        assert!(LogRadialBinning::new(0.0, 1.0, 3).is_err());
1373        assert!(LogRadialBinning::new(1.0, 1.0, 3).is_err());
1374        assert!(LogRadialBinning::new(1.0, 2.0, 0).is_err());
1375        assert!(PsfConfig::new(b, 1.0, 1.0).is_err());
1376        assert!(PsfConfig::new(b, 0.0, f64::INFINITY).is_err());
1377    }
1378
1379    #[test]
1380    fn annulus_integral_matches_quadrature() {
1381        let p = GaussianPsf::triple(7.0, 3e-9, 2e-7, 0.7, 4e-6, 0.2).unwrap();
1382        for (a, b) in [(0.0, 1e-9), (2e-9, 9e-9), (1e-7, 5e-7), (3e-6, 1e-5)] {
1383            // Simpson's rule on E f(r) 2πr dr.
1384            let n = 20_000;
1385            let h = (b - a) / n as f64;
1386            let f = |r: f64| p.density_ev_per_m2(r) * 2.0 * PI * r;
1387            let mut s = f(a) + f(b);
1388            for i in 1..n {
1389                s += f(a + i as f64 * h) * if i % 2 == 1 { 4.0 } else { 2.0 };
1390            }
1391            let q = s * h / 3.0;
1392            let c = p.annulus_energy_ev(a, b);
1393            assert!(
1394                (q - c).abs() <= 1e-9 * c.abs().max(1e-300),
1395                "[{a}, {b}): {q} vs {c}"
1396            );
1397        }
1398        // Normalised: the whole plane holds E.
1399        assert!((p.annulus_energy_ev(0.0, 1.0) - 7.0).abs() < 1e-12);
1400        let d = GaussianPsf::double(1.0, 1e-9, 1e-7, 0.5).unwrap();
1401        assert!((d.annulus_energy_ev(0.0, 1.0) - 1.0).abs() < 1e-15);
1402        // f(0) = (1/α² + η/β²) / (π (1 + η)).
1403        let f0 = (1.0 / 1e-18 + 0.5 / 1e-14) / (PI * 1.5);
1404        assert!((d.normalized_per_m2(0.0) - f0).abs() < 1e-12 * f0);
1405    }
1406
1407    #[test]
1408    fn analytic_jacobian_matches_finite_differences() {
1409        for model in [PsfModel::DoubleGaussian, PsfModel::TripleGaussian] {
1410            let p: Vec<f64> = match model {
1411                PsfModel::DoubleGaussian => vec![100.0, 5e-9, 3e-7, 0.6],
1412                PsfModel::TripleGaussian => vec![100.0, 5e-9, 3e-7, 0.6, 4e-6, 0.3],
1413            };
1414            let b = LogRadialBinning::new(1e-10, 1e-4, 30).unwrap();
1415            let e = b.edges();
1416            let data = FitData {
1417                a: e[..e.len() - 1].to_vec(),
1418                b: e[1..].to_vec(),
1419                // y = 0, so the residuals are -μ without cancellation.
1420                y: vec![0.0; e.len() - 1],
1421                sigma: vec![1.0; e.len() - 1],
1422            };
1423            let np = model.parameters();
1424            let (r0, jac) = data.residuals_and_jacobian(model, &p);
1425            for j in 0..np {
1426                let col_max = (0..r0.len()).fold(0.0f64, |m, i| m.max(jac[i * np + j].abs()));
1427                let h = 1e-6 * p[j];
1428                let mut pp = p.clone();
1429                pp[j] += h;
1430                let mut pm = p.clone();
1431                pm[j] -= h;
1432                let (rp, _) = data.residuals_and_jacobian(model, &pp);
1433                let (rm, _) = data.residuals_and_jacobian(model, &pm);
1434                for i in 0..r0.len() {
1435                    let fd = -(rp[i] - rm[i]) / (2.0 * h);
1436                    let an = jac[i * np + j];
1437                    assert!(
1438                        (fd - an).abs() <= 1e-6 * an.abs() + 1e-8 * col_max,
1439                        "{model:?} p{j} bin {i}: {fd} vs {an}"
1440                    );
1441                }
1442            }
1443        }
1444    }
1445
1446    #[test]
1447    fn accumulator_errors_follow_per_history_totals() {
1448        let b = LogRadialBinning::new(1.0, 100.0, 2).unwrap();
1449        let cfg = PsfConfig::new(b, 0.0, 1.0).unwrap();
1450        let mut acc = RadialAccumulator::new(cfg).unwrap();
1451        // Bin 0: r < 1. Bin 1: [1, 10). Bin 2: [10, 100).
1452        let hist: [&[(f64, f64)]; 3] = [
1453            &[(0.5, 2.0), (0.2, 1.0), (5.0, 3.0)],
1454            &[(5.0, 1.0)],
1455            &[(50.0, 4.0), (500.0, 1.0)],
1456        ];
1457        for h in hist {
1458            for &(r, e) in h {
1459                acc.deposit([0.5, r, 0.0], e);
1460            }
1461            // Outside the slab: ignored.
1462            acc.deposit([2.0, 0.0, 0.0], 100.0);
1463            acc.end_history();
1464        }
1465        let p = acc.profile();
1466        assert_eq!(p.histories, 3);
1467        assert_eq!(p.energy_ev, vec![3.0, 4.0, 4.0]);
1468        assert_eq!(p.beyond_ev, 1.0);
1469        assert_eq!(p.total_ev, 12.0);
1470        // Bin 0: per-history totals 3, 0, 0.
1471        let se = |xs: [f64; 3]| {
1472            let m = xs.iter().sum::<f64>() / 3.0;
1473            let v = xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / 2.0;
1474            (3.0 * v).sqrt()
1475        };
1476        assert!((p.std_err_ev[0] - se([3.0, 0.0, 0.0])).abs() < 1e-12);
1477        assert!((p.std_err_ev[1] - se([3.0, 1.0, 0.0])).abs() < 1e-12);
1478        assert!((p.total_std_err_ev - se([6.0, 1.0, 5.0])).abs() < 1e-12);
1479        p.validate().unwrap();
1480        // Merging two halves equals one accumulator.
1481        let mut a = RadialAccumulator::new(cfg).unwrap();
1482        let mut c = RadialAccumulator::new(cfg).unwrap();
1483        for (k, h) in hist.iter().enumerate() {
1484            let t = if k == 0 { &mut a } else { &mut c };
1485            for &(r, e) in *h {
1486                t.deposit([0.5, r, 0.0], e);
1487            }
1488            t.end_history();
1489        }
1490        a.merge(&c);
1491        assert_eq!(a.profile(), acc.profile());
1492    }
1493
1494    #[test]
1495    fn solver_and_inverse() {
1496        let m = vec![4.0, 1.0, 0.0, 1.0, 3.0, 1.0, 0.0, 1.0, 2.0];
1497        let x = solve(m.clone(), vec![1.0, 2.0, 3.0], 3).unwrap();
1498        let r: Vec<f64> = (0..3)
1499            .map(|i| (0..3).map(|k| m[i * 3 + k] * x[k]).sum())
1500            .collect();
1501        for (a, b) in r.iter().zip([1.0, 2.0, 3.0]) {
1502            assert!((a - b).abs() < 1e-12);
1503        }
1504        let inv = invert(m.clone(), 3).unwrap();
1505        for i in 0..3 {
1506            for j in 0..3 {
1507                let s: f64 = (0..3).map(|k| m[i * 3 + k] * inv[k * 3 + j]).sum();
1508                assert!((s - if i == j { 1.0 } else { 0.0 }).abs() < 1e-12);
1509            }
1510        }
1511        assert!(solve(vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 1.0], 2).is_none());
1512    }
1513}