Skip to main content

spectrafit_models/
mgh09_rational.rs

1use crate::Model;
2
3/// Kowalik–Osborne rational function (NIST StRD MGH09 model).
4///
5/// ```text
6/// y = amplitude · (x² + num_lin · x) / (x² + den_lin · x + den_const)
7/// ```
8///
9/// Parameters (in order): `[amplitude, num_lin, den_lin, den_const]`
10///
11/// Mapping from NIST MGH09 b-parameters:
12/// - `amplitude` = b1 (≈ 0.1928)
13/// - `num_lin`   = b2 (≈ 0.1913; coefficient of x in the numerator)
14/// - `den_lin`   = b3 (≈ 0.1231; coefficient of x in the denominator)
15/// - `den_const` = b4 (≈ 0.1361; constant term in the denominator)
16///
17/// **Domain guard:** `D = x² + den_lin·x + den_const` must be non-zero.
18/// The MGH09 certified parameters satisfy `discriminant = den_lin² − 4·den_const < 0`,
19/// which means D > 0 for all x. If `D = 0` (or becomes zero during LM search), the
20/// function returns `f64::NAN` so the solver backs off.
21///
22/// **Analytic Jacobian** (let `N = x² + num_lin·x`, `D = x² + den_lin·x + den_const`):
23/// - ∂y/∂amplitude = N/D
24/// - ∂y/∂num_lin   = amplitude · x / D
25/// - ∂y/∂den_lin   = −amplitude · N · x / D²
26/// - ∂y/∂den_const = −amplitude · N / D²
27pub struct Mgh09Rational;
28
29impl Model for Mgh09Rational {
30    fn eval(&self, x: &[f64], p: &[f64]) -> f64 {
31        let amplitude = p[0];
32        let num_lin = p[1];
33        let den_lin = p[2];
34        let den_const = p[3];
35        let xi = x[0];
36        let n = xi * xi + num_lin * xi;
37        let d = xi * xi + den_lin * xi + den_const;
38        if d == 0.0 {
39            return f64::NAN;
40        }
41        amplitude * n / d
42    }
43
44    fn jacobian_into(&self, x: &[f64], p: &[f64], out: &mut [f64]) {
45        let amplitude = p[0];
46        let num_lin = p[1];
47        let den_lin = p[2];
48        let den_const = p[3];
49        let xi = x[0];
50        let n = xi * xi + num_lin * xi;
51        let d = xi * xi + den_lin * xi + den_const;
52        if d == 0.0 {
53            out[0] = f64::NAN;
54            out[1] = f64::NAN;
55            out[2] = f64::NAN;
56            out[3] = f64::NAN;
57            return;
58        }
59        let n_over_d = n / d;
60        let inv_d2 = 1.0 / (d * d);
61        out[0] = n_over_d; // ∂y/∂amplitude = N/D
62        out[1] = amplitude * xi / d; // ∂y/∂num_lin   = amplitude·x/D
63        out[2] = -amplitude * n * xi * inv_d2; // ∂y/∂den_lin   = −amplitude·N·x/D²
64        out[3] = -amplitude * n * inv_d2; // ∂y/∂den_const = −amplitude·N/D²
65    }
66
67    fn jacobian(&self, x: &[f64], p: &[f64]) -> Vec<f64> {
68        let amplitude = p[0];
69        let num_lin = p[1];
70        let den_lin = p[2];
71        let den_const = p[3];
72        let xi = x[0];
73        let n = xi * xi + num_lin * xi;
74        let d = xi * xi + den_lin * xi + den_const;
75        if d == 0.0 {
76            return vec![f64::NAN, f64::NAN, f64::NAN, f64::NAN];
77        }
78        let n_over_d = n / d;
79        let inv_d2 = 1.0 / (d * d);
80        vec![
81            n_over_d,                     // ∂y/∂amplitude
82            amplitude * xi / d,           // ∂y/∂num_lin
83            -amplitude * n * xi * inv_d2, // ∂y/∂den_lin
84            -amplitude * n * inv_d2,      // ∂y/∂den_const
85        ]
86    }
87
88    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
89        vec![
90            "amplitude".into(),
91            "num_lin".into(),
92            "den_lin".into(),
93            "den_const".into(),
94        ]
95    }
96
97    fn eval_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
98        debug_assert_eq!(out.len(), xs.len());
99        let amplitude = params[0];
100        let num_lin = params[1];
101        let den_lin = params[2];
102        let den_const = params[3];
103        for (slot, &xi) in out.iter_mut().zip(xs.iter()) {
104            let n = xi * xi + num_lin * xi;
105            let d = xi * xi + den_lin * xi + den_const;
106            *slot = if d == 0.0 {
107                f64::NAN
108            } else {
109                amplitude * n / d
110            };
111        }
112    }
113
114    fn jac_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
115        debug_assert_eq!(out.len(), xs.len() * 4);
116        let amplitude = params[0];
117        let num_lin = params[1];
118        let den_lin = params[2];
119        let den_const = params[3];
120        for (i, &xi) in xs.iter().enumerate() {
121            let n = xi * xi + num_lin * xi;
122            let d = xi * xi + den_lin * xi + den_const;
123            if d == 0.0 {
124                out[i * 4] = f64::NAN;
125                out[i * 4 + 1] = f64::NAN;
126                out[i * 4 + 2] = f64::NAN;
127                out[i * 4 + 3] = f64::NAN;
128            } else {
129                let inv_d2 = 1.0 / (d * d);
130                out[i * 4] = n / d; // ∂y/∂amplitude
131                out[i * 4 + 1] = amplitude * xi / d; // ∂y/∂num_lin
132                out[i * 4 + 2] = -amplitude * n * xi * inv_d2; // ∂y/∂den_lin
133                out[i * 4 + 3] = -amplitude * n * inv_d2; // ∂y/∂den_const
134            }
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use approx::assert_relative_eq;
143
144    fn model() -> Mgh09Rational {
145        Mgh09Rational
146    }
147
148    // MGH09 certified params: b1=0.1928, b2=0.1913, b3=0.1231, b4=0.1361
149    // At x=4.0 (first data point):
150    //   N = 16 + 0.1913*4 = 16.7652
151    //   D = 16 + 0.1231*4 + 0.1361 = 16.6285
152    //   y = 0.1928 * 16.7652 / 16.6285 ≈ 0.1944  (matches NIST first obs ~0.1957)
153    #[test]
154    fn eval_first_mgh09_point_approx() {
155        let p = [
156            1.9280693458e-01_f64,
157            1.9128232873e-01,
158            1.2305650693e-01,
159            1.3606233068e-01,
160        ];
161        let y = model().eval(&[4.0], &p);
162        assert!((y - 0.1957).abs() < 5e-3, "Expected ~0.1957, got {y}");
163    }
164
165    // Domain guard: D = x² + den_lin·x + den_const = 0 → NaN.
166    // Construct such a case: x=0, den_const=0 → D=0.
167    #[test]
168    fn eval_zero_denominator_returns_nan() {
169        let p = [1.0_f64, 0.5, 0.0, 0.0]; // D = 0 + 0 + 0 = 0 at x=0
170        let y = model().eval(&[0.0], &p);
171        assert!(y.is_nan(), "Expected NaN for D=0, got {y}");
172    }
173
174    #[test]
175    fn jacobian_shape() {
176        let j = model().jacobian(&[2.0], &[0.19, 0.19, 0.12, 0.14]);
177        assert_eq!(j.len(), 4);
178        assert!(
179            j.iter().all(|v| v.is_finite()),
180            "Jacobian must be finite: {j:?}"
181        );
182    }
183
184    // At x=0, N=0 so y=0 and ∂y/∂amplitude=0, ∂y/∂num_lin=0, ∂y/∂den_lin=0, ∂y/∂den_const=0
185    #[test]
186    fn jacobian_at_x_zero() {
187        let p = [0.2_f64, 0.2, 0.12, 0.14];
188        let j = model().jacobian(&[0.0], &p);
189        assert_eq!(j.len(), 4);
190        assert_relative_eq!(j[0], 0.0, epsilon = 1e-12); // N/D = 0/D = 0
191        assert_relative_eq!(j[1], 0.0, epsilon = 1e-12); // amplitude·x/D = 0
192        assert_relative_eq!(j[2], 0.0, epsilon = 1e-12); // -amplitude·N·x/D² = 0
193        assert_relative_eq!(j[3], 0.0, epsilon = 1e-12); // -amplitude·N/D² = 0
194    }
195
196    // Central-difference (O(h^2)) sweep over three parameter regimes and
197    // several x-points, replacing the old single-point forward differences
198    // (h=1e-5..1e-7, max_relative=1e-4..1e-5). Each test keeps its name
199    // (traceability) but now checks only its own parameter index across the
200    // full regime/x-point grid. Regimes and x-points keep
201    // D = x² + den_lin·x + den_const away from 0 (the domain-guard boundary).
202    const MGH09_REGIMES: [[f64; 4]; 3] = [
203        [0.19, 0.19, 0.12, 0.14], // nominal (near-certified MGH09 fit)
204        [1e-3, 1e-3, 1e-3, 1e-2], // small amplitude/coefficients
205        [5.0, -2.0, 3.0, 8.0],    // offset/negative, wide
206    ];
207    const MGH09_XS: [f64; 5] = [0.1, 0.5, 1.5, 2.0, 4.0];
208
209    fn check_mgh09_param_central_diff(idx: usize) {
210        for p in MGH09_REGIMES {
211            for &x in &MGH09_XS {
212                let j = model().jacobian(&[x], &p);
213                let h = 1e-6 * p[idx].abs().max(1.0);
214                let mut pp = p;
215                pp[idx] += h;
216                let mut pm = p;
217                pm[idx] -= h;
218                let fd = (model().eval(&[x], &pp) - model().eval(&[x], &pm)) / (2.0 * h);
219                assert_relative_eq!(j[idx], fd, epsilon = 1e-7, max_relative = 1e-6);
220            }
221        }
222    }
223
224    // FD check for ∂y/∂amplitude
225    #[test]
226    fn jacobian_fd_amplitude() {
227        check_mgh09_param_central_diff(0);
228    }
229
230    // FD check for ∂y/∂num_lin
231    #[test]
232    fn jacobian_fd_num_lin() {
233        check_mgh09_param_central_diff(1);
234    }
235
236    // FD check for ∂y/∂den_lin
237    #[test]
238    fn jacobian_fd_den_lin() {
239        check_mgh09_param_central_diff(2);
240    }
241
242    // FD check for ∂y/∂den_const
243    #[test]
244    fn jacobian_fd_den_const() {
245        check_mgh09_param_central_diff(3);
246    }
247
248    #[test]
249    fn jacobian_into_matches_jacobian() {
250        let x = &[1.5_f64];
251        let p = [0.19_f64, 0.19, 0.12, 0.14];
252        let j_vec = model().jacobian(x, &p);
253        let mut out = [0.0_f64; 4];
254        model().jacobian_into(x, &p, &mut out);
255        for k in 0..4 {
256            assert_relative_eq!(out[k], j_vec[k], epsilon = 1e-12);
257        }
258    }
259
260    #[test]
261    fn eval_slice_matches_scalar() {
262        let xs: Vec<f64> = [
263            4.0, 2.0, 1.0, 0.5, 0.25, 0.167, 0.125, 0.1, 0.0833, 0.0714, 0.0625,
264        ]
265        .to_vec();
266        let p = [
267            1.9280693458e-01_f64,
268            1.9128232873e-01,
269            1.2305650693e-01,
270            1.3606233068e-01,
271        ];
272        let mut out = vec![0.0_f64; xs.len()];
273        model().eval_slice_into(&xs, &p, &mut out);
274        for (&xi, &bi) in xs.iter().zip(out.iter()) {
275            assert_relative_eq!(bi, model().eval(&[xi], &p), epsilon = 1e-10);
276        }
277    }
278
279    #[test]
280    fn jac_slice_matches_scalar() {
281        let xs: Vec<f64> = [4.0, 2.0, 1.0, 0.5, 0.25].to_vec();
282        let p = [0.19_f64, 0.19, 0.12, 0.14];
283        let n = xs.len();
284        let mut out = vec![0.0_f64; n * 4];
285        model().jac_slice_into(&xs, &p, &mut out);
286        for (i, xi) in xs.iter().enumerate() {
287            let j = model().jacobian(&[*xi], &p);
288            for k in 0..4 {
289                assert_relative_eq!(out[i * 4 + k], j[k], epsilon = 1e-10);
290            }
291        }
292    }
293}