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}