Skip to main content

spectrafit_models/
emg.rs

1use crate::erf_ext::erfcx;
2use crate::Model;
3
4/// Exponentially-modified Gaussian (EMG) — a Gaussian convolved with a one-sided
5/// exponential, the canonical asymmetric/tailing chromatography & spectroscopy peak.
6///
7/// `A · (γ/2) · exp[γ(c−x) + (γσ)²/2] · erfc[(c + γσ² − x)/(σ√2)]`
8///
9/// Parameters (in order): `[amplitude, center, sigma, gamma]`
10///
11/// - `gamma` is the exponential decay rate of the tail (toward high `x`); as `γ→0`
12///   the shape approaches a Gaussian.
13///
14/// # Numerical stability (no clamp)
15///
16/// The naive form computes `exp(arg_exp)·erfc(z)`, which overflows to `inf·0 → NaN`
17/// for `arg_exp > 709` (e.g. `γσ > 37`). Instead we use the algebraic identity
18/// `arg_exp − z² = −(x−c)²/(2σ²)` and split on the sign of `z`:
19///
20/// - `z ≥ 0`: `A·(γ/2)·exp(−(x−c)²/(2σ²))·erfcx(z)` — both factors are bounded
21///   (`erfcx(z) ∈ (0,1]`, the Gaussian `≤ 1`), so there is no overflow.
22/// - `z < 0`: `A·(γ/2)·exp(arg_exp)·erfc(z)` — here `arg_exp < 0`, so the `exp`
23///   cannot overflow, and `erfc(z) ∈ (1,2)`.
24///
25/// The two branches are continuous at `z = 0` (`erfcx(0) = erfc(0) = 1` and the
26/// Gaussian factor equals `exp(arg_exp)` there). A final `is_finite` guard remains
27/// as belt-and-suspenders. The numpy benchmark oracle uses the identical split with
28/// `scipy.special.erfcx`, so numpy↔Rust parity holds to machine precision.
29pub struct ExpGaussian;
30
31impl Model for ExpGaussian {
32    fn eval(&self, x: &[f64], params: &[f64]) -> f64 {
33        let (a, c, sigma, gamma) = (params[0], params[1], params[2], params[3]);
34        let u = c - x[0];
35        let arg_exp = gamma * u + 0.5 * (gamma * sigma) * (gamma * sigma);
36        let z = (c + gamma * sigma * sigma - x[0]) / (std::f64::consts::SQRT_2 * sigma);
37        let v = if z >= 0.0 {
38            // Overflow-free: exp(arg_exp)·erfc(z) == exp(-(x-c)²/(2σ²))·erfcx(z).
39            let gauss = (-(x[0] - c) * (x[0] - c) / (2.0 * sigma * sigma)).exp();
40            a * 0.5 * gamma * gauss * erfcx(z)
41        } else {
42            // arg_exp < 0 here, so exp is safe; erfc(z) ∈ (1, 2).
43            a * 0.5 * gamma * arg_exp.exp() * libm::erfc(z)
44        };
45        if v.is_finite() {
46            v
47        } else {
48            0.0
49        }
50    }
51
52    /// Analytical Jacobian of the EMG (exponentially-modified Gaussian).
53    ///
54    /// Define: `e_arg = γ(c−x) + (γσ)²/2`, `u = (c + γσ² − x)/(σ√2)`,
55    /// `E = exp(e_arg)`, `C = erfc(u)`, `g_u = (2/√π)·exp(−u²)`.
56    ///
57    /// Then `f = A·(γ/2)·E·C`.
58    ///
59    /// ∂f/∂A      = (γ/2)·E·C
60    /// ∂f/∂center = A·(γ/2)·E·[ γ·C − g_u/(σ√2) ]
61    /// ∂f/∂sigma  = A·(γ/2)·E·[ γ²σ·C − g_u·(γ√2 − u/σ) ]
62    /// ∂f/∂gamma  = (A/2)·E·C + A·(γ/2)·E·[ (c−x+γσ²)·C − g_u·σ/√2 ]
63    ///
64    /// When the overflow-clamped region returns f=0, all derivatives are 0.
65    ///
66    /// # Numerical stability
67    ///
68    /// The Jacobian uses the same identity as `eval`:
69    ///   `E·C = gauss·erfcx(u)` for z ≥ 0  (overflow-free)
70    ///   `E·C = exp(e_arg)·erfc(u)` for z < 0 (safe, e_arg < 0 here)
71    ///
72    /// In both branches, `E·g_u = gauss·(2/√π)` where
73    /// `gauss = exp(−(x−c)²/(2σ²))`, because the identity
74    /// `e_arg − u² = −(x−c)²/(2σ²)` holds exactly.
75    fn jacobian(&self, x: &[f64], params: &[f64]) -> Vec<f64> {
76        let (a, c, sigma, gamma) = (params[0], params[1], params[2], params[3]);
77        let sqrt2 = std::f64::consts::SQRT_2;
78        let inv_sqrt_pi = 1.0 / std::f64::consts::PI.sqrt();
79        let e_arg = gamma * (c - x[0]) + 0.5 * (gamma * sigma) * (gamma * sigma);
80        let u = (c + gamma * sigma * sigma - x[0]) / (sqrt2 * sigma);
81        let gauss = (-(x[0] - c) * (x[0] - c) / (2.0 * sigma * sigma)).exp();
82
83        // E·C — the stable product, matching eval().
84        let ec = if u >= 0.0 {
85            gauss * erfcx(u)
86        } else {
87            e_arg.exp() * libm::erfc(u)
88        };
89
90        if !(a * 0.5 * gamma * ec).is_finite() {
91            return vec![0.0; 4];
92        }
93
94        // E·g_u = gauss·(2/√π)  — works in both z branches.
95        let e_g_u = gauss * 2.0 * inv_sqrt_pi;
96
97        let da = 0.5 * gamma * ec;
98        let dc = a * 0.5 * gamma * (gamma * ec - e_g_u / (sigma * sqrt2));
99        let ds =
100            a * 0.5 * gamma * (gamma * gamma * sigma * ec - e_g_u * (gamma * sqrt2 - u / sigma));
101        let c_minus_x_plus_gss = c - x[0] + gamma * sigma * sigma;
102        let dg = 0.5 * a * ec + a * 0.5 * gamma * (c_minus_x_plus_gss * ec - e_g_u * sigma / sqrt2);
103
104        vec![da, dc, ds, dg]
105    }
106
107    #[inline]
108    fn jacobian_into(&self, x: &[f64], params: &[f64], out: &mut [f64]) {
109        let jac = self.jacobian(x, params);
110        out[..4].copy_from_slice(&jac);
111    }
112
113    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
114        vec![
115            "amplitude".into(),
116            "center".into(),
117            "sigma".into(),
118            "gamma".into(),
119        ]
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn finite_over_a_reasonable_grid() {
129        let m = ExpGaussian;
130        let p = [4.0, 0.0, 1.0, 0.8];
131        for i in -50..=50 {
132            let v = m.eval(&[i as f64 * 0.2], &p);
133            assert!(v.is_finite(), "non-finite at x={}", i as f64 * 0.2);
134        }
135    }
136
137    #[test]
138    fn asymmetric_tail_toward_high_x() {
139        // EMG tails toward high x: the high side decays slower than the low side.
140        let m = ExpGaussian;
141        let p = [4.0, 0.0, 1.0, 0.8];
142        let hi = m.eval(&[2.5], &p);
143        let lo = m.eval(&[-2.5], &p);
144        assert!(hi > lo);
145    }
146
147    #[test]
148    fn jacobian_matches_central_difference_across_regimes() {
149        // No per-file FD check previously existed for EMG's analytic Jacobian
150        // (only the crate-wide `tests/jacobian_parity.rs` integration harness
151        // covered it, single-point/forward-difference). This closes the
152        // per-kernel gap with the same central-difference, multi-regime,
153        // multi-x-point pattern used across the rest of spectrafit-models.
154        use approx::assert_relative_eq;
155        let m = ExpGaussian;
156        let param_sets = [
157            [4.0, 0.0, 1.0, 0.8],    // nominal
158            [1e-3, 0.0, 0.05, 0.05], // small amplitude/width/rate
159            [5.0, -2.0, 3.0, 2.0],   // offset centre, wide, faster tail
160        ];
161        for p in param_sets {
162            let (c, sigma) = (p[1], p[2]);
163            for &mult in &[-3.0_f64, -1.0, -0.1, 0.0, 0.1, 1.0, 3.0] {
164                let x = c + mult * sigma;
165                let j = m.jacobian(&[x], &p);
166                for i in 0..p.len() {
167                    let h = 1e-6 * p[i].abs().max(1.0);
168                    let (mut a, mut b) = (p, p);
169                    a[i] += h;
170                    b[i] -= h;
171                    let fd = (m.eval(&[x], &a) - m.eval(&[x], &b)) / (2.0 * h);
172                    assert_relative_eq!(j[i], fd, epsilon = 1e-7, max_relative = 1e-6);
173                }
174            }
175        }
176    }
177
178    #[test]
179    fn extreme_tail_matches_mpmath_reference() {
180        // gamma=38, sigma=1 → arg_exp ≈ 703 at x=0.5 (overflow regime for the naive
181        // exp·erfc form). The stable erfcx split must match a 50-digit mpmath
182        // reference: value = A·0.5·γ·exp(arg_exp)·erfc(z).
183        use approx::assert_relative_eq;
184        let m = ExpGaussian;
185        let p = [1.0, 0.0, 1.0, 38.0];
186        // (x, mpmath 50-digit reference value)
187        let refs = [
188            (0.5_f64, 0.356_506_374_757_193_2_f64),
189            (0.0, 0.398_666_576_586_319_1),
190            (1.0, 0.248_329_343_171_832_5),
191            (-2.0, 0.051_259_420_971_274_5),
192            (3.0, 0.004_807_802_777_736_4),
193        ];
194        for (x, want) in refs {
195            let got = m.eval(&[x], &p);
196            assert!(got.is_finite(), "non-finite at x={x}");
197            assert_relative_eq!(got, want, max_relative = 1e-9);
198        }
199    }
200}