Skip to main content

spectrafit_models/
power_law_offset.rs

1use crate::Model;
2
3/// Power-law with offset: `amplitude · (offset + x)^(−1/shape)`
4///
5/// Parameters (in order): `[amplitude, offset, shape]`
6///
7/// This is the NIST StRD Bennett5 model. It describes a power-law relationship
8/// of the form `y = b1·(b2 + x)^(−1/b3)` with the mapping:
9/// - `amplitude` = b1 (≈ −2524 for Bennett5; may be negative)
10/// - `offset`    = b2 (≈ 46.7 for Bennett5; shifts x-origin)
11/// - `shape`     = b3 (≈ 0.932 for Bennett5; controls the exponent)
12///
13/// **Domain guard:** `u = offset + x` must be strictly positive so that `ln(u)`
14/// and `u^p` (with `p = −1/shape`) are finite. If `u ≤ 0` the function returns
15/// `f64::NAN` (the LM solver will reject the step and backtrack).
16///
17/// **Note:** `amplitude` is the overall scale, NOT a peak-at-center value as in
18/// the spectral-peak convention documented in `docs/reference/models/index.md`. For Bennett5 the
19/// certified b1 is large and negative.
20///
21/// Analytic Jacobian (let `u = offset + x`, `p = −1/shape`):
22/// - ∂y/∂amplitude = u^p
23/// - ∂y/∂offset   = amplitude · p · u^(p−1)
24/// - ∂y/∂shape    = amplitude · u^p · ln(u) · (1/shape²)
25pub struct PowerLawOffset;
26
27impl Model for PowerLawOffset {
28    fn eval(&self, x: &[f64], p: &[f64]) -> f64 {
29        let amplitude = p[0];
30        let offset = p[1];
31        let shape = p[2];
32        let u = offset + x[0];
33        if u <= 0.0 {
34            return f64::NAN;
35        }
36        let exponent = -1.0 / shape;
37        amplitude * u.powf(exponent)
38    }
39
40    fn jacobian_into(&self, x: &[f64], p: &[f64], out: &mut [f64]) {
41        let amplitude = p[0];
42        let offset = p[1];
43        let shape = p[2];
44        let u = offset + x[0];
45        if u <= 0.0 {
46            out[0] = f64::NAN;
47            out[1] = f64::NAN;
48            out[2] = f64::NAN;
49            return;
50        }
51        let exponent = -1.0 / shape;
52        let u_pow = u.powf(exponent); // u^(−1/shape)
53        let u_pow_m1 = u_pow / u; // u^(−1/shape − 1) = u_pow / u
54
55        out[0] = u_pow; // ∂y/∂amplitude
56        out[1] = amplitude * exponent * u_pow_m1; // ∂y/∂offset
57        out[2] = amplitude * u_pow * u.ln() / (shape * shape); // ∂y/∂shape
58    }
59
60    fn jacobian(&self, x: &[f64], p: &[f64]) -> Vec<f64> {
61        let amplitude = p[0];
62        let offset = p[1];
63        let shape = p[2];
64        let u = offset + x[0];
65        if u <= 0.0 {
66            return vec![f64::NAN, f64::NAN, f64::NAN];
67        }
68        let exponent = -1.0 / shape;
69        let u_pow = u.powf(exponent);
70        let u_pow_m1 = u_pow / u;
71        vec![
72            u_pow,                                        // ∂y/∂amplitude
73            amplitude * exponent * u_pow_m1,              // ∂y/∂offset
74            amplitude * u_pow * u.ln() / (shape * shape), // ∂y/∂shape
75        ]
76    }
77
78    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
79        vec!["amplitude".into(), "offset".into(), "shape".into()]
80    }
81
82    fn eval_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
83        debug_assert_eq!(out.len(), xs.len());
84        let amplitude = params[0];
85        let offset = params[1];
86        let shape = params[2];
87        let exponent = -1.0 / shape;
88        for (slot, &xi) in out.iter_mut().zip(xs.iter()) {
89            let u = offset + xi;
90            *slot = if u <= 0.0 {
91                f64::NAN
92            } else {
93                amplitude * u.powf(exponent)
94            };
95        }
96    }
97
98    fn jac_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
99        debug_assert_eq!(out.len(), xs.len() * 3);
100        let amplitude = params[0];
101        let offset = params[1];
102        let shape = params[2];
103        let exponent = -1.0 / shape;
104        let inv_shape2 = 1.0 / (shape * shape);
105        for (i, &xi) in xs.iter().enumerate() {
106            let u = offset + xi;
107            if u <= 0.0 {
108                out[i * 3] = f64::NAN;
109                out[i * 3 + 1] = f64::NAN;
110                out[i * 3 + 2] = f64::NAN;
111            } else {
112                let u_pow = u.powf(exponent);
113                let u_pow_m1 = u_pow / u;
114                out[i * 3] = u_pow;
115                out[i * 3 + 1] = amplitude * exponent * u_pow_m1;
116                out[i * 3 + 2] = amplitude * u_pow * u.ln() * inv_shape2;
117            }
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use approx::assert_relative_eq;
126
127    fn model() -> PowerLawOffset {
128        PowerLawOffset
129    }
130
131    // Bennett5 certified params: amplitude=-2523.5, offset=46.74, shape=0.9322
132    // At x=7.447168 (first data point):
133    //   u = 46.74 + 7.447 = 54.187, exponent = -1/0.9322 ≈ -1.0727
134    //   y = -2523.5 * 54.187^(-1.0727) ≈ -34.8  (matches NIST first point ~-34.83)
135    #[test]
136    fn eval_first_bennett5_point_approx() {
137        let p = [-2523.5_f64, 46.74, 0.9322];
138        let y = model().eval(&[7.447168], &p);
139        assert!((y - (-34.8)).abs() < 0.3, "Expected ~-34.8, got {y}");
140    }
141
142    // Domain guard: u = offset + x ≤ 0 → NaN.
143    #[test]
144    fn eval_negative_u_returns_nan() {
145        let p = [1.0_f64, 1.0, 0.5];
146        let y = model().eval(&[-5.0], &p); // u = 1 - 5 = -4
147        assert!(y.is_nan(), "Expected NaN for u≤0, got {y}");
148    }
149
150    #[test]
151    fn jacobian_shape() {
152        let j = model().jacobian(&[5.0], &[1.0, 2.0, 0.5]);
153        assert_eq!(j.len(), 3);
154        assert!(
155            j.iter().all(|v| v.is_finite()),
156            "Jacobian must be finite: {j:?}"
157        );
158    }
159
160    // Central-difference (O(h^2)) sweep over three parameter regimes and
161    // several x-points, replacing the old single-point forward differences
162    // (h=1e-4..1e-7, max_relative=1e-4). Each test keeps its name
163    // (traceability) but now checks only its own parameter index across the
164    // full regime/x-point grid. Regimes and x-points keep u = offset + x
165    // strictly positive (the domain-guard boundary).
166    const POWER_LAW_OFFSET_REGIMES: [[f64; 3]; 3] = [
167        [-2500.0, 46.7, 0.93], // nominal (near-certified Bennett5 fit)
168        [1e-3, 1.0, 0.5],      // small amplitude, unit offset
169        [50.0, 5.0, -2.0],     // positive amplitude, negative shape
170    ];
171    const POWER_LAW_OFFSET_XS: [f64; 5] = [1.0, 3.0, 8.0, 15.0, 40.0];
172
173    fn check_power_law_offset_param_central_diff(idx: usize) {
174        for p in POWER_LAW_OFFSET_REGIMES {
175            for &x in &POWER_LAW_OFFSET_XS {
176                let j = model().jacobian(&[x], &p);
177                let h = 1e-6 * p[idx].abs().max(1.0);
178                let mut pp = p;
179                pp[idx] += h;
180                let mut pm = p;
181                pm[idx] -= h;
182                let fd = (model().eval(&[x], &pp) - model().eval(&[x], &pm)) / (2.0 * h);
183                assert_relative_eq!(j[idx], fd, epsilon = 1e-7, max_relative = 1e-6);
184            }
185        }
186    }
187
188    // FD check for ∂y/∂amplitude
189    #[test]
190    fn jacobian_fd_amplitude() {
191        check_power_law_offset_param_central_diff(0);
192    }
193
194    // FD check for ∂y/∂offset
195    #[test]
196    fn jacobian_fd_offset() {
197        check_power_law_offset_param_central_diff(1);
198    }
199
200    // FD check for ∂y/∂shape
201    #[test]
202    fn jacobian_fd_shape() {
203        check_power_law_offset_param_central_diff(2);
204    }
205
206    #[test]
207    fn jacobian_into_matches_jacobian() {
208        let x = &[10.0_f64];
209        let p = [-2500.0_f64, 46.7, 0.93];
210        let j_vec = model().jacobian(x, &p);
211        let mut out = [0.0_f64; 3];
212        model().jacobian_into(x, &p, &mut out);
213        assert_relative_eq!(out[0], j_vec[0], epsilon = 1e-12);
214        assert_relative_eq!(out[1], j_vec[1], epsilon = 1e-12);
215        assert_relative_eq!(out[2], j_vec[2], epsilon = 1e-12);
216    }
217
218    #[test]
219    fn eval_slice_matches_scalar() {
220        let xs: Vec<f64> = (0..10).map(|i| 7.0 + i as f64 * 0.5).collect();
221        let p = [-2500.0_f64, 46.7, 0.93];
222        let mut out = vec![0.0_f64; xs.len()];
223        model().eval_slice_into(&xs, &p, &mut out);
224        for (&xi, &bi) in xs.iter().zip(out.iter()) {
225            assert_relative_eq!(bi, model().eval(&[xi], &p), epsilon = 1e-10);
226        }
227    }
228
229    #[test]
230    fn jac_slice_matches_scalar() {
231        let xs: Vec<f64> = (0..8).map(|i| 8.0 + i as f64 * 0.4).collect();
232        let p = [-2500.0_f64, 46.7, 0.93];
233        let n = xs.len();
234        let mut out = vec![0.0_f64; n * 3];
235        model().jac_slice_into(&xs, &p, &mut out);
236        for (i, xi) in xs.iter().enumerate() {
237            let j = model().jacobian(&[*xi], &p);
238            for k in 0..3 {
239                assert_relative_eq!(out[i * 3 + k], j[k], epsilon = 1e-10);
240            }
241        }
242    }
243}