Skip to main content

spectrafit_models/
lib.rs

1//! spectrafit-models — analytical model kernels with exact Jacobians.
2#![warn(missing_docs)]
3
4pub(crate) mod erf_ext;
5pub(crate) mod math_backend;
6
7/// Asymmetric IR band (Gaussian × logistic sigmoid).
8pub mod asym_ir;
9/// Breit-Wigner-Fano resonance.
10pub mod breit_wigner;
11/// Cauchy refractive-index dispersion (`a + b/x² + c/x⁴`).
12pub mod cauchy_dispersion;
13/// Doniach–Šunjić asymmetric core-level lineshape (XPS).
14pub mod doniach;
15/// Exponentially-modified Gaussian (asymmetric tailing peak).
16pub mod emg;
17/// Exponential over a line (NIST StRD Chwirut1, Chwirut2):
18/// `exp(−rate·x) / (den_const + den_lin·x)`.
19pub mod exp_over_linear;
20/// Exponential decay and double-exponential models.
21pub mod exponential;
22/// Fano resonance lineshape model.
23pub mod fano;
24/// Gaussian peak model with exact Jacobian.
25pub mod gaussian;
26/// Axis-aligned 2-D Gaussian peak model (n_dims == 2).
27pub mod gaussian2d;
28/// Axis-aligned N-D Gaussian peak model (parametric dimensionality).
29pub mod gaussian_nd;
30/// Generalised logistic / Richards curve (NIST StRD Rat42, Rat43):
31/// `amplitude / (1 + exp(shift − rate·x))^(1/shape)`.
32pub mod generalised_logistic;
33/// Driven damped harmonic-oscillator IR absorption.
34pub mod harmonic_ir;
35/// Kohlrausch–Williams–Watts stretched exponential (`A·exp(−(x/τ)^β)`).
36pub mod kww;
37/// Log-normal peak (`A·exp(−(ln(x/c))²/(2σ²))` for x > 0).
38pub mod log_normal;
39/// Lorentzian (Cauchy) peak model with exact Jacobian.
40pub mod lorentzian;
41/// Kowalik–Osborne rational function (NIST StRD MGH09):
42/// `amplitude · (x² + num_lin·x) / (x² + den_lin·x + den_const)`.
43pub mod mgh09_rational;
44/// Moffat peak (`A / (((x−c)/σ)²+1)^β`).
45pub mod moffat;
46/// Pearson VII peak (`A / [1 + ((x−c)/σ)²·(2^{1/m}−1)]^m`).
47pub mod pearson7;
48/// Constant and linear polynomial baseline models.
49pub mod polynomial;
50/// Power-law with offset: `amplitude · (offset + x)^(−1/shape)` (Bennett5 model).
51pub mod power_law_offset;
52/// Power-law saturation: `amplitude · (1 − (1 + rate·x/2)^(−2))` (Misra1b model).
53pub mod power_saturation;
54/// Pseudo-Voigt (Gaussian/Lorentzian mixture) model.
55pub mod pseudo_voigt;
56/// Rational cubic over cubic, denominator constant pinned at 1 (NIST StRD
57/// Kirby2, Hahn1, Thurber): `(a0+a1x+a2x²+a3x³) / (1+b1x+b2x²+b3x³)`.
58pub mod rational_cubic;
59/// Saturating exponential: `amplitude · (1 − exp(−rate · x))` (BoxBOD model).
60pub mod saturating_exponential;
61/// Skewed Gaussian (error-function-modulated asymmetry).
62pub mod skewed_gaussian;
63/// Split (asymmetric) Gaussian — different width each side (a.k.a. bi-Gaussian).
64pub mod split_gaussian;
65/// Split Pearson VII (split width + exponent each side).
66pub mod split_pearson7;
67/// Step-function models: arctan, tanh, and erfc variants.
68pub mod step;
69/// Student's-t peak (`A / (1+((x−c)/σ)²/ν)^((ν+1)/2)`).
70pub mod students_t;
71/// Tauc optical band-gap edge (`A·((x−e_gap)·H(x−e_gap))^p`).
72pub mod tauc;
73/// Pseudo-Voigt linear-mixture model (the `voigt`/`pseudo_voigt` key).
74pub mod voigt;
75/// True Voigt profile via the Faddeeva function (Gaussian ⊗ Lorentzian).
76pub mod voigt_true;
77
78/// Core model trait implemented by every built-in kernel.
79///
80/// Convention: `x` is a coordinate slice.
81/// - 1-D models expect `x.len() == 1` (i.e. `x[0]` is the scalar coordinate).
82/// - n-D models declare `n_dims() > 1`.
83pub trait Model: Send + Sync {
84    /// Evaluate the model at coordinate `x` with the given `params`.
85    ///
86    /// # Preconditions
87    ///
88    /// Implementations index `params` and `x` by raw position and assume the
89    /// caller has already validated arity (this keeps the per-point evaluation
90    /// branch-free on the hot path). The caller MUST guarantee:
91    /// - `params.len() == self.param_names().len()`
92    /// - `x.len() >= self.n_dims()`
93    ///
94    /// # Panics
95    ///
96    /// Panics (index-out-of-bounds) if `params.len() < self.param_names().len()`
97    /// or `x.len() < self.n_dims()`. Because the pyo3 binding crate calls these
98    /// kernels on the hot path, a panic here would unwind across the FFI
99    /// boundary — validate param/coordinate arity at graph-compile time so the
100    /// kernel is only ever invoked well-formed.
101    fn eval(&self, x: &[f64], params: &[f64]) -> f64;
102
103    /// Jacobian — one derivative per parameter, in the same order as
104    /// `param_names()`.
105    ///
106    /// The default implementation uses forward-difference finite differences
107    /// with step `h = 1e-7 * |p[i]|.max(1e-7)` (relative + absolute floor).
108    /// Override with an analytical formula when possible for best performance.
109    ///
110    /// # Preconditions / Panics
111    ///
112    /// Same arity contract as [`eval`](Model::eval): the caller MUST ensure
113    /// `params.len() == self.param_names().len()` and `x.len() >= self.n_dims()`.
114    /// A short slice panics with an index-out-of-bounds rather than returning an
115    /// error; the same FFI-unwind hazard applies, so validate arity upstream.
116    fn jacobian(&self, x: &[f64], params: &[f64]) -> Vec<f64> {
117        let f0 = self.eval(x, params);
118        let mut p = params.to_vec();
119        (0..params.len())
120            .map(|i| {
121                let h = 1e-7_f64 * params[i].abs().max(1e-7);
122                p[i] = params[i] + h;
123                let df = (self.eval(x, &p) - f0) / h;
124                p[i] = params[i];
125                df
126            })
127            .collect()
128    }
129
130    /// Fill a pre-allocated slice with Jacobian values (one entry per parameter).
131    ///
132    /// This is the hot-path companion to [`jacobian`].  The caller provides a
133    /// scratch buffer (`out`) of length ≥ `param_names().len()`; the method
134    /// writes derivatives into `out[0..n_params]` without any heap allocation.
135    ///
136    /// The default falls back to [`jacobian`] and copies the result.  Models
137    /// with analytical Jacobians **should override** this to compute values
138    /// directly into `out`, sharing intermediate calculations across parameters.
139    #[inline]
140    fn jacobian_into(&self, x: &[f64], params: &[f64], out: &mut [f64]) {
141        let jac = self.jacobian(x, params);
142        out[..jac.len()].copy_from_slice(&jac);
143    }
144
145    /// Batch evaluation of a 1-D model: fill `out[i] = eval([xs[i]], params)`.
146    ///
147    /// **Only called when `n_dims() == 1`.**
148    ///
149    /// Default: per-point loop calling `eval()`.  Override to hoist
150    /// loop-invariant constants (e.g. precompute `1/(2σ²)` once) and let LLVM
151    /// eliminate redundant arithmetic across the slice.
152    #[inline]
153    fn eval_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
154        debug_assert_eq!(xs.len(), out.len());
155        for (xi, slot) in xs.iter().zip(out.iter_mut()) {
156            *slot = self.eval(std::slice::from_ref(xi), params);
157        }
158    }
159
160    /// Batch Jacobian for a 1-D model: fill `out` in row-major layout
161    /// `[i * params.len() + j] = d(model)/d(params[j])` at `xs[i]`.
162    ///
163    /// **Only called when `n_dims() == 1`.**
164    ///
165    /// Default: per-point loop calling `jacobian_into()`.  Override to hoist
166    /// invariants (avoids recomputing `σ²` and the `exp()` argument for every
167    /// point call).
168    #[inline]
169    fn jac_slice_into(&self, xs: &[f64], params: &[f64], out: &mut [f64]) {
170        debug_assert_eq!(xs.len() * params.len(), out.len());
171        let np = params.len();
172        for (i, xi) in xs.iter().enumerate() {
173            self.jacobian_into(
174                std::slice::from_ref(xi),
175                params,
176                &mut out[i * np..(i + 1) * np],
177            );
178        }
179    }
180
181    /// Ordered parameter names, matching the layout expected by `eval` and `jacobian`.
182    ///
183    /// Returns an owned `Vec` of `Cow<'static, str>` so that runtime-generated
184    /// models (e.g. `GaussianND{d}` with dynamic `center_0..center_{d-1}`) can
185    /// produce their parameter names without requiring compile-time-static slices.
186    /// For all built-in kernels with static names, every element is
187    /// `Cow::Borrowed(&'static str)` — zero extra heap allocation.
188    fn param_names(&self) -> Vec<std::borrow::Cow<'static, str>>;
189
190    /// Number of coordinate dimensions consumed from `x`.  Defaults to 1.
191    fn n_dims(&self) -> usize {
192        1
193    }
194}
195
196/// Canonical list of every model-type string [`model_from_str`] can construct.
197///
198/// **Derived from the single source of truth** — the
199/// `model_manifest!`-generated `spectrafit_types::ModelTypeStr::ALL`, mapped
200/// through `as_str()`. There is no second hand-maintained list to drift: adding
201/// a model is one manifest row in `spectrafit-types`, and it auto-enrolls here.
202///
203/// The invariant `all_model_types_round_trip_through_model_from_str` (in the
204/// self-consistency test) pins that every entry here is accepted by
205/// [`model_from_str`] and that every `model_from_str` arm appears here — which,
206/// now that this list IS the manifest, proves every `ModelTypeStr` variant's
207/// wire string is constructible. Adding a model is therefore one manifest row
208/// plus one [`model_from_str`] arm — never a third edit to a parallel list here.
209/// Order follows `ModelTypeStr::ALL` declaration order (consumers iterate it as
210/// a set, not by position).
211pub fn all_model_types() -> &'static [&'static str] {
212    use std::sync::LazyLock;
213    static ALL: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
214        spectrafit_types::ModelTypeStr::ALL
215            .iter()
216            .map(|m| m.as_str())
217            .collect()
218    });
219    ALL.as_slice()
220}
221
222/// Dimension-aware model construction.
223///
224/// Identical to [`model_from_str`] for every fixed-dimensionality kernel, but
225/// for the parametric `"gaussian_nd"` it builds a [`gaussian_nd::GaussianND`] of
226/// the dimensionality `n_dims` (the node's explicit field, passed by the
227/// compiler). Returns `None` for an unknown type, OR for `"gaussian_nd"` with
228/// `n_dims == None` (the caller must surface a clear "dimension required" error).
229pub fn model_from_str_with_dims(model_type: &str, n_dims: Option<usize>) -> Option<Box<dyn Model>> {
230    match model_type {
231        "gaussian_nd" => {
232            n_dims.map(|d| Box::new(crate::gaussian_nd::GaussianND::new(d)) as Box<dyn Model>)
233        }
234        other => model_from_str(other),
235    }
236}
237
238/// Dispatch a model type string to a boxed `Model` implementation.
239///
240/// Returns `None` for unknown type strings.
241pub fn model_from_str(model_type: &str) -> Option<Box<dyn Model>> {
242    match model_type {
243        "gaussian" => Some(Box::new(crate::gaussian::Gaussian)),
244        "gaussian2d" => Some(Box::new(crate::gaussian2d::Gaussian2D)),
245        // Default 1-D instance for the string-registry roundtrip; the compiler
246        // builds the real D-dimensional instance via `model_from_str_with_dims`
247        // from the node's explicit `n_dims`.
248        "gaussian_nd" => Some(Box::new(crate::gaussian_nd::GaussianND::new(1))),
249        "lorentzian" => Some(Box::new(crate::lorentzian::Lorentzian)),
250        "voigt" => Some(Box::new(crate::voigt::Voigt)),
251        "constant" => Some(Box::new(crate::polynomial::Constant)),
252        "linear" => Some(Box::new(crate::polynomial::Linear)),
253        "quadratic" => Some(Box::new(crate::polynomial::Quadratic)),
254        "arctan_step" => Some(Box::new(crate::step::ArctanStep)),
255        "tanh_step" => Some(Box::new(crate::step::TanhStep)),
256        "erfc_step" => Some(Box::new(crate::step::ErfcStep)),
257        "pseudo_voigt" => Some(Box::new(crate::pseudo_voigt::PseudoVoigt)),
258        "fano" => Some(Box::new(crate::fano::Fano)),
259        "double_exponential" => Some(Box::new(crate::exponential::DoubleExponential)),
260        "true_voigt" => Some(Box::new(crate::voigt_true::TrueVoigt)),
261        "skewed_gaussian" => Some(Box::new(crate::skewed_gaussian::SkewedGaussian)),
262        "exp_gaussian" => Some(Box::new(crate::emg::ExpGaussian)),
263        "doniach_sunjic" => Some(Box::new(crate::doniach::DoniachSunjic)),
264        "log_normal" => Some(Box::new(crate::log_normal::LogNormal)),
265        "pearson7" => Some(Box::new(crate::pearson7::Pearson7)),
266        "split_gaussian" => Some(Box::new(crate::split_gaussian::SplitGaussian)),
267        "moffat" => Some(Box::new(crate::moffat::Moffat)),
268        "students_t" => Some(Box::new(crate::students_t::StudentsT)),
269        "split_pearson7" => Some(Box::new(crate::split_pearson7::SplitPearson7)),
270        "breit_wigner" => Some(Box::new(crate::breit_wigner::BreitWigner)),
271        "asym_ir" => Some(Box::new(crate::asym_ir::AsymIr)),
272        "harmonic_ir" => Some(Box::new(crate::harmonic_ir::HarmonicIr)),
273        "tauc" => Some(Box::new(crate::tauc::Tauc)),
274        "cauchy_dispersion" => Some(Box::new(crate::cauchy_dispersion::CauchyDispersion)),
275        "kww" => Some(Box::new(crate::kww::Kww)),
276        "saturating_exponential" => Some(Box::new(
277            crate::saturating_exponential::SaturatingExponential,
278        )),
279        "power_saturation" => Some(Box::new(crate::power_saturation::PowerSaturation)),
280        "power_law_offset" => Some(Box::new(crate::power_law_offset::PowerLawOffset)),
281        "mgh09_rational" => Some(Box::new(crate::mgh09_rational::Mgh09Rational)),
282        "rational_cubic" => Some(Box::new(crate::rational_cubic::RationalCubic)),
283        "generalised_logistic" => Some(Box::new(crate::generalised_logistic::GeneralisedLogistic)),
284        "exp_over_linear" => Some(Box::new(crate::exp_over_linear::ExpOverLinear)),
285        _ => None,
286    }
287}