Skip to main content

lindhard/electron/inelastic/
mermin_fit.rs

1//! Fitting Mermin oscillators to an optical energy-loss function (the
2//! MELF-GOS fit).
3//!
4//! # What is fitted
5//!
6//! In the MELF-GOS method the optical ELF is represented by a weighted sum of
7//! Mermin-type energy-loss functions, whose parameters `A_j`, `ħω_j`, `ħγ_j`
8//! (intensity, position, width) are "determined by fitting the outer-shell
9//! ELF contribution to the available experimental optical spectrum" through
10//! the `k = 0` form, in which the Mermin ELF is the Drude-Lorentz one:
11//!
12//! ```text
13//! ELF(E) = Σ_j A_j E ħγ_j / ([(ħω_j)² - E²]² + [E ħγ_j]²)
14//! ```
15//!
16//! (P. de Vera et al., Int. J. Mol. Sci. 23, 6121 (2022),
17//! doi:10.3390/ijms23116121, PMC9181504, section 2.1.1, eqs. (3) and (5),
18//! read 2026-10-07; see [`super::mermin`]). The same paper says the
19//! consistency of the fit "is checked by fulfilling the Kramers-Kronig and
20//! f-sum rules"; this module reports the f-sum and `P_eff` of the fit next to
21//! those of the data ([`MerminFit`]). It does not say how the least-squares
22//! problem is solved, and **the algorithm below is our own**, built from
23//! textbook pieces that were not opened for this implementation:
24//!
25//! * the amplitudes enter linearly, so for fixed `(ω_j, γ_j)` they are the
26//!   solution of a **non-negative least-squares** problem (Lawson-Hanson
27//!   active-set algorithm, C. L. Lawson and R. J. Hanson, *Solving Least
28//!   Squares Problems*, 1974, with each subproblem solved by Householder QR);
29//!   amplitudes are therefore non-negative by construction;
30//! * the energies and widths are found by **Levenberg-Marquardt** iterations on
31//!   `(ln ω_j, ln γ_j)` of the amplitude-eliminated residual (variable
32//!   projection, G. H. Golub and V. Pereyra, 1973), with a central-difference
33//!   Jacobian.
34//!
35//! Every step is a fixed sequence of floating-point operations on the input
36//! table: the fit is deterministic and does not depend on the thread count.
37//!
38//! # Limits
39//!
40//! The fit is a local optimisation. The amplitudes of the start are ignored
41//! (they are solved for); the energies and widths of the start decide which
42//! local minimum is reached. The default start places the oscillators at the
43//! tallest local maxima of the tabulated ELF (completed by log-spaced
44//! energies), which is a heuristic; for real data the caller should inspect
45//! [`MerminFit::residuals`] and, if needed, supply a start. The threshold
46//! step `Θ(E - E_th)` of dV2022 eq. (3) is not fitted.
47//!
48//! The whole tabulated ELF is fitted, inner-shell edges included, where
49//! dV2022 fits Mermin oscillators to the outer-shell ELF only (their eqs. (1)
50//! and (3); inner shells by GOS, eq. (2)) and checks the fit against the
51//! Kramers-Kronig and f-sum rules (after eq. (5)). The default of three
52//! oscillators is coarse for real data: for the committed Cu ELF it carries
53//! 57 % of the table's f-sum and 67 % of its `P_eff`, and the Mermin IMFP is
54//! 18 to 34 % longer than the single-pole one on the table (#300;
55//! `docs/validation.md`, "Cu: the Mermin IMFP and the default oscillator
56//! fit"; the default is #306). The widths are bounded below by the local knot
57//! spacing (see [`fit_mermin_oscillators`]); without that bound, fits of the
58//! Cu ELF with 10 or 16 oscillators placed an oscillator far narrower than
59//! the knot spacing between two knots, which the residuals do not see and the
60//! sum rules do (#307). The floor does not stop the same fits from placing
61//! a very wide oscillator whose f-sum lies above the table (#311). Read
62//! [`MerminFit::f_sum_ev2`] against [`MerminFit::data_f_sum_ev2`] in any
63//! case.
64//!
65//! # Closed-form sum rules of the fit
66//!
67//! For the Drude-Lorentz sum the f-sum is `(π/2) Σ A_j` and
68//! `P_eff = Σ A_j/(ħω_j)²` ([`super::drude`]; the derivation there is ours, by
69//! residues, with a numerical cross-check), against the numerical sums of the
70//! data table over its tabulated range ([`super::sum_rules`]).
71
72// Index loops are the clearest form of the small dense linear algebra below.
73#![allow(clippy::needless_range_loop)]
74
75use super::drude::DrudeLorentzOscillator;
76use super::mermin::check_oscillator;
77use super::sum_rules::SumRuleReport;
78use crate::electron::data::{ElectronDataError, OpticalElf};
79use std::f64::consts::FRAC_PI_2;
80use std::fmt;
81
82type Result<T> = std::result::Result<T, ElectronDataError>;
83
84/// Largest number of oscillators accepted.
85pub const MAX_OSCILLATORS: usize = 16;
86
87/// How the residuals of the tabulated points are weighted in the fit.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum FitWeighting {
90    /// Every tabulated point counts equally (absolute residuals).
91    Uniform,
92    /// Residuals are divided by `ELF_i + floor · max ELF`, so that the
93    /// tails of the ELF (decades below the peak) are not ignored. `floor`
94    /// must be in `(0, 1]`.
95    Relative {
96        /// Fraction of the maximum ELF added to the divisor.
97        floor: f64,
98    },
99}
100
101/// Settings of [`fit_mermin_oscillators`].
102#[derive(Debug, Clone, PartialEq)]
103pub struct MerminFitOptions {
104    /// Number of oscillators, `1..=MAX_OSCILLATORS`. Ignored if `start` is
105    /// given (its length is used).
106    pub n_oscillators: usize,
107    /// Starting energies and widths (amplitudes are ignored), or `None` for
108    /// the default start described in the module docs.
109    pub start: Option<Vec<DrudeLorentzOscillator>>,
110    /// The weighting of the residuals.
111    pub weighting: FitWeighting,
112    /// Maximum number of Levenberg-Marquardt iterations.
113    pub max_iterations: usize,
114}
115
116impl Default for MerminFitOptions {
117    fn default() -> Self {
118        Self {
119            n_oscillators: 3,
120            start: None,
121            weighting: FitWeighting::Relative { floor: 1e-2 },
122            max_iterations: 500,
123        }
124    }
125}
126
127/// The result of a fit: parameters, residuals and the sum rules. See the
128/// module docs.
129#[derive(Debug, Clone, PartialEq)]
130pub struct MerminFit {
131    /// The fitted oscillators (`strength_ev2` is the amplitude `A_j`, eV²,
132    /// `>= 0`; `energy_ev` is `ħω_j`; `width_ev` is `ħγ_j`), sorted by energy.
133    pub oscillators: Vec<DrudeLorentzOscillator>,
134    /// The tabulated energies, eV.
135    pub energy_ev: Vec<f64>,
136    /// The tabulated ELF values.
137    pub data: Vec<f64>,
138    /// The fitted ELF at the tabulated energies.
139    pub model: Vec<f64>,
140    /// `model - data` at the tabulated energies.
141    pub residuals: Vec<f64>,
142    /// The weighting that was minimised.
143    pub weighting: FitWeighting,
144    /// Root-mean-square of the weighted residuals (the minimised quantity).
145    pub weighted_rms: f64,
146    /// Root-mean-square of `model - data`.
147    pub rms_residual: f64,
148    /// Largest `|model - data|`.
149    pub max_abs_residual: f64,
150    /// Levenberg-Marquardt iterations used.
151    pub iterations: usize,
152    /// Whether a convergence criterion was met before `max_iterations`.
153    pub converged: bool,
154    /// `(π/2) Σ A_j`, eV²: the f-sum `∫ E ELF dE` of the fit over `(0, ∞)`.
155    pub f_sum_ev2: f64,
156    /// `Σ A_j/(ħω_j)²`: `P_eff` of the fit over `(0, ∞)` (no `n(0)⁻²` term).
157    pub p_eff: f64,
158    /// `∫ E ELF dE` of the data over its tabulated range, eV².
159    pub data_f_sum_ev2: f64,
160    /// `P_eff` of the data over its tabulated range.
161    pub data_p_eff: f64,
162}
163
164fn drude_basis(w: f64, e: f64, g: f64) -> f64 {
165    let d = w * w - e * e;
166    g * w / (d * d + g * g * w * w)
167}
168
169impl MerminFit {
170    /// The fitted ELF at `energy_ev` (the Drude-Lorentz sum of the
171    /// oscillators; zero for `energy_ev <= 0`).
172    pub fn model_elf(&self, energy_ev: f64) -> f64 {
173        if energy_ev <= 0.0 {
174            return 0.0;
175        }
176        self.oscillators
177            .iter()
178            .map(|o| o.strength_ev2 * drude_basis(energy_ev, o.energy_ev, o.width_ev))
179            .sum()
180    }
181
182    /// The plasma energy of the fit, `sqrt(Σ A_j)`, eV.
183    pub fn plasma_energy_ev(&self) -> f64 {
184        self.oscillators
185            .iter()
186            .map(|o| o.strength_ev2)
187            .sum::<f64>()
188            .sqrt()
189    }
190
191    /// One line to record in metadata: the number of oscillators and their
192    /// parameters.
193    pub fn summary(&self) -> String {
194        let osc: Vec<String> = self
195            .oscillators
196            .iter()
197            .map(|o| {
198                format!(
199                    "(A={:.6e} eV^2, E={:.6e} eV, gamma={:.6e} eV)",
200                    o.strength_ev2, o.energy_ev, o.width_ev
201                )
202            })
203            .collect();
204        format!("{} oscillators {}", self.oscillators.len(), osc.join(" "))
205    }
206}
207
208impl fmt::Display for MerminFit {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        writeln!(
211            f,
212            "Mermin-ELF fit: {} oscillators, {} iterations, converged: {}",
213            self.oscillators.len(),
214            self.iterations,
215            self.converged
216        )?;
217        for (j, o) in self.oscillators.iter().enumerate() {
218            writeln!(
219                f,
220                "  {j}: A = {:.6e} eV^2, E = {:.6e} eV, gamma = {:.6e} eV",
221                o.strength_ev2, o.energy_ev, o.width_ev
222            )?;
223        }
224        writeln!(
225            f,
226            "  residual rms {:.3e} (weighted {:.3e}), max |r| {:.3e}",
227            self.rms_residual, self.weighted_rms, self.max_abs_residual
228        )?;
229        writeln!(
230            f,
231            "  f-sum: fit {:.6e} eV^2, data {:.6e} eV^2 (ratio {:.6})",
232            self.f_sum_ev2,
233            self.data_f_sum_ev2,
234            self.f_sum_ev2 / self.data_f_sum_ev2
235        )?;
236        write!(
237            f,
238            "  P_eff: fit {:.6}, data {:.6}",
239            self.p_eff, self.data_p_eff
240        )
241    }
242}
243
244// ---- small dense linear algebra ----
245
246/// Least squares `min |A x - y|` for the columns `cols` (each of length
247/// `m`), by Householder QR. Assumes full column rank; returns `None` when a
248/// column is numerically dependent.
249fn lstsq(cols: &[&[f64]], y: &[f64]) -> Option<Vec<f64>> {
250    let n = cols.len();
251    let m = y.len();
252    if n == 0 {
253        return Some(Vec::new());
254    }
255    if n > m {
256        return None;
257    }
258    let mut a: Vec<Vec<f64>> = cols.iter().map(|c| c.to_vec()).collect();
259    let mut b = y.to_vec();
260    let mut diag = vec![0.0; n];
261    for k in 0..n {
262        let norm = a[k][k..].iter().map(|x| x * x).sum::<f64>().sqrt();
263        if norm < 1e-13 {
264            return None;
265        }
266        let alpha = if a[k][k] > 0.0 { -norm } else { norm };
267        let mut v: Vec<f64> = a[k][k..].to_vec();
268        v[0] -= alpha;
269        let vnorm2: f64 = v.iter().map(|x| x * x).sum();
270        diag[k] = alpha;
271        if vnorm2 > 0.0 {
272            for j in (k + 1)..n {
273                let dot: f64 = v.iter().zip(&a[j][k..]).map(|(p, q)| p * q).sum();
274                let s = 2.0 * dot / vnorm2;
275                for (i, vi) in v.iter().enumerate() {
276                    a[j][k + i] -= s * vi;
277                }
278            }
279            let dot: f64 = v.iter().zip(&b[k..]).map(|(p, q)| p * q).sum();
280            let s = 2.0 * dot / vnorm2;
281            for (i, vi) in v.iter().enumerate() {
282                b[k + i] -= s * vi;
283            }
284        }
285    }
286    // back substitution with R (upper triangle: R[k][j] = a[j][k], diag[k])
287    let mut x = vec![0.0; n];
288    for k in (0..n).rev() {
289        let mut s = b[k];
290        for j in (k + 1)..n {
291            s -= a[j][k] * x[j];
292        }
293        x[k] = s / diag[k];
294    }
295    x.iter().all(|v| v.is_finite()).then_some(x)
296}
297
298/// Non-negative least squares, `min |A x - y|` with `x >= 0`: the
299/// Lawson-Hanson active-set algorithm. The columns should be scaled to unit
300/// norm.
301fn nnls(cols: &[Vec<f64>], y: &[f64]) -> Vec<f64> {
302    let n = cols.len();
303    let mut x = vec![0.0; n];
304    let mut passive = vec![false; n];
305    let ynorm = y.iter().map(|v| v * v).sum::<f64>().sqrt();
306    let tol = 1e-13 * ynorm.max(f64::MIN_POSITIVE);
307    let gradient = |x: &[f64]| -> Vec<f64> {
308        let mut r = y.to_vec();
309        for (c, xc) in cols.iter().zip(x) {
310            if *xc != 0.0 {
311                for (ri, ci) in r.iter_mut().zip(c) {
312                    *ri -= xc * ci;
313                }
314            }
315        }
316        cols.iter()
317            .map(|c| c.iter().zip(&r).map(|(p, q)| p * q).sum::<f64>())
318            .collect()
319    };
320    for _ in 0..(3 * n + 3) {
321        let w = gradient(&x);
322        let Some((t, wt)) = (0..n)
323            .filter(|&j| !passive[j])
324            .map(|j| (j, w[j]))
325            .max_by(|a, b| a.1.total_cmp(&b.1))
326        else {
327            break;
328        };
329        if wt <= tol {
330            break;
331        }
332        passive[t] = true;
333        for _ in 0..(3 * n + 3) {
334            let idx: Vec<usize> = (0..n).filter(|&j| passive[j]).collect();
335            let sub: Vec<&[f64]> = idx.iter().map(|&j| cols[j].as_slice()).collect();
336            let Some(s) = lstsq(&sub, y) else {
337                // dependent column: drop the one just added and stop
338                passive[t] = false;
339                return x;
340            };
341            if s.iter().all(|&v| v > 0.0) {
342                x = vec![0.0; n];
343                for (&j, &v) in idx.iter().zip(&s) {
344                    x[j] = v;
345                }
346                break;
347            }
348            let mut alpha = f64::INFINITY;
349            for (&j, &sj) in idx.iter().zip(&s) {
350                if sj <= 0.0 {
351                    let a = x[j] / (x[j] - sj);
352                    if a < alpha {
353                        alpha = a;
354                    }
355                }
356            }
357            for (&j, &sj) in idx.iter().zip(&s) {
358                x[j] += alpha * (sj - x[j]);
359            }
360            for &j in &idx {
361                if x[j] <= 1e-14 {
362                    x[j] = 0.0;
363                    passive[j] = false;
364                }
365            }
366        }
367    }
368    x
369}
370
371/// Solve the small symmetric positive system `M x = r` by Gaussian
372/// elimination with partial pivoting.
373fn solve(mut m: Vec<Vec<f64>>, mut r: Vec<f64>) -> Option<Vec<f64>> {
374    let n = r.len();
375    for k in 0..n {
376        let p = (k..n).max_by(|&a, &b| m[a][k].abs().total_cmp(&m[b][k].abs()))?;
377        if m[p][k].abs() < 1e-300 {
378            return None;
379        }
380        m.swap(k, p);
381        r.swap(k, p);
382        for i in (k + 1)..n {
383            let f = m[i][k] / m[k][k];
384            for j in k..n {
385                m[i][j] -= f * m[k][j];
386            }
387            r[i] -= f * r[k];
388        }
389    }
390    let mut x = vec![0.0; n];
391    for k in (0..n).rev() {
392        let mut s = r[k];
393        for j in (k + 1)..n {
394            s -= m[k][j] * x[j];
395        }
396        x[k] = s / m[k][k];
397    }
398    x.iter().all(|v| v.is_finite()).then_some(x)
399}
400
401// ---- the fit ----
402
403/// The local knot spacing of the sorted grid `w` at the energy `e`, eV: the
404/// larger of the two gaps next to the knot nearest to `e` (the single gap of
405/// an end knot; ties go to the lower knot). The lower bound of the fitted
406/// widths (see [`fit_mermin_oscillators`]); a pure function of the knots.
407fn local_knot_spacing(w: &[f64], e: f64) -> f64 {
408    let n = w.len();
409    if n < 2 {
410        return 0.0;
411    }
412    let k = match w.binary_search_by(|x| x.total_cmp(&e)) {
413        Ok(k) => k,
414        Err(0) => 0,
415        Err(i) if i >= n => n - 1,
416        Err(i) => {
417            if e - w[i - 1] <= w[i] - e {
418                i - 1
419            } else {
420                i
421            }
422        }
423    };
424    let left = if k > 0 { w[k] - w[k - 1] } else { 0.0 };
425    let right = if k + 1 < n { w[k + 1] - w[k] } else { 0.0 };
426    left.max(right)
427}
428
429struct Problem<'a> {
430    w: &'a [f64],
431    y: Vec<f64>,
432    row_weight: Vec<f64>,
433}
434
435impl Problem<'_> {
436    /// The energy and width (eV) of the parameters `(ln E, ln γ)`, with the
437    /// width raised to the local knot spacing at `E` if it is below it (the
438    /// width floor of [`fit_mermin_oscillators`]). Above the floor the width
439    /// is `exp(ln γ)` unchanged, bit for bit.
440    fn energy_width(&self, ln_e: f64, ln_g: f64) -> (f64, f64) {
441        let e = ln_e.exp();
442        (e, ln_g.exp().max(local_knot_spacing(self.w, e)))
443    }
444
445    /// Project the stored width parameters `ln γ_j` of `theta` onto the width
446    /// floor: raise each one that is below `ln` of the local knot spacing at
447    /// its `E_j` up to it. Returns whether any parameter changed (if none is
448    /// below the floor, `theta` is untouched, bit for bit). Without this, a
449    /// `ln γ_j` below the floor would not enter the residual (see
450    /// [`Self::energy_width`]), its Jacobian column would be zero and the
451    /// fit could never move it again.
452    fn raise_widths_to_floor(&self, theta: &mut [f64]) -> bool {
453        let mut changed = false;
454        for j in 0..theta.len() / 2 {
455            let ln_floor = local_knot_spacing(self.w, theta[2 * j].exp()).ln();
456            if theta[2 * j + 1] < ln_floor {
457                theta[2 * j + 1] = ln_floor;
458                changed = true;
459            }
460        }
461        changed
462    }
463
464    /// The amplitudes `A` (eV², `>= 0`) and the weighted residual vector for
465    /// the nonlinear parameters `theta = (ln E_j, ln γ_j)`.
466    fn project(&self, theta: &[f64]) -> (Vec<f64>, Vec<f64>) {
467        let n = theta.len() / 2;
468        let yw: Vec<f64> = self
469            .y
470            .iter()
471            .zip(&self.row_weight)
472            .map(|(y, s)| y * s)
473            .collect();
474        let mut cols = Vec::with_capacity(n);
475        let mut norms = Vec::with_capacity(n);
476        for j in 0..n {
477            let (e, g) = self.energy_width(theta[2 * j], theta[2 * j + 1]);
478            let mut c: Vec<f64> = self
479                .w
480                .iter()
481                .zip(&self.row_weight)
482                .map(|(&w, &s)| s * drude_basis(w, e, g))
483                .collect();
484            let norm = c.iter().map(|v| v * v).sum::<f64>().sqrt();
485            let norm = if norm > 0.0 && norm.is_finite() {
486                norm
487            } else {
488                1.0
489            };
490            for v in &mut c {
491                *v /= norm;
492            }
493            cols.push(c);
494            norms.push(norm);
495        }
496        let coef = nnls(&cols, &yw);
497        let mut r: Vec<f64> = yw.iter().map(|v| -v).collect();
498        for (c, k) in cols.iter().zip(&coef) {
499            for (ri, ci) in r.iter_mut().zip(c) {
500                *ri += k * ci;
501            }
502        }
503        let amps = coef.iter().zip(&norms).map(|(k, n)| k / n).collect();
504        (amps, r)
505    }
506}
507
508fn default_start(elf: &OpticalElf, n: usize) -> Vec<DrudeLorentzOscillator> {
509    let w = elf.energy_ev();
510    let e = elf.elf_values();
511    let mut peaks: Vec<(f64, f64)> = Vec::new();
512    for i in 0..w.len() {
513        let left = i == 0 || e[i] > e[i - 1];
514        let right = i + 1 == w.len() || e[i] >= e[i + 1];
515        if left && right && e[i] > 0.0 {
516            peaks.push((e[i], w[i]));
517        }
518    }
519    peaks.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1)));
520    let mut energies: Vec<f64> = peaks.iter().take(n).map(|p| p.1).collect();
521    // complete with log-spaced energies
522    let (lo, hi) = (w[0].ln(), w[w.len() - 1].ln());
523    let mut k = 1usize;
524    while energies.len() < n {
525        let cand = (lo + (hi - lo) * k as f64 / (n as f64 + 1.0)).exp();
526        if !energies.iter().any(|&x| (x / cand).ln().abs() < 0.05) {
527            energies.push(cand);
528        }
529        k += 1;
530        if k > 10 * n + 10 {
531            energies.push(cand * (1.0 + 0.1 * k as f64));
532        }
533    }
534    energies.sort_by(f64::total_cmp);
535    energies
536        .into_iter()
537        .map(|e| DrudeLorentzOscillator {
538            strength_ev2: 0.0,
539            energy_ev: e,
540            width_ev: 0.25 * e,
541        })
542        .collect()
543}
544
545/// Fit a sum of Mermin oscillators to `elf` (see the module docs).
546///
547/// # Width floor
548///
549/// Each width `ħγ_j` is bounded below by the local knot spacing of the table
550/// at `ħω_j`: the larger of the two gaps next to the tabulated energy nearest
551/// to `ħω_j` (the single gap of an end knot). The stored parameter `ln γ_j`
552/// is projected onto the bound (raised to `ln` of the floor) at the start and
553/// after every accepted step, so a floored width keeps a nonzero Jacobian
554/// column and can move off the floor when the data want it wider. The floor
555/// is also applied inside every residual evaluation (trial and Jacobian
556/// points) and to the reported oscillators, so all three agree. A fit in
557/// which no width ever falls below the floor is unchanged bit for bit; of
558/// the committed ELFs at the default options only C is affected (its start
559/// width 0.05 eV at the 0.2 eV peak is raised to the 0.1 eV knot spacing,
560/// and the fit ends within about 1e-7 of the unbounded one, in 146
561/// iterations instead of 140). The reason: the residuals are evaluated at
562/// the knots only, and a
563/// Lorentzian narrower than the gap it sits in can fall between two knots,
564/// where no residual constrains it while its amplitude, and with it the
565/// f-sum `(π/2) A_j`, is free (#307: the 10- and 16-oscillator fits of the
566/// committed Cu ELF reached 6771 and 16467 times the table's f-sum this way).
567/// Such a width is not determined by the tabulated values. **This bound is
568/// our own choice**: de Vera et al. (2022), section 2.1.1, give no fitting
569/// algorithm, only the Kramers-Kronig and f-sum consistency check, which
570/// [`MerminFit`] reports. The floor is a pure function of the knots and adds
571/// no dependence on the thread count.
572///
573/// The floor is a step function of `ħω_j`: it jumps where the nearest knot
574/// changes, halfway between two knots. For a width at the floor the residual
575/// is therefore discontinuous in `ln ħω_j` there, and a central difference
576/// straddling the jump gives a large Jacobian entry. The fit accepts only
577/// strict decreases of the cost, so it does not move uphill across a jump,
578/// but it can stop at one: if no damping gives a decrease, it reports
579/// `converged = true` at a point that is a minimum only on one side of the
580/// jump. Likewise, at a width on the floor where the data want a narrower
581/// line, `converged = true` means a minimum subject to the bound.
582///
583/// The floor does not bound the f-sum against very wide oscillators: with
584/// 10 or 16 oscillators the committed Cu ELF still gets one of width
585/// 4e5 to 8e5 eV whose weight lies above the table, where the default
586/// relative weighting barely sees it (f-sum 188 and 106 times the table's,
587/// unconverged). The weighting does not reliably prevent it (#311; table in
588/// `docs/validation.md`, "Cu: the Mermin IMFP and the default oscillator
589/// fit"): at 10 oscillators `relative` floors of 0.1 and 1 avoid it
590/// (f-sum 0.79 and 0.87 of the table's), [`FitWeighting::Uniform`] makes it
591/// worse (4e4 times); at 16 oscillators none of the floors 1e-3 to 1 nor
592/// uniform weighting avoids it, and two of those fits report
593/// `converged = true`. Every such oscillator found is wider than the table's
594/// energy range. Check [`MerminFit::f_sum_ev2`] and [`MerminFit::p_eff`]
595/// against the table's whatever the options.
596pub fn fit_mermin_oscillators(elf: &OpticalElf, options: &MerminFitOptions) -> Result<MerminFit> {
597    let w = elf.energy_ev();
598    let y = elf.elf_values();
599    let start = match &options.start {
600        Some(s) => s.clone(),
601        None => {
602            if !(1..=MAX_OSCILLATORS).contains(&options.n_oscillators) {
603                return Err(ElectronDataError::Invalid {
604                    what: "Mermin fit",
605                    reason: format!(
606                        "number of oscillators must be in 1..={MAX_OSCILLATORS}, got {}",
607                        options.n_oscillators
608                    ),
609                });
610            }
611            default_start(elf, options.n_oscillators)
612        }
613    };
614    let n = start.len();
615    if !(1..=MAX_OSCILLATORS).contains(&n) {
616        return Err(ElectronDataError::Invalid {
617            what: "Mermin fit",
618            reason: format!("number of oscillators must be in 1..={MAX_OSCILLATORS}, got {n}"),
619        });
620    }
621    for o in &start {
622        let mut o = *o;
623        o.strength_ev2 = 0.0; // amplitudes of the start are not used
624        check_oscillator(&o)?;
625    }
626    if w.len() < 2 * n + 1 {
627        return Err(ElectronDataError::Invalid {
628            what: "Mermin fit",
629            reason: format!(
630                "{} tabulated points cannot determine {n} oscillators (need at least {})",
631                w.len(),
632                2 * n + 1
633            ),
634        });
635    }
636    let ymax = y.iter().cloned().fold(0.0_f64, f64::max);
637    if ymax.is_nan() || ymax <= 0.0 {
638        return Err(ElectronDataError::Invalid {
639            what: "Mermin fit",
640            reason: "the ELF is zero everywhere".into(),
641        });
642    }
643    let row_weight: Vec<f64> = match options.weighting {
644        FitWeighting::Uniform => vec![1.0; w.len()],
645        FitWeighting::Relative { floor } => {
646            if !(floor > 0.0 && floor <= 1.0) {
647                return Err(ElectronDataError::Invalid {
648                    what: "Mermin fit weighting",
649                    reason: format!("relative floor must be in (0, 1], got {floor}"),
650                });
651            }
652            y.iter().map(|v| 1.0 / (v + floor * ymax)).collect()
653        }
654    };
655    let problem = Problem {
656        w,
657        y: y.to_vec(),
658        row_weight,
659    };
660
661    let mut theta: Vec<f64> = start
662        .iter()
663        .flat_map(|o| [o.energy_ev.ln(), o.width_ev.ln()])
664        .collect();
665    // the width floor is enforced on the stored parameters (a projection),
666    // at the start and after every accepted step
667    problem.raise_widths_to_floor(&mut theta);
668    let np = theta.len();
669    let (mut amps, mut r) = problem.project(&theta);
670    let mut cost = 0.5 * r.iter().map(|v| v * v).sum::<f64>();
671    let scale = 0.5
672        * problem
673            .y
674            .iter()
675            .zip(&problem.row_weight)
676            .map(|(y, s)| (y * s).powi(2))
677            .sum::<f64>();
678    let mut lambda = 1e-3;
679    let mut iterations = 0;
680    let mut converged = false;
681    let h = 1e-6;
682
683    while iterations < options.max_iterations {
684        if cost <= 1e-28 * scale {
685            converged = true;
686            break;
687        }
688        iterations += 1;
689        // central-difference Jacobian of the projected residual
690        let mut jac: Vec<Vec<f64>> = Vec::with_capacity(np); // column-major
691        for p in 0..np {
692            let mut tp = theta.clone();
693            tp[p] += h;
694            let (_, rp) = problem.project(&tp);
695            tp[p] -= 2.0 * h;
696            let (_, rm) = problem.project(&tp);
697            jac.push(
698                rp.iter()
699                    .zip(&rm)
700                    .map(|(a, b)| (a - b) / (2.0 * h))
701                    .collect(),
702            );
703        }
704        let jtj: Vec<Vec<f64>> = (0..np)
705            .map(|a| {
706                (0..np)
707                    .map(|b| jac[a].iter().zip(&jac[b]).map(|(p, q)| p * q).sum())
708                    .collect()
709            })
710            .collect();
711        let g: Vec<f64> = (0..np)
712            .map(|a| jac[a].iter().zip(&r).map(|(p, q)| p * q).sum())
713            .collect();
714        let gmax = g.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
715        if gmax <= 1e-14 * scale.sqrt().max(f64::MIN_POSITIVE) {
716            converged = true;
717            break;
718        }
719        // inner loop: increase damping until the step reduces the cost
720        let mut accepted = false;
721        for _ in 0..40 {
722            let mut m = jtj.clone();
723            for (a, row) in m.iter_mut().enumerate() {
724                row[a] += lambda * jtj[a][a].max(1e-12 * gmax.max(1e-300));
725            }
726            let rhs: Vec<f64> = g.iter().map(|v| -v).collect();
727            if let Some(mut delta) = solve(m, rhs) {
728                for d in &mut delta {
729                    *d = d.clamp(-1.0, 1.0);
730                }
731                let trial: Vec<f64> = theta.iter().zip(&delta).map(|(t, d)| t + d).collect();
732                let (a2, r2) = problem.project(&trial);
733                let c2 = 0.5 * r2.iter().map(|v| v * v).sum::<f64>();
734                if c2.is_finite() && c2 < cost {
735                    let step = delta.iter().fold(0.0_f64, |m, d| m.max(d.abs()));
736                    let drop = (cost - c2) / cost;
737                    theta = trial;
738                    amps = a2;
739                    r = r2;
740                    cost = c2;
741                    if problem.raise_widths_to_floor(&mut theta) {
742                        // the residual at the projected point (equal to the
743                        // trial's up to rounding: the trial already used the
744                        // floor for these widths)
745                        (amps, r) = problem.project(&theta);
746                        cost = 0.5 * r.iter().map(|v| v * v).sum::<f64>();
747                    }
748                    lambda = (lambda * 0.1).max(1e-15);
749                    accepted = true;
750                    if step < 1e-13 || drop < 1e-15 {
751                        converged = true;
752                    }
753                    break;
754                }
755            }
756            lambda *= 10.0;
757        }
758        if !accepted {
759            // no downhill step at any damping: at a (local) minimum
760            converged = true;
761            break;
762        }
763        if converged {
764            break;
765        }
766    }
767
768    let mut oscillators: Vec<DrudeLorentzOscillator> = (0..n)
769        .map(|j| {
770            let (energy_ev, width_ev) = problem.energy_width(theta[2 * j], theta[2 * j + 1]);
771            DrudeLorentzOscillator {
772                strength_ev2: amps[j],
773                energy_ev,
774                width_ev,
775            }
776        })
777        .collect();
778    oscillators.sort_by(|a, b| {
779        a.energy_ev
780            .total_cmp(&b.energy_ev)
781            .then(a.width_ev.total_cmp(&b.width_ev))
782    });
783
784    let mut fit = MerminFit {
785        oscillators,
786        energy_ev: w.to_vec(),
787        data: y.to_vec(),
788        model: Vec::new(),
789        residuals: Vec::new(),
790        weighting: options.weighting,
791        weighted_rms: (r.iter().map(|v| v * v).sum::<f64>() / r.len() as f64).sqrt(),
792        rms_residual: 0.0,
793        max_abs_residual: 0.0,
794        iterations,
795        converged,
796        f_sum_ev2: 0.0,
797        p_eff: 0.0,
798        data_f_sum_ev2: 0.0,
799        data_p_eff: 0.0,
800    };
801    fit.model = w.iter().map(|&e| fit.model_elf(e)).collect();
802    fit.residuals = fit.model.iter().zip(y).map(|(m, d)| m - d).collect();
803    fit.rms_residual = (fit.residuals.iter().map(|v| v * v).sum::<f64>() / w.len() as f64).sqrt();
804    fit.max_abs_residual = fit.residuals.iter().fold(0.0_f64, |m, v| m.max(v.abs()));
805    fit.f_sum_ev2 = FRAC_PI_2 * fit.oscillators.iter().map(|o| o.strength_ev2).sum::<f64>();
806    fit.p_eff = fit
807        .oscillators
808        .iter()
809        .map(|o| o.strength_ev2 / (o.energy_ev * o.energy_ev))
810        .sum();
811    let rules = SumRuleReport::new(elf);
812    fit.data_f_sum_ev2 = rules.f_sum_ev2;
813    fit.data_p_eff = rules.p_eff;
814    Ok(fit)
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820    use crate::electron::inelastic::drude::DrudeLorentz;
821
822    fn osc(a: f64, e: f64, g: f64) -> DrudeLorentzOscillator {
823        DrudeLorentzOscillator {
824            strength_ev2: a,
825            energy_ev: e,
826            width_ev: g,
827        }
828    }
829
830    #[test]
831    fn lstsq_solves_an_overdetermined_system() {
832        let c0 = [1.0, 1.0, 1.0, 1.0];
833        let c1 = [0.0, 1.0, 2.0, 3.0];
834        let y = [1.0, 3.0, 5.0, 7.0]; // 1 + 2x exactly
835        let x = lstsq(&[&c0, &c1], &y).unwrap();
836        assert!((x[0] - 1.0).abs() < 1e-12 && (x[1] - 2.0).abs() < 1e-12);
837    }
838
839    #[test]
840    fn nnls_clips_negative_coefficients() {
841        let c0 = vec![1.0, 0.0, 0.0];
842        let c1 = vec![0.0, 1.0, 0.0];
843        // y = 2 c0 - 3 c1: unconstrained solution has a negative coefficient
844        let x = nnls(&[c0, c1], &[2.0, -3.0, 0.0]);
845        assert!((x[0] - 2.0).abs() < 1e-12);
846        assert_eq!(x[1], 0.0);
847    }
848
849    #[test]
850    fn nnls_matches_least_squares_when_the_solution_is_positive() {
851        let c0 = vec![1.0, 1.0, 0.5, 0.0];
852        let c1 = vec![0.0, 1.0, 1.0, 1.0];
853        let y: Vec<f64> = c0.iter().zip(&c1).map(|(a, b)| 1.5 * a + 0.7 * b).collect();
854        let x = nnls(&[c0, c1], &y);
855        assert!((x[0] - 1.5).abs() < 1e-12 && (x[1] - 0.7).abs() < 1e-12);
856    }
857
858    fn synthetic() -> (Vec<DrudeLorentzOscillator>, OpticalElf) {
859        let truth = vec![
860            osc(120.0, 9.0, 3.0),
861            osc(400.0, 22.0, 7.0),
862            osc(900.0, 55.0, 25.0),
863        ];
864        let elf = DrudeLorentz::new(truth.clone())
865            .unwrap()
866            .to_optical_elf("synthetic", 0.5, 2000.0, 400)
867            .unwrap();
868        (truth, elf)
869    }
870
871    #[test]
872    fn recovers_three_known_oscillators_from_a_perturbed_start() {
873        let (truth, elf) = synthetic();
874        let start = vec![
875            osc(1.0, 9.0 * 1.15, 3.0 * 0.8),
876            osc(1.0, 22.0 * 0.88, 7.0 * 1.2),
877            osc(1.0, 55.0 * 1.1, 25.0 * 0.85),
878        ];
879        let fit = fit_mermin_oscillators(
880            &elf,
881            &MerminFitOptions {
882                start: Some(start),
883                ..Default::default()
884            },
885        )
886        .unwrap();
887        assert!(fit.converged, "{fit}");
888        assert_eq!(fit.oscillators.len(), 3);
889        for (got, want) in fit.oscillators.iter().zip(&truth) {
890            for (name, g, w) in [
891                ("A", got.strength_ev2, want.strength_ev2),
892                ("E", got.energy_ev, want.energy_ev),
893                ("gamma", got.width_ev, want.width_ev),
894            ] {
895                assert!(((g - w) / w).abs() < 1e-3, "{name}: {g} vs {w}\n{fit}");
896            }
897            assert!(got.strength_ev2 >= 0.0);
898        }
899        // the sum rules of the fit are the closed forms of the truth
900        let want = DrudeLorentz::new(truth).unwrap();
901        assert!(((fit.f_sum_ev2 - want.f_sum_ev2()) / want.f_sum_ev2()).abs() < 1e-3);
902        assert!(((fit.p_eff - want.p_eff()) / want.p_eff()).abs() < 1e-3);
903        assert!(fit.max_abs_residual < 1e-6, "{fit}");
904        assert_eq!(fit.residuals.len(), elf.energy_ev().len());
905    }
906
907    #[test]
908    fn default_start_also_recovers_the_synthetic_elf() {
909        let (truth, elf) = synthetic();
910        let fit = fit_mermin_oscillators(&elf, &MerminFitOptions::default()).unwrap();
911        assert!(fit.rms_residual < 1e-4, "{fit}");
912        let want = DrudeLorentz::new(truth).unwrap();
913        assert!(((fit.f_sum_ev2 - want.f_sum_ev2()) / want.f_sum_ev2()).abs() < 1e-2);
914    }
915
916    #[test]
917    fn amplitudes_stay_non_negative_for_a_surplus_of_oscillators() {
918        let (_, elf) = synthetic();
919        let fit = fit_mermin_oscillators(
920            &elf,
921            &MerminFitOptions {
922                n_oscillators: 5,
923                ..Default::default()
924            },
925        )
926        .unwrap();
927        assert!(fit.oscillators.iter().all(|o| o.strength_ev2 >= 0.0));
928        assert!(fit.rms_residual < 1e-3, "{fit}");
929    }
930
931    #[test]
932    fn fit_is_bitwise_deterministic_across_threads() {
933        let (_, elf) = synthetic();
934        let opts = MerminFitOptions::default();
935        let base = fit_mermin_oscillators(&elf, &opts).unwrap();
936        let handles: Vec<_> = (0..4)
937            .map(|_| {
938                let (elf, opts) = (elf.clone(), opts.clone());
939                std::thread::spawn(move || fit_mermin_oscillators(&elf, &opts).unwrap())
940            })
941            .collect();
942        for h in handles {
943            let other = h.join().unwrap();
944            for (a, b) in base.oscillators.iter().zip(&other.oscillators) {
945                assert_eq!(a.strength_ev2.to_bits(), b.strength_ev2.to_bits());
946                assert_eq!(a.energy_ev.to_bits(), b.energy_ev.to_bits());
947                assert_eq!(a.width_ev.to_bits(), b.width_ev.to_bits());
948            }
949            assert_eq!(base.iterations, other.iterations);
950        }
951    }
952
953    #[test]
954    fn invalid_options_are_rejected() {
955        let (_, elf) = synthetic();
956        for n in [0, MAX_OSCILLATORS + 1] {
957            assert!(fit_mermin_oscillators(
958                &elf,
959                &MerminFitOptions {
960                    n_oscillators: n,
961                    ..Default::default()
962                }
963            )
964            .is_err());
965        }
966        assert!(fit_mermin_oscillators(
967            &elf,
968            &MerminFitOptions {
969                weighting: FitWeighting::Relative { floor: 0.0 },
970                ..Default::default()
971            }
972        )
973        .is_err());
974        assert!(fit_mermin_oscillators(
975            &elf,
976            &MerminFitOptions {
977                start: Some(vec![osc(1.0, -1.0, 1.0)]),
978                ..Default::default()
979            }
980        )
981        .is_err());
982    }
983
984    #[test]
985    fn local_knot_spacing_is_the_larger_gap_next_to_the_nearest_knot() {
986        // non-uniform grid: gaps 1, 2, 4, 8
987        let w = [1.0, 2.0, 4.0, 8.0, 16.0];
988        // interior knot and points nearest to it: max(2, 4)
989        assert_eq!(local_knot_spacing(&w, 4.0), 4.0);
990        assert_eq!(local_knot_spacing(&w, 3.5), 4.0);
991        assert_eq!(local_knot_spacing(&w, 5.9), 4.0);
992        // nearer to 8: max(4, 8)
993        assert_eq!(local_knot_spacing(&w, 6.1), 8.0);
994        // a tie goes to the lower knot (2: max(1, 2))
995        assert_eq!(local_knot_spacing(&w, 3.0), 2.0);
996        // end knots use their single gap, also outside the table
997        assert_eq!(local_knot_spacing(&w, 1.0), 1.0);
998        assert_eq!(local_knot_spacing(&w, 0.1), 1.0);
999        assert_eq!(local_knot_spacing(&w, 16.0), 8.0);
1000        assert_eq!(local_knot_spacing(&w, 1e6), 8.0);
1001    }
1002
1003    #[test]
1004    fn widths_are_not_fitted_below_the_knot_spacing() {
1005        // a start far narrower than the grid: the reported widths are at
1006        // least the local knot spacing
1007        let (_, elf) = synthetic();
1008        let w = elf.energy_ev();
1009        let start = vec![osc(1.0, 9.0, 1e-4), osc(1.0, 55.0, 1e-4)];
1010        let fit = fit_mermin_oscillators(
1011            &elf,
1012            &MerminFitOptions {
1013                start: Some(start),
1014                max_iterations: 5,
1015                ..Default::default()
1016            },
1017        )
1018        .unwrap();
1019        for o in &fit.oscillators {
1020            assert!(o.width_ev >= local_knot_spacing(w, o.energy_ev), "{fit}");
1021        }
1022    }
1023
1024    #[test]
1025    fn data_sum_rules_are_reported() {
1026        let (truth, elf) = synthetic();
1027        let fit = fit_mermin_oscillators(&elf, &MerminFitOptions::default()).unwrap();
1028        let want = DrudeLorentz::new(truth).unwrap();
1029        // the table ends at 2 keV, so the data's sums are a little short
1030        assert!(fit.data_f_sum_ev2 > 0.9 * want.f_sum_ev2());
1031        assert!(fit.data_p_eff > 0.9 * want.p_eff());
1032        assert!(fit.summary().starts_with("3 oscillators"));
1033    }
1034}