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}