Skip to main content

spectrafit_models/
power_saturation.rs

1use crate::Model;
2
3/// Power-law saturation: `amplitude · (1 − (1 + rate·x/2)^(−2))`
4///
5/// Parameters (in order): `[amplitude, rate]`
6///
7/// This is the Misra1b NIST StRD model.  It describes a growth process
8/// approaching a saturation level `amplitude` with a power-law rather than
9/// exponential approach.  For `rate > 0` and `x ≥ 0` the function rises
10/// monotonically from `0` to `amplitude`.
11///
12/// Analytic Jacobian:
13///   Let `u = 1 + rate·x/2`.
14///   - ∂y/∂amplitude = 1 − u^(−2)
15///   - ∂y/∂rate      = amplitude · x · u^(−3)
16///
17/// Note: `amplitude` is the asymptotic saturation level, NOT a peak-at-center
18/// value as in the peak-model convention documented in `docs/reference/models/index.md`.
19pub struct PowerSaturation;
20
21impl Model for PowerSaturation {
22    fn eval(&self, x: &[f64], p: &[f64]) -> f64 {
23        let xi = x[0];
24        let u = 1.0 + p[1] * xi / 2.0;
25        p[0] * (1.0 - u.powi(-2))
26    }
27
28    fn jacobian_into(&self, x: &[f64], p: &[f64], out: &mut [f64]) {
29        let xi = x[0];
30        let u = 1.0 + p[1] * xi / 2.0;
31        let u_neg2 = u.powi(-2);
32        let u_neg3 = u.powi(-3);
33        out[0] = 1.0 - u_neg2; // ∂y/∂amplitude = 1 − (1+rate·x/2)^−2
34        out[1] = p[0] * xi * u_neg3; // ∂y/∂rate      = amplitude·x·(1+rate·x/2)^−3
35    }
36
37    fn jacobian(&self, x: &[f64], p: &[f64]) -> Vec<f64> {
38        let xi = x[0];
39        let u = 1.0 + p[1] * xi / 2.0;
40        let u_neg2 = u.powi(-2);
41        let u_neg3 = u.powi(-3);
42        vec![
43            1.0 - u_neg2,       // ∂y/∂amplitude
44            p[0] * xi * u_neg3, // ∂y/∂rate
45        ]
46    }
47
48    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
49        vec!["amplitude".into(), "rate".into()]
50    }
51
52    fn eval_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
53        debug_assert_eq!(out.len(), xs.len());
54        let (amplitude, rate) = (params[0], params[1]);
55        for (slot, &xi) in out.iter_mut().zip(xs.iter()) {
56            let u = 1.0 + rate * xi / 2.0;
57            *slot = amplitude * (1.0 - u.powi(-2));
58        }
59    }
60
61    fn jac_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
62        debug_assert_eq!(out.len(), xs.len() * 2);
63        let (amplitude, rate) = (params[0], params[1]);
64        for (i, &xi) in xs.iter().enumerate() {
65            let u = 1.0 + rate * xi / 2.0;
66            let u_neg2 = u.powi(-2);
67            let u_neg3 = u.powi(-3);
68            out[i * 2] = 1.0 - u_neg2; // ∂y/∂amplitude
69            out[i * 2 + 1] = amplitude * xi * u_neg3; // ∂y/∂rate
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use approx::assert_relative_eq;
78
79    fn model() -> PowerSaturation {
80        PowerSaturation
81    }
82
83    // amplitude=10, rate=0.5, x=0: 10*(1-(1+0)^-2) = 10*(1-1) = 0
84    #[test]
85    fn eval_at_zero() {
86        let v = model().eval(&[0.0], &[10.0, 0.5]);
87        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
88    }
89
90    // amplitude=10, rate=0, x=5: u=1, 10*(1-1^-2) = 0  (rate=0 → no growth)
91    #[test]
92    fn eval_zero_rate() {
93        let v = model().eval(&[5.0], &[10.0, 0.0]);
94        assert_relative_eq!(v, 0.0, epsilon = 1e-12);
95    }
96
97    // At very large x*rate the function → amplitude (u → ∞, u^-2 → 0)
98    #[test]
99    fn eval_saturates_to_amplitude() {
100        let amplitude = 5.0;
101        let v = model().eval(&[1_000_000.0], &[amplitude, 1.0]);
102        assert_relative_eq!(v, amplitude, epsilon = 1e-6);
103    }
104
105    #[test]
106    fn jacobian_shape() {
107        let j = model().jacobian(&[2.0], &[10.0, 0.5]);
108        assert_eq!(j.len(), 2);
109    }
110
111    // Central-difference (O(h^2)) sweep over three parameter regimes and
112    // several x-points, replacing the old single-point forward differences
113    // (h=1e-6/1e-8, epsilon=1e-5/1e-4). Each test keeps its name
114    // (traceability) but now checks only its own parameter index across the
115    // full regime/x-point grid.
116    const POWER_SAT_REGIMES: [[f64; 2]; 3] = [
117        [10.0, 0.001], // nominal (slow rate, mirrors Misra1b scale)
118        [1e-3, 1e-4],  // small amplitude/rate
119        [50.0, 2.0],   // large amplitude, fast rate
120    ];
121    const POWER_SAT_XS: [f64; 5] = [0.5, 3.0, 20.0, 100.0, 500.0];
122
123    fn check_power_sat_param_central_diff(idx: usize) {
124        for p in POWER_SAT_REGIMES {
125            for &x in &POWER_SAT_XS {
126                let j = model().jacobian(&[x], &p);
127                let h = 1e-6 * p[idx].abs().max(1.0);
128                let mut pp = p;
129                pp[idx] += h;
130                let mut pm = p;
131                pm[idx] -= h;
132                let fd = (model().eval(&[x], &pp) - model().eval(&[x], &pm)) / (2.0 * h);
133                assert_relative_eq!(j[idx], fd, epsilon = 1e-7, max_relative = 1e-6);
134            }
135        }
136    }
137
138    // FD check for ∂y/∂amplitude
139    #[test]
140    fn jacobian_numerical_amplitude() {
141        check_power_sat_param_central_diff(0);
142    }
143
144    // FD check for ∂y/∂rate
145    #[test]
146    fn jacobian_numerical_rate() {
147        check_power_sat_param_central_diff(1);
148    }
149
150    #[test]
151    fn jacobian_into_matches_jacobian() {
152        let x = &[5.0f64];
153        let p = [300.0, 0.0004];
154        let j_vec = model().jacobian(x, &p);
155        let mut out = [0.0_f64; 2];
156        model().jacobian_into(x, &p, &mut out);
157        assert_relative_eq!(out[0], j_vec[0], epsilon = 1e-12);
158        assert_relative_eq!(out[1], j_vec[1], epsilon = 1e-12);
159    }
160
161    #[test]
162    fn eval_slice_matches_scalar() {
163        let xs: Vec<f64> = (0..10).map(|i| i as f64 * 100.0).collect();
164        let p = [300.0, 0.0004];
165        let mut out = vec![0.0_f64; xs.len()];
166        model().eval_slice_into(&xs, &p, &mut out);
167        for (xi, &bi) in xs.iter().zip(out.iter()) {
168            assert_relative_eq!(bi, model().eval(&[*xi], &p), epsilon = 1e-10);
169        }
170    }
171
172    #[test]
173    fn jac_slice_matches_scalar() {
174        let xs: Vec<f64> = (0..8).map(|i| i as f64 * 100.0).collect();
175        let p = [300.0, 0.0004];
176        let n = xs.len();
177        let mut out = vec![0.0_f64; n * 2];
178        model().jac_slice_into(&xs, &p, &mut out);
179        for (i, xi) in xs.iter().enumerate() {
180            let j = model().jacobian(&[*xi], &p);
181            for k in 0..2 {
182                assert_relative_eq!(out[i * 2 + k], j[k], epsilon = 1e-10);
183            }
184        }
185    }
186
187    // Known value check: x=200, amplitude=337.997, rate=0.000390
188    // u = 1 + 0.000390*200/2 = 1 + 0.039 = 1.039
189    // y = 337.997 * (1 - 1.039^-2) = 337.997 * (1 - 0.926637) ≈ 24.79
190    #[test]
191    fn known_value() {
192        let x = &[200.0f64];
193        let p = [337.997_463_63, 3.903_909_128_7e-4];
194        let y = model().eval(x, &p);
195        // Approximate: u≈1.0390391, u^-2≈0.9267..., 1-u^-2≈0.0733...
196        // y ≈ 337.997*0.0733 ≈ 24.78
197        assert!(y > 20.0 && y < 30.0, "Known-value sanity: y={y}");
198    }
199}