Skip to main content

spectrafit_models/
generalised_logistic.rs

1use crate::Model;
2
3/// Generalised logistic (Richards) curve.
4///
5/// ```text
6/// y = amplitude / (1 + exp(shift − rate·x))^(1/shape)
7/// ```
8///
9/// Parameters (in order): `[amplitude, shift, rate, shape]`
10///
11/// One kernel covers two NIST StRD datasets, because the plain logistic is this
12/// form at `shape = 1`:
13/// - Rat43 is the full four-parameter Richards curve.
14/// - Rat42 is `b1/(1 + exp(b2 − b3·x))`: hold `shape` fixed at 1 and the other
15///   three map straight onto NIST's b1..b3.
16///
17/// **Domain guard:** `shape` must be non-zero, and `1 + exp(...)` is always > 1 so
18/// the power is well defined for finite arguments. A zero `shape` returns
19/// `f64::NAN` rather than an infinity, so the solver backs off instead of
20/// poisoning the residual vector.
21///
22/// **Analytic Jacobian** (let `u = exp(shift − rate·x)`, `s = 1 + u`, `q = s^(−1/shape)`):
23/// - ∂y/∂amplitude = q
24/// - ∂y/∂shift     = −amplitude·q·u / (shape·s)
25/// - ∂y/∂rate      =  amplitude·q·u·x / (shape·s)
26/// - ∂y/∂shape     =  amplitude·q·ln(s) / shape²
27pub struct GeneralisedLogistic;
28
29#[inline]
30fn parts(xi: f64, p: &[f64]) -> (f64, f64, f64) {
31    let u = (p[1] - p[2] * xi).exp();
32    let s = 1.0 + u;
33    let q = s.powf(-1.0 / p[3]);
34    (u, s, q)
35}
36
37#[inline]
38fn jac_at(xi: f64, p: &[f64], out: &mut [f64]) {
39    if p[3] == 0.0 {
40        out[..4].fill(f64::NAN);
41        return;
42    }
43    let (u, s, q) = parts(xi, p);
44    let common = p[0] * q * u / (p[3] * s);
45    out[0] = q;
46    out[1] = -common;
47    out[2] = common * xi;
48    out[3] = p[0] * q * s.ln() / (p[3] * p[3]);
49}
50
51impl Model for GeneralisedLogistic {
52    fn eval(&self, x: &[f64], p: &[f64]) -> f64 {
53        if p[3] == 0.0 {
54            return f64::NAN;
55        }
56        parts(x[0], p).2 * p[0]
57    }
58
59    fn jacobian_into(&self, x: &[f64], p: &[f64], out: &mut [f64]) {
60        jac_at(x[0], p, out);
61    }
62
63    fn jacobian(&self, x: &[f64], p: &[f64]) -> Vec<f64> {
64        let mut out = vec![0.0_f64; 4];
65        jac_at(x[0], p, &mut out);
66        out
67    }
68
69    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
70        vec![
71            "amplitude".into(),
72            "shift".into(),
73            "rate".into(),
74            "shape".into(),
75        ]
76    }
77
78    fn eval_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
79        debug_assert_eq!(out.len(), xs.len());
80        for (slot, &xi) in out.iter_mut().zip(xs.iter()) {
81            *slot = if params[3] == 0.0 {
82                f64::NAN
83            } else {
84                params[0] * parts(xi, params).2
85            };
86        }
87    }
88
89    fn jac_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
90        debug_assert_eq!(out.len(), xs.len() * 4);
91        for (i, &xi) in xs.iter().enumerate() {
92            jac_at(xi, params, &mut out[i * 4..i * 4 + 4]);
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use approx::assert_relative_eq;
101
102    fn model() -> GeneralisedLogistic {
103        GeneralisedLogistic
104    }
105
106    // Rat43's certified values.
107    const RAT43: [f64; 4] = [
108        6.9964151270e02,
109        5.2771253025e00,
110        7.5962938329e-01,
111        1.2792483859e00,
112    ];
113
114    // Anchored on the certified fit, not the observations: at these parameters the
115    // model reproduces Rat43's certified residual sum of squares (8.786405e3)
116    // exactly. The endpoints are where that fit sits furthest from the data
117    // (20.30 against an observed 16.08 at x=1), so an observation-anchored test
118    // would be measuring the dataset's scatter rather than the kernel.
119    #[test]
120    fn eval_rat43_reproduces_the_certified_fit() {
121        assert_relative_eq!(
122            model().eval(&[1.0], &RAT43),
123            20.301_882_778_86,
124            max_relative = 1e-10
125        );
126        assert_relative_eq!(
127            model().eval(&[15.0], &RAT43),
128            698.438_251,
129            max_relative = 1e-6
130        );
131    }
132
133    #[test]
134    fn shape_one_is_the_plain_logistic() {
135        // Rat42's form: amplitude / (1 + exp(shift − rate·x)).
136        let p = [72.0_f64, 2.6, 0.067, 1.0];
137        for x in [9.0, 30.0, 80.0] {
138            let want = p[0] / (1.0 + (p[1] - p[2] * x).exp());
139            assert_relative_eq!(model().eval(&[x], &p), want, max_relative = 1e-12);
140        }
141    }
142
143    #[test]
144    fn zero_shape_returns_nan() {
145        let p = [1.0_f64, 0.5, 0.5, 0.0];
146        assert!(model().eval(&[1.0], &p).is_nan());
147        assert!(model().jacobian(&[1.0], &p).iter().all(|v| v.is_nan()));
148    }
149
150    #[test]
151    fn jacobian_matches_finite_difference() {
152        // Already a central difference; widened from one regime/x-point to
153        // three regimes and several x-points (shape kept away from 0, the
154        // domain-guard boundary).
155        let param_sets = [
156            RAT43,                      // certified Rat43 fit (nominal)
157            [1e-3_f64, 0.0, 1e-2, 0.5], // small amplitude/rate, sub-unit shape
158            [5.0_f64, -2.0, 3.0, -1.5], // offset shift, wide, negative shape
159        ];
160        for p in param_sets {
161            for &x in &[0.1_f64, 1.0, 5.0, 7.5, 15.0] {
162                let j = model().jacobian(&[x], &p);
163                for k in 0..4 {
164                    let h = 1e-6 * p[k].abs().max(1.0);
165                    let mut pp = p;
166                    pp[k] += h;
167                    let mut pm = p;
168                    pm[k] -= h;
169                    let fd = (model().eval(&[x], &pp) - model().eval(&[x], &pm)) / (2.0 * h);
170                    assert_relative_eq!(j[k], fd, max_relative = 1e-6, epsilon = 1e-7);
171                }
172            }
173        }
174    }
175
176    #[test]
177    fn slices_match_scalar() {
178        let xs = [1.0_f64, 5.0, 10.0, 15.0];
179        let mut ys = vec![0.0_f64; xs.len()];
180        model().eval_slice_into(&xs, &RAT43, &mut ys);
181        let mut js = vec![0.0_f64; xs.len() * 4];
182        model().jac_slice_into(&xs, &RAT43, &mut js);
183        for (i, &xi) in xs.iter().enumerate() {
184            assert_relative_eq!(ys[i], model().eval(&[xi], &RAT43), epsilon = 1e-12);
185            let j = model().jacobian(&[xi], &RAT43);
186            for k in 0..4 {
187                assert_relative_eq!(js[i * 4 + k], j[k], epsilon = 1e-12);
188            }
189        }
190    }
191}