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}