Skip to main content

spectrafit_models/
gaussian.rs

1use crate::math_backend::batch_exp;
2use crate::Model;
3
4/// Gaussian kernel: `A * exp(-(x₀ - c)² / (2σ²))`
5///
6/// Parameters (in order): `[amplitude, center, sigma]`
7pub struct Gaussian;
8
9impl Model for Gaussian {
10    fn eval(&self, x: &[f64], params: &[f64]) -> f64 {
11        let (a, c, sigma) = (params[0], params[1], params[2]);
12        let z = -(x[0] - c).powi(2) / (2.0 * sigma * sigma);
13        a * z.exp()
14    }
15
16    fn jacobian(&self, x: &[f64], params: &[f64]) -> Vec<f64> {
17        let (a, c, sigma) = (params[0], params[1], params[2]);
18        let dx = x[0] - c;
19        let z = -dx * dx / (2.0 * sigma * sigma);
20        let g = z.exp();
21
22        // ∂/∂amplitude = g
23        let da = g;
24        // ∂/∂center    = A * g * (x₀ - c) / σ²
25        let dc = a * g * dx / (sigma * sigma);
26        // ∂/∂sigma     = A * g * (x₀ - c)² / σ³
27        let ds = a * g * dx * dx / (sigma * sigma * sigma);
28
29        vec![da, dc, ds]
30    }
31
32    #[inline]
33    fn jacobian_into(&self, x: &[f64], params: &[f64], out: &mut [f64]) {
34        let (a, c, sigma) = (params[0], params[1], params[2]);
35        let dx = x[0] - c;
36        let s2 = sigma * sigma;
37        let g = (-dx * dx / (2.0 * s2)).exp();
38        out[0] = g;
39        out[1] = a * g * dx / s2;
40        out[2] = a * g * dx * dx / (s2 * sigma);
41    }
42
43    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
44        vec!["amplitude".into(), "center".into(), "sigma".into()]
45    }
46
47    fn eval_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
48        debug_assert_eq!(out.len(), xs.len());
49        let (a, c, sigma) = (params[0], params[1], params[2]);
50        let inv_2s2 = 1.0 / (2.0 * sigma * sigma);
51        // Pass 1: write exp arguments into a temporary (vvexp requires
52        // non-aliased src/dst pointers — Rust's borrow rules guarantee this).
53        let args: Vec<f64> = xs
54            .iter()
55            .map(|xi| {
56                let dx = xi - c;
57                -(dx * dx) * inv_2s2
58            })
59            .collect();
60        // Pass 2: vectorized exp — platform backend (vvexp on macOS, scalar elsewhere).
61        batch_exp(out, &args);
62        // Pass 3: scale by amplitude (LLVM auto-vectorizes this simple loop).
63        for slot in out.iter_mut() {
64            *slot *= a;
65        }
66    }
67
68    fn jac_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
69        debug_assert_eq!(out.len(), xs.len() * 3);
70        let (a, c, sigma) = (params[0], params[1], params[2]);
71        let s2 = sigma * sigma;
72        let inv_2s2 = 0.5 / s2;
73        let inv_s2 = 1.0 / s2;
74        let inv_s3 = inv_s2 / sigma;
75        let n = xs.len();
76        // Compute exp arguments (loop-invariants hoisted outside).
77        let args: Vec<f64> = xs
78            .iter()
79            .map(|xi| {
80                let dx = xi - c;
81                -(dx * dx) * inv_2s2
82            })
83            .collect();
84        // Vectorized exp into a separate output buffer.
85        let mut g = vec![0.0_f64; n];
86        batch_exp(&mut g, &args);
87        // Fill Jacobian row-major: [∂/∂amplitude, ∂/∂center, ∂/∂sigma].
88        for (i, (&xi, &gi)) in xs.iter().zip(g.iter()).enumerate() {
89            let dx = xi - c;
90            out[i * 3] = gi;
91            out[i * 3 + 1] = a * gi * dx * inv_s2;
92            out[i * 3 + 2] = a * gi * dx * dx * inv_s3;
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use approx::assert_relative_eq;
101
102    #[test]
103    fn eval_at_center() {
104        // At x=center the Gaussian equals amplitude.
105        let g = Gaussian;
106        let v = g.eval(&[0.0], &[1.0, 0.0, 1.0]);
107        assert_relative_eq!(v, 1.0, epsilon = 1e-12);
108    }
109
110    #[test]
111    fn eval_at_sigma_offset() {
112        // At x = center + σ the Gaussian equals A * exp(-0.5) = A / sqrt(e).
113        let g = Gaussian;
114        let v = g.eval(&[1.0], &[1.0, 0.0, 1.0]);
115        let expected = 1.0 / std::f64::consts::E.sqrt();
116        assert_relative_eq!(v, expected, epsilon = 1e-12);
117    }
118
119    #[test]
120    fn jacobian_shape() {
121        let g = Gaussian;
122        let j = g.jacobian(&[0.5], &[2.0, 0.0, 1.0]);
123        assert_eq!(j.len(), 3);
124    }
125
126    #[test]
127    fn jacobian_at_center_da() {
128        // ∂/∂amplitude at center: g = exp(0) = 1, so jac[0] = 1.0
129        let g = Gaussian;
130        let j = g.jacobian(&[0.0], &[1.0, 0.0, 1.0]);
131        assert_relative_eq!(j[0], 1.0, epsilon = 1e-12);
132    }
133
134    #[test]
135    fn jacobian_at_center_dc_zero() {
136        // ∂/∂center at x == center is 0 (symmetric peak).
137        let g = Gaussian;
138        let j = g.jacobian(&[0.0], &[1.0, 0.0, 1.0]);
139        assert_relative_eq!(j[1], 0.0, epsilon = 1e-12);
140    }
141
142    // Central-difference (O(h^2)) sweep over three parameter regimes and
143    // several x-points relative to the centre, replacing the old
144    // single-point forward difference (h=1e-6, epsilon=1e-5). Each test
145    // keeps its name (traceability) but now checks only its own parameter
146    // index across the full regime/x-point grid.
147    const GAUSSIAN_REGIMES: [[f64; 3]; 3] = [
148        [2.0, 0.0, 1.0],   // nominal
149        [1e-3, 0.0, 0.05], // small amplitude/width
150        [5.0, -2.0, 3.0],  // offset centre, wide
151    ];
152
153    fn check_param_central_diff(idx: usize) {
154        let g = Gaussian;
155        for p in GAUSSIAN_REGIMES {
156            let (c, sigma) = (p[1], p[2]);
157            for &mult in &[-3.0_f64, -1.0, -0.1, 0.0, 0.1, 1.0, 3.0] {
158                let x = [c + mult * sigma];
159                let j = g.jacobian(&x, &p);
160                let h = 1e-6 * p[idx].abs().max(1.0);
161                let (mut a, mut b) = (p, p);
162                a[idx] += h;
163                b[idx] -= h;
164                let fd = (g.eval(&x, &a) - g.eval(&x, &b)) / (2.0 * h);
165                assert_relative_eq!(j[idx], fd, epsilon = 1e-7, max_relative = 1e-6);
166            }
167        }
168    }
169
170    #[test]
171    fn jacobian_numerical_check_amplitude() {
172        check_param_central_diff(0);
173    }
174
175    #[test]
176    fn jacobian_numerical_check_center() {
177        check_param_central_diff(1);
178    }
179
180    #[test]
181    fn jacobian_numerical_check_sigma() {
182        check_param_central_diff(2);
183    }
184
185    // ── batch path correctness ────────────────────────────────────────────────
186
187    #[test]
188    fn eval_slice_into_matches_scalar() {
189        let g = Gaussian;
190        let xs: Vec<f64> = (-5..=5).map(|i| i as f64 * 0.5).collect();
191        let params = [3.0f64, 0.5, 1.2];
192        let mut batch_out = vec![0.0_f64; xs.len()];
193        g.eval_slice_into(&xs, &params, &mut batch_out);
194        for (xi, &bi) in xs.iter().zip(batch_out.iter()) {
195            let scalar = g.eval(&[*xi], &params);
196            assert_relative_eq!(bi, scalar, epsilon = 1e-10, max_relative = 1e-10);
197        }
198    }
199
200    #[test]
201    fn jac_slice_into_matches_scalar() {
202        let g = Gaussian;
203        let xs: Vec<f64> = (-3..=3).map(|i| i as f64 * 0.75).collect();
204        let params = [2.0f64, -0.5, 0.8];
205        let n = xs.len();
206        let mut jac_out = vec![0.0_f64; n * 3];
207        g.jac_slice_into(&xs, &params, &mut jac_out);
208        for (i, xi) in xs.iter().enumerate() {
209            let scalar = g.jacobian(&[*xi], &params);
210            assert_relative_eq!(jac_out[i * 3], scalar[0], epsilon = 1e-10);
211            assert_relative_eq!(jac_out[i * 3 + 1], scalar[1], epsilon = 1e-10);
212            assert_relative_eq!(jac_out[i * 3 + 2], scalar[2], epsilon = 1e-10);
213        }
214    }
215}