Skip to main content

spectrafit_models/
saturating_exponential.rs

1use crate::math_backend::batch_exp;
2use crate::Model;
3
4/// Saturating exponential: `amplitude · (1 − exp(−rate · x))`
5///
6/// Parameters (in order): `[amplitude, rate]`
7///
8/// This is the BoxBOD model (NIST StRD): it describes processes that grow
9/// toward a saturation level `amplitude` with characteristic rate `rate`.
10/// For `rate > 0` and `x ≥ 0` the function rises monotonically from `0`
11/// to `amplitude`.
12///
13/// Note: here `amplitude` is the asymptotic saturation level — the plateau the
14/// curve approaches as `x → ∞` — NOT a peak-at-center value as in the
15/// peak-model convention documented in `docs/reference/models/index.md`.
16pub struct SaturatingExponential;
17
18impl Model for SaturatingExponential {
19    fn eval(&self, x: &[f64], p: &[f64]) -> f64 {
20        let xi = x[0];
21        p[0] * (1.0 - (-p[1] * xi).exp())
22    }
23
24    fn jacobian_into(&self, x: &[f64], p: &[f64], out: &mut [f64]) {
25        let xi = x[0];
26        let e = (-p[1] * xi).exp();
27        out[0] = 1.0 - e; // ∂y/∂amplitude = 1 − exp(−rate·x)
28        out[1] = p[0] * xi * e; // ∂y/∂rate      = amplitude · x · exp(−rate·x)
29    }
30
31    fn jacobian(&self, x: &[f64], p: &[f64]) -> Vec<f64> {
32        let xi = x[0];
33        let e = (-p[1] * xi).exp();
34        vec![
35            1.0 - e,       // ∂y/∂amplitude
36            p[0] * xi * e, // ∂y/∂rate
37        ]
38    }
39
40    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
41        vec!["amplitude".into(), "rate".into()]
42    }
43
44    fn eval_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
45        debug_assert_eq!(out.len(), xs.len());
46        let (amplitude, rate) = (params[0], params[1]);
47        let args: Vec<f64> = xs.iter().map(|xi| -rate * xi).collect();
48        let mut exps = vec![0.0_f64; xs.len()];
49        batch_exp(&mut exps, &args);
50        for (slot, e) in out.iter_mut().zip(exps.iter()) {
51            *slot = amplitude * (1.0 - e);
52        }
53    }
54
55    fn jac_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
56        debug_assert_eq!(out.len(), xs.len() * 2);
57        let (amplitude, rate) = (params[0], params[1]);
58        let args: Vec<f64> = xs.iter().map(|xi| -rate * xi).collect();
59        let mut exps = vec![0.0_f64; xs.len()];
60        batch_exp(&mut exps, &args);
61        for (i, (xi, e)) in xs.iter().zip(exps.iter()).enumerate() {
62            out[i * 2] = 1.0 - e; // ∂y/∂amplitude
63            out[i * 2 + 1] = amplitude * xi * e; // ∂y/∂rate
64        }
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use approx::assert_relative_eq;
72
73    fn model() -> SaturatingExponential {
74        SaturatingExponential
75    }
76
77    // amplitude=3, rate=0.5, x=0: 3*(1-exp(0)) = 3*(1-1) = 0
78    #[test]
79    fn eval_at_zero() {
80        let v = model().eval(&[0.0], &[3.0, 0.5]);
81        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
82    }
83
84    // amplitude=3, rate=0, x=2: 3*(1-exp(0)) = 0  (rate=0 → no decay)
85    #[test]
86    fn eval_zero_rate() {
87        let v = model().eval(&[2.0], &[3.0, 0.0]);
88        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
89    }
90
91    // At very large x*rate the function → amplitude
92    #[test]
93    fn eval_saturates_to_amplitude() {
94        let amplitude = 5.0;
95        let v = model().eval(&[1000.0], &[amplitude, 1.0]);
96        assert_relative_eq!(v, amplitude, epsilon = 1e-9);
97    }
98
99    #[test]
100    fn jacobian_shape() {
101        let j = model().jacobian(&[1.0], &[3.0, 0.5]);
102        assert_eq!(j.len(), 2);
103    }
104
105    // Central-difference (O(h^2)) sweep over three parameter regimes and
106    // several x-points, replacing the old single-point forward difference
107    // (h=1e-6, epsilon=1e-5). Each test keeps its name (traceability) but
108    // now checks only its own parameter index across the full grid.
109    const SAT_EXP_REGIMES: [[f64; 2]; 3] = [
110        [3.0, 0.5],   // nominal
111        [1e-3, 1e-3], // small amplitude/rate
112        [50.0, 2.0],  // large amplitude, fast rate
113    ];
114    const SAT_EXP_XS: [f64; 5] = [0.1, 0.5, 1.5, 4.0, 10.0];
115
116    fn check_sat_exp_param_central_diff(idx: usize) {
117        for p in SAT_EXP_REGIMES {
118            for &x in &SAT_EXP_XS {
119                let j = model().jacobian(&[x], &p);
120                let h = 1e-6 * p[idx].abs().max(1.0);
121                let mut pp = p;
122                pp[idx] += h;
123                let mut pm = p;
124                pm[idx] -= h;
125                let fd = (model().eval(&[x], &pp) - model().eval(&[x], &pm)) / (2.0 * h);
126                assert_relative_eq!(j[idx], fd, epsilon = 1e-7, max_relative = 1e-6);
127            }
128        }
129    }
130
131    #[test]
132    fn jacobian_numerical_amplitude() {
133        check_sat_exp_param_central_diff(0);
134    }
135
136    #[test]
137    fn jacobian_numerical_rate() {
138        check_sat_exp_param_central_diff(1);
139    }
140
141    #[test]
142    fn jacobian_into_matches_jacobian() {
143        let x = &[2.0f64];
144        let p = [3.0, 0.5];
145        let j_vec = model().jacobian(x, &p);
146        let mut out = [0.0_f64; 2];
147        model().jacobian_into(x, &p, &mut out);
148        assert_relative_eq!(out[0], j_vec[0], epsilon = 1e-12);
149        assert_relative_eq!(out[1], j_vec[1], epsilon = 1e-12);
150    }
151
152    #[test]
153    fn eval_slice_matches_scalar() {
154        let xs: Vec<f64> = (0..10).map(|i| i as f64 * 0.5).collect();
155        let p = [3.0, 0.5];
156        let mut out = vec![0.0_f64; xs.len()];
157        model().eval_slice_into(&xs, &p, &mut out);
158        for (xi, &bi) in xs.iter().zip(out.iter()) {
159            assert_relative_eq!(bi, model().eval(&[*xi], &p), epsilon = 1e-10);
160        }
161    }
162
163    #[test]
164    fn jac_slice_matches_scalar() {
165        let xs: Vec<f64> = (0..8).map(|i| i as f64 * 0.5).collect();
166        let p = [3.0, 0.5];
167        let n = xs.len();
168        let mut out = vec![0.0_f64; n * 2];
169        model().jac_slice_into(&xs, &p, &mut out);
170        for (i, xi) in xs.iter().enumerate() {
171            let j = model().jacobian(&[*xi], &p);
172            for k in 0..2 {
173                assert_relative_eq!(out[i * 2 + k], j[k], epsilon = 1e-10);
174            }
175        }
176    }
177}