Skip to main content

spectrafit_models/
cauchy_dispersion.rs

1use crate::Model;
2
3/// Cauchy refractive-index dispersion: `n(x) = a + b/x² + c/x⁴`.
4///
5/// Parameters (in order): `[a, b, c]`
6///
7/// - `a` is the high-frequency (constant) refractive-index offset.
8/// - `b` is the first dispersion coefficient (units of x²).
9/// - `c` is the second dispersion coefficient (units of x⁴).
10///
11/// The independent variable `x` is a wavelength and must be `> 0`; the kernel is smooth
12/// and analytic there. At `x == 0` the `1/x²` / `1/x⁴` terms are undefined, so the kernel
13/// returns `0.0` (matching the numpy oracle `np.where(x > 0, a + b/x² + c/x⁴, 0)`), keeping
14/// numpy↔Rust parity exact. The analytical Jacobian is `[1, 1/x², 1/x⁴]`.
15pub struct CauchyDispersion;
16
17impl Model for CauchyDispersion {
18    fn eval(&self, x: &[f64], params: &[f64]) -> f64 {
19        let (a, b, c) = (params[0], params[1], params[2]);
20        if x[0] > 0.0 {
21            let x2 = x[0] * x[0];
22            let x4 = x2 * x2;
23            a + b / x2 + c / x4
24        } else {
25            0.0
26        }
27    }
28
29    /// Analytical Jacobian: `∂n/∂a = 1`, `∂n/∂b = 1/x²`, `∂n/∂c = 1/x⁴` (for `x > 0`);
30    /// all zero at `x ≤ 0` where the kernel is clamped to `0`.
31    fn jacobian(&self, x: &[f64], _params: &[f64]) -> Vec<f64> {
32        if x[0] > 0.0 {
33            let x2 = x[0] * x[0];
34            let x4 = x2 * x2;
35            vec![1.0, 1.0 / x2, 1.0 / x4]
36        } else {
37            vec![0.0, 0.0, 0.0]
38        }
39    }
40
41    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
42        vec!["a".into(), "b".into(), "c".into()]
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use approx::assert_relative_eq;
50
51    #[test]
52    fn dispersion_value() {
53        // a + b/x² + c/x⁴ at x = 2: 1.5 + 0.4/4 + 0.2/16 = 1.5 + 0.1 + 0.0125.
54        let m = CauchyDispersion;
55        assert_relative_eq!(
56            m.eval(&[2.0], &[1.5, 0.4, 0.2]),
57            1.5 + 0.1 + 0.0125,
58            epsilon = 1e-12
59        );
60    }
61
62    #[test]
63    fn non_positive_x_is_zero() {
64        let m = CauchyDispersion;
65        assert_eq!(m.eval(&[0.0], &[1.5, 0.4, 0.2]), 0.0);
66        assert_eq!(m.eval(&[-1.0], &[1.5, 0.4, 0.2]), 0.0);
67    }
68
69    #[test]
70    fn analytical_jacobian_matches_closed_form() {
71        let m = CauchyDispersion;
72        let j = m.jacobian(&[2.0], &[1.5, 0.4, 0.2]);
73        assert_eq!(j.len(), 3);
74        assert_relative_eq!(j[0], 1.0, epsilon = 1e-12);
75        assert_relative_eq!(j[1], 1.0 / 4.0, epsilon = 1e-12);
76        assert_relative_eq!(j[2], 1.0 / 16.0, epsilon = 1e-12);
77    }
78
79    #[test]
80    fn param_names_are_canonical() {
81        assert_eq!(
82            CauchyDispersion
83                .param_names()
84                .iter()
85                .map(|c| c.as_ref())
86                .collect::<Vec<_>>(),
87            &["a", "b", "c"]
88        );
89    }
90
91    #[test]
92    fn jacobian_matches_central_difference_across_regimes() {
93        // Central difference is O(h^2); the old forward-difference pattern
94        // (see fano.rs::jacobian_finite_diff_check) was O(h), i.e. its own
95        // truncation error was the size of the 1e-4 tolerance it asserted
96        // against.
97        let m = CauchyDispersion;
98        let param_sets = [
99            [1.5, 0.4, 0.2],    // nominal
100            [1e-3, 1e-3, 1e-3], // small coefficients
101            [5.0, -2.0, 8.0],   // large / negative coefficients
102        ];
103        for p in param_sets {
104            // x <= 0 clamps eval (and jacobian) to 0.0 regardless of params, so
105            // the central difference agrees trivially there (0 == 0), proving
106            // the clamp itself doesn't leak a spurious derivative. x = 0.3 is
107            // the smallest positive point kept: below that, c/x^4 for the
108            // large-coefficient regime dwarfs the ~1e-6-scaled perturbation of
109            // `a`, and the resulting catastrophic cancellation in the finite
110            // difference (not the analytic Jacobian, which is exactly
111            // param-independent here) pushes its own error above 1e-7.
112            for &x in &[0.3_f64, 0.5, 1.0, 2.0, 7.0, 0.0, -1.0] {
113                let j = m.jacobian(&[x], &p);
114                for i in 0..p.len() {
115                    let h = 1e-6 * p[i].abs().max(1.0);
116                    let (mut a, mut b) = (p, p);
117                    a[i] += h;
118                    b[i] -= h;
119                    let fd = (m.eval(&[x], &a) - m.eval(&[x], &b)) / (2.0 * h);
120                    assert_relative_eq!(j[i], fd, epsilon = 1e-7, max_relative = 1e-6);
121                }
122            }
123        }
124    }
125}