Skip to main content

spectrafit_models/
doniach.rs

1use crate::Model;
2
3/// Doniach–Šunjić asymmetric lineshape (XPS core-level peaks).
4///
5/// `A · cos[πγ/2 + (1−γ)·atan((x−c)/σ)] / (1 + ((x−c)/σ)²)^((1−γ)/2)`
6///
7/// Parameters (in order): `[amplitude, center, sigma, gamma]`
8///
9/// - `gamma` is the asymmetry index (`0` ⇒ symmetric Lorentzian-like; larger ⇒
10///   stronger high-binding-energy tail). `amplitude` scales the curve.
11///
12/// The area-normalising `1/σ^(1−γ)` prefactor of the textbook form is folded into
13/// `amplitude` so the parameter set matches the other height-amplitude kernels;
14/// the numpy benchmark formula is identical, so numpy↔Rust parity is exact.
15pub struct DoniachSunjic;
16
17impl Model for DoniachSunjic {
18    fn eval(&self, x: &[f64], params: &[f64]) -> f64 {
19        let (a, c, sigma, gamma) = (params[0], params[1], params[2], params[3]);
20        let u = (x[0] - c) / sigma;
21        let num = (std::f64::consts::FRAC_PI_2 * gamma + (1.0 - gamma) * u.atan()).cos();
22        let den = (1.0 + u * u).powf((1.0 - gamma) / 2.0);
23        a * num / den
24    }
25
26    /// Analytical Jacobian of the Doniach–Šunjić lineshape.
27    ///
28    /// Let `u = (x−c)/σ`, `φ = πγ/2 + (1−γ)·atan(u)`,
29    /// `D = (1+u²)^((1−γ)/2)`, so `f = A·cos(φ)/D`.
30    ///
31    /// ∂f/∂A = cos(φ)/D
32    ///
33    /// Shared factor for center/sigma (via ∂f/∂u):
34    ///   ∂f/∂u = A·(1−γ)·[−sin(φ)−cos(φ)·u] / ((1+u²)·D)
35    ///
36    ///   ∂u/∂c = −1/σ  ⟹  ∂f/∂c = ∂f/∂u · (−1/σ)
37    ///   ∂u/∂σ = −u/σ  ⟹  ∂f/∂σ = ∂f/∂u · (−u/σ)
38    ///
39    /// For gamma: `∂φ/∂γ = π/2 − atan(u)`, `∂D/∂γ = −½·ln(1+u²)·D`
40    ///   ∂f/∂γ = A·[−sin(φ)·(π/2−atan(u)) + ½·cos(φ)·ln(1+u²)] / D
41    fn jacobian(&self, x: &[f64], params: &[f64]) -> Vec<f64> {
42        let (a, c, sigma, gamma) = (params[0], params[1], params[2], params[3]);
43        let u = (x[0] - c) / sigma;
44        let u2_1 = 1.0 + u * u;
45        let atan_u = u.atan();
46        let phi = std::f64::consts::FRAC_PI_2 * gamma + (1.0 - gamma) * atan_u;
47        let d = u2_1.powf((1.0 - gamma) / 2.0);
48        let cos_phi = phi.cos();
49        let sin_phi = phi.sin();
50
51        let da = cos_phi / d;
52
53        // ∂f/∂u = A·(1−γ)·[−sin(φ)−cos(φ)·u] / (u2_1·D)
54        // ∂f/∂c = ∂f/∂u · (−1/σ)
55        let df_du_coeff = a * (1.0 - gamma) / (u2_1 * d);
56        let du_core = -sin_phi - cos_phi * u;
57        let dc = df_du_coeff * du_core * (-1.0 / sigma);
58        let ds = df_du_coeff * du_core * (-u / sigma);
59
60        // ∂f/∂γ
61        let dg =
62            a / d * (-sin_phi * (std::f64::consts::FRAC_PI_2 - atan_u) + 0.5 * cos_phi * u2_1.ln());
63
64        vec![da, dc, ds, dg]
65    }
66
67    #[inline]
68    fn jacobian_into(&self, x: &[f64], params: &[f64], out: &mut [f64]) {
69        let jac = self.jacobian(x, params);
70        out[..4].copy_from_slice(&jac);
71    }
72
73    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>> {
74        vec![
75            "amplitude".into(),
76            "center".into(),
77            "sigma".into(),
78            "gamma".into(),
79        ]
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use approx::assert_relative_eq;
87
88    #[test]
89    fn symmetric_at_gamma_zero_is_lorentzian_at_center() {
90        // γ=0 ⇒ cos[(1)·atan(0)] / (1)^(1/2) = 1 at center, scaled by A.
91        let m = DoniachSunjic;
92        assert_relative_eq!(m.eval(&[0.0], &[2.0, 0.0, 1.0, 0.0]), 2.0, epsilon = 1e-12);
93    }
94
95    #[test]
96    fn asymmetry_breaks_mirror_symmetry() {
97        // With γ>0 the lineshape is asymmetric: f(c+d) != f(c-d).
98        let m = DoniachSunjic;
99        let left = m.eval(&[-1.0], &[1.0, 0.0, 1.0, 0.2]);
100        let right = m.eval(&[1.0], &[1.0, 0.0, 1.0, 0.2]);
101        assert!((left - right).abs() > 1e-3);
102    }
103
104    #[test]
105    fn param_names_are_canonical() {
106        assert_eq!(
107            DoniachSunjic
108                .param_names()
109                .iter()
110                .map(|c| c.as_ref())
111                .collect::<Vec<_>>(),
112            &["amplitude", "center", "sigma", "gamma"]
113        );
114    }
115
116    // ----- Limiting-case asymptotic (ground-truth verification) -----
117    //
118    // At γ = 0 the Doniach-Šunjić lineshape reduces *exactly* to a Lorentzian.
119    // Identity derivation: with γ = 0,
120    //
121    //     num = cos[(1)·atan(u)] = 1/√(1+u²),  den = (1+u²)^(1/2)
122    //     ⇒ A·num/den = A · (1/√(1+u²)) / √(1+u²) = A / (1+u²)
123    //
124    // which is the Lorentzian. Promote the existing one-point γ=0 check to a
125    // full multi-point identity against the Lorentzian kernel — catches any
126    // future formula tweak that breaks the no-asymmetry symmetric reduction.
127
128    #[test]
129    fn jacobian_matches_central_difference_across_regimes() {
130        // Central difference is O(h^2); the old forward-difference pattern
131        // (see fano.rs::jacobian_finite_diff_check) was O(h), i.e. its own
132        // truncation error was the size of the 1e-4 tolerance it asserted
133        // against.
134        let m = DoniachSunjic;
135        let param_sets = [
136            [2.0, 0.0, 1.0, 0.3],   // nominal, moderate asymmetry
137            [1e-3, 0.0, 0.05, 0.1], // small amplitude and width
138            [5.0, -2.0, 3.0, 0.7],  // offset centre, wide, strong asymmetry
139        ];
140        for p in param_sets {
141            for &x in &[-3.0_f64, -0.5, 0.0, p[1], 0.5, 1.0, 7.0] {
142                let j = m.jacobian(&[x], &p);
143                for i in 0..p.len() {
144                    let h = 1e-6 * p[i].abs().max(1.0);
145                    let (mut a, mut b) = (p, p);
146                    a[i] += h;
147                    b[i] -= h;
148                    let fd = (m.eval(&[x], &a) - m.eval(&[x], &b)) / (2.0 * h);
149                    assert_relative_eq!(j[i], fd, epsilon = 1e-7, max_relative = 1e-6);
150                }
151            }
152        }
153    }
154
155    #[test]
156    fn gamma_zero_equals_lorentzian_everywhere() {
157        use crate::lorentzian::Lorentzian;
158        let ds = DoniachSunjic;
159        let lor = Lorentzian;
160        let a = 2.5_f64;
161        let c = 0.4_f64;
162        let sigma = 0.9_f64;
163        let p_ds = [a, c, sigma, 0.0]; // γ = 0
164        let p_lor = [a, c, sigma];
165        for &xi in &[-2.0_f64, -0.5, c, 0.5, 2.0] {
166            assert_relative_eq!(
167                ds.eval(&[xi], &p_ds),
168                lor.eval(&[xi], &p_lor),
169                epsilon = 1e-12
170            );
171        }
172    }
173}