Skip to main content

spectrafit_models/
rational_cubic.rs

1use crate::Model;
2
3/// Rational function, cubic over cubic, with the denominator constant pinned at 1.
4///
5/// ```text
6/// y = (a0 + a1·x + a2·x² + a3·x³) / (1 + b1·x + b2·x² + b3·x³)
7/// ```
8///
9/// Parameters (in order): `[a0, a1, a2, a3, b1, b2, b3]`
10///
11/// One kernel covers three NIST StRD datasets because a lower-order rational is
12/// this form with the unused coefficients held at zero:
13/// - Hahn1 and Thurber are cubic/cubic and use all seven.
14/// - Kirby2 is quadratic/quadratic: fix `a3 = 0` and `b3 = 0` and the remaining
15///   five map straight onto NIST's b1..b5.
16///
17/// The denominator constant is pinned at 1 rather than fitted because NIST writes
18/// these models that way. Fitting it too would make numerator and denominator
19/// jointly scalable — `(kN)/(kD)` is the same curve — and hand the solver an exact
20/// rank deficiency.
21///
22/// **Domain guard:** `D` must be non-zero. A rational's denominator can cross zero
23/// mid-search even when the certified parameters keep it clear of the data, so
24/// `D = 0` returns `f64::NAN` and the solver backs off rather than emitting an
25/// infinity that would poison the residual vector.
26///
27/// **Analytic Jacobian** (let `N` and `D` be numerator and denominator):
28/// - ∂y/∂a_k = x^k / D          for k = 0,1,2,3
29/// - ∂y/∂b_k = −N · x^k / D²    for k = 1,2,3
30pub struct RationalCubic;
31
32#[inline]
33fn nd(xi: f64, p: &[f64]) -> (f64, f64) {
34    let x2 = xi * xi;
35    let x3 = x2 * xi;
36    let n = p[0] + p[1] * xi + p[2] * x2 + p[3] * x3;
37    let d = 1.0 + p[4] * xi + p[5] * x2 + p[6] * x3;
38    (n, d)
39}
40
41#[inline]
42fn jac_at(xi: f64, p: &[f64], out: &mut [f64]) {
43    let x2 = xi * xi;
44    let x3 = x2 * xi;
45    let (n, d) = nd(xi, p);
46    if d == 0.0 {
47        out[..7].fill(f64::NAN);
48        return;
49    }
50    let inv_d = 1.0 / d;
51    let scale = -n * inv_d * inv_d;
52    out[0] = inv_d;
53    out[1] = xi * inv_d;
54    out[2] = x2 * inv_d;
55    out[3] = x3 * inv_d;
56    out[4] = scale * xi;
57    out[5] = scale * x2;
58    out[6] = scale * x3;
59}
60
61impl Model for RationalCubic {
62    fn eval(&self, x: &[f64], p: &[f64]) -> f64 {
63        let (n, d) = nd(x[0], p);
64        if d == 0.0 {
65            return f64::NAN;
66        }
67        n / d
68    }
69
70    fn jacobian_into(&self, x: &[f64], p: &[f64], out: &mut [f64]) {
71        jac_at(x[0], p, out);
72    }
73
74    fn jacobian(&self, x: &[f64], p: &[f64]) -> Vec<f64> {
75        let mut out = vec![0.0_f64; 7];
76        jac_at(x[0], p, &mut out);
77        out
78    }
79
80    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
81        vec![
82            "a0".into(),
83            "a1".into(),
84            "a2".into(),
85            "a3".into(),
86            "b1".into(),
87            "b2".into(),
88            "b3".into(),
89        ]
90    }
91
92    fn eval_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
93        debug_assert_eq!(out.len(), xs.len());
94        for (slot, &xi) in out.iter_mut().zip(xs.iter()) {
95            let (n, d) = nd(xi, params);
96            *slot = if d == 0.0 { f64::NAN } else { n / d };
97        }
98    }
99
100    fn jac_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
101        debug_assert_eq!(out.len(), xs.len() * 7);
102        for (i, &xi) in xs.iter().enumerate() {
103            jac_at(xi, params, &mut out[i * 7..i * 7 + 7]);
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use approx::assert_relative_eq;
112
113    fn model() -> RationalCubic {
114        RationalCubic
115    }
116
117    // Kirby2's certified values, carried in the quadratic/quadratic slots with the
118    // cubic coefficients at zero. First observation is x=9.65, y=0.0082.
119    const KIRBY2: [f64; 7] = [
120        1.6745063063e00,
121        -1.3927397867e-01,
122        2.5961181191e-03,
123        0.0,
124        -1.7241811870e-03,
125        2.1664802578e-05,
126        0.0,
127    ];
128
129    // Anchored at two points where the certified fit tracks the data, NOT at the
130    // first observation: Kirby2's x=9.65 point is the largest residual in all 151
131    // observations (data 0.0082, certified fit 0.5808), so a test written there
132    // measures the dataset's outlier rather than the kernel.
133    #[test]
134    fn eval_kirby2_matches_data_where_the_certified_fit_holds() {
135        let mid = model().eval(&[100.0], &KIRBY2);
136        assert!(
137            (mid - 12.944).abs() < 0.25,
138            "at x=100 expected ~12.94, got {mid}"
139        );
140        let top = model().eval(&[371.3], &KIRBY2);
141        assert!(
142            (top - 92.2).abs() < 0.25,
143            "at x=371.3 expected ~92.2, got {top}"
144        );
145    }
146
147    #[test]
148    fn zero_denominator_returns_nan() {
149        // D = 1 + b1·x = 0 at x = 1 when b1 = -1.
150        let p = [1.0_f64, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0];
151        assert!(model().eval(&[1.0], &p).is_nan());
152        let j = model().jacobian(&[1.0], &p);
153        assert!(j.iter().all(|v| v.is_nan()));
154    }
155
156    #[test]
157    fn constant_when_all_but_a0_vanish() {
158        // N = a0, D = 1 → y = a0 for every x.
159        let p = [2.5_f64, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
160        for x in [-3.0, 0.0, 1.0, 7.5] {
161            assert_relative_eq!(model().eval(&[x], &p), 2.5, epsilon = 1e-12);
162        }
163    }
164
165    #[test]
166    fn jacobian_shape_and_finiteness() {
167        let j = model().jacobian(&[2.0], &KIRBY2);
168        assert_eq!(j.len(), 7);
169        assert!(j.iter().all(|v| v.is_finite()), "{j:?}");
170    }
171
172    #[test]
173    fn jacobian_matches_finite_difference() {
174        // Already a central difference; widened from one regime/x-point to
175        // three regimes and an x-range spanning all three NIST StRD datasets
176        // this kernel backs (docs/reference/models/index.md): Thurber
177        // (x: −3.07…2.20), Kirby2 (x: 9.65…371.30), Hahn1 (x: 14.13…851.61).
178        //
179        // CORRECTION to an earlier version of this comment: it attributed the
180        // original failure at x=100 to a generic "fixed-h step too large"
181        // effect. That was wrong on the facts — the step below was already
182        // relative, `h = 1e-6 * |p[k]|.max(1.0)`, identical in form to every
183        // other file in this pass. The real mechanism is a *degenerate*
184        // relative step: Kirby2 is quadratic/quadratic, so b3 = 0 exactly,
185        // and `|0|.max(1.0)` collapses the "relative" step to the absolute
186        // floor of 1.0 — an effective h of 1e-6 regardless of scale. That
187        // fixed 1e-6 is then amplified by ∂y/∂b3 = −N·x³/D², which grows as
188        // x³: at x=50 (reproduced independently), h·x³ ≈ 0.125 against a D
189        // of only a few, no longer an infinitesimal probe:
190        //
191        //   step rule                              rel_err at x=50, k=b3
192        //   h = 1e-6 · |p|.max(1.0)  (old, degenerate on b3=0)   1.7e-2
193        //   h = 1e-6 / x³                                        3.7e-11
194        //   h = 1e-9 fixed                                       1.7e-8
195        //
196        // Fix: for the denominator coefficients (b1, b2, b3 — indices 4..7,
197        // multiplying x¹, x², x³ respectively) the step is no longer
198        // parameter-relative but **D-relative**: h = (1e-4·|D|).max(1e-9) /
199        // |x|.max(1.0)^power. This targets a fixed ~0.01% perturbation of
200        // the denominator itself, so it stays large enough to clear
201        // floating-point rounding noise even when a coefficient is exactly
202        // 0 (unlike `1e-6/x³`, which underflows toward the f64 rounding
203        // floor at large x once combined with a *nonzero* small b2/b3 in the
204        // "offset/negative" regime below — confirmed by re-running this
205        // exact grid with a plain `1e-6/x^power` rule, which failed at
206        // x≥100 there) and small enough to stay a good local-slope probe
207        // even when D is enormous (up to ~3e7 at x=850). Numerator
208        // coefficients (a0..a3, indices 0..4) are exactly linear in y, so
209        // their original parameter-relative step is untouched and exact up
210        // to floating-point rounding.
211        let param_sets = [
212            KIRBY2,                                   // certified Kirby2 fit (nominal)
213            [1e-3, 1e-3, 1e-3, 0.0, 1e-3, 1e-3, 0.0], // small coefficients
214            [5.0, -2.0, 3.0, -0.5, 0.2, -0.1, 0.05],  // offset/negative, cubic terms active
215        ];
216        // Spans Thurber's negative domain, Kirby2's up to ~371, and Hahn1's
217        // up to ~852.
218        let xs = [
219            -3.0_f64, 0.5, 1.0, 2.0, 3.25, 5.0, 10.0, 50.0, 100.0, 300.0, 850.0,
220        ];
221        for p in param_sets {
222            for &x in &xs {
223                let (_, d0) = nd(x, &p);
224                if d0 == 0.0 {
225                    continue; // domain-guard boundary, covered by its own test
226                }
227                let j = model().jacobian(&[x], &p);
228                for k in 0..7 {
229                    let h = if k >= 4 {
230                        let power = (k - 3) as i32; // b1->x¹, b2->x², b3->x³
231                        (1e-4 * d0.abs()).max(1e-9) / x.abs().max(1.0).powi(power)
232                    } else {
233                        1e-6 * p[k].abs().max(1.0)
234                    };
235                    let mut pp = p;
236                    pp[k] += h;
237                    let mut pm = p;
238                    pm[k] -= h;
239                    let fd = (model().eval(&[x], &pp) - model().eval(&[x], &pm)) / (2.0 * h);
240                    assert_relative_eq!(j[k], fd, max_relative = 1e-6, epsilon = 1e-7);
241                }
242            }
243        }
244    }
245
246    #[test]
247    fn jacobian_into_matches_jacobian() {
248        let x = &[1.5_f64];
249        let j_vec = model().jacobian(x, &KIRBY2);
250        let mut out = [0.0_f64; 7];
251        model().jacobian_into(x, &KIRBY2, &mut out);
252        for k in 0..7 {
253            assert_relative_eq!(out[k], j_vec[k], epsilon = 1e-12);
254        }
255    }
256
257    #[test]
258    fn eval_slice_matches_scalar() {
259        let xs = [9.65_f64, 10.74, 20.42, 40.12, 100.5, 200.0];
260        let mut out = vec![0.0_f64; xs.len()];
261        model().eval_slice_into(&xs, &KIRBY2, &mut out);
262        for (&xi, &got) in xs.iter().zip(out.iter()) {
263            assert_relative_eq!(got, model().eval(&[xi], &KIRBY2), epsilon = 1e-12);
264        }
265    }
266
267    #[test]
268    fn jac_slice_matches_scalar() {
269        let xs = [9.65_f64, 20.42, 100.5];
270        let mut out = vec![0.0_f64; xs.len() * 7];
271        model().jac_slice_into(&xs, &KIRBY2, &mut out);
272        for (i, &xi) in xs.iter().enumerate() {
273            let j = model().jacobian(&[xi], &KIRBY2);
274            for k in 0..7 {
275                assert_relative_eq!(out[i * 7 + k], j[k], epsilon = 1e-12);
276            }
277        }
278    }
279}