Skip to main content

spectrafit_models/
pseudo_voigt.rs

1use crate::Model;
2
3/// Proper Pseudo-Voigt: `η · L(x) + (1 − η) · G(x)`
4///
5/// where η ∈ [0,1] is the Lorentzian fraction (fitted parameter),
6/// G is a Gaussian, and L is a Lorentzian with the **same** width parameter.
7///
8/// Parameters (in order): `[amplitude, center, sigma, fraction]`
9///
10/// Gaussian term:   `G = exp(−(x−c)²/(2σ²))`
11/// Lorentzian term: `L = 1 / (1 + (x−c)²/σ²)`
12///
13/// Jacobians are computed via the chain rule through G, L, and η.
14pub struct PseudoVoigt;
15
16impl Model for PseudoVoigt {
17    fn eval(&self, x: &[f64], params: &[f64]) -> f64 {
18        let (a, c, sigma, eta) = (params[0], params[1], params[2], params[3]);
19        // Clip the mixing fraction to [0,1]: η outside this range is unphysical
20        // (it would extrapolate past pure-Lorentzian/Gaussian). Matches the numpy
21        // oracle's np.clip(fraction, 0, 1) so an LM search that overshoots the
22        // bound measures the solver, not a formula divergence.
23        let eta = eta.clamp(0.0, 1.0);
24        let dx = x[0] - c;
25        let g = (-dx * dx / (2.0 * sigma * sigma)).exp();
26        let l = 1.0 / (1.0 + dx * dx / (sigma * sigma));
27        a * (eta * l + (1.0 - eta) * g)
28    }
29
30    fn jacobian(&self, x: &[f64], params: &[f64]) -> Vec<f64> {
31        let (a, c, sigma, raw_eta) = (params[0], params[1], params[2], params[3]);
32        // η is clamped to [0,1] in eval; clamp here too so the analytic Jacobian
33        // is consistent with the (clamped) value, and zero ∂/∂fraction outside the
34        // bound where the clamped output is constant in fraction.
35        let eta = raw_eta.clamp(0.0, 1.0);
36        let in_range = (0.0..=1.0).contains(&raw_eta);
37        let dx = x[0] - c;
38        let dx2 = dx * dx;
39        let s2 = sigma * sigma;
40
41        let g = (-dx2 / (2.0 * s2)).exp();
42        let denom = 1.0 + dx2 / s2;
43        let l = 1.0 / denom;
44        let mix = eta * l + (1.0 - eta) * g;
45
46        // ∂/∂amplitude
47        let da = mix;
48
49        // ∂/∂center
50        let dg_dc = g * dx / s2;
51        let dl_dc = 2.0 * dx / (s2 * denom * denom);
52        let dc = a * (eta * dl_dc + (1.0 - eta) * dg_dc);
53
54        // ∂/∂sigma
55        let dg_ds = g * dx2 / (s2 * sigma);
56        let dl_ds = 2.0 * dx2 / (s2 * sigma * denom * denom);
57        let ds = a * (eta * dl_ds + (1.0 - eta) * dg_ds);
58
59        // ∂/∂fraction (eta): zero outside the clamp where the output is flat.
60        let df = if in_range { a * (l - g) } else { 0.0 };
61
62        vec![da, dc, ds, df]
63    }
64
65    #[inline]
66    fn jacobian_into(&self, x: &[f64], params: &[f64], out: &mut [f64]) {
67        let (a, c, sigma, raw_eta) = (params[0], params[1], params[2], params[3]);
68        let eta = raw_eta.clamp(0.0, 1.0);
69        let in_range = (0.0..=1.0).contains(&raw_eta);
70        let dx = x[0] - c;
71        let dx2 = dx * dx;
72        let s2 = sigma * sigma;
73        let g = (-dx2 / (2.0 * s2)).exp();
74        let denom = 1.0 + dx2 / s2;
75        let l = 1.0 / denom;
76        let denom2 = denom * denom;
77        let dg_dc = g * dx / s2;
78        let dl_dc = 2.0 * dx / (s2 * denom2);
79        let dg_ds = g * dx2 / (s2 * sigma);
80        let dl_ds = 2.0 * dx2 / (s2 * sigma * denom2);
81        out[0] = eta * l + (1.0 - eta) * g;
82        out[1] = a * (eta * dl_dc + (1.0 - eta) * dg_dc);
83        out[2] = a * (eta * dl_ds + (1.0 - eta) * dg_ds);
84        out[3] = if in_range { a * (l - g) } else { 0.0 };
85    }
86
87    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
88        vec![
89            "amplitude".into(),
90            "center".into(),
91            "sigma".into(),
92            "fraction".into(),
93        ]
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use approx::assert_relative_eq;
101
102    #[test]
103    fn pure_gaussian_at_eta_zero() {
104        // fraction=0 → pure Gaussian, value at center = amplitude
105        let v = PseudoVoigt.eval(&[0.0], &[3.0, 0.0, 1.0, 0.0]);
106        assert_relative_eq!(v, 3.0, epsilon = 1e-12);
107    }
108
109    #[test]
110    fn pure_lorentzian_at_eta_one() {
111        // fraction=1 → pure Lorentzian, value at center = amplitude
112        let v = PseudoVoigt.eval(&[0.0], &[3.0, 0.0, 1.0, 1.0]);
113        assert_relative_eq!(v, 3.0, epsilon = 1e-12);
114    }
115
116    #[test]
117    fn jacobian_shape() {
118        let j = PseudoVoigt.jacobian(&[0.5], &[1.0, 0.0, 1.0, 0.5]);
119        assert_eq!(j.len(), 4);
120    }
121
122    #[test]
123    fn jacobian_finite_diff_check() {
124        // Upgraded from a single-point forward difference (h=1e-5,
125        // epsilon=1e-4) to a central difference (O(h^2)) swept over three
126        // parameter regimes and several x-points relative to the centre,
127        // keeping the mixing fraction inside [0,1] (the clamp boundary is
128        // covered separately by `fraction_clamped_to_unit_interval`).
129        let param_sets = [
130            [2.0, 0.3, 0.9, 0.4],   // nominal
131            [1e-3, 0.0, 0.05, 0.2], // small amplitude/width
132            [5.0, -2.0, 3.0, 0.8],  // offset centre, wide
133        ];
134        for p in param_sets {
135            let (c, sigma) = (p[1], p[2]);
136            for &mult in &[-3.0_f64, -1.0, -0.1, 0.0, 0.1, 1.0, 3.0] {
137                let x = [c + mult * sigma];
138                let j_anal = PseudoVoigt.jacobian(&x, &p);
139                for i in 0..p.len() {
140                    let h = 1e-6 * p[i].abs().max(1.0);
141                    let (mut a, mut b) = (p, p);
142                    a[i] += h;
143                    b[i] -= h;
144                    let fd = (PseudoVoigt.eval(&x, &a) - PseudoVoigt.eval(&x, &b)) / (2.0 * h);
145                    assert_relative_eq!(j_anal[i], fd, epsilon = 1e-7, max_relative = 1e-6);
146                }
147            }
148        }
149    }
150
151    #[test]
152    fn fraction_clamped_to_unit_interval() {
153        // fraction > 1 behaves as fraction = 1 (pure Lorentzian); fraction < 0 as 0.
154        let over = PseudoVoigt.eval(&[0.7], &[2.5, 0.0, 1.3, 1.3]);
155        let one = PseudoVoigt.eval(&[0.7], &[2.5, 0.0, 1.3, 1.0]);
156        assert_relative_eq!(over, one, epsilon = 1e-12);
157        let under = PseudoVoigt.eval(&[0.7], &[2.5, 0.0, 1.3, -0.4]);
158        let zero = PseudoVoigt.eval(&[0.7], &[2.5, 0.0, 1.3, 0.0]);
159        assert_relative_eq!(under, zero, epsilon = 1e-12);
160        // ∂/∂fraction is zero outside the clamp (output is flat in fraction there).
161        let j = PseudoVoigt.jacobian(&[0.7], &[2.5, 0.0, 1.3, 1.3]);
162        assert_relative_eq!(j[3], 0.0, epsilon = 1e-12);
163    }
164
165    #[test]
166    fn da_at_center() {
167        // ∂/∂amplitude at center: G=1, L=1 → da = eta + (1-eta) = 1
168        let j = PseudoVoigt.jacobian(&[0.0], &[1.0, 0.0, 1.0, 0.5]);
169        assert_relative_eq!(j[0], 1.0, epsilon = 1e-12);
170    }
171}